@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.
- package/dist/cli.js +304 -24
- package/dist/index.cjs +301 -23
- package/dist/index.d.cts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +300 -23
- package/package.json +1 -1
- package/templates/apps/api/.env.example +14 -0
- package/templates/apps/api/src/app/app.module.ts +5 -1
- package/templates/apps/api/src/main.ts +12 -6
- package/templates/apps/microservices/auth/package.json +1 -1
- package/templates/apps/microservices/auth/project.json +2 -1
- package/templates/apps/microservices/auth/src/app/app.module.ts +50 -39
- package/templates/apps/microservices/auth/src/main.ts +6 -23
- package/templates/apps/microservices/jobs/project.json +2 -1
- package/templates/apps/microservices/jobs/src/app/redis-connection.ts +35 -0
- package/templates/apps/microservices/jobs/src/app/workers/cleanup.worker.ts +2 -1
- package/templates/apps/microservices/jobs/src/app/workers/email.worker.ts +2 -1
- package/templates/apps/microservices/jobs/src/app/workers/image-process.worker.ts +2 -1
- package/templates/apps/microservices/notes/project.json +2 -1
- package/templates/apps/microservices/notes/src/app/app.module.ts +52 -38
- package/templates/apps/microservices/notes/src/main.ts +6 -23
- package/templates/apps/microservices/payment/project.json +2 -1
- package/templates/apps/microservices/payment/src/app/app.module.ts +37 -12
- package/templates/apps/microservices/payment/src/main.ts +6 -23
- package/templates/apps/microservices/upload/package.json +1 -1
- package/templates/apps/microservices/upload/project.json +2 -1
- package/templates/apps/microservices/upload/src/app/app.module.ts +50 -42
- package/templates/apps/microservices/upload/src/main.ts +6 -23
- package/templates/apps/templates/client-antd/.env.example +7 -0
- package/templates/apps/templates/client-antd/vite.config.mts +4 -4
- package/templates/apps/templates/client-mui/.env.example +7 -0
- package/templates/apps/templates/client-mui/vite.config.mts +4 -4
- package/templates/apps/templates/client-shadcn/.env.example +6 -1
- package/templates/apps/templates/client-shadcn/vite.config.mts +4 -4
- package/templates/libs/auth-client/src/index.ts +1 -0
- package/templates/libs/auth-client/src/lib/auth-client.module.ts +1 -1
- package/templates/libs/auth-client/src/lib/auth-client.service.ts +1 -1
- package/templates/libs/auth-client/src/lib/auth-client.tokens.ts +4 -0
- package/templates/libs/firebase-admin/README.md +11 -0
- package/templates/libs/firebase-admin/eslint.config.mjs +24 -0
- package/templates/libs/firebase-admin/package.json +12 -0
- package/templates/libs/firebase-admin/project.json +19 -0
- package/templates/libs/firebase-admin/src/index.ts +1 -0
- package/templates/libs/firebase-admin/src/lib/__tests__/firebase-admin.unit.test.ts +105 -0
- package/templates/libs/firebase-admin/src/lib/firebase-admin.ts +70 -0
- package/templates/libs/firebase-admin/tsconfig.json +23 -0
- package/templates/libs/firebase-admin/tsconfig.lib.json +23 -0
- package/templates/libs/firebase-admin/tsconfig.spec.json +22 -0
- package/templates/libs/firebase-admin/vitest.config.mts +21 -0
- package/templates/libs/jobs-client/src/index.ts +1 -0
- package/templates/libs/jobs-client/src/lib/jobs-client.module.ts +1 -1
- package/templates/libs/jobs-client/src/lib/jobs-client.service.ts +15 -3
- package/templates/libs/jobs-client/src/lib/jobs-client.tokens.ts +4 -0
- package/templates/libs/notes-client/src/index.ts +1 -0
- package/templates/libs/notes-client/src/lib/notes-client.module.ts +1 -1
- package/templates/libs/notes-client/src/lib/notes-client.service.ts +1 -1
- package/templates/libs/notes-client/src/lib/notes-client.tokens.ts +4 -0
- package/templates/libs/payment-client/src/index.ts +1 -0
- package/templates/libs/payment-client/src/lib/payment-client.module.ts +1 -1
- package/templates/libs/payment-client/src/lib/payment-client.service.ts +1 -1
- package/templates/libs/payment-client/src/lib/payment-client.tokens.ts +4 -0
- package/templates/libs/shared/src/__tests__/bootstrap.unit.test.ts +92 -0
- package/templates/libs/shared/src/__tests__/transport.unit.test.ts +14 -2
- package/templates/libs/shared/src/bootstrap.ts +79 -0
- package/templates/libs/shared/src/env.ts +88 -0
- package/templates/libs/shared/src/index.ts +2 -0
- package/templates/libs/shared/src/transport.ts +62 -3
- package/templates/libs/upload-client/src/index.ts +1 -0
- package/templates/libs/upload-client/src/lib/upload-client.module.ts +1 -1
- package/templates/libs/upload-client/src/lib/upload-client.service.ts +1 -1
- package/templates/libs/upload-client/src/lib/upload-client.tokens.ts +4 -0
- package/templates/libs/vite-plugins/src/index.d.mts +6 -0
- package/templates/libs/vite-plugins/src/index.mjs +50 -0
- package/templates/package.json +1 -0
- package/templates/tools/create-icore/_template-shell/package.json +1 -0
- 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
|
-
|
|
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.
|
|
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, {
|
|
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>(
|
|
@@ -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
|
-
|
|
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.
|
|
5
|
+
import { NOTES_CLIENT } from './notes-client.tokens';
|
|
6
6
|
|
|
7
7
|
@Injectable()
|
|
8
8
|
export class NotesClientService {
|
|
@@ -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
|
-
|
|
6
|
+
import { PAYMENT_CLIENT } from './payment-client.tokens';
|
|
7
7
|
|
|
8
8
|
@Module({})
|
|
9
9
|
export class PaymentClientModule {
|
|
@@ -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 {
|
|
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,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: {
|
|
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: {
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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.
|
|
5
|
+
import { UPLOAD_CLIENT } from './upload-client.tokens';
|
|
6
6
|
|
|
7
7
|
@Injectable()
|
|
8
8
|
export class UploadClientService {
|
|
@@ -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
|
+
}
|
package/templates/package.json
CHANGED
|
@@ -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"]
|