@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.
- package/dist/{chunk-mpa5nv1v.js → chunk-nn9ekg83.js} +2 -5
- package/dist/cli.js +1 -4
- package/dist/features.d.ts +6 -11
- package/dist/index.js +1 -4
- package/package.json +2 -2
- package/templates/features/assets/assets.module.ts +6 -15
- package/templates/features/auth/auth.demo.ts +7 -19
- package/templates/features/auth/auth.module.ts +16 -31
- package/templates/features/auth/auth.tables.ts +7 -15
- package/templates/features/cache/cache.module.ts +7 -13
- package/templates/features/chat/chat.demo.ts +10 -21
- package/templates/features/chat/chat.gateway.ts +8 -19
- package/templates/features/database/database.module.ts +8 -19
- package/templates/features/database/ledger.controller.ts +13 -26
- package/templates/features/database/ledger.service.ts +16 -44
- package/templates/features/docs/docs.demo.ts +13 -32
- package/templates/features/health/health.module.ts +10 -19
- package/templates/features/health/indicators.ts +10 -26
- package/templates/features/http/compression.demo.ts +80 -0
- package/templates/features/http/http.demo.ts +7 -13
- package/templates/features/http/http.module.ts +30 -2
- package/templates/features/http/request-trail.ts +5 -8
- package/templates/features/http/trace.controller.ts +30 -0
- package/templates/features/http/trace.demo.ts +58 -0
- package/templates/features/jobs/jobs.controller.ts +7 -18
- package/templates/features/jobs/jobs.module.ts +8 -18
- package/templates/features/jobs/jobs.processor.ts +5 -13
- package/templates/features/schedule/maintenance.service.ts +11 -29
- package/templates/features/schedule/schedule.module.ts +3 -7
- package/templates/features/storage/files.controller.ts +10 -19
- package/templates/features/throttle/limits.controller.ts +5 -12
- package/templates/features/throttle/throttle.module.ts +8 -26
- package/templates/features/upstream/upstream.demo.ts +6 -15
- package/templates/features/upstream/upstream.module.ts +4 -13
- package/templates/features/users/users.controller.ts +8 -0
- package/templates/features/users/users.repository.ts +4 -13
- package/templates/features/users/users.schemas.ts +11 -28
- package/templates/minimal/src/app.module.ts +0 -5
- package/templates/minimal/src/app.test.ts +0 -5
- package/templates/minimal/src/greetings.controller.ts +2 -12
- package/templates/minimal/src/greetings.service.ts +2 -10
- package/templates/minimal/src/main.ts +0 -5
- package/dist/chunk-mpa5nv1v.js.map +0 -12
- package/dist/cli.js.map +0 -10
- 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
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
138
|
-
* `db.transaction()
|
|
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
|
-
//
|
|
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
|
|
250
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
52
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
//
|
|
141
|
-
//
|
|
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
|
-
*
|
|
15
|
-
* `
|
|
16
|
-
*
|
|
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
|
-
//
|
|
27
|
-
//
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
65
|
-
//
|
|
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
|
-
*
|
|
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
|
|
41
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
88
|
-
//
|
|
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
|
-
*
|
|
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
|
|
87
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
9
|
-
|
|
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
|
|
11
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
+
}
|