@dunx/create-app 2.5.0 → 3.0.1

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 (35) hide show
  1. package/dist/features.d.ts +6 -11
  2. package/package.json +1 -1
  3. package/templates/features/assets/assets.module.ts +6 -15
  4. package/templates/features/auth/auth.demo.ts +7 -19
  5. package/templates/features/auth/auth.module.ts +16 -31
  6. package/templates/features/auth/auth.tables.ts +7 -15
  7. package/templates/features/cache/cache.module.ts +7 -13
  8. package/templates/features/chat/chat.demo.ts +10 -21
  9. package/templates/features/chat/chat.gateway.ts +8 -19
  10. package/templates/features/database/database.module.ts +8 -19
  11. package/templates/features/database/ledger.controller.ts +13 -26
  12. package/templates/features/database/ledger.service.ts +16 -44
  13. package/templates/features/docs/docs.demo.ts +13 -32
  14. package/templates/features/health/health.module.ts +10 -19
  15. package/templates/features/health/indicators.ts +10 -26
  16. package/templates/features/http/compression.demo.ts +7 -15
  17. package/templates/features/http/http.demo.ts +7 -13
  18. package/templates/features/http/request-trail.ts +5 -8
  19. package/templates/features/jobs/jobs.controller.ts +7 -18
  20. package/templates/features/jobs/jobs.module.ts +8 -18
  21. package/templates/features/jobs/jobs.processor.ts +5 -13
  22. package/templates/features/schedule/maintenance.service.ts +11 -29
  23. package/templates/features/schedule/schedule.module.ts +3 -7
  24. package/templates/features/storage/files.controller.ts +10 -19
  25. package/templates/features/throttle/limits.controller.ts +5 -12
  26. package/templates/features/throttle/throttle.module.ts +8 -26
  27. package/templates/features/upstream/upstream.demo.ts +6 -15
  28. package/templates/features/upstream/upstream.module.ts +4 -13
  29. package/templates/features/users/users.repository.ts +4 -13
  30. package/templates/features/users/users.schemas.ts +11 -33
  31. package/templates/minimal/src/app.module.ts +0 -5
  32. package/templates/minimal/src/app.test.ts +0 -5
  33. package/templates/minimal/src/greetings.controller.ts +2 -12
  34. package/templates/minimal/src/greetings.service.ts +2 -10
  35. package/templates/minimal/src/main.ts +0 -5
@@ -16,12 +16,9 @@ import { ledger, type Entry } from './schema.js';
16
16
 
