@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
|
@@ -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
|
}),
|
|
@@ -2,21 +2,13 @@ import { Logger } from '@dunx/core';
|
|
|
2
2
|
import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The three decorators
|
|
5
|
+
* The three schedule decorators on one class, discovered off the prototype chain
|
|
6
|
+
* with no second registration. Nothing here coordinates across replicas: work
|
|
7
|
+
* that must happen once per fleet is a `@JobHandler`.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Nothing here is single-node-unsafe on purpose: two replicas would both run every
|
|
12
|
-
* one of these, because nothing in `@dunx/infra/schedule` coordinates. Work that
|
|
13
|
-
* must happen once across a fleet is a job, which is `@JobHandler` and bullmq.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* Counters are written as `x = x + 1` rather than `x += 1`, and that is not style.
|
|
17
|
-
* Bun 1.4.0 refuses to parse a class that has both a decorated member and a
|
|
18
|
-
* read-modify-write on a private field - `+=`, `++` and `??=` alike - with a
|
|
19
|
-
* `SyntaxError` naming neither. See docs/bun-apis.md.
|
|
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.
|
|
20
12
|
*/
|
|
21
13
|
export class Maintenance {
|
|
22
14
|
#sweeps = 0;
|
|
@@ -25,22 +17,15 @@ export class Maintenance {
|
|
|
25
17
|
|
|
26
18
|
constructor(private readonly logger: Logger) {}
|
|
27
19
|
|
|
28
|
-
/**
|
|
29
|
-
* `
|
|
30
|
-
* "ready" is `onInit`, which is the latest hook there is and runs **before**
|
|
31
|
-
* `Bun.serve` binds. Measured: this has already run by the time `listen()`
|
|
32
|
-
* resolves, so the first request never sees a cold cache.
|
|
33
|
-
*/
|
|
20
|
+
/** `0` fires on the next macrotask after `onInit`, which is before
|
|
21
|
+
* `Bun.serve` binds - so the first request never sees a cold cache. */
|
|
34
22
|
@OnceOnBoot(0, { name: 'maintenance.warm' })
|
|
35
23
|
warmCaches(): void {
|
|
36
24
|
this.#warmed = true;
|
|
37
25
|
this.logger.info('@OnceOnBoot(0): caches warmed, before listen() resolved');
|
|
38
26
|
}
|
|
39
27
|
|
|
40
|
-
/**
|
|
41
|
-
* Every ten minutes, so it does not fire during a tour or a suite. `trigger`
|
|
42
|
-
* runs it now, which is what makes a schedule testable without waiting.
|
|
43
|
-
*/
|
|
28
|
+
/** Ten minutes, so it never fires during a tour. `trigger` runs it now. */
|
|
44
29
|
@Interval(600_000, { name: 'maintenance.sweep' })
|
|
45
30
|
sweepSessions(): number {
|
|
46
31
|
this.#sweeps = this.#sweeps + 1;
|
|
@@ -48,11 +33,8 @@ export class Maintenance {
|
|
|
48
33
|
}
|
|
49
34
|
|
|
50
35
|
/**
|
|
51
|
-
* Minute resolution: `Bun.cron` rejects a sixth field
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* `overlap` is `skip` by default, which is what `Bun.cron` does for free - it
|
|
55
|
-
* computes the next fire only once the returned promise settles.
|
|
36
|
+
* Minute resolution: `Bun.cron` rejects a sixth field, so sub-minute work is
|
|
37
|
+
* `@Interval`. `overlap` defaults to `skip`, which `Bun.cron` gives for free.
|
|
56
38
|
*/
|
|
57
39
|
@Cron('0 3 * * *', { name: 'maintenance.compact' })
|
|
58
40
|
async compactLedger(): Promise<number> {
|
|
@@ -7,13 +7,9 @@ import { ScheduleDemo } from './schedule.demo.js';
|
|
|
7
7
|
/**
|
|
8
8
|
* `Bun.cron` behind `@Cron`, `@Interval` and `@OnceOnBoot`, armed at boot.
|
|
9
9
|
*
|
|
10
|
-
* `keepAlive: false
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `tz` comes from the config because that is the one thing a zero-argument
|
|
15
|
-
* `forRoot` cannot reach. A named zone is refused at boot on a Bun that ignores
|
|
16
|
-
* `Bun.cron`'s `tz` option, rather than running at the UTC hour and saying nothing.
|
|
10
|
+
* `keepAlive: false`: `Bun.cron` would hold the event loop open, and this app has
|
|
11
|
+
* a server doing that already while `bun run tour` has to exit. `tz` comes from
|
|
12
|
+
* the config, and a named zone is refused at boot on a Bun that ignores it.
|
|
17
13
|
*/
|
|
18
14
|
@Module({
|
|
19
15
|
imports: [
|
|
@@ -11,10 +11,9 @@ import { LocalStorage, PathTraversalError, Storage } from '@dunx/infra/files';
|
|
|
11
11
|
import { z } from 'zod';
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* The key is a query parameter
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* seeing: try `?key=../../etc/passwd`.
|
|
14
|
+
* The key is a query parameter because keys contain slashes: `reports/q1.csv` is
|
|
15
|
+
* one key, not two segments. `Storage` itself rejects traversal - try
|
|
16
|
+
* `?key=../../etc/passwd`.
|
|
18
17
|
*/
|
|
19
18
|
const FileKey = z.object({ key: z.string().min(1).max(200) }).meta({
|
|
20
19
|
id: 'FileKey',
|
|
@@ -34,15 +33,12 @@ const listFiles = {
|
|
|
34
33
|
const objectKey = { query: FileKey } as const;
|
|
35
34
|
const writeFile = { query: FileKey, body: WriteFile } as const;
|
|
36
35
|
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
* `forRoot` call in storage.module.ts and nothing here changes.
|
|
40
|
-
*/
|
|
36
|
+
/** Injects `Storage`, never `LocalStorage`: swapping disk for bucket is one
|
|
37
|
+
* `forRoot` call and nothing here changes. */
|
|
41
38
|
@Controller('files')
|
|
42
39
|
export class FilesController {
|
|
43
40
|
constructor(private readonly storage: Storage) {}
|
|
44
41
|
|
|
45
|
-
/** `list` is an AsyncIterable so a million objects page rather than accumulate. */
|
|
46
42
|
@Get('/', listFiles)
|
|
47
43
|
async list(
|
|
48
44
|
input: Input<typeof listFiles>,
|
|
@@ -54,8 +50,7 @@ export class FilesController {
|
|
|
54
50
|
})) {
|
|
55
51
|
keys.push(entry.key);
|
|
56
52
|
}
|
|
57
|
-
// The contract cannot promise a root
|
|
58
|
-
// reach anything backend-specific.
|
|
53
|
+
// The contract cannot promise a root, so narrowing reaches it.
|
|
59
54
|
const root =
|
|
60
55
|
this.storage instanceof LocalStorage ? this.storage.root : '(remote)';
|
|
61
56
|
return { root, keys: keys.sort() };
|
|
@@ -97,10 +92,8 @@ export class FilesController {
|
|
|
97
92
|
return { deleted: true };
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
/**
|
|
101
|
-
*
|
|
102
|
-
* message instead of handing back a URL that cannot work.
|
|
103
|
-
*/
|
|
95
|
+
/** Nothing signs bytes on a local disk, so this refuses rather than hand back
|
|
96
|
+
* a URL that cannot work. */
|
|
104
97
|
@Get('/presign', objectKey)
|
|
105
98
|
async presign(input: Input<typeof objectKey>): Promise<{ url: string }> {
|
|
106
99
|
const { key } = input.query;
|
|
@@ -115,10 +108,8 @@ export class FilesController {
|
|
|
115
108
|
}
|
|
116
109
|
}
|
|
117
110
|
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
120
|
-
* before any syscall, and without this it would surface as a 500.
|
|
121
|
-
*/
|
|
111
|
+
/** A traversal is a bad request: `Storage` rejects it before any syscall, and
|
|
112
|
+
* without this it would surface as a 500. */
|
|
122
113
|
private async present(key: string): Promise<void> {
|
|
123
114
|
try {
|
|
124
115
|
if (await this.storage.exists(key)) return;
|
|
@@ -1,33 +1,26 @@
|
|
|
1
1
|
import { Controller, Get, SkipThrottle, Throttle } from '@dunx/http';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* The module's default is generous, so the interesting cases are the two
|
|
7
|
-
* decorators: `@Throttle` replaces it for one handler, `@SkipThrottle` opts out.
|
|
8
|
-
* A limit on the class would cover every handler here, and a handler's own would
|
|
9
|
-
* still win - the same precedence `@Roles` has.
|
|
4
|
+
* `@Throttle` replaces the module default for one handler, `@SkipThrottle` opts
|
|
5
|
+
* out. A class-level limit covers every handler, and a handler's own still wins.
|
|
10
6
|
*/
|
|
11
7
|
@Controller('limits')
|
|
12
8
|
export class LimitsController {
|
|
13
|
-
/**
|
|
14
|
-
*
|
|
15
|
-
* thrown rather than returned, so it comes out in the app's own error shape.
|
|
16
|
-
*/
|
|
9
|
+
/** Three per minute per subject; the fourth is a thrown 429 with
|
|
10
|
+
* `retry-after`, so it takes the app's own error shape. */
|
|
17
11
|
@Throttle({ limit: 3, windowSeconds: 60 })
|
|
18
12
|
@Get('/burst')
|
|
19
13
|
burst(): { allowed: true } {
|
|
20
14
|
return { allowed: true };
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
/** Exempt
|
|
17
|
+
/** Exempt: a probe or internal callback that must never be counted. */
|
|
24
18
|
@SkipThrottle()
|
|
25
19
|
@Get('/exempt')
|
|
26
20
|
exempt(): { counted: false } {
|
|
27
21
|
return { counted: false };
|
|
28
22
|
}
|
|
29
23
|
|
|
30
|
-
/** The module default, which is what every other route in this app gets. */
|
|
31
24
|
@Get('/default')
|
|
32
25
|
standard(): { ok: true } {
|
|
33
26
|
return { ok: true };
|
|
@@ -13,28 +13,12 @@ import { LimitsController } from './limits.controller.js';
|
|
|
13
13
|
import { ThrottleDemo } from './throttle.demo.js';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* A fixed-window rate limit over the whole app
|
|
16
|
+
* A fixed-window rate limit over the whole app, picking its counter at boot by
|
|
17
|
+
* asking whether the cache answers. A real deployment names one outright.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* example has to work with nothing installed, so it probes and says which it got.
|
|
22
|
-
*
|
|
23
|
-
* That probe is `Sessions.status()`, which `CacheModule` already exports rather than
|
|
24
|
-
* a second ping written here.
|
|
25
|
-
*
|
|
26
|
-
* Falling back matters more than it looks. The guard fails **open**, so with
|
|
27
|
-
* `RedisThrottleStore` and no Redis nothing is counted at all: no 429, no
|
|
28
|
-
* `ratelimit-*` headers, every request through. That is the right call for a
|
|
29
|
-
* production limiter, and it would make this example demonstrate nothing on a
|
|
30
|
-
* machine without Redis.
|
|
31
|
-
*
|
|
32
|
-
* `RedisThrottleStore` takes its client structurally, so `RedisConnection` satisfies
|
|
33
|
-
* it with no adapter.
|
|
34
|
-
*
|
|
35
|
-
* The prefix carries the pid, the same trick `Sessions` uses: two runs against one
|
|
36
|
-
* Redis would otherwise spend each other's budget, and a leftover window would make
|
|
37
|
-
* a suite fail on the previous run's counters.
|
|
19
|
+
* The probe matters because the guard fails open: with `RedisThrottleStore` and
|
|
20
|
+
* no Redis nothing is counted. The prefix carries the pid, so two runs against one
|
|
21
|
+
* Redis cannot spend each other's budget.
|
|
38
22
|
*/
|
|
39
23
|
@Module({
|
|
40
24
|
imports: [
|
|
@@ -62,11 +46,9 @@ import { ThrottleDemo } from './throttle.demo.js';
|
|
|
62
46
|
? new RedisThrottleStore(redis)
|
|
63
47
|
: new MemoryThrottleStore(),
|
|
64
48
|
/**
|
|
65
|
-
* Who is
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* from. Only a guard ahead of this one knows which, which is why this is
|
|
69
|
-
* an option rather than something the package reads for itself.
|
|
49
|
+
* Who is counted: an API key when presented, else the address. Only a
|
|
50
|
+
* guard ahead of this one knows, so it is an option rather than
|
|
51
|
+
* something the package reads for itself.
|
|
70
52
|
*/
|
|
71
53
|
subject: (req: Bun.BunRequest) =>
|
|
72
54
|
req.headers.get('x-api-key') ?? address.of(req),
|
|
@@ -6,11 +6,9 @@ import {
|
|
|
6
6
|
} from '@dunx/http/client';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Calling out
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* non-2xx as an error carrying the parsed body, and forward the inbound request id
|
|
13
|
-
* so one trace covers both services.
|
|
9
|
+
* Calling out over `fetch`. Three things a bare `fetch` does not do: retry a 503
|
|
10
|
+
* with backoff, raise a non-2xx as an error carrying the parsed body, and forward
|
|
11
|
+
* the inbound request id.
|
|
14
12
|
*/
|
|
15
13
|
export class UpstreamDemo {
|
|
16
14
|
constructor(
|
|
@@ -24,7 +22,6 @@ export class UpstreamDemo {
|
|
|
24
22
|
);
|
|
25
23
|
this.logger.info(`GET api/notes -> ${JSON.stringify(notes)}`);
|
|
26
24
|
|
|
27
|
-
// The 503s are retried; the attempt callback is what makes that visible.
|
|
28
25
|
const attempts: string[] = [];
|
|
29
26
|
const recovered = await this.http.get<{ after: number }>(
|
|
30
27
|
new URL('api/upstream/flaky', url),
|
|
@@ -42,12 +39,8 @@ export class UpstreamDemo {
|
|
|
42
39
|
`recovered after ${recovered.after}`,
|
|
43
40
|
);
|
|
44
41
|
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
* reason the class exists: an upstream 401 arriving as an `HttpError(401)`
|
|
48
|
-
* would make *this* service answer 401, telling its own caller "you are
|
|
49
|
-
* unauthorized" when what failed was this service authenticating upstream.
|
|
50
|
-
*/
|
|
42
|
+
/** A 404 is a `FetchError`, not an `HttpError`: an upstream 401 arriving as
|
|
43
|
+
* `HttpError(401)` would make this service answer 401 to its own caller. */
|
|
51
44
|
try {
|
|
52
45
|
await this.http.get(new URL('api/upstream/missing', url), {
|
|
53
46
|
retry: { maxRetries: 0 },
|
|
@@ -61,9 +54,7 @@ export class UpstreamDemo {
|
|
|
61
54
|
);
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
// Not retried:
|
|
65
|
-
// caller's signal fired or the timeout did. `/slow` sleeps 300 ms, so this
|
|
66
|
-
// is a deadline rather than a race with the loopback.
|
|
57
|
+
// Not retried: an abort means the caller's signal or the timeout fired.
|
|
67
58
|
try {
|
|
68
59
|
await this.http.get(new URL('api/upstream/slow', url), {
|
|
69
60
|
timeoutMs: 25,
|
|
@@ -5,15 +5,9 @@ import { FlakyController } from './flaky.controller.js';
|
|
|
5
5
|
import { UpstreamDemo } from './upstream.demo.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* The outbound half of `@dunx/http`, from the `./client` subpath
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* its own and `@dunx/http` exports `HttpFactory` for the inbound direction. The
|
|
12
|
-
* subpath is what keeps the two unambiguous at an import site; the alias is what
|
|
13
|
-
* keeps them unambiguous here.
|
|
14
|
-
*
|
|
15
|
-
* No `baseUrl`: this app calls itself, and its own url is not known until
|
|
16
|
-
* `listen()` has run. A real upstream sets one and every call names a path.
|
|
8
|
+
* The outbound half of `@dunx/http`, from the `./client` subpath, aliased because
|
|
9
|
+
* this app has an `HttpModule` of its own. No `baseUrl`: this app calls itself,
|
|
10
|
+
* and its url is not known until `listen()` has run.
|
|
17
11
|
*/
|
|
18
12
|
@Module({
|
|
19
13
|
imports: [
|
|
@@ -24,12 +18,9 @@ import { UpstreamDemo } from './upstream.demo.js';
|
|
|
24
18
|
retry: {
|
|
25
19
|
maxRetries: 3,
|
|
26
20
|
retryDelayMs: 20,
|
|
27
|
-
// Jitter comes from `crypto.getRandomValues`, not `Math.random`.
|
|
28
21
|
backoff: { jitterMs: 10, maxMs: 200 },
|
|
29
22
|
},
|
|
30
|
-
// The inbound request id, forwarded
|
|
31
|
-
// both services. Read from `RequestContext`, so it only carries when there
|
|
32
|
-
// is a request in scope.
|
|
23
|
+
// The inbound request id, forwarded so one trace spans both services.
|
|
33
24
|
propagateRequestId: true,
|
|
34
25
|
}),
|
|
35
26
|
inject: [AppConfigService] as const,
|
|
@@ -17,11 +17,19 @@ export class UsersController {
|
|
|
17
17
|
// `Input<typeof listUsers>` has to be written out - a standard method decorator
|
|
18
18
|
// can check a parameter's type but cannot contextually type an unannotated one.
|
|
19
19
|
// Every field type still comes from the schema, so nothing is declared twice.
|
|
20
|
+
//
|
|
21
|
+
// The return type is checked too, against `listUsers.response[200]`: a handler
|
|
22
|
+
// that answers with a shape the document does not describe is a TS1241 here
|
|
23
|
+
// rather than a surprise for whoever read the document. `readonly User[]` is
|
|
24
|
+
// accepted against a schema inferring `User[]` - mutability does not survive
|
|
25
|
+
// serialisation.
|
|
20
26
|
@Get('/', listUsers)
|
|
21
27
|
list(input: Input<typeof listUsers>): Promise<readonly User[]> {
|
|
22
28
|
return this.users.findAll(input.query.limit, input.query.q);
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
// Only the success status is checked - the 404 in `oneUser.response` leaves via
|
|
32
|
+
// a thrown HttpError, which no return type can describe.
|
|
25
33
|
@Get('/:id', oneUser)
|
|
26
34
|
async one(input: Input<typeof oneUser>): Promise<User> {
|
|
27
35
|
// Already a number: the params schema coerced it before this ran.
|
|
@@ -3,21 +3,13 @@ import { eq, like, sql } from 'drizzle-orm';
|
|
|
3
3
|
import * as schema from '../database/schema.js';
|
|
4
4
|
import { users, type User } from '../database/schema.js';
|
|
5
5
|
|
|
6
|
-
// The row type comes from the table, so the controller and the service import one
|
|
7
|
-
// definition rather than a hand-written copy of the columns.
|
|
8
6
|
export type { User };
|
|
9
7
|
|
|
10
8
|
export class UsersRepository {
|
|
11
9
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* token - and ignores the type argument, so the schema types survive injection.
|
|
16
|
-
*
|
|
17
|
-
* Every method below is `async` although bun-sqlite executes synchronously: the
|
|
18
|
-
* HTTP layer awaits them, and moving this table to the pooled backend then costs
|
|
19
|
-
* no signature change. `Ledger.transferSync` is what refusing that trade looks
|
|
20
|
-
* like.
|
|
10
|
+
* Every method is `async` although bun-sqlite executes synchronously, so moving
|
|
11
|
+
* this table to the pooled backend costs no signature change.
|
|
12
|
+
* `Ledger.transferSync` is what refusing that trade looks like.
|
|
21
13
|
*/
|
|
22
14
|
constructor(private readonly db: SyncDatabase<typeof schema>) {}
|
|
23
15
|
|
|
@@ -28,7 +20,7 @@ export class UsersRepository {
|
|
|
28
20
|
)`);
|
|
29
21
|
}
|
|
30
22
|
|
|
31
|
-
/**
|
|
23
|
+
/** `name` is UNIQUE, so a repeat boot is a no-op. */
|
|
32
24
|
async seed(names: readonly string[]): Promise<void> {
|
|
33
25
|
this.db
|
|
34
26
|
.insert(users)
|
|
@@ -52,7 +44,6 @@ export class UsersRepository {
|
|
|
52
44
|
return this.db.select().from(users).where(eq(users.id, id)).get() ?? null;
|
|
53
45
|
}
|
|
54
46
|
|
|
55
|
-
/** `.returning()`, so the id is the one the database wrote. */
|
|
56
47
|
async create(name: string): Promise<User> {
|
|
57
48
|
return this.db.insert(users).values({ name }).returning().get();
|
|
58
49
|
}
|
|
@@ -2,28 +2,13 @@ import type { RouteSchemas } from '@dunx/http';
|
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* `@dunx/http` validates against - so nothing adapts anything, and the framework
|
|
8
|
-
* still depends on no validator.
|
|
5
|
+
* Plain zod in a route's options: `z.object()` already carries `~standard`, the
|
|
6
|
+
* whole contract `@dunx/http` validates against.
|
|
9
7
|
*
|
|
10
|
-
* `.meta({ id })` names the
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* **Prose goes in `description`, not `title`.** In JSON Schema `title` is a short
|
|
15
|
-
* display name, and Swagger UI labels a schema by it. A sentence there makes the
|
|
16
|
-
* whole Schemas list read as prose: `User` shows up as "A stored user" and is
|
|
17
|
-
* impossible to find.
|
|
18
|
-
*
|
|
19
|
-
* Leaving `title` out is right, and not because it is unused: `@dunx/openapi` fills
|
|
20
|
-
* it in with the component name when a schema is hoisted, which is what makes the
|
|
21
|
-
* item of `array<User>` read as `User` rather than as `object`.
|
|
22
|
-
*
|
|
23
|
-
* One more ordering trap: `.strict()` **after** `.meta()` discards the metadata, so
|
|
24
|
-
* the schema is inlined despite declaring an id. Put `.meta()` last.
|
|
25
|
-
*
|
|
26
|
-
* See `UsersDemo` for the generated document.
|
|
8
|
+
* Three traps. `.meta({ id })` names the `$defs` entry, and without it the schema
|
|
9
|
+
* is inlined at every use site. Prose goes in `description` - Swagger UI labels a
|
|
10
|
+
* schema by `title`, and `@dunx/openapi` fills that with the component name.
|
|
11
|
+
* `.strict()` after `.meta()` discards the metadata, so put `.meta()` last.
|
|
27
12
|
*/
|
|
28
13
|
export const Tag = z
|
|
29
14
|
.object({ label: z.string().min(1) })
|
|
@@ -49,15 +34,14 @@ export const ListUsers = z
|
|
|
49
34
|
.meta({ id: 'ListUsers', description: 'Filter and page the user list' });
|
|
50
35
|
|
|
51
36
|
/**
|
|
52
|
-
* The response side
|
|
53
|
-
*
|
|
54
|
-
*
|
|
37
|
+
* The response side, never validated at runtime: the verb decorator holds the
|
|
38
|
+
* handler's return type to it at compile time instead. That check caught this
|
|
39
|
+
* schema advertising a `tags: string[]` the `users` table has no column for.
|
|
55
40
|
*/
|
|
56
41
|
export const User = z
|
|
57
42
|
.object({
|
|
58
43
|
id: z.number().int(),
|
|
59
44
|
name: z.string(),
|
|
60
|
-
tags: z.array(z.string()),
|
|
61
45
|
})
|
|
62
46
|
.meta({ id: 'User', description: 'A stored user' });
|
|
63
47
|
|
|
@@ -65,8 +49,8 @@ export const NotFound = z
|
|
|
65
49
|
.object({ error: z.string(), status: z.literal(404) })
|
|
66
50
|
.meta({ id: 'NotFound', description: 'Nothing at that id' });
|
|
67
51
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
52
|
+
// A declared schema is what makes the matching `input` field exist and be
|
|
53
|
+
// parsed. `satisfies` keeps the literal types `Input<>` reads.
|
|
70
54
|
export const listUsers = {
|
|
71
55
|
query: ListUsers,
|
|
72
56
|
response: { 200: z.array(User) },
|
|
@@ -75,7 +59,6 @@ export const oneUser = {
|
|
|
75
59
|
params: UserIndex,
|
|
76
60
|
response: { 200: User, 404: NotFound },
|
|
77
61
|
} as const satisfies RouteSchemas;
|
|
78
|
-
// No status: POST defaults to 201, every other verb to 200.
|
|
79
62
|
export const createUser = {
|
|
80
63
|
body: CreateUser,
|
|
81
64
|
response: { 201: User },
|
|
@@ -2,11 +2,6 @@ import { Module } from '@dunx/core';
|
|
|
2
2
|
import { GreetingsController } from './greetings.controller.js';
|
|
3
3
|
import { GreetingsService } from './greetings.service.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The root module. `controllers` are discovered for routes, `providers` are
|
|
7
|
-
* everything else. Import order is construction order, and shutdown runs in
|
|
8
|
-
* reverse - which matters once there is a database to close.
|
|
9
|
-
*/
|
|
10
5
|
@Module({
|
|
11
6
|
controllers: [GreetingsController],
|
|
12
7
|
providers: [GreetingsService],
|
|
@@ -2,11 +2,6 @@ import { describe, expect, test } from 'bun:test';
|
|
|
2
2
|
import { createTestServer } from '@dunx/testing';
|
|
3
3
|
import { AppModule } from './app.module.js';
|
|
4
4
|
|
|
5
|
-
/**
|
|
6
|
-
* The whole app behind a real `Bun.serve` on port 0. This is also what CI runs to
|
|
7
|
-
* prove the example still boots - see `examples/testing` for overrides and the
|
|
8
|
-
* rest of `@dunx/testing`.
|
|
9
|
-
*/
|
|
10
5
|
describe('minimal', () => {
|
|
11
6
|
test('serves the greeting', async () => {
|
|
12
7
|
const server = await createTestServer({ modules: [AppModule] });
|