@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.
Files changed (37) hide show
  1. package/dist/{chunk-yzz4z6jv.js → chunk-mpa5nv1v.js} +110 -13
  2. package/dist/chunk-mpa5nv1v.js.map +12 -0
  3. package/dist/cli.js +1 -1
  4. package/dist/index.js +1 -1
  5. package/package.json +1 -1
  6. package/templates/features/assets/assets.demo.ts +41 -0
  7. package/templates/features/assets/assets.module.ts +37 -0
  8. package/templates/features/assets/public/app.a1b2c3d4.js +1 -0
  9. package/templates/features/assets/public/index.html +9 -0
  10. package/templates/features/assets/public/site.css +3 -0
  11. package/templates/features/auth/auth.module.ts +15 -4
  12. package/templates/features/cache/cache.controller.ts +5 -2
  13. package/templates/features/database/ledger.controller.ts +7 -4
  14. package/templates/features/guards/reports.controller.ts +11 -2
  15. package/templates/features/health/health.demo.ts +72 -0
  16. package/templates/features/health/health.module.ts +70 -9
  17. package/templates/features/health/indicators.ts +98 -0
  18. package/templates/features/http/http.demo.ts +6 -4
  19. package/templates/features/http/http.module.ts +3 -3
  20. package/templates/features/http/{request-log.ts → request-trail.ts} +10 -11
  21. package/templates/features/jobs/jobs.controller.ts +7 -2
  22. package/templates/features/jobs/thumbnail.jobs.ts +7 -1
  23. package/templates/features/notes/notes.controller.ts +1 -1
  24. package/templates/features/pictures/images.controller.ts +5 -2
  25. package/templates/features/schedule/maintenance.service.ts +71 -0
  26. package/templates/features/schedule/schedule.demo.ts +56 -0
  27. package/templates/features/schedule/schedule.module.ts +31 -0
  28. package/templates/features/storage/files.controller.ts +5 -4
  29. package/templates/features/throttle/limits.controller.ts +35 -0
  30. package/templates/features/throttle/throttle.demo.ts +74 -0
  31. package/templates/features/throttle/throttle.module.ts +88 -0
  32. package/templates/features/upstream/flaky.controller.ts +48 -0
  33. package/templates/features/upstream/upstream.demo.ts +83 -0
  34. package/templates/features/upstream/upstream.module.ts +42 -0
  35. package/templates/features/users/users.schemas.ts +22 -8
  36. package/dist/chunk-yzz4z6jv.js.map +0 -12
  37. package/templates/features/health/health.controller.ts +0 -65
