@lenne.tech/nest-server 11.32.4 → 11.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/architecture.md +16 -0
- package/.claude/rules/configurable-features.md +11 -3
- package/.claude/rules/testing.md +101 -1
- package/CLAUDE.md +5 -0
- package/FRAMEWORK-API.md +58 -3
- package/dist/core/common/helpers/content-disposition.helper.d.ts +1 -0
- package/dist/core/common/helpers/content-disposition.helper.js +14 -0
- package/dist/core/common/helpers/content-disposition.helper.js.map +1 -0
- package/dist/core/common/helpers/file.helper.d.ts +7 -0
- package/dist/core/common/helpers/file.helper.js +21 -4
- package/dist/core/common/helpers/file.helper.js.map +1 -1
- package/dist/core/common/helpers/graceful-shutdown.helper.d.ts +2 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js +55 -0
- package/dist/core/common/helpers/graceful-shutdown.helper.js.map +1 -0
- package/dist/core/common/helpers/project-name.helper.d.ts +2 -0
- package/dist/core/common/helpers/project-name.helper.js +38 -0
- package/dist/core/common/helpers/project-name.helper.js.map +1 -0
- package/dist/core/common/interfaces/cron-job-config.interface.d.ts +1 -0
- package/dist/core/common/interfaces/server-options.interface.d.ts +39 -0
- package/dist/core/common/services/core-cron-jobs.initializer.d.ts +10 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js +46 -0
- package/dist/core/common/services/core-cron-jobs.initializer.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.registry.d.ts +8 -0
- package/dist/core/common/services/core-cron-jobs.registry.js +12 -0
- package/dist/core/common/services/core-cron-jobs.registry.js.map +1 -0
- package/dist/core/common/services/core-cron-jobs.service.d.ts +37 -7
- package/dist/core/common/services/core-cron-jobs.service.js +278 -27
- package/dist/core/common/services/core-cron-jobs.service.js.map +1 -1
- package/dist/core/common/services/core-redis-pubsub.d.ts +20 -0
- package/dist/core/common/services/core-redis-pubsub.js +112 -0
- package/dist/core/common/services/core-redis-pubsub.js.map +1 -0
- package/dist/core/common/services/core-redis.service.d.ts +25 -0
- package/dist/core/common/services/core-redis.service.js +154 -0
- package/dist/core/common/services/core-redis.service.js.map +1 -0
- package/dist/core/common/services/core-s3.service.d.ts +51 -0
- package/dist/core/common/services/core-s3.service.js +261 -0
- package/dist/core/common/services/core-s3.service.js.map +1 -0
- package/dist/core/common/services/core-trust-proxy.initializer.d.ts +16 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js +77 -0
- package/dist/core/common/services/core-trust-proxy.initializer.js.map +1 -0
- package/dist/core/common/services/rate-limit-store.d.ts +61 -0
- package/dist/core/common/services/rate-limit-store.js +235 -0
- package/dist/core/common/services/rate-limit-store.js.map +1 -0
- package/dist/core/modules/ai/core-ai-mcp.controller.d.ts +19 -3
- package/dist/core/modules/ai/core-ai-mcp.controller.js +148 -18
- package/dist/core/modules/ai/core-ai-mcp.controller.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +7 -2
- package/dist/core/modules/ai/services/core-ai.service.js +24 -20
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +4 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +1 -1
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +3 -14
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +30 -59
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.d.ts +8 -2
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js +118 -45
- package/dist/core/modules/better-auth/core-better-auth-email-verification.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.d.ts +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js +2 -11
- package/dist/core/modules/better-auth/core-better-auth-rate-limit.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.d.ts +12 -10
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js +30 -85
- package/dist/core/modules/better-auth/core-better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js +0 -1
- package/dist/core/modules/better-auth/core-better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/file/core-file.controller.d.ts +11 -2
- package/dist/core/modules/file/core-file.controller.js +52 -15
- package/dist/core/modules/file/core-file.controller.js.map +1 -1
- package/dist/core/modules/file/core-file.resolver.d.ts +4 -4
- package/dist/core/modules/file/core-file.resolver.js +23 -16
- package/dist/core/modules/file/core-file.resolver.js.map +1 -1
- package/dist/core/modules/file/core-file.service.d.ts +41 -7
- package/dist/core/modules/file/core-file.service.js +227 -7
- package/dist/core/modules/file/core-file.service.js.map +1 -1
- package/dist/core/modules/file/file-metadata.helper.d.ts +16 -0
- package/dist/core/modules/file/file-metadata.helper.js +33 -0
- package/dist/core/modules/file/file-metadata.helper.js.map +1 -0
- package/dist/core/modules/file/file-roles.helper.d.ts +4 -0
- package/dist/core/modules/file/file-roles.helper.js +53 -0
- package/dist/core/modules/file/file-roles.helper.js.map +1 -0
- package/dist/core/modules/file/file-storage.helper.d.ts +13 -0
- package/dist/core/modules/file/file-storage.helper.js +65 -0
- package/dist/core/modules/file/file-storage.helper.js.map +1 -0
- package/dist/core/modules/file/filesystem-file.helper.d.ts +22 -0
- package/dist/core/modules/file/filesystem-file.helper.js +77 -0
- package/dist/core/modules/file/filesystem-file.helper.js.map +1 -0
- package/dist/core/modules/file/interfaces/file-service-options.interface.d.ts +1 -0
- package/dist/core/modules/file/interfaces/file-upload.interface.d.ts +7 -1
- package/dist/core/modules/file/s3-file.helper.d.ts +37 -0
- package/dist/core/modules/file/s3-file.helper.js +81 -0
- package/dist/core/modules/file/s3-file.helper.js.map +1 -0
- package/dist/core/modules/hub/core-hub.controller.d.ts +8 -8
- package/dist/core/modules/hub/core-hub.controller.js +24 -22
- package/dist/core/modules/hub/core-hub.controller.js.map +1 -1
- package/dist/core/modules/hub/helpers/hub-mask.helper.js +2 -2
- package/dist/core/modules/hub/helpers/hub-mask.helper.js.map +1 -1
- package/dist/core/modules/hub/hub-buffer.d.ts +23 -0
- package/dist/core/modules/hub/hub-buffer.js +101 -0
- package/dist/core/modules/hub/hub-buffer.js.map +1 -0
- package/dist/core/modules/hub/services/core-hub-mailbox.service.d.ts +7 -5
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js +14 -10
- package/dist/core/modules/hub/services/core-hub-mailbox.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-log-buffer.service.d.ts +5 -2
- package/dist/core/modules/hub/services/hub-log-buffer.service.js +17 -8
- package/dist/core/modules/hub/services/hub-log-buffer.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-query-profiler.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-query-profiler.service.js +13 -7
- package/dist/core/modules/hub/services/hub-query-profiler.service.js.map +1 -1
- package/dist/core/modules/hub/services/hub-trace-buffer.service.d.ts +4 -2
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js +13 -8
- package/dist/core/modules/hub/services/hub-trace-buffer.service.js.map +1 -1
- package/dist/core/modules/migrate/helpers/migration.helper.d.ts +2 -6
- package/dist/core/modules/migrate/helpers/migration.helper.js +4 -4
- package/dist/core/modules/migrate/helpers/migration.helper.js.map +1 -1
- package/dist/core/modules/migrate/migration-runner.d.ts +1 -0
- package/dist/core/modules/migrate/migration-runner.js +4 -0
- package/dist/core/modules/migrate/migration-runner.js.map +1 -1
- package/dist/core/modules/migrate/mongo-state-store.d.ts +1 -0
- package/dist/core/modules/migrate/mongo-state-store.js +98 -9
- package/dist/core/modules/migrate/mongo-state-store.js.map +1 -1
- package/dist/core/modules/system-setup/core-system-setup.service.d.ts +2 -0
- package/dist/core/modules/system-setup/core-system-setup.service.js +31 -1
- package/dist/core/modules/system-setup/core-system-setup.service.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.guard.d.ts +17 -3
- package/dist/core/modules/tenant/core-tenant.guard.js +92 -15
- package/dist/core/modules/tenant/core-tenant.guard.js.map +1 -1
- package/dist/core/modules/tenant/core-tenant.module.js +3 -4
- package/dist/core/modules/tenant/core-tenant.module.js.map +1 -1
- package/dist/core/modules/tus/core-tus.controller.d.ts +2 -0
- package/dist/core/modules/tus/core-tus.controller.js +29 -3
- package/dist/core/modules/tus/core-tus.controller.js.map +1 -1
- package/dist/core/modules/tus/core-tus.service.d.ts +24 -1
- package/dist/core/modules/tus/core-tus.service.js +182 -28
- package/dist/core/modules/tus/core-tus.service.js.map +1 -1
- package/dist/core/modules/tus/interfaces/tus-config.interface.js +3 -0
- package/dist/core/modules/tus/interfaces/tus-config.interface.js.map +1 -1
- package/dist/core/modules/tus/tus-redis-locker.d.ts +25 -0
- package/dist/core/modules/tus/tus-redis-locker.js +87 -0
- package/dist/core/modules/tus/tus-redis-locker.js.map +1 -0
- package/dist/core/modules/tus/tus.module.d.ts +1 -0
- package/dist/core/modules/tus/tus.module.js +30 -4
- package/dist/core/modules/tus/tus.module.js.map +1 -1
- package/dist/core.module.js +18 -1
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -1
- package/dist/main.js +2 -1
- package/dist/main.js.map +1 -1
- package/dist/server/modules/file/file.controller.js.map +1 -1
- package/dist/server/modules/file/file.resolver.js +1 -12
- package/dist/server/modules/file/file.resolver.js.map +1 -1
- package/dist/server/modules/file/file.service.d.ts +5 -1
- package/dist/server/modules/file/file.service.js +12 -3
- package/dist/server/modules/file/file.service.js.map +1 -1
- package/dist/server/modules/user/avatar.controller.d.ts +5 -1
- package/dist/server/modules/user/avatar.controller.js +26 -10
- package/dist/server/modules/user/avatar.controller.js.map +1 -1
- package/dist/server/modules/user/user.model.js +3 -2
- package/dist/server/modules/user/user.model.js.map +1 -1
- package/dist/server/modules/user/user.module.js +6 -2
- package/dist/server/modules/user/user.module.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +1 -1
- package/dist/server/modules/user/user.service.js +6 -13
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/docs/REQUEST-LIFECYCLE.md +57 -8
- package/docs/security-overrides.md +21 -3
- package/migration-guides/11.32.3-to-11.32.4.md +15 -3
- package/migration-guides/11.32.x-to-11.33.x.md +1551 -0
- package/package.json +74 -42
- package/src/core/common/helpers/content-disposition.helper.ts +90 -0
- package/src/core/common/helpers/file.helper.ts +53 -10
- package/src/core/common/helpers/graceful-shutdown.helper.ts +116 -0
- package/src/core/common/helpers/project-name.helper.ts +71 -0
- package/src/core/common/interfaces/cron-job-config.interface.ts +16 -0
- package/src/core/common/interfaces/server-options.interface.ts +439 -2
- package/src/core/common/services/core-cron-jobs.initializer.ts +39 -0
- package/src/core/common/services/core-cron-jobs.registry.ts +45 -0
- package/src/core/common/services/core-cron-jobs.service.ts +589 -47
- package/src/core/common/services/core-redis-pubsub.ts +183 -0
- package/src/core/common/services/core-redis.service.ts +249 -0
- package/src/core/common/services/core-s3.service.ts +465 -0
- package/src/core/common/services/core-trust-proxy.initializer.ts +112 -0
- package/src/core/common/services/rate-limit-store.ts +492 -0
- package/src/core/modules/ai/README.md +25 -0
- package/src/core/modules/ai/core-ai-mcp.controller.ts +253 -22
- package/src/core/modules/ai/services/core-ai.service.ts +33 -24
- package/src/core/modules/auth/core-auth.module.ts +7 -1
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +12 -21
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +49 -93
- package/src/core/modules/better-auth/core-better-auth-email-verification.service.ts +202 -66
- package/src/core/modules/better-auth/core-better-auth-rate-limit.middleware.ts +11 -18
- package/src/core/modules/better-auth/core-better-auth-rate-limiter.service.ts +51 -132
- package/src/core/modules/better-auth/core-better-auth-user.mapper.ts +7 -1
- package/src/core/modules/file/INTEGRATION-CHECKLIST.md +108 -0
- package/src/core/modules/file/README.md +277 -53
- package/src/core/modules/file/core-file.controller.ts +168 -12
- package/src/core/modules/file/core-file.resolver.ts +46 -12
- package/src/core/modules/file/core-file.service.ts +482 -16
- package/src/core/modules/file/file-metadata.helper.ts +142 -0
- package/src/core/modules/file/file-roles.helper.ts +110 -0
- package/src/core/modules/file/file-storage.helper.ts +163 -0
- package/src/core/modules/file/filesystem-file.helper.ts +184 -0
- package/src/core/modules/file/interfaces/file-service-options.interface.ts +14 -0
- package/src/core/modules/file/interfaces/file-upload.interface.ts +17 -1
- package/src/core/modules/file/s3-file.helper.ts +209 -0
- package/src/core/modules/hub/README.md +48 -0
- package/src/core/modules/hub/core-hub.controller.ts +18 -16
- package/src/core/modules/hub/helpers/hub-mask.helper.ts +21 -4
- package/src/core/modules/hub/hub-buffer.ts +198 -0
- package/src/core/modules/hub/services/core-hub-mailbox.service.ts +22 -12
- package/src/core/modules/hub/services/hub-log-buffer.service.ts +24 -8
- package/src/core/modules/hub/services/hub-query-profiler.service.ts +9 -6
- package/src/core/modules/hub/services/hub-trace-buffer.service.ts +14 -9
- package/src/core/modules/migrate/README.md +33 -6
- package/src/core/modules/migrate/helpers/migration.helper.ts +13 -6
- package/src/core/modules/migrate/migration-runner.ts +10 -1
- package/src/core/modules/migrate/mongo-state-store.ts +179 -11
- package/src/core/modules/system-setup/README.md +7 -2
- package/src/core/modules/system-setup/core-system-setup.service.ts +95 -1
- package/src/core/modules/tenant/README.md +21 -1
- package/src/core/modules/tenant/core-tenant.guard.ts +135 -14
- package/src/core/modules/tenant/core-tenant.module.ts +8 -4
- package/src/core/modules/tus/INTEGRATION-CHECKLIST.md +93 -21
- package/src/core/modules/tus/README.md +35 -2
- package/src/core/modules/tus/core-tus.controller.ts +64 -12
- package/src/core/modules/tus/core-tus.service.ts +358 -34
- package/src/core/modules/tus/interfaces/tus-config.interface.ts +9 -0
- package/src/core/modules/tus/tus-redis-locker.ts +139 -0
- package/src/core/modules/tus/tus.module.ts +78 -6
- package/src/core.module.ts +26 -1
- package/src/index.ts +10 -0
- package/src/main.ts +7 -1
- package/src/server/modules/file/file.controller.ts +7 -3
- package/src/server/modules/file/file.resolver.ts +5 -17
- package/src/server/modules/file/file.service.ts +42 -3
- package/src/server/modules/user/avatar.controller.ts +39 -9
- package/src/server/modules/user/user.model.ts +9 -3
- package/src/server/modules/user/user.module.ts +12 -3
- package/src/server/modules/user/user.service.ts +20 -17
- package/src/test/README.md +7 -2
- package/src/core/common/helpers/file.helper.spec.ts +0 -145
- package/src/core/common/helpers/logging.helper.spec.ts +0 -61
- package/src/core/common/helpers/process-diagnostics.helper.spec.ts +0 -310
- package/src/core/common/services/brevo.service.spec.ts +0 -266
- package/src/core/modules/file/core-file.controller.spec.ts +0 -164
- package/src/core/modules/hub/core-hub.module.spec.ts +0 -108
- package/src/core/modules/hub/helpers/hub-command-shape.helper.spec.ts +0 -48
- package/src/core/modules/hub/helpers/hub-mask.helper.spec.ts +0 -67
- package/src/core/modules/hub/helpers/hub-mermaid.helper.spec.ts +0 -61
- package/src/core/modules/hub/helpers/hub-shell.helper.spec.ts +0 -106
- package/src/core/modules/hub/hub-config.helper.spec.ts +0 -108
- package/src/core/modules/hub/hub-ring-buffer.spec.ts +0 -95
- package/src/core/modules/hub/services/core-hub-mailbox.service.spec.ts +0 -116
- package/src/core/modules/hub/services/hub-trace-buffer.service.spec.ts +0 -112
- package/src/core/modules/migrate/helpers/migration.helper.spec.ts +0 -85
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { Logger, OnModuleDestroy } from '@nestjs/common';
|
|
2
|
+
import { PubSubEngine } from 'graphql-subscriptions';
|
|
3
|
+
|
|
4
|
+
import type { CoreRedisService } from './core-redis.service';
|
|
5
|
+
import type { Redis } from 'ioredis';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Redis-backed drop-in replacement for the in-memory `PubSub` of `graphql-subscriptions`.
|
|
9
|
+
*
|
|
10
|
+
* Used as the `PUB_SUB` provider whenever Redis is enabled, so GraphQL subscriptions
|
|
11
|
+
* are delivered across ALL replicas instead of only the process that published them.
|
|
12
|
+
* `asyncIterableIterator()` is inherited from `PubSubEngine` and works unchanged.
|
|
13
|
+
*
|
|
14
|
+
* Payloads are JSON-serialized — they MUST be JSON-serializable. Class instances,
|
|
15
|
+
* `Date` objects, `Map`/`Set` and `undefined` values do not survive the round trip.
|
|
16
|
+
*
|
|
17
|
+
* The subscriber connection is shared process-wide (`CoreRedisService.getSubscriber()`),
|
|
18
|
+
* so this engine keeps its own channel → handler map and only issues a Redis
|
|
19
|
+
* SUBSCRIBE/UNSUBSCRIBE when the first handler for a channel arrives / the last one leaves.
|
|
20
|
+
*/
|
|
21
|
+
/** ISO-8601 with a time component — the shape `Date.prototype.toJSON()` produces */
|
|
22
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Turn ISO-8601 strings back into `Date`s while parsing.
|
|
26
|
+
*
|
|
27
|
+
* The in-memory PubSub passed live objects, so subscribers received real `Date`s; a JSON round
|
|
28
|
+
* trip hands them strings instead, and `DateScalar.serialize()` throws on those. Enabling Redis
|
|
29
|
+
* is documented as a drop-in, so it must not change the shape of what a subscriber receives.
|
|
30
|
+
*
|
|
31
|
+
* Only strings that look exactly like a serialized Date are converted, which cannot distinguish
|
|
32
|
+
* a genuine ISO-timestamp STRING from a Date — a deliberate trade: the in-memory behavior is
|
|
33
|
+
* the reference, and there a Date stays a Date.
|
|
34
|
+
*/
|
|
35
|
+
function reviveDates(_key: string, value: unknown): unknown {
|
|
36
|
+
return typeof value === 'string' && ISO_DATE.test(value) ? new Date(value) : value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class CoreRedisPubSub extends PubSubEngine implements OnModuleDestroy {
|
|
40
|
+
protected readonly logger = new Logger(CoreRedisPubSub.name);
|
|
41
|
+
|
|
42
|
+
/** Channel → (subscription id → handler) */
|
|
43
|
+
protected readonly handlers = new Map<string, Map<number, (...args: any[]) => void>>();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The 'message' listener attached to the SHARED subscriber connection, kept so it can be
|
|
47
|
+
* removed again. Several features listen on that one connection, so a listener left behind by
|
|
48
|
+
* a closed app keeps dispatching into its dead handler maps for the life of the process.
|
|
49
|
+
*/
|
|
50
|
+
protected messageListener?: (channel: string, message: string) => void;
|
|
51
|
+
|
|
52
|
+
/** Subscription id → channel */
|
|
53
|
+
protected readonly subscriptions = new Map<number, string>();
|
|
54
|
+
|
|
55
|
+
protected subIdCounter = 0;
|
|
56
|
+
|
|
57
|
+
constructor(protected readonly redisService: CoreRedisService) {
|
|
58
|
+
super();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async publish(triggerName: string, payload: any): Promise<void> {
|
|
62
|
+
try {
|
|
63
|
+
await this.redisService.getClient().publish(this.channel(triggerName), JSON.stringify(payload));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
// The in-memory PubSub this replaces could not fail, so callers do not guard their
|
|
66
|
+
// publishes. Letting a Redis blip escape would turn a missed NOTIFICATION into a failed
|
|
67
|
+
// business operation — a user written to the database but reported as a failed sign-up.
|
|
68
|
+
this.logger.warn(
|
|
69
|
+
`Subscription publish to "${triggerName}" failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async subscribe(triggerName: string, onMessage: (...args: any[]) => void): Promise<number> {
|
|
75
|
+
const channel = this.channel(triggerName);
|
|
76
|
+
let channelHandlers = this.handlers.get(channel);
|
|
77
|
+
|
|
78
|
+
// First handler for this channel: subscribe on Redis
|
|
79
|
+
if (!channelHandlers) {
|
|
80
|
+
channelHandlers = new Map();
|
|
81
|
+
this.handlers.set(channel, channelHandlers);
|
|
82
|
+
try {
|
|
83
|
+
await this.getSubscriber().subscribe(channel);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
this.handlers.delete(channel);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const subId = ++this.subIdCounter;
|
|
91
|
+
channelHandlers.set(subId, onMessage);
|
|
92
|
+
this.subscriptions.set(subId, channel);
|
|
93
|
+
return subId;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async unsubscribe(subId: number): Promise<void> {
|
|
97
|
+
const channel = this.subscriptions.get(subId);
|
|
98
|
+
if (!channel) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.subscriptions.delete(subId);
|
|
102
|
+
|
|
103
|
+
const channelHandlers = this.handlers.get(channel);
|
|
104
|
+
if (!channelHandlers) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
channelHandlers.delete(subId);
|
|
108
|
+
|
|
109
|
+
// Last handler for this channel: unsubscribe on Redis
|
|
110
|
+
if (channelHandlers.size === 0) {
|
|
111
|
+
this.handlers.delete(channel);
|
|
112
|
+
await this.getSubscriber().unsubscribe(channel);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Framework-namespaced Redis channel for a GraphQL trigger name
|
|
118
|
+
*/
|
|
119
|
+
protected channel(triggerName: string): string {
|
|
120
|
+
return this.redisService.key('pubsub', triggerName);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Deliver an incoming Redis message to all handlers of that channel.
|
|
125
|
+
* Messages of channels this engine does not know (the subscriber connection is
|
|
126
|
+
* shared with other framework features) are ignored.
|
|
127
|
+
*/
|
|
128
|
+
protected dispatch(channel: string, message: string): void {
|
|
129
|
+
const channelHandlers = this.handlers.get(channel);
|
|
130
|
+
if (!channelHandlers?.size) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
let payload: any;
|
|
134
|
+
try {
|
|
135
|
+
payload = JSON.parse(message, reviveDates);
|
|
136
|
+
} catch {
|
|
137
|
+
this.logger.warn(`Ignoring non-JSON message on channel ${channel}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const handler of channelHandlers.values()) {
|
|
141
|
+
handler(payload);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Shared subscriber connection with the 'message' listener attached once
|
|
147
|
+
*/
|
|
148
|
+
/**
|
|
149
|
+
* Unsubscribe every channel and detach the shared listener.
|
|
150
|
+
*
|
|
151
|
+
* Without this the subscriptions and the 'message' listener outlive the application — and
|
|
152
|
+
* because the subscriber connection is shared, a second app in the same process (a test suite,
|
|
153
|
+
* any multi-app host) inherits the previous one's listener alongside its own.
|
|
154
|
+
*/
|
|
155
|
+
async onModuleDestroy(): Promise<void> {
|
|
156
|
+
const channels = [...this.handlers.keys()];
|
|
157
|
+
this.handlers.clear();
|
|
158
|
+
this.subscriptions.clear();
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const subscriber = this.redisService.getSubscriber();
|
|
162
|
+
if (this.messageListener) {
|
|
163
|
+
subscriber.off('message', this.messageListener);
|
|
164
|
+
this.messageListener = undefined;
|
|
165
|
+
}
|
|
166
|
+
if (channels.length) {
|
|
167
|
+
await subscriber.unsubscribe(...channels);
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
// Redis already gone: the connection is being torn down anyway.
|
|
171
|
+
this.logger.debug(`PubSub teardown skipped: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
protected getSubscriber(): Redis {
|
|
176
|
+
const subscriber = this.redisService.getSubscriber();
|
|
177
|
+
if (!this.messageListener) {
|
|
178
|
+
this.messageListener = (channel: string, message: string) => this.dispatch(channel, message);
|
|
179
|
+
subscriber.on('message', this.messageListener);
|
|
180
|
+
}
|
|
181
|
+
return subscriber;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { Injectable, Logger, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
|
2
|
+
|
|
3
|
+
import { getProjectSlug } from '../helpers/project-name.helper';
|
|
4
|
+
import { ConfigService } from './config.service';
|
|
5
|
+
|
|
6
|
+
import type { IRedisConfig } from '../interfaces/server-options.interface';
|
|
7
|
+
import type { Redis, RedisOptions } from 'ioredis';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Fully normalized Redis configuration with all defaults applied
|
|
11
|
+
*/
|
|
12
|
+
export type NormalizedRedisConfig = Required<Pick<IRedisConfig, 'db' | 'host' | 'keyPrefix' | 'port'>> &
|
|
13
|
+
Pick<IRedisConfig, 'options' | 'password' | 'url' | 'username'>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Central Redis connection service (see IServerOptions.redis).
|
|
17
|
+
*
|
|
18
|
+
* All distributed framework features (rate limiting, cron deduplication, GraphQL
|
|
19
|
+
* subscriptions, caches, Hub collectors) share this single service. It follows the
|
|
20
|
+
* "presence implies enabled" pattern: without a `redis` config every consumer keeps
|
|
21
|
+
* its process-local fallback behavior and this service stays inert.
|
|
22
|
+
*
|
|
23
|
+
* The `ioredis` package is an OPTIONAL peer dependency and is lazy-imported at
|
|
24
|
+
* bootstrap — consumers without Redis never need it installed. When Redis is
|
|
25
|
+
* configured but the package is missing, the boot fails fast with an actionable
|
|
26
|
+
* error instead of a runtime crash on first use.
|
|
27
|
+
*/
|
|
28
|
+
@Injectable()
|
|
29
|
+
export class CoreRedisService implements OnApplicationShutdown, OnModuleInit {
|
|
30
|
+
protected readonly logger = new Logger(CoreRedisService.name);
|
|
31
|
+
|
|
32
|
+
/** All connections created by this service, quit together on shutdown */
|
|
33
|
+
protected clients: Redis[] = [];
|
|
34
|
+
|
|
35
|
+
/** Normalized config; undefined when Redis is not enabled */
|
|
36
|
+
protected config?: NormalizedRedisConfig;
|
|
37
|
+
|
|
38
|
+
/** ioredis constructor, resolved by the lazy import at init */
|
|
39
|
+
protected RedisCtor?: new (...args: any[]) => Redis;
|
|
40
|
+
|
|
41
|
+
/** Shared general-purpose client */
|
|
42
|
+
protected sharedClient?: Redis;
|
|
43
|
+
|
|
44
|
+
/** Dedicated subscriber connection (a subscribing client cannot run commands) */
|
|
45
|
+
protected subscriberClient?: Redis;
|
|
46
|
+
|
|
47
|
+
constructor(protected readonly configService: ConfigService) {
|
|
48
|
+
const raw = this.configService.getFastButReadOnly<boolean | IRedisConfig | undefined>('redis');
|
|
49
|
+
if (raw === undefined || raw === null || raw === false) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const partial: IRedisConfig = typeof raw === 'boolean' ? {} : raw;
|
|
53
|
+
if (partial.enabled === false) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.config = {
|
|
57
|
+
db: partial.db ?? 0,
|
|
58
|
+
host: partial.host ?? 'localhost',
|
|
59
|
+
// Default namespaced PER APPLICATION, not per framework. A constant default is invisible
|
|
60
|
+
// until two applications share one Redis — a normal staging setup — and then their
|
|
61
|
+
// identically-named cron jobs, rate-limit counters and Hub buffers collide on the same keys.
|
|
62
|
+
// The failure is silent: one app's BullMQ worker consumes the other's scheduled jobs, so the
|
|
63
|
+
// job never runs where it was defined. Override via `redis.keyPrefix` when you WANT sharing.
|
|
64
|
+
keyPrefix: partial.keyPrefix ?? getProjectSlug(),
|
|
65
|
+
options: partial.options,
|
|
66
|
+
password: partial.password,
|
|
67
|
+
port: partial.port ?? 6379,
|
|
68
|
+
url: partial.url,
|
|
69
|
+
username: partial.username,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether Redis is configured and enabled
|
|
75
|
+
*/
|
|
76
|
+
get enabled(): boolean {
|
|
77
|
+
return !!this.config;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Normalized Redis configuration, or undefined when disabled
|
|
82
|
+
*/
|
|
83
|
+
getConfig(): NormalizedRedisConfig | undefined {
|
|
84
|
+
return this.config;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build a framework-managed Redis key: `<keyPrefix>:<part>:<part>...`
|
|
89
|
+
*
|
|
90
|
+
* The prefix is applied per key instead of via the ioredis `keyPrefix` option,
|
|
91
|
+
* which would conflict with BullMQ's own prefix handling.
|
|
92
|
+
*/
|
|
93
|
+
key(...parts: string[]): string {
|
|
94
|
+
return [this.config?.keyPrefix ?? getProjectSlug(), ...parts].join(':');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Lazy-import ioredis and create the shared client.
|
|
99
|
+
* No-op when Redis is not enabled.
|
|
100
|
+
*/
|
|
101
|
+
async onModuleInit(): Promise<void> {
|
|
102
|
+
if (!this.config) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const mod: any = await import('ioredis');
|
|
107
|
+
this.RedisCtor = mod.Redis ?? mod.default;
|
|
108
|
+
} catch {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'Redis is configured (ServerOptions.redis) but the optional peer dependency "ioredis" is not installed. ' +
|
|
111
|
+
'Run: pnpm add ioredis',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
this.sharedClient = this.newConnection('shared');
|
|
115
|
+
this.logActivationSummary();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* State the consequences of `redis` being configured, once, at boot.
|
|
120
|
+
*
|
|
121
|
+
* Configuring Redis is not a local decision — it silently changes the behavior of a dozen
|
|
122
|
+
* subsystems at once, and several of those changes are only observable in production. The worst
|
|
123
|
+
* case is a project that never opted in at all: `redis` is a generic name, so a project may
|
|
124
|
+
* already carry that key in its server options for its OWN client, and then a mere version bump
|
|
125
|
+
* flips everything over. Cron stops being per-replica, rate limits get stricter (they were
|
|
126
|
+
* effectively `max × replicas` before), diagnostics leave the process. All correct, none of it
|
|
127
|
+
* announced.
|
|
128
|
+
*
|
|
129
|
+
* So the summary lists what actually changed FOR THIS CONFIG — features that are switched off
|
|
130
|
+
* are omitted rather than listed as inactive, because a list of non-events is noise.
|
|
131
|
+
*/
|
|
132
|
+
protected logActivationSummary(): void {
|
|
133
|
+
if (!this.config) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const cfg = this.configService.configFastButReadOnly ?? ({} as Record<string, any>);
|
|
138
|
+
const changed: string[] = [
|
|
139
|
+
'rate limits (legacy auth, better-auth, AI) — now exact across replicas instead of per replica',
|
|
140
|
+
'cron jobs — deduplicated fleet-wide; per-job opt-out via `distributed: false`',
|
|
141
|
+
'email-verification cooldown — one slot fleet-wide instead of one per replica',
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
if (cfg.graphQl !== false) {
|
|
145
|
+
changed.push('GraphQL subscriptions — via Redis pub/sub; payloads must be JSON-serializable');
|
|
146
|
+
}
|
|
147
|
+
if (cfg.multiTenancy) {
|
|
148
|
+
changed.push('tenant-membership cache — invalidation is broadcast to every replica');
|
|
149
|
+
}
|
|
150
|
+
if (cfg.hub) {
|
|
151
|
+
changed.push('Hub log/trace/query buffers — mirrored to Redis, so they outlive the process');
|
|
152
|
+
}
|
|
153
|
+
if (cfg.ai?.mcp) {
|
|
154
|
+
changed.push('MCP sessions — ownership registry (sticky sessions still required)');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.logger.log(
|
|
158
|
+
`Redis configured (${this.config.url ? 'url' : `${this.config.host}:${this.config.port}`}, ` +
|
|
159
|
+
`db ${this.config.db}, keyPrefix "${this.config.keyPrefix}") — these subsystems are now ` +
|
|
160
|
+
`distributed:\n - ${changed.join('\n - ')}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Shared general-purpose client.
|
|
166
|
+
* Throws when Redis is not enabled or the module has not been initialized yet.
|
|
167
|
+
*/
|
|
168
|
+
getClient(): Redis {
|
|
169
|
+
if (!this.config) {
|
|
170
|
+
throw new Error('Redis is not configured/enabled (ServerOptions.redis)');
|
|
171
|
+
}
|
|
172
|
+
if (!this.sharedClient) {
|
|
173
|
+
throw new Error('CoreRedisService is not initialized yet (onModuleInit pending)');
|
|
174
|
+
}
|
|
175
|
+
return this.sharedClient;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Dedicated cached subscriber connection — a client in subscribe mode
|
|
180
|
+
* cannot execute regular commands, so it must not share the main connection.
|
|
181
|
+
*/
|
|
182
|
+
getSubscriber(): Redis {
|
|
183
|
+
this.getClient();
|
|
184
|
+
if (!this.subscriberClient) {
|
|
185
|
+
this.subscriberClient = this.newConnection('subscriber');
|
|
186
|
+
}
|
|
187
|
+
return this.subscriberClient;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Create a NEW dedicated connection (e.g. for BullMQ, blocking commands).
|
|
192
|
+
* The connection is tracked and closed on application shutdown.
|
|
193
|
+
*/
|
|
194
|
+
createClient(label = 'extra', overrides?: RedisOptions): Redis {
|
|
195
|
+
this.getClient();
|
|
196
|
+
return this.newConnection(label, overrides);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Quit all created connections
|
|
201
|
+
*/
|
|
202
|
+
async onApplicationShutdown(): Promise<void> {
|
|
203
|
+
await Promise.allSettled(
|
|
204
|
+
this.clients.map(async (client) => {
|
|
205
|
+
try {
|
|
206
|
+
// A client in a reconnect loop QUEUES the quit rather than executing it, so the promise
|
|
207
|
+
// stays pending for the whole retry budget — while the reconnect timer, which is not
|
|
208
|
+
// unref'd, keeps the process alive. Bound the wait and take the socket down either way.
|
|
209
|
+
await Promise.race([client.quit(), new Promise((resolve) => setTimeout(resolve, 2000).unref())]);
|
|
210
|
+
} finally {
|
|
211
|
+
client.disconnect();
|
|
212
|
+
}
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
this.clients = [];
|
|
216
|
+
this.sharedClient = undefined;
|
|
217
|
+
this.subscriberClient = undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Create a tracked ioredis connection from the normalized config
|
|
222
|
+
*/
|
|
223
|
+
protected newConnection(label: string, overrides?: RedisOptions): Redis {
|
|
224
|
+
if (!this.config || !this.RedisCtor) {
|
|
225
|
+
throw new Error('CoreRedisService is not initialized yet (onModuleInit pending)');
|
|
226
|
+
}
|
|
227
|
+
const { db, host, options, password, port, url, username } = this.config;
|
|
228
|
+
const baseOptions: RedisOptions = {
|
|
229
|
+
// Without a command timeout a command issued while Redis is down neither resolves nor
|
|
230
|
+
// rejects — it waits in the offline queue for the entire retry budget. The rate limiter
|
|
231
|
+
// degrades on a REJECTION, so a blip would stall every sign-in instead of falling back.
|
|
232
|
+
// Listed before the spread so a project's own `redis.options` can still override it.
|
|
233
|
+
commandTimeout: 2000,
|
|
234
|
+
...(options as RedisOptions),
|
|
235
|
+
// Per-connection opt-out. A BLOCKING consumer (BullMQ's BZPOPMIN waits up to 10s by
|
|
236
|
+
// design) must not inherit the timeout: ioredis would abort the wait, BullMQ would
|
|
237
|
+
// classify it as a real error, and its fetch loop would stop for good.
|
|
238
|
+
...overrides,
|
|
239
|
+
};
|
|
240
|
+
const client = url
|
|
241
|
+
? new this.RedisCtor(url, baseOptions)
|
|
242
|
+
: new this.RedisCtor({ db, host, password, port, username, ...baseOptions });
|
|
243
|
+
client.on?.('error', (error: Error) => {
|
|
244
|
+
this.logger.error(`Redis connection error (${label}): ${error.message}`);
|
|
245
|
+
});
|
|
246
|
+
this.clients.push(client);
|
|
247
|
+
return client;
|
|
248
|
+
}
|
|
249
|
+
}
|