@zaaxch/tailframe 1.0.0 → 2.1.0
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/architecture.mjs +4 -0
- package/src/conventions.mjs +84 -0
- package/src/generate.mjs +118 -57
- package/src/new.mjs +197 -134
- package/src/service-templates.mjs +483 -0
- package/src/ui-templates.mjs +141 -0
- package/src/validate.mjs +1 -1
package/src/new.mjs
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { authAppStoreSource, GenerateError } from "./generate.mjs";
|
|
4
|
+
import {
|
|
5
|
+
appErrorSource,
|
|
6
|
+
applicationRoutesSource,
|
|
7
|
+
containerSource,
|
|
8
|
+
createRequestContextSource,
|
|
9
|
+
csrfSource,
|
|
10
|
+
firebaseSource,
|
|
11
|
+
getHealthSource,
|
|
12
|
+
getHealthTestSource,
|
|
13
|
+
getReadinessSource,
|
|
14
|
+
getReadinessTestSource,
|
|
15
|
+
healthRoutesSource,
|
|
16
|
+
healthSchemasSource,
|
|
17
|
+
httpErrorsSource,
|
|
18
|
+
mongoReadinessProbeSource,
|
|
19
|
+
readinessProbeSource,
|
|
20
|
+
redisReadinessProbeSource,
|
|
21
|
+
redisSource,
|
|
22
|
+
requestContextSource,
|
|
23
|
+
rpcHandlerSource,
|
|
24
|
+
rpcSource,
|
|
25
|
+
serverSource,
|
|
26
|
+
useCaseSource,
|
|
27
|
+
workerSource
|
|
28
|
+
} from "./service-templates.mjs";
|
|
29
|
+
import {
|
|
30
|
+
configureHttpSource,
|
|
31
|
+
healthApiSource,
|
|
32
|
+
uiErrorsSource,
|
|
33
|
+
uiHttpSource,
|
|
34
|
+
uiRpcSource
|
|
35
|
+
} from "./ui-templates.mjs";
|
|
4
36
|
|
|
5
37
|
function fail(message) {
|
|
6
38
|
throw new GenerateError(message);
|
|
@@ -45,9 +77,7 @@ const prettierConfig = {
|
|
|
45
77
|
endOfLine: "lf",
|
|
46
78
|
singleQuote: false
|
|
47
79
|
};
|
|
48
|
-
|
|
49
|
-
add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
|
|
50
|
-
add(".gitattributes", `* text=auto eol=lf
|
|
80
|
+
const gitAttributes = `* text=auto eol=lf
|
|
51
81
|
|
|
52
82
|
*.png binary
|
|
53
83
|
*.jpg binary
|
|
@@ -55,7 +85,10 @@ add(".gitattributes", `* text=auto eol=lf
|
|
|
55
85
|
*.gif binary
|
|
56
86
|
*.ico binary
|
|
57
87
|
*.pdf binary
|
|
58
|
-
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
add(".prettierrc.json", JSON.stringify(prettierConfig, null, "\t"));
|
|
91
|
+
add(".gitattributes", gitAttributes);
|
|
59
92
|
|
|
60
93
|
const repos = [[svc, "Express/TypeScript API and background runtime."]];
|
|
61
94
|
if (options.ui) repos.push([ui, "Vue/Vite customer interface."]);
|
|
@@ -132,7 +165,7 @@ Use this workflow for any new domain capability or operation. Add only the layer
|
|
|
132
165
|
1. Identify the module, requested operations, entry points, and repositories in scope. Classify every intended file with the applicable AGENTS.md placement table before writing. Do not assume full CRUD.
|
|
133
166
|
2. Read the root and every applicable child \`AGENTS.md\`.
|
|
134
167
|
3. Inspect the nearest working module and tests.
|
|
135
|
-
4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation;
|
|
168
|
+
4. Create architecture files only with the tailframe CLI: \`tailframe generate\` creates service modules, use cases, http files, ports, adapters, identifiers, integrations, UI modules, and the closed UI shell catalog on canonical paths with canonical names. Service module generation also registers the new use case and mounts or extends its single route factory through deterministic composition-file edits. Hand-creating architecture files or editing generated shell-catalog implementations is a conformance violation; inspect the generated diff and complete only the remaining behavior-specific checklist.
|
|
136
169
|
5. Colocate product capability behavior under \`src/modules/<module>\` in both the service and UI. Never substitute \`features\` or global \`apis\`, \`views\`, \`services\`, \`stores\`, or \`types\` directories. UI \`app/stores\` is closed to generated \`auth.store.ts\` and \`theme.store.ts\`; \`app/public\` is closed to generated \`ThemeToggle.vue\`. Never create \`src/modules/theme\` or \`src/modules/auth\`. Product records, current-user domain records, selections, filters, and workflows stay in their owning module. Start backend modules with \`use-cases/\`, \`http/\`, and tests; add \`domain/\` or \`persistence/\` only when required. Add only the UI module directories earned by the capability.
|
|
137
170
|
6. Define use cases as \`execute(context, input)\`. Keep trusted \`RequestContext\` separate from client input.
|
|
138
171
|
7. Put ownership, RBAC, entitlements, and domain policies in use-case or domain code, not client bodies or route metadata.
|
|
@@ -140,7 +173,7 @@ Use this workflow for any new domain capability or operation. Add only the layer
|
|
|
140
173
|
9. For persisted capabilities, prefer branded, domain-owned string IDs at domain and repository-port boundaries; generate the module's identifier file with \`tailframe generate identifiers <module> <NameId...>\`. Keep MongoDB \`ObjectId\` conversion inside MongoDB persistence adapters, using persistence-only document types rather than \`any\` to bypass the boundary.
|
|
141
174
|
10. Let HTTP routes, jobs, workers, scheduled tasks, and CLIs invoke use cases directly; do not call one entry point from another.
|
|
142
175
|
11. Keep operation names, payloads, authentication, response contracts, and client types synchronized. A service module may import another only through a named file directly under the provider's use-cases directory. A UI module may import another only through a named file directly under the provider's public directory. Both graphs must remain acyclic. UI modules may import only the generated auth/theme app stores and \`ThemeToggle\` from app; every other app path is private.
|
|
143
|
-
12.
|
|
176
|
+
12. Verify generated container and route wiring, then add only dependency construction, client, navigation, worker, or process registration earned by real behavior. Route module views directly from \`app/router.ts\`. App views and shell components consume product modules only through named module \`public/\` entries; they never import module views or other internals.
|
|
144
177
|
13. Run npm run validate:architecture in every affected repository, then run focused type checks and tests. Report repositories, operations, entry points, checks, placement decisions, and gaps.
|
|
145
178
|
|
|
146
179
|
Do not create empty architectural layers, speculative operations, or mandatory controllers. Do not claim behavior from static files, weaken trusted context or persistence ownership, or change unrelated background behavior.
|
|
@@ -148,7 +181,6 @@ Do not create empty architectural layers, speculative operations, or mandatory c
|
|
|
148
181
|
|
|
149
182
|
const svcDeps = {
|
|
150
183
|
"@dotenvx/dotenvx": "^1.39.1",
|
|
151
|
-
"body-parser": "^2.2.0",
|
|
152
184
|
cors: "^2.8.5",
|
|
153
185
|
express: "^4.21.2",
|
|
154
186
|
joi: "^17.13.3",
|
|
@@ -172,7 +204,7 @@ add(`${svc}/package.json`, JSON.stringify({
|
|
|
172
204
|
...(options.worker ? { "dev:worker": "nodemon --legacy-watch --exec ts-node -r tsconfig-paths/register src/worker.ts" } : {}),
|
|
173
205
|
build: "tsc && tsc-alias", start: "node dist/server.js",
|
|
174
206
|
...(options.worker ? { worker: "node dist/worker.js" } : {}),
|
|
175
|
-
test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/",
|
|
207
|
+
test: "npm run test:unit && npm run test:integration", "test:unit": "jest --selectProjects unit --runInBand", "test:integration": "jest --selectProjects integration --runInBand", "validate:architecture": "tailframe validate --kind service .", format: "prettier --write src/", "format:check": "prettier --check src/",
|
|
176
208
|
"test:db:up": "docker compose -f docker-compose.test.yml up -d", "test:db:down": "docker compose -f docker-compose.test.yml down -v",
|
|
177
209
|
"docker:dev": "docker compose -f docker-compose.dev.yml up",
|
|
178
210
|
"docker:prod": "docker compose -f docker-compose.yml up -d",
|
|
@@ -189,6 +221,7 @@ module.exports = { projects: [
|
|
|
189
221
|
{ ...base, displayName: "integration", testMatch: ["**/*.integration.test.ts"] }
|
|
190
222
|
] };`);
|
|
191
223
|
add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
224
|
+
add(`${svc}/.gitattributes`, gitAttributes);
|
|
192
225
|
add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
|
|
193
226
|
add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
|
|
194
227
|
if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
|
|
@@ -206,106 +239,44 @@ export const env = {
|
|
|
206
239
|
};`);
|
|
207
240
|
const databaseSource = `import { MongoClient } from "mongodb";
|
|
208
241
|
import { env } from "@/platform/config/env";
|
|
242
|
+
|
|
209
243
|
export const mongo = new MongoClient(env.mongodbUri);
|
|
210
|
-
|
|
211
|
-
export async function
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
import { env } from "@/platform/config/env";
|
|
215
|
-
export const redis: RedisClientType = createClient({ url: env.redisUrl });
|
|
216
|
-
redis.on("error", (error) => console.error("Redis client error", error));
|
|
217
|
-
export async function connectRedis() { if (!redis.isOpen) await redis.connect(); return redis; }
|
|
218
|
-
export async function closeRedis() { if (redis.isOpen) await redis.quit(); }
|
|
219
|
-
`);
|
|
220
|
-
add(`${svc}/src/core/RequestContext.ts`, `export interface AuthenticatedPrincipal {\n\tuid: string;\n\temail?: string;\n}\nexport interface RequestContext {\n\trequestId: string;\n\tprincipal?: AuthenticatedPrincipal;\n\troles: string[];\n}\n`);
|
|
221
|
-
add(`${svc}/src/core/UseCase.ts`, `import type { RequestContext } from "@/core/RequestContext";\nexport interface UseCase<Input, Output> {\n\texecute(context: RequestContext, input: Input): Promise<Output>;\n}\n`);
|
|
222
|
-
add(`${svc}/src/platform/http/createRequestContext.ts`, options.auth === "firebase" ? `import { randomUUID } from "node:crypto";
|
|
223
|
-
import type { Request } from "express";
|
|
224
|
-
import type { RequestContext } from "@/core/RequestContext";
|
|
225
|
-
import { verifyFirebaseToken } from "@/platform/auth/firebase";
|
|
226
|
-
export async function createRequestContext(req: Request): Promise<RequestContext> {
|
|
227
|
-
const requestId = String(req.headers["x-request-id"] ?? randomUUID());
|
|
228
|
-
const decoded = req.headers.authorization?.startsWith("Bearer ") ? await verifyFirebaseToken(req.headers.authorization.slice(7)) : undefined;
|
|
229
|
-
return { requestId, principal: decoded ? { uid: decoded.uid, email: decoded.email } : undefined, roles: [] };
|
|
230
|
-
}
|
|
231
|
-
export function systemRequestContext(requestId = "system"): RequestContext { return { requestId, roles: [] }; }
|
|
232
|
-
` : `import { randomUUID } from "node:crypto";
|
|
233
|
-
import type { Request } from "express";
|
|
234
|
-
import type { RequestContext } from "@/core/RequestContext";
|
|
235
|
-
export function createRequestContext(req: Request): RequestContext {
|
|
236
|
-
return { requestId: String(req.headers["x-request-id"] ?? randomUUID()), roles: [] };
|
|
237
|
-
}
|
|
238
|
-
export function systemRequestContext(requestId = "system"): RequestContext { return { requestId, roles: [] }; }
|
|
239
|
-
`);
|
|
240
|
-
if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, `import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
|
|
241
|
-
import { getAuth, type DecodedIdToken } from "firebase-admin/auth";
|
|
242
|
-
import { env } from "@/platform/config/env";
|
|
243
|
-
export function verifyFirebaseToken(token: string): Promise<DecodedIdToken> {
|
|
244
|
-
if (!getApps().length) initializeApp({ credential: applicationDefault(), projectId: env.firebaseProjectId || undefined });
|
|
245
|
-
return getAuth().verifyIdToken(token);
|
|
246
|
-
}`);
|
|
247
|
-
add(`${svc}/src/platform/http/rpc.ts`, `import type { Response } from "express";\nexport function rpcResult(res: Response, result: unknown) { return res.json({ result }); }\n`);
|
|
248
|
-
add(`${svc}/src/platform/http/errors.ts`, `import type { ErrorRequestHandler } from "express";
|
|
249
|
-
export const errorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|
250
|
-
if (error?.isJoi) { res.status(400).json({ error: { message: error.message } }); return; }
|
|
251
|
-
const status = Number(error?.status ?? 500);
|
|
252
|
-
res.status(status).json({ error: { message: status === 500 ? "Internal server error" : error.message } });
|
|
253
|
-
};
|
|
254
|
-
`);
|
|
255
|
-
add(`${svc}/src/modules/health/use-cases/GetHealth.ts`, `import type { UseCase } from "@/core/UseCase";\nimport type { RequestContext } from "@/core/RequestContext";\nexport type HealthResult = { status: "ok" };\nexport class GetHealth implements UseCase<Record<string, never>, HealthResult> {\n\tasync execute(_context: RequestContext, _input: Record<string, never>): Promise<HealthResult> { return { status: "ok" }; }\n}\n`);
|
|
256
|
-
add(`${svc}/src/modules/health/http/health.schemas.ts`, `import Joi from "joi";\nexport const GetHealthSchema = Joi.object({}).unknown(false);\n`);
|
|
257
|
-
add(`${svc}/src/modules/health/http/health.routes.ts`, `import { Router } from "express";\nimport type { GetHealth } from "@/modules/health/use-cases/GetHealth";\nimport { GetHealthSchema } from "@/modules/health/http/health.schemas";\nimport { createRequestContext } from "@/platform/http/createRequestContext";\nimport { rpcResult } from "@/platform/http/rpc";\nexport function healthRoutes(getHealth: GetHealth) {\n\tconst router = Router();\n\trouter.post("/health.get", async (req, res, next) => { try { const input = await GetHealthSchema.validateAsync(req.body ?? {}); rpcResult(res, await getHealth.execute(await createRequestContext(req), input)); } catch (error) { next(error); } });\n\treturn router;\n}\n`);
|
|
258
|
-
add(`${svc}/src/app/container.ts`, `import { container } from "tsyringe";\nimport { GetHealth } from "@/modules/health/use-cases/GetHealth";\ncontainer.registerSingleton(GetHealth, GetHealth);\nexport { container };\n`);
|
|
259
|
-
add(`${svc}/src/app/routes.ts`, `import { Router } from "express";\nimport { container } from "@/app/container";\nimport { GetHealth } from "@/modules/health/use-cases/GetHealth";\nimport { healthRoutes } from "@/modules/health/http/health.routes";\nexport function applicationRoutes() { const router = Router(); router.use(healthRoutes(container.resolve(GetHealth))); return router; }\n`);
|
|
260
|
-
add(`${svc}/src/app/server.ts`, `import path from "node:path";
|
|
261
|
-
import express from "express";
|
|
262
|
-
import cors from "cors";
|
|
263
|
-
import { applicationRoutes } from "@/app/routes";
|
|
264
|
-
import { env } from "@/platform/config/env";
|
|
265
|
-
import { connectDatabase, closeDatabase } from "@/platform/database";
|
|
266
|
-
import { errorHandler } from "@/platform/http/errors";
|
|
267
|
-
export interface ApplicationOptions { publicPath?: string; production?: boolean; }
|
|
268
|
-
export function createApplication(options: ApplicationOptions = {}) {
|
|
269
|
-
const app = express();
|
|
270
|
-
${options.ui ? `const publicPath = options.publicPath ?? path.join(__dirname, "..", "public");
|
|
271
|
-
const production = options.production ?? env.nodeEnv === "production";
|
|
272
|
-
if (production) app.use(express.static(publicPath, { index: false, maxAge: "1y", immutable: true }));` : ""}
|
|
273
|
-
app.use(cors({ origin: env.corsOrigin }));
|
|
274
|
-
app.use(express.json());
|
|
275
|
-
app.use("/api/v1", applicationRoutes());
|
|
276
|
-
${options.ui ? `if (production) {
|
|
277
|
-
app.get(/^(?!\\/api(?:\\/|$)).*/, (_req, res) => res.sendFile(path.join(publicPath, "index.html")));
|
|
278
|
-
}` : ""}
|
|
279
|
-
app.use(errorHandler);
|
|
280
|
-
return app;
|
|
281
|
-
}
|
|
282
|
-
export async function startServer() {
|
|
283
|
-
await connectDatabase();
|
|
284
|
-
const app = createApplication();
|
|
285
|
-
const server = app.listen(env.port);
|
|
286
|
-
const shutdown = async () => { server.close(); await closeDatabase(); };
|
|
287
|
-
process.once("SIGINT", shutdown);
|
|
288
|
-
process.once("SIGTERM", shutdown);
|
|
289
|
-
return { app, server, shutdown };
|
|
244
|
+
|
|
245
|
+
export async function connectDatabase() {
|
|
246
|
+
await mongo.connect();
|
|
247
|
+
return mongo.db(env.mongodbDbName);
|
|
290
248
|
}
|
|
291
|
-
|
|
249
|
+
|
|
250
|
+
export async function closeDatabase() {
|
|
251
|
+
await mongo.close();
|
|
252
|
+
}`;
|
|
253
|
+
add(`${svc}/src/platform/database/index.ts`, databaseSource);
|
|
254
|
+
if (options.redis) add(`${svc}/src/platform/redis/index.ts`, redisSource);
|
|
255
|
+
add(`${svc}/src/core/RequestContext.ts`, requestContextSource);
|
|
256
|
+
add(`${svc}/src/core/UseCase.ts`, useCaseSource);
|
|
257
|
+
add(`${svc}/src/core/errors.ts`, appErrorSource);
|
|
258
|
+
add(`${svc}/src/platform/http/createRequestContext.ts`, createRequestContextSource(options.auth));
|
|
259
|
+
if (options.auth === "firebase") add(`${svc}/src/platform/auth/firebase.ts`, firebaseSource);
|
|
260
|
+
add(`${svc}/src/platform/http/rpc.ts`, rpcSource);
|
|
261
|
+
add(`${svc}/src/platform/http/rpcHandler.ts`, rpcHandlerSource);
|
|
262
|
+
add(`${svc}/src/platform/http/errors.ts`, httpErrorsSource);
|
|
263
|
+
const csrf = options.auth === "firebase" && options.ui;
|
|
264
|
+
if (csrf) add(`${svc}/src/platform/http/csrf.ts`, csrfSource);
|
|
265
|
+
add(`${svc}/src/modules/health/use-cases/GetHealth.ts`, getHealthSource);
|
|
266
|
+
add(`${svc}/src/modules/health/use-cases/GetReadiness.ts`, getReadinessSource);
|
|
267
|
+
add(`${svc}/src/modules/health/use-cases/ports/ReadinessProbe.ts`, readinessProbeSource);
|
|
268
|
+
add(`${svc}/src/modules/health/http/health.schemas.ts`, healthSchemasSource);
|
|
269
|
+
add(`${svc}/src/modules/health/http/health.routes.ts`, healthRoutesSource);
|
|
270
|
+
add(`${svc}/src/platform/integrations/mongodb/MongoReadinessProbe.ts`, mongoReadinessProbeSource);
|
|
271
|
+
if (options.redis) add(`${svc}/src/platform/integrations/redis/RedisReadinessProbe.ts`, redisReadinessProbeSource);
|
|
272
|
+
add(`${svc}/src/app/container.ts`, containerSource({ redis: options.redis }));
|
|
273
|
+
add(`${svc}/src/app/routes.ts`, applicationRoutesSource);
|
|
274
|
+
add(`${svc}/src/app/server.ts`, serverSource({ ui: options.ui, redis: options.redis, csrf }));
|
|
292
275
|
add(`${svc}/src/server.ts`, `import "reflect-metadata";\nimport { startServer } from "@/app/server";\nvoid startServer();\n`);
|
|
293
|
-
if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`,
|
|
294
|
-
import { connectRedis, closeRedis } from "@/platform/redis";
|
|
295
|
-
import { systemRequestContext } from "@/platform/http/createRequestContext";
|
|
296
|
-
import { GetHealth } from "@/modules/health/use-cases/GetHealth";
|
|
297
|
-
// Workers invoke use cases directly. Add real scheduled work only with an explicit domain workflow.
|
|
298
|
-
export async function startWorker() {
|
|
299
|
-
await connectDatabase();
|
|
300
|
-
await connectRedis();
|
|
301
|
-
console.log("${title} worker runtime ready", await new GetHealth().execute(systemRequestContext(), {}));
|
|
302
|
-
const shutdown = async () => { await closeDatabase(); await closeRedis(); process.exit(0); };
|
|
303
|
-
process.once("SIGINT", shutdown);
|
|
304
|
-
process.once("SIGTERM", shutdown);
|
|
305
|
-
}
|
|
306
|
-
`);
|
|
276
|
+
if (options.worker) add(`${svc}/src/app/workers/startWorker.ts`, workerSource());
|
|
307
277
|
if (options.worker) add(`${svc}/src/worker.ts`, `import "reflect-metadata";\nimport { startWorker } from "@/app/workers/startWorker";\nvoid startWorker();\n`);
|
|
308
|
-
add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`,
|
|
278
|
+
add(`${svc}/src/modules/health/__tests__/GetHealth.test.ts`, getHealthTestSource);
|
|
279
|
+
add(`${svc}/src/modules/health/__tests__/GetReadiness.test.ts`, getReadinessTestSource);
|
|
309
280
|
add(`${svc}/src/__tests__/testDb.ts`, `import { MongoClient } from "mongodb";
|
|
310
281
|
const client = new MongoClient(process.env.TEST_MONGODB_URI ?? "mongodb://localhost:27018/?replicaSet=rs0&directConnection=true");
|
|
311
282
|
export async function openTestDatabase() { await client.connect(); return client.db(process.env.TEST_MONGODB_DB_NAME ?? "${options.name.replaceAll("-", "_")}_test"); }
|
|
@@ -344,24 +315,57 @@ services:
|
|
|
344
315
|
}
|
|
345
316
|
'
|
|
346
317
|
`);
|
|
318
|
+
const boundaryRequest = (expression) => csrf ? `browser(${expression})` : expression;
|
|
347
319
|
add(`${svc}/src/app/__tests__/boundary.integration.test.ts`, `import request from "supertest";
|
|
348
|
-
${options.ui ? 'import path from "node:path";\n' : ""}
|
|
320
|
+
${options.ui ? 'import path from "node:path";\n' : ""}${options.redis ? 'import type { RedisClientType } from "redis";\n' : ""}
|
|
321
|
+
import { registerDependencies } from "@/app/container";
|
|
349
322
|
import { createApplication } from "@/app/server";
|
|
350
323
|
import { closeTestDatabase, resetTestDatabase } from "@/__tests__/testDb";
|
|
324
|
+
${options.redis ? `
|
|
325
|
+
const stubRedis = {
|
|
326
|
+
ping: async () => "PONG"
|
|
327
|
+
} as unknown as RedisClientType;
|
|
328
|
+
` : ""}${csrf ? `
|
|
329
|
+
const browser = (agent: request.Test) => agent.set("X-Requested-With", "XMLHttpRequest");
|
|
330
|
+
` : ""}
|
|
351
331
|
describe("HTTP boundary", () => {
|
|
352
|
-
beforeAll(async () => {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
332
|
+
beforeAll(async () => {
|
|
333
|
+
registerDependencies({ db: await resetTestDatabase()${options.redis ? ", redis: stubRedis" : ""} });
|
|
334
|
+
});
|
|
335
|
+
afterAll(async () => {
|
|
336
|
+
await closeTestDatabase();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("returns successful RPC envelopes", async () => {
|
|
340
|
+
await ${boundaryRequest('request(createApplication()).post("/api/v1/health.get")')}
|
|
341
|
+
.send({})
|
|
342
|
+
.expect(200)
|
|
343
|
+
.expect(({ body }) => expect(body).toEqual({ result: { status: "ok" } }));
|
|
344
|
+
await ${boundaryRequest('request(createApplication()).post("/api/v1/health.readiness")')}
|
|
345
|
+
.send({})
|
|
346
|
+
.expect(200)
|
|
347
|
+
.expect(({ body }) => expect(body).toEqual({ result: { status: "ready" } }));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("returns a validation error envelope with a machine-readable code", async () => {
|
|
351
|
+
await ${boundaryRequest('request(createApplication()).post("/api/v1/health.get")')}
|
|
352
|
+
.send({ unexpected: true })
|
|
353
|
+
.expect(400)
|
|
354
|
+
.expect(({ body }) => {
|
|
355
|
+
expect(body.error.code).toBe("VALIDATION_FAILED");
|
|
356
|
+
expect(body.error.message).toEqual(expect.any(String));
|
|
357
|
+
});
|
|
358
358
|
});
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
359
|
+
${csrf ? `
|
|
360
|
+
it("rejects a mutating browser request without the CSRF header", async () => {
|
|
361
|
+
await request(createApplication())
|
|
362
|
+
.post("/api/v1/health.get")
|
|
363
|
+
.send({})
|
|
364
|
+
.expect(403)
|
|
365
|
+
.expect(({ body }) => expect(body.error.code).toBe("CSRF_HEADER_MISSING"));
|
|
363
366
|
});
|
|
364
|
-
${options.ui ? `
|
|
367
|
+
` : ""}${options.ui ? `
|
|
368
|
+
it("serves the production SPA without falling through for API paths", async () => {
|
|
365
369
|
const app = createApplication({ production: true, publicPath: path.join(__dirname, "fixtures/public") });
|
|
366
370
|
await request(app).get("/dashboard").expect(200).expect(({ text }) => expect(text).toContain("boundary-test-spa"));
|
|
367
371
|
await request(app).get("/api/v1/does-not-exist").expect(404);
|
|
@@ -402,12 +406,15 @@ Product vocabulary stays in the module that owns its meaning; never move it into
|
|
|
402
406
|
|
|
403
407
|
## Public API contract
|
|
404
408
|
- Use singular RPC-shaped \`POST /api/v1/<module>.<operation>\` operations and the \`{ result: ... }\` success envelope.
|
|
409
|
+
- Give each module one \`<module>.routes.ts\` file with exactly one \`<module>Routes\` factory. Pass multiple operations through one named use-case object.
|
|
410
|
+
- Name request schemas after operations in PascalCase and end them with \`Schema\`. Translate RPC POST requests only through \`src/platform/http/rpcHandler.ts\`; keep redirects, callbacks, and other non-RPC HTTP behavior explicit.
|
|
405
411
|
- Implement only requested operations; do not create speculative CRUD.
|
|
406
412
|
|
|
407
413
|
## Module ownership
|
|
408
414
|
- Create architecture files only with \`tailframe generate\`; hand-created architecture files are a conformance violation.
|
|
409
415
|
- Colocate capability code under \`src/modules/<module>\`.
|
|
410
416
|
- Start with \`use-cases/\`, required \`http/\` translation, and tests. Add \`domain/\` or \`persistence/\` only when real behavior earns them.
|
|
417
|
+
- Keep one named use-case class, port, adapter, or domain concept per file. Do not bundle operations or framework injection metadata into product files.
|
|
411
418
|
- Use cases expose \`execute(context, input)\` and remain callable from HTTP, jobs, workers, scheduled tasks, or CLIs.
|
|
412
419
|
- Do not require controllers that only validate and delegate.
|
|
413
420
|
|
|
@@ -435,18 +442,23 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
|
|
|
435
442
|
- HTTP routes, jobs, workers, scheduled tasks, and CLIs call use cases directly; they do not call each other.
|
|
436
443
|
- Keep external-system clients under \`src/platform/integrations/<provider>\` and adapt them through module-owned ports.
|
|
437
444
|
- 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.
|
|
438
|
-
-
|
|
445
|
+
- 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.
|
|
446
|
+
- Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
|
|
439
447
|
${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
|
|
440
448
|
|
|
449
|
+
## Production image
|
|
450
|
+
The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."}${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
|
|
451
|
+
|
|
441
452
|
## Tests and validation
|
|
442
|
-
Run \`npm run validate:architecture\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
|
|
453
|
+
Run \`npm run validate:architecture\` and \`npm run format:check\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
|
|
443
454
|
`);
|
|
444
455
|
|
|
445
456
|
if (options.ui) {
|
|
446
457
|
const uiDeps = { "@tailwindcss/vite": "^4.1.18", "@vueuse/core": "^14.3.0", axios: "^1.9.0", pinia: "^3.0.1", primevue: "^4.2.5", vue: "^3.5.13", "vue-router": "^4.5.0", ...(options.auth === "firebase" ? { firebase: "^11.0.0" } : {}) };
|
|
447
458
|
const uiDevDeps = { "@tsconfig/node22": "^22.0.1", "@zaaxch/tailframe": contractVersion, "@types/node": "^22.13.14", "@vitejs/plugin-vue": "^5.2.3", "@vue/test-utils": "^2.4.10", "@vue/tsconfig": "^0.7.0", jsdom: "^29.1.1", "npm-run-all2": "^7.0.2", prettier: "3.5.3", typescript: "~5.8.0", vite: "^6.2.4", vitest: "^4.1.7", "vue-tsc": "^2.2.8" };
|
|
448
|
-
add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
|
|
459
|
+
add(`${ui}/package.json`, JSON.stringify({ name: ui, version: "0.1.0", private: true, type: "module", scripts: { dev: "vite --host 0.0.0.0", build: "run-p type-check \"build-only {@}\" --", "build-only": "vite build", "type-check": "vue-tsc --build", test: "vitest run", "test:watch": "vitest", "validate:architecture": "tailframe validate --kind ui .", format: "prettier --write src/", "format:check": "prettier --check src/" }, dependencies: uiDeps, devDependencies: uiDevDeps }, null, "\t"));
|
|
449
460
|
add(`${ui}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
461
|
+
add(`${ui}/.gitattributes`, gitAttributes);
|
|
450
462
|
add(`${ui}/tsconfig.json`, JSON.stringify({ files: [], references: [{ path: "./tsconfig.app.json" }, { path: "./tsconfig.node.json" }] }, null, "\t"));
|
|
451
463
|
add(`${ui}/tsconfig.app.json`, JSON.stringify({ extends: "@vue/tsconfig/tsconfig.dom.json", include: ["env.d.ts", "src/**/*", "src/**/*.vue"], compilerOptions: { composite: true, baseUrl: ".", paths: { "@/*": ["./src/*"] } } }, null, "\t"));
|
|
452
464
|
add(`${ui}/tsconfig.node.json`, JSON.stringify({ extends: "@tsconfig/node22/tsconfig.json", include: ["vite.config.*"], compilerOptions: { composite: true, types: ["node"] } }, null, "\t"));
|
|
@@ -467,17 +479,63 @@ add(`${ui}/index.html`, `<!doctype html>
|
|
|
467
479
|
`);
|
|
468
480
|
add(`${ui}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\ncerts/\n*.log\n`);
|
|
469
481
|
if (options.auth === "firebase") add(`${ui}/.env.example`, `VITE_FIREBASE_API_KEY=\nVITE_FIREBASE_AUTH_DOMAIN=\nVITE_FIREBASE_PROJECT_ID=\nVITE_FIREBASE_APP_ID=\n`);
|
|
470
|
-
add(`${ui}/src/core/
|
|
482
|
+
add(`${ui}/src/core/errors.ts`, uiErrorsSource);
|
|
483
|
+
add(`${ui}/src/core/rpc.ts`, uiRpcSource);
|
|
471
484
|
if (options.auth === "firebase") {
|
|
472
485
|
add(`${ui}/src/platform/firebase.ts`, `import { initializeApp } from "firebase/app";\nimport { getAuth, GoogleAuthProvider } from "firebase/auth";\n\nconst firebaseApp = initializeApp({\n\tapiKey: import.meta.env.VITE_FIREBASE_API_KEY,\n\tauthDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,\n\tprojectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,\n\tappId: import.meta.env.VITE_FIREBASE_APP_ID\n});\n\nexport const auth = getAuth(firebaseApp);\nexport const googleProvider = new GoogleAuthProvider();`);
|
|
473
486
|
add(`${ui}/src/app/stores/auth.store.ts`, authAppStoreSource());
|
|
474
487
|
}
|
|
475
488
|
add(`${ui}/src/core/RouteNames.ts`, `/** Route vocabulary shared by the router and by modules. Modules never import the router itself. */\nexport enum RouteNames {\n\tHOME = "Home"\n}\n`);
|
|
476
|
-
add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router"
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
489
|
+
add(`${ui}/src/app/router.ts`, `import { createRouter, createWebHistory } from "vue-router";
|
|
490
|
+
import { RouteNames } from "@/core/RouteNames";
|
|
491
|
+
|
|
492
|
+
export default createRouter({
|
|
493
|
+
history: createWebHistory(),
|
|
494
|
+
routes: [{ path: "/", name: RouteNames.HOME, component: () => import("@/modules/health/views/HealthView.vue") }]
|
|
495
|
+
});`);
|
|
496
|
+
add(`${ui}/src/platform/http.ts`, uiHttpSource);
|
|
497
|
+
if (options.auth === "firebase") add(`${ui}/src/app/configureHttp.ts`, configureHttpSource);
|
|
498
|
+
add(`${ui}/src/modules/health/api/health.api.ts`, healthApiSource);
|
|
499
|
+
add(`${ui}/src/modules/health/views/HealthView.vue`, `<script setup lang="ts">
|
|
500
|
+
import { ref } from "vue";
|
|
501
|
+
import { isServiceError } from "@/core/errors";
|
|
502
|
+
import { getReadiness } from "@/modules/health/api/health.api";
|
|
503
|
+
|
|
504
|
+
const loading = ref(false);
|
|
505
|
+
const status = ref("");
|
|
506
|
+
const error = ref("");
|
|
507
|
+
|
|
508
|
+
async function checkReadiness() {
|
|
509
|
+
loading.value = true;
|
|
510
|
+
status.value = "";
|
|
511
|
+
error.value = "";
|
|
512
|
+
try {
|
|
513
|
+
status.value = (await getReadiness()).status;
|
|
514
|
+
} catch (value) {
|
|
515
|
+
error.value = isServiceError(value) ? value.message : "Readiness check failed";
|
|
516
|
+
} finally {
|
|
517
|
+
loading.value = false;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
</script>
|
|
521
|
+
|
|
522
|
+
<template>
|
|
523
|
+
<main class="mx-auto flex min-h-screen max-w-5xl flex-col justify-center gap-4 px-6 py-16">
|
|
524
|
+
<p class="font-bold uppercase tracking-[0.12em] text-emerald-800">Architecture reference</p>
|
|
525
|
+
<h1 class="m-0 text-5xl font-bold sm:text-7xl">${title}</h1>
|
|
526
|
+
<p>The product domain is intentionally undefined.</p>
|
|
527
|
+
<button
|
|
528
|
+
type="button"
|
|
529
|
+
class="self-start rounded-full bg-emerald-800 px-4 py-3 text-white disabled:opacity-60"
|
|
530
|
+
:disabled="loading"
|
|
531
|
+
@click="checkReadiness"
|
|
532
|
+
>
|
|
533
|
+
{{ loading ? "Checking…" : "Check readiness" }}
|
|
534
|
+
</button>
|
|
535
|
+
<p v-if="status" role="status">Service status: {{ status }}</p>
|
|
536
|
+
<p v-if="error" role="alert">{{ error }}</p>
|
|
537
|
+
</main>
|
|
538
|
+
</template>`);
|
|
481
539
|
add(`${ui}/src/app/App.vue`, `<template><RouterView /></template><script setup lang="ts">import { RouterView } from "vue-router";</script>`);
|
|
482
540
|
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`);
|
|
483
541
|
add(`${ui}/src/main.ts`, `import { startApplication } from "@/app/startApplication";\nstartApplication();\n`);
|
|
@@ -535,13 +593,13 @@ Reuse Vue, PrimeVue, Tailwind, loading, error, and accessibility conventions. Pr
|
|
|
535
593
|
- Keep \`src/app/styles.css\` limited to Tailwind imports, theme tokens, and true global base behavior.
|
|
536
594
|
|
|
537
595
|
## Production packaging
|
|
538
|
-
The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined production image.
|
|
596
|
+
The UI runs through Vite in development but has no production container. The service's multi-stage Dockerfile builds this repository and copies its output into \`dist/public\`; Express serves it with SPA fallback. AWS ECR stores the combined \`linux/amd64\` production image under the default immutable \`<service-sha>_<ui-sha>\` tag, and production Compose requires that exact tag.${options.auth === "firebase" ? ` Provide \`${ui}/.env.production\` before a production build; the service build mounts it as a required BuildKit secret only while Vite compiles the browser bundle and does not copy it into the final image.` : ""}
|
|
539
597
|
|
|
540
598
|
## Product language
|
|
541
599
|
The product domain is undefined. Do not invent entities, workflows, roles, claims, navigation, or customer-facing promises.
|
|
542
600
|
|
|
543
601
|
## Validation
|
|
544
|
-
Run \`npm run validate:architecture\`, then add focused Vitest coverage and run type-check, build, and
|
|
602
|
+
Run \`npm run validate:architecture\` and \`npm run format:check\`, then add focused Vitest coverage and run type-check, build, and \`npm test\` as applicable. Use \`npm run test:watch\` only for interactive development.
|
|
545
603
|
`);
|
|
546
604
|
}
|
|
547
605
|
|
|
@@ -557,7 +615,7 @@ WORKDIR /app/ui
|
|
|
557
615
|
COPY ${ui}/package*.json ./
|
|
558
616
|
RUN npm ci
|
|
559
617
|
COPY ${ui}/ ./
|
|
560
|
-
RUN npm run build
|
|
618
|
+
${options.auth === "firebase" ? "RUN --mount=type=secret,id=ui_env,target=/app/ui/.env.production,required=true npm run build" : "RUN npm run build"}
|
|
561
619
|
|
|
562
620
|
FROM node:22.13-bookworm-slim
|
|
563
621
|
WORKDIR /app
|
|
@@ -598,18 +656,23 @@ set -euo pipefail
|
|
|
598
656
|
: "\${AWS_ACCOUNT_ID:?Set AWS_ACCOUNT_ID}"
|
|
599
657
|
: "\${AWS_REGION:?Set AWS_REGION}"
|
|
600
658
|
|
|
659
|
+
SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
|
|
660
|
+
BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
|
|
661
|
+
${options.ui ? `UI_DIR="\${BUILD_CONTEXT}/${ui}"
|
|
662
|
+
SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"
|
|
663
|
+
UI_SHA="$(git -C "\${UI_DIR}" rev-parse --short HEAD)"` : `SVC_SHA="$(git -C "\${SVC_DIR}" rev-parse --short HEAD)"`}
|
|
664
|
+
|
|
601
665
|
ECR_REPOSITORY="\${ECR_REPOSITORY:-${options.name}}"
|
|
602
|
-
IMAGE_TAG="\${IMAGE_TAG
|
|
666
|
+
IMAGE_TAG="\${IMAGE_TAG:-${options.ui ? `\${SVC_SHA}_\${UI_SHA}` : `\${SVC_SHA}`}}"
|
|
603
667
|
REGISTRY="\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com"
|
|
604
668
|
IMAGE="\${REGISTRY}/\${ECR_REPOSITORY}:\${IMAGE_TAG}"
|
|
605
|
-
SVC_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")/.." && pwd)"
|
|
606
|
-
BUILD_CONTEXT="${options.ui ? `$(cd "\${SVC_DIR}/.." && pwd)` : `\${SVC_DIR}`}"
|
|
607
669
|
DOCKERFILE="\${SVC_DIR}/Dockerfile"
|
|
670
|
+
${options.ui && options.auth === "firebase" ? `UI_ENV_FILE="\${UI_ENV_FILE:-\${UI_DIR}/.env.production}"` : ""}
|
|
608
671
|
|
|
609
672
|
aws ecr get-login-password --region "\${AWS_REGION}" |
|
|
610
673
|
docker login --username AWS --password-stdin "\${REGISTRY}"
|
|
611
674
|
|
|
612
|
-
docker build --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
|
|
675
|
+
docker build --platform linux/amd64${options.ui && options.auth === "firebase" ? ` --secret "id=ui_env,src=\${UI_ENV_FILE}"` : ""} --file "\${DOCKERFILE}" --tag "\${IMAGE}" "\${BUILD_CONTEXT}"
|
|
613
676
|
docker push "\${IMAGE}"
|
|
614
677
|
|
|
615
678
|
echo "Pushed \${IMAGE}"
|
|
@@ -631,7 +694,7 @@ const dependsBlock = `\n depends_on:\n${dependencies.map(([dependency, condit
|
|
|
631
694
|
const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
|
|
632
695
|
const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
|
|
633
696
|
const workerService = options.worker ? `\n worker:
|
|
634
|
-
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG
|
|
697
|
+
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
635
698
|
env_file:
|
|
636
699
|
- .env.production
|
|
637
700
|
command: ["node", "dist/worker.js"]${databaseEnvironmentBlock}${volumeBlock}${dependsBlock}
|
|
@@ -676,7 +739,7 @@ const namedVolumes = [
|
|
|
676
739
|
add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
|
|
677
740
|
services:
|
|
678
741
|
${options.name}:
|
|
679
|
-
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG
|
|
742
|
+
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
680
743
|
env_file:
|
|
681
744
|
- .env.production
|
|
682
745
|
ports:
|