@idevconn/create-icore 0.5.0 → 0.5.1

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.
Files changed (39) hide show
  1. package/dist/cli.js +84 -25
  2. package/dist/index.cjs +81 -24
  3. package/dist/index.d.cts +7 -1
  4. package/dist/index.d.ts +7 -1
  5. package/dist/index.js +80 -24
  6. package/package.json +1 -1
  7. package/templates/apps/api/.env.example +14 -0
  8. package/templates/apps/microservices/auth/package.json +1 -1
  9. package/templates/apps/microservices/auth/src/app/app.module.ts +17 -30
  10. package/templates/apps/microservices/auth/src/main.ts +6 -23
  11. package/templates/apps/microservices/jobs/src/app/redis-connection.ts +35 -0
  12. package/templates/apps/microservices/jobs/src/app/workers/cleanup.worker.ts +2 -1
  13. package/templates/apps/microservices/jobs/src/app/workers/email.worker.ts +2 -1
  14. package/templates/apps/microservices/jobs/src/app/workers/image-process.worker.ts +2 -1
  15. package/templates/apps/microservices/notes/src/app/app.module.ts +22 -27
  16. package/templates/apps/microservices/notes/src/main.ts +6 -23
  17. package/templates/apps/microservices/payment/src/app/app.module.ts +6 -4
  18. package/templates/apps/microservices/payment/src/main.ts +6 -23
  19. package/templates/apps/microservices/upload/package.json +1 -1
  20. package/templates/apps/microservices/upload/src/app/app.module.ts +18 -30
  21. package/templates/apps/microservices/upload/src/main.ts +6 -23
  22. package/templates/libs/firebase-admin/README.md +11 -0
  23. package/templates/libs/firebase-admin/eslint.config.mjs +24 -0
  24. package/templates/libs/firebase-admin/package.json +12 -0
  25. package/templates/libs/firebase-admin/project.json +19 -0
  26. package/templates/libs/firebase-admin/src/index.ts +1 -0
  27. package/templates/libs/firebase-admin/src/lib/__tests__/firebase-admin.unit.test.ts +105 -0
  28. package/templates/libs/firebase-admin/src/lib/firebase-admin.ts +70 -0
  29. package/templates/libs/firebase-admin/tsconfig.json +23 -0
  30. package/templates/libs/firebase-admin/tsconfig.lib.json +23 -0
  31. package/templates/libs/firebase-admin/tsconfig.spec.json +22 -0
  32. package/templates/libs/firebase-admin/vitest.config.mts +21 -0
  33. package/templates/libs/jobs-client/src/lib/jobs-client.service.ts +14 -2
  34. package/templates/libs/shared/src/__tests__/bootstrap.unit.test.ts +92 -0
  35. package/templates/libs/shared/src/__tests__/transport.unit.test.ts +14 -2
  36. package/templates/libs/shared/src/bootstrap.ts +79 -0
  37. package/templates/libs/shared/src/index.ts +1 -0
  38. package/templates/libs/shared/src/transport.ts +25 -3
  39. package/templates/tsconfig.base.json +2 -1
@@ -1,28 +1,11 @@
1
1
  import { Logger } from '@nestjs/common';
2
2
  import { NestFactory } from '@nestjs/core';
3
3
  import { MicroserviceOptions } from '@nestjs/microservices';
4
- import { buildTransportMS } from '@icore/shared';
4
+ import { bootstrapMicroservice, buildTransportMS } from '@icore/shared';
5
5
  import { AppModule } from './app/app.module';
6
6
 
7
- async function bootstrap() {
8
- const app = await NestFactory.createMicroservice<MicroserviceOptions>(
9
- AppModule,
10
- buildTransportMS('PAYMENT'),
11
- );
12
- await app.listen();
13
- }
14
-
15
- bootstrap()
16
- .then(() => {
17
- const logger = new Logger('Payment-Bootstrap');
18
- logger.log(
19
- `Payment MS Bootstrap completed: transport=${process.env.PAYMENT_TRANSPORT ?? 'tcp'} host=${process.env.PAYMENT_HOST ?? '127.0.0.1'} port=${process.env.PAYMENT_PORT ?? '4003'}`,
20
- );
21
- })
22
- .catch((err) => {
23
- new Logger('Payment-Bootstrap').error(
24
- 'Payment MS bootstrap failed',
25
- err instanceof Error ? err.stack : err,
26
- );
27
- process.exit(1);
28
- });
7
+ void bootstrapMicroservice(
8
+ 'PAYMENT',
9
+ () => NestFactory.createMicroservice<MicroserviceOptions>(AppModule, buildTransportMS('PAYMENT')),
10
+ new Logger('Payment-Bootstrap'),
11
+ );
@@ -3,12 +3,12 @@
3
3
  "version": "0.0.1",