@@ -0,0 +1,74 @@
1
+ import { Logger } from '@dunx/core';
2
+
3
+ interface Attempt {
4
+ readonly status: number;
5
+ readonly remaining: string | null;
6
+ readonly retryAfter: string | null;
7
+ }
8
+
9
+ /**
10
+ * Four requests at a limit of three, so the fourth is the 429 - and the headers
11
+ * that tell a client when to come back.
12
+ *
13
+ * Every request here presents its own `x-api-key`, which the module reads as the
14
+ * subject. Two runs of this demo therefore count separately, and so does anything
15
+ * else hitting the same route.
16
+ */
17
+ export class ThrottleDemo {
18
+ constructor(private readonly logger: Logger) {}
19
+
20
+ async demonstrate(url: string): Promise<void> {
21
+ const key = `demo-${process.pid}`;
22
+ const attempts = await this.hit(url, 'limits/burst', key, 4);
23
+
24
+ this.logger.info(
25
+ `@Throttle({ limit: 3, windowSeconds: 60 }) x4 -> ` +
26
+ attempts.map((attempt) => attempt.status).join(', '),
27
+ );
28
+ this.logger.info(
29
+ `ratelimit-remaining per attempt -> ` +
30
+ attempts.map((attempt) => attempt.remaining ?? '-').join(', '),
31
+ );
32
+
33
+ const refused = attempts.at(-1);
34
+ this.logger.info(
35
+ refused?.status === 429
36
+ ? `the 4th carries retry-after: ${refused.retryAfter}s`
37
+ : `expected a 429 on the 4th, got ${refused?.status ?? 'nothing'}`,
38
+ );
39
+
40
+ // Six on an exempt route, which is past every limit in this app.
41
+ const exempt = await this.hit(url, 'limits/exempt', key, 6);
42
+ this.logger.info(
43
+ `@SkipThrottle() x6 -> ${exempt.map((a) => a.status).join(', ')} ` +
44
+ '(not counted at all)',
45
+ );
46
+
47
+ // A different key is a different budget, on the route that just refused one.
48
+ const other = await this.hit(url, 'limits/burst', `${key}-other`, 1);
49
+ this.logger.info(
50
+ `same route, different x-api-key -> ${other[0]?.status ?? '-'} ` +
51
+ '(the subject is what the window belongs to)',
52
+ );
53
+ }
54
+
55
+ private async hit(
56
+ url: string,
57
+ path: string,
58
+ key: string,
59
+ times: number,
60
+ ): Promise<readonly Attempt[]> {
61
+ const attempts: Attempt[] = [];
62
+ for (let i = 0; i < times; i += 1) {
63
+ const response = await fetch(new URL(`api/${path}`, url), {
64
+ headers: { 'x-api-key': key },
65
+ });
66
+ attempts.push({
67
+ status: response.status,
68
+ remaining: response.headers.get('ratelimit-remaining'),
69
+ retryAfter: response.headers.get('retry-after'),
70
+ });
71
+ }
72
+ return attempts;
73
+ }
74
+ }
@@ -0,0 +1,88 @@
1
+ import { Logger, Module } from '@dunx/core';
2
+ import {
3
+ ClientAddress,
4
+ MemoryThrottleStore,
5
+ RedisThrottleStore,
6
+ ThrottleModule,
7
+ } from '@dunx/http';
8
+ import { RedisConnection } from '@dunx/infra/redis';
9
+ import { CacheModule } from '../cache/cache.module.js';
10
+ import { Sessions } from '../cache/sessions.service.js';
11
+ import { AppConfigService } from '../config.js';
12
+ import { LimitsController } from './limits.controller.js';
13
+ import { ThrottleDemo } from './throttle.demo.js';
14
+
15
+ /**
16
+ * A fixed-window rate limit over the whole app.
17
+ *
18
+ * **Which counter is decided at boot, by asking whether the cache answers.** A real
19
+ * deployment names one outright: `RedisThrottleStore` for more than one replica,
20
+ * because the in-process default lets each of them allow the full budget. This
21
+ * example has to work with nothing installed, so it probes and says which it got.
22
+ *
23
+ * That probe is `Sessions.status()`, which `CacheModule` already exports rather than
24
+ * a second ping written here.
25
+ *
26
+ * Falling back matters more than it looks. The guard fails **open**, so with
27
+ * `RedisThrottleStore` and no Redis nothing is counted at all: no 429, no
28
+ * `ratelimit-*` headers, every request through. That is the right call for a
29
+ * production limiter, and it would make this example demonstrate nothing on a
30
+ * machine without Redis.
31
+ *
32
+ * `RedisThrottleStore` takes its client structurally, so `RedisConnection` satisfies
33
+ * it with no adapter.
34
+ *
35
+ * The prefix carries the pid, the same trick `Sessions` uses: two runs against one
36
+ * Redis would otherwise spend each other's budget, and a leftover window would make
37
+ * a suite fail on the previous run's counters.
38
+ */
39
+ @Module({
40
+ imports: [
41
+ CacheModule,
42
+ ThrottleModule.forRootAsync({
43
+ imports: [CacheModule],
44
+ useFactory: async (
45
+ config: AppConfigService,
46
+ redis: RedisConnection,
47
+ address: ClientAddress,
48
+ sessions: Sessions,
49
+ logger: Logger,
50
+ ) => {
51
+ const cache = await sessions.status();
52
+ logger.info(
53
+ cache.reachable
54
+ ? `rate limit counting in redis at ${cache.url}, shared by every replica`
55
+ : `rate limit counting in memory: ${cache.url} is unreachable, so the ` +
56
+ 'budget is per process',
57
+ );
58
+ return {
59
+ ...config.get('throttle'),
60
+ prefix: `${config.get('appName')}:${process.pid}`,
61
+ store: cache.reachable
62
+ ? new RedisThrottleStore(redis)
63
+ : new MemoryThrottleStore(),
64
+ /**
65
+ * Who is being counted. An API key when one is presented, else the
66
+ * address - which is the shape a real app wants, where an authenticated
67
+ * caller is limited by identity and an anonymous one by where it came
68
+ * from. Only a guard ahead of this one knows which, which is why this is
69
+ * an option rather than something the package reads for itself.
70
+ */
71
+ subject: (req: Bun.BunRequest) =>
72
+ req.headers.get('x-api-key') ?? address.of(req),
73
+ };
74
+ },
75
+ inject: [
76
+ AppConfigService,
77
+ RedisConnection,
78
+ ClientAddress,
79
+ Sessions,
80
+ Logger,
81
+ ] as const,
82
+ }),
83
+ ],
84
+ controllers: [LimitsController],
85
+ providers: [ThrottleDemo],
86
+ exports: [ThrottleDemo],
87
+ })
88
+ export class LimitsModule {}
@@ -0,0 +1,48 @@
1
+ import {
2
+ Controller,
3
+ Get,
4
+ HttpError,
5
+ HttpStatusCode,
6
+ SkipThrottle,
7
+ } from '@dunx/http';
8
+
9
+ /**
10
+ * An upstream that fails before it works, so the retry has something to retry.
11
+ *
12
+ * Per process and per key, so the tour and a suite do not spend each other's
13
+ * failures. `@SkipThrottle()` because a retry loop is exactly the traffic shape the
14
+ * rate limit exists to refuse.
15
+ */
16
+ @Controller('upstream')
17
+ @SkipThrottle()
18
+ export class FlakyController {
19
+ readonly #failures = new Map<string, number>();
20
+
21
+ /** 503 for the first two calls on a key, then 200. */
22
+ @Get('/flaky')
23
+ flaky(): { recovered: true; after: number } {
24
+ const key = 'default';
25
+ const seen = (this.#failures.get(key) ?? 0) + 1;
26
+ this.#failures.set(key, seen);
27
+ if (seen <= 2) {
28
+ throw new HttpError(
29
+ HttpStatusCode.SERVICE_UNAVAILABLE,
30
+ `not ready yet (attempt ${seen})`,
31
+ );
32
+ }
33
+ return { recovered: true, after: seen };
34
+ }
35
+
36
+ /** Slower than any budget the demo gives it, so the timeout is not a race. */
37
+ @Get('/slow')
38
+ async slow(): Promise<{ done: true }> {
39
+ await Bun.sleep(300);
40
+ return { done: true };
41
+ }
42
+
43
+ /** Never retried: a 404 is an answer, not a failure worth repeating. */
44
+ @Get('/missing')
45
+ missing(): never {
46
+ throw new HttpError(HttpStatusCode.NOT_FOUND, 'no such upstream record');
47
+ }
48
+ }
@@ -0,0 +1,83 @@
1
+ import { Logger } from '@dunx/core';
2
+ import {
3
+ FetchError,
4
+ FetchTransportError,
5
+ HttpService,
6
+ } from '@dunx/http/client';
7
+
8
+ /**
9
+ * Calling out, over `fetch` and therefore over no dependency at all.
10
+ *
11
+ * Three things a bare `fetch` does not do: retry a 503 with backoff, raise a
12
+ * non-2xx as an error carrying the parsed body, and forward the inbound request id
13
+ * so one trace covers both services.
14
+ */
15
+ export class UpstreamDemo {
16
+ constructor(
17
+ private readonly logger: Logger,
18
+ private readonly http: HttpService,
19
+ ) {}
20
+
21
+ async demonstrate(url: string): Promise<void> {
22
+ const notes = await this.http.get<readonly string[]>(
23
+ new URL('api/notes', url),
24
+ );
25
+ this.logger.info(`GET api/notes -> ${JSON.stringify(notes)}`);
26
+
27
+ // The 503s are retried; the attempt callback is what makes that visible.
28
+ const attempts: string[] = [];
29
+ const recovered = await this.http.get<{ after: number }>(
30
+ new URL('api/upstream/flaky', url),
31
+ {
32
+ retry: {
33
+ maxRetries: 3,
34
+ retryDelayMs: 20,
35
+ onAttempt: (attempt, isRetry) =>
36
+ attempts.push(`${attempt}${isRetry ? ' (retry)' : ''}`),
37
+ },
38
+ },
39
+ );
40
+ this.logger.info(
41
+ `two 503s then a 200 -> attempts ${attempts.join(', ')}, ` +
42
+ `recovered after ${recovered.after}`,
43
+ );
44
+
45
+ /**
46
+ * A 404 is a `FetchError`, not an `HttpError`. That distinction is the whole
47
+ * reason the class exists: an upstream 401 arriving as an `HttpError(401)`
48
+ * would make *this* service answer 401, telling its own caller "you are
49
+ * unauthorized" when what failed was this service authenticating upstream.
50
+ */
51
+ try {
52
+ await this.http.get(new URL('api/upstream/missing', url), {
53
+ retry: { maxRetries: 0 },
54
+ });
55
+ } catch (error) {
56
+ if (!(error instanceof FetchError)) throw error;
57
+ this.logger.info(
58
+ `404 -> FetchError status ${error.status}, body ` +
59
+ `${JSON.stringify(error.body)} (an AppError, so it surfaces as a 500 ` +
60
+ 'rather than passing the upstream status through)',
61
+ );
62
+ }
63
+
64
+ // Not retried: the budget for the call is spent, and an abort means the
65
+ // caller's signal fired or the timeout did. `/slow` sleeps 300 ms, so this
66
+ // is a deadline rather than a race with the loopback.
67
+ try {
68
+ await this.http.get(new URL('api/upstream/slow', url), {
69
+ timeoutMs: 25,
70
+ retry: { maxRetries: 0 },
71
+ });
72
+ throw new Error(
73
+ 'the 25 ms budget should not have covered a 300 ms route',
74
+ );
75
+ } catch (error) {
76
+ if (!(error instanceof FetchTransportError)) throw error;
77
+ this.logger.info(
78
+ `timeoutMs: 25 against a 300 ms route -> ${error.name} ` +
79
+ '(an abort is never retried)',
80
+ );
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,42 @@
1
+ import { Module } from '@dunx/core';
2
+ import { HttpModule as HttpClientModule } from '@dunx/http/client';
3
+ import { AppConfigService } from '../config.js';
4
+ import { FlakyController } from './flaky.controller.js';
5
+ import { UpstreamDemo } from './upstream.demo.js';
6
+
7
+ /**
8
+ * The outbound half of `@dunx/http`, from the `./client` subpath.
9
+ *
10
+ * Imported as `HttpClientModule`, because this app already has an `HttpModule` of
11
+ * its own and `@dunx/http` exports `HttpFactory` for the inbound direction. The
12
+ * subpath is what keeps the two unambiguous at an import site; the alias is what
13
+ * keeps them unambiguous here.
14
+ *
15
+ * No `baseUrl`: this app calls itself, and its own url is not known until
16
+ * `listen()` has run. A real upstream sets one and every call names a path.
17
+ */
18
+ @Module({
19
+ imports: [
20
+ HttpClientModule.forRootAsync({
21
+ useFactory: (config: AppConfigService) => ({
22
+ ...config.get('upstream'),
23
+ headers: { 'user-agent': `${config.get('appName')}/outbound` },
24
+ retry: {
25
+ maxRetries: 3,
26
+ retryDelayMs: 20,
27
+ // Jitter comes from `crypto.getRandomValues`, not `Math.random`.
28
+ backoff: { jitterMs: 10, maxMs: 200 },
29
+ },
30
+ // The inbound request id, forwarded to the upstream, so one trace spans
31
+ // both services. Read from `RequestContext`, so it only carries when there
32
+ // is a request in scope.
33
+ propagateRequestId: true,
34
+ }),
35
+ inject: [AppConfigService] as const,
36
+ }),
37
+ ],
38
+ controllers: [FlakyController],
39
+ providers: [UpstreamDemo],
40
+ exports: [UpstreamDemo],
41
+ })
42
+ export class UpstreamModule {}
@@ -8,31 +8,45 @@ import { z } from 'zod';
8
8
  * still depends on no validator.
9
9
  *
10
10
  * `.meta({ id })` names the definition zod emits under `$defs`, which is the slot
11
- * OpenAPI calls `components/schemas`. `.meta({ title })` lands inline. See
12
- * `UsersDemo` for the generated document.
11
+ * OpenAPI calls `components/schemas`. Without an id the schema is inlined at every
12
+ * use site instead of referenced once.
13
+ *
14
+ * **Prose goes in `description`, not `title`.** In JSON Schema `title` is a short
15
+ * display name, and Swagger UI labels a schema by it. A sentence there makes the
16
+ * whole Schemas list read as prose: `User` shows up as "A stored user" and is
17
+ * impossible to find.
18
+ *
19
+ * Leaving `title` out is right, and not because it is unused: `@dunx/openapi` fills
20
+ * it in with the component name when a schema is hoisted, which is what makes the
21
+ * item of `array<User>` read as `User` rather than as `object`.
22
+ *
23
+ * One more ordering trap: `.strict()` **after** `.meta()` discards the metadata, so
24
+ * the schema is inlined despite declaring an id. Put `.meta()` last.
25
+ *
26
+ * See `UsersDemo` for the generated document.
13
27
  */
14
28
  export const Tag = z
15
29
  .object({ label: z.string().min(1) })
16
- .meta({ id: 'Tag', title: 'A label attached to a user' });
30
+ .meta({ id: 'Tag', description: 'A label attached to a user' });
17
31
 
18
32
  export const CreateUser = z
19
33
  .object({
20
34
  name: z.string().min(1).max(40),
21
35
  tags: z.array(Tag).default([]),
22
36
  })
23
- .meta({ id: 'CreateUser', title: 'Create a user' });
37
+ .meta({ id: 'CreateUser', description: 'Create a user' });
24
38
 
25
39
  /** Path params arrive as strings; `z.coerce` is where `:id` becomes a number. */
26
40
  export const UserIndex = z
27
41
  .object({ id: z.coerce.number().int().min(1) })
28
- .meta({ id: 'UserIndex', title: 'A user id in the path' });
42
+ .meta({ id: 'UserIndex', description: 'A user id in the path' });
29
43
 
30
44
  export const ListUsers = z
31
45
  .object({
32
46
  q: z.string().min(1).optional(),
33
47
  limit: z.coerce.number().int().min(1).max(50).default(10),
34
48
  })
35
- .meta({ id: 'ListUsers', title: 'Filter and page the user list' });
49
+ .meta({ id: 'ListUsers', description: 'Filter and page the user list' });
36
50
 
37
51
  /**
38
52
  * The response side. Same Standard Schema contract as a request, so it hoists into
@@ -45,11 +59,11 @@ export const User = z
45
59
  name: z.string(),
46
60
  tags: z.array(z.string()),
47
61
  })
48
- .meta({ id: 'User', title: 'A stored user' });
62
+ .meta({ id: 'User', description: 'A stored user' });
49
63
 
50
64
  export const NotFound = z
51
65
  .object({ error: z.string(), status: z.literal(404) })
52
- .meta({ id: 'NotFound', title: 'Nothing at that id' });
66
+ .meta({ id: 'NotFound', description: 'Nothing at that id' });
53
67
 
54
68
  // Declaring a schema is what makes the matching `input` field exist, get parsed
55
69
  // and get validated. `satisfies` keeps the literal types `Input<>` reads.
@@ -1,12 +0,0 @@
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 });\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 // `swagger-ui-dist` is what the page is: an optional peer of\n // `@dunx/openapi`, needed if and only if the explorer is mounted. The\n // `notes` feature declares `@dunx/openapi` too and does not need it, because\n // it only writes `@ApiDoc` metadata.\n dependencies: ['@dunx/openapi', 'swagger-ui-dist', 'zod'],\n config: [],\n },\n {\n name: 'http',\n source: 'http',\n summary: 'CORS, a request-logging middleware 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: 'One endpoint reporting which parts are live and which degraded.',\n // `files` joined this list when module scoping made the dependency explicit: the\n // controller injects `Storage`, so the module has to import the one that provides\n // it. Selecting health without files used to typecheck and fail at boot.\n requires: ['cache', 'database', 'files'],\n module: { klass: 'HealthModule', from: './health/health.module.js' },\n dependencies: ['@dunx/infra'],\n config: ['appName'],\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 'swagger-ui-dist': '^5.32.14',\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 const imports = [\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})\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\n const imports = [\n `import { HttpFactory${websockets ? ', RedisRelay' : ''}, type HttpApp } 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 { RequestLoggerMiddleware } from './http/request-log.js';\"]\n : []),\n ].filter((line) => !line.includes('{ }'));\n\n const root = 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 ...(http\n ? [\n 'app.use(RequestLoggerMiddleware);',\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 ];\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": "BC087157153A09C764756E2164756E21",
11
- "names": []
12
- }
@@ -1,65 +0,0 @@
1
- import { Controller, Get, Public } from '@dunx/http';
2
- import { Storage } from '@dunx/infra/files';
3
- import { Sessions } from '../cache/sessions.service.js';
4
- import { AppConfigService } from '../config.js';
5
- import { Ledger } from '../database/ledger.service.js';
6
-
7
- export interface AreaStatus {
8
- readonly name: string;
9
- readonly state: 'live' | 'degraded';
10
- readonly detail: string;
11
- }
12
-
13
- /**
14
- * What is actually working right now. Redis is the only area that can be down
15
- * without stopping the app, so it is the only one that ever reports `degraded` -
16
- * everything else is in-process and either booted or the app did not.
17
- */
18
- @Controller('health')
19
- export class HealthController {
20
- constructor(
21
- private readonly config: AppConfigService,
22
- private readonly ledger: Ledger,
23
- private readonly storage: Storage,
24
- private readonly sessions: Sessions,
25
- ) {}
26
-
27
- @Public()
28
- @Get('/', {})
29
- async status(): Promise<{
30
- ok: boolean;
31
- app: string;
32
- areas: readonly AreaStatus[];
33
- }> {
34
- const areas = await this.areas();
35
- return {
36
- ok: true,
37
- app: this.config.get('appName'),
38
- areas,
39
- };
40
- }
41
-
42
- async areas(): Promise<readonly AreaStatus[]> {
43
- const cache = await this.sessions.status();
44
- return [
45
- {
46
- name: '@dunx/infra/db',
47
- state: 'live',
48
- detail: `${this.ledger.rows()} ledger rows, balance ${this.ledger.balance()}`,
49
- },
50
- {
51
- name: '@dunx/infra/files',
52
- state: 'live',
53
- detail: `${this.storage.constructor.name} ready`,
54
- },
55
- { name: '@dunx/infra/images', state: 'live', detail: 'Bun.Image ready' },
56
- {
57
- name: '@dunx/infra/redis',
58
- state: cache.reachable ? 'live' : 'degraded',
59
- detail: cache.reachable
60
- ? `reachable at ${cache.url}`
61
- : (cache.note ?? `unreachable at ${cache.url}`),
62
- },
63
- ];
64
- }
65
- }