@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.
- package/dist/features.d.ts +6 -11
- package/package.json +1 -1
- 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 +7 -15
- package/templates/features/http/http.demo.ts +7 -13
- package/templates/features/http/request-trail.ts +5 -8
- 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.repository.ts +4 -13
- package/templates/features/users/users.schemas.ts +11 -33
- 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
|
@@ -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 }),
|
|
@@ -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
|
-
/**
|
|
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
|
-
* `
|
|
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
|
-
//
|
|
47
|
-
//
|
|
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`
|
|
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
|
-
//
|
|
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
|
-
*
|
|
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) {
|
|
@@ -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) {}
|
|
@@ -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()`
|
|
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
|
-
*
|
|
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
|
|
88
|
-
*
|
|
89
|
-
*
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
//
|
|
30
|
-
//
|
|
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
|
-
//
|
|
34
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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
|
}),
|