@dunx/create-app 3.1.2 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,8 +40,8 @@ There is no flag for choosing features. The command opens a list:
40
40
  | Ctrl+C, Esc | Stop, having written nothing |
41
41
 
42
42
  `◉` is chosen, `◈` is pulled in by something else you chose, `○` is neither. The
43
- two lines under the list update as you go: what your selection drags in, and which
44
- of it needs Redis or Postgres running to do anything.
43
+ two lines under the list update as you go: one shows what your selection drags in,
44
+ the other shows which of it needs Redis or Postgres running to do anything.
45
45
 
46
46
  Three more questions appear only when there is something to ask: a directory, when
47
47
  the command line named none; a package name, when the directory's is one npm would
@@ -56,9 +56,10 @@ reject; and whether to write into a directory that already has files in it.
56
56
  | `--yes`, `-y` | off | Skip the questions, take the minimal template |
57
57
  | `--help` | | Print usage |
58
58
 
59
- **Piped, redirected or in CI it asks nothing** and writes the minimal template, so
60
- a script never hangs on a question nothing can answer. To choose features without
61
- a terminal, call [`scaffold`](#programmatic-use) rather than passing flags.
59
+ **Piped, redirected or in CI it asks nothing.** It writes the minimal template
60
+ instead, so a script never hangs on a question nothing can answer. To choose
61
+ features without a terminal, call [`scaffold`](#programmatic-use) rather than
62
+ passing flags.
62
63
 
63
64
  The name is validated against npm's rules **before** anything is created, because
64
65
  an invalid one would otherwise surface as a confusing `bun install` failure inside
@@ -73,8 +74,8 @@ bunx @dunx/create-app .
73
74
 
74
75
  `.git`, `.gitkeep`, `.DS_Store` and `LICENSE` do not count as contents, so a fresh
75
76
  repo or a clone of an empty GitHub repository is a valid target without a question.
76
- Nothing else is ignored: `.gitignore` and `README.md` both come out of the template,
77
- and overwriting your copy of either is what the last question asks about.
77
+ Nothing else is ignored: `.gitignore` and `README.md` both come out of the template.
78
+ Overwriting your copy of either is what the last question asks about.
78
79
 
79
80
  ## What a composed app looks like
80
81
 
@@ -89,9 +90,10 @@ my-api/
89
90
  bunfig.toml the transform preload
90
91
  ```
91
92
 
92
- Three files are generated for the selection and the feature directories are copied.
93
- `main.ts` is one file rather than two: a test imports `createApp` from it, and the
94
- `import.meta.main` block at the bottom is what stops that starting a server.
93
+ Three files are generated for the selection, and the feature directories are just
94
+ copied. `main.ts` is one file rather than two: a test imports `createApp` from it,
95
+ and the `import.meta.main` block at the bottom stops that same import from
96
+ starting a server.
95
97
 
96
98
  **There is no worker entry point, even with queues.** `QueueModule` is given
97
99
  `consume: true`, so the container opens the bullmq workers at `onInit` and closes
@@ -100,18 +102,21 @@ forked by bullmq itself into `src/jobs/jobs.processor.ts`.
100
102
 
101
103
  ## What it generates
102
104
 
103
- The `minimal` template, the same app as
104
- [`examples/minimal`](https://github.com/petarzarkov/dunx/tree/main/examples/minimal) a service, a controller, a module, `HttpFactory.create`, one test against a real
105
+ The `minimal` template is the same app as
106
+ [`examples/minimal`](https://github.com/petarzarkov/dunx/tree/main/examples/minimal):
107
+ a service, a controller, a module, `HttpFactory.create`, one test against a real
105
108
  server, and the `bunfig.toml` preload line that makes constructor injection work.
106
109
 
107
- Its `src/` is a **byte-for-byte copy** of that example, and a test in this package
110
+ Its `src/` is a **byte-for-byte copy** of that example. A test in this package
108
111
  fails if the two ever drift.
109
112
 
110
113
  Every app also gets an `AGENTS.md` naming its layout, its commands and the rules
111
114
  dunx fails at boot over, plus a `CLAUDE.md` pointing at it. Both link
112
115
  <https://petarzarkov.github.io/dunx/setup.md>, which is served per release, rather
113
- than copying the framework's own instructions into your repository. The example is the one CI boots, so keeping them
114
- identical is what makes the template trustworthy rather than merely plausible.
116
+ than copying the framework's own instructions into your repository.
117
+
118
+ The example is the one CI boots, so keeping them identical makes the template
119
+ trustworthy rather than merely plausible.
115
120
 
116
121
  ## Two details
117
122
 
@@ -146,7 +151,7 @@ const { directory, files } = await scaffold({
146
151
  ```
147
152
 
148
153
  This is the scripted path the removed `--with` flag used to be. `features` takes
149
- the same names the list shows, in any order, and pulls in what they require;
154
+ the same names the list shows, in any order, and pulls in what they require.
150
155
  `FEATURES` exports the set. Omitting it writes the minimal template.
151
156
 
152
157
  `scaffold` throws `ScaffoldError` for anything the caller can fix - an unknown
@@ -284,6 +284,15 @@ var FEATURES = [
284
284
  dependencies: [],
285
285
  config: []
286
286
  },
287
+ {
288
+ name: "stats",
289
+ source: "stats",
290
+ summary: "Per-route request timings and per-operation query timings, as JSON.",
291
+ requires: ["database"],
292
+ module: { klass: "StatsModule", from: "./stats/stats.module.js" },
293
+ dependencies: [],
294
+ config: []
295
+ },
287
296
  {
288
297
  name: "client",
289
298
  source: "upstream",
@@ -495,11 +504,11 @@ var manifest = (features) => {
495
504
  `;
496
505
  };
497
506
  var THIRD_PARTY = Object.freeze({
498
- zod: "^4.4.3",
499
- "drizzle-orm": "^0.45.2",
500
- "better-auth": "^1.6.25",
501
- bullmq: "^6.0.5",
502
- ioredis: "^6.0.0"
507
+ zod: "4.5.4",
508
+ "drizzle-orm": "0.45.2",
509
+ "better-auth": "1.6.25",
510
+ bullmq: "6.0.5",
511
+ ioredis: "6.0.0"
503
512
  });
504
513
  var versionOf = (dep) => THIRD_PARTY[dep] ?? "latest";
505
514
  var appModule = (name, features) => {
@@ -642,14 +651,17 @@ var main = (name, features) => {
642
651
  version: '0.1.0',
643
652
  root: AppModule,
644
653
  })` : "AppModule";
645
- const options = websockets ? [
646
- "// Multi-node websocket fan-out on `Bun.RedisClient`, so it costs no",
647
- "// dependency. With no Redis running this degrades to single-process",
648
- "// behaviour, logs one warning, and the app still boots.",
649
- "websocket: { idleTimeout: 30 },",
650
- "relay: new RedisRelay({ connectionTimeout: 500 }),",
651
- "relayChannel: RELAY_CHANNEL,"
652
- ] : [];
654
+ const options = [
655
+ ...has(features, "stats") ? ["metrics: true,"] : [],
656
+ ...websockets ? [
657
+ "// Multi-node websocket fan-out on `Bun.RedisClient`, so it costs no",
658
+ "// dependency. With no Redis running this degrades to single-process",
659
+ "// behaviour, logs one warning, and the app still boots.",
660
+ "websocket: { idleTimeout: 30 },",
661
+ "relay: new RedisRelay({ connectionTimeout: 500 }),",
662
+ "relayChannel: RELAY_CHANNEL,"
663
+ ] : []
664
+ ];
653
665
  const shaping = [
654
666
  "app.setGlobalPrefix('api');",
655
667
  ...assets ? ["app.use(StaticFiles);"] : [],
package/dist/cli.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  packageVersion,
10
10
  resolveFeatures,
11
11
  scaffold
12
- } from "./chunk-tn4dnvcw.js";
12
+ } from "./chunk-rv2w0t23.js";
13
13
 
14
14
  // src/cli.ts
15
15
  import { parseArgs } from "util";
@@ -3,7 +3,8 @@ import { type Feature } from './features.js';
3
3
  export declare const configGroupsFor: (features: readonly Feature[]) => readonly string[];
4
4
  export declare const manifest: (features: readonly Feature[]) => string;
5
5
  /**
6
- * Third-party ranges, pinned here rather than read off `examples/full` at run time:
6
+ * Third-party versions, exact for the reason dunx's own manifests are, and
7
+ * written here rather than read off `examples/full` at run time:
7
8
  * the generated app installs from npm and the example installs from the workspace,
8
9
  * so the example's manifest is not a statement about what a consumer should take.
9
10
  * `features.test.ts` checks these against the example's, which is what stops them
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  featureNames,
9
9
  isValidPackageName,
10
10
  scaffold
11
- } from "./chunk-tn4dnvcw.js";
11
+ } from "./chunk-rv2w0t23.js";
12
12
  export {
13
13
  FEATURES,
14
14
  ScaffoldError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/create-app",
3
- "version": "3.1.2",
3
+ "version": "3.2.0",
4
4
  "description": "Scaffold a new dunx application - bunx @dunx/create-app my-api",
5
5
  "keywords": [
6
6
  "bun",
@@ -1,4 +1,4 @@
1
- import { Logger } from '@dunx/core';
1
+ import { Gauge, Logger } from '@dunx/core';
2
2
  import {
3
3
  Gateway,
4
4
  HttpStatusCode,
@@ -18,6 +18,9 @@ import { Lobby } from './lobby.service.js';
18
18
  * upgrade as a native route. */
19
19
  @Gateway('/chat')
20
20
  export class ChatGateway {
21
+ /** A `Gauge` rather than a `Counter`: connections close as well as open. */
22
+ readonly #live = new Gauge();
23
+
21
24
  constructor(
22
25
  private readonly lobby: Lobby,
23
26
  private readonly logger: Logger,
@@ -41,9 +44,15 @@ export class ChatGateway {
41
44
  opened(socket: Socket): void {
42
45
  // Bun's own pub/sub: topics live in the runtime.
43
46
  socket.subscribe(Lobby.TOPIC);
47
+ this.#live.inc();
44
48
  socket.send('welcome');
45
49
  }
46
50
 
51
+ /** What the gauge reads, for the tour and for anything else that asks. */
52
+ get live(): number {
53
+ return this.#live.value;
54
+ }
55
+
47
56
  @OnMessage('say')
48
57
  say(text: string): { delivered: number } {
49
58
  // Broadcast reaches every subscriber; the return value replies to the sender.
@@ -69,6 +78,7 @@ export class ChatGateway {
69
78
 
70
79
  @OnClose()
71
80
  closed(socket: Socket, code: number): void {
81
+ this.#live.dec();
72
82
  this.logger.info(`${socket.data.path} closed with ${code}`);
73
83
  }
74
84
  }
@@ -2,6 +2,7 @@ import { Module } from '@dunx/core';
2
2
  import {
3
3
  DbConnection,
4
4
  DbModule,
5
+ QueryMetrics,
5
6
  SyncDatabase,
6
7
  SyncSqliteOptions,
7
8
  } from '@dunx/infra/db';
@@ -18,18 +19,24 @@ import * as schema from './schema.js';
18
19
  // `SyncSqliteOptions` runs SQLite in synchronous mode, so the token is
19
20
  // `SyncDatabase` and `transactionSync` is reachable. `SqliteOptions` is the
20
21
  // default and what an app wants if it might move to Postgres.
21
- DbModule.forRootAsync(SyncDatabase, {
22
- useFactory: (config: AppConfigService) =>
23
- new SyncSqliteOptions({
24
- // Required: the type argument every constructor below sees.
25
- schema,
26
- // A dotted path, checked against AppConfig the same way a top-level
27
- // key is. `config.get('database').file` still reads the same value.
28
- filename: config.get('database.file'),
29
- pragmas: ['foreign_keys = ON'],
30
- }),
31
- inject: [AppConfigService],
32
- }),
22
+ DbModule.forRootAsync(
23
+ SyncDatabase,
24
+ {
25
+ useFactory: (config: AppConfigService) =>
26
+ new SyncSqliteOptions({
27
+ // Required: the type argument every constructor below sees.
28
+ schema,
29
+ // A dotted path, checked against AppConfig the same way a top-level
30
+ // key is. `config.get('database').file` still reads the same value.
31
+ filename: config.get('database.file'),
32
+ pragmas: ['foreign_keys = ON'],
33
+ }),
34
+ inject: [AppConfigService],
35
+ },
36
+ // Times every query the driver runs, readable through `QueryMetrics` and
37
+ // shown on the dashboard's stats panel. Off by default.
38
+ { metrics: true },
39
+ ),
33
40
  ],
34
41
  controllers: [LedgerController],
35
42
  providers: [Ledger],
@@ -37,6 +44,6 @@ import * as schema from './schema.js';
37
44
  * Re-exported so importers can inject it. `DbModule` exports to this module
38
45
  * only; naming it again passes it on.
39
46
  */
40
- exports: [SyncDatabase, DbConnection, Ledger],
47
+ exports: [SyncDatabase, DbConnection, Ledger, QueryMetrics],
41
48
  })
42
49
  export class DatabaseModule {}
@@ -66,9 +66,8 @@ export class AppHttpOptions extends HttpOptionsProvider {
66
66
  responseBody: log.responseBody,
67
67
  // The dashboard polls every five seconds and would bury everything else.
68
68
  ignorePrefix: ['/api/_dunx'],
69
- // ~360 ns per request, so off by default. On here so `traceId` joins
70
- // `requestId` and `@dunx/http/client` forwards it upstream.
71
- trace: true,
69
+ // `trace` and `traceResponse` are not set: both are on by default, and
70
+ // `TraceController` reading the trace back proves it.
72
71
  };
73
72
  }
74
73
  }
@@ -5,8 +5,9 @@ import { Controller, Get, type Input, type RouteSchemas } from '@dunx/http';
5
5
  * What the request's W3C trace looks like from inside a handler.
6
6
  *
7
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.
8
+ * request logging puts the trace fields into it with nothing configured. Every
9
+ * log line this request writes carries the same values, and the response carries
10
+ * `traceresponse`. `requestLogging: { trace: false }` is what removes them.
10
11
  */
11
12
  @Controller('trace')
12
13
  export class TraceController {
@@ -17,13 +18,16 @@ export class TraceController {
17
18
  traceId: string | undefined;
18
19
  spanId: string | undefined;
19
20
  parentSpanId: string | undefined;
21
+ traceFlags: string | undefined;
20
22
  inbound: string | null;
21
23
  } {
22
- const { traceId, spanId, parentSpanId } = this.context.getContext();
24
+ const { traceId, spanId, parentSpanId, traceFlags } =
25
+ this.context.getContext();
23
26
  return {
24
27
  traceId: traceId as string | undefined,
25
28
  spanId: spanId as string | undefined,
26
29
  parentSpanId: parentSpanId as string | undefined,
30
+ traceFlags: traceFlags as string | undefined,
27
31
  inbound: input.req.headers.get('traceparent'),
28
32
  };
29
33
  }
@@ -1,4 +1,4 @@
1
- import { Logger } from '@dunx/core';
1
+ import { Counter, Logger } from '@dunx/core';
2
2
  import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
3
3
 
4
4
  /**
@@ -6,13 +6,13 @@ import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
6
6
  * with no second registration. Nothing here coordinates across replicas: work
7
7
  * that must happen once per fleet is a `@JobHandler`.
8
8
  *
9
- * Counters are `x = x + 1` rather than `x += 1` because Bun 1.4.0 fails to parse
10
- * a class with both a decorated member and a read-modify-write on a private
11
- * field. See docs/bun-apis.md.
9
+ * Counts go through `@dunx/core`'s `Counter` rather than a private field, which
10
+ * also sidesteps Bun 1.4.0 failing to parse a class with both a decorated member
11
+ * and a read-modify-write on a private field. See docs/bun-apis.md.
12
12
  */
13
13
  export class Maintenance {
14
- #sweeps = 0;
15
- #compactions = 0;
14
+ readonly #sweeps = new Counter();
15
+ readonly #compactions = new Counter();
16
16
  #warmed = false;
17
17
 
18
18
  constructor(private readonly logger: Logger) {}
@@ -28,8 +28,8 @@ export class Maintenance {
28
28
  /** Ten minutes, so it never fires during a tour. `trigger` runs it now. */
29
29
  @Interval(600_000, { name: 'maintenance.sweep' })
30
30
  sweepSessions(): number {
31
- this.#sweeps = this.#sweeps + 1;
32
- return this.#sweeps;
31
+ this.#sweeps.inc();
32
+ return this.#sweeps.value;
33
33
  }
34
34
 
35
35
  /**
@@ -39,14 +39,14 @@ export class Maintenance {
39
39
  @Cron('0 3 * * *', { name: 'maintenance.compact' })
40
40
  async compactLedger(): Promise<number> {
41
41
  await Bun.sleep(1);
42
- this.#compactions = this.#compactions + 1;
43
- return this.#compactions;
42
+ this.#compactions.inc();
43
+ return this.#compactions.value;
44
44
  }
45
45
 
46
46
  get counts(): { sweeps: number; compactions: number; warmed: boolean } {
47
47
  return {
48
- sweeps: this.#sweeps,
49
- compactions: this.#compactions,
48
+ sweeps: this.#sweeps.value,
49
+ compactions: this.#compactions.value,
50
50
  warmed: this.#warmed,
51
51
  };
52
52
  }
@@ -0,0 +1,69 @@
1
+ import { EventLoopLag, Logger } from '@dunx/core';
2
+ import { RequestMetrics } from '@dunx/http';
3
+ import { QueryMetrics } from '@dunx/infra/db';
4
+
5
+ /** Read out of the same container, from work the tour already did. */
6
+ export class StatsDemo {
7
+ constructor(
8
+ private readonly logger: Logger,
9
+ private readonly requests: RequestMetrics,
10
+ private readonly queries: QueryMetrics,
11
+ private readonly lag: EventLoopLag,
12
+ ) {}
13
+
14
+ demonstrate(): void {
15
+ const http = this.requests.snapshot();
16
+ const ms = (nanoseconds: number | undefined): string =>
17
+ nanoseconds === undefined ? '-' : `${(nanoseconds / 1e6).toFixed(2)}ms`;
18
+
19
+ this.logger.info(
20
+ `${http.routes.length} route series, ${http.inFlight} in flight, ` +
21
+ `${http.pendingWebSockets} sockets - both read off Bun.serve, not counted`,
22
+ );
23
+
24
+ const slowest = [...http.routes]
25
+ .sort((a, b) => (b.duration.p99 ?? 0) - (a.duration.p99 ?? 0))
26
+ .slice(0, 3);
27
+ for (const route of slowest) {
28
+ this.logger.info(
29
+ `${route.method} ${route.route}: ${route.count} calls, ` +
30
+ `p50 ${ms(route.duration.p50)} p99 ${ms(route.duration.p99)} ` +
31
+ `max ${ms(route.duration.max)}`,
32
+ );
33
+ }
34
+
35
+ const worst = slowest[0];
36
+ if (worst?.slowestTraceId !== undefined) {
37
+ this.logger.info(
38
+ `the slowest ${worst.route} call has traceId ${worst.slowestTraceId} - ` +
39
+ 'every line that request wrote carries it',
40
+ );
41
+ }
42
+
43
+ const unmatched = http.routes.find(
44
+ (route) => route.route === '(unmatched)',
45
+ );
46
+ this.logger.info(
47
+ `unmatched paths: ${unmatched?.count ?? 0} across one series, so a ` +
48
+ 'scanner walking urls cannot grow the series count',
49
+ );
50
+
51
+ const db = this.queries.snapshot();
52
+ this.logger.info(
53
+ `${db.total} queries, timed at the bun:sqlite handle dunx constructs - ` +
54
+ "drizzle's own logQuery fires before the statement runs and cannot time one",
55
+ );
56
+ for (const operation of db.operations) {
57
+ this.logger.info(
58
+ `${operation.operation}: ${operation.count} calls, ` +
59
+ `${operation.errors} failed, p99 ${ms(operation.duration.p99)}`,
60
+ );
61
+ }
62
+
63
+ const loop = this.lag.snapshot();
64
+ this.logger.info(
65
+ `event loop lag: ${loop.count} samples, p99 ${ms(loop.p99)} ` +
66
+ `max ${ms(loop.max)}`,
67
+ );
68
+ }
69
+ }
@@ -0,0 +1,14 @@
1
+ import { EventLoopLag, Module } from '@dunx/core';
2
+ import { DatabaseModule } from '../database/database.module.js';
3
+ import { StatsDemo } from './stats.demo.js';
4
+
5
+ /**
6
+ * `RequestMetrics` is global, so only `QueryMetrics` is imported. `EventLoopLag`
7
+ * is a provider so its `onInit` enables it at boot rather than at read time.
8
+ */
9
+ @Module({
10
+ imports: [DatabaseModule],
11
+ providers: [EventLoopLag, StatsDemo],
12
+ exports: [StatsDemo],
13
+ })
14
+ export class StatsModule {}
@@ -9,7 +9,7 @@ import { HealthClient } from './health.client.js';
9
9
  /**
10
10
  * Calling out over `fetch`. Three things a bare `fetch` does not do: retry a 503
11
11
  * with backoff, raise a non-2xx as an error carrying the parsed body, and forward
12
- * the inbound request id.
12
+ * the inbound trace.
13
13
  */
14
14
  export class UpstreamDemo {
15
15
  constructor(
@@ -21,8 +21,10 @@ import { UpstreamDemo } from './upstream.demo.js';
21
21
  retryDelayMs: 20,
22
22
  backoff: { jitterMs: 10, maxMs: 200 },
23
23
  },
24
- // The inbound request id, forwarded so one trace spans both services.
25
- propagateRequestId: true,
24
+ // The inbound trace, forwarded as `traceparent` so one trace spans
25
+ // both services. On by default; stated here because it is the point of
26
+ // this module.
27
+ propagateTrace: true,
26
28
  }),
27
29
  inject: [AppConfigService] as const,
28
30
  }),
@@ -26,12 +26,12 @@ preload = ["@dunx/transform/preload"]
26
26
  ```
27
27
 
28
28
  `@dunx/transform` reads each class's constructor parameter types when the file
29
- loads and records them, so the container can resolve them before calling `new`.
30
- Without it, providers are constructed with no arguments and boot fails saying so -
31
- it is not a silent `undefined`.
29
+ loads and records them. The container uses that record to resolve them before
30
+ calling `new`. Without it, providers are constructed with no arguments, and boot
31
+ fails by naming the problem rather than returning a silent `undefined`.
32
32
 
33
33
  That is also why there is no `@Injectable()` and no `@Inject()`. Being listed in a
34
- module's `providers` is what makes a class injectable, and TC39 standard decorators
34
+ module's `providers` is what makes a class injectable. TC39 standard decorators
35
35
  have no parameter decorators, so `@Inject()` does not exist.
36
36
 
37
37
  ## Next