@tulipes/core 0.1.3 → 0.1.5
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 +25 -10
- package/dist/boot/boot.d.ts +3 -3
- package/dist/boot/boot.js +12 -9
- package/dist/boot/boot.js.map +1 -1
- package/dist/boot/contracts.d.ts +2 -2
- package/dist/cli/init.d.ts +9 -7
- package/dist/cli/init.js +464 -67
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.js +1 -1
- package/dist/http/error-handler.js +21 -0
- package/dist/http/error-handler.js.map +1 -1
- package/dist/queues/queue-manager.d.ts +1 -1
- package/dist/queues/queue-manager.js +1 -1
- package/dist/sockets/load-sockets.d.ts +2 -2
- package/dist/sockets/load-sockets.js +1 -1
- package/dist/sockets/socket-manager.d.ts +1 -1
- package/dist/sockets/socket-manager.js +1 -1
- package/package.json +3 -2
- package/templates/CLAUDE.md +72 -0
- package/templates/claude/skills/tulipes-boot-errors/SKILL.md +64 -0
- package/templates/claude/skills/tulipes-endpoint/SKILL.md +82 -0
- package/templates/claude/skills/tulipes-env-variable/SKILL.md +78 -0
- package/templates/claude/skills/tulipes-model/SKILL.md +75 -0
- package/templates/claude/skills/tulipes-module/SKILL.md +60 -0
- package/templates/claude/skills/tulipes-permissions/SKILL.md +61 -0
- package/templates/claude/skills/tulipes-queue/SKILL.md +62 -0
- package/templates/claude/skills/tulipes-socket/SKILL.md +55 -0
package/dist/cli/init.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
/**
|
|
5
|
-
* `tulipes init [dir]` — scaffold a complete application
|
|
6
|
-
* ZERO infrastructure: the generated core module declares no MONGO_URI or
|
|
7
|
-
* REDIS_URL, and with no models and no queue files the pipeline skips both
|
|
8
|
-
* engines legitimately. `yarn install && yarn dev` must always succeed on a
|
|
9
|
-
* bare machine; databases arrive later as one meta.variables.json entry.
|
|
5
|
+
* `tulipes init [dir]` — scaffold a complete application.
|
|
10
6
|
*
|
|
11
7
|
* Three modules ship: core (sys 0 — env contract, roles, request ids),
|
|
12
|
-
* security (sys 10 —
|
|
13
|
-
*
|
|
8
|
+
* security (sys 10 — helmet, cors, logging, body parsing) and hello, a
|
|
9
|
+
* worked example of EVERY module contract: config with lifecycle hooks,
|
|
10
|
+
* ACL grants, routes, a controller, a helper, a model, a bootstrap task,
|
|
11
|
+
* a queue with its processor, and a socket namespace.
|
|
12
|
+
*
|
|
13
|
+
* The generated app therefore needs mongo and redis running — models and
|
|
14
|
+
* queues cannot work without them, and the boot refuses rather than
|
|
15
|
+
* degrading quietly.
|
|
14
16
|
*/
|
|
15
17
|
export async function runInit(rootDir, target) {
|
|
16
18
|
const dir = resolve(rootDir, target ?? ".");
|
|
@@ -28,6 +30,7 @@ export async function runInit(rootDir, target) {
|
|
|
28
30
|
writeFileSync(filePath, content);
|
|
29
31
|
console.log(` create ${relPath}`);
|
|
30
32
|
}
|
|
33
|
+
copyAgentGuides(dir);
|
|
31
34
|
console.log([
|
|
32
35
|
"",
|
|
33
36
|
`Project "${name}" is ready. Next:`,
|
|
@@ -39,8 +42,9 @@ export async function runInit(rootDir, target) {
|
|
|
39
42
|
"",
|
|
40
43
|
" → http://localhost:3000/hello",
|
|
41
44
|
"",
|
|
42
|
-
"
|
|
43
|
-
"
|
|
45
|
+
"Needs mongo and redis running — see MONGO_URI / REDIS_URL in",
|
|
46
|
+
".envs/.env.development. Run `yarn worker` in a second terminal to",
|
|
47
|
+
"consume queued jobs.",
|
|
44
48
|
].filter((line) => line !== undefined).join("\n"));
|
|
45
49
|
}
|
|
46
50
|
/**
|
|
@@ -49,10 +53,39 @@ export async function runInit(rootDir, target) {
|
|
|
49
53
|
* the exports map deliberately doesn't expose package.json.
|
|
50
54
|
*/
|
|
51
55
|
function ownVersion() {
|
|
52
|
-
const
|
|
53
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
56
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8"));
|
|
54
57
|
return pkg.version;
|
|
55
58
|
}
|
|
59
|
+
function packageRoot() {
|
|
60
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Instructions for AI coding agents working in the generated app: an
|
|
64
|
+
* always-loaded CLAUDE.md plus task-scoped skills. They ship as real
|
|
65
|
+
* markdown in the package (templates/) rather than string literals here,
|
|
66
|
+
* so they stay editable and reviewable as documents.
|
|
67
|
+
*
|
|
68
|
+
* The source tree uses `claude/` and is written out as `.claude/`: npm
|
|
69
|
+
* has a long history of mangling dot-prefixed paths inside published
|
|
70
|
+
* tarballs, and this sidesteps it entirely.
|
|
71
|
+
*/
|
|
72
|
+
function copyAgentGuides(dir) {
|
|
73
|
+
const templates = join(packageRoot(), "templates");
|
|
74
|
+
if (!existsSync(templates))
|
|
75
|
+
return; // tolerate a stripped install
|
|
76
|
+
const claudeMd = join(templates, "CLAUDE.md");
|
|
77
|
+
if (existsSync(claudeMd)) {
|
|
78
|
+
cpSync(claudeMd, join(dir, "CLAUDE.md"));
|
|
79
|
+
console.log(" create CLAUDE.md");
|
|
80
|
+
}
|
|
81
|
+
const skills = join(templates, "claude");
|
|
82
|
+
if (existsSync(skills)) {
|
|
83
|
+
cpSync(skills, join(dir, ".claude"), { recursive: true });
|
|
84
|
+
for (const skill of readdirSync(join(skills, "skills"))) {
|
|
85
|
+
console.log(` create .claude/skills/${skill}/SKILL.md`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
56
89
|
function sanitizeName(raw) {
|
|
57
90
|
const name = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
58
91
|
return name || "tulipes-app";
|
|
@@ -60,7 +93,7 @@ function sanitizeName(raw) {
|
|
|
60
93
|
const json = (value) => JSON.stringify(value, null, 2) + "\n";
|
|
61
94
|
function renderProject(name, coreVersion) {
|
|
62
95
|
const core = `^${coreVersion}`;
|
|
63
|
-
|
|
96
|
+
const files = [
|
|
64
97
|
["package.json", json({
|
|
65
98
|
name,
|
|
66
99
|
private: true,
|
|
@@ -130,7 +163,7 @@ function renderProject(name, coreVersion) {
|
|
|
130
163
|
["app.ts", [
|
|
131
164
|
`import { boot } from "@tulipes/core/boot";`,
|
|
132
165
|
``,
|
|
133
|
-
`await boot({ rootDir: import.meta.dirname, mode: "
|
|
166
|
+
`await boot({ rootDir: import.meta.dirname, mode: "backend" });`,
|
|
134
167
|
``,
|
|
135
168
|
].join("\n")],
|
|
136
169
|
["worker.ts", [
|
|
@@ -305,6 +338,13 @@ function renderProject(name, coreVersion) {
|
|
|
305
338
|
`PORT=3000`,
|
|
306
339
|
`LOG_LEVEL=debug`,
|
|
307
340
|
``,
|
|
341
|
+
`# Browser origins allowed to call this API. Empty (the default) allows`,
|
|
342
|
+
`# none; "*" allows any but forbids credentials.`,
|
|
343
|
+
`CORS_ORIGINS=http://localhost:5173`,
|
|
344
|
+
``,
|
|
345
|
+
`MONGO_URI=mongodb://127.0.0.1:27017/${name}`,
|
|
346
|
+
`REDIS_URL=redis://127.0.0.1:6379`,
|
|
347
|
+
``,
|
|
308
348
|
].join("\n")],
|
|
309
349
|
// ── modules/core — sys tier, priority 0: the very first router ─────────
|
|
310
350
|
["modules/core/package.json", json({
|
|
@@ -321,9 +361,25 @@ function renderProject(name, coreVersion) {
|
|
|
321
361
|
name: "PORT",
|
|
322
362
|
type: "number",
|
|
323
363
|
group: "http",
|
|
324
|
-
description: "HTTP port the
|
|
364
|
+
description: "HTTP port the backend process listens on",
|
|
325
365
|
default: 3000,
|
|
326
366
|
},
|
|
367
|
+
// Infra connection strings live in the sys core module because every
|
|
368
|
+
// feature shares them — one owner per variable, no duplicates.
|
|
369
|
+
{
|
|
370
|
+
name: "MONGO_URI",
|
|
371
|
+
type: "url",
|
|
372
|
+
group: "database",
|
|
373
|
+
required: true,
|
|
374
|
+
description: "MongoDB connection string",
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "REDIS_URL",
|
|
378
|
+
type: "url",
|
|
379
|
+
group: "redis",
|
|
380
|
+
required: true,
|
|
381
|
+
description: "Redis connection string (queues, cache)",
|
|
382
|
+
},
|
|
327
383
|
],
|
|
328
384
|
})],
|
|
329
385
|
["modules/core/module.acl.ts", [
|
|
@@ -367,11 +423,14 @@ function renderProject(name, coreVersion) {
|
|
|
367
423
|
tulipes: { tier: "sys", priority: 10 },
|
|
368
424
|
dependencies: {
|
|
369
425
|
"@tulipes/core": core,
|
|
426
|
+
cors: "^2",
|
|
370
427
|
express: "^5",
|
|
428
|
+
helmet: "^8",
|
|
371
429
|
pino: "^9",
|
|
372
430
|
"pino-http": "^10",
|
|
373
431
|
"pino-pretty": "^13",
|
|
374
432
|
},
|
|
433
|
+
devDependencies: { "@types/cors": "^2" },
|
|
375
434
|
})],
|
|
376
435
|
["modules/security/meta.variables.json", json({
|
|
377
436
|
variables: [
|
|
@@ -383,28 +442,85 @@ function renderProject(name, coreVersion) {
|
|
|
383
442
|
description: "Minimum pino log level",
|
|
384
443
|
default: "info",
|
|
385
444
|
},
|
|
445
|
+
{
|
|
446
|
+
name: "CORS_ORIGINS",
|
|
447
|
+
type: "string",
|
|
448
|
+
group: "cors",
|
|
449
|
+
description: 'Comma-separated browser origins allowed to call this API; "*" allows any, empty allows none (same-origin only)',
|
|
450
|
+
default: "",
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
name: "CORS_CREDENTIALS",
|
|
454
|
+
type: "boolean",
|
|
455
|
+
group: "cors",
|
|
456
|
+
description: "Allow cookies and Authorization headers on cross-origin requests (cannot be combined with CORS_ORIGINS=*)",
|
|
457
|
+
default: false,
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
name: "HSTS_MAX_AGE",
|
|
461
|
+
type: "number",
|
|
462
|
+
group: "hardening",
|
|
463
|
+
description: "Strict-Transport-Security max-age in seconds; 0 disables the header (browsers only honour it over HTTPS)",
|
|
464
|
+
default: 15_552_000,
|
|
465
|
+
},
|
|
386
466
|
],
|
|
387
467
|
})],
|
|
388
|
-
["modules/security/helpers/
|
|
389
|
-
`import type {
|
|
468
|
+
["modules/security/helpers/hardening.ts", [
|
|
469
|
+
`import type { CorsOptions } from "cors";`,
|
|
470
|
+
`import type { HelmetOptions } from "helmet";`,
|
|
471
|
+
`import type { Environment } from "@tulipes/core/env";`,
|
|
390
472
|
``,
|
|
391
473
|
`/**`,
|
|
392
|
-
` *
|
|
393
|
-
` *
|
|
394
|
-
` *
|
|
474
|
+
` * Helmet tuned for an API rather than a web page. Its defaults assume a`,
|
|
475
|
+
` * site serving HTML and its own assets; this one serves JSON and`,
|
|
476
|
+
` * nothing else, so the policy can be far stricter.`,
|
|
395
477
|
` */`,
|
|
396
|
-
`export function
|
|
397
|
-
`
|
|
398
|
-
|
|
399
|
-
`
|
|
400
|
-
`
|
|
401
|
-
` //
|
|
402
|
-
`
|
|
403
|
-
`
|
|
404
|
-
`
|
|
478
|
+
`export function helmetOptions(environment: Environment): HelmetOptions {`,
|
|
479
|
+
` const maxAge = Number(environment.get("HSTS_MAX_AGE"));`,
|
|
480
|
+
``,
|
|
481
|
+
` return {`,
|
|
482
|
+
` // An API serves no active content: deny every resource type`,
|
|
483
|
+
` // outright, which defuses any HTML-ish response a bug produces.`,
|
|
484
|
+
` contentSecurityPolicy: {`,
|
|
485
|
+
` useDefaults: false,`,
|
|
486
|
+
` directives: { "default-src": ["'none'"], "frame-ancestors": ["'none'"] },`,
|
|
487
|
+
` },`,
|
|
488
|
+
` // Browsers only honour HSTS over HTTPS, so it is inert in local dev`,
|
|
489
|
+
` // — but a max-age of 0 lets a deployment switch it off deliberately.`,
|
|
490
|
+
` hsts: maxAge > 0 ? { maxAge, includeSubDomains: true } : false,`,
|
|
491
|
+
` // Helmet defaults to SAMEORIGIN; an API is never framed at all.`,
|
|
492
|
+
` frameguard: { action: "deny" },`,
|
|
493
|
+
` // Irrelevant without a browsing context, and each costs a header.`,
|
|
494
|
+
` originAgentCluster: false,`,
|
|
495
|
+
` crossOriginEmbedderPolicy: false,`,
|
|
405
496
|
` };`,
|
|
406
497
|
`}`,
|
|
407
498
|
``,
|
|
499
|
+
`/**`,
|
|
500
|
+
` * CORS from the environment, because allowed origins differ per`,
|
|
501
|
+
` * deployment. Fail-closed: the default is an empty list, meaning no`,
|
|
502
|
+
` * cross-origin browser may call this API until one is named.`,
|
|
503
|
+
` */`,
|
|
504
|
+
`export function corsOptions(environment: Environment): CorsOptions {`,
|
|
505
|
+
` const raw = String(environment.get("CORS_ORIGINS")).trim();`,
|
|
506
|
+
` const credentials = Boolean(environment.get("CORS_CREDENTIALS"));`,
|
|
507
|
+
``,
|
|
508
|
+
` if (raw === "*") {`,
|
|
509
|
+
` // "*" and credentials are mutually exclusive per the CORS spec — the`,
|
|
510
|
+
` // browser rejects the response, so refuse the combination at boot`,
|
|
511
|
+
` // rather than shipping an API that silently fails in production.`,
|
|
512
|
+
` if (credentials) {`,
|
|
513
|
+
` throw new Error(`,
|
|
514
|
+
` 'CORS_ORIGINS="*" cannot be combined with CORS_CREDENTIALS=true — list the origins explicitly',`,
|
|
515
|
+
` );`,
|
|
516
|
+
` }`,
|
|
517
|
+
` return { origin: true, credentials: false };`,
|
|
518
|
+
` }`,
|
|
519
|
+
``,
|
|
520
|
+
` const origins = raw.split(",").map((origin) => origin.trim()).filter(Boolean);`,
|
|
521
|
+
` return { origin: origins, credentials };`,
|
|
522
|
+
`}`,
|
|
523
|
+
``,
|
|
408
524
|
].join("\n")],
|
|
409
525
|
["modules/security/helpers/logger.ts", [
|
|
410
526
|
`import { pino, type Logger } from "pino";`,
|
|
@@ -434,26 +550,35 @@ function renderProject(name, coreVersion) {
|
|
|
434
550
|
``,
|
|
435
551
|
].join("\n")],
|
|
436
552
|
["modules/security/routes/security.routes.ts", [
|
|
553
|
+
`import cors from "cors";`,
|
|
437
554
|
`import express from "express";`,
|
|
555
|
+
`import helmet from "helmet";`,
|
|
438
556
|
`import { pinoHttp } from "pino-http";`,
|
|
439
557
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
440
558
|
``,
|
|
441
|
-
`import {
|
|
559
|
+
`import { corsOptions, helmetOptions } from "../helpers/hardening.js";`,
|
|
442
560
|
`import { logger } from "../helpers/logger.js";`,
|
|
443
561
|
``,
|
|
444
562
|
`/**`,
|
|
445
563
|
` * The app's request-hardening stack. Sys tier, so this mounts ahead of`,
|
|
446
564
|
` * every app-tier router — the framework core mounts no middleware of`,
|
|
447
565
|
` * its own; this module IS the pipeline's head.`,
|
|
566
|
+
` *`,
|
|
567
|
+
` * Order matters:`,
|
|
568
|
+
` * helmet — headers on every response, including errors and preflights`,
|
|
569
|
+
` * cors — must answer OPTIONS preflights before anything parses a body`,
|
|
570
|
+
` * pino — logs the request once the two above have had their say`,
|
|
571
|
+
` * json — parsing last, so a rejected origin never reaches the parser`,
|
|
448
572
|
` */`,
|
|
449
|
-
`export default function securityRoutes({ app, Environment }: Ctx): void {`,
|
|
450
|
-
` app!.use(
|
|
573
|
+
`export default function securityRoutes({ app, Environment, config }: Ctx): void {`,
|
|
574
|
+
` app!.use(helmet(helmetOptions(Environment)));`,
|
|
575
|
+
` app!.use(cors(corsOptions(Environment)));`,
|
|
451
576
|
``,
|
|
452
577
|
` app!.use(`,
|
|
453
578
|
` pinoHttp({`,
|
|
454
579
|
` logger: logger(Environment),`,
|
|
455
|
-
` // The core module (priority 0) stamps X-Request-Id
|
|
456
|
-
` // it so
|
|
580
|
+
` // The core module (priority 0) stamps X-Request-Id before this`,
|
|
581
|
+
` // runs — reuse it so logs and response headers tell one story.`,
|
|
457
582
|
` genReqId: (_req, res) => String(res.getHeader("X-Request-Id") ?? ""),`,
|
|
458
583
|
` serializers: {`,
|
|
459
584
|
` req: (req: { method: string; url: string }) => ({`,
|
|
@@ -466,64 +591,336 @@ function renderProject(name, coreVersion) {
|
|
|
466
591
|
` );`,
|
|
467
592
|
``,
|
|
468
593
|
` // Body-size cap is a security control too — a parser without a limit`,
|
|
469
|
-
` // is a memory-exhaustion invitation.`,
|
|
470
|
-
` app!.use(express.json({ limit: "1mb" }));`,
|
|
594
|
+
` // is a memory-exhaustion invitation. Tuned from config/app.config.ts.`,
|
|
595
|
+
` app!.use(express.json({ limit: config.http?.bodyLimit ?? "1mb" }));`,
|
|
471
596
|
`}`,
|
|
472
597
|
``,
|
|
473
598
|
].join("\n")],
|
|
474
|
-
// ── modules/hello — app tier: the proof route ──────────────────────────
|
|
475
599
|
["modules/hello/package.json", json({
|
|
476
600
|
name: `@app/hello`,
|
|
477
601
|
version: "0.0.0",
|
|
478
602
|
private: true,
|
|
479
603
|
type: "module",
|
|
480
604
|
tulipes: { tier: "app", priority: 100, dependsOn: [] },
|
|
481
|
-
dependencies: {
|
|
605
|
+
dependencies: {
|
|
606
|
+
"@tulipes/core": core,
|
|
607
|
+
express: "^5",
|
|
608
|
+
mongoose: "^8",
|
|
609
|
+
},
|
|
482
610
|
})],
|
|
483
|
-
["modules/hello/
|
|
484
|
-
|
|
611
|
+
["modules/hello/meta.variables.json", json({
|
|
612
|
+
variables: [
|
|
613
|
+
{
|
|
614
|
+
name: "HELLO_GREETING",
|
|
615
|
+
type: "string",
|
|
616
|
+
group: "hello",
|
|
617
|
+
description: "Word this module greets callers with",
|
|
618
|
+
default: "Hello",
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
})],
|
|
622
|
+
["modules/hello/module.config.ts", [
|
|
485
623
|
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
486
624
|
``,
|
|
487
|
-
|
|
488
|
-
`
|
|
625
|
+
`/**`,
|
|
626
|
+
` * Runs in boot phase 6. Whatever this returns is stored at`,
|
|
627
|
+
` * \`config.hello\` and typed by \`tulipes sync\`, so every other module`,
|
|
628
|
+
` * reads it without importing anything from here.`,
|
|
629
|
+
` */`,
|
|
630
|
+
`export default function helloConfig({ Environment }: Ctx) {`,
|
|
631
|
+
` return {`,
|
|
632
|
+
` greeting: Environment.get("HELLO_GREETING"),`,
|
|
633
|
+
` };`,
|
|
634
|
+
`}`,
|
|
489
635
|
``,
|
|
490
|
-
|
|
491
|
-
`
|
|
492
|
-
` });`,
|
|
636
|
+
`/** After the process is fully up (server listening / worker consuming). */`,
|
|
637
|
+
`export async function onReady({ config }: Ctx): Promise<void> {`,
|
|
638
|
+
` console.log(\`[hello] ready — greeting with "\${config.hello?.greeting}"\`);`,
|
|
639
|
+
`}`,
|
|
493
640
|
``,
|
|
494
|
-
|
|
641
|
+
`/**`,
|
|
642
|
+
` * Graceful shutdown. Hooks run in REVERSE load order, so a module can`,
|
|
643
|
+
` * rely on its dependencies still being alive while it cleans up.`,
|
|
644
|
+
` */`,
|
|
645
|
+
`export async function onShutdown(): Promise<void> {`,
|
|
646
|
+
` // Close anything this module owns: timers, external clients, streams.`,
|
|
495
647
|
`}`,
|
|
496
648
|
``,
|
|
497
649
|
].join("\n")],
|
|
498
|
-
["
|
|
499
|
-
|
|
650
|
+
["modules/hello/module.acl.ts", [
|
|
651
|
+
`import type { AclBuilder } from "@tulipes/core/acl";`,
|
|
500
652
|
``,
|
|
501
|
-
|
|
653
|
+
`/**`,
|
|
654
|
+
` * Roles are global and defined once by the sys core module; a feature`,
|
|
655
|
+
` * module only attaches grants, and always on resources namespaced by`,
|
|
656
|
+
` * its own name ("hello:*"). Granting the same role+resource from two`,
|
|
657
|
+
` * modules crashes the boot.`,
|
|
658
|
+
` */`,
|
|
659
|
+
`export default function helloAcl(acl: AclBuilder): void {`,
|
|
660
|
+
` acl.allow("user", "hello:read");`,
|
|
661
|
+
`}`,
|
|
502
662
|
``,
|
|
503
|
-
|
|
663
|
+
].join("\n")],
|
|
664
|
+
["modules/hello/helpers/greeting.ts", [
|
|
665
|
+
`/**`,
|
|
666
|
+
` * Pure logic: no Express, no database, no framework. Trivial to unit`,
|
|
667
|
+
` * test, and the framework never scans this folder — helpers are plain`,
|
|
668
|
+
` * imports. Anything two modules need moves up to lib/.`,
|
|
669
|
+
` */`,
|
|
670
|
+
`export function buildGreeting(greeting: string, name: string): string {`,
|
|
671
|
+
` return \`\${greeting}, \${name}!\`;`,
|
|
672
|
+
`}`,
|
|
504
673
|
``,
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
`
|
|
508
|
-
`
|
|
509
|
-
"```",
|
|
674
|
+
].join("\n")],
|
|
675
|
+
["modules/hello/controllers/hello.controllers.ts", [
|
|
676
|
+
`import type { Request, Response } from "express";`,
|
|
677
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
510
678
|
``,
|
|
511
|
-
`
|
|
512
|
-
`module asks for. When your first model lands, declare \`MONGO_URI\``,
|
|
513
|
-
`(type \`url\`, required) in \`modules/core/meta.variables.json\` and set`,
|
|
514
|
-
`it in \`.envs/.env.development\`; same with \`REDIS_URL\` for queues.`,
|
|
679
|
+
`import { buildGreeting } from "../helpers/greeting.js";`,
|
|
515
680
|
``,
|
|
516
|
-
|
|
681
|
+
`/**`,
|
|
682
|
+
` * Controllers hold the work so routes stay a table of contents. The`,
|
|
683
|
+
` * framework does not scan this folder — routes import these directly,`,
|
|
684
|
+
` * which keeps the wiring explicit.`,
|
|
685
|
+
` *`,
|
|
686
|
+
` * Each controller takes ctx and RETURNS the handler. That closure is`,
|
|
687
|
+
` * what makes it testable: pass a stub context, get a plain function.`,
|
|
688
|
+
` */`,
|
|
689
|
+
`export function greet({ config }: Ctx) {`,
|
|
690
|
+
` return (req: Request, res: Response): void => {`,
|
|
691
|
+
` const name = String(req.query.name ?? "world");`,
|
|
692
|
+
` res.json({`,
|
|
693
|
+
` message: buildGreeting(String(config.hello?.greeting), name),`,
|
|
694
|
+
` app: config.app?.name,`,
|
|
695
|
+
` });`,
|
|
696
|
+
` };`,
|
|
697
|
+
`}`,
|
|
517
698
|
``,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
699
|
+
].join("\n")],
|
|
700
|
+
["modules/hello/routes/hello.routes.ts", [
|
|
701
|
+
`import { Router } from "express";`,
|
|
702
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
703
|
+
`import { HttpError } from "@tulipes/core/http";`,
|
|
704
|
+
``,
|
|
705
|
+
`import { greet } from "../controllers/hello.controllers.js";`,
|
|
706
|
+
``,
|
|
707
|
+
`export default function helloRoutes(ctx: Ctx): Router {`,
|
|
708
|
+
` const router = Router();`,
|
|
709
|
+
` const { acl, config } = ctx;`,
|
|
710
|
+
``,
|
|
711
|
+
` // Built from the shared prefix, so versioning the whole API is a`,
|
|
712
|
+
` // one-line change in config/app.config.ts.`,
|
|
713
|
+
` const base = String(config.api?.prefix ?? "");`,
|
|
714
|
+
``,
|
|
715
|
+
` router.get(\`\${base}/hello\`, greet(ctx));`,
|
|
716
|
+
``,
|
|
717
|
+
` // Literal paths must be registered before parameterised ones, or`,
|
|
718
|
+
` // "/hello/:name" would swallow this.`,
|
|
719
|
+
` router.get(\`\${base}/hello/secret\`, (req, res) => {`,
|
|
720
|
+
` // A real app resolves the role in an auth module and puts it on the`,
|
|
721
|
+
` // request; this reads a header purely so the demo is curl-able.`,
|
|
722
|
+
` const role = String(req.header("x-demo-role") ?? "guest");`,
|
|
723
|
+
` if (!acl!.can(role, "hello:read")) {`,
|
|
724
|
+
` throw new HttpError(403, \`role "\${role}" may not read hello\`);`,
|
|
725
|
+
` }`,
|
|
726
|
+
` res.json({ secret: "only roles granted hello:read see this" });`,
|
|
727
|
+
` });`,
|
|
728
|
+
``,
|
|
729
|
+
` // Throwing is the whole error story — the framework's terminal`,
|
|
730
|
+
` // handler renders it. Express 5 forwards async rejections there too.`,
|
|
731
|
+
` router.get(\`\${base}/hello/:name\`, async (req, res) => {`,
|
|
732
|
+
` if (req.params.name === "nobody") {`,
|
|
733
|
+
` throw new HttpError(404, "nobody is not a greetable name");`,
|
|
734
|
+
` }`,
|
|
735
|
+
` res.json({ message: \`\${config.hello?.greeting}, \${req.params.name}!\` });`,
|
|
736
|
+
` });`,
|
|
737
|
+
``,
|
|
738
|
+
` return router;`,
|
|
739
|
+
`}`,
|
|
740
|
+
``,
|
|
741
|
+
].join("\n")],
|
|
742
|
+
["modules/hello/sockets/hello.sockets.ts", [
|
|
743
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
744
|
+
`import type { SocketRegistry } from "@tulipes/core/sockets";`,
|
|
745
|
+
``,
|
|
746
|
+
`/**`,
|
|
747
|
+
` * Backend mode only. A namespace is claimed by exactly one module; emit`,
|
|
748
|
+
` * from anywhere with \`ctx.sockets!.of("/hello")\`.`,
|
|
749
|
+
` *`,
|
|
750
|
+
` * Try it: wscat -c ws://localhost:3000/hello`,
|
|
751
|
+
` */`,
|
|
752
|
+
`export default function helloSockets({ config }: Ctx, sockets: SocketRegistry): void {`,
|
|
753
|
+
` sockets.namespace("/hello", (nsp) => {`,
|
|
754
|
+
` nsp.on("connection", (socket) => {`,
|
|
755
|
+
` socket.emit("greeting", {`,
|
|
756
|
+
` message: config.hello?.greeting,`,
|
|
757
|
+
` online: nsp.sockets.size,`,
|
|
758
|
+
` });`,
|
|
759
|
+
``,
|
|
760
|
+
` socket.on("ping", (ack: (reply: string) => void) => ack("pong"));`,
|
|
761
|
+
` });`,
|
|
762
|
+
` });`,
|
|
763
|
+
`}`,
|
|
525
764
|
``,
|
|
526
765
|
].join("\n")],
|
|
527
766
|
];
|
|
767
|
+
// ── the contracts that need a database or redis ────────────────────────
|
|
768
|
+
files.push(["modules/hello/models/greeting.model.ts", [
|
|
769
|
+
`import { Schema } from "mongoose";`,
|
|
770
|
+
`import type { ModelDef } from "@tulipes/core/db";`,
|
|
771
|
+
``,
|
|
772
|
+
`/**`,
|
|
773
|
+
` * A model file is a DECLARATION, never a registration: the framework`,
|
|
774
|
+
` * compiles the schema on its own connection and puts it in the model`,
|
|
775
|
+
` * store. Never call mongoose.model() yourself.`,
|
|
776
|
+
` *`,
|
|
777
|
+
` * Read it anywhere the context reaches: models!.get("Greeting").`,
|
|
778
|
+
` */`,
|
|
779
|
+
`const greetingSchema = new Schema(`,
|
|
780
|
+
` {`,
|
|
781
|
+
` name: { type: String, required: true, unique: true, lowercase: true, trim: true },`,
|
|
782
|
+
` message: { type: String, required: true },`,
|
|
783
|
+
` timesUsed: { type: Number, default: 0, min: 0 },`,
|
|
784
|
+
` },`,
|
|
785
|
+
` { timestamps: true },`,
|
|
786
|
+
`);`,
|
|
787
|
+
``,
|
|
788
|
+
`export default { name: "Greeting", schema: greetingSchema } satisfies ModelDef;`,
|
|
789
|
+
``,
|
|
790
|
+
].join("\n")], ["modules/hello/bootstrap/seed-greetings.bootstrap.ts", [
|
|
791
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
792
|
+
``,
|
|
793
|
+
`/**`,
|
|
794
|
+
` * Runs after models are registered and before the server accepts`,
|
|
795
|
+
` * traffic — the place for indexes, seed rows and defaults.`,
|
|
796
|
+
` *`,
|
|
797
|
+
` * MUST be idempotent: it runs on every boot of every process, backend and`,
|
|
798
|
+
` * worker alike. Upsert; never blind-insert.`,
|
|
799
|
+
` */`,
|
|
800
|
+
`export default async function seedGreetings({ models }: Ctx): Promise<void> {`,
|
|
801
|
+
` const Greeting = models!.get("Greeting");`,
|
|
802
|
+
``,
|
|
803
|
+
` await Greeting.updateOne(`,
|
|
804
|
+
` { name: "world" },`,
|
|
805
|
+
` { $setOnInsert: { name: "world", message: "Hello, world!" } },`,
|
|
806
|
+
` { upsert: true },`,
|
|
807
|
+
` );`,
|
|
808
|
+
`}`,
|
|
809
|
+
``,
|
|
810
|
+
].join("\n")], ["modules/hello/queues/hello.queues.ts", [
|
|
811
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
812
|
+
`import type { QueueRegistry } from "@tulipes/core/queues";`,
|
|
813
|
+
``,
|
|
814
|
+
`/**`,
|
|
815
|
+
` * ONE file describes both sides. The backend process registers the queue`,
|
|
816
|
+
` * so routes can produce into it; \`yarn worker\` turns the processor`,
|
|
817
|
+
` * into a live consumer. Never duplicate the definition.`,
|
|
818
|
+
` *`,
|
|
819
|
+
` * Queue names use "." — BullMQ reserves ":" as its redis separator.`,
|
|
820
|
+
` */`,
|
|
821
|
+
`export default function helloQueues({ models }: Ctx, queues: QueueRegistry): void {`,
|
|
822
|
+
` queues.define("hello.count-greeting");`,
|
|
823
|
+
``,
|
|
824
|
+
` queues.process("hello.count-greeting", async (job) => {`,
|
|
825
|
+
` // Jobs retry, so processors must be idempotent-safe. Pass ids in`,
|
|
826
|
+
` // job.data and re-read state here rather than shipping documents.`,
|
|
827
|
+
` const { name } = job.data as { name: string };`,
|
|
828
|
+
` await models!.get("Greeting").updateOne({ name }, { $inc: { timesUsed: 1 } });`,
|
|
829
|
+
` return { counted: name };`,
|
|
830
|
+
` });`,
|
|
831
|
+
`}`,
|
|
832
|
+
``,
|
|
833
|
+
].join("\n")], ["modules/hello/routes/greetings.routes.ts", [
|
|
834
|
+
`import { Router } from "express";`,
|
|
835
|
+
`import type { Ctx } from "@tulipes/core/boot";`,
|
|
836
|
+
`import { HttpError } from "@tulipes/core/http";`,
|
|
837
|
+
``,
|
|
838
|
+
`/**`,
|
|
839
|
+
` * A module may ship several routes files; each is discovered and`,
|
|
840
|
+
` * mounted independently. This one shows the database and the queue.`,
|
|
841
|
+
` */`,
|
|
842
|
+
`export default function greetingsRoutes({ config, models, queues }: Ctx): Router {`,
|
|
843
|
+
` const router = Router();`,
|
|
844
|
+
` const base = String(config.api?.prefix ?? "");`,
|
|
845
|
+
``,
|
|
846
|
+
` router.get(\`\${base}/greetings\`, async (req, res) => {`,
|
|
847
|
+
` // Pagination bounds are app-wide, so every list endpoint clamps`,
|
|
848
|
+
` // the same way and no caller can ask for the whole table.`,
|
|
849
|
+
` const { defaultLimit = 20, maxLimit = 100 } = config.pagination ?? {};`,
|
|
850
|
+
` const asked = Number(req.query.limit ?? defaultLimit);`,
|
|
851
|
+
` const limit = Math.min(Number.isFinite(asked) ? asked : defaultLimit, maxLimit);`,
|
|
852
|
+
``,
|
|
853
|
+
` const greetings = await models!`,
|
|
854
|
+
` .get("Greeting")`,
|
|
855
|
+
` .find()`,
|
|
856
|
+
` .select("name message timesUsed -_id")`,
|
|
857
|
+
` .limit(limit)`,
|
|
858
|
+
` .lean();`,
|
|
859
|
+
``,
|
|
860
|
+
` res.json({ count: greetings.length, limit, greetings });`,
|
|
861
|
+
` });`,
|
|
862
|
+
``,
|
|
863
|
+
` router.post(\`\${base}/greetings/:name/use\`, async (req, res) => {`,
|
|
864
|
+
` const { name } = req.params;`,
|
|
865
|
+
` const greeting = await models!.get("Greeting").findOne({ name }).lean();`,
|
|
866
|
+
` if (!greeting) throw new HttpError(404, \`no greeting named "\${name}"\`);`,
|
|
867
|
+
``,
|
|
868
|
+
` // Slow or retryable work belongs in a job, not in the request.`,
|
|
869
|
+
` // Run \`yarn worker\` in another terminal to see it processed.`,
|
|
870
|
+
` await queues!.add("hello.count-greeting", "count", { name });`,
|
|
871
|
+
` res.status(202).json({ name, queued: true });`,
|
|
872
|
+
` });`,
|
|
873
|
+
``,
|
|
874
|
+
` return router;`,
|
|
875
|
+
`}`,
|
|
876
|
+
``,
|
|
877
|
+
].join("\n")]);
|
|
878
|
+
files.push(["README.md", [
|
|
879
|
+
`# ${name}`,
|
|
880
|
+
``,
|
|
881
|
+
`A [Tulipes](https://www.npmjs.com/package/@tulipes/core) application.`,
|
|
882
|
+
``,
|
|
883
|
+
`## Run`,
|
|
884
|
+
``,
|
|
885
|
+
"```sh",
|
|
886
|
+
`corepack enable # once per machine — activates the pinned yarn 4`,
|
|
887
|
+
`yarn install`,
|
|
888
|
+
`yarn dev # boot the backend process → http://localhost:3000/hello`,
|
|
889
|
+
"```",
|
|
890
|
+
``,
|
|
891
|
+
`Needs mongo and redis running — see \`MONGO_URI\` and \`REDIS_URL\` in`,
|
|
892
|
+
`\`.envs/.env.development\`. Run \`yarn worker\` in a second terminal to`,
|
|
893
|
+
`consume queued jobs.`,
|
|
894
|
+
``,
|
|
895
|
+
`## The hello module`,
|
|
896
|
+
``,
|
|
897
|
+
`\`modules/hello\` is a worked example of every module contract — read it`,
|
|
898
|
+
`before writing your own:`,
|
|
899
|
+
``,
|
|
900
|
+
`| File | Contract |`,
|
|
901
|
+
`|---|---|`,
|
|
902
|
+
`| \`meta.variables.json\` | the module's environment contract |`,
|
|
903
|
+
`| \`module.config.ts\` | config factory + \`onReady\` / \`onShutdown\` hooks |`,
|
|
904
|
+
`| \`module.acl.ts\` | permission grants on \`hello:*\` |`,
|
|
905
|
+
`| \`routes/*.routes.ts\` | endpoints, \`HttpError\`, ACL checks |`,
|
|
906
|
+
`| \`controllers/\` | the work; imported by routes, never scanned |`,
|
|
907
|
+
`| \`helpers/\` | pure logic, no framework |`,
|
|
908
|
+
`| \`sockets/*.sockets.ts\` | a claimed Socket.IO namespace |`,
|
|
909
|
+
`| \`models/*.model.ts\` | a schema declaration for the model store |`,
|
|
910
|
+
`| \`bootstrap/*.bootstrap.ts\` | idempotent seeding, before traffic |`,
|
|
911
|
+
`| \`queues/*.queues.ts\` | a job definition and its processor |`,
|
|
912
|
+
``,
|
|
913
|
+
`## Daily commands`,
|
|
914
|
+
``,
|
|
915
|
+
`| command | does |`,
|
|
916
|
+
`|---|---|`,
|
|
917
|
+
`| \`yarn dev\` | regenerate types, run backend process under tsx watch |`,
|
|
918
|
+
`| \`yarn worker\` | run the queue-consumer process (needs a queue first) |`,
|
|
919
|
+
`| \`yarn sync\` | regenerate \`types/config.d.ts\` + \`.env.example\` |`,
|
|
920
|
+
`| \`yarn check\` | validate env against every module contract — CI gate |`,
|
|
921
|
+
`| \`yarn tulipes new module <name>\` | scaffold a module |`,
|
|
922
|
+
``,
|
|
923
|
+
].join("\n")]);
|
|
924
|
+
return files;
|
|
528
925
|
}
|
|
529
926
|
//# sourceMappingURL=init.js.map
|