@tulipes/core 0.1.10 → 0.1.12
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/README.md +246 -208
- package/dist/boot/boot.js +1 -1
- package/dist/boot/boot.js.map +1 -1
- package/dist/boot/contracts.d.ts +7 -0
- package/dist/cli/init.js +260 -169
- package/dist/cli/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/CLAUDE.md +1 -0
package/dist/cli/init.js
CHANGED
|
@@ -245,155 +245,56 @@ function renderProject(name, coreVersion) {
|
|
|
245
245
|
` * ── The app-wide config seed ──────────────────────────────────────────`,
|
|
246
246
|
` *`,
|
|
247
247
|
` * Runs FIRST in boot phase 6, before any module.config.ts factory, and`,
|
|
248
|
-
` * whatever it returns becomes the base of the global config.
|
|
249
|
-
` *
|
|
248
|
+
` * whatever it returns becomes the base of the global config. Modules`,
|
|
249
|
+
` * read it through \`ctx.config.<key>\`:`,
|
|
250
250
|
` *`,
|
|
251
|
-
` *
|
|
252
|
-
` * const router = Router();`,
|
|
253
|
-
` * router.get(\`\${config.api!.prefix}/users\`, handler);`,
|
|
254
|
-
` * }`,
|
|
251
|
+
` * router.get(\`\${config.api!.prefix}/users\`, handler);`,
|
|
255
252
|
` *`,
|
|
256
|
-
` *
|
|
257
|
-
` *
|
|
258
|
-
` *
|
|
259
|
-
` *
|
|
253
|
+
` * Add a key here when two or more modules share a value. Config`,
|
|
254
|
+
` * belonging to ONE feature goes in that module's \`module.config.ts\``,
|
|
255
|
+
` * instead, where \`yarn sync\` types it for you. A value that differs`,
|
|
256
|
+
` * per deployment belongs in neither: declare it in a module's`,
|
|
257
|
+
` * \`meta.variables.json\` and read it with \`Environment.get\`, the way`,
|
|
258
|
+
` * MONGO_URI, CORS_ORIGINS and BODY_LIMIT do.`,
|
|
260
259
|
` *`,
|
|
261
|
-
` *
|
|
262
|
-
` *
|
|
263
|
-
` * This file is for values two or more modules share.`,
|
|
264
|
-
` *`,
|
|
265
|
-
` * Values can be hardcoded (below) or driven by the environment. To make`,
|
|
266
|
-
` * any of them env-driven, declare the variable in a module's`,
|
|
267
|
-
` * meta.variables.json first, then read it here with`,
|
|
268
|
-
` * \`Environment.get("NAME")\` — reads of undeclared variables throw on`,
|
|
269
|
-
` * purpose, so a typo can never boot.`,
|
|
260
|
+
` * The framework reads exactly one field of this object: \`app.name\`,`,
|
|
261
|
+
` * which titles the startup banner. Everything else is yours.`,
|
|
270
262
|
` */`,
|
|
263
|
+
``,
|
|
271
264
|
`// \`satisfies\` (not a type annotation) so TypeScript keeps the exact`,
|
|
272
265
|
`// shape of this object for the augmentation at the bottom of the file.`,
|
|
273
|
-
`const appConfig = ((
|
|
274
|
-
` /**`,
|
|
275
|
-
` * Identity. \`name\` is the one field core consumes — it titles the`,
|
|
276
|
-
` * startup banner ("▲ ${name}"). The rest is yours: stamp \`version\``,
|
|
277
|
-
` * into a /health payload, switch behaviour on \`env\`, etc.`,
|
|
278
|
-
` */`,
|
|
266
|
+
`const appConfig = (() => ({`,
|
|
279
267
|
` app: {`,
|
|
280
268
|
` name: ${JSON.stringify(name)},`,
|
|
281
|
-
` version: "0.1.0",`,
|
|
282
|
-
` description: "A Tulipes API",`,
|
|
283
|
-
` // development | staging | production | test — resolved from APP_ENV`,
|
|
284
|
-
` env: Environment.appEnv,`,
|
|
285
|
-
` // Read a declared variable (PORT lives in modules/core/meta.variables.json)`,
|
|
286
|
-
` port: Environment.get("PORT"),`,
|
|
287
269
|
` },`,
|
|
288
270
|
``,
|
|
289
271
|
` /**`,
|
|
290
|
-
` * Route prefixing. Modules build
|
|
272
|
+
` * Route prefixing. Modules build paths from this instead of`,
|
|
291
273
|
` * hardcoding "/api/v1", so versioning the whole API is a one-line`,
|
|
292
|
-
` * change here
|
|
274
|
+
` * change here.`,
|
|
293
275
|
` */`,
|
|
294
276
|
` api: {`,
|
|
295
277
|
` prefix: "/api/v1",`,
|
|
296
|
-
` // Where the docs/collection live, if you expose them`,
|
|
297
278
|
` docsPath: "/api/docs",`,
|
|
298
279
|
` },`,
|
|
299
280
|
``,
|
|
300
281
|
` /**`,
|
|
301
|
-
` * HTTP behaviour consumed by the sys security module`,
|
|
302
|
-
` * (modules/security/routes/security.routes.ts). Change the body limit`,
|
|
303
|
-
` * or CORS origins here rather than editing that file.`,
|
|
304
|
-
` */`,
|
|
305
|
-
` http: {`,
|
|
306
|
-
` // Passed to express.json({ limit }) — a parser without a cap is a`,
|
|
307
|
-
` // memory-exhaustion invitation.`,
|
|
308
|
-
` bodyLimit: "1mb",`,
|
|
309
|
-
` // Browser origins allowed to call this API. Add a cors() middleware`,
|
|
310
|
-
` // in the security module and feed it this list.`,
|
|
311
|
-
` corsOrigins: ["http://localhost:5173"],`,
|
|
312
|
-
` // Set true behind nginx/a load balancer so req.ip is the real client`,
|
|
313
|
-
` // (app.set("trust proxy", config.http!.trustProxy)).`,
|
|
314
|
-
` trustProxy: false,`,
|
|
315
|
-
` // Seconds a request may run before you abort it, if you add a timeout`,
|
|
316
|
-
` // middleware.`,
|
|
317
|
-
` requestTimeout: 30,`,
|
|
318
|
-
` },`,
|
|
319
|
-
``,
|
|
320
|
-
` /**`,
|
|
321
282
|
` * List-endpoint defaults, so every module paginates identically and`,
|
|
322
|
-
` * no caller can ask for
|
|
283
|
+
` * no caller can ask for ten thousand rows.`,
|
|
323
284
|
` */`,
|
|
324
285
|
` pagination: {`,
|
|
325
286
|
` defaultLimit: 20,`,
|
|
326
287
|
` maxLimit: 100,`,
|
|
327
288
|
` },`,
|
|
328
|
-
``,
|
|
329
|
-
` /**`,
|
|
330
|
-
` * Cross-cutting security knobs. Secrets themselves never live here —`,
|
|
331
|
-
` * declare them as \`"type": "secret"\` variables in meta.variables.json`,
|
|
332
|
-
` * (redacted in logs and the banner) and read them where needed.`,
|
|
333
|
-
` */`,
|
|
334
|
-
` security: {`,
|
|
335
|
-
` // Token lifetimes for an auth module, in seconds`,
|
|
336
|
-
` accessTokenTtl: 900, // 15 minutes`,
|
|
337
|
-
` refreshTokenTtl: 2_592_000, // 30 days`,
|
|
338
|
-
` // bcrypt/argon cost factor`,
|
|
339
|
-
` passwordRounds: 12,`,
|
|
340
|
-
` // Rate limiting, if you mount a limiter in the security module`,
|
|
341
|
-
` rateLimit: { windowSeconds: 60, max: 100 },`,
|
|
342
|
-
` },`,
|
|
343
|
-
``,
|
|
344
|
-
` /**`,
|
|
345
|
-
` * Public-facing URLs. Anything that builds a link a human will click —`,
|
|
346
|
-
` * password-reset emails, webhook callbacks, OAuth redirects — reads`,
|
|
347
|
-
` * these instead of guessing the host from a request.`,
|
|
348
|
-
` */`,
|
|
349
|
-
` urls: {`,
|
|
350
|
-
` // Built from PUBLIC_DOMAIN so links generated in production —`,
|
|
351
|
-
` // reset emails, webhook callbacks — point at the real host`,
|
|
352
|
-
` // instead of a localhost URL that is broken everywhere but this`,
|
|
353
|
-
` // machine. The "localhost" sentinel separates the two cases.`,
|
|
354
|
-
` api:`,
|
|
355
|
-
` Environment.get("PUBLIC_DOMAIN") === "localhost"`,
|
|
356
|
-
` ? \`http://localhost:\${Environment.get("PORT")}\``,
|
|
357
|
-
` : \`https://\${Environment.get("PUBLIC_DOMAIN")}\`,`,
|
|
358
|
-
` frontend: "http://localhost:5173",`,
|
|
359
|
-
` },`,
|
|
360
|
-
``,
|
|
361
|
-
` /**`,
|
|
362
|
-
` * Upload constraints shared by every module that accepts files.`,
|
|
363
|
-
` */`,
|
|
364
|
-
` uploads: {`,
|
|
365
|
-
` maxSizeMb: 10,`,
|
|
366
|
-
` allowedMimeTypes: ["image/png", "image/jpeg", "application/pdf"],`,
|
|
367
|
-
` },`,
|
|
368
|
-
``,
|
|
369
|
-
` /**`,
|
|
370
|
-
` * Feature flags. Cheap way to ship dark code: a module checks`,
|
|
371
|
-
` * \`config.features!.signup\` before mounting a route. Flip per`,
|
|
372
|
-
` * environment by reading a declared boolean variable instead of a`,
|
|
373
|
-
` * literal.`,
|
|
374
|
-
` */`,
|
|
375
|
-
` features: {`,
|
|
376
|
-
` signup: true,`,
|
|
377
|
-
` maintenanceMode: false,`,
|
|
378
|
-
` },`,
|
|
379
|
-
``,
|
|
380
|
-
` /**`,
|
|
381
|
-
` * Localization defaults for modules that render text (emails, errors).`,
|
|
382
|
-
` */`,
|
|
383
|
-
` i18n: {`,
|
|
384
|
-
` defaultLocale: "en",`,
|
|
385
|
-
` supportedLocales: ["en", "fr"],`,
|
|
386
|
-
` },`,
|
|
387
289
|
`})) satisfies AppConfigFn;`,
|
|
388
290
|
``,
|
|
389
291
|
`export default appConfig;`,
|
|
390
292
|
``,
|
|
391
293
|
`/**`,
|
|
392
|
-
` * Types
|
|
393
|
-
` * string, not \`unknown\`. \`
|
|
294
|
+
` * Types these keys at every call site: \`config.api!.prefix\` is a`,
|
|
295
|
+
` * string, not \`unknown\`. \`yarn sync\` does this automatically for`,
|
|
394
296
|
` * module.config.ts factories; the app-level seed types itself here.`,
|
|
395
|
-
` *
|
|
396
|
-
` * factory has run.`,
|
|
297
|
+
` * Partial because the config object is empty until this factory runs.`,
|
|
397
298
|
` */`,
|
|
398
299
|
`declare module "@tulipes/core/config" {`,
|
|
399
300
|
` interface GlobalConfig extends Partial<ReturnType<typeof appConfig>> {}`,
|
|
@@ -568,7 +469,7 @@ function renderProject(name, coreVersion) {
|
|
|
568
469
|
` root /var/www/html;`,
|
|
569
470
|
` }`,
|
|
570
471
|
``,
|
|
571
|
-
` # Keep in step with
|
|
472
|
+
` # Keep in step with the BODY_LIMIT variable. If nginx is stricter,`,
|
|
572
473
|
` # oversized requests die here as an nginx HTML page instead of the`,
|
|
573
474
|
` # API's JSON 413.`,
|
|
574
475
|
` client_max_body_size 1m;`,
|
|
@@ -590,8 +491,8 @@ function renderProject(name, coreVersion) {
|
|
|
590
491
|
` proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`,
|
|
591
492
|
` proxy_set_header X-Forwarded-Proto $scheme;`,
|
|
592
493
|
``,
|
|
593
|
-
` #
|
|
594
|
-
` # client IP from the headers above.`,
|
|
494
|
+
` # app.set("trust proxy", true) in the security module lets the`,
|
|
495
|
+
` # app read the real client IP from the headers above.`,
|
|
595
496
|
``,
|
|
596
497
|
` # Long-lived websockets must outlive nginx's 60s default.`,
|
|
597
498
|
` proxy_read_timeout 3600s;`,
|
|
@@ -806,7 +707,14 @@ function renderProject(name, coreVersion) {
|
|
|
806
707
|
// module needs live here rather than in a top-level lib/.
|
|
807
708
|
exports: { ".": "./index.ts" },
|
|
808
709
|
tulipes: { tier: "sys", priority: 0 },
|
|
809
|
-
dependencies: {
|
|
710
|
+
dependencies: {
|
|
711
|
+
"@tulipes/core": core,
|
|
712
|
+
ioredis: "^5",
|
|
713
|
+
mongoose: "^8",
|
|
714
|
+
pino: "^9",
|
|
715
|
+
"pino-pretty": "^13",
|
|
716
|
+
"pino-roll": "^3",
|
|
717
|
+
},
|
|
810
718
|
})],
|
|
811
719
|
["modules/core/meta.variables.json", json({
|
|
812
720
|
variables: [
|
|
@@ -840,6 +748,70 @@ function renderProject(name, coreVersion) {
|
|
|
840
748
|
description: 'Public hostname this API is served from; "localhost" means not deployed',
|
|
841
749
|
default: "localhost",
|
|
842
750
|
},
|
|
751
|
+
{
|
|
752
|
+
name: "LOG_LEVEL",
|
|
753
|
+
type: "enum",
|
|
754
|
+
enum: ["fatal", "error", "warn", "info", "debug", "trace"],
|
|
755
|
+
group: "logging",
|
|
756
|
+
description: "Lowest level that reaches any destination",
|
|
757
|
+
default: "info",
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
name: "LOG_CONSOLE",
|
|
761
|
+
type: "boolean",
|
|
762
|
+
group: "logging",
|
|
763
|
+
description: "Pretty coloured logs on stdout; turn off where a collector reads files only",
|
|
764
|
+
default: true,
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
name: "LOG_FILE",
|
|
768
|
+
type: "boolean",
|
|
769
|
+
group: "logging",
|
|
770
|
+
description: "Write JSON-line log files under LOG_DIR",
|
|
771
|
+
default: true,
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
name: "LOG_DIR",
|
|
775
|
+
type: "string",
|
|
776
|
+
group: "logging",
|
|
777
|
+
description: "Directory for log files, relative to the app root or absolute",
|
|
778
|
+
default: "logs",
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
name: "LOG_BASENAME",
|
|
782
|
+
type: "string",
|
|
783
|
+
group: "logging",
|
|
784
|
+
description: "Base name of the all-levels log file; the process mode and date are appended",
|
|
785
|
+
default: "app",
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
name: "LOG_ERROR_BASENAME",
|
|
789
|
+
type: "string",
|
|
790
|
+
group: "logging",
|
|
791
|
+
description: "Base name of the errors-only log file; the process mode and date are appended",
|
|
792
|
+
default: "error",
|
|
793
|
+
},
|
|
794
|
+
{
|
|
795
|
+
name: "LOG_ROTATION_FREQUENCY",
|
|
796
|
+
type: "string",
|
|
797
|
+
group: "logging",
|
|
798
|
+
description: 'Rotate on this schedule: "daily", "hourly", or milliseconds',
|
|
799
|
+
default: "daily",
|
|
800
|
+
},
|
|
801
|
+
{
|
|
802
|
+
name: "LOG_ROTATION_SIZE",
|
|
803
|
+
type: "string",
|
|
804
|
+
group: "logging",
|
|
805
|
+
description: 'Also rotate once a file reaches this size, e.g. "20m"',
|
|
806
|
+
default: "20m",
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
name: "LOG_RETENTION_FILES",
|
|
810
|
+
type: "number",
|
|
811
|
+
group: "logging",
|
|
812
|
+
description: "How many rotated files to keep per destination, besides the current one",
|
|
813
|
+
default: 14,
|
|
814
|
+
},
|
|
843
815
|
],
|
|
844
816
|
})],
|
|
845
817
|
["modules/core/helpers/cache/types.ts", [
|
|
@@ -1281,6 +1253,106 @@ function renderProject(name, coreVersion) {
|
|
|
1281
1253
|
`};`,
|
|
1282
1254
|
``,
|
|
1283
1255
|
].join("\n")],
|
|
1256
|
+
["modules/core/helpers/logger/index.ts", [
|
|
1257
|
+
`import { join } from "node:path";`,
|
|
1258
|
+
`// Default import, not the named one: pino.transport lives on the`,
|
|
1259
|
+
`// namespace that only the default export carries.`,
|
|
1260
|
+
`import pino from "pino";`,
|
|
1261
|
+
`import type { Logger, TransportTargetOptions } from "pino";`,
|
|
1262
|
+
`import type { ProcessMode } from "@tulipes/core/boot";`,
|
|
1263
|
+
`import type { Environment } from "@tulipes/core/env";`,
|
|
1264
|
+
``,
|
|
1265
|
+
`/**`,
|
|
1266
|
+
` * The app's logger, shared by every module.`,
|
|
1267
|
+
` *`,
|
|
1268
|
+
` * Two destinations at once, because they answer different questions:`,
|
|
1269
|
+
` *`,
|
|
1270
|
+
` * console pretty and coloured, for a human watching a terminal`,
|
|
1271
|
+
` * files JSON lines, for grep/jq/Loki/CloudWatch — one object per`,
|
|
1272
|
+
` * line with a stable shape, which is what makes logs queryable`,
|
|
1273
|
+
` *`,
|
|
1274
|
+
` * Files are named per process mode. Backend and worker run at the same`,
|
|
1275
|
+
` * time, and two processes rotating one file race: both notice the size`,
|
|
1276
|
+
` * limit, both rename, and one of them loses entries. Separate files cost`,
|
|
1277
|
+
` * nothing and remove the failure entirely.`,
|
|
1278
|
+
` */`,
|
|
1279
|
+
`let instance: Logger | undefined;`,
|
|
1280
|
+
``,
|
|
1281
|
+
`export function logger(environment: Environment, mode: ProcessMode = "backend"): Logger {`,
|
|
1282
|
+
` if (instance) return instance;`,
|
|
1283
|
+
``,
|
|
1284
|
+
` const level = String(environment.get("LOG_LEVEL"));`,
|
|
1285
|
+
` const targets: TransportTargetOptions[] = [];`,
|
|
1286
|
+
``,
|
|
1287
|
+
` if (Boolean(environment.get("LOG_CONSOLE"))) {`,
|
|
1288
|
+
` targets.push({`,
|
|
1289
|
+
` target: "pino-pretty",`,
|
|
1290
|
+
` level,`,
|
|
1291
|
+
` options: {`,
|
|
1292
|
+
` colorize: true,`,
|
|
1293
|
+
` translateTime: "SYS:HH:MM:ss.l",`,
|
|
1294
|
+
` // pid and hostname matter in aggregated files, not on the terminal`,
|
|
1295
|
+
` // of the machine that is obviously running it.`,
|
|
1296
|
+
` ignore: "pid,hostname",`,
|
|
1297
|
+
` messageFormat: "{if module}[{module}] {end}{msg}",`,
|
|
1298
|
+
` },`,
|
|
1299
|
+
` });`,
|
|
1300
|
+
` }`,
|
|
1301
|
+
``,
|
|
1302
|
+
` if (Boolean(environment.get("LOG_FILE"))) {`,
|
|
1303
|
+
` const dir = String(environment.get("LOG_DIR"));`,
|
|
1304
|
+
` const rotation = {`,
|
|
1305
|
+
` // Both limits apply: whichever trips first rotates the file.`,
|
|
1306
|
+
` frequency: String(environment.get("LOG_ROTATION_FREQUENCY")),`,
|
|
1307
|
+
` size: String(environment.get("LOG_ROTATION_SIZE")),`,
|
|
1308
|
+
` limit: { count: Number(environment.get("LOG_RETENTION_FILES")) },`,
|
|
1309
|
+
` extension: ".log",`,
|
|
1310
|
+
` dateFormat: "yyyy-MM-dd",`,
|
|
1311
|
+
` mkdir: true,`,
|
|
1312
|
+
` };`,
|
|
1313
|
+
``,
|
|
1314
|
+
` targets.push({`,
|
|
1315
|
+
` target: "pino-roll",`,
|
|
1316
|
+
` level,`,
|
|
1317
|
+
` options: { ...rotation, file: join(dir, \`\${environment.get("LOG_BASENAME")}-\${mode}\`) },`,
|
|
1318
|
+
` });`,
|
|
1319
|
+
``,
|
|
1320
|
+
` // A second copy of errors only. Everything here is also in the main`,
|
|
1321
|
+
` // file; the point is a small file to open first when something breaks,`,
|
|
1322
|
+
` // instead of paging through a day of request logs.`,
|
|
1323
|
+
` targets.push({`,
|
|
1324
|
+
` target: "pino-roll",`,
|
|
1325
|
+
` level: "error",`,
|
|
1326
|
+
` options: {`,
|
|
1327
|
+
` ...rotation,`,
|
|
1328
|
+
` file: join(dir, \`\${environment.get("LOG_ERROR_BASENAME")}-\${mode}\`),`,
|
|
1329
|
+
` },`,
|
|
1330
|
+
` });`,
|
|
1331
|
+
` }`,
|
|
1332
|
+
``,
|
|
1333
|
+
` instance = targets.length`,
|
|
1334
|
+
` ? pino({ level, base: { mode } }, pino.transport({ targets }))`,
|
|
1335
|
+
` : // Every destination disabled: still return a working logger rather`,
|
|
1336
|
+
` // than null-checking at a hundred call sites.`,
|
|
1337
|
+
` pino({ level, base: { mode } });`,
|
|
1338
|
+
``,
|
|
1339
|
+
` return instance;`,
|
|
1340
|
+
`}`,
|
|
1341
|
+
``,
|
|
1342
|
+
`/**`,
|
|
1343
|
+
` * A logger tagged with the module it belongs to, so a line's origin is`,
|
|
1344
|
+
` * visible in both destinations: \`[users] ready\` on the console, and a`,
|
|
1345
|
+
` * queryable \`"module":"users"\` field in the file.`,
|
|
1346
|
+
` */`,
|
|
1347
|
+
`export function moduleLogger(`,
|
|
1348
|
+
` environment: Environment,`,
|
|
1349
|
+
` name: string,`,
|
|
1350
|
+
` mode: ProcessMode = "backend",`,
|
|
1351
|
+
`): Logger {`,
|
|
1352
|
+
` return logger(environment, mode).child({ module: name });`,
|
|
1353
|
+
`}`,
|
|
1354
|
+
``,
|
|
1355
|
+
].join("\n")],
|
|
1284
1356
|
["modules/core/index.ts", [
|
|
1285
1357
|
`/**`,
|
|
1286
1358
|
` * What the core module offers the rest of the app.`,
|
|
@@ -1300,6 +1372,8 @@ function renderProject(name, coreVersion) {
|
|
|
1300
1372
|
` type Cache,`,
|
|
1301
1373
|
`} from "./helpers/cache/index.js";`,
|
|
1302
1374
|
``,
|
|
1375
|
+
`export { logger, moduleLogger } from "./helpers/logger/index.js";`,
|
|
1376
|
+
``,
|
|
1303
1377
|
`export {`,
|
|
1304
1378
|
` baseSchemaOptions,`,
|
|
1305
1379
|
` baseModelPlugin,`,
|
|
@@ -1377,27 +1451,26 @@ function renderProject(name, coreVersion) {
|
|
|
1377
1451
|
version: "0.0.0",
|
|
1378
1452
|
private: true,
|
|
1379
1453
|
type: "module",
|
|
1380
|
-
|
|
1454
|
+
// dependsOn "core" for the shared logger it imports from @app/core.
|
|
1455
|
+
tulipes: { tier: "sys", priority: 10, dependsOn: ["core"] },
|
|
1381
1456
|
dependencies: {
|
|
1382
1457
|
"@tulipes/core": core,
|
|
1458
|
+
"@app/core": "workspace:*",
|
|
1383
1459
|
cors: "^2",
|
|
1384
1460
|
express: "^5",
|
|
1385
1461
|
helmet: "^8",
|
|
1386
|
-
pino: "^9",
|
|
1387
1462
|
"pino-http": "^10",
|
|
1388
|
-
"pino-pretty": "^13",
|
|
1389
1463
|
},
|
|
1390
1464
|
devDependencies: { "@types/cors": "^2" },
|
|
1391
1465
|
})],
|
|
1392
1466
|
["modules/security/meta.variables.json", json({
|
|
1393
1467
|
variables: [
|
|
1394
1468
|
{
|
|
1395
|
-
name: "
|
|
1396
|
-
type: "
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
default: "info",
|
|
1469
|
+
name: "BODY_LIMIT",
|
|
1470
|
+
type: "string",
|
|
1471
|
+
group: "http",
|
|
1472
|
+
description: 'Largest JSON request body express will parse, e.g. "1mb"; nginx client_max_body_size must be at least this',
|
|
1473
|
+
default: "1mb",
|
|
1401
1474
|
},
|
|
1402
1475
|
{
|
|
1403
1476
|
name: "CORS_ORIGINS",
|
|
@@ -1479,33 +1552,6 @@ function renderProject(name, coreVersion) {
|
|
|
1479
1552
|
`}`,
|
|
1480
1553
|
``,
|
|
1481
1554
|
].join("\n")],
|
|
1482
|
-
["modules/security/helpers/logger.ts", [
|
|
1483
|
-
`import { pino, type Logger } from "pino";`,
|
|
1484
|
-
`import type { Environment } from "@tulipes/core/env";`,
|
|
1485
|
-
``,
|
|
1486
|
-
`let instance: Logger | undefined;`,
|
|
1487
|
-
``,
|
|
1488
|
-
`/**`,
|
|
1489
|
-
` * The app's pino logger — JSON lines in production, pretty-printed in`,
|
|
1490
|
-
` * development. Level comes from LOG_LEVEL so a deploy can turn on`,
|
|
1491
|
-
` * debug without a code change.`,
|
|
1492
|
-
` */`,
|
|
1493
|
-
`export function logger(environment: Environment): Logger {`,
|
|
1494
|
-
` if (instance) return instance;`,
|
|
1495
|
-
``,
|
|
1496
|
-
` instance = pino({`,
|
|
1497
|
-
` level: String(environment.get("LOG_LEVEL")),`,
|
|
1498
|
-
` ...(environment.appEnv === "development" && {`,
|
|
1499
|
-
` transport: {`,
|
|
1500
|
-
` target: "pino-pretty",`,
|
|
1501
|
-
` options: { translateTime: "HH:MM:ss", ignore: "pid,hostname" },`,
|
|
1502
|
-
` },`,
|
|
1503
|
-
` }),`,
|
|
1504
|
-
` });`,
|
|
1505
|
-
` return instance;`,
|
|
1506
|
-
`}`,
|
|
1507
|
-
``,
|
|
1508
|
-
].join("\n")],
|
|
1509
1555
|
["modules/security/routes/security.routes.ts", [
|
|
1510
1556
|
`import cors from "cors";`,
|
|
1511
1557
|
`import express from "express";`,
|
|
@@ -1514,12 +1560,12 @@ function renderProject(name, coreVersion) {
|
|
|
1514
1560
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
1515
1561
|
``,
|
|
1516
1562
|
`import { corsOptions, helmetOptions } from "../helpers/hardening.js";`,
|
|
1517
|
-
`import { logger } from "
|
|
1563
|
+
`import { logger } from "@app/core";`,
|
|
1518
1564
|
``,
|
|
1519
1565
|
`/**`,
|
|
1520
1566
|
` * The app's request-hardening stack. Sys tier, so this mounts ahead of`,
|
|
1521
|
-
` * every app-tier router — the framework core mounts no middleware of`,
|
|
1522
|
-
` *
|
|
1567
|
+
` * every app-tier router — the framework core mounts no middleware of its`,
|
|
1568
|
+
` * own; this module IS the pipeline's head.`,
|
|
1523
1569
|
` *`,
|
|
1524
1570
|
` * Order matters:`,
|
|
1525
1571
|
` * helmet — headers on every response, including errors and preflights`,
|
|
@@ -1527,15 +1573,21 @@ function renderProject(name, coreVersion) {
|
|
|
1527
1573
|
` * pino — logs the request once the two above have had their say`,
|
|
1528
1574
|
` * json — parsing last, so a rejected origin never reaches the parser`,
|
|
1529
1575
|
` */`,
|
|
1530
|
-
`export default function securityRoutes(
|
|
1576
|
+
`export default function securityRoutes(ctx: Ctx): void {`,
|
|
1577
|
+
` const { app, Environment, config, mode } = ctx`,
|
|
1578
|
+
``,
|
|
1531
1579
|
` app!.use(helmet(helmetOptions(Environment)));`,
|
|
1532
1580
|
` app!.use(cors(corsOptions(Environment)));`,
|
|
1533
1581
|
``,
|
|
1534
1582
|
` app!.use(`,
|
|
1535
1583
|
` pinoHttp({`,
|
|
1536
|
-
` logger: logger(Environment),`,
|
|
1537
|
-
` //
|
|
1538
|
-
` //
|
|
1584
|
+
` logger: logger(Environment, mode),`,
|
|
1585
|
+
` // Without this a 500 is logged at info, so the errors-only log file`,
|
|
1586
|
+
` // stays empty exactly when you need it.`,
|
|
1587
|
+
` customLogLevel: (_req, res, err) =>`,
|
|
1588
|
+
` err || res.statusCode >= 500 ? "error" : res.statusCode >= 400 ? "warn" : "info",`,
|
|
1589
|
+
` // The core module (priority 0) stamps X-Request-Id before this runs —`,
|
|
1590
|
+
` // reuse it so log lines and response headers tell one story.`,
|
|
1539
1591
|
` genReqId: (_req, res) => String(res.getHeader("X-Request-Id") ?? ""),`,
|
|
1540
1592
|
` serializers: {`,
|
|
1541
1593
|
` req: (req: { method: string; url: string }) => ({`,
|
|
@@ -1547,9 +1599,11 @@ function renderProject(name, coreVersion) {
|
|
|
1547
1599
|
` }),`,
|
|
1548
1600
|
` );`,
|
|
1549
1601
|
``,
|
|
1550
|
-
` // Body-size cap is a security control too — a parser without a limit`,
|
|
1551
|
-
` //
|
|
1552
|
-
`
|
|
1602
|
+
` // Body-size cap is a security control too — a parser without a limit is`,
|
|
1603
|
+
` // a memory-exhaustion invitation. Declared by this module, because it`,
|
|
1604
|
+
` // varies per deployment: an upload-heavy API raises it without touching`,
|
|
1605
|
+
` // code.`,
|
|
1606
|
+
` app!.use(express.json({ limit: String(Environment.get("BODY_LIMIT")) }));`,
|
|
1553
1607
|
`}`,
|
|
1554
1608
|
``,
|
|
1555
1609
|
].join("\n")],
|
|
@@ -1781,6 +1835,8 @@ function renderProject(name, coreVersion) {
|
|
|
1781
1835
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
1782
1836
|
`import type { QueueRegistry } from "@tulipes/core/queues";`,
|
|
1783
1837
|
``,
|
|
1838
|
+
`import { moduleLogger } from "@app/core";`,
|
|
1839
|
+
``,
|
|
1784
1840
|
`/**`,
|
|
1785
1841
|
` * ONE file describes both sides. The backend process registers the queue`,
|
|
1786
1842
|
` * so routes can produce into it; \`yarn worker\` turns the processor`,
|
|
@@ -1788,7 +1844,12 @@ function renderProject(name, coreVersion) {
|
|
|
1788
1844
|
` *`,
|
|
1789
1845
|
` * Queue names use "." — BullMQ reserves ":" as its redis separator.`,
|
|
1790
1846
|
` */`,
|
|
1791
|
-
`export default function helloQueues(
|
|
1847
|
+
`export default function helloQueues(ctx: Ctx, queues: QueueRegistry): void {`,
|
|
1848
|
+
` const { models } = ctx;`,
|
|
1849
|
+
` // The same logger the backend uses. In the worker process it writes`,
|
|
1850
|
+
` // to app-worker.log, so the two processes never share a file.`,
|
|
1851
|
+
` const log = moduleLogger(ctx.Environment, "hello", ctx.mode);`,
|
|
1852
|
+
``,
|
|
1792
1853
|
` queues.define("hello.count-greeting");`,
|
|
1793
1854
|
``,
|
|
1794
1855
|
` queues.process("hello.count-greeting", async (job) => {`,
|
|
@@ -1796,6 +1857,7 @@ function renderProject(name, coreVersion) {
|
|
|
1796
1857
|
` // job.data and re-read state here rather than shipping documents.`,
|
|
1797
1858
|
` const { name } = job.data as { name: string };`,
|
|
1798
1859
|
` await models!.get("Greeting").updateOne({ name }, { $inc: { timesUsed: 1 } });`,
|
|
1860
|
+
` log.info({ name }, "counted greeting");`,
|
|
1799
1861
|
` return { counted: name };`,
|
|
1800
1862
|
` });`,
|
|
1801
1863
|
`}`,
|
|
@@ -1891,6 +1953,35 @@ function renderProject(name, coreVersion) {
|
|
|
1891
1953
|
`| \`yarn tulipes new module <name>\` | scaffold a module |`,
|
|
1892
1954
|
`| \`yarn tulipes update\` | upgrade the framework everywhere it is declared |`,
|
|
1893
1955
|
``,
|
|
1956
|
+
`## Logging`,
|
|
1957
|
+
``,
|
|
1958
|
+
`The logger lives in the core module and is shared by every other one:`,
|
|
1959
|
+
``,
|
|
1960
|
+
"```ts",
|
|
1961
|
+
`import { moduleLogger } from "@app/core";`,
|
|
1962
|
+
``,
|
|
1963
|
+
`const log = moduleLogger(ctx.Environment, "billing", ctx.mode);`,
|
|
1964
|
+
`log.info({ invoiceId }, "invoice sent");`,
|
|
1965
|
+
"```",
|
|
1966
|
+
``,
|
|
1967
|
+
`It writes to two destinations at once, because they answer different`,
|
|
1968
|
+
`questions: a **coloured, human-readable** stream on the console, and`,
|
|
1969
|
+
`**JSON lines** in \`logs/\` — one object per line, which is what makes`,
|
|
1970
|
+
`them greppable with jq or ingestible by a collector. Errors are`,
|
|
1971
|
+
`additionally copied to their own file, so the first thing to open when`,
|
|
1972
|
+
`something breaks is small.`,
|
|
1973
|
+
``,
|
|
1974
|
+
`Files carry the process mode — \`app-backend.log\`, \`app-worker.log\` —`,
|
|
1975
|
+
`because backend and worker run at the same time and two processes`,
|
|
1976
|
+
`rotating one file will race.`,
|
|
1977
|
+
``,
|
|
1978
|
+
`Everything is configured from \`modules/core/meta.variables.json\`:`,
|
|
1979
|
+
`\`LOG_LEVEL\`, \`LOG_CONSOLE\`, \`LOG_FILE\`, \`LOG_DIR\`, \`LOG_BASENAME\`,`,
|
|
1980
|
+
`\`LOG_ERROR_BASENAME\`, and rotation via \`LOG_ROTATION_FREQUENCY\`,`,
|
|
1981
|
+
`\`LOG_ROTATION_SIZE\` and \`LOG_RETENTION_FILES\` (how many rotated files`,
|
|
1982
|
+
`to keep). Rotation happens on whichever of frequency or size trips`,
|
|
1983
|
+
`first.`,
|
|
1984
|
+
``,
|
|
1894
1985
|
`## Scripts`,
|
|
1895
1986
|
``,
|
|
1896
1987
|
`\`scripts/\` holds one-off tasks — backfills, exports, admin chores.`,
|