@dunx/create-app 2.3.0 → 2.4.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/dist/{chunk-yzz4z6jv.js → chunk-mpa5nv1v.js} +110 -13
- package/dist/chunk-mpa5nv1v.js.map +12 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/features/assets/assets.demo.ts +41 -0
- package/templates/features/assets/assets.module.ts +37 -0
- package/templates/features/assets/public/app.a1b2c3d4.js +1 -0
- package/templates/features/assets/public/index.html +9 -0
- package/templates/features/assets/public/site.css +3 -0
- package/templates/features/auth/auth.module.ts +15 -4
- package/templates/features/cache/cache.controller.ts +5 -2
- package/templates/features/database/ledger.controller.ts +7 -4
- package/templates/features/guards/reports.controller.ts +11 -2
- package/templates/features/health/health.demo.ts +72 -0
- package/templates/features/health/health.module.ts +70 -9
- package/templates/features/health/indicators.ts +98 -0
- package/templates/features/http/http.demo.ts +6 -4
- package/templates/features/http/http.module.ts +3 -3
- package/templates/features/http/{request-log.ts → request-trail.ts} +10 -11
- package/templates/features/jobs/jobs.controller.ts +7 -2
- package/templates/features/jobs/thumbnail.jobs.ts +7 -1
- package/templates/features/notes/notes.controller.ts +1 -1
- package/templates/features/pictures/images.controller.ts +5 -2
- package/templates/features/schedule/maintenance.service.ts +71 -0
- package/templates/features/schedule/schedule.demo.ts +56 -0
- package/templates/features/schedule/schedule.module.ts +31 -0
- package/templates/features/storage/files.controller.ts +5 -4
- package/templates/features/throttle/limits.controller.ts +35 -0
- package/templates/features/throttle/throttle.demo.ts +74 -0
- package/templates/features/throttle/throttle.module.ts +88 -0
- package/templates/features/upstream/flaky.controller.ts +48 -0
- package/templates/features/upstream/upstream.demo.ts +83 -0
- package/templates/features/upstream/upstream.module.ts +42 -0
- package/templates/features/users/users.schemas.ts +22 -8
- package/dist/chunk-yzz4z6jv.js.map +0 -12
- package/templates/features/health/health.controller.ts +0 -65
|
@@ -82,6 +82,37 @@ var CONFIG_GROUPS = Object.freeze({
|
|
|
82
82
|
field: "readonly authorization: { readonly enabled: boolean };",
|
|
83
83
|
map: "authorization: { enabled: true },",
|
|
84
84
|
env: []
|
|
85
|
+
},
|
|
86
|
+
throttle: {
|
|
87
|
+
schema: [
|
|
88
|
+
"/** The app-wide limit. Generous, so a per-route `@Throttle` is the interesting half. */",
|
|
89
|
+
"THROTTLE_LIMIT: z.coerce.number().int().min(1).default(1000),",
|
|
90
|
+
"THROTTLE_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),"
|
|
91
|
+
],
|
|
92
|
+
field: "readonly throttle: { readonly limit: number; readonly windowSeconds: number };",
|
|
93
|
+
map: "throttle: { limit: value.THROTTLE_LIMIT, windowSeconds: value.THROTTLE_WINDOW_SECONDS },",
|
|
94
|
+
env: [
|
|
95
|
+
{ name: "THROTTLE_LIMIT", value: "1000" },
|
|
96
|
+
{ name: "THROTTLE_WINDOW_SECONDS", value: "60" }
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
schedule: {
|
|
100
|
+
schema: [
|
|
101
|
+
"/** A `@Cron` that names no zone of its own runs in this one. */",
|
|
102
|
+
"SCHEDULE_TZ: z.string().default('UTC'),"
|
|
103
|
+
],
|
|
104
|
+
field: "readonly schedule: { readonly tz: string };",
|
|
105
|
+
map: "schedule: { tz: value.SCHEDULE_TZ },",
|
|
106
|
+
env: [{ name: "SCHEDULE_TZ", value: "UTC" }]
|
|
107
|
+
},
|
|
108
|
+
upstream: {
|
|
109
|
+
schema: [
|
|
110
|
+
"/** Per-call budget for the outbound client. */",
|
|
111
|
+
"UPSTREAM_TIMEOUT_MS: z.coerce.number().int().min(1).default(5000),"
|
|
112
|
+
],
|
|
113
|
+
field: "readonly upstream: { readonly timeoutMs: number };",
|
|
114
|
+
map: "upstream: { timeoutMs: value.UPSTREAM_TIMEOUT_MS },",
|
|
115
|
+
env: [{ name: "UPSTREAM_TIMEOUT_MS", value: "5000" }]
|
|
85
116
|
}
|
|
86
117
|
});
|
|
87
118
|
var BASE_CONFIG = ["appName", "port", "log"];
|
|
@@ -101,13 +132,13 @@ var FEATURES = [
|
|
|
101
132
|
summary: "OpenAPI 3.1 from the routes own schemas, plus the Swagger UI page.",
|
|
102
133
|
requires: [],
|
|
103
134
|
module: { klass: "DocsModule", from: "./docs/docs.module.js" },
|
|
104
|
-
dependencies: ["@dunx/openapi", "
|
|
135
|
+
dependencies: ["@dunx/openapi", "zod"],
|
|
105
136
|
config: []
|
|
106
137
|
},
|
|
107
138
|
{
|
|
108
139
|
name: "http",
|
|
109
140
|
source: "http",
|
|
110
|
-
summary: "CORS, a
|
|
141
|
+
summary: "CORS, a middleware of your own on the response, and error mapping.",
|
|
111
142
|
requires: [],
|
|
112
143
|
module: { klass: "HttpModule", from: "./http/http.module.js" },
|
|
113
144
|
dependencies: [],
|
|
@@ -200,11 +231,51 @@ var FEATURES = [
|
|
|
200
231
|
{
|
|
201
232
|
name: "health",
|
|
202
233
|
source: "health",
|
|
203
|
-
summary: "
|
|
234
|
+
summary: "`HealthModule`'s liveness and readiness probes, wired to this app's own indicators.",
|
|
204
235
|
requires: ["cache", "database", "files"],
|
|
205
|
-
module: { klass: "
|
|
236
|
+
module: { klass: "ProbesModule", from: "./health/health.module.js" },
|
|
206
237
|
dependencies: ["@dunx/infra"],
|
|
207
238
|
config: ["appName"]
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "throttle",
|
|
242
|
+
source: "throttle",
|
|
243
|
+
summary: "A fixed-window rate limit, with the counter in Redis and per-route overrides.",
|
|
244
|
+
requires: ["cache"],
|
|
245
|
+
module: { klass: "LimitsModule", from: "./throttle/throttle.module.js" },
|
|
246
|
+
dependencies: ["@dunx/infra"],
|
|
247
|
+
config: ["appName", "throttle"],
|
|
248
|
+
service: "Redis or Valkey"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "schedule",
|
|
252
|
+
source: "schedule",
|
|
253
|
+
summary: "@Cron, @Interval and @OnceOnBoot on Bun.cron, armed at boot and triggerable.",
|
|
254
|
+
requires: [],
|
|
255
|
+
module: {
|
|
256
|
+
klass: "MaintenanceModule",
|
|
257
|
+
from: "./schedule/schedule.module.js"
|
|
258
|
+
},
|
|
259
|
+
dependencies: ["@dunx/infra"],
|
|
260
|
+
config: ["schedule"]
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "assets",
|
|
264
|
+
source: "assets",
|
|
265
|
+
summary: "A static directory on Bun.file, with a short max-age and an immutable rule.",
|
|
266
|
+
requires: [],
|
|
267
|
+
module: { klass: "AssetsModule", from: "./assets/assets.module.js" },
|
|
268
|
+
dependencies: [],
|
|
269
|
+
config: []
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "client",
|
|
273
|
+
source: "upstream",
|
|
274
|
+
summary: "The outbound half of @dunx/http: retry, backoff and a typed FetchError.",
|
|
275
|
+
requires: [],
|
|
276
|
+
module: { klass: "UpstreamModule", from: "./upstream/upstream.module.js" },
|
|
277
|
+
dependencies: [],
|
|
278
|
+
config: ["appName", "upstream"]
|
|
208
279
|
}
|
|
209
280
|
];
|
|
210
281
|
var featureNames = FEATURES.map((feature) => feature.name);
|
|
@@ -298,7 +369,6 @@ var manifest = (features) => {
|
|
|
298
369
|
};
|
|
299
370
|
var THIRD_PARTY = Object.freeze({
|
|
300
371
|
zod: "^4.4.3",
|
|
301
|
-
"swagger-ui-dist": "^5.32.14",
|
|
302
372
|
"drizzle-orm": "^0.45.2",
|
|
303
373
|
"better-auth": "^1.6.25",
|
|
304
374
|
bullmq: "^6.0.5",
|
|
@@ -307,7 +377,9 @@ var THIRD_PARTY = Object.freeze({
|
|
|
307
377
|
var versionOf = (dep) => THIRD_PARTY[dep] ?? "latest";
|
|
308
378
|
var appModule = (name, features) => {
|
|
309
379
|
const needsLogger = true;
|
|
380
|
+
const documentsAuth = has(features, "openapi") && has(features, "auth");
|
|
310
381
|
const imports = [
|
|
382
|
+
...documentsAuth ? ["import { Auth } from '@dunx/auth';"] : [],
|
|
311
383
|
"import { ConfigModule, Module } from '@dunx/core';",
|
|
312
384
|
...needsLogger ? ["import { LoggerModule } from '@dunx/infra/logger';"] : [],
|
|
313
385
|
"import { AppConfigService, validate } from './config.js';",
|
|
@@ -341,7 +413,10 @@ var appModule = (name, features) => {
|
|
|
341
413
|
imports: [
|
|
342
414
|
${moduleImports.map((line) => ` ${line}`).join(`
|
|
343
415
|
`)}
|
|
344
|
-
]
|
|
416
|
+
],${documentsAuth ? `
|
|
417
|
+
// Better Auth serves its own routes, so the document is the only place they
|
|
418
|
+
// appear - and \`betterAuthDocument\` needs the instance.
|
|
419
|
+
exports: [Auth],` : ""}
|
|
345
420
|
})
|
|
346
421
|
export class AppModule {}
|
|
347
422
|
`;
|
|
@@ -406,17 +481,37 @@ var bootstrap = (name, features) => {
|
|
|
406
481
|
const openapi = has(features, "openapi");
|
|
407
482
|
const websockets = has(features, "websockets");
|
|
408
483
|
const http = has(features, "http");
|
|
484
|
+
const documentsAuth = openapi && has(features, "auth");
|
|
485
|
+
const assets = has(features, "assets");
|
|
486
|
+
const throttle = has(features, "throttle");
|
|
409
487
|
const imports = [
|
|
410
|
-
|
|
488
|
+
...documentsAuth ? ["import { Auth, betterAuthDocument } from '@dunx/auth';"] : [],
|
|
489
|
+
`import { ${[
|
|
490
|
+
"HttpFactory",
|
|
491
|
+
...websockets ? ["RedisRelay"] : [],
|
|
492
|
+
...assets ? ["StaticFiles"] : [],
|
|
493
|
+
...throttle ? ["ThrottleGuard"] : [],
|
|
494
|
+
"type HttpApp"
|
|
495
|
+
].join(", ")} } from '@dunx/http';`,
|
|
411
496
|
...openapi ? ["import { OpenApiModule } from '@dunx/openapi';"] : [],
|
|
412
497
|
"import { AppModule } from './app.module.js';",
|
|
413
498
|
`import { ${[
|
|
414
499
|
...http ? ["AppConfigService"] : [],
|
|
415
500
|
...websockets ? ["RELAY_CHANNEL"] : []
|
|
416
501
|
].join(", ")} } from './config.js';`,
|
|
417
|
-
...http ? ["import {
|
|
502
|
+
...http ? ["import { RequestTrailMiddleware } from './http/request-trail.js';"] : []
|
|
418
503
|
].filter((line) => !line.includes("{ }"));
|
|
419
|
-
const root =
|
|
504
|
+
const root = documentsAuth ? `OpenApiModule.forRootAsync({
|
|
505
|
+
root: AppModule,
|
|
506
|
+
inject: [Auth] as const,
|
|
507
|
+
useFactory: (auth: Auth) => ({
|
|
508
|
+
title: '__DUNX_APP_NAME__',
|
|
509
|
+
version: '0.1.0',
|
|
510
|
+
contribute: [
|
|
511
|
+
betterAuthDocument(auth, { basePath: '/api/auth', tag: 'Auth' }),
|
|
512
|
+
],
|
|
513
|
+
}),
|
|
514
|
+
})` : openapi ? `OpenApiModule.forRoot({
|
|
420
515
|
title: '__DUNX_APP_NAME__',
|
|
421
516
|
version: '0.1.0',
|
|
422
517
|
root: AppModule,
|
|
@@ -431,15 +526,17 @@ var bootstrap = (name, features) => {
|
|
|
431
526
|
] : [];
|
|
432
527
|
const shaping = [
|
|
433
528
|
"app.setGlobalPrefix('api');",
|
|
529
|
+
...assets ? ["app.use(StaticFiles);"] : [],
|
|
434
530
|
...http ? [
|
|
435
|
-
"app.use(
|
|
531
|
+
"app.use(RequestTrailMiddleware);",
|
|
436
532
|
"app.set('trust proxy', true);",
|
|
437
533
|
"app.enableCors({",
|
|
438
534
|
" origin: app.get(AppConfigService).get('corsOrigin'),",
|
|
439
535
|
" credentials: true,",
|
|
440
536
|
" maxAge: 600,",
|
|
441
537
|
"});"
|
|
442
|
-
] : []
|
|
538
|
+
] : [],
|
|
539
|
+
...throttle ? ["app.use(ThrottleGuard);"] : []
|
|
443
540
|
];
|
|
444
541
|
return `${HEADER(name)}${imports.join(`
|
|
445
542
|
`)}
|
|
@@ -695,5 +792,5 @@ var scaffold = async (options) => {
|
|
|
695
792
|
|
|
696
793
|
export { FEATURES, featureNames, impliedBy, TEMPLATES, VERSION_PLACEHOLDER, ScaffoldError, scaffold };
|
|
697
794
|
|
|
698
|
-
//# debugId=
|
|
699
|
-
//# sourceMappingURL=chunk-
|
|
795
|
+
//# debugId=8590CB93017DD8C964756E2164756E21
|
|
796
|
+
//# sourceMappingURL=chunk-mpa5nv1v.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/features.ts", "../src/scaffold.ts", "../src/generate.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"/**\n * The features a generated app can be composed from, each one a directory of\n * `examples/full` - the example CI boots and tours on every push.\n *\n * That is the whole point of sourcing them there rather than writing starter code\n * here: a template nobody runs rots, and this repo already runs `examples/full`\n * end to end. `bun run sync:templates` copies the directories in and\n * `features.test.ts` fails if a copy drifts, so what gets scaffolded is what CI\n * proved works.\n *\n * What is **not** copied is the wiring: `app.module.ts`, `config.ts`,\n * `bootstrap.ts` and `main.ts` in the full example name every feature at once, so\n * they are generated from the selection instead. See `generate.ts`.\n */\nexport interface Feature {\n /** Flag name, and the directory under `templates/features/`. */\n readonly name: string;\n /** The directory in `examples/full/src` this mirrors. */\n readonly source: string;\n readonly summary: string;\n /** Features this one imports from, pulled in automatically. */\n readonly requires: readonly string[];\n /** The module class to import, and the file it comes from. */\n readonly module: { readonly klass: string; readonly from: string };\n /** Runtime dependencies this feature adds to the generated manifest. */\n readonly dependencies: readonly string[];\n /** Config groups this feature reads, contributed to the generated config. */\n readonly config: readonly string[];\n /**\n * A service that has to be running for the feature to do anything. Named so the\n * prompt can say so and the generated README can list it, rather than the app\n * failing in a way the reader has to diagnose.\n */\n readonly service?: string;\n}\n\n/**\n * Config groups, keyed by the name a feature asks for. `env` is what lands in\n * `.env.example`, `schema` the zod line, `field` the `AppConfig` member and `map`\n * how the flat variable becomes the shaped one - the four things\n * `examples/full/src/config.ts` states for every group at once, split so a\n * selection can state only its own.\n */\nexport interface ConfigGroup {\n readonly schema: readonly string[];\n readonly field: string;\n readonly map: string;\n readonly env: readonly { readonly name: string; readonly value: string }[];\n}\n\nexport const CONFIG_GROUPS: Readonly<Record<string, ConfigGroup>> =\n Object.freeze({\n port: {\n schema: [\n 'PORT: z.coerce.number().int().min(0).max(65535).default(3000),',\n ],\n field: 'readonly port: number;',\n map: 'port: value.PORT,',\n env: [{ name: 'PORT', value: '3000' }],\n },\n appName: {\n schema: [],\n field: 'readonly appName: string;',\n map: \"appName: '__DUNX_APP_NAME__',\",\n env: [],\n },\n log: {\n schema: [\n 'LOG_LEVEL: z.enum(LogLevel).default(LogLevel.INFO),',\n '/** Unset means console only. Set it to also append JSON to a rotating file. */',\n 'LOG_FILE: z.string().optional(),',\n ],\n field:\n 'readonly log: { readonly level: LogLevel; readonly file: string | undefined };',\n map: 'log: { level: value.LOG_LEVEL, file: value.LOG_FILE },',\n env: [{ name: 'LOG_LEVEL', value: 'info' }],\n },\n corsOrigin: {\n schema: [\"CORS_ORIGIN: z.string().default('https://example.com'),\"],\n field: 'readonly corsOrigin: string;',\n map: 'corsOrigin: value.CORS_ORIGIN,',\n env: [{ name: 'CORS_ORIGIN', value: 'https://example.com' }],\n },\n database: {\n schema: [\n '/** `:memory:` needs no server and leaves nothing behind, so restarts are clean. */',\n \"DATABASE_FILE: z.string().default(':memory:'),\",\n ],\n field: 'readonly database: { readonly file: string };',\n map: 'database: { file: value.DATABASE_FILE },',\n env: [{ name: 'DATABASE_FILE', value: ':memory:' }],\n },\n redis: {\n schema: [\n '/** Absent is fine: the cache routes report themselves degraded instead of failing. */',\n 'REDIS_URL: z.string().optional(),',\n ],\n field: 'readonly redis: { readonly url: string | undefined };',\n map: 'redis: { url: value.REDIS_URL },',\n env: [{ name: 'REDIS_URL', value: 'redis://localhost:6379' }],\n },\n images: {\n schema: [\n 'IMAGE_QUALITY: z.coerce.number().int().min(1).max(100).default(82),',\n ],\n field: 'readonly images: { readonly quality: number };',\n map: 'images: { quality: value.IMAGE_QUALITY },',\n env: [{ name: 'IMAGE_QUALITY', value: '82' }],\n },\n auth: {\n schema: [\n '/** better-auth signs session cookies with this. 32 characters is its own minimum. */',\n \"AUTH_SECRET: z.string().min(32).default('dunx-development-secret-not-for-production'),\",\n ],\n field: 'readonly auth: { readonly secret: string };',\n map: 'auth: { secret: value.AUTH_SECRET },',\n env: [\n {\n name: 'AUTH_SECRET',\n value: 'change-me-to-at-least-32-characters-long',\n },\n ],\n },\n seedUsers: {\n schema: [],\n field: 'readonly seedUsers: readonly string[];',\n map: \"seedUsers: ['ada', 'grace'],\",\n env: [],\n },\n authorization: {\n schema: [],\n field: 'readonly authorization: { readonly enabled: boolean };',\n map: 'authorization: { enabled: true },',\n env: [],\n },\n throttle: {\n schema: [\n '/** The app-wide limit. Generous, so a per-route `@Throttle` is the interesting half. */',\n 'THROTTLE_LIMIT: z.coerce.number().int().min(1).default(1000),',\n 'THROTTLE_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),',\n ],\n field:\n 'readonly throttle: { readonly limit: number; readonly windowSeconds: number };',\n map: 'throttle: { limit: value.THROTTLE_LIMIT, windowSeconds: value.THROTTLE_WINDOW_SECONDS },',\n env: [\n { name: 'THROTTLE_LIMIT', value: '1000' },\n { name: 'THROTTLE_WINDOW_SECONDS', value: '60' },\n ],\n },\n schedule: {\n schema: [\n '/** A `@Cron` that names no zone of its own runs in this one. */',\n \"SCHEDULE_TZ: z.string().default('UTC'),\",\n ],\n field: 'readonly schedule: { readonly tz: string };',\n map: 'schedule: { tz: value.SCHEDULE_TZ },',\n env: [{ name: 'SCHEDULE_TZ', value: 'UTC' }],\n },\n upstream: {\n schema: [\n '/** Per-call budget for the outbound client. */',\n 'UPSTREAM_TIMEOUT_MS: z.coerce.number().int().min(1).default(5000),',\n ],\n field: 'readonly upstream: { readonly timeoutMs: number };',\n map: 'upstream: { timeoutMs: value.UPSTREAM_TIMEOUT_MS },',\n env: [{ name: 'UPSTREAM_TIMEOUT_MS', value: '5000' }],\n },\n });\n\n/** Always present, whatever is selected: the port and the logger need them. */\nexport const BASE_CONFIG: readonly string[] = ['appName', 'port', 'log'];\n\nexport const FEATURES: readonly Feature[] = [\n {\n name: 'notes',\n source: 'notes',\n summary: 'CRUD routes with zod validation. The smallest real feature.',\n requires: [],\n module: { klass: 'NotesModule', from: './notes/notes.module.js' },\n dependencies: ['@dunx/openapi', 'zod'],\n config: [],\n },\n {\n name: 'openapi',\n source: 'docs',\n summary:\n 'OpenAPI 3.1 from the routes own schemas, plus the Swagger UI page.',\n requires: [],\n module: { klass: 'DocsModule', from: './docs/docs.module.js' },\n // No `swagger-ui-dist` here: it is a hard dependency of `@dunx/openapi`, so\n // it arrives transitively and a scaffolded app never names it.\n dependencies: ['@dunx/openapi', 'zod'],\n config: [],\n },\n {\n name: 'http',\n source: 'http',\n summary:\n 'CORS, a middleware of your own on the response, and error mapping.',\n requires: [],\n module: { klass: 'HttpModule', from: './http/http.module.js' },\n dependencies: [],\n config: ['corsOrigin'],\n },\n {\n name: 'guards',\n source: 'guards',\n summary:\n 'Route guards with @Roles and @Public, and a protected controller.',\n requires: [],\n module: { klass: 'GuardsModule', from: './guards/guards.module.js' },\n dependencies: ['zod'],\n config: ['authorization'],\n },\n {\n name: 'database',\n source: 'database',\n summary: 'drizzle over bun:sqlite, with a schema, seeds and migrations.',\n requires: [],\n module: { klass: 'DatabaseModule', from: './database/database.module.js' },\n dependencies: ['@dunx/infra', 'drizzle-orm', 'zod'],\n config: ['database'],\n },\n {\n name: 'users',\n source: 'users',\n summary: 'A repository, a service and validated routes over the database.',\n requires: ['database'],\n module: { klass: 'UsersModule', from: './users/users.module.js' },\n dependencies: ['@dunx/infra', 'drizzle-orm', 'zod'],\n config: ['appName', 'seedUsers'],\n },\n {\n name: 'auth',\n source: 'auth',\n summary: 'better-auth mounted, with SessionGuard and an audit trail.',\n requires: ['database'],\n module: { klass: 'AccountsModule', from: './auth/auth.module.js' },\n dependencies: ['@dunx/auth', 'better-auth', 'drizzle-orm'],\n config: ['auth', 'port'],\n },\n {\n name: 'cache',\n source: 'cache',\n summary: 'Bun.RedisClient behind a session store, degrading when absent.',\n requires: [],\n module: { klass: 'CacheModule', from: './cache/cache.module.js' },\n dependencies: ['@dunx/infra', 'zod'],\n config: ['redis'],\n service: 'Redis or Valkey',\n },\n {\n name: 'websockets',\n source: 'chat',\n summary: 'A @Gateway with @OnMessage events, PubSub and a Redis relay.',\n // `cache` joined this list for the same reason `files` joined health's: the gateway\n // injects `RedisConnection` for cross-process fan-out, and a module now has to\n // import the one that provides it. The summary already said \"and a Redis relay\".\n requires: ['cache'],\n module: { klass: 'ChatModule', from: './chat/chat.module.js' },\n dependencies: ['@dunx/infra'],\n config: [],\n service: 'Redis or Valkey, for multi-node fan-out only',\n },\n {\n name: 'images',\n source: 'pictures',\n summary: 'Bun.Image resizing and format conversion behind a route.',\n requires: [],\n module: { klass: 'PicturesModule', from: './pictures/pictures.module.js' },\n dependencies: ['@dunx/infra', 'zod'],\n config: ['images'],\n },\n {\n name: 'files',\n source: 'storage',\n summary: 'Uploads and downloads on Bun.file, with a workspace root.',\n requires: [],\n module: { klass: 'StorageModule', from: './storage/storage.module.js' },\n dependencies: ['@dunx/infra', 'zod'],\n config: [],\n },\n {\n name: 'jobs',\n source: 'jobs',\n summary: 'bullmq queues and a worker, over Bun.RedisClient.',\n requires: ['images'],\n module: { klass: 'JobsModule', from: './jobs/jobs.module.js' },\n dependencies: ['@dunx/infra', 'bullmq', 'ioredis', 'zod'],\n config: ['redis'],\n service: 'Redis or Valkey',\n },\n {\n name: 'health',\n source: 'health',\n summary:\n \"`HealthModule`'s liveness and readiness probes, wired to this app's own indicators.\",\n // Each one supplies an indicator: `cache` the Redis connection, `database` the\n // connection and the `Ledger` the custom check queries, `files` the `Workspace`\n // whose directory the disk check measures. Selecting health without them used\n // to typecheck and fail at boot.\n requires: ['cache', 'database', 'files'],\n module: { klass: 'ProbesModule', from: './health/health.module.js' },\n dependencies: ['@dunx/infra'],\n config: ['appName'],\n },\n {\n name: 'throttle',\n source: 'throttle',\n summary:\n 'A fixed-window rate limit, with the counter in Redis and per-route overrides.',\n // `cache` for the `RedisConnection` the shared counter writes to. The\n // in-process default needs nothing, but it is per replica, so the example\n // shows the one that survives a second pod.\n requires: ['cache'],\n module: { klass: 'LimitsModule', from: './throttle/throttle.module.js' },\n dependencies: ['@dunx/infra'],\n config: ['appName', 'throttle'],\n service: 'Redis or Valkey',\n },\n {\n name: 'schedule',\n source: 'schedule',\n summary:\n '@Cron, @Interval and @OnceOnBoot on Bun.cron, armed at boot and triggerable.',\n requires: [],\n module: {\n klass: 'MaintenanceModule',\n from: './schedule/schedule.module.js',\n },\n dependencies: ['@dunx/infra'],\n config: ['schedule'],\n },\n {\n name: 'assets',\n source: 'assets',\n summary:\n 'A static directory on Bun.file, with a short max-age and an immutable rule.',\n requires: [],\n module: { klass: 'AssetsModule', from: './assets/assets.module.js' },\n dependencies: [],\n config: [],\n },\n {\n name: 'client',\n source: 'upstream',\n summary:\n 'The outbound half of @dunx/http: retry, backoff and a typed FetchError.',\n requires: [],\n module: { klass: 'UpstreamModule', from: './upstream/upstream.module.js' },\n dependencies: [],\n config: ['appName', 'upstream'],\n },\n];\n\nexport const featureNames: readonly string[] = FEATURES.map(\n (feature) => feature.name,\n);\n\nconst byName = new Map(FEATURES.map((feature) => [feature.name, feature]));\n\nexport class UnknownFeatureError extends Error {\n override readonly name = 'UnknownFeatureError';\n}\n\n/**\n * The selection plus everything it requires, in **import order** - which is\n * construction order, and shutdown runs in reverse. A feature is emitted after\n * everything it requires, so the database outlives the features reading it, the\n * same ordering `examples/full/src/app.module.ts` states by hand.\n *\n * Depth-first over `requires`, with a visited set, so a diamond resolves once and\n * the result is stable whatever order the caller asked in.\n */\nexport const resolveFeatures = (\n requested: readonly string[],\n): readonly Feature[] => {\n const unknown = requested.filter((name) => !byName.has(name));\n if (unknown.length > 0) {\n throw new UnknownFeatureError(\n `Unknown feature${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}. ` +\n `Available: ${featureNames.join(', ')}.`,\n );\n }\n\n const ordered: Feature[] = [];\n const seen = new Set<string>();\n const rank = new Map(FEATURES.map((feature, at) => [feature.name, at]));\n\n const visit = (name: string): void => {\n if (seen.has(name)) return;\n seen.add(name);\n const feature = byName.get(name);\n if (!feature) return;\n // Requirements in **registry order**, not in the order this feature happens to\n // list them: two independent requirements would otherwise come out in the\n // order they were typed, which is not a statement about construction order and\n // would make `requires: ['cache', 'database']` build the cache first.\n for (const required of [...feature.requires].sort(\n (left, right) => (rank.get(left) ?? 0) - (rank.get(right) ?? 0),\n )) {\n visit(required);\n }\n ordered.push(feature);\n };\n\n // Registry order, not request order, so two runs asking for the same set in a\n // different order generate byte-identical files.\n for (const feature of FEATURES) {\n if (requested.includes(feature.name)) visit(feature.name);\n }\n\n return ordered;\n};\n\n/** Which of the resolved features the caller did not ask for. */\nexport const impliedBy = (\n requested: readonly string[],\n resolved: readonly Feature[],\n): readonly string[] =>\n resolved\n .map((feature) => feature.name)\n .filter((name) => !requested.includes(name));\n",
|
|
6
|
+
"import { existsSync, readdirSync } from 'node:fs';\nimport { basename, dirname, join, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\nimport { Glob } from 'bun';\nimport { resolveFeatures, type Feature } from './features.js';\nimport {\n appModule,\n bootstrap,\n config,\n configGroupsFor,\n envExample,\n main,\n manifest,\n readme,\n worker,\n} from './generate.js';\n\n/** The templates that ship with the package, as `templates/<name>/`. */\nexport const TEMPLATES = Object.freeze(['minimal'] as const);\nexport type TemplateName = (typeof TEMPLATES)[number];\n\n/**\n * Every `@dunx/*` version in a template manifest is this placeholder. Versioning\n * is lockstep, so the right version to install is whatever version of\n * `@dunx/create-app` is doing the scaffolding - resolved at run time rather than\n * written into the template, which would go stale on the next release.\n */\nexport const VERSION_PLACEHOLDER = '__DUNX_VERSION__';\n\n/**\n * Names a package cannot ship as-is, so they ship prefixed and are renamed on write.\n *\n * `.gitignore` is the known one: npm renames a published copy to `.npmignore`.\n *\n * **`bunfig.toml` is the one that was silently missing.** It is stripped from the\n * tarball entirely - presumably so a dependency cannot hijack the installing\n * project's Bun config - and it is the single file dunx asks an app to have. Every\n * app scaffolded from a published `@dunx/create-app` therefore had no\n * `@dunx/transform/preload`, and failed at boot with the very error the guide\n * describes. Measured with `bun pm pack`, and `pack.test.ts` now measures it on\n * every run rather than trusting this comment.\n */\nconst RENAMED = Object.freeze({\n _gitignore: '.gitignore',\n '_bunfig.toml': 'bunfig.toml',\n});\n\n/**\n * Entries that do not make a directory non-empty for scaffolding purposes.\n *\n * `.git` is the one that matters: `git init` then scaffold into the repo is the\n * documented way to start, and refusing it blocks the flow outright. `.gitkeep`\n * exists only so git can track an otherwise empty directory, so it *means* empty.\n * `.DS_Store` appears from merely opening the folder in Finder. `LICENSE` is what\n * GitHub's create-a-repository flow leaves in a fresh clone.\n *\n * The list is deliberately short, and the test for it is whether the template\n * writes that name. It does not write any of these four, so ignoring them can\n * never destroy anything. `.gitignore` and `README.md` are excluded for exactly\n * that reason: the template writes both, and silently overwriting a user's copy\n * is what `--force` exists to gate.\n */\nconst IGNORED_WHEN_EMPTY: ReadonlySet<string> = new Set([\n '.DS_Store',\n '.git',\n '.gitkeep',\n 'LICENSE',\n]);\n\nexport interface ScaffoldOptions {\n /** Directory to create. Relative paths resolve against `cwd`. */\n readonly target: string;\n /** Package name for the generated app. Defaults to the target's basename. */\n readonly name?: string;\n readonly template?: TemplateName;\n /**\n * Features to compose the app from, by name. Anything they require is pulled in.\n *\n * Passing any switches from copying a fixed template to generating the wiring\n * around the chosen feature directories - see `generate.ts`. An empty list, or\n * none at all, scaffolds `template` unchanged, so the default behaviour is exactly\n * what it was.\n */\n readonly features?: readonly string[];\n /** Write into a directory that already has files in it. */\n readonly force?: boolean;\n readonly cwd?: string;\n /** Overrides the version written into the generated manifest. */\n readonly version?: string;\n}\n\nexport interface ScaffoldResult {\n readonly directory: string;\n readonly name: string;\n readonly template: TemplateName | 'composed';\n /** Resolved feature names, in import order. Empty for a fixed template. */\n readonly features: readonly string[];\n readonly files: readonly string[];\n}\n\nexport class ScaffoldError extends Error {\n override readonly name = 'ScaffoldError';\n}\n\n/**\n * `dist/index.js` and `dist/cli.js` both sit one level under the package root, so\n * `../templates` resolves the same from either. In the source tree it resolves\n * from `src/`, which is the same depth - so tests exercise the real path rather\n * than a special case.\n *\n * `fileURLToPath`, not `new URL(...).pathname`: the latter stays percent-encoded,\n * so an install under a directory with a space in it looks for `space%20test/`\n * and reports the template missing. On Windows it is worse - it yields a\n * leading-slash, drive-lettered path that resolves nowhere.\n */\nconst templatesRoot = (): string =>\n resolve(dirname(fileURLToPath(import.meta.url)), '..', 'templates');\n\n/**\n * npm forbids uppercase and a leading dot or underscore, and a scope is legal.\n * Checked here because the failure would otherwise surface as a confusing\n * `bun install` error inside a directory the user just created.\n */\nconst isValidPackageName = (name: string): boolean =>\n /^(?:@[a-z0-9-*~][a-z0-9-*._~]*\\/)?[a-z0-9-~][a-z0-9-._~]*$/.test(name);\n\nconst readPackageVersion = async (): Promise<string> => {\n const file = Bun.file(join(templatesRoot(), '..', 'package.json'));\n const json = (await file.json()) as { version?: string };\n return json.version ?? '0.0.0';\n};\n\n/** Placeholders are substituted in every written file, generated or copied. */\nconst fill = (contents: string, name: string, version: string): string =>\n contents\n .replaceAll(VERSION_PLACEHOLDER, version)\n .replaceAll('__DUNX_APP_NAME__', name);\n\n/**\n * The four files a subset of features cannot copy, because the full example states\n * every feature at once in each of them.\n */\nconst generated = (\n name: string,\n features: readonly Feature[],\n): Readonly<Record<string, string>> => {\n const groups = configGroupsFor(features);\n const files: Record<string, string> = {\n 'package.json': manifest(features),\n 'README.md': readme(name, features),\n '.env.example': envExample(groups),\n 'src/main.ts': main(name, features),\n 'src/bootstrap.ts': bootstrap(name, features),\n 'src/app.module.ts': appModule(name, features),\n 'src/config.ts': config(name, groups),\n };\n if (features.some((feature) => feature.name === 'jobs')) {\n files['src/worker.ts'] = worker(name);\n }\n return files;\n};\n\nexport const scaffold = async (\n options: ScaffoldOptions,\n): Promise<ScaffoldResult> => {\n const template = options.template ?? 'minimal';\n if (!TEMPLATES.includes(template)) {\n throw new ScaffoldError(\n `Unknown template \"${template}\". Available: ${TEMPLATES.join(', ')}.`,\n );\n }\n\n // Resolved before anything is written, so an unknown feature name fails with the\n // list of real ones rather than half a directory.\n const requested = options.features ?? [];\n let features: readonly Feature[] = [];\n try {\n features = resolveFeatures(requested);\n } catch (error) {\n throw new ScaffoldError(\n error instanceof Error ? error.message : String(error),\n );\n }\n const composing = features.length > 0;\n\n const directory = resolve(options.cwd ?? process.cwd(), options.target);\n const name = options.name ?? basename(directory);\n\n if (!isValidPackageName(name)) {\n throw new ScaffoldError(\n `\"${name}\" is not a usable package name. Pass --name to choose one.`,\n );\n }\n\n if (existsSync(directory) && options.force !== true) {\n const blocking = readdirSync(directory).filter(\n (entry) => !IGNORED_WHEN_EMPTY.has(entry),\n );\n if (blocking.length > 0) {\n // Naming what blocked it, because `.git` used to block it and the message\n // gave no way to tell that from a directory of real work.\n const shown = blocking.sort().slice(0, 3).join(', ');\n const rest = blocking.length > 3 ? `, +${blocking.length - 3} more` : '';\n throw new ScaffoldError(\n `${directory} is not empty (${shown}${rest}). ` +\n `Pass --force to write into it anyway.`,\n );\n }\n }\n\n const version = options.version ?? `^${await readPackageVersion()}`;\n const written: string[] = [];\n\n /** Copies a directory of the package's own templates into the new app. */\n const copyTree = async (from: string, into: string): Promise<void> => {\n // `**/*` with `dot: true` so a template can carry a dotfile that npm did not\n // rename; the explicit `_gitignore` mapping covers the one that it does.\n for await (const relative of new Glob('**/*').scan({\n cwd: from,\n dot: true,\n onlyFiles: true,\n })) {\n const base = relative.split('/').at(-1) ?? relative;\n const renamed = (RENAMED as Record<string, string | undefined>)[base];\n const target = join(\n into,\n renamed === undefined ? relative : join(dirname(relative), renamed),\n );\n\n const contents = await Bun.file(join(from, relative)).text();\n // `Bun.write` creates parent directories, so there is no mkdir pass.\n await Bun.write(join(directory, target), fill(contents, name, version));\n written.push(target);\n }\n };\n\n if (!composing) {\n const source = join(templatesRoot(), template);\n if (!existsSync(source)) {\n throw new ScaffoldError(\n `Template \"${template}\" is missing from ${source}.`,\n );\n }\n await copyTree(source, '.');\n return {\n directory,\n name,\n template,\n features: [],\n files: written.sort(),\n };\n }\n\n // The base carries what every composed app needs and no feature owns: the\n // tsconfig, the transform preload, and the gitignore.\n const base = join(templatesRoot(), 'base');\n if (!existsSync(base)) {\n throw new ScaffoldError(\n `The base template is missing from ${base}. Run \\`bun run sync:templates\\`.`,\n );\n }\n await copyTree(base, '.');\n\n for (const feature of features) {\n const from = join(templatesRoot(), 'features', feature.source);\n if (!existsSync(from)) {\n throw new ScaffoldError(\n `Feature \"${feature.name}\" is missing from ${from}. ` +\n 'Run `bun run sync:templates`.',\n );\n }\n await copyTree(from, join('src', feature.source));\n }\n\n for (const [target, contents] of Object.entries(generated(name, features))) {\n await Bun.write(join(directory, target), fill(contents, name, version));\n written.push(target);\n }\n\n return {\n directory,\n name,\n template: 'composed',\n features: features.map((feature) => feature.name),\n files: written.sort(),\n };\n};\n",
|
|
7
|
+
"import { BASE_CONFIG, CONFIG_GROUPS, type Feature } from './features.js';\n\n/**\n * The wiring, generated from a feature selection.\n *\n * The full example states every feature at once in four files - `app.module.ts`,\n * `config.ts`, `bootstrap.ts` and `main.ts` - so those are the ones a subset cannot\n * copy. Everything else is the feature's own directory, copied verbatim.\n *\n * Generated rather than assembled by editing a copy on purpose: an edited copy\n * cannot be checked against the example it came from, and the byte-for-byte parity\n * test is what stops the vendored features drifting from the app CI actually boots.\n */\nconst HEADER = (name: string): string =>\n `// Generated by @dunx/create-app for ${name}. Yours to edit.\\n`;\n\nconst uniq = (values: readonly string[]): string[] => [...new Set(values)];\n\n/** Every config group the selection needs, base first, in a stable order. */\nexport const configGroupsFor = (\n features: readonly Feature[],\n): readonly string[] => {\n const wanted = uniq([\n ...BASE_CONFIG,\n ...features.flatMap((feature) => feature.config),\n ]);\n return Object.keys(CONFIG_GROUPS).filter((group) => wanted.includes(group));\n};\n\nexport const dependenciesFor = (\n features: readonly Feature[],\n): readonly string[] =>\n uniq([\n '@dunx/core',\n '@dunx/http',\n '@dunx/transform',\n '@dunx/infra',\n ...features.flatMap((feature) => feature.dependencies),\n ]).sort();\n\nconst DUNX = /^@dunx\\//;\n\nexport const manifest = (features: readonly Feature[]): string => {\n const deps = dependenciesFor(features);\n const dependencies: Record<string, string> = {};\n for (const dep of deps) {\n dependencies[dep] = DUNX.test(dep) ? '__DUNX_VERSION__' : versionOf(dep);\n }\n\n const scripts: Record<string, string> = {\n start: 'bun src/main.ts',\n test: 'bun test',\n typecheck: 'tsc --noEmit',\n };\n // A queue needs a process to drain it, and it is not the web one.\n if (features.some((feature) => feature.name === 'jobs')) {\n scripts['worker'] = 'bun src/worker.ts';\n }\n\n return `${JSON.stringify(\n {\n name: '__DUNX_APP_NAME__',\n version: '0.1.0',\n private: true,\n type: 'module',\n scripts,\n dependencies,\n devDependencies: {\n '@dunx/testing': '__DUNX_VERSION__',\n '@types/bun': '>=1.3.0',\n typescript: '^5.7.0',\n },\n engines: { bun: '>=1.3.0' },\n },\n null,\n 2,\n )}\\n`;\n};\n\n/**\n * Third-party ranges, pinned here rather than read off `examples/full` at run time:\n * the generated app installs from npm and the example installs from the workspace,\n * so the example's manifest is not a statement about what a consumer should take.\n * `features.test.ts` checks these against the example's, which is what stops them\n * silently diverging from a version combination that is actually exercised.\n */\nexport const THIRD_PARTY: Readonly<Record<string, string>> = Object.freeze({\n zod: '^4.4.3',\n 'drizzle-orm': '^0.45.2',\n 'better-auth': '^1.6.25',\n bullmq: '^6.0.5',\n ioredis: '^6.0.0',\n});\n\nconst versionOf = (dep: string): string => THIRD_PARTY[dep] ?? 'latest';\n\nexport const appModule = (\n name: string,\n features: readonly Feature[],\n): string => {\n const needsLogger = true;\n // `OpenApiModule` wraps this module rather than being imported by it, so its\n // factory resolves `Auth` from here - see `bootstrap`.\n const documentsAuth = has(features, 'openapi') && has(features, 'auth');\n const imports = [\n ...(documentsAuth ? [\"import { Auth } from '@dunx/auth';\"] : []),\n \"import { ConfigModule, Module } from '@dunx/core';\",\n ...(needsLogger\n ? [\"import { LoggerModule } from '@dunx/infra/logger';\"]\n : []),\n \"import { AppConfigService, validate } from './config.js';\",\n ...features.map(\n (feature) =>\n `import { ${feature.module.klass} } from '${feature.module.from}';`,\n ),\n ];\n\n const moduleImports = [\n 'ConfigModule.forRoot({ validate, as: AppConfigService }),',\n '// The level comes from the validated config, which is the one thing a',\n '// zero-argument `forRoot` function cannot reach.',\n 'LoggerModule.forRootAsync(',\n ' {',\n ' useFactory: (config: AppConfigService) => ({',\n \" name: config.get('appName'),\",\n \" level: config.get('log').level,\",\n ' }),',\n ' inject: [AppConfigService] as const,',\n ' },',\n ' { captureGlobalErrors: true },',\n '),',\n ...features.map((feature) => `${feature.module.klass},`),\n ];\n\n return `${HEADER(name)}${imports.join('\\n')}\n\n/**\n * Import order is construction order, and shutdown runs in reverse - so config and\n * the logger are built first and torn down last, and anything a feature depends on\n * outlives it.\n */\n@Module({\n imports: [\n${moduleImports.map((line) => ` ${line}`).join('\\n')}\n ],${\n documentsAuth\n ? `\n // Better Auth serves its own routes, so the document is the only place they\n // appear - and \\`betterAuthDocument\\` needs the instance.\n exports: [Auth],`\n : ''\n }\n})\nexport class AppModule {}\n`;\n};\n\nexport const config = (name: string, groups: readonly string[]): string => {\n const chosen = groups\n .map((group) => [group, CONFIG_GROUPS[group]] as const)\n .filter(\n (entry): entry is [string, (typeof CONFIG_GROUPS)[string]] =>\n entry[1] !== undefined,\n );\n\n const schema = chosen.flatMap(([, group]) => group.schema);\n const needsLogLevel = groups.includes('log');\n\n return `${HEADER(name)}import { ConfigService, type ConfigSource${\n needsLogLevel ? ', LogLevel' : ''\n } } from '@dunx/core';\nimport { z } from 'zod';\n\n/**\n * One validation function, which is the whole \\`ConfigModule\\` contract. dunx does\n * not pick the library - this is zod because the routes already use it, and a\n * hand-written function that throws would work identically.\n *\n * \\`.default()\\` is where a value comes from when the variable is unset, so a clean\n * checkout boots with no \\`.env\\` at all. Bun loads \\`.env\\` and \\`.env.local\\` itself,\n * so there is nothing here that reads a file.\n */\nconst envSchema = z.object({\n${schema.map((line) => ` ${line}`).join('\\n')}\n});\n\nexport interface AppConfig {\n${chosen.map(([, group]) => ` ${group.field}`).join('\\n')}\n}\n\n/**\n * One name for the typed config everywhere. A subclass rather than\n * \\`ConfigService<AppConfig>\\` at each site because a factory's \\`inject: [...]\\`\n * carries no type argument - the class does, and it is a real runtime value, so it\n * is both a precise token and a usable constructor annotation.\n */\nexport class AppConfigService extends ConfigService<AppConfig> {}\n\n/** The one broker channel the websocket relay carries every topic on. */\nexport const RELAY_CHANNEL = '__DUNX_APP_NAME__:ws';\n\n/** Flat variables in, a shaped object out. Nothing downstream reads \\`Bun.env\\`. */\nexport const validate = (env: ConfigSource): AppConfig => {\n const parsed = envSchema.safeParse(env);\n if (!parsed.success) {\n const issues = parsed.error.issues\n .map((issue) => \\`\\${issue.path.join('.') || '(root)'}: \\${issue.message}\\`)\n .join('\\\\n - ');\n throw new Error(\\`Configuration is invalid:\\\\n - \\${issues}\\`);\n }\n const value = parsed.data;\n\n return {\n${chosen.map(([, group]) => ` ${group.map}`).join('\\n')}\n };\n};\n`;\n};\n\nconst has = (features: readonly Feature[], name: string): boolean =>\n features.some((feature) => feature.name === name);\n\nexport const bootstrap = (\n name: string,\n features: readonly Feature[],\n): string => {\n const openapi = has(features, 'openapi');\n const websockets = has(features, 'websockets');\n const http = has(features, 'http');\n const documentsAuth = openapi && has(features, 'auth');\n // Both bind a middleware class that the **app** registers rather than the module:\n // position in the chain is the app's decision, so it is generated here.\n const assets = has(features, 'assets');\n const throttle = has(features, 'throttle');\n\n const imports = [\n ...(documentsAuth\n ? [\"import { Auth, betterAuthDocument } from '@dunx/auth';\"]\n : []),\n `import { ${[\n 'HttpFactory',\n ...(websockets ? ['RedisRelay'] : []),\n ...(assets ? ['StaticFiles'] : []),\n ...(throttle ? ['ThrottleGuard'] : []),\n 'type HttpApp',\n ].join(', ')} } from '@dunx/http';`,\n ...(openapi ? [\"import { OpenApiModule } from '@dunx/openapi';\"] : []),\n \"import { AppModule } from './app.module.js';\",\n `import { ${[\n ...(http ? ['AppConfigService'] : []),\n ...(websockets ? ['RELAY_CHANNEL'] : []),\n ].join(', ')} } from './config.js';`,\n ...(http\n ? [\"import { RequestTrailMiddleware } from './http/request-trail.js';\"]\n : []),\n ].filter((line) => !line.includes('{ }'));\n\n /**\n * `forRootAsync` once Better Auth is in: it answers `/api/auth/*` from its own\n * handler, so route discovery sees none of it and `contribute` is what puts its\n * paths in the document. The factory needs the instance, and there is no\n * container while the module graph is still being described.\n */\n const root = documentsAuth\n ? `OpenApiModule.forRootAsync({\n root: AppModule,\n inject: [Auth] as const,\n useFactory: (auth: Auth) => ({\n title: '__DUNX_APP_NAME__',\n version: '0.1.0',\n contribute: [\n betterAuthDocument(auth, { basePath: '/api/auth', tag: 'Auth' }),\n ],\n }),\n })`\n : openapi\n ? `OpenApiModule.forRoot({\n title: '__DUNX_APP_NAME__',\n version: '0.1.0',\n root: AppModule,\n })`\n : 'AppModule';\n\n const options = websockets\n ? [\n '// Multi-node websocket fan-out on `Bun.RedisClient`, so it costs no',\n '// dependency. With no Redis running this degrades to single-process',\n '// behaviour, logs one warning, and the app still boots.',\n 'websocket: { idleTimeout: 30 },',\n 'relay: new RedisRelay({ connectionTimeout: 500 }),',\n 'relayChannel: RELAY_CHANNEL,',\n ]\n : [];\n\n /**\n * Everything between `create()` and `listen()`. The prefix is set whatever is\n * selected, because the copied controllers declare paths under it and the URLs\n * `main.ts` prints assume it.\n *\n * `app.use` takes the middleware **class**, not an instance: the container\n * constructs it, which is what lets it have dependencies of its own.\n */\n const shaping = [\n \"app.setGlobalPrefix('api');\",\n // Assets first, and outside the prefix: a page pulling twenty hashed bundles\n // must not spend a caller's request budget, and middleware never gets the\n // prefix because it is not a discovered route.\n ...(assets ? ['app.use(StaticFiles);'] : []),\n ...(http\n ? [\n 'app.use(RequestTrailMiddleware);',\n \"app.set('trust proxy', true);\",\n 'app.enableCors({',\n \" origin: app.get(AppConfigService).get('corsOrigin'),\",\n ' credentials: true,',\n ' maxAge: 600,',\n '});',\n ]\n : []),\n // After anything that establishes who is calling, because the subject the\n // limit counts by is what that decides.\n ...(throttle ? ['app.use(ThrottleGuard);'] : []),\n ];\n\n return `${HEADER(name)}${imports.join('\\n')}\n\n/**\n * One app, built the same way for \\`bun start\\` and for the tests - so what the\n * tests exercise is what actually serves.\n *\n * \\`create()\\` boots the container and discovers routes and gateways; \\`listen()\\` is\n * what builds the \\`Bun.serve\\` route table. Everything between the two still gets to\n * shape it, and after \\`listen()\\` every one of those throws.\n */\nexport const createApp = async (): Promise<HttpApp> => {\n const app = await HttpFactory.create(\n ${root}${\n options.length === 0\n ? ',\\n'\n : `,\n {\n${options.map((line) => ` ${line}`).join('\\n')}\n },\n`\n } );\n\n${shaping.map((line) => ` ${line}`).join('\\n')}\n\n return app;\n};\n`;\n};\n\nexport const main = (name: string, features: readonly Feature[]): string => {\n const health = has(features, 'health');\n const openapi = has(features, 'openapi');\n\n const lines = [\n ...(openapi\n ? [\n \"logger.info(`docs ${new URL('api/docs', url).href}`);\",\n \"logger.info(`openapi ${new URL('api/openapi.json', url).href}`);\",\n ]\n : []),\n ...(health\n ? [\"logger.info(`health ${new URL('api/health', url).href}`);\"]\n : []),\n ];\n\n return `${HEADER(name)}import { Logger } from '@dunx/core';\nimport { createApp } from './bootstrap.js';\nimport { AppConfigService } from './config.js';\n\nasync function bootstrap(): Promise<void> {\n const app = await createApp();\n app.enableShutdownHooks();\n\n const config = app.get(AppConfigService);\n const logger = app.get(Logger);\n const url = await app.listen(config.get('port'));\n\n logger.info(\\`listening on \\${url}\\`);\n${lines.map((line) => ` ${line}`).join('\\n')}${lines.length > 0 ? '\\n' : ''}\n // Nothing else to do: the server holds the process open, and the shutdown hooks\n // resolve this once a signal arrives.\n await app.closed;\n}\n\nbootstrap().catch((error: unknown) => {\n console.error('failed to start', error);\n process.exit(1);\n});\n`;\n};\n\n/** The queue worker, only when a queue was asked for. */\nexport const worker = (name: string): string =>\n `${HEADER(name)}import { AppFactory } from '@dunx/core';\nimport { AppModule } from './app.module.js';\n\n/**\n * A queue needs a process to drain it, and it is deliberately not the web one: a\n * worker that shares the server's event loop competes with request handling.\n */\nconst app = await AppFactory.create(AppModule);\napp.enableShutdownHooks();\nawait app.closed;\n`;\n\nexport const envExample = (groups: readonly string[]): string => {\n const lines = groups\n .flatMap((group) => CONFIG_GROUPS[group]?.env ?? [])\n .map((entry) => `${entry.name}=${entry.value}`);\n\n return lines.length === 0\n ? '# Every variable has a default, so this file is optional.\\n'\n : `# Every variable here has a default, so the app boots with no .env at all.\\n${lines.join('\\n')}\\n`;\n};\n\nexport const readme = (name: string, features: readonly Feature[]): string => {\n const services = features.filter((feature) => feature.service !== undefined);\n\n return `# ${name}\n\nScaffolded with \\`bunx @dunx/create-app\\`.\n\n\\`\\`\\`bash\nbun install\nbun run start\n\\`\\`\\`\n\n## What is wired up\n\n${\n features.length === 0\n ? 'Nothing beyond the base app.'\n : features\n .map((feature) => `- **${feature.name}** - ${feature.summary}`)\n .join('\\n')\n}\n\n${\n services.length === 0\n ? ''\n : `## Services\n\nThese features need something running. Each one degrades rather than failing the\nboot, so the app still starts without them.\n\n${services.map((feature) => `- **${feature.name}** needs ${feature.service}`).join('\\n')}\n\n`\n}## Layout\n\n- \\`src/main.ts\\` - the entry point\n- \\`src/bootstrap.ts\\` - builds the app; shared by \\`start\\` and the tests\n- \\`src/app.module.ts\\` - the root module, importing every feature\n- \\`src/config.ts\\` - one validation function, flat env in and a shaped object out\n${features.map((feature) => `- \\`src/${feature.source}/\\` - ${feature.name}`).join('\\n')}\n\n\\`main.ts\\`, \\`bootstrap.ts\\`, \\`app.module.ts\\` and \\`config.ts\\` were generated for the\nfeatures you chose; everything else is copied from dunx's \\`examples/full\\`, which is\nrun and toured in CI on every push. The \\`*.demo.ts\\` files are that example's\nscripted walkthroughs - delete one and its \\`providers\\` entry when you do not want it.\n\n## Constructor injection\n\n\\`bunfig.toml\\` preloads \\`@dunx/transform\\`, which records each class's constructor\nparameter types so the container can resolve them. Without that line providers are\nbuilt with no arguments and boot fails saying so.\n`;\n};\n"
|
|
8
|
+
],
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "8590CB93017DD8C964756E2164756E21",
|
|
11
|
+
"names": []
|
|
12
|
+
}
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two cache policies, and the traversal refusal.
|
|
5
|
+
*
|
|
6
|
+
* There is no `index.html` fallback and no SPA rewrite here, because `StaticFiles`
|
|
7
|
+
* ships neither: building one in would mean the middleware deciding what a 404
|
|
8
|
+
* means for paths it does not own.
|
|
9
|
+
*/
|
|
10
|
+
export class AssetsDemo {
|
|
11
|
+
constructor(private readonly logger: Logger) {}
|
|
12
|
+
|
|
13
|
+
async demonstrate(url: string): Promise<void> {
|
|
14
|
+
for (const path of ['assets/site.css', 'assets/app.a1b2c3d4.js']) {
|
|
15
|
+
const response = await fetch(new URL(path, url));
|
|
16
|
+
this.logger.info(
|
|
17
|
+
`GET /${path} -> ${response.status} ` +
|
|
18
|
+
`${response.headers.get('content-type')}, ` +
|
|
19
|
+
`cache-control: ${response.headers.get('cache-control')}`,
|
|
20
|
+
);
|
|
21
|
+
await response.arrayBuffer();
|
|
22
|
+
}
|
|
23
|
+
this.logger.info(
|
|
24
|
+
'the hashed name is immutable, the plain one is max-age=60 - a content ' +
|
|
25
|
+
'hash is the only honest reason to promise forever',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Resolved against the root at construction, and checked on every request.
|
|
29
|
+
const escaped = await fetch(new URL('assets/../../package.json', url));
|
|
30
|
+
this.logger.info(
|
|
31
|
+
`GET /assets/../../package.json -> ${escaped.status} (never leaves the root)`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Anything outside the mount falls straight through, so the app's own routes
|
|
35
|
+
// and its 404 behave exactly as they did before this was registered.
|
|
36
|
+
const through = await fetch(new URL('api/notes', url));
|
|
37
|
+
this.logger.info(
|
|
38
|
+
`GET /api/notes -> ${through.status} (outside /assets, untouched)`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Module } from '@dunx/core';
|
|
2
|
+
import { StaticModule } from '@dunx/http';
|
|
3
|
+
import { AssetsDemo } from './assets.demo.js';
|
|
4
|
+
|
|
5
|
+
/** A content hash, so a change produces a different URL. */
|
|
6
|
+
const HASHED = /\.[0-9a-f]{8}\.(js|css)$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `public/` directory next to this file, served at `/assets`.
|
|
10
|
+
*
|
|
11
|
+
* `StaticModule` binds `StaticFiles`; the **app** registers it, in `bootstrap.ts`.
|
|
12
|
+
* Position in the chain is the app's decision and no default can make it: assets
|
|
13
|
+
* usually want to be outside an auth guard and inside request logging.
|
|
14
|
+
*
|
|
15
|
+
* The mount is outside `setGlobalPrefix('api')`, because middleware is not a
|
|
16
|
+
* discovered route and never gets the prefix.
|
|
17
|
+
*/
|
|
18
|
+
@Module({
|
|
19
|
+
imports: [
|
|
20
|
+
StaticModule.forRoot({
|
|
21
|
+
// Inside the feature folder rather than at the app root, so the folder is
|
|
22
|
+
// self-contained: `@dunx/create-app` vendors this directory wholesale, and
|
|
23
|
+
// an asset kept outside it would need machinery to travel with it.
|
|
24
|
+
root: new URL('./public', import.meta.url).pathname,
|
|
25
|
+
path: '/assets',
|
|
26
|
+
// Short, because a long max-age on a name that can change is a promise the
|
|
27
|
+
// server cannot keep.
|
|
28
|
+
maxAge: 60,
|
|
29
|
+
// Only honest for a content-addressed name. Guessing wrong here is a stale
|
|
30
|
+
// asset nobody can flush, which is why the default claims nothing.
|
|
31
|
+
immutable: (pathname) => HASHED.test(pathname),
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
providers: [AssetsDemo],
|
|
35
|
+
exports: [AssetsDemo],
|
|
36
|
+
})
|
|
37
|
+
export class AssetsModule {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
globalThis.dunxExample = 'content-addressed, so cacheable forever';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<main>
|
|
2
|
+
<h1>dunx static assets</h1>
|
|
3
|
+
<p>
|
|
4
|
+
Served by <code>StaticFiles</code> from <code>examples/full/public</code>,
|
|
5
|
+
on <code>Bun.file</code> - which streams, sets the content type, and answers
|
|
6
|
+
a Range request with sendfile(2).
|
|
7
|
+
</p>
|
|
8
|
+
<script src="/assets/app.a1b2c3d4.js"></script>
|
|
9
|
+
</main>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { AuthModule, bunPassword } from '@dunx/auth';
|
|
1
|
+
import { Auth, AuthModule, bunPassword } from '@dunx/auth';
|
|
2
2
|
import { drizzleDatabase } from '@dunx/auth/drizzle';
|
|
3
3
|
import { Module } from '@dunx/core';
|
|
4
4
|
import { DbConnection } from '@dunx/infra/db';
|
|
5
|
-
import { admin, bearer } from 'better-auth/plugins';
|
|
5
|
+
import { admin, bearer, openAPI } from 'better-auth/plugins';
|
|
6
6
|
import { AppConfigService } from '../config.js';
|
|
7
7
|
import { DatabaseModule } from '../database/database.module.js';
|
|
8
8
|
import { AuthDemo } from './auth.demo.js';
|
|
@@ -46,7 +46,15 @@ import { ProfileController } from './profile.controller.js';
|
|
|
46
46
|
// `admin` puts `role` on the user, which `@Roles()` then reads. `bearer`
|
|
47
47
|
// lets a non-browser client send `Authorization: Bearer <token>` instead of
|
|
48
48
|
// a cookie - which is what the tour does.
|
|
49
|
-
|
|
49
|
+
// `openAPI()` is what makes `generateOpenAPISchema` exist, and
|
|
50
|
+
// `betterAuthDocument` in bootstrap.ts is what puts its paths in the app's
|
|
51
|
+
// document. `disableDefaultReference` because dunx already serves an
|
|
52
|
+
// explorer at /api/docs and two reference pages is one too many.
|
|
53
|
+
plugins: [
|
|
54
|
+
admin(),
|
|
55
|
+
bearer(),
|
|
56
|
+
openAPI({ disableDefaultReference: true }),
|
|
57
|
+
],
|
|
50
58
|
}),
|
|
51
59
|
inject: [AppConfigService, DbConnection] as const,
|
|
52
60
|
},
|
|
@@ -59,6 +67,9 @@ import { ProfileController } from './profile.controller.js';
|
|
|
59
67
|
providers: [AuthTables, Audit, AuthDemo],
|
|
60
68
|
// `AuthTables` creates them on the app's own handle, so this module needs the
|
|
61
69
|
// drizzle handle as well as the connection the factory above used.
|
|
62
|
-
|
|
70
|
+
//
|
|
71
|
+
// `Auth` comes back out because `OpenApiModule` wraps the root and can only
|
|
72
|
+
// inject what the root exports - see bootstrap.ts.
|
|
73
|
+
exports: [Audit, AuthDemo, Auth],
|
|
63
74
|
})
|
|
64
75
|
export class AccountsModule {}
|
|
@@ -12,14 +12,17 @@ import { Sessions } from './sessions.service.js';
|
|
|
12
12
|
|
|
13
13
|
const SessionKey = z
|
|
14
14
|
.object({ id: z.string().min(1).max(80) })
|
|
15
|
-
.meta({ id: 'SessionKey',
|
|
15
|
+
.meta({ id: 'SessionKey', description: 'A session id' });
|
|
16
16
|
|
|
17
17
|
const StoreSession = z
|
|
18
18
|
.object({
|
|
19
19
|
data: z.record(z.string(), z.unknown()),
|
|
20
20
|
ttl: z.coerce.number().int().min(1).max(3600).default(60),
|
|
21
21
|
})
|
|
22
|
-
.meta({
|
|
22
|
+
.meta({
|
|
23
|
+
id: 'StoreSession',
|
|
24
|
+
description: 'Session payload and its lifetime',
|
|
25
|
+
});
|
|
23
26
|
|
|
24
27
|
const oneSession = { params: SessionKey } as const;
|
|
25
28
|
const putSession = { params: SessionKey, body: StoreSession } as const;
|
|
@@ -14,14 +14,14 @@ import type { Entry } from './schema.js';
|
|
|
14
14
|
|
|
15
15
|
const EntryIndex = z
|
|
16
16
|
.object({ id: z.coerce.number().int().min(1) })
|
|
17
|
-
.meta({ id: 'EntryIndex',
|
|
17
|
+
.meta({ id: 'EntryIndex', description: 'A ledger entry id in the path' });
|
|
18
18
|
|
|
19
19
|
const CreateEntry = z
|
|
20
20
|
.object({
|
|
21
21
|
memo: z.string().min(1).max(80),
|
|
22
22
|
amount: z.number().int(),
|
|
23
23
|
})
|
|
24
|
-
.meta({ id: 'CreateEntry',
|
|
24
|
+
.meta({ id: 'CreateEntry', description: 'A single ledger movement' });
|
|
25
25
|
|
|
26
26
|
/** Both legs succeed or neither does - the rollback is the point of the route. */
|
|
27
27
|
const Transfer = z
|
|
@@ -36,7 +36,7 @@ const Transfer = z
|
|
|
36
36
|
*/
|
|
37
37
|
fail: z.boolean().default(false),
|
|
38
38
|
})
|
|
39
|
-
.meta({ id: 'Transfer',
|
|
39
|
+
.meta({ id: 'Transfer', description: 'Move an amount between two memos' });
|
|
40
40
|
|
|
41
41
|
const listEntries = {
|
|
42
42
|
query: z.object({
|
|
@@ -68,7 +68,10 @@ const pageQuery = z
|
|
|
68
68
|
.optional()
|
|
69
69
|
.describe('Opaque cursor from meta.nextCursor. Omit for the first page.'),
|
|
70
70
|
})
|
|
71
|
-
.meta({
|
|
71
|
+
.meta({
|
|
72
|
+
id: 'LedgerPageQuery',
|
|
73
|
+
description: 'Keyset pagination over the ledger',
|
|
74
|
+
});
|
|
72
75
|
|
|
73
76
|
const pagedEntries = { query: pageQuery } as const;
|
|
74
77
|
const oneEntry = { params: EntryIndex } as const;
|
|
@@ -12,12 +12,21 @@ import { z } from 'zod';
|
|
|
12
12
|
import { AuthGuard, RolesGuard } from './auth.guard.js';
|
|
13
13
|
import { ReportsService } from './reports.service.js';
|
|
14
14
|
|
|
15
|
+
const CreateReport = z
|
|
16
|
+
.object({ title: z.string().min(1) })
|
|
17
|
+
.meta({ id: 'CreateReport', description: 'A report to file' });
|
|
18
|
+
|
|
19
|
+
const RenameReport = z.object({ title: z.string().min(1) }).meta({
|
|
20
|
+
id: 'RenameReport',
|
|
21
|
+
description: 'A new title for an existing report',
|
|
22
|
+
});
|
|
23
|
+
|
|
15
24
|
const renameReport = {
|
|
16
25
|
params: z.object({ id: z.coerce.number().int() }),
|
|
17
|
-
body:
|
|
26
|
+
body: RenameReport,
|
|
18
27
|
} as const;
|
|
19
28
|
|
|
20
|
-
const createReport = { body:
|
|
29
|
+
const createReport = { body: CreateReport } as const;
|
|
21
30
|
|
|
22
31
|
// `@UseGuards(AuthGuard)` at class scope rather than as global middleware: every
|
|
23
32
|
// other route in this app is meant to be reachable without credentials, and a
|