@idevconn/create-icore 0.4.1 → 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 (76) hide show
  1. package/dist/cli.js +304 -24
  2. package/dist/index.cjs +301 -23
  3. package/dist/index.d.cts +7 -1
  4. package/dist/index.d.ts +7 -1
  5. package/dist/index.js +300 -23
  6. package/package.json +1 -1
  7. package/templates/apps/api/.env.example +14 -0
  8. package/templates/apps/api/src/app/app.module.ts +5 -1
  9. package/templates/apps/api/src/main.ts +12 -6
  10. package/templates/apps/microservices/auth/package.json +1 -1
  11. package/templates/apps/microservices/auth/project.json +2 -1
  12. package/templates/apps/microservices/auth/src/app/app.module.ts +50 -39
  13. package/templates/apps/microservices/auth/src/main.ts +6 -23
  14. package/templates/apps/microservices/jobs/project.json +2 -1
  15. package/templates/apps/microservices/jobs/src/app/redis-connection.ts +35 -0
  16. package/templates/apps/microservices/jobs/src/app/workers/cleanup.worker.ts +2 -1
  17. package/templates/apps/microservices/jobs/src/app/workers/email.worker.ts +2 -1
  18. package/templates/apps/microservices/jobs/src/app/workers/image-process.worker.ts +2 -1
  19. package/templates/apps/microservices/notes/project.json +2 -1
  20. package/templates/apps/microservices/notes/src/app/app.module.ts +52 -38
  21. package/templates/apps/microservices/notes/src/main.ts +6 -23
  22. package/templates/apps/microservices/payment/project.json +2 -1
  23. package/templates/apps/microservices/payment/src/app/app.module.ts +37 -12
  24. package/templates/apps/microservices/payment/src/main.ts +6 -23
  25. package/templates/apps/microservices/upload/package.json +1 -1
  26. package/templates/apps/microservices/upload/project.json +2 -1
  27. package/templates/apps/microservices/upload/src/app/app.module.ts +50 -42
  28. package/templates/apps/microservices/upload/src/main.ts +6 -23
  29. package/templates/apps/templates/client-antd/.env.example +7 -0
  30. package/templates/apps/templates/client-antd/vite.config.mts +4 -4
  31. package/templates/apps/templates/client-mui/.env.example +7 -0
  32. package/templates/apps/templates/client-mui/vite.config.mts +4 -4
  33. package/templates/apps/templates/client-shadcn/.env.example +6 -1
  34. package/templates/apps/templates/client-shadcn/vite.config.mts +4 -4
  35. package/templates/libs/auth-client/src/index.ts +1 -0
  36. package/templates/libs/auth-client/src/lib/auth-client.module.ts +1 -1
  37. package/templates/libs/auth-client/src/lib/auth-client.service.ts +1 -1
  38. package/templates/libs/auth-client/src/lib/auth-client.tokens.ts +4 -0
  39. package/templates/libs/firebase-admin/README.md +11 -0
  40. package/templates/libs/firebase-admin/eslint.config.mjs +24 -0
  41. package/templates/libs/firebase-admin/package.json +12 -0
  42. package/templates/libs/firebase-admin/project.json +19 -0
  43. package/templates/libs/firebase-admin/src/index.ts +1 -0
  44. package/templates/libs/firebase-admin/src/lib/__tests__/firebase-admin.unit.test.ts +105 -0
  45. package/templates/libs/firebase-admin/src/lib/firebase-admin.ts +70 -0
  46. package/templates/libs/firebase-admin/tsconfig.json +23 -0
  47. package/templates/libs/firebase-admin/tsconfig.lib.json +23 -0
  48. package/templates/libs/firebase-admin/tsconfig.spec.json +22 -0
  49. package/templates/libs/firebase-admin/vitest.config.mts +21 -0
  50. package/templates/libs/jobs-client/src/index.ts +1 -0
  51. package/templates/libs/jobs-client/src/lib/jobs-client.module.ts +1 -1
  52. package/templates/libs/jobs-client/src/lib/jobs-client.service.ts +15 -3
  53. package/templates/libs/jobs-client/src/lib/jobs-client.tokens.ts +4 -0
  54. package/templates/libs/notes-client/src/index.ts +1 -0
  55. package/templates/libs/notes-client/src/lib/notes-client.module.ts +1 -1
  56. package/templates/libs/notes-client/src/lib/notes-client.service.ts +1 -1
  57. package/templates/libs/notes-client/src/lib/notes-client.tokens.ts +4 -0
  58. package/templates/libs/payment-client/src/index.ts +1 -0
  59. package/templates/libs/payment-client/src/lib/payment-client.module.ts +1 -1
  60. package/templates/libs/payment-client/src/lib/payment-client.service.ts +1 -1
  61. package/templates/libs/payment-client/src/lib/payment-client.tokens.ts +4 -0
  62. package/templates/libs/shared/src/__tests__/bootstrap.unit.test.ts +92 -0
  63. package/templates/libs/shared/src/__tests__/transport.unit.test.ts +14 -2
  64. package/templates/libs/shared/src/bootstrap.ts +79 -0
  65. package/templates/libs/shared/src/env.ts +88 -0
  66. package/templates/libs/shared/src/index.ts +2 -0
  67. package/templates/libs/shared/src/transport.ts +62 -3
  68. package/templates/libs/upload-client/src/index.ts +1 -0
  69. package/templates/libs/upload-client/src/lib/upload-client.module.ts +1 -1
  70. package/templates/libs/upload-client/src/lib/upload-client.service.ts +1 -1
  71. package/templates/libs/upload-client/src/lib/upload-client.tokens.ts +4 -0
  72. package/templates/libs/vite-plugins/src/index.d.mts +6 -0
  73. package/templates/libs/vite-plugins/src/index.mjs +50 -0
  74. package/templates/package.json +1 -0
  75. package/templates/tools/create-icore/_template-shell/package.json +1 -0
  76. package/templates/tsconfig.base.json +2 -1