4
4
  "private": true,
5
5
  "dependencies": {
6
+ "@icore/firebase-admin": "*",
6
7
  "@icore/shared": "*",
7
8
  "@icore/storage-cloudinary": "*",
8
9
  "@icore/storage-firebase": "*",
9
10
  "@icore/storage-supabase": "*",
10
11
  "cloudinary": "^2.0.0",
11
- "firebase-admin": "^13.0.0",
12
12
  "@nestjs/common": "^11.1.24",
13
13
  "@nestjs/config": "^4.0.4",
14
14
  "@nestjs/core": "^11.1.24",
@@ -1,27 +1,21 @@
1
1
  import { join } from 'node:path';
2
- import { Module } from '@nestjs/common';
2
+ import { Module, Logger } from '@nestjs/common';
3
3
  import { ConfigModule, ConfigService } from '@nestjs/config';
4
4
  import { createClient } from '@supabase/supabase-js';
5
- import * as admin from 'firebase-admin';
6
5
  import { v2 as cloudinary } from 'cloudinary';
7
6
  import { SupabaseStorageStrategy } from '@icore/storage-supabase';
8
7
  import { FirebaseStorageStrategy } from '@icore/storage-firebase';
9
8
  import { CloudinaryStorageStrategy, type CloudinaryApiLike } from '@icore/storage-cloudinary';
9
+ import { getFirebaseAdmin, FIREBASE_ADMIN_REQUIRED_ENV } from '@icore/firebase-admin';
10
10
  import { FakeStorageStrategy, missingEnv, formatEnvBanner } from '@icore/shared';
11
11
  import type { StorageStrategy } from '@icore/shared';
12
- import { Logger } from '@nestjs/common';
13
12
  import { StorageController } from './storage.controller';
14
13
 
15
14
  const ENV_PATH = 'apps/microservices/upload/.env';
16
15
 
17
16
  const REQUIRED_ENV: Record<string, string[]> = {
18
17
  supabase: ['SUPABASE_URL', 'SUPABASE_SERVICE_ROLE_KEY', 'SUPABASE_STORAGE_BUCKET'],
19
- firebase: [
20
- 'FIREBASE_STORAGE_BUCKET',
21
- 'FB_ADMIN_PROJECT_ID',
22
- 'FB_ADMIN_CLIENT_EMAIL',
23
- 'FB_ADMIN_PRIVATE_KEY',
24
- ],
18
+ firebase: [...FIREBASE_ADMIN_REQUIRED_ENV, 'FIREBASE_STORAGE_BUCKET'],
25
19
  cloudinary: ['CLOUDINARY_CLOUD_NAME', 'CLOUDINARY_API_KEY', 'CLOUDINARY_API_SECRET'],
26
20
  };
27
21
 
@@ -29,19 +23,23 @@ function requireEnv(cfg: ConfigService, key: string): string {
29
23
  return cfg.getOrThrow<string>(key);
30
24
  }
31
25
 
26
+ function makeSupabaseStorage(cfg: ConfigService): StorageStrategy {
27
+ const client = createClient(
28
+ requireEnv(cfg, 'SUPABASE_URL'),
29
+ requireEnv(cfg, 'SUPABASE_SERVICE_ROLE_KEY'),
30
+ { auth: { autoRefreshToken: false, persistSession: false } },
31
+ );
32
+ return new SupabaseStorageStrategy({
33
+ client,
34
+ bucket: requireEnv(cfg, 'SUPABASE_STORAGE_BUCKET'),
35
+ });
36
+ }
37
+
32
38
  function makeFirebaseStorage(cfg: ConfigService): StorageStrategy {
33
39
  const bucketName = requireEnv(cfg, 'FIREBASE_STORAGE_BUCKET');
34
- if (admin.apps.length === 0) {
35
- admin.initializeApp({
36
- credential: admin.credential.cert({
37
- projectId: requireEnv(cfg, 'FB_ADMIN_PROJECT_ID'),
38
- clientEmail: requireEnv(cfg, 'FB_ADMIN_CLIENT_EMAIL'),
39
- privateKey: requireEnv(cfg, 'FB_ADMIN_PRIVATE_KEY').replace(/\\n/g, '\n'),
40
- }),
41
- });
42
- }
40
+ const app = getFirebaseAdmin(cfg);
43
41
  return new FirebaseStorageStrategy({
44
- bucket: admin
42
+ bucket: app
45
43
  .storage()
46
44
  .bucket(bucketName) as unknown as import('@icore/storage-firebase').FirebaseStorageBucketLike,
47
45
  });
@@ -129,17 +127,7 @@ function makeCloudinaryStorage(cfg: ConfigService): StorageStrategy {
129
127
  if (!keys || missing.length > 0) return fallback();
130
128
 
131
129
  try {
132
- if (provider === 'supabase') {
133
- const client = createClient(
134
- requireEnv(cfg, 'SUPABASE_URL'),
135
- requireEnv(cfg, 'SUPABASE_SERVICE_ROLE_KEY'),
136
- { auth: { autoRefreshToken: false, persistSession: false } },
137
- );
138
- return new SupabaseStorageStrategy({
139
- client,
140
- bucket: requireEnv(cfg, 'SUPABASE_STORAGE_BUCKET'),
141
- });
142
- }
130
+ if (provider === 'supabase') return makeSupabaseStorage(cfg);
143
131
  if (provider === 'firebase') return makeFirebaseStorage(cfg);
144
132
  return makeCloudinaryStorage(cfg);
145
133
  } catch (err) {
@@ -1,28 +1,11 @@
1
1
  import { Logger } from '@nestjs/common';
2
2
  import { NestFactory } from '@nestjs/core';
3
3
  import { MicroserviceOptions } from '@nestjs/microservices';
4
- import { buildTransportMS } from '@icore/shared';
4
+ import { bootstrapMicroservice, buildTransportMS } from '@icore/shared';
5
5
  import { AppModule } from './app/app.module';
6
6
 
7
- async function bootstrap() {
8
- const app = await NestFactory.createMicroservice<MicroserviceOptions>(
9
- AppModule,
10
- buildTransportMS('UPLOAD'),
11
- );
12
- await app.listen();
13
- }
14
-
15
- bootstrap()
16
- .then(() => {
17
- const logger = new Logger('Upload-Bootstrap');
18
- logger.log(
19
- `Upload MS Bootstrap completed: transport=${process.env.UPLOAD_TRANSPORT ?? 'tcp'} host=${process.env.UPLOAD_HOST ?? '127.0.0.1'} port=${process.env.UPLOAD_PORT ?? '4002'}`,
20
- );
21
- })
22
- .catch((err) => {
23
- new Logger('Upload-Bootstrap').error(
24
- 'Upload MS bootstrap failed',
25
- err instanceof Error ? err.stack : err,
26
- );
27
- process.exit(1);
28
- });
7
+ void bootstrapMicroservice(
8
+ 'UPLOAD',
9
+ () => NestFactory.createMicroservice<MicroserviceOptions>(AppModule, buildTransportMS('UPLOAD')),
10
+ new Logger('Upload-Bootstrap'),
11
+ );
@@ -0,0 +1,11 @@
1
+ # firebase-admin
2
+
3
+ This library was generated with [Nx](https://nx.dev).
4
+
5
+ ## Building
6
+
7
+ Run `nx build firebase-admin` to build the library.
8
+
9
+ ## Running unit tests
10
+
11
+ Run `nx test firebase-admin` to execute the unit tests via [Vitest](https://vitest.dev/).
@@ -0,0 +1,24 @@
1
+ import baseConfig from '../../eslint.config.mjs';
2
+
3
+ export default [
4
+ ...baseConfig,
5
+ {
6
+ files: ['**/*.json'],
7
+ rules: {
8
+ '@nx/dependency-checks': [
9
+ 'error',
10
+ {
11
+ ignoredFiles: [
12
+ '{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}',
13
+ '{projectRoot}/vitest.config.{js,ts,mjs,mts}',
14
+ '{projectRoot}/src/**/*.{spec,test}.{js,ts,jsx,tsx}',
15
+ '{projectRoot}/src/**/__tests__/**/*.{js,ts,jsx,tsx}',
16
+ ],
17
+ },
18
+ ],
19
+ },
20
+ languageOptions: {
21
+ parser: await import('jsonc-eslint-parser'),
22
+ },
23
+ },
24
+ ];
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "@icore/firebase-admin",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "commonjs",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "dependencies": {
9
+ "firebase-admin": "^13.10.0",
10
+ "tslib": "^2.3.0"
11
+ }
12
+ }
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "firebase-admin",
3
+ "$schema": "../../node_modules/nx/schemas/project-schema.json",
4
+ "sourceRoot": "libs/firebase-admin/src",
5
+ "projectType": "library",
6
+ "tags": [],
7
+ "targets": {
8
+ "build": {
9
+ "executor": "@nx/js:tsc",
10
+ "outputs": ["{options.outputPath}"],
11
+ "options": {
12
+ "outputPath": "dist/libs/firebase-admin",
13
+ "main": "libs/firebase-admin/src/index.ts",
14
+ "tsConfig": "libs/firebase-admin/tsconfig.lib.json",
15
+ "assets": ["libs/firebase-admin/*.md"]
16
+ }
17
+ }
18
+ }
19
+ }
@@ -0,0 +1 @@
1
+ export * from './lib/firebase-admin';
@@ -0,0 +1,105 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { apps, cert, initializeApp, app } = vi.hoisted(() => {
4
+ const apps: unknown[] = [];
5
+ return {
6
+ apps,
7
+ cert: vi.fn((sa: unknown) => ({ __cert: sa })),
8
+ initializeApp: vi.fn((opts: unknown) => {
9
+ const created = { __app: opts };
10
+ apps.push(created);
11
+ return created;
12
+ }),
13
+ app: vi.fn(() => apps[0]),
14
+ };
15
+ });
16
+
17
+ vi.mock('firebase-admin', () => ({
18
+ get apps() {
19
+ return apps;
20
+ },
21
+ app,
22
+ initializeApp,
23
+ credential: { cert },
24
+ }));
25
+
26
+ import { FIREBASE_ADMIN_REQUIRED_ENV, getFirebaseAdmin } from '../firebase-admin';
27
+
28
+ type Cfg = {
29
+ getOrThrow: (k: string) => string;
30
+ get: (k: string) => string | undefined;
31
+ };
32
+
33
+ function makeCfg(extra: Record<string, string> = {}): Cfg {
34
+ const env: Record<string, string> = {
35
+ FB_ADMIN_TYPE: 'service_account',
36
+ FB_ADMIN_PROJECT_ID: 'proj',
37
+ FB_ADMIN_PRIVATE_KEY_ID: 'kid',
38
+ FB_ADMIN_PRIVATE_KEY: '-----BEGIN-----\\nABC\\n-----END-----',
39
+ FB_ADMIN_CLIENT_EMAIL: 'svc@proj.iam.gserviceaccount.com',
40
+ FB_ADMIN_CLIENT_ID: 'cid',
41
+ FB_ADMIN_AUTH_URI: 'https://accounts.google.com/o/oauth2/auth',
42
+ FB_ADMIN_TOKEN_URI: 'https://oauth2.googleapis.com/token',
43
+ FB_ADMIN_AUTH_PROVIDER_X509_CERT_URL: 'https://www.googleapis.com/oauth2/v1/certs',
44
+ FB_ADMIN_CLIENT_X509_CERT_URL: 'https://www.googleapis.com/robot/v1/metadata/x509/svc',
45
+ FB_ADMIN_UNIVERSE_DOMAIN: 'googleapis.com',
46
+ ...extra,
47
+ };
48
+ return {
49
+ getOrThrow: (k) => {
50
+ if (!(k in env)) throw new Error(`missing ${k}`);
51
+ return env[k];
52
+ },
53
+ get: (k) => env[k],
54
+ };
55
+ }
56
+
57
+ describe('getFirebaseAdmin', () => {
58
+ beforeEach(() => {
59
+ apps.length = 0;
60
+ cert.mockClear();
61
+ initializeApp.mockClear();
62
+ app.mockClear();
63
+ });
64
+
65
+ afterEach(() => vi.restoreAllMocks());
66
+
67
+ it('requires the full Firebase service-account env contract (all 11 keys)', () => {
68
+ expect([...FIREBASE_ADMIN_REQUIRED_ENV]).toEqual([
69
+ 'FB_ADMIN_TYPE',
70
+ 'FB_ADMIN_PROJECT_ID',
71
+ 'FB_ADMIN_PRIVATE_KEY_ID',
72
+ 'FB_ADMIN_PRIVATE_KEY',
73
+ 'FB_ADMIN_CLIENT_EMAIL',
74
+ 'FB_ADMIN_CLIENT_ID',
75
+ 'FB_ADMIN_AUTH_URI',
76
+ 'FB_ADMIN_TOKEN_URI',
77
+ 'FB_ADMIN_AUTH_PROVIDER_X509_CERT_URL',
78
+ 'FB_ADMIN_CLIENT_X509_CERT_URL',
79
+ 'FB_ADMIN_UNIVERSE_DOMAIN',
80
+ ]);
81
+ });
82
+
83
+ it('passes the complete service account to cert() with escaped newlines fixed', () => {
84
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
85
+ getFirebaseAdmin(makeCfg() as any);
86
+ expect(initializeApp).toHaveBeenCalledTimes(1);
87
+ const sa = cert.mock.calls[0][0] as Record<string, string>;
88
+ expect(sa['project_id']).toBe('proj');
89
+ expect(sa['client_email']).toBe('svc@proj.iam.gserviceaccount.com');
90
+ expect(sa['private_key_id']).toBe('kid');
91
+ expect(sa['universe_domain']).toBe('googleapis.com');
92
+ // \n escapes in the env value are turned into real newlines
93
+ expect(sa['private_key']).toContain('\n');
94
+ expect(sa['private_key']).not.toContain('\\n');
95
+ });
96
+
97
+ it('initializes the default app only once (guards on admin.apps)', () => {
98
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
99
+ const cfg = makeCfg() as any;
100
+ getFirebaseAdmin(cfg);
101
+ getFirebaseAdmin(cfg);
102
+ expect(initializeApp).toHaveBeenCalledTimes(1);
103
+ expect(app).toHaveBeenCalledTimes(1); // second call returns the existing app
104
+ });
105
+ });
@@ -0,0 +1,70 @@
1
+ import * as admin from 'firebase-admin';
2
+
3
+ /**
4
+ * Minimal structural view of NestJS's `ConfigService` — just the two readers
5
+ * this helper needs. Declaring it structurally keeps the lib free of a
6
+ * `@nestjs/config` dependency while still accepting a real `ConfigService` at
7
+ * every call site.
8
+ */
9
+ export interface FirebaseConfigReader {
10
+ getOrThrow<T = string>(key: string): T;
11
+ get<T = string>(key: string): T | undefined;
12
+ }
13
+
14
+ /**
15
+ * The full Firebase service-account env contract.
16
+ *
17
+ * These are exactly the keys Firebase emits in the service-account JSON you
18
+ * download from Project Settings → Service accounts. Every microservice that
19
+ * talks to Firebase (auth, storage, Firestore) requires all of them, so each
20
+ * MS spreads this into its own REQUIRED_ENV map — keeping the contract in one
21
+ * place instead of three drifting copies.
22
+ */
23
+ export const FIREBASE_ADMIN_REQUIRED_ENV = [
24
+ 'FB_ADMIN_TYPE',
25
+ 'FB_ADMIN_PROJECT_ID',
26
+ 'FB_ADMIN_PRIVATE_KEY_ID',
27
+ 'FB_ADMIN_PRIVATE_KEY',
28
+ 'FB_ADMIN_CLIENT_EMAIL',
29
+ 'FB_ADMIN_CLIENT_ID',
30
+ 'FB_ADMIN_AUTH_URI',
31
+ 'FB_ADMIN_TOKEN_URI',
32
+ 'FB_ADMIN_AUTH_PROVIDER_X509_CERT_URL',
33
+ 'FB_ADMIN_CLIENT_X509_CERT_URL',
34
+ 'FB_ADMIN_UNIVERSE_DOMAIN',
35
+ ] as const;
36
+
37
+ /**
38
+ * Initialises the default Firebase Admin app exactly once and returns it.
39
+ *
40
+ * The Admin SDK throws "default app already exists" if `initializeApp` runs
41
+ * twice in a process, so this guards on `admin.apps.length` and is safe to call
42
+ * from every Firebase consumer (auth / storage / notes). Callers then use the
43
+ * returned app: `getFirebaseAdmin(cfg).auth()`, `.firestore()`, `.storage()`.
44
+ *
45
+ * The full service-account object is passed to `cert()` — Firebase only mints
46
+ * tokens from project_id/client_email/private_key, but feeding the complete
47
+ * downloaded JSON keeps the code aligned with the documented FB_ADMIN_* env.
48
+ */
49
+ export function getFirebaseAdmin(cfg: FirebaseConfigReader): admin.app.App {
50
+ if (admin.apps.length > 0) return admin.app();
51
+
52
+ return admin.initializeApp({
53
+ credential: admin.credential.cert({
54
+ type: cfg.getOrThrow<string>('FB_ADMIN_TYPE'),
55
+ project_id: cfg.getOrThrow<string>('FB_ADMIN_PROJECT_ID'),
56
+ private_key_id: cfg.getOrThrow<string>('FB_ADMIN_PRIVATE_KEY_ID'),
57
+ private_key: cfg.getOrThrow<string>('FB_ADMIN_PRIVATE_KEY').replace(/\\n/g, '\n'),
58
+ client_email: cfg.getOrThrow<string>('FB_ADMIN_CLIENT_EMAIL'),
59
+ client_id: cfg.getOrThrow<string>('FB_ADMIN_CLIENT_ID'),
60
+ auth_uri: cfg.getOrThrow<string>('FB_ADMIN_AUTH_URI'),
61
+ token_uri: cfg.getOrThrow<string>('FB_ADMIN_TOKEN_URI'),
62
+ auth_provider_x509_cert_url: cfg.getOrThrow<string>('FB_ADMIN_AUTH_PROVIDER_X509_CERT_URL'),
63
+ client_x509_cert_url: cfg.getOrThrow<string>('FB_ADMIN_CLIENT_X509_CERT_URL'),
64
+ universe_domain: cfg.getOrThrow<string>('FB_ADMIN_UNIVERSE_DOMAIN'),
65
+ } as unknown as admin.ServiceAccount),
66
+ // Optional: the storage MS also passes the bucket name explicitly to
67
+ // .bucket(); set here too so admin.storage().bucket() (no arg) works.
68
+ storageBucket: cfg.get<string>('FIREBASE_STORAGE_BUCKET'),
69
+ });
70
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "module": "commonjs",
5
+ "forceConsistentCasingInFileNames": true,
6
+ "strict": true,
7
+ "importHelpers": true,
8
+ "noImplicitOverride": true,
9
+ "noImplicitReturns": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "noPropertyAccessFromIndexSignature": true
12
+ },
13
+ "files": [],
14
+ "include": [],
15
+ "references": [
16
+ {
17
+ "path": "./tsconfig.lib.json"
18
+ },
19
+ {
20
+ "path": "./tsconfig.spec.json"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "declaration": true,
6
+ "types": ["node"]
7
+ },
8
+ "include": ["src/**/*.ts"],
9
+ "exclude": [
10
+ "vite.config.ts",
11
+ "vite.config.mts",
12
+ "vitest.config.ts",
13
+ "vitest.config.mts",
14
+ "src/**/*.test.ts",
15
+ "src/**/*.spec.ts",
16
+ "src/**/*.test.tsx",
17
+ "src/**/*.spec.tsx",
18
+ "src/**/*.test.js",
19
+ "src/**/*.spec.js",
20
+ "src/**/*.test.jsx",
21
+ "src/**/*.spec.jsx"
22
+ ]
23
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "../../dist/out-tsc",
5
+ "types": ["vitest/globals", "vitest/importMeta", "vite/client", "node", "vitest"]
6
+ },
7
+ "include": [
8
+ "vite.config.ts",
9
+ "vite.config.mts",
10
+ "vitest.config.ts",
11
+ "vitest.config.mts",
12
+ "src/**/*.test.ts",
13
+ "src/**/*.spec.ts",
14
+ "src/**/*.test.tsx",
15
+ "src/**/*.spec.tsx",
16
+ "src/**/*.test.js",
17
+ "src/**/*.spec.js",
18
+ "src/**/*.test.jsx",
19
+ "src/**/*.spec.jsx",
20
+ "src/**/*.d.ts"
21
+ ]
22
+ }
@@ -0,0 +1,21 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
3
+ import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
4
+
5
+ export default defineConfig(() => ({
6
+ root: __dirname,
7
+ cacheDir: '../../node_modules/.vite/libs/firebase-admin',
8
+ plugins: [nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
9
+ test: {
10
+ name: 'firebase-admin',
11
+ watch: false,
12
+ globals: true,
13
+ environment: 'node',
14
+ include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
15
+ reporters: ['default'],
16
+ coverage: {
17
+ reportsDirectory: '../../coverage/libs/firebase-admin',
18
+ provider: 'v8' as const,
19
+ },
20
+ },
21
+ }));
@@ -1,4 +1,4 @@
1
- import { Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
1
+ import { Inject, Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
2
2
  import { Queue, type JobsOptions } from 'bullmq';
3
3
  import IORedis from 'ioredis';
4
4
  import { ICORE_QUEUES, type JobsMap } from '@icore/shared';
@@ -6,11 +6,23 @@ import { JOBS_REDIS_URL } from './jobs-client.tokens';
6
6
 
7
7
  @Injectable()
8
8
  export class JobsClientService implements OnModuleDestroy {
9
+ private readonly logger = new Logger(JobsClientService.name);
9
10
  private readonly connection: IORedis;
10
11
  private readonly queues = new Map<string, Queue>();
11
12
 
12
13
  constructor(@Inject(JOBS_REDIS_URL) redisUrl: string) {
13
- this.connection = new IORedis(redisUrl, { maxRetriesPerRequest: null });
14
+ this.connection = new IORedis(redisUrl, {
15
+ maxRetriesPerRequest: null,
16
+ retryStrategy: (times) => Math.min(times * 200, 5000),
17
+ });
18
+ // Without an 'error' handler ioredis throws an unhandled 'error' event and
19
+ // crashes the host process when Redis is down. Log once, keep retrying.
20
+ let warned = false;
21
+ this.connection.on('error', (err: Error) => {
22
+ if (warned) return;
23
+ warned = true;
24
+ this.logger.warn(`Redis unreachable at ${redisUrl}: ${err.message}. enqueue() will retry.`);
25
+ });
14
26
  }
15
27
 
16
28
  async enqueue<K extends keyof JobsMap>(
@@ -0,0 +1,92 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { bootstrapMicroservice } from '../bootstrap';
3
+
4
+ interface FakeApp {
5
+ listen: () => Promise<void>;
6
+ close: () => Promise<void>;
7
+ }
8
+
9
+ function makeLogger() {
10
+ return { log: vi.fn(), warn: vi.fn(), error: vi.fn() };
11
+ }
12
+
13
+ const ORIG = { ...process.env };
14
+
15
+ describe('bootstrapMicroservice', () => {
16
+ beforeEach(() => {
17
+ vi.useFakeTimers();
18
+ delete process.env['AUTH_TRANSPORT'];
19
+ delete process.env['NODE_ENV'];
20
+ });
21
+
22
+ afterEach(() => {
23
+ vi.useRealTimers();
24
+ vi.restoreAllMocks();
25
+ Object.assign(process.env, ORIG);
26
+ });
27
+
28
+ it('redis in dev: retries with a banner instead of exiting, then binds when the broker appears', async () => {
29
+ process.env['AUTH_TRANSPORT'] = 'redis';
30
+ const logger = makeLogger();
31
+ const close = vi.fn().mockResolvedValue(undefined);
32
+ let attempts = 0;
33
+ const createApp = vi.fn(async (): Promise<FakeApp> => {
34
+ attempts += 1;
35
+ return {
36
+ // first attempt fails (broker down), second succeeds
37
+ listen:
38
+ attempts < 2 ? () => Promise.reject(new Error('ECONNREFUSED')) : () => Promise.resolve(),
39
+ close,
40
+ };
41
+ });
42
+
43
+ const done = bootstrapMicroservice('AUTH', createApp, logger);
44
+ // let the first attempt fail + the retry delay elapse
45
+ await vi.advanceTimersByTimeAsync(3000);
46
+ await done;
47
+
48
+ expect(createApp).toHaveBeenCalledTimes(2);
49
+ expect(close).toHaveBeenCalledTimes(1); // failed app cleaned up before retry
50
+ expect(logger.warn).toHaveBeenCalledTimes(1);
51
+ expect(logger.warn.mock.calls[0][0]).toContain('broker unreachable');
52
+ expect(logger.log).toHaveBeenCalledTimes(1); // "listening" on success
53
+ });
54
+
55
+ it('tcp: fails fast (process.exit) rather than retrying', async () => {
56
+ process.env['AUTH_TRANSPORT'] = 'tcp';
57
+ const logger = makeLogger();
58
+ const exit = vi.spyOn(process, 'exit').mockImplementation(((): never => {
59
+ throw new Error('__exit__');
60
+ }) as never);
61
+ const createApp = vi.fn(
62
+ async (): Promise<FakeApp> => ({
63
+ listen: () => Promise.reject(new Error('EADDRINUSE')),
64
+ close: () => Promise.resolve(),
65
+ }),
66
+ );
67
+
68
+ await expect(bootstrapMicroservice('AUTH', createApp, logger)).rejects.toThrow('__exit__');
69
+ expect(exit).toHaveBeenCalledWith(1);
70
+ expect(logger.error).toHaveBeenCalledTimes(1);
71
+ expect(logger.warn).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it('production: fails fast even on a broker transport', async () => {
75
+ process.env['AUTH_TRANSPORT'] = 'redis';
76
+ process.env['NODE_ENV'] = 'production';
77
+ const logger = makeLogger();
78
+ const exit = vi.spyOn(process, 'exit').mockImplementation(((): never => {
79
+ throw new Error('__exit__');
80
+ }) as never);
81
+ const createApp = vi.fn(
82
+ async (): Promise<FakeApp> => ({
83
+ listen: () => Promise.reject(new Error('ECONNREFUSED')),
84
+ close: () => Promise.resolve(),
85
+ }),
86
+ );
87
+
88
+ await expect(bootstrapMicroservice('AUTH', createApp, logger)).rejects.toThrow('__exit__');
89
+ expect(exit).toHaveBeenCalledWith(1);
90
+ expect(logger.warn).not.toHaveBeenCalled();
91
+ });
92
+ });
@@ -33,8 +33,12 @@ describe('buildTransport', () => {
33
33
  process.env.AUTH_REDIS_URL = 'redis://localhost:6379';
34
34
  const opts = buildTransport('AUTH');
35
35
  expect(opts.transport).toBe(Transport.REDIS);
36
- const redis = opts.options as { url: string };
36
+ const redis = opts.options as { url: string; retryAttempts: number; retryDelay: number };
37
37
  expect(redis.url).toBe('redis://localhost:6379');
38
+ // Resilience: NestJS gives up (and app.listen() rejects) when retryAttempts
39
+ // is unset — an effectively-infinite retry keeps reconnecting instead.
40
+ expect(redis.retryAttempts).toBe(Number.POSITIVE_INFINITY);
41
+ expect(redis.retryDelay).toBeGreaterThan(0);
38
42
  });
39
43
 
40
44
  it('selects NATS when ${PREFIX}_TRANSPORT=nats', () => {
@@ -42,8 +46,16 @@ describe('buildTransport', () => {
42
46
  process.env.AUTH_NATS_URL = 'nats://localhost:4222,nats://localhost:4223';
43
47
  const opts = buildTransport('AUTH');
44
48
  expect(opts.transport).toBe(Transport.NATS);
45
- const nats = opts.options as { servers: string[] };
49
+ const nats = opts.options as {
50
+ servers: string[];
51
+ reconnect: boolean;
52
+ maxReconnectAttempts: number;
53
+ };
46
54
  expect(nats.servers).toEqual(['nats://localhost:4222', 'nats://localhost:4223']);
55
+ // Resilience: reconnect forever once connected (the initial connect is left
56
+ // to reject so bootstrapMicroservice() can banner + retry).
57
+ expect(nats.reconnect).toBe(true);
58
+ expect(nats.maxReconnectAttempts).toBe(-1);
47
59
  });
48
60
 
49
61
  it('throws on unknown transport', () => {