17
17
  export class Ledger implements OnInit, OnShutdown {
18
18
  /**
19
- * `SyncDatabase` is drizzle's `BunSQLiteDatabase` under a name that says the
20
- * connection was opened in synchronous mode - which is what makes
21
- * `transactionSync` below reachable. `@dunx/transform` records the bare type name
22
- * (a real runtime class, so a usable token) and ignores the type argument, so the
23
- * schema types survive injection. `DbConnection` is the lifecycle and the driver
24
- * underneath; drizzle has neither.
19
+ * `SyncDatabase` is `BunSQLiteDatabase` under a name saying the connection was
20
+ * opened in synchronous mode, which is what reaches `transactionSync` below.
21
+ * `DbConnection` carries the lifecycle and the driver; drizzle has neither.
25
22
  */
26
23
  constructor(
27
24
  private readonly db: SyncDatabase<typeof schema>,
@@ -29,13 +26,7 @@ export class Ledger implements OnInit, OnShutdown {
29
26
  private readonly logger: Logger,
30
27
  ) {}
31
28
 
32
- /**
33
- * Standing in for a migration rather than replacing one: schema changes are
34
- * `drizzle-kit generate` plus drizzle-orm/bun-sqlite/migrator, which own the
35
- * SQL, the journal and the snapshot folder. A `:memory:` database has nowhere
36
- * to keep any of that, so the table is created here - and at `onInit`, so the
37
- * routes below have somewhere to write before the first request arrives.
38
- */
29
+ /** Standing in for a migration, which a `:memory:` database cannot keep. */
39
30
  async onInit(): Promise<void> {
40
31
  this.db.run(sql`CREATE TABLE IF NOT EXISTS ledger (
41
32
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -55,17 +46,9 @@ export class Ledger implements OnInit, OnShutdown {
55
46
  }
56
47
 
57
48
  /**
58
- * The same rows, paginated by cursor instead of by `limit`.
59
- *
60
- * Keyset rather than `OFFSET`, which is what makes it correct while rows are being
61
- * written: a cursor names the last row seen, so an insert between two requests
62
- * cannot shift a page and serve the same entry twice. This table has no timestamp,
63
- * so `paginate` falls back to the primary key - `id` is unique on its own, so no
64
- * tie-break column is needed.
65
- *
66
- * Synchronous, because `paginate`'s return type follows its `db`: a
67
- * `drizzle-orm/bun-sqlite` handle answers `all()` rather than a promise, so this
68
- * method needs no `async` and neither does its caller.
49
+ * Keyset rather than `OFFSET`: a cursor names the last row seen, so an insert
50
+ * between two requests cannot shift a page and serve an entry twice. No
51
+ * timestamp column here, so `paginate` falls back to the unique primary key.
69
52
  */
70
53
  page(options: PageOptions): Page<Entry> {
71
54
  return paginate<typeof ledger, Entry>({
@@ -107,11 +90,9 @@ export class Ledger implements OnInit, OnShutdown {
107
90
  }
108
91
 
109
92
  /**
110
- * Both legs or neither. `transaction()` from `@dunx/infra/db`, not
111
- * `db.transaction()`: drizzle's own on bun-sqlite delegates to `bun:sqlite`'s
112
- * synchronous `transaction()`, which commits as soon as the callback returns its
113
- * promise - so everything after the first `await` would run in autocommit. That
114
- * is what makes the rollback below possible at all.
93
+ * `transaction()` from `@dunx/infra/db`, not `db.transaction()`: drizzle's own
94
+ * commits as soon as the callback returns its promise, so everything after the
95
+ * first `await` would run in autocommit.
115
96
  */
116
97
  transfer(
117
98
  from: string,
@@ -134,11 +115,8 @@ export class Ledger implements OnInit, OnShutdown {
134
115
  }
135
116
 
136
117
  /**
137
- * The same two legs, with nothing to await. `transactionSync` is drizzle's own
138
- * `db.transaction()` - correct here precisely because the callback cannot return
139
- * a promise, which is the case its early commit breaks. The return type is
140
- * `number`, not `Promise<number>`, so a controller calling this needs no `async`
141
- * and the request never yields.
118
+ * The same two legs with nothing to await. `transactionSync` is drizzle's own
119
+ * `db.transaction()`, safe because the callback cannot return a promise.
142
120
  */
143
121
  transferSync(from: string, to: string, amount: number, fail = false): number {
144
122
  return transactionSync(this.db, (tx) => {
@@ -161,8 +139,7 @@ export class Ledger implements OnInit, OnShutdown {
161
139
  'table "ledger" created at onInit',
162
140
  );
163
141
 
164
- // The escape hatch. `raw` is `unknown` on the base - the abstract class cannot
165
- // promise either driver - and `instanceof` is what restores the concrete type.
142
+ // `raw` is `unknown` on the base, so `instanceof` restores the driver type.
166
143
  if (this.connection instanceof SqliteConnection) {
167
144
  logger.info(`raw driver -> bun:sqlite ${this.connection.raw.filename}`);
168
145
  }
@@ -195,10 +172,7 @@ export class Ledger implements OnInit, OnShutdown {
195
172
  await this.seeds();
196
173
  }
197
174
 
198
- /**
199
- * The same rollback with no promise anywhere - `transactionSync` throws where
200
- * `transaction` rejects, so the recovery is `try`/`catch` rather than `.catch()`.
201
- */
175
+ /** `transactionSync` throws where `transaction` rejects. */
202
176
  private rollsBackSynchronously(): void {
203
177
  const before = this.rows();
204
178
  try {
@@ -214,7 +188,6 @@ export class Ledger implements OnInit, OnShutdown {
214
188
  );
215
189
  }
216
190
 
217
- /** The `await` inside is what proves the transaction is not autocommitting. */
218
191
  private async commits(): Promise<void> {
219
192
  const balance = await transaction(this.db, async (tx) => {
220
193
  tx.insert(ledger).values({ memo: 'refund', amount: 12 }).run();
@@ -246,9 +219,8 @@ export class Ledger implements OnInit, OnShutdown {
246
219
  }
247
220
 
248
221
  /**
249
- * Seed *data*, which is the half `drizzle-kit` has no concept of. Numbered files
250
- * in `seeds/`, each applied once and recorded in `dunx_seeds` - so this reports
251
- * them journaled rather than applied, `onInit` having already run them.
222
+ * Seed data, the half `drizzle-kit` has no concept of. Numbered files in
223
+ * `seeds/`, applied once and recorded in `dunx_seeds`.
252
224
  */
253
225
  private async seeds(): Promise<void> {
254
226
  const dir = `${import.meta.dir}/seeds`;
@@ -17,11 +17,8 @@ const documentAt = async (
17
17
  export class DocsDemo {
18
18
  constructor(private readonly logger: Logger) {}
19
19
 
20
- /**
21
- * The document is served by a controller in the same graph, so it goes through the
22
- * same middleware and the same CORS as everything else - and describes the paths
23
- * the app really mounted, `setGlobalPrefix('api')` included.
24
- */
20
+ /** Served by a controller in the same graph, so it goes through the same
21
+ * middleware and describes the paths the app really mounted. */
25
22
  async demonstrate(app: HttpApp, url: string): Promise<void> {
26
23
  const { logger } = this;
27
24
  const [response, document] = await documentAt(url);
@@ -35,8 +32,6 @@ export class DocsDemo {
35
32
  `components/schemas: ${JSON.stringify(Object.keys(document.components.schemas))}`,
36
33
  );
37
34
 
38
- // `.meta({ id: 'CreateUser' })` on the zod schema is what named this ref, and
39
- // the $defs entry it referenced (`Tag`) came along with it.
40
35
  const create = document.paths['/api/users']?.post;
41
36
  logger.info(
42
37
  `POST /api/users requestBody -> ` +
@@ -48,9 +43,8 @@ export class DocsDemo {
48
43
  `POST /api/users 400 -> ` +
49
44
  JSON.stringify(create?.responses['400']?.content?.['application/json']),
50
45
  );
51
- // `options.response` is the same contract as the request side, so a named
52
- // response schema becomes a component and the operation $refs it. It is
53
- // documentation only - nothing validates a response.
46
+ // A named response schema becomes a component the operation $refs. It is
47
+ // documentation only: nothing validates a response.
54
48
  const one = document.paths['/api/users/{id}']?.get;
55
49
  logger.info(
56
50
  `GET /api/users/{id} responses -> ` +
@@ -66,8 +60,7 @@ export class DocsDemo {
66
60
  const list = document.paths['/api/users']?.get;
67
61
  logger.info(`GET /api/users query -> ${JSON.stringify(list?.parameters)}`);
68
62
 
69
- // The check that matters: a $ref that resolves to nothing renders as an empty
70
- // box in every viewer and reports no error at all.
63
+ // A $ref resolving to nothing renders as an empty box and reports no error.
71
64
  logger.info(
72
65
  `unresolved $refs: ${danglingRefs(document).length}, warnings: ` +
73
66
  JSON.stringify(app.get(OpenApiExplorer).warnings),
@@ -81,14 +74,9 @@ export class DocsDemo {
81
74
  );
82
75
 
83
76
  /**
84
- * **The page fetches, and this is the check that it only fetches from here.**
85
- * The explorer used to be dunx's own bundle inlined into the page, so the
86
- * assertion was that nothing was requested at all. It is now `swagger-ui-dist`,
87
- * 3.7x the size gzipped, served as two assets - so the guarantee is narrower and
88
- * has to be stated as what it is: same-origin only, no CDN.
89
- *
90
- * Script bodies are stripped first. Inside a `<script>` everything is text, so a
91
- * `src=` in the boot script is not a resource.
77
+ * The page fetches two `swagger-ui-dist` assets, and the guarantee is that
78
+ * they are same-origin: no CDN. Script bodies are stripped first, since a
79
+ * `src=` inside a `<script>` is text rather than a resource.
92
80
  */
93
81
  const shell = html.replace(/(<script[^>]*>)[\s\S]*?(<\/script>)/g, '$1$2');
94
82
  const requested = [
@@ -102,9 +90,7 @@ export class DocsDemo {
102
90
  JSON.stringify(requested),
103
91
  );
104
92
 
105
- // Every one of them has to actually answer, which is the half a unit test
106
- // cannot show: these resolve out of the consumer's own swagger-ui-dist
107
- // install, through this app's global prefix.
93
+ // Each has to answer, resolving out of the consumer's own install.
108
94
  for (const href of requested) {
109
95
  const asset = await fetch(new URL(href.replace(/^\//, ''), url));
110
96
  logger.info(
@@ -116,11 +102,8 @@ export class DocsDemo {
116
102
  }
117
103
  }
118
104
 
119
- /**
120
- * The guarded app, whose `AuthGuard` is global. Security in the document comes from
121
- * the same `@Public()` and `@Roles()` metadata the guards read at runtime - there
122
- * is no second annotation for the documentation to disagree with.
123
- */
105
+ /** Security in the document comes from the same `@Public()` and `@Roles()`
106
+ * metadata the guards read at runtime. */
124
107
  async guarded(url: string): Promise<void> {
125
108
  const { logger } = this;
126
109
  const [, document] = await documentAt(url);
@@ -137,10 +120,8 @@ export class DocsDemo {
137
120
  `@Public() GET /api/reports/health -> security ${JSON.stringify(health?.security)}`,
138
121
  );
139
122
 
140
- // The class-level @Roles('admin') is merged into every one of its routes, so it
141
- // is documented on this one too - even though no RolesGuard reads it here. The
142
- // document describes what the metadata declares; which guard enforces it is a
143
- // separate decision, and one no generator can see.
123
+ // A class-level @Roles merges into every route, so it is documented here
124
+ // too. The document describes the metadata; enforcement is a separate call.
144
125
  const list = document.paths['/api/reports']?.get;
145
126
  logger.info(
146
127
  `class-level @Roles("admin") GET /api/reports -> security ` +
@@ -11,20 +11,16 @@ import { HealthDemo } from './health.demo.js';
11
11
  import { AppIndicators } from './indicators.js';
12
12
 
13
13
  /**
14
- * The indicators, in a module of their own for the reason `WorkspaceModule` is:
15
- * `HealthModule.forRootAsync` registers its provider in its own scope, so a factory
16
- * injecting `AppIndicators` has to name the module it comes from - and pointing that
17
- * back at `ProbesModule` would be a cycle.
18
- *
19
- * A health check is still the feature that imports the most, so this list is an
20
- * accurate statement of what it touches.
14
+ * Its own module because `HealthModule.forRootAsync` registers in its own scope,
15
+ * so a factory injecting `AppIndicators` must name where it comes from - and
16
+ * pointing that back at `ProbesModule` would be a cycle.
21
17
  */
22
18
  @Module({
23
19
  imports: [DatabaseModule, CacheModule, WorkspaceModule],
24
20
  providers: [
25
21
  provide(AppIndicators, {
26
- // Async because the upload root is: `Workspace.create()` is idempotent, so
27
- // this is the directory `FilesModule` already made rather than a second one.
22
+ // `Workspace.create()` is idempotent, so this is the directory
23
+ // `FilesModule` already made.
28
24
  useFactory: async (
29
25
  db: DbConnection,
30
26
  redis: RedisConnection,
@@ -45,13 +41,9 @@ import { AppIndicators } from './indicators.js';
45
41
  export class IndicatorsModule {}
46
42
 
47
43
  /**
48
- * `HealthModule` from `@dunx/http`, which mounts `/api/health/live` and
49
- * `/api/health/ready`. Both are `@Public()` and hidden from the OpenAPI document:
50
- * a probe carries no credentials and is not an API a consumer calls.
51
- *
52
- * There is no indicator for `@dunx/infra/files` or `@dunx/infra/images`. Both are
53
- * in-process, so "it booted" is already answered by the port answering at all, and
54
- * a check that cannot fail tells an operator nothing.
44
+ * Mounts `/api/health/live` and `/api/health/ready`, both `@Public()` and hidden
45
+ * from the document. No indicator for the in-process packages: a check that
46
+ * cannot fail tells an operator nothing.
55
47
  */
56
48
  @Module({
57
49
  imports: [
@@ -61,9 +53,8 @@ export class IndicatorsModule {}
61
53
  useFactory: (indicators: AppIndicators) => ({
62
54
  readiness: indicators.readiness,
63
55
  liveness: indicators.liveness,
64
- // A real deployment sets a few probe intervals here, so a load balancer
65
- // sees readiness fail before the socket closes. Short enough that
66
- // `bun run tour` and the suites are not waiting on it.
56
+ // A real deployment tunes these so a load balancer sees readiness fail
57
+ // before the socket closes.
67
58
  drainDelayMs: 250,
68
59
  }),
69
60
  inject: [AppIndicators] as const,
@@ -12,12 +12,8 @@ import type { DbConnection } from '@dunx/infra/db';
12
12
  import type { RedisConnection } from '@dunx/infra/redis';
13
13
  import { Ledger } from '../database/ledger.service.js';
14
14
 
15
- /**
16
- * The custom-indicator path: an app-specific query rather than a round trip.
17
- *
18
- * `DatabaseIndicator` answers "does the connection answer". This answers "is the
19
- * data there", which is the part only the app knows how to ask.
20
- */
15
+ /** A custom indicator: `DatabaseIndicator` asks whether the connection answers,
16
+ * this asks whether the data is there. */
21
17
  export class LedgerIndicator extends HealthIndicator {
22
18
  readonly name = 'ledger';
23
19
 
@@ -37,12 +33,8 @@ export class LedgerIndicator extends HealthIndicator {
37
33
  }
38
34
 
39
35
  /**
40
- * `RedisIndicator` with the criticality flipped, because this app treats a missing
41
- * cache as degraded rather than fatal - the same promise `CacheModule` makes with
42
- * lazy connections and `maxRetries: 0`.
43
- *
44
- * Shedding traffic here would be wrong: the routes that need Redis report
45
- * themselves degraded and every other route still works.
36
+ * `RedisIndicator` with the criticality flipped: a missing cache is degraded
37
+ * rather than fatal, matching `CacheModule`'s lazy connect and `maxRetries: 0`.
46
38
  */
47
39
  export class CacheIndicator extends RedisIndicator {
48
40
  override readonly critical = false;
@@ -57,21 +49,14 @@ export interface AppIndicatorsInit {
57
49
  }
58
50
 
59
51
  /**
60
- * The one declaration of what this service probes.
61
- *
62
- * It is a provider rather than two lists inlined into two factories because two
63
- * things read it: `HealthModule` answers `/api/health/ready`, and
64
- * `DashboardModule` lights the same checks on the ops page. A `HealthIndicator`
65
- * satisfies `DashboardProbe` as written, so neither side needs an adapter.
52
+ * One declaration of what this service probes, read by both `HealthModule` and
53
+ * `DashboardModule`. A `HealthIndicator` satisfies `DashboardProbe` as written.
66
54
  */
67
55
  export class AppIndicators {
68
56
  readonly readiness: readonly HealthIndicator[];
69
57
  readonly liveness: readonly HealthIndicator[];
70
- /**
71
- * The readiness list minus what the dashboard already sources itself:
72
- * `DashboardOptions.redis` drives the Redis panel *and* a `redis` probe, so
73
- * handing it `CacheIndicator` as well would light the same name twice.
74
- */
58
+ /** Minus what the dashboard sources itself: `DashboardOptions.redis` already
59
+ * drives a `redis` probe, so `CacheIndicator` here would light it twice. */
75
60
  readonly dashboardProbes: readonly HealthIndicator[];
76
61
 
77
62
  constructor(init: AppIndicatorsInit) {
@@ -79,13 +64,12 @@ export class AppIndicators {
79
64
  new DatabaseIndicator(init.db),
80
65
  new LedgerIndicator(init.ledger),
81
66
  new CacheIndicator(init.redis),
82
- // Non-critical, because no other pod's disk is any emptier.
83
67
  new DiskIndicator(
84
68
  new DiskOptions({ path: init.uploadRoot, maxUsedFraction: 0.95 }),
85
69
  ),
86
70
  ];
87
- // A ceiling belongs on liveness, where the orchestrator restarts the process
88
- // rather than routing around it.
71
+ // A ceiling belongs on liveness, where the orchestrator restarts rather
72
+ // than routes around.
89
73
  this.liveness = [
90
74
  new MemoryIndicator(
91
75
  new MemoryOptions({ maxRssBytes: 1024 * 1024 * 1024 }),
@@ -1,8 +1,7 @@
1
1
  import { Logger } from '@dunx/core';
2
2
 
3
- /** Large enough that every branch below is the interesting one. */
4
3
  const DOCUMENT = 'api/openapi.json';
5
- /** Two fields. Under the 1024-byte threshold, so it is sent as it is. */
4
+ /** Under the 1024-byte threshold, so it is sent as it is. */
6
5
  const SMALL = 'api/notes/whoami';
7
6
 
8
7
  interface Measured {
@@ -12,10 +11,8 @@ interface Measured {
12
11
  readonly vary: string;
13
12
  }
14
13
 
15
- /**
16
- * `fetch` decodes the body itself but leaves `content-encoding` and the encoded
17
- * `content-length` on the response, so one request measures both sides.
18
- */
14
+ /** `fetch` decodes the body but leaves `content-encoding` and the encoded
15
+ * `content-length`, so one request measures both sides. */
19
16
  const measure = async (
20
17
  url: string,
21
18
  path: string,
@@ -43,9 +40,8 @@ export class CompressionDemo {
43
40
  async demonstrate(url: string): Promise<void> {
44
41
  const { logger } = this;
45
42
 
46
- // This folder is vendored by `@dunx/create-app`, and a scaffold that took
47
- // `http` without `openapi` has no document to encode. Same convention as a
48
- // part whose backing service is absent: say so and carry on.
43
+ // Vendored by `@dunx/create-app`: a scaffold taking `http` without `openapi`
44
+ // has no document to encode.
49
45
  const available = await fetch(new URL(DOCUMENT, url));
50
46
  await available.body?.cancel();
51
47
  if (!available.ok) {
@@ -53,8 +49,7 @@ export class CompressionDemo {
53
49
  return;
54
50
  }
55
51
 
56
- // `identity` names a coding the app does not offer, so negotiation picks
57
- // nothing and the body goes out unencoded.
52
+ // `identity` is a coding the app does not offer, so nothing is picked.
58
53
  for (const accept of ['identity', 'gzip', 'zstd', 'gzip, zstd']) {
59
54
  const m = await measure(url, DOCUMENT, accept);
60
55
  logger.info(
@@ -63,15 +58,12 @@ export class CompressionDemo {
63
58
  );
64
59
  }
65
60
 
66
- // Both offered at the same q, so the server's own order decides. The app
67
- // offers `['zstd', 'gzip']`: on a body this size the two compress to within
68
- // 0.2% of each other and zstd is the faster encoder.
61
+ // Same q, so the server's order decides: `['zstd', 'gzip']`.
69
62
  const preferred = await measure(url, DOCUMENT, 'gzip, zstd');
70
63
  logger.info(
71
64
  `a tie in the client's q-values is broken by the server order -> ${preferred.encoding}`,
72
65
  );
73
66
 
74
- // An explicit q-value outranks that order.
75
67
  const forced = await measure(url, DOCUMENT, 'zstd;q=0.1, gzip;q=0.9');
76
68
  logger.info(`zstd;q=0.1, gzip;q=0.9 -> ${forced.encoding}`);
77
69
 
@@ -34,11 +34,8 @@ const postNote = (url: string, text: unknown): Promise<Response> =>
34
34
  body: JSON.stringify({ text }),
35
35
  });
36
36
 
37
- /**
38
- * `203.0.113.7` stands in for whatever the caller put in the header itself, and
39
- * `10.0.0.1` for the entry the one proxy in front of this app appended. With
40
- * `trust proxy` set to one hop, only the second is worth anything.
41
- */
37
+ /** `203.0.113.7` is what the caller sent, `10.0.0.1` what the one proxy
38
+ * appended. With `trust proxy` at one hop, only the second counts. */
42
39
  const whoami = async (url: string, forwarded: boolean): Promise<string> => {
43
40
  const response = await fetch(new URL('api/notes/whoami', url), {
44
41
  headers: forwarded ? { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' } : {},
@@ -83,28 +80,25 @@ export class HttpDemo {
83
80
  `${JSON.stringify(await rejected.json())}`,
84
81
  );
85
82
 
86
- // Bun.serve answers a method miss with 404, so a preflight can never be
87
- // inferred - enableCors mounts an explicit OPTIONS per path.
83
+ // Bun.serve answers a method miss with 404, so `enableCors` mounts an
84
+ // explicit OPTIONS per path.
88
85
  const allowed = await preflight(url, origin);
89
86
  logger.info(
90
87
  `enableCors: OPTIONS from ${origin} -> ${allowed.status} ${describeCors(allowed)}`,
91
88
  );
92
- // A denied origin gets no CORS headers at all, which is what makes a browser
93
- // block the response.
89
+ // A denied origin gets no CORS headers, which is what blocks the browser.
94
90
  const denied = await preflight(url, 'https://evil.test');
95
91
  logger.info(
96
92
  `OPTIONS from https://evil.test -> ${denied.status} ${describeCors(denied)}`,
97
93
  );
98
94
 
99
- // One trusted hop, so the last entry wins and the caller's own leftmost
100
- // entry is ignored. Reaching past the proxy takes set('trust proxy', 2).
95
+ // One trusted hop, so the last entry wins. Reaching past it takes 2.
101
96
  logger.info(
102
97
  `set("trust proxy", true): X-Forwarded-For sent -> ${await whoami(url, true)}`,
103
98
  );
104
99
  logger.info(`no header -> ${await whoami(url, false)}`);
105
100
 
106
- // The route table and the middleware chain fold into one closure per route at
107
- // listen(), so a late call could only ever be a silent no-op.
101
+ // Routes and middleware fold into one closure per route at listen().
108
102
  try {
109
103
  app.setGlobalPrefix('too-late');
110
104
  } catch (error) {
@@ -7,15 +7,12 @@ export class RequestTrail {
7
7
  }
8
8
 
9
9
  /**
10
- * A class with `handle(req, ctx, next)`, resolved from the container - which is
11
- * what lets it inject. Chains are folded into one closure per route at boot, and
12
- * `ctx` is the route it was folded into: names, method, path, and its metadata.
10
+ * A class with `handle(req, ctx, next)`, resolved from the container so it can
11
+ * inject. `ctx` is the route the chain was folded into.
13
12
  *
14
- * **This does not log**, which is what the name says now and did not before.
15
- * `@dunx/http` installs `RequestLoggingMiddleware` itself - one structured entry
16
- * per request, tuned through `requestLogging` in bootstrap.ts - so an app writing
17
- * its own would write everything twice. What is left here is the part a framework
18
- * cannot supply: an app-specific side effect on a response the middleware can see.
13
+ * It does not log: `@dunx/http` writes the request entry itself, so an app doing
14
+ * its own would write everything twice. What is left is the app-specific side
15
+ * effect a framework cannot supply.
19
16
  */
20
17
  export class RequestTrailMiddleware implements Middleware {
21
18
  constructor(private readonly trail: RequestTrail) {}
@@ -17,8 +17,7 @@ const Enqueue = z
17
17
  width: z.coerce.number().int().min(1).max(1024).default(128),
18
18
  format: z.enum(EncodableFormat).default(EncodableFormat.WEBP),
19
19
  })
20
- // `.strict()` **after** `.meta()` discards the metadata - `meta()` then returns
21
- // null and the schema is inlined despite declaring an id. Order matters.
20
+ // `.strict()` after `.meta()` discards the metadata; put `.meta()` last.
22
21
  .strict()
23
22
  .meta({
24
23
  id: 'EnqueueRender',
@@ -28,11 +27,7 @@ const Enqueue = z
28
27
  const enqueue = { body: Enqueue } as const;
29
28
  const oneJob = { params: z.object({ id: z.string().min(1) }) } as const;
30
29
 
31
- /**
32
- * The publish side. Nothing here consumes: `QueueModule.forRoot` binds
33
- * `JobPublisher` and no worker, so this process enqueues and returns immediately.
34
- * Consumed by this same process - see `JobsModule`'s `consume: true`.
35
- */
30
+ /** The publish side: `QueueModule.forRoot` binds `JobPublisher` and no worker. */
36
31
  @Controller('jobs')
37
32
  export class JobsController {
38
33
  constructor(private readonly publisher: JobPublisher) {}
@@ -48,16 +43,12 @@ export class JobsController {
48
43
  return {
49
44
  id: job.id ?? '(unassigned)',
50
45
  queue: THUMBNAIL_QUEUE,
51
- // `waiting` until a worker takes it - which is the observable point of a
52
- // queue, so it is in the response rather than hidden.
53
46
  state: await job.getState(),
54
47
  };
55
48
  }
56
49
 
57
- /**
58
- * Poll a job. `returnvalue` is whatever the handler returned, so this is how the
59
- * web process reads a result computed in another process.
60
- */
50
+ /** `returnvalue` is whatever the handler returned, so this is how the web
51
+ * process reads a result computed elsewhere. */
61
52
  @Get('/thumbnails/:id', oneJob)
62
53
  async status(input: Input<typeof oneJob>): Promise<{
63
54
  id: string;
@@ -84,11 +75,9 @@ export class JobsController {
84
75
  }
85
76
 
86
77
  /**
87
- * No Redis is a degraded queue, not a broken app - the same contract the cache
88
- * routes keep. bullmq surfaces the failure through ioredis rather than Bun's
89
- * client, so the connection-error shape is not guaranteed to match; anything
90
- * unrecognised still becomes a 503 rather than a 500, because "the queue is not
91
- * reachable" is the only thing it can mean here.
78
+ * No Redis is a degraded queue, not a broken app. bullmq surfaces the failure
79
+ * through ioredis, so the error shape is not guaranteed; anything unrecognised
80
+ * still becomes a 503.
92
81
  */
93
82
  private async degrades<T>(run: () => Promise<T>): Promise<T> {
94
83
  try {
@@ -6,17 +6,9 @@ import { JobsController } from './jobs.controller.js';
6
6
  import { ThumbnailJobs } from './thumbnail.jobs.js';
7
7
 
8
8
  /**
9
- * Imported by **both** containers, which is the whole shape of a queue: the web
10
- * process publishes, a separate worker process consumes, and they agree only on
11
- * this module.
12
- *
13
- * `consume: true` is what makes this process work them as well as publish, and it
14
- * is the only line about it anywhere - the container owns starting and stopping the
15
- * workers, so no entrypoint has to. Leave it out and the module binds the publish
16
- * side alone, which is what a web tier with a separate worker fleet wants.
17
- *
18
- * `PicturesModule` is here because the handler injects `Thumbnails`, and the
19
- * container that runs it has to be able to build it.
9
+ * Imported by both containers: the web process publishes, a worker process
10
+ * consumes, and they agree only on this module. `consume: true` makes this
11
+ * process work them too; leave it out and it binds the publish side alone.
20
12
  */
21
13
  @Module({
22
14
  imports: [
@@ -26,12 +18,11 @@ import { ThumbnailJobs } from './thumbnail.jobs.js';
26
18
  return {
27
19
  ...(url === undefined ? {} : { url }),
28
20
  prefix: 'dunx-full',
29
- // This process works its own queues. The container starts the workers at
30
- // onInit and stops them at onShutdown - before the database they use -
31
- // so `main.ts` says nothing about queues and there is no second command.
21
+ // The container starts the workers at onInit and stops them before
22
+ // the database they use, so `main.ts` says nothing about queues.
32
23
  consume: true,
33
- // The file bullmq forks into for a queue whose handler is marked
34
- // `background`. Absolute, because the child resolves it, not this module.
24
+ // Where bullmq forks for a `background` handler. Absolute: the child
25
+ // resolves it, not this module.
35
26
  processor: new URL('./jobs.processor.ts', import.meta.url).pathname,
36
27
  };
37
28
  },
@@ -41,8 +32,7 @@ import { ThumbnailJobs } from './thumbnail.jobs.js';
41
32
  ],
42
33
  controllers: [JobsController],
43
34
  providers: [ThumbnailJobs],
44
- // The publisher, re-exported so a feature that enqueues does not import
45
- // @dunx/infra/queue itself.
35
+ // Re-exported so a feature that enqueues does not import @dunx/infra/queue.
46
36
  exports: [JobPublisher, ThumbnailJobs],
47
37
  })
48
38
  export class JobsModule {}
@@ -5,25 +5,17 @@ import { AppConfigService, validate } from '../config.js';
5
5
  import { JobsModule } from './jobs.module.js';
6
6
 
7
7
  /**
8
- * **The file bullmq forks into.** Its default export is the processor, and nothing
9
- * else here runs in the parent.
10
- *
11
- * The child builds its own container, which is the whole point: a handler gets the
12
- * database, the image pipeline and the logger it declares, without sharing an event
13
- * loop with the process serving HTTP. `JobProcessor` builds it once per child and
14
- * reuses it for every job on that child.
15
- *
16
- * Its own module rather than reusing `WorkerModule` from `worker.ts`: that file is
17
- * an entrypoint with a `run()` at the bottom, and importing it here would boot a
18
- * second worker inside every child.
8
+ * The file bullmq forks into; its default export is the processor. The child
9
+ * builds its own container, so a handler gets what it declares without sharing
10
+ * an event loop with the HTTP process. Its own module rather than `worker.ts`,
11
+ * which has a `run()` that would boot a second worker inside every child.
19
12
  */
20
13
  @Module({
21
14
  imports: [
22
15
  ConfigModule.forRoot({ validate, as: AppConfigService }),
23
16
  LoggerModule.forRootAsync({
24
17
  useFactory: (config: AppConfigService) => ({
25
- // Named so a line from a child is attributable to one on sight - which is
26
- // the traceability a sandbox is for.
18
+ // Named, so a line from a child is attributable on sight.
27
19
  name: `${config.get('appName')}-job`,
28
20
  level: config.get('log').level,
29
21
  }),