@dunx/create-app 2.4.0 → 3.0.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 (45) hide show
  1. package/dist/{chunk-mpa5nv1v.js → chunk-nn9ekg83.js} +2 -5
  2. package/dist/cli.js +1 -4
  3. package/dist/features.d.ts +6 -11
  4. package/dist/index.js +1 -4
  5. package/package.json +2 -2
  6. package/templates/features/assets/assets.module.ts +6 -15
  7. package/templates/features/auth/auth.demo.ts +7 -19
  8. package/templates/features/auth/auth.module.ts +16 -31
  9. package/templates/features/auth/auth.tables.ts +7 -15
  10. package/templates/features/cache/cache.module.ts +7 -13
  11. package/templates/features/chat/chat.demo.ts +10 -21
  12. package/templates/features/chat/chat.gateway.ts +8 -19
  13. package/templates/features/database/database.module.ts +8 -19
  14. package/templates/features/database/ledger.controller.ts +13 -26
  15. package/templates/features/database/ledger.service.ts +16 -44
  16. package/templates/features/docs/docs.demo.ts +13 -32
  17. package/templates/features/health/health.module.ts +10 -19
  18. package/templates/features/health/indicators.ts +10 -26
  19. package/templates/features/http/compression.demo.ts +80 -0
  20. package/templates/features/http/http.demo.ts +7 -13
  21. package/templates/features/http/http.module.ts +30 -2
  22. package/templates/features/http/request-trail.ts +5 -8
  23. package/templates/features/http/trace.controller.ts +30 -0
  24. package/templates/features/http/trace.demo.ts +58 -0
  25. package/templates/features/jobs/jobs.controller.ts +7 -18
  26. package/templates/features/jobs/jobs.module.ts +8 -18
  27. package/templates/features/jobs/jobs.processor.ts +5 -13
  28. package/templates/features/schedule/maintenance.service.ts +11 -29
  29. package/templates/features/schedule/schedule.module.ts +3 -7
  30. package/templates/features/storage/files.controller.ts +10 -19
  31. package/templates/features/throttle/limits.controller.ts +5 -12
  32. package/templates/features/throttle/throttle.module.ts +8 -26
  33. package/templates/features/upstream/upstream.demo.ts +6 -15
  34. package/templates/features/upstream/upstream.module.ts +4 -13
  35. package/templates/features/users/users.controller.ts +8 -0
  36. package/templates/features/users/users.repository.ts +4 -13
  37. package/templates/features/users/users.schemas.ts +11 -28
  38. package/templates/minimal/src/app.module.ts +0 -5
  39. package/templates/minimal/src/app.test.ts +0 -5
  40. package/templates/minimal/src/greetings.controller.ts +2 -12
  41. package/templates/minimal/src/greetings.service.ts +2 -10
  42. package/templates/minimal/src/main.ts +0 -5
  43. package/dist/chunk-mpa5nv1v.js.map +0 -12
  44. package/dist/cli.js.map +0 -10
  45. package/dist/index.js.map +0 -9
