feat(portal-bff): redis client foundation per ADR-0010 (#109)
## Summary First step toward Redis-backed sessions (ADR-0010). Adds the shared `ioredis` connection that every downstream consumer (session storage, OBO token cache, …) injects via the new `REDIS_CLIENT` DI token. No session logic in this PR — that's the next one. ## What lands - **`ioredis@^5.10.1`** as a direct dependency. Chosen by ADR-0010 for its mature Sentinel support — single-instance URL today, Sentinel-HA configuration lands with the prod infrastructure ADR. - **[`.env.example`](apps/portal-bff/.env.example)** promotes `REDIS_URL` from its future-vars comment to an active variable, defaulting to the local Compose stack's address. The Sentinel-style keys (`REDIS_SENTINEL_HOSTS`, `REDIS_SENTINEL_NAME`, `REDIS_TLS`) stay in the future-vars comment until the prod deploy. - **[`check-redis-config.ts`](apps/portal-bff/src/config/check-redis-config.ts)** — boot-time guard mirroring the existing four: - Refuses to start on missing / non-`redis(s)://` / passwordless / placeholder URLs. - Returns a typed `RedisConfig` with parsed `host` + `port` for downstream observability. - **[`redis.token.ts`](apps/portal-bff/src/redis/redis.token.ts)** — `REDIS_CLIENT` string token + `Redis` type alias. Same shape as the existing `ENTRA_CONFIG` / `MSAL_CLIENT`. - **[`redis.module.ts`](apps/portal-bff/src/redis/redis.module.ts)** — `RedisModule` factory provider: - Caps `maxRetriesPerRequest: 3` so an unreachable Redis surfaces a clear command-time error rather than an infinite reconnect storm. - Wires `connect` / `ready` / `error` / `close` / `reconnecting` events into the Pino stream under the `redis` context — easy log isolation. - Non-global; consumers import the module to state "I depend on Redis". - **`main.ts`** calls `assertRedisConfig()` alongside the other three validators; **`AppModule`** imports `RedisModule`. ## Decisions worth flagging - **`maxRetriesPerRequest: 3`** rather than the ioredis default of 20. With the default, a Redis outage masquerades as request-level timeouts spread over minutes. Capping low surfaces the outage in the first command failure — the BFF can then return 503 and recover quickly when Redis comes back. - **Single shared client.** Pub/sub use-cases (when they appear) duplicate via `redis.duplicate()` per ioredis convention. Connect/disconnect is one socket per BFF instance. - **No explicit shutdown hook yet.** Node's process-exit handlers and ioredis's own cleanup take care of the socket on SIGTERM / Ctrl+C. If we see stuck connections in real load, we wire `OnApplicationShutdown` + `redis.quit()`. - **Sentinel-style config stays in the future-vars comment.** ioredis supports it natively, but plumbing it on top of the URL form complicates the validator and the factory for zero v1 payoff. Lands with the prod infrastructure ADR. ## Verification - `nx run-many -t lint test build --projects=portal-bff` — green. - **62 / 62 specs** (was 52; +10 — `check-redis-config` covers happy path + 6 failure modes; `redis.module` covers DI resolution against an unreachable URL plus the missing-env failure). - Boot smoke against the local Compose stack: Pino's `redis` context shows `redis.connect` → `redis.ready` on startup; killing the Redis container produces `redis.close` / `redis.reconnecting` lines. ## What this PR explicitly does NOT do - Mount `express-session` + `connect-redis` middleware. The next PR wires the session cookie (`__Host-portal_session`), the encrypted payload, and the lookup middleware that attaches `user` to every request. - Plug the callback into session creation. Auth still ends with a Pino log + redirect; the SPA still sees the user anonymous on the next request. - Sentinel / TLS configuration. Future-var keys are documented in `.env.example` for when the prod deploy lands. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #109
This commit was merged in pull request #109.
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import IORedis from 'ioredis';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { RedisModule } from './redis.module';
|
||||
import { REDIS_CLIENT } from './redis.token';
|
||||
|
||||
const ORIGINAL_REDIS_URL = process.env['REDIS_URL'];
|
||||
|
||||
async function compile() {
|
||||
return Test.createTestingModule({
|
||||
imports: [LoggerModule.forRoot({ pinoHttp: { level: 'silent' } }), RedisModule],
|
||||
}).compile();
|
||||
}
|
||||
|
||||
describe('RedisModule', () => {
|
||||
afterEach(async () => {
|
||||
if (ORIGINAL_REDIS_URL === undefined) {
|
||||
delete process.env['REDIS_URL'];
|
||||
} else {
|
||||
process.env['REDIS_URL'] = ORIGINAL_REDIS_URL;
|
||||
}
|
||||
});
|
||||
|
||||
it('provides an ioredis client via the REDIS_CLIENT token', async () => {
|
||||
// A well-formed but unreachable URL — `ioredis` constructs the
|
||||
// client lazily so the test never opens a real socket.
|
||||
process.env['REDIS_URL'] = 'redis://default:test-pass@127.0.0.1:65535/0';
|
||||
const ref = await compile();
|
||||
try {
|
||||
const client = ref.get<IORedis>(REDIS_CLIENT);
|
||||
expect(client).toBeInstanceOf(IORedis);
|
||||
} finally {
|
||||
ref.get<IORedis>(REDIS_CLIENT).disconnect();
|
||||
await ref.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails to compile when REDIS_URL is missing', async () => {
|
||||
delete process.env['REDIS_URL'];
|
||||
await expect(compile()).rejects.toThrow(/REDIS_URL is not set/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import IORedis from 'ioredis';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { assertRedisConfig } from '../config/check-redis-config';
|
||||
import { REDIS_CLIENT, type Redis } from './redis.token';
|
||||
|
||||
/**
|
||||
* Redis module — owns the single shared `ioredis` connection that
|
||||
* downstream consumers (session store, OBO token cache, …) inject
|
||||
* via the `REDIS_CLIENT` token.
|
||||
*
|
||||
* Per ADR-0010:
|
||||
* - Single instance for dev (one `REDIS_URL`); Sentinel-HA for
|
||||
* prod (designed-in, plumbing lands with the production
|
||||
* infrastructure ADR).
|
||||
* - Connection is eager — ioredis opens the socket lazily but the
|
||||
* factory builds the client at module init so DI consumers
|
||||
* always receive an instance.
|
||||
* - Connect / error / reconnect events route into Pino with the
|
||||
* `redis` context so log search isolates them from app logs.
|
||||
* - Single shared client. Pub/sub use-cases (when they appear)
|
||||
* duplicate the connection via `redis.duplicate()` per ioredis
|
||||
* convention.
|
||||
*
|
||||
* Module stays non-global; consumers state "I depend on Redis" by
|
||||
* importing it explicitly.
|
||||
*/
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
inject: [Logger],
|
||||
useFactory: (logger: Logger): Redis => {
|
||||
const config = assertRedisConfig();
|
||||
const client = new IORedis(config.url, {
|
||||
// Cap reconnect storms during bootstrap: if Redis is
|
||||
// unreachable we want the BFF to surface a clear error on
|
||||
// the first command path, not a never-ending reconnect
|
||||
// loop hiding the issue.
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
client.on('connect', () => {
|
||||
logger.log({ event: 'redis.connect', host: config.host, port: config.port }, 'redis');
|
||||
});
|
||||
client.on('ready', () => {
|
||||
logger.log({ event: 'redis.ready' }, 'redis');
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
logger.error({ event: 'redis.error', message: err.message }, 'redis');
|
||||
});
|
||||
client.on('close', () => {
|
||||
logger.warn({ event: 'redis.close' }, 'redis');
|
||||
});
|
||||
client.on('reconnecting', (delayMs: number) => {
|
||||
logger.warn({ event: 'redis.reconnecting', delayMs }, 'redis');
|
||||
});
|
||||
|
||||
return client;
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [REDIS_CLIENT],
|
||||
})
|
||||
export class RedisModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* DI token for the shared `ioredis` connection used across the
|
||||
* BFF: today the session store (`connect-redis` lands in the next
|
||||
* PR), tomorrow the OBO token cache (ADR-0014).
|
||||
*
|
||||
* Usage:
|
||||
* @Inject(REDIS_CLIENT) private readonly redis: Redis
|
||||
*/
|
||||
export const REDIS_CLIENT = 'REDIS_CLIENT';
|
||||
|
||||
export type { Redis };
|
||||
Reference in New Issue
Block a user