@idevconn/create-icore 0.5.0 → 0.5.2

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 (59) 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 +3 -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/auth-strategies/firebase/src/lib/__tests__/firebase-auth.contract.unit.test.ts +1 -1
  23. package/templates/libs/auth-strategies/supabase/src/lib/__tests__/supabase-auth.contract.unit.test.ts +1 -1
  24. package/templates/libs/db-strategies/firestore/src/lib/__tests__/firestore-db.contract.unit.test.ts +1 -1
  25. package/templates/libs/db-strategies/supabase/src/lib/__tests__/supabase-db.contract.unit.test.ts +1 -1
  26. package/templates/libs/firebase-admin/README.md +11 -0
  27. package/templates/libs/firebase-admin/eslint.config.mjs +24 -0
  28. package/templates/libs/firebase-admin/package.json +12 -0
  29. package/templates/libs/firebase-admin/project.json +19 -0
  30. package/templates/libs/firebase-admin/src/index.ts +1 -0
  31. package/templates/libs/firebase-admin/src/lib/__tests__/firebase-admin.unit.test.ts +105 -0
  32. package/templates/libs/firebase-admin/src/lib/firebase-admin.ts +70 -0
  33. package/templates/libs/firebase-admin/tsconfig.json +24 -0
  34. package/templates/libs/firebase-admin/tsconfig.lib.json +23 -0
  35. package/templates/libs/firebase-admin/tsconfig.spec.json +22 -0
  36. package/templates/libs/firebase-admin/vitest.config.mts +21 -0
  37. package/templates/libs/jobs-client/src/lib/jobs-client.service.ts +14 -2
  38. package/templates/libs/jobs-client/tsconfig.json +2 -1
  39. package/templates/libs/notes-client/tsconfig.json +2 -1
  40. package/templates/libs/payment-client/tsconfig.json +2 -1
  41. package/templates/libs/shared/src/__tests__/bootstrap.unit.test.ts +92 -0
  42. package/templates/libs/shared/src/__tests__/transport.unit.test.ts +14 -2
  43. package/templates/libs/shared/src/bootstrap.ts +79 -0
  44. package/templates/libs/shared/src/index.ts +1 -0
  45. package/templates/libs/shared/src/strategies/__tests__/fake-auth.contract.unit.test.ts +1 -1
  46. package/templates/libs/shared/src/strategies/__tests__/fake-db.contract.unit.test.ts +1 -1
  47. package/templates/libs/shared/src/strategies/__tests__/fake-storage.contract.unit.test.ts +1 -1
  48. package/templates/libs/shared/src/strategies/index.ts +3 -3
  49. package/templates/libs/shared/src/testing.ts +14 -0
  50. package/templates/libs/shared/src/transport.ts +25 -3
  51. package/templates/libs/shared/tsconfig.lib.json +3 -1
  52. package/templates/libs/shared/vitest.config.mts +11 -1
  53. package/templates/libs/storage-strategies/cloudinary/src/lib/__tests__/cloudinary-storage.contract.unit.test.ts +1 -1
  54. package/templates/libs/storage-strategies/firebase/src/lib/__tests__/firebase-storage.contract.unit.test.ts +1 -1
  55. package/templates/libs/storage-strategies/supabase/src/lib/__tests__/supabase-storage.contract.unit.test.ts +1 -1
  56. package/templates/tsconfig.base.json +3 -1
  57. /package/templates/libs/shared/src/strategies/{contract/auth-contract.ts → __tests__/auth.contract.unit.test.ts} +0 -0
  58. /package/templates/libs/shared/src/strategies/{contract/db-contract.ts → __tests__/db.contract.unit.test.ts} +0 -0
  59. /package/templates/libs/shared/src/strategies/{contract/storage-contract.ts → __tests__/storage.contract.unit.test.ts} +0 -0