@@ -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 }),
@@ -0,0 +1,80 @@
1
+ import { Logger } from '@dunx/core';
2
+
3
+ const DOCUMENT = 'api/openapi.json';
4
+ /** Under the 1024-byte threshold, so it is sent as it is. */
5
+ const SMALL = 'api/notes/whoami';
6
+
7
+ interface Measured {
8
+ readonly encoding: string;
9
+ readonly wire: number;
10
+ readonly decoded: number;
11
+ readonly vary: string;
12
+ }
13
+
14
+ /** `fetch` decodes the body but leaves `content-encoding` and the encoded
15
+ * `content-length`, so one request measures both sides. */
16
+ const measure = async (
17
+ url: string,
18
+ path: string,
19
+ accept: string,
20
+ ): Promise<Measured> => {
21
+ const response = await fetch(new URL(path, url), {
22
+ headers: { 'accept-encoding': accept },
23
+ });
24
+ const decoded = await response.text();
25
+ const wire = response.headers.get('content-length');
26
+ return {
27
+ encoding: response.headers.get('content-encoding') ?? 'identity',
28
+ wire: wire === null ? decoded.length : Number(wire),
29
+ decoded: decoded.length,
30
+ vary: response.headers.get('vary') ?? '-',
31
+ };
32
+ };
33
+
34
+ const ratio = (m: Measured): string =>
35
+ `${((m.wire / m.decoded) * 100).toFixed(1)}%`;
36
+
37
+ export class CompressionDemo {
38
+ constructor(private readonly logger: Logger) {}
39
+
40
+ async demonstrate(url: string): Promise<void> {
41
+ const { logger } = this;
42
+
43
+ // Vendored by `@dunx/create-app`: a scaffold taking `http` without `openapi`
44
+ // has no document to encode.
45
+ const available = await fetch(new URL(DOCUMENT, url));
46
+ await available.body?.cancel();
47
+ if (!available.ok) {
48
+ logger.info(`skipping: no ${DOCUMENT} in this app to encode`);
49
+ return;
50
+ }
51
+
52
+ // `identity` is a coding the app does not offer, so nothing is picked.
53
+ for (const accept of ['identity', 'gzip', 'zstd', 'gzip, zstd']) {
54
+ const m = await measure(url, DOCUMENT, accept);
55
+ logger.info(
56
+ `accept-encoding: ${accept.padEnd(11)} -> ${m.encoding.padEnd(8)} ` +
57
+ `${String(m.wire).padStart(6)} of ${m.decoded} bytes (${ratio(m)})`,
58
+ );
59
+ }
60
+
61
+ // Same q, so the server's order decides: `['zstd', 'gzip']`.
62
+ const preferred = await measure(url, DOCUMENT, 'gzip, zstd');
63
+ logger.info(
64
+ `a tie in the client's q-values is broken by the server order -> ${preferred.encoding}`,
65
+ );
66
+
67
+ const forced = await measure(url, DOCUMENT, 'zstd;q=0.1, gzip;q=0.9');
68
+ logger.info(`zstd;q=0.1, gzip;q=0.9 -> ${forced.encoding}`);
69
+
70
+ const small = await measure(url, SMALL, 'gzip, zstd');
71
+ logger.info(
72
+ `under the 1024-byte threshold: ${SMALL} -> ${small.encoding} ` +
73
+ `(${small.decoded} bytes, encoding it would add bytes)`,
74
+ );
75
+ logger.info(
76
+ `vary: ${small.vary} - set even when nothing was encoded, so a shared ` +
77
+ `cache does not serve one client's encoding to another`,
78
+ );
79
+ }
80
+ }
@@ -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) {
@@ -1,11 +1,39 @@
1
1
  import { Module } from '@dunx/core';
2
+ import { CompressionModule } from '@dunx/http';
3
+ import { CompressionDemo } from './compression.demo.js';
4
+ import { TraceController } from './trace.controller.js';
5
+ import { TraceDemo } from './trace.demo.js';
2
6
  import { HttpDemo } from './http.demo.js';
3
7
  import { RequestTrail, RequestTrailMiddleware } from './request-trail.js';
4
8
 
5
9
  // `use()` resolves middleware from the container, and every class self-binds - so
6
10
  // declaring them here is for the reader, not for the resolver.
7
11
  @Module({
8
- providers: [RequestTrail, RequestTrailMiddleware, HttpDemo],
9
- exports: [RequestTrail, RequestTrailMiddleware, HttpDemo],
12
+ imports: [
13
+ // Binds `Compression`; the **app** registers it, in `bootstrap.ts`, for the
14
+ // same reason `StaticFiles` is registered there. Nothing is installed by
15
+ // importing this, so an app that never calls `app.use(Compression)` has no
16
+ // branch in the request path to skip.
17
+ //
18
+ // Defaults left alone apart from the threshold, which is here to be seen: a
19
+ // body under it is sent as it is, because gzip's header and trailer alone are
20
+ // 18 bytes and a short JSON response comes out larger.
21
+ CompressionModule.forRoot({ threshold: 1024 }),
22
+ ],
23
+ controllers: [TraceController],
24
+ providers: [
25
+ RequestTrail,
26
+ RequestTrailMiddleware,
27
+ HttpDemo,
28
+ CompressionDemo,
29
+ TraceDemo,
30
+ ],
31
+ exports: [
32
+ RequestTrail,
33
+ RequestTrailMiddleware,
34
+ HttpDemo,
35
+ CompressionDemo,
36
+ TraceDemo,
37
+ ],
10
38
  })
11
39
  export class HttpModule {}
@@ -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) {}
@@ -0,0 +1,30 @@
1
+ import { RequestContext } from '@dunx/core';
2
+ import { Controller, Get, type Input, type RouteSchemas } from '@dunx/http';
3
+
4
+ /**
5
+ * What the request's W3C trace looks like from inside a handler.
6
+ *
7
+ * `RequestContext` is bound by `@dunx/core` whatever else the app imports, and
8
+ * `requestLogging: { trace: true }` is what puts the trace fields into it. Every
9
+ * log line this request writes carries the same three values.
10
+ */
11
+ @Controller('trace')
12
+ export class TraceController {
13
+ constructor(private readonly context: RequestContext) {}
14
+
15
+ @Get('/')
16
+ current(input: Input<RouteSchemas>): {
17
+ traceId: string | undefined;
18
+ spanId: string | undefined;
19
+ parentSpanId: string | undefined;
20
+ inbound: string | null;
21
+ } {
22
+ const { traceId, spanId, parentSpanId } = this.context.getContext();
23
+ return {
24
+ traceId: traceId as string | undefined,
25
+ spanId: spanId as string | undefined,
26
+ parentSpanId: parentSpanId as string | undefined,
27
+ inbound: input.req.headers.get('traceparent'),
28
+ };
29
+ }
30
+ }
@@ -0,0 +1,58 @@
1
+ import { Logger } from '@dunx/core';
2
+
3
+ interface Seen {
4
+ readonly traceId: string | undefined;
5
+ readonly spanId: string | undefined;
6
+ readonly parentSpanId: string | undefined;
7
+ }
8
+
9
+ const UPSTREAM_TRACE = '4bf92f3577b34da6a3ce929d0e0e4736';
10
+ const UPSTREAM_SPAN = '00f067aa0ba902b7';
11
+
12
+ const ask = async (url: string, traceparent?: string): Promise<Seen> => {
13
+ const response = await fetch(new URL('api/trace', url), {
14
+ headers: traceparent === undefined ? {} : { traceparent },
15
+ });
16
+ return (await response.json()) as Seen;
17
+ };
18
+
19
+ export class TraceDemo {
20
+ constructor(private readonly logger: Logger) {}
21
+
22
+ async demonstrate(url: string): Promise<void> {
23
+ const { logger } = this;
24
+
25
+ const fresh = await ask(url);
26
+ logger.info(
27
+ `no traceparent in: trace ${fresh.traceId} span ${fresh.spanId} ` +
28
+ `(no parent - this service started the trace)`,
29
+ );
30
+
31
+ const continued = await ask(
32
+ url,
33
+ `00-${UPSTREAM_TRACE}-${UPSTREAM_SPAN}-01`,
34
+ );
35
+ logger.info(
36
+ `traceparent in: trace ${continued.traceId} span ${continued.spanId} ` +
37
+ `parent ${continued.parentSpanId}`,
38
+ );
39
+ logger.info(
40
+ `the caller's trace id survived: ${continued.traceId === UPSTREAM_TRACE}, ` +
41
+ `and its span became this one's parent: ${continued.parentSpanId === UPSTREAM_SPAN}`,
42
+ );
43
+
44
+ // Discarded rather than repaired. An unparseable header means the caller's
45
+ // trace is unknown, so this request starts one of its own.
46
+ const broken = await ask(url, '00-not-a-trace-id-01');
47
+ logger.info(
48
+ `malformed traceparent in: trace ${broken.traceId} ` +
49
+ `(a fresh one, not the caller's)`,
50
+ );
51
+
52
+ // Two requests are two traces, and two spans within one.
53
+ const second = await ask(url);
54
+ logger.info(
55
+ `each request gets its own span: ${fresh.spanId !== second.spanId}`,
56
+ );
57
+ }
58
+ }