@@ -2,7 +2,7 @@ import { DynamicModule, Module } from '@nestjs/common';
2
2
  import { ConfigService } from '@nestjs/config';
3
3
  import { JobsClientService } from './jobs-client.service';
4
4
 
5
- export const JOBS_REDIS_URL = 'JOBS_REDIS_URL';
5
+ import { JOBS_REDIS_URL } from './jobs-client.tokens';
6
6
 
7
7
  @Module({})
8
8
  export class JobsClientModule {
@@ -1,16 +1,28 @@
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';
5
- import { JOBS_REDIS_URL } from './jobs-client.module';
5
+ 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,4 @@
1
+ // Injection token — kept in its own file so module and service can both
2
+ // import it without creating a circular dependency (which breaks DI in
3
+ // webpack-bundled NestJS apps).
4
+ export const JOBS_REDIS_URL = 'JOBS_REDIS_URL';
@@ -1,2 +1,3 @@
1
+ export * from './lib/notes-client.tokens';
1
2
  export * from './lib/notes-client.module';
2
3
  export * from './lib/notes-client.service';
@@ -3,7 +3,7 @@ import { ClientsModule } from '@nestjs/microservices';
3
3
  import { buildTransport } from '@icore/shared';
4
4
  import { NotesClientService } from './notes-client.service';
5
5
 
6
- export const NOTES_CLIENT = 'NOTES_CLIENT';
6
+ import { NOTES_CLIENT } from './notes-client.tokens';
7
7
 
8
8
  @Module({})
9
9
  export class NotesClientModule {
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
2
2
  import { ClientProxy } from '@nestjs/microservices';
3
3
  import { firstValueFrom } from 'rxjs';
4
4
  import type { ListNotesOptions, Note } from '@icore/shared';
5
- import { NOTES_CLIENT } from './notes-client.module';
5
+ import { NOTES_CLIENT } from './notes-client.tokens';
6
6
 
7
7
  @Injectable()
8
8
  export class NotesClientService {
@@ -0,0 +1,4 @@
1
+ // Injection token — kept in its own file so module and service can both
2
+ // import it without creating a circular dependency (which breaks DI in
3
+ // webpack-bundled NestJS apps).
4
+ export const NOTES_CLIENT = 'NOTES_CLIENT';
@@ -1,2 +1,3 @@
1
+ export * from './lib/payment-client.tokens';
1
2
  export * from './lib/payment-client.module';
2
3
  export * from './lib/payment-client.service';
@@ -3,7 +3,7 @@ import { ClientsModule } from '@nestjs/microservices';
3
3
  import { buildTransport } from '@icore/shared';
4
4
  import { PaymentClientService } from './payment-client.service';
5
5
 
6
- export const PAYMENT_CLIENT = 'PAYMENT_CLIENT';
6
+ import { PAYMENT_CLIENT } from './payment-client.tokens';
7
7
 
8
8
  @Module({})
9
9
  export class PaymentClientModule {
@@ -7,7 +7,7 @@ import type {
7
7
  OrderResult,
8
8
  RequestOptions,
9
9
  } from '@idevconn/payment';
10
- import { PAYMENT_CLIENT } from './payment-client.module';
10
+ import { PAYMENT_CLIENT } from './payment-client.tokens';
11
11
 
12
12
  @Injectable()
13
13
  export class PaymentClientService {
@@ -0,0 +1,4 @@
1
+ // Injection token — kept in its own file so module and service can both
2
+ // import it without creating a circular dependency (which breaks DI in
3
+ // webpack-bundled NestJS apps).
4
+ export const PAYMENT_CLIENT = 'PAYMENT_CLIENT';
@@ -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
+ }
@@ -0,0 +1,88 @@
1
+ // Server-side env helpers. NOT exported from ./client (browser-safe entry) —
2
+ // generated microservices import these to report which provider credentials
3
+ // are missing on startup instead of crashing.
4
+
5
+ /**
6
+ * Returns the keys whose value is missing or blank.
7
+ * @param get accessor (e.g. ConfigService.get bound to the MS config)
8
+ * @param keys env var names required for the selected provider
9
+ */
10
+ export function missingEnv(get: (key: string) => string | undefined, keys: string[]): string[] {
11
+ return keys.filter((k) => !get(k)?.trim());
12
+ }
13
+
14
+ /**
15
+ * Builds an eye-catching boxed banner listing the env vars a microservice
16
+ * needs but is missing, so it stands out in the `yarn dev` log noise.
17
+ */
18
+ export function formatEnvBanner(opts: {
19
+ service: string;
20
+ provider: string | undefined;
21
+ missing: string[];
22
+ envPath: string;
23
+ reason?: string;
24
+ /** Override the first line. Defaults to the in-memory-fake warning. */
25
+ headline?: string;
26
+ }): string {
27
+ const { service, provider, missing, envPath, reason, headline } = opts;
28
+ const lines: string[] = [];
29
+ lines.push(headline ?? `⚠ ${service} — running with an IN-MEMORY FAKE (requests will fail)`);
30
+ lines.push('');
31
+ if (!provider) {
32
+ lines.push(`Provider env var is not set.`);
33
+ } else if (missing.length > 0) {
34
+ lines.push(`"${provider}" needs these env vars, currently missing:`);
35
+ for (const k of missing) lines.push(` • ${k}`);
36
+ } else if (reason) {
37
+ // Vars are present but invalid (e.g. placeholder URL the SDK rejected).
38
+ lines.push(`"${provider}" failed to initialise:`);
39
+ lines.push(` ${reason}`);
40
+ }
41
+ lines.push('');
42
+ lines.push(`Set real values in: ${envPath}`);
43
+
44
+ const width = Math.max(...lines.map((l) => l.length), 50);
45
+ const top = `╔═${'═'.repeat(width)}═╗`;
46
+ const bot = `╚═${'═'.repeat(width)}═╝`;
47
+ const body = lines.map((l) => `║ ${l.padEnd(width)} ║`).join('\n');
48
+ return `\n${top}\n${body}\n${bot}`;
49
+ }
50
+
51
+ /**
52
+ * Returns a boxed startup info banner listing every MS the gateway will
53
+ * connect to, reading the current transport env vars.
54
+ */
55
+ export function formatGatewayBanner(opts: {
56
+ port: number;
57
+ origin: string;
58
+ services: Array<{ name: string; prefix: string }>;
59
+ }): string {
60
+ const { port, origin, services } = opts;
61
+ const lines: string[] = [];
62
+ lines.push(`Gateway listening on ${origin}:${port}/api`);
63
+ lines.push(`Swagger UI ${origin}:${port}/api/docs`);
64
+ lines.push('');
65
+ lines.push('Microservice transports:');
66
+ for (const { name, prefix } of services) {
67
+ const kind = (process.env[`${prefix}_TRANSPORT`] ?? 'tcp').toLowerCase();
68
+ let target: string;
69
+ if (kind === 'tcp') {
70
+ const host = process.env[`${prefix}_HOST`] ?? '127.0.0.1';
71
+ const port_ = process.env[`${prefix}_PORT`] ?? '?';
72
+ target = `tcp ${host}:${port_}`;
73
+ } else if (kind === 'redis') {
74
+ target = `redis ${process.env[`${prefix}_REDIS_URL`] ?? '(REDIS_URL not set)'}`;
75
+ } else if (kind === 'nats') {
76
+ target = `nats ${process.env[`${prefix}_NATS_URL`] ?? '(NATS_URL not set)'}`;
77
+ } else {
78
+ target = kind;
79
+ }
80
+ lines.push(` ${name.padEnd(10)} → ${target}`);
81
+ }
82
+
83
+ const width = Math.max(...lines.map((l) => l.length), 50);
84
+ const top = `╔═${'═'.repeat(width)}═╗`;
85
+ const bot = `╚═${'═'.repeat(width)}═╝`;
86
+ const body = lines.map((l) => `║ ${l.padEnd(width)} ║`).join('\n');
87
+ return `\n${top}\n${body}\n${bot}`;
88
+ }
@@ -1,3 +1,5 @@
1
+ export * from './env';
2
+ export * from './bootstrap';
1
3
  export * from './abilities';
2
4
  export * from './jobs';
3
5
  export * from './strategies';
@@ -1,4 +1,40 @@
1
1
  import { Transport, type ClientOptions, type MicroserviceOptions } from '@nestjs/microservices';
2
+ import { formatEnvBanner } from './env';
3
+
4
+ // Transport vars each kind needs (besides ${prefix}_TRANSPORT itself).
5
+ function transportKeys(prefix: string, kind: string): string[] {
6
+ switch (kind) {
7
+ case 'tcp':
8
+ return [`${prefix}_HOST`, `${prefix}_PORT`];
9
+ case 'redis':
10
+ return [`${prefix}_REDIS_URL`];
11
+ case 'nats':
12
+ return [`${prefix}_NATS_URL`];
13
+ default:
14
+ return [];
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Throws an eye-catching banner if any transport var for `prefix` is missing.
20
+ * Used by both the gateway client modules and each MS bootstrap, so transport
21
+ * misconfiguration surfaces clearly instead of a raw "Missing env var".
22
+ */
23
+ function assertTransportEnv(prefix: string, kind: string): void {
24
+ const keys = transportKeys(prefix, kind);
25
+ const missing = keys.filter((k) => !process.env[k]?.trim());
26
+ if (missing.length > 0) {
27
+ throw new Error(
28
+ formatEnvBanner({
29
+ service: `${prefix} transport`,
30
+ provider: kind,
31
+ missing,
32
+ envPath: `the service .env (${prefix}_* transport vars)`,
33
+ headline: `⚠ ${prefix} transport (${kind}) not configured — cannot reach the microservice`,
34
+ }),
35
+ );
36
+ }
37
+ }
2
38
 
3
39
  function required(name: string): string {
4
40
  const value = process.env[name];
@@ -17,6 +53,7 @@ function requiredPort(name: string): number {
17
53
 
18
54
  export function buildTransport(prefix: string): ClientOptions {
19
55
  const kind = (process.env[`${prefix}_TRANSPORT`] ?? 'tcp').toLowerCase();
56
+ assertTransportEnv(prefix, kind);
20
57
  switch (kind) {
21
58
  case 'tcp':
22
59
  return {
@@ -30,15 +67,37 @@ export function buildTransport(prefix: string): ClientOptions {
30
67
  // ioredis accepts a connection URL string; the NestJS RedisOptions type
31
68
  // exposes host/port fields but passes options directly to ioredis which
32
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.
33
78
  return {
34
79
  transport: Transport.REDIS,
35
- options: { url: required(`${prefix}_REDIS_URL`) },
80
+ options: {
81
+ url: required(`${prefix}_REDIS_URL`),
82
+ retryAttempts: Number.POSITIVE_INFINITY,
83
+ retryDelay: 2000,
84
+ },
36
85
  } as unknown as ClientOptions;
37
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.
38
92
  return {
39
93
  transport: Transport.NATS,
40
- options: { servers: required(`${prefix}_NATS_URL`).split(',') },
41
- };
94
+ options: {
95
+ servers: required(`${prefix}_NATS_URL`).split(','),
96
+ reconnect: true,
97
+ maxReconnectAttempts: -1,
98
+ reconnectTimeWait: 2000,
99
+ },
100
+ } as unknown as ClientOptions;
42
101
  default:
43
102
  throw new Error(`Unknown transport: ${kind}`);
44
103
  }
@@ -1,2 +1,3 @@
1
+ export * from './lib/upload-client.tokens';
1
2
  export * from './lib/upload-client.module';
2
3
  export * from './lib/upload-client.service';
@@ -3,7 +3,7 @@ import { ClientsModule } from '@nestjs/microservices';
3
3
  import { buildTransport } from '@icore/shared';
4
4
  import { UploadClientService } from './upload-client.service';
5
5
 
6
- export const UPLOAD_CLIENT = 'UPLOAD_CLIENT';
6
+ import { UPLOAD_CLIENT } from './upload-client.tokens';
7
7
 
8
8
  @Module({})
9
9
  export class UploadClientModule {
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
2
2
  import { ClientProxy } from '@nestjs/microservices';
3
3
  import { firstValueFrom } from 'rxjs';
4
4
  import type { StorageRef } from '@icore/shared';
5
- import { UPLOAD_CLIENT } from './upload-client.module';
5
+ import { UPLOAD_CLIENT } from './upload-client.tokens';
6
6
 
7
7
  @Injectable()
8
8
  export class UploadClientService {
@@ -0,0 +1,4 @@
1
+ // Injection token — kept in its own file so module and service can both
2
+ // import it without creating a circular dependency (which breaks DI in
3
+ // webpack-bundled NestJS apps).
4
+ export const UPLOAD_CLIENT = 'UPLOAD_CLIENT';
@@ -19,3 +19,9 @@ export declare function commonTestConfig(
19
19
  name: string,
20
20
  coverageDir: string,
21
21
  ): NonNullable<UserConfig['test']>;
22
+
23
+ export declare function commonServer(
24
+ port: number,
25
+ ): NonNullable<import('vite').UserConfig['server']>;
26
+
27
+ export declare function apiInfoPlugin(opts?: { proxyTarget?: string }): import('vite').Plugin;
@@ -104,3 +104,53 @@ export function commonTestConfig(name, coverageDir) {
104
104
  },
105
105
  };
106
106
  }
107
+
108
+ /**
109
+ * Shared Vite dev-server config: binds the given port and proxies `/api`
110
+ * to the gateway (:3001) so the client's relative API base works in dev.
111
+ * @param {number} port
112
+ * @returns {import('vite').UserConfig['server']}
113
+ */
114
+ export function commonServer(port) {
115
+ return {
116
+ port,
117
+ host: 'localhost',
118
+ proxy: { '/api': { target: 'http://localhost:3001', changeOrigin: true } },
119
+ };
120
+ }
121
+
122
+ function box(lines) {
123
+ const width = Math.max(...lines.map((l) => l.length), 48);
124
+ const top = `╔═${'═'.repeat(width)}═╗`;
125
+ const bot = `╚═${'═'.repeat(width)}═╝`;
126
+ const body = lines.map((l) => `║ ${l.padEnd(width)} ║`).join('\n');
127
+ return `\n${top}\n${body}\n${bot}`;
128
+ }
129
+
130
+ /**
131
+ * Prints a terminal banner on dev-server start showing the API base the client
132
+ * will use and the gateway proxy target, so misconfiguration is obvious.
133
+ * @param {{ proxyTarget?: string }} [opts]
134
+ * @returns {import('vite').Plugin}
135
+ */
136
+ export function apiInfoPlugin(opts = {}) {
137
+ const target = opts.proxyTarget ?? 'http://localhost:3001';
138
+ return {
139
+ name: 'icore-api-info',
140
+ apply: 'serve',
141
+ configureServer(server) {
142
+ server.httpServer?.once('listening', () => {
143
+ const explicit = process.env.VITE_API_URL;
144
+ const lines = explicit
145
+ ? [`API base: VITE_API_URL = ${explicit}`, '', `(gateway dev-proxy is bypassed)`]
146
+ : [
147
+ `API base: /api → proxied to ${target}`,
148
+ '',
149
+ `Gateway must be running on ${target}.`,
150
+ `Override with VITE_API_URL in the client .env.`,
151
+ ];
152
+ server.config.logger.info(box([`ℹ iCore client API wiring`, '', ...lines]));
153
+ });
154
+ },
155
+ };
156
+ }
@@ -23,6 +23,7 @@
23
23
  "prepare": "husky"
24
24
  },
25
25
  "devDependencies": {
26
+ "@icore/vite-plugins": "workspace:*",
26
27
  "@eslint/js": "^9.18.0",
27
28
  "@nestjs/schematics": "^11.0.0",
28
29
  "@nestjs/testing": "^11.0.0",
@@ -23,6 +23,7 @@
23
23
  "prepare": "husky"
24
24
  },
25
25
  "devDependencies": {
26
+ "@icore/vite-plugins": "workspace:*",
26
27
  "@eslint/js": "^9.18.0",
27
28
  "@nestjs/schematics": "^11.0.0",
28
29
  "@nestjs/testing": "^11.0.0",
@@ -29,7 +29,8 @@
29
29
  "@icore/payment-client": ["./libs/payment-client/src/index.ts"],
30
30
  "@icore/notes-client": ["./libs/notes-client/src/index.ts"],
31
31
  "@icore/jobs-client": ["./libs/jobs-client/src/index.ts"],
32
- "@icore/vite-plugins": ["./libs/vite-plugins/src/index.d.mts"]
32
+ "@icore/vite-plugins": ["./libs/vite-plugins/src/index.d.mts"],
33
+ "@icore/firebase-admin": ["./libs/firebase-admin/src/index.ts"]
33
34
  }
34
35
  },
35
36
  "exclude": ["node_modules", "dist", ".nx"]