@zaaxch/tailframe 4.0.4 → 4.0.6
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/package.json +1 -1
- package/src/new.mjs +63 -16
- package/src/owned-sources.mjs +58 -1
- package/src/service-templates.mjs +148 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.6",
|
|
4
4
|
"description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/new.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
healthRoutesSource,
|
|
19
19
|
healthSchemasSource,
|
|
20
20
|
httpErrorsSource,
|
|
21
|
+
observabilitySource,
|
|
21
22
|
mongoReadinessProbeSource,
|
|
22
23
|
postgresReadinessProbeSource,
|
|
23
24
|
readinessProbeSource,
|
|
@@ -221,6 +222,7 @@ const svcDeps = {
|
|
|
221
222
|
joi: "^17.13.3",
|
|
222
223
|
"reflect-metadata": "^0.2.2",
|
|
223
224
|
tsyringe: "^4.9.1",
|
|
225
|
+
winston: "^3.19.0",
|
|
224
226
|
...(postgres ? { pg: "^8.16.3" } : { mongodb: "^6.17.0" }),
|
|
225
227
|
...(options.auth === "firebase" ? { "firebase-admin": "^13.0.0" } : {}),
|
|
226
228
|
...(options.redis ? { "rate-limiter-flexible": "^11.2.0", redis: "^5.10.0" } : {})
|
|
@@ -290,7 +292,7 @@ export const env = {
|
|
|
290
292
|
firebaseServiceAccountPath: process.env.FIREBASE_SERVICE_ACCOUNT_PATH,` : ""}
|
|
291
293
|
${postgres ? `postgresUri: process.env.POSTGRES_URI ?? "postgresql://${options.name}-app:development@localhost:5432/${databaseName}",` : `mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
|
|
292
294
|
mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",`}
|
|
293
|
-
${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379"
|
|
295
|
+
${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379"` : ""}
|
|
294
296
|
};`);
|
|
295
297
|
const databaseSource = postgres ? `import { Pool } from "pg";
|
|
296
298
|
import { env } from "@/platform/config/env";
|
|
@@ -331,6 +333,7 @@ if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
|
|
|
331
333
|
add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
|
|
332
334
|
add(`${svc}/src/core/UseCase.ts`, useCaseSource);
|
|
333
335
|
add(`${svc}/src/core/errors.ts`, appErrorSource);
|
|
336
|
+
add(`${svc}/src/app/observability.ts`, observabilitySource);
|
|
334
337
|
add(`${svc}/src/platform/http/createRequestContext.ts`, createRequestContextSource(options.auth));
|
|
335
338
|
if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, firebaseSource);
|
|
336
339
|
add(`${svc}/src/platform/http/rpc.ts`, rpcSource);
|
|
@@ -355,15 +358,36 @@ if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimp
|
|
|
355
358
|
add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
|
|
356
359
|
add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
|
|
357
360
|
add(`${svc}/src/__tests__/testDb.ts`, postgres ? `import { Pool } from "pg";
|
|
358
|
-
const pool = new Pool({
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
export async function
|
|
361
|
+
const pool = new Pool({
|
|
362
|
+
connectionString: process.env.TEST_POSTGRES_URI ?? "postgresql://postgres:postgres@localhost:5433/${databaseName}_test"
|
|
363
|
+
});
|
|
364
|
+
export async function openTestDatabase() {
|
|
365
|
+
await pool.query("SELECT 1");
|
|
366
|
+
return pool;
|
|
367
|
+
}
|
|
368
|
+
export async function resetTestDatabase() {
|
|
369
|
+
await pool.query("DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public");
|
|
370
|
+
return pool;
|
|
371
|
+
}
|
|
372
|
+
export async function closeTestDatabase() {
|
|
373
|
+
await pool.end();
|
|
374
|
+
}
|
|
362
375
|
` : `import { MongoClient } from "mongodb";
|
|
363
|
-
const client = new MongoClient(
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
export async function
|
|
376
|
+
const client = new MongoClient(
|
|
377
|
+
process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true"
|
|
378
|
+
);
|
|
379
|
+
export async function openTestDatabase() {
|
|
380
|
+
await client.connect();
|
|
381
|
+
return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test");
|
|
382
|
+
}
|
|
383
|
+
export async function resetTestDatabase() {
|
|
384
|
+
const database = await openTestDatabase();
|
|
385
|
+
await database.dropDatabase();
|
|
386
|
+
return database;
|
|
387
|
+
}
|
|
388
|
+
export async function closeTestDatabase() {
|
|
389
|
+
await client.close();
|
|
390
|
+
}
|
|
367
391
|
`);
|
|
368
392
|
add(`${svc}/docker-compose.test.yml`, postgres ? `name: ${options.name}-test
|
|
369
393
|
services:
|
|
@@ -465,13 +489,20 @@ ${csrf ? `
|
|
|
465
489
|
` : ""}${options.ui ? `
|
|
466
490
|
it("serves the production SPA without falling through for API paths", async () => {
|
|
467
491
|
const app = createApplication({ production: true, publicPath: path.join(__dirname, "fixtures/public") });
|
|
468
|
-
await request(app)
|
|
492
|
+
await request(app)
|
|
493
|
+
.get("/dashboard")
|
|
494
|
+
.expect(200)
|
|
495
|
+
.expect(({ text }) => expect(text).toContain("boundary-test-spa"));
|
|
469
496
|
await request(app).get("/api/v1/does-not-exist").expect(404);
|
|
470
|
-
})
|
|
471
|
-
` : ""}
|
|
497
|
+
});` : ""}
|
|
472
498
|
});
|
|
473
499
|
`);
|
|
474
|
-
if (options.ui) add(`${svc}/src/app/__tests__/fixtures/public/index.html`, `<!doctype html
|
|
500
|
+
if (options.ui) add(`${svc}/src/app/__tests__/fixtures/public/index.html`, `<!doctype html>
|
|
501
|
+
<html>
|
|
502
|
+
<body>
|
|
503
|
+
<div id="app">boundary-test-spa</div>
|
|
504
|
+
</body>
|
|
505
|
+
</html>`);
|
|
475
506
|
add(`${svc}/AGENTS.md`, `# ${title} service guidance
|
|
476
507
|
|
|
477
508
|
## Scope
|
|
@@ -480,7 +511,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
|
|
|
480
511
|
## Architecture vocabulary
|
|
481
512
|
- Use \`src/app\` for process assembly, server creation, route mounting, and dependency registration.
|
|
482
513
|
- Use \`src/core\` only for small technology-neutral contracts such as \`RequestContext\` and \`UseCase\`.
|
|
483
|
-
- Use \`src/platform\` for technical mechanisms such as Express, authentication, configuration, databases, Redis, and shared HTTP behavior.
|
|
514
|
+
- Use \`src/platform\` for technical mechanisms such as Express, authentication, configuration, databases, Redis, structured logging, and shared HTTP behavior.
|
|
484
515
|
- Use \`src/modules/<module>\` for product capabilities.
|
|
485
516
|
- These are the only service architecture roots. Keep \`src/core\` flat. Put external-system adapters under \`src/platform/integrations/<provider>\`; directories immediately under \`src/app\` are limited to \`cli\`, \`jobs\`, \`mcp\`, \`scheduled-tasks\`, \`workers\`, and \`__tests__\`.
|
|
486
517
|
- Inside a module, use \`use-cases/\`, \`http/\`, and tests first. Add \`domain/\` or \`persistence/\` only when behavior requires them.
|
|
@@ -496,6 +527,7 @@ These conventions apply to \`${svc}\`. This service is a modular monolith organi
|
|
|
496
527
|
| Express route or request schema | Owning module's \`http\` |
|
|
497
528
|
| Vendor or external-system adapter | \`src/platform/integrations/<provider>\` |
|
|
498
529
|
| Database, Redis, authentication, or HTTP mechanism | \`src/platform\` |
|
|
530
|
+
| Product-specific Winston transport | \`src/app/observability.ts\` |
|
|
499
531
|
| Process lifecycle, registration, schedule, or consumer | \`src/app/<entry-point-kind>\` |
|
|
500
532
|
| Cross-entry-point technology-neutral contract | Flat \`src/core\` |
|
|
501
533
|
| Root executable | Bootstrap of matching \`src/app\` assembly only |
|
|
@@ -544,6 +576,7 @@ ${postgres
|
|
|
544
576
|
- Keep optional entry-point assembly under \`src/app/<entry-point-kind>\`; root files such as \`src/server.ts\` and \`src/worker.ts\` may only bootstrap that assembly.
|
|
545
577
|
- Initialize infrastructure first, then call \`registerDependencies(...)\` from every process entry point. The composition root resets the container, explicitly constructs dependencies, and registers class-token instances. Keep tsyringe, injection tokens, decorators, and container resolution out of modules and platform adapters.
|
|
546
578
|
- Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
|
|
579
|
+
- The canonical Winston JSON console transport logs every HTTP failure with safe request metadata. Add product-specific transports only in \`src/app/observability.ts\`; do not remove, silence, or reconfigure the canonical transport. Preserve diagnostic causes on translated errors without exposing them in client envelopes.
|
|
547
580
|
- Scheduled work prevents unintended overlap, stops intake before shutdown, awaits the active run, destroys its scheduler or consumer, and only then closes Redis and database clients.
|
|
548
581
|
${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
|
|
549
582
|
|
|
@@ -654,8 +687,22 @@ import NotificationHost from "@/app/components/NotificationHost.vue";
|
|
|
654
687
|
</script>`);
|
|
655
688
|
add(`${ui}/src/app/startApplication.ts`, `import { createApp } from "vue";\nimport { createPinia } from "pinia";\nimport PrimeVue from "primevue/config";\nimport App from "@/app/App.vue";\n${options.auth === "firebase" ? 'import { configureHttp } from "@/app/configureHttp";\n' : ""}import router from "@/app/router";\nimport "@/app/styles.css";\nexport function startApplication() {\n\tconst app = createApp(App);\n\tconst pinia = createPinia();\n\tapp.use(pinia).use(PrimeVue).use(router);\n\t${options.auth === "firebase" ? "configureHttp(pinia);\n\t" : ""}app.mount("#app");\n}\n`);
|
|
656
689
|
add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
|
|
657
|
-
add(`${ui}/src/app/styles.css`, `@import "tailwindcss"
|
|
658
|
-
|
|
690
|
+
add(`${ui}/src/app/styles.css`, `@import "tailwindcss";
|
|
691
|
+
:root {
|
|
692
|
+
font-family: Inter, ui-sans-serif, system-ui;
|
|
693
|
+
color: #17221c;
|
|
694
|
+
background: #f5f3ec;
|
|
695
|
+
}
|
|
696
|
+
body {
|
|
697
|
+
margin: 0;
|
|
698
|
+
}`);
|
|
699
|
+
add(`${ui}/src/app/__tests__/App.spec.ts`, `import { mount } from "@vue/test-utils";
|
|
700
|
+
import App from "@/app/App.vue";
|
|
701
|
+
describe("App", () => {
|
|
702
|
+
it("renders a router view", () => {
|
|
703
|
+
expect(mount(App, { global: { stubs: ["RouterView", "NotificationHost"] } }).exists()).toBe(true);
|
|
704
|
+
});
|
|
705
|
+
});`);
|
|
659
706
|
add(`${ui}/AGENTS.md`, `# ${title} UI guidance
|
|
660
707
|
|
|
661
708
|
## Scope
|
package/src/owned-sources.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
csrfSource,
|
|
6
6
|
firebaseSource,
|
|
7
7
|
httpErrorsSource,
|
|
8
|
+
loggerSource,
|
|
8
9
|
mongoUsersSource,
|
|
9
10
|
rateLimitSource,
|
|
10
11
|
readEnvSource,
|
|
@@ -77,6 +78,14 @@ it("maps verified and unverified Firebase email claims and verifies only once",
|
|
|
77
78
|
|
|
78
79
|
const errorTranslationTestSource = `import { AppError, type FailureKind } from "@/core/errors";
|
|
79
80
|
import { errorHandler } from "@/platform/http/errors";
|
|
81
|
+
import { logger } from "@/platform/logging/logger";
|
|
82
|
+
|
|
83
|
+
jest.mock("@/platform/logging/logger", () => ({
|
|
84
|
+
logger: { log: jest.fn() },
|
|
85
|
+
serializeError: (error: unknown) => error
|
|
86
|
+
}));
|
|
87
|
+
|
|
88
|
+
const log = logger.log as jest.MockedFunction<typeof logger.log>;
|
|
80
89
|
|
|
81
90
|
const statuses: Array<[FailureKind, number]> = [
|
|
82
91
|
["invalid", 400],
|
|
@@ -91,11 +100,58 @@ const statuses: Array<[FailureKind, number]> = [
|
|
|
91
100
|
];
|
|
92
101
|
|
|
93
102
|
it.each(statuses)("translates %s to HTTP %i", (kind, status) => {
|
|
103
|
+
log.mockClear();
|
|
94
104
|
const json = jest.fn();
|
|
95
105
|
const response = { status: jest.fn(() => ({ json })) } as never;
|
|
96
|
-
|
|
106
|
+
const request = { method: "POST", path: "/health.get", requestContext: { requestId: "request-1" } } as never;
|
|
107
|
+
errorHandler(new AppError({ code: "CODE", message: "message", kind }), request, response, jest.fn());
|
|
97
108
|
expect((response as { status: jest.Mock }).status).toHaveBeenCalledWith(status);
|
|
98
109
|
expect(json).toHaveBeenCalledWith({ error: { code: "CODE", message: "message" } });
|
|
110
|
+
expect(log).toHaveBeenCalledWith(
|
|
111
|
+
expect.objectContaining({
|
|
112
|
+
level: status >= 500 ? "error" : "warn",
|
|
113
|
+
event: "http.request.failed",
|
|
114
|
+
requestId: "request-1",
|
|
115
|
+
code: "CODE",
|
|
116
|
+
kind
|
|
117
|
+
})
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("logs an unknown failure without exposing it to the client", () => {
|
|
122
|
+
log.mockClear();
|
|
123
|
+
const json = jest.fn();
|
|
124
|
+
const response = { status: jest.fn(() => ({ json })) } as never;
|
|
125
|
+
const error = new Error("database disconnected");
|
|
126
|
+
errorHandler(error, { method: "POST", path: "/health.get" } as never, response, jest.fn());
|
|
127
|
+
expect(json).toHaveBeenCalledWith({ error: { code: "INTERNAL", message: "Internal server error" } });
|
|
128
|
+
expect(log).toHaveBeenCalledWith(
|
|
129
|
+
expect.objectContaining({
|
|
130
|
+
level: "error",
|
|
131
|
+
code: "INTERNAL",
|
|
132
|
+
error: expect.objectContaining({ message: "database disconnected" })
|
|
133
|
+
})
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("logs a preserved application-error cause", () => {
|
|
138
|
+
log.mockClear();
|
|
139
|
+
const json = jest.fn();
|
|
140
|
+
const response = { status: jest.fn(() => ({ json })) } as never;
|
|
141
|
+
const cause = new Error("Firebase token verification failed");
|
|
142
|
+
errorHandler(
|
|
143
|
+
AppError.unauthenticated(cause),
|
|
144
|
+
{ method: "POST", path: "/health.get" } as never,
|
|
145
|
+
response,
|
|
146
|
+
jest.fn()
|
|
147
|
+
);
|
|
148
|
+
expect(log).toHaveBeenCalledWith(
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
error: expect.objectContaining({
|
|
151
|
+
cause: expect.objectContaining({ message: "Firebase token verification failed" })
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
);
|
|
99
155
|
});
|
|
100
156
|
`;
|
|
101
157
|
|
|
@@ -188,6 +244,7 @@ export function ownedSources(config) {
|
|
|
188
244
|
"src/core/errors.ts": appErrorSource,
|
|
189
245
|
"src/core/SchemaLifecycle.ts": schemaLifecycleSource,
|
|
190
246
|
"src/platform/config/readEnv.ts": readEnvSource,
|
|
247
|
+
"src/platform/logging/logger.ts": loggerSource,
|
|
191
248
|
"src/platform/http/createRequestContext.ts": createRequestContextSource(profiles.has("firebase") ? "firebase" : "none"),
|
|
192
249
|
"src/platform/http/csrf.ts": csrfSource,
|
|
193
250
|
"src/platform/http/errors.ts": httpErrorsSource,
|
|
@@ -43,20 +43,61 @@ export const appErrorSource = `export type FailureKind =
|
|
|
43
43
|
| "unavailable"
|
|
44
44
|
| "internal";
|
|
45
45
|
|
|
46
|
+
export interface AppErrorOptions {
|
|
47
|
+
code: string;
|
|
48
|
+
message: string;
|
|
49
|
+
kind: FailureKind;
|
|
50
|
+
details?: unknown;
|
|
51
|
+
cause?: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
46
54
|
export class AppError extends Error {
|
|
47
55
|
readonly name = "AppError";
|
|
56
|
+
readonly code: string;
|
|
57
|
+
readonly kind: FailureKind;
|
|
58
|
+
readonly details: unknown;
|
|
59
|
+
|
|
60
|
+
constructor({ code, message, kind, details, cause }: AppErrorOptions) {
|
|
61
|
+
super(message, cause === undefined ? undefined : { cause });
|
|
62
|
+
this.code = code;
|
|
63
|
+
this.kind = kind;
|
|
64
|
+
this.details = details;
|
|
65
|
+
}
|
|
48
66
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
message: string,
|
|
52
|
-
readonly kind: FailureKind,
|
|
53
|
-
readonly details?: unknown
|
|
54
|
-
) {
|
|
55
|
-
super(message);
|
|
67
|
+
static unauthenticated(cause?: unknown): AppError {
|
|
68
|
+
return new AppError({ code: "UNAUTHENTICATED", message: "Unauthorized", kind: "unauthenticated", cause });
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
`;
|
|
59
72
|
|
|
73
|
+
export const loggerSource = `import { createLogger, format, transports } from "winston";
|
|
74
|
+
|
|
75
|
+
export function serializeError(error: unknown, depth = 0): unknown {
|
|
76
|
+
if (!(error instanceof Error)) return error;
|
|
77
|
+
return {
|
|
78
|
+
name: error.name,
|
|
79
|
+
message: error.message,
|
|
80
|
+
stack: error.stack,
|
|
81
|
+
...(error.cause === undefined || depth >= 3 ? {} : { cause: serializeError(error.cause, depth + 1) })
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const serializeErrorMetadata = format((info) => {
|
|
86
|
+
if (info.error !== undefined) info.error = serializeError(info.error);
|
|
87
|
+
return info;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The service-wide logger. Tailframe always retains the JSON console transport so
|
|
92
|
+
* canonical failure logging cannot be disabled by application assembly.
|
|
93
|
+
*/
|
|
94
|
+
export const logger = createLogger({
|
|
95
|
+
level: "info",
|
|
96
|
+
format: format.combine(format.timestamp(), format.errors({ stack: true }), serializeErrorMetadata(), format.json()),
|
|
97
|
+
transports: [new transports.Console()]
|
|
98
|
+
});
|
|
99
|
+
`;
|
|
100
|
+
|
|
60
101
|
export function createRequestContextSource(auth) {
|
|
61
102
|
if (auth === "firebase") {
|
|
62
103
|
return `import { randomUUID } from "node:crypto";
|
|
@@ -88,7 +129,7 @@ export async function createRequestContext(req: Request): Promise<RequestContext
|
|
|
88
129
|
return context;
|
|
89
130
|
}
|
|
90
131
|
if (!/^Bearer [^\\s]+$/.test(header)) {
|
|
91
|
-
throw
|
|
132
|
+
throw AppError.unauthenticated();
|
|
92
133
|
}
|
|
93
134
|
|
|
94
135
|
try {
|
|
@@ -99,8 +140,8 @@ export async function createRequestContext(req: Request): Promise<RequestContext
|
|
|
99
140
|
};
|
|
100
141
|
req.requestContext = context;
|
|
101
142
|
return context;
|
|
102
|
-
} catch {
|
|
103
|
-
throw
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw AppError.unauthenticated(error);
|
|
104
145
|
}
|
|
105
146
|
}
|
|
106
147
|
|
|
@@ -200,9 +241,10 @@ export function rpcHandler<Input, Output, WireInput = Input>(
|
|
|
200
241
|
}
|
|
201
242
|
`;
|
|
202
243
|
|
|
203
|
-
export const httpErrorsSource = `import type { ErrorRequestHandler } from "express";
|
|
244
|
+
export const httpErrorsSource = `import type { ErrorRequestHandler, Request } from "express";
|
|
204
245
|
import Joi from "joi";
|
|
205
246
|
import { AppError } from "@/core/errors";
|
|
247
|
+
import { logger, serializeError } from "@/platform/logging/logger";
|
|
206
248
|
|
|
207
249
|
const statusByKind: Readonly<Record<AppError["kind"], number>> = {
|
|
208
250
|
invalid: 400,
|
|
@@ -216,9 +258,26 @@ const statusByKind: Readonly<Record<AppError["kind"], number>> = {
|
|
|
216
258
|
internal: 500
|
|
217
259
|
};
|
|
218
260
|
|
|
219
|
-
|
|
261
|
+
function logFailure(req: Request, error: unknown, status: number, code: string, kind?: AppError["kind"]) {
|
|
262
|
+
logger.log({
|
|
263
|
+
level: status >= 500 ? "error" : "warn",
|
|
264
|
+
message: "HTTP request failed",
|
|
265
|
+
event: "http.request.failed",
|
|
266
|
+
requestId: req.requestContext?.requestId,
|
|
267
|
+
method: req.method,
|
|
268
|
+
path: req.path,
|
|
269
|
+
status,
|
|
270
|
+
code,
|
|
271
|
+
...(kind === undefined ? {} : { kind }),
|
|
272
|
+
error: serializeError(error)
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
|
220
277
|
if (error instanceof AppError) {
|
|
221
|
-
|
|
278
|
+
const status = statusByKind[error.kind];
|
|
279
|
+
logFailure(req, error, status, error.code, error.kind);
|
|
280
|
+
res.status(status).json({
|
|
222
281
|
error: {
|
|
223
282
|
code: error.code,
|
|
224
283
|
message: error.message,
|
|
@@ -228,6 +287,7 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|
|
228
287
|
return;
|
|
229
288
|
}
|
|
230
289
|
if (Joi.isError(error)) {
|
|
290
|
+
logFailure(req, error, 400, "VALIDATION_FAILED", "invalid");
|
|
231
291
|
res.status(400).json({
|
|
232
292
|
error: {
|
|
233
293
|
code: "VALIDATION_FAILED",
|
|
@@ -237,11 +297,22 @@ export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|
|
237
297
|
});
|
|
238
298
|
return;
|
|
239
299
|
}
|
|
240
|
-
|
|
300
|
+
logFailure(req, error, 500, "INTERNAL", "internal");
|
|
241
301
|
res.status(500).json({ error: { code: "INTERNAL", message: "Internal server error" } });
|
|
242
302
|
};
|
|
243
303
|
`;
|
|
244
304
|
|
|
305
|
+
export const observabilitySource = `import type { Logger } from "winston";
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Add product-specific Winston transports here. Do not remove or reconfigure the
|
|
309
|
+
* Tailframe console transport: it is the required baseline for canonical errors.
|
|
310
|
+
*/
|
|
311
|
+
export function configureObservability(_logger: Pick<Logger, "add">): void {
|
|
312
|
+
// Add product-specific transports and leave the canonical console transport intact.
|
|
313
|
+
}
|
|
314
|
+
`;
|
|
315
|
+
|
|
245
316
|
export const csrfSource = `import type { RequestHandler } from "express";
|
|
246
317
|
import { AppError } from "@/core/errors";
|
|
247
318
|
|
|
@@ -255,7 +326,7 @@ export function csrfHeaderGuard(exemptPaths: readonly string[] = []): RequestHan
|
|
|
255
326
|
!exempt.has(req.path) &&
|
|
256
327
|
req.headers["x-requested-with"] !== "XMLHttpRequest"
|
|
257
328
|
) {
|
|
258
|
-
next(new AppError("CSRF_HEADER_MISSING", "CSRF header missing", "forbidden"));
|
|
329
|
+
next(new AppError({ code: "CSRF_HEADER_MISSING", message: "CSRF header missing", kind: "forbidden" }));
|
|
259
330
|
return;
|
|
260
331
|
}
|
|
261
332
|
next();
|
|
@@ -271,6 +342,7 @@ export const schemaLifecycleSource = `export interface SchemaLifecycle {
|
|
|
271
342
|
|
|
272
343
|
export const applySchemaCliSource = `import "reflect-metadata";
|
|
273
344
|
import { schemaLifecycle } from "@/app/schema";
|
|
345
|
+
import { logger } from "@/platform/logging/logger";
|
|
274
346
|
|
|
275
347
|
async function main() {
|
|
276
348
|
try {
|
|
@@ -281,7 +353,7 @@ async function main() {
|
|
|
281
353
|
}
|
|
282
354
|
|
|
283
355
|
void main().catch((error) => {
|
|
284
|
-
|
|
356
|
+
logger.error("Schema application failed", { event: "schema.apply.failed", error });
|
|
285
357
|
process.exitCode = 1;
|
|
286
358
|
});
|
|
287
359
|
`;
|
|
@@ -343,9 +415,17 @@ export function createRateLimiter(client: RedisClientType, policy: RateLimitPoli
|
|
|
343
415
|
next();
|
|
344
416
|
} catch (error) {
|
|
345
417
|
if (error instanceof RateLimiterRes)
|
|
346
|
-
next(new AppError("RATE_LIMITED", "Too many requests", "rate_limited"));
|
|
418
|
+
next(new AppError({ code: "RATE_LIMITED", message: "Too many requests", kind: "rate_limited" }));
|
|
347
419
|
else if (error instanceof AppError) next(error);
|
|
348
|
-
else
|
|
420
|
+
else
|
|
421
|
+
next(
|
|
422
|
+
new AppError({
|
|
423
|
+
code: "RATE_LIMIT_UNAVAILABLE",
|
|
424
|
+
message: "Request protection unavailable",
|
|
425
|
+
kind: "unavailable",
|
|
426
|
+
cause: error
|
|
427
|
+
})
|
|
428
|
+
);
|
|
349
429
|
}
|
|
350
430
|
};
|
|
351
431
|
}
|
|
@@ -426,8 +506,13 @@ export class GetReadiness implements UseCase<Record<string, never>, ReadinessRes
|
|
|
426
506
|
try {
|
|
427
507
|
await Promise.all(this.probes.map((probe) => probe.check()));
|
|
428
508
|
return { status: "ready" };
|
|
429
|
-
} catch {
|
|
430
|
-
throw new AppError(
|
|
509
|
+
} catch (error) {
|
|
510
|
+
throw new AppError({
|
|
511
|
+
code: "NOT_READY",
|
|
512
|
+
message: "Service is not ready",
|
|
513
|
+
kind: "unavailable",
|
|
514
|
+
cause: error
|
|
515
|
+
});
|
|
431
516
|
}
|
|
432
517
|
}
|
|
433
518
|
}
|
|
@@ -496,9 +581,10 @@ export class RedisReadinessProbe implements ReadinessProbe {
|
|
|
496
581
|
|
|
497
582
|
export const redisSource = `import { createClient, type RedisClientType } from "redis";
|
|
498
583
|
import { env } from "@/platform/config/env";
|
|
584
|
+
import { logger } from "@/platform/logging/logger";
|
|
499
585
|
|
|
500
586
|
export const redis: RedisClientType = createClient({ url: env.redisUrl });
|
|
501
|
-
redis.on("error", (error) =>
|
|
587
|
+
redis.on("error", (error) => logger.error("Redis client error", { event: "redis.client.error", error }));
|
|
502
588
|
|
|
503
589
|
export async function connectRedis() {
|
|
504
590
|
if (!redis.isOpen) await redis.connect();
|
|
@@ -563,10 +649,12 @@ export function serverSource({ ui, redis, csrf }) {
|
|
|
563
649
|
return `${ui ? 'import path from "node:path";\n' : ""}import express from "express";
|
|
564
650
|
import cors from "cors";
|
|
565
651
|
import { registerDependencies } from "@/app/container";
|
|
652
|
+
import { configureObservability } from "@/app/observability";
|
|
566
653
|
import { applicationRoutes } from "@/app/routes";
|
|
567
654
|
import { env } from "@/platform/config/env";
|
|
568
655
|
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
569
656
|
${redis ? 'import { closeRedis, connectRedis } from "@/platform/redis";\n' : ""}${csrf ? 'import { csrfHeaderGuard } from "@/platform/http/csrf";\n' : ""}import { errorHandler } from "@/platform/http/errors";
|
|
657
|
+
import { logger } from "@/platform/logging/logger";
|
|
570
658
|
|
|
571
659
|
export interface ApplicationOptions {
|
|
572
660
|
${ui ? "\tpublicPath?: string;\n\tproduction?: boolean;\n" : ""}}
|
|
@@ -582,43 +670,57 @@ ${ui ? '\tif (production) {\n\t\tapp.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) =
|
|
|
582
670
|
}
|
|
583
671
|
|
|
584
672
|
export async function startServer() {
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
673
|
+
try {
|
|
674
|
+
configureObservability(logger);
|
|
675
|
+
const db = await connectDatabase();
|
|
676
|
+
${redis ? "\t\tconst redis = await connectRedis();\n" : ""} registerDependencies({ db${redis ? ", redis" : ""} });
|
|
677
|
+
const app = createApplication();
|
|
678
|
+
const server = app.listen(env.port);
|
|
679
|
+
const shutdown = async () => {
|
|
680
|
+
await new Promise<void>((resolve, reject) => {
|
|
681
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
682
|
+
});
|
|
683
|
+
${redis ? "\t\t\tawait closeRedis();\n" : ""} await closeDatabase();
|
|
684
|
+
};
|
|
685
|
+
process.once("SIGINT", () => void shutdown());
|
|
686
|
+
process.once("SIGTERM", () => void shutdown());
|
|
687
|
+
return { app, server, shutdown };
|
|
688
|
+
} catch (error) {
|
|
689
|
+
logger.error("Server startup failed", { event: "server.start.failed", error });
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
598
692
|
}
|
|
599
693
|
`;
|
|
600
694
|
}
|
|
601
695
|
|
|
602
696
|
export function workerSource() {
|
|
603
697
|
return `import { container, registerDependencies } from "@/app/container";
|
|
698
|
+
import { configureObservability } from "@/app/observability";
|
|
604
699
|
import { GetReadiness } from "@/modules/health/use-cases/GetReadiness";
|
|
605
700
|
import { closeDatabase, connectDatabase } from "@/platform/database";
|
|
606
701
|
import { systemRequestContext } from "@/platform/http/createRequestContext";
|
|
702
|
+
import { logger } from "@/platform/logging/logger";
|
|
607
703
|
import { closeRedis, connectRedis } from "@/platform/redis";
|
|
608
704
|
|
|
609
705
|
export async function startWorker() {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
706
|
+
try {
|
|
707
|
+
configureObservability(logger);
|
|
708
|
+
const db = await connectDatabase();
|
|
709
|
+
const redis = await connectRedis();
|
|
710
|
+
registerDependencies({ db, redis });
|
|
711
|
+
await container.resolve(GetReadiness).execute(systemRequestContext(), {});
|
|
712
|
+
|
|
713
|
+
const shutdown = async () => {
|
|
714
|
+
await closeRedis();
|
|
715
|
+
await closeDatabase();
|
|
716
|
+
};
|
|
717
|
+
process.once("SIGINT", () => void shutdown());
|
|
718
|
+
process.once("SIGTERM", () => void shutdown());
|
|
719
|
+
return { shutdown };
|
|
720
|
+
} catch (error) {
|
|
721
|
+
logger.error("Worker startup failed", { event: "worker.start.failed", error });
|
|
722
|
+
throw error;
|
|
723
|
+
}
|
|
622
724
|
}
|
|
623
725
|
`;
|
|
624
726
|
}
|