@@ -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', () => {
@@ -0,0 +1,79 @@
1
+ import { formatEnvBanner } from './env';
2
+
3
+ interface MicroserviceLike {
4
+ listen: () => Promise<unknown>;
5
+ close: () => Promise<void>;
6
+ }
7
+
8
+ interface LoggerLike {
9
+ log: (msg: string) => void;
10
+ warn: (msg: string) => void;
11
+ error: (msg: string, trace?: unknown) => void;
12
+ }
13
+
14
+ const RETRY_DELAY_MS = 3000;
15
+
16
+ function delay(ms: number): Promise<void> {
17
+ return new Promise((resolve) => setTimeout(resolve, ms));
18
+ }
19
+
20
+ /**
21
+ * Boots a microservice and binds its transport, surviving a broker that isn't
22
+ * up yet.
23
+ *
24
+ * NestJS `ServerRedis`/`ServerNats` REJECT `app.listen()` on the *initial*
25
+ * connect failure — the ioredis retryStrategy / NATS reconnect only governs
26
+ * re-connection after a first successful connect. So without this helper a MS
27
+ * would `process.exit(1)` whenever Redis/NATS is down on boot, even though the
28
+ * transport options ask for infinite retries.
29
+ *
30
+ * Behaviour (honours the "never crash on missing infra" rule):
31
+ * - tcp transport, or NODE_ENV=production → fail fast: `process.exit(1)`.
32
+ * - redis/nats in dev → log a boxed banner and retry `listen()` forever, so
33
+ * the service idles until the broker appears, then binds automatically.
34
+ *
35
+ * `createApp` must construct a *fresh* app each call: a failed listen closes
36
+ * the transport's clients, so the next attempt needs a new instance.
37
+ */
38
+ export async function bootstrapMicroservice(
39
+ prefix: string,
40
+ createApp: () => Promise<MicroserviceLike>,
41
+ logger: LoggerLike,
42
+ ): Promise<void> {
43
+ const transport = (process.env[`${prefix}_TRANSPORT`] ?? 'tcp').toLowerCase();
44
+ const isProd = process.env['NODE_ENV'] === 'production';
45
+ const failFast = transport === 'tcp' || isProd;
46
+
47
+ for (let attempt = 1; ; attempt++) {
48
+ let app: MicroserviceLike | undefined;
49
+ try {
50
+ app = await createApp();
51
+ await app.listen();
52
+ logger.log(`${prefix} microservice listening — transport=${transport}`);
53
+ return;
54
+ } catch (err) {
55
+ if (app) await app.close().catch(() => undefined);
56
+ const reason = err instanceof Error ? err.message : String(err);
57
+
58
+ if (failFast) {
59
+ logger.error(
60
+ `${prefix} microservice bootstrap failed`,
61
+ err instanceof Error ? err.stack : err,
62
+ );
63
+ process.exit(1);
64
+ }
65
+
66
+ logger.warn(
67
+ formatEnvBanner({
68
+ service: `${prefix} microservice`,
69
+ provider: transport,
70
+ missing: [],
71
+ envPath: `the service .env (${prefix}_${transport.toUpperCase()}_URL)`,
72
+ reason: `${reason} — retry ${attempt} in ${RETRY_DELAY_MS / 1000}s; idling until the ${transport} broker is reachable`,
73
+ headline: `⚠ ${prefix} microservice — ${transport} broker unreachable (idling, not crashing)`,
74
+ }),
75
+ );
76
+ await delay(RETRY_DELAY_MS);
77
+ }
78
+ }
79
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './env';
2
+ export * from './bootstrap';
2
3
  export * from './abilities';
3
4
  export * from './jobs';
4
5
  export * from './strategies';
@@ -1,5 +1,5 @@
1
1
  import { FakeAuthStrategy } from '../fakes/fake-auth';
2
- import { runAuthContract } from '../contract/auth-contract';
2
+ import { runAuthContract } from './auth.contract.unit.test';
3
3
 
4
4
  runAuthContract('FakeAuthStrategy', () => new FakeAuthStrategy(), {
5
5
  getMagicLinkToken: (strategy, email) =>
@@ -1,4 +1,4 @@
1
1
  import { FakeDBStrategy } from '../fakes/fake-db';
2
- import { runDBContract } from '../contract/db-contract';
2
+ import { runDBContract } from './db.contract.unit.test';
3
3
 
4
4
  runDBContract('FakeDBStrategy', () => new FakeDBStrategy());
@@ -1,4 +1,4 @@
1
1
  import { FakeStorageStrategy } from '../fakes/fake-storage';
2
- import { runStorageContract } from '../contract/storage-contract';
2
+ import { runStorageContract } from './storage.contract.unit.test';
3
3
 
4
4
  runStorageContract('FakeStorageStrategy', () => new FakeStorageStrategy());
@@ -1,7 +1,7 @@
1
1
  export * from './auth';
2
2
  export * from './storage';
3
3
  export * from './db';
4
- export * from './contract/auth-contract';
5
- export * from './contract/storage-contract';
6
- export * from './contract/db-contract';
7
4
  export * from './fakes';
5
+ // NOTE: the strategy contract harness (runAuthContract / runStorageContract /
6
+ // runDBContract) is intentionally NOT exported here — it is test-only code and
7
+ // lives behind the '@icore/shared/testing' entry. See ../testing.ts.
@@ -0,0 +1,14 @@
1
+ // Test-only surface of @icore/shared.
2
+ //
3
+ // The strategy CONTRACT HARNESS lives here, NOT in the production `index.ts`:
4
+ // it uses Vitest globals (describe/it/expect) and must never compile into the
5
+ // shipped library. Import it from test files via '@icore/shared/testing'.
6
+ //
7
+ // The harness implementation sits under `strategies/__tests__/` so the prod
8
+ // build (tsconfig.lib.json excludes `__tests__`) skips it entirely.
9
+ export {
10
+ runAuthContract,
11
+ type AuthContractHelpers,
12
+ } from './strategies/__tests__/auth.contract.unit.test';
13
+ export { runStorageContract } from './strategies/__tests__/storage.contract.unit.test';
14
+ export { runDBContract } from './strategies/__tests__/db.contract.unit.test';
@@ -67,15 +67,37 @@ export function buildTransport(prefix: string): ClientOptions {
67
67
  // ioredis accepts a connection URL string; the NestJS RedisOptions type
68
68
  // exposes host/port fields but passes options directly to ioredis which
69
69
  // also accepts a url field at runtime.
70
+ //
71
+ // retryAttempts/retryDelay are mandatory for resilience: NestJS
72
+ // ServerRedis builds its ioredis retryStrategy from them, and when
73
+ // retryAttempts is unset it logs "retry attempts not specified", stops
74
+ // reconnecting, and `app.listen()` REJECTS → the MS process exits. With
75
+ // an effectively-infinite retry the initial connect stays pending and
76
+ // keeps reconnecting, so a not-yet-up Redis never crashes the service —
77
+ // it idles and attaches once Redis is reachable.
70
78
  return {
71
79
  transport: Transport.REDIS,
72
- options: { url: required(`${prefix}_REDIS_URL`) },
80
+ options: {
81
+ url: required(`${prefix}_REDIS_URL`),
82
+ retryAttempts: Number.POSITIVE_INFINITY,
83
+ retryDelay: 2000,
84
+ },
73
85
  } as unknown as ClientOptions;
74
86
  case 'nats':
87
+ // reconnect/maxReconnectAttempts: -1 retries forever once connected, so a
88
+ // dropped broker re-attaches instead of giving up. The *initial* connect
89
+ // is intentionally allowed to reject when NATS is down on boot —
90
+ // bootstrapMicroservice() catches that, logs a banner, and retries, giving
91
+ // the same visible behaviour as the Redis transport above.
75
92
  return {
76
93
  transport: Transport.NATS,
77
- options: { servers: required(`${prefix}_NATS_URL`).split(',') },
78
- };
94
+ options: {
95
+ servers: required(`${prefix}_NATS_URL`).split(','),
96
+ reconnect: true,
97
+ maxReconnectAttempts: -1,
98
+ reconnectTimeWait: 2000,
99
+ },
100
+ } as unknown as ClientOptions;
79
101
  default:
80
102
  throw new Error(`Unknown transport: ${kind}`);
81
103
  }
@@ -3,7 +3,7 @@
3
3
  "compilerOptions": {
4
4
  "outDir": "../../dist/out-tsc",
5
5
  "declaration": true,
6
- "types": ["node", "vitest/globals"]
6
+ "types": ["node"]
7
7
  },
8
8
  "include": ["src/**/*.ts"],
9
9
  "exclude": [
@@ -11,6 +11,8 @@
11
11
  "vite.config.mts",
12
12
  "vitest.config.ts",
13
13
  "vitest.config.mts",
14
+ "src/testing.ts",
15
+ "src/**/__tests__/**",
14
16
  "src/**/*.test.ts",
15
17
  "src/**/*.spec.ts",
16
18
  "src/**/*.test.tsx",
@@ -1,4 +1,4 @@
1
- import { defineConfig } from 'vitest/config';
1
+ import { defineConfig, configDefaults } from 'vitest/config';
2
2
  import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
3
3
  import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
4
4
 
@@ -12,6 +12,16 @@ export default defineConfig(() => ({
12
12
  globals: true,
13
13
  environment: 'node',
14
14
  include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
15
+ // The strategy contract harness files only EXPORT reusable suites
16
+ // (runAuthContract / runStorageContract / runDBContract) — they hold no
17
+ // top-level tests, so Vitest must not try to run them directly. They follow
18
+ // the *.unit.test.ts naming so the prod build excludes them; the concrete
19
+ // `fake-*.contract.unit.test.ts` files (and the per-provider libs) invoke
20
+ // the harness.
21
+ exclude: [
22
+ ...configDefaults.exclude,
23
+ '**/strategies/__tests__/{auth,storage,db}.contract.unit.test.ts',
24
+ ],
15
25
  reporters: ['default'],
16
26
  coverage: {
17
27
  reportsDirectory: '../../coverage/libs/shared',
@@ -1,4 +1,4 @@
1
- import { runStorageContract } from '@icore/shared';
1
+ import { runStorageContract } from '@icore/shared/testing';
2
2
  import { CloudinaryStorageStrategy } from '../cloudinary-storage.strategy.js';
3
3
  import { createMockCloudinary } from '../testing/mock-cloudinary.js';
4
4
 
@@ -1,4 +1,4 @@
1
- import { runStorageContract } from '@icore/shared';
1
+ import { runStorageContract } from '@icore/shared/testing';
2
2
  import { FirebaseStorageStrategy } from '../firebase-storage.strategy.js';
3
3
  import { createMockFirebaseBucket } from '../testing/mock-firebase-storage.js';
4
4
 
@@ -1,4 +1,4 @@
1
- import { runStorageContract } from '@icore/shared';
1
+ import { runStorageContract } from '@icore/shared/testing';
2
2
  import { SupabaseStorageStrategy } from '../supabase-storage.strategy';
3
3
  import { createMockSupabaseStorageClient } from '../testing/mock-supabase-storage';
4
4
 
@@ -14,6 +14,7 @@
14
14
  "paths": {
15
15
  "@icore/shared": ["./libs/shared/src/index.ts"],
16
16
  "@icore/shared/client": ["./libs/shared/src/client.ts"],
17
+ "@icore/shared/testing": ["./libs/shared/src/testing.ts"],
17
18
  "@icore/auth-supabase": ["./libs/auth-strategies/supabase/src/index.ts"],
18
19
  "@icore/auth-client": ["./libs/auth-client/src/index.ts"],
19
20
  "@icore/package.json": ["./package.json"],
@@ -29,7 +30,8 @@
29
30
  "@icore/payment-client": ["./libs/payment-client/src/index.ts"],
30
31
  "@icore/notes-client": ["./libs/notes-client/src/index.ts"],
31
32
  "@icore/jobs-client": ["./libs/jobs-client/src/index.ts"],
32
- "@icore/vite-plugins": ["./libs/vite-plugins/src/index.d.mts"]
33
+ "@icore/vite-plugins": ["./libs/vite-plugins/src/index.d.mts"],
34
+ "@icore/firebase-admin": ["./libs/firebase-admin/src/index.ts"]
33
35
  }
34
36
  },
35
37
  "exclude": ["node_modules", "dist", ".nx"]