@dunx/create-app 2.3.1 → 2.5.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-yzz4z6jv.js → chunk-nn9ekg83.js} +110 -16
- package/dist/cli.js +1 -4
- package/dist/index.js +1 -4
- package/package.json +2 -2
- package/templates/features/assets/assets.demo.ts +41 -0
- package/templates/features/assets/assets.module.ts +37 -0
- package/templates/features/assets/public/app.a1b2c3d4.js +1 -0
- package/templates/features/assets/public/index.html +9 -0
- package/templates/features/assets/public/site.css +3 -0
- package/templates/features/auth/auth.module.ts +15 -4
- package/templates/features/cache/cache.controller.ts +5 -2
- package/templates/features/database/ledger.controller.ts +7 -4
- package/templates/features/guards/reports.controller.ts +11 -2
- package/templates/features/health/health.demo.ts +72 -0
- package/templates/features/health/health.module.ts +70 -9
- package/templates/features/health/indicators.ts +98 -0
- package/templates/features/http/compression.demo.ts +88 -0
- package/templates/features/http/http.demo.ts +6 -4
- package/templates/features/http/http.module.ts +31 -3
- package/templates/features/http/{request-log.ts → request-trail.ts} +10 -11
- 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 -2
- package/templates/features/jobs/thumbnail.jobs.ts +7 -1
- package/templates/features/notes/notes.controller.ts +1 -1
- package/templates/features/pictures/images.controller.ts +5 -2
- package/templates/features/schedule/maintenance.service.ts +71 -0
- package/templates/features/schedule/schedule.demo.ts +56 -0
- package/templates/features/schedule/schedule.module.ts +31 -0
- package/templates/features/storage/files.controller.ts +5 -4
- package/templates/features/throttle/limits.controller.ts +35 -0
- package/templates/features/throttle/throttle.demo.ts +74 -0
- package/templates/features/throttle/throttle.module.ts +88 -0
- package/templates/features/upstream/flaky.controller.ts +48 -0
- package/templates/features/upstream/upstream.demo.ts +83 -0
- package/templates/features/upstream/upstream.module.ts +42 -0
- package/templates/features/users/users.controller.ts +8 -0
- package/templates/features/users/users.schemas.ts +30 -11
- package/dist/chunk-yzz4z6jv.js.map +0 -12
- package/dist/cli.js.map +0 -10
- package/dist/index.js.map +0 -9
- package/templates/features/health/health.controller.ts +0 -65
|
@@ -82,6 +82,37 @@ var CONFIG_GROUPS = Object.freeze({
|
|
|
82
82
|
field: "readonly authorization: { readonly enabled: boolean };",
|
|
83
83
|
map: "authorization: { enabled: true },",
|
|
84
84
|
env: []
|
|
85
|
+
},
|
|
86
|
+
throttle: {
|
|
87
|
+
schema: [
|
|
88
|
+
"/** The app-wide limit. Generous, so a per-route `@Throttle` is the interesting half. */",
|
|
89
|
+
"THROTTLE_LIMIT: z.coerce.number().int().min(1).default(1000),",
|
|
90
|
+
"THROTTLE_WINDOW_SECONDS: z.coerce.number().int().min(1).default(60),"
|
|
91
|
+
],
|
|
92
|
+
field: "readonly throttle: { readonly limit: number; readonly windowSeconds: number };",
|
|
93
|
+
map: "throttle: { limit: value.THROTTLE_LIMIT, windowSeconds: value.THROTTLE_WINDOW_SECONDS },",
|
|
94
|
+
env: [
|
|
95
|
+
{ name: "THROTTLE_LIMIT", value: "1000" },
|
|
96
|
+
{ name: "THROTTLE_WINDOW_SECONDS", value: "60" }
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
schedule: {
|
|
100
|
+
schema: [
|
|
101
|
+
"/** A `@Cron` that names no zone of its own runs in this one. */",
|
|
102
|
+
"SCHEDULE_TZ: z.string().default('UTC'),"
|
|
103
|
+
],
|
|
104
|
+
field: "readonly schedule: { readonly tz: string };",
|
|
105
|
+
map: "schedule: { tz: value.SCHEDULE_TZ },",
|
|
106
|
+
env: [{ name: "SCHEDULE_TZ", value: "UTC" }]
|
|
107
|
+
},
|
|
108
|
+
upstream: {
|
|
109
|
+
schema: [
|
|
110
|
+
"/** Per-call budget for the outbound client. */",
|
|
111
|
+
"UPSTREAM_TIMEOUT_MS: z.coerce.number().int().min(1).default(5000),"
|
|
112
|
+
],
|
|
113
|
+
field: "readonly upstream: { readonly timeoutMs: number };",
|
|
114
|
+
map: "upstream: { timeoutMs: value.UPSTREAM_TIMEOUT_MS },",
|
|
115
|
+
env: [{ name: "UPSTREAM_TIMEOUT_MS", value: "5000" }]
|
|
85
116
|
}
|
|
86
117
|
});
|
|
87
118
|
var BASE_CONFIG = ["appName", "port", "log"];
|
|
@@ -101,13 +132,13 @@ var FEATURES = [
|
|
|
101
132
|
summary: "OpenAPI 3.1 from the routes own schemas, plus the Swagger UI page.",
|
|
102
133
|
requires: [],
|
|
103
134
|
module: { klass: "DocsModule", from: "./docs/docs.module.js" },
|
|
104
|
-
dependencies: ["@dunx/openapi", "
|
|
135
|
+
dependencies: ["@dunx/openapi", "zod"],
|
|
105
136
|
config: []
|
|
106
137
|
},
|
|
107
138
|
{
|
|
108
139
|
name: "http",
|
|
109
140
|
source: "http",
|
|
110
|
-
summary: "CORS, a
|
|
141
|
+
summary: "CORS, a middleware of your own on the response, and error mapping.",
|
|
111
142
|
requires: [],
|
|
112
143
|
module: { klass: "HttpModule", from: "./http/http.module.js" },
|
|
113
144
|
dependencies: [],
|
|
@@ -200,11 +231,51 @@ var FEATURES = [
|
|
|
200
231
|
{
|
|
201
232
|
name: "health",
|
|
202
233
|
source: "health",
|
|
203
|
-
summary: "
|
|
234
|
+
summary: "`HealthModule`'s liveness and readiness probes, wired to this app's own indicators.",
|
|
204
235
|
requires: ["cache", "database", "files"],
|
|
205
|
-
module: { klass: "
|
|
236
|
+
module: { klass: "ProbesModule", from: "./health/health.module.js" },
|
|
206
237
|
dependencies: ["@dunx/infra"],
|
|
207
238
|
config: ["appName"]
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "throttle",
|
|
242
|
+
source: "throttle",
|
|
243
|
+
summary: "A fixed-window rate limit, with the counter in Redis and per-route overrides.",
|
|
244
|
+
requires: ["cache"],
|
|
245
|
+
module: { klass: "LimitsModule", from: "./throttle/throttle.module.js" },
|
|
246
|
+
dependencies: ["@dunx/infra"],
|
|
247
|
+
config: ["appName", "throttle"],
|
|
248
|
+
service: "Redis or Valkey"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: "schedule",
|
|
252
|
+
source: "schedule",
|
|
253
|
+
summary: "@Cron, @Interval and @OnceOnBoot on Bun.cron, armed at boot and triggerable.",
|
|
254
|
+
requires: [],
|
|
255
|
+
module: {
|
|
256
|
+
klass: "MaintenanceModule",
|
|
257
|
+
from: "./schedule/schedule.module.js"
|
|
258
|
+
},
|
|
259
|
+
dependencies: ["@dunx/infra"],
|
|
260
|
+
config: ["schedule"]
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: "assets",
|
|
264
|
+
source: "assets",
|
|
265
|
+
summary: "A static directory on Bun.file, with a short max-age and an immutable rule.",
|
|
266
|
+
requires: [],
|
|
267
|
+
module: { klass: "AssetsModule", from: "./assets/assets.module.js" },
|
|
268
|
+
dependencies: [],
|
|
269
|
+
config: []
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "client",
|
|
273
|
+
source: "upstream",
|
|
274
|
+
summary: "The outbound half of @dunx/http: retry, backoff and a typed FetchError.",
|
|
275
|
+
requires: [],
|
|
276
|
+
module: { klass: "UpstreamModule", from: "./upstream/upstream.module.js" },
|
|
277
|
+
dependencies: [],
|
|
278
|
+
config: ["appName", "upstream"]
|
|
208
279
|
}
|
|
209
280
|
];
|
|
210
281
|
var featureNames = FEATURES.map((feature) => feature.name);
|
|
@@ -289,16 +360,15 @@ var manifest = (features) => {
|
|
|
289
360
|
dependencies,
|
|
290
361
|
devDependencies: {
|
|
291
362
|
"@dunx/testing": "__DUNX_VERSION__",
|
|
292
|
-
"@types/bun": ">=1.
|
|
363
|
+
"@types/bun": ">=1.4.0",
|
|
293
364
|
typescript: "^5.7.0"
|
|
294
365
|
},
|
|
295
|
-
engines: { bun: ">=1.
|
|
366
|
+
engines: { bun: ">=1.4.0" }
|
|
296
367
|
}, null, 2)}
|
|
297
368
|
`;
|
|
298
369
|
};
|
|
299
370
|
var THIRD_PARTY = Object.freeze({
|
|
300
371
|
zod: "^4.4.3",
|
|
301
|
-
"swagger-ui-dist": "^5.32.14",
|
|
302
372
|
"drizzle-orm": "^0.45.2",
|
|
303
373
|
"better-auth": "^1.6.25",
|
|
304
374
|
bullmq: "^6.0.5",
|
|
@@ -307,7 +377,9 @@ var THIRD_PARTY = Object.freeze({
|
|
|
307
377
|
var versionOf = (dep) => THIRD_PARTY[dep] ?? "latest";
|
|
308
378
|
var appModule = (name, features) => {
|
|
309
379
|
const needsLogger = true;
|
|
380
|
+
const documentsAuth = has(features, "openapi") && has(features, "auth");
|
|
310
381
|
const imports = [
|
|
382
|
+
...documentsAuth ? ["import { Auth } from '@dunx/auth';"] : [],
|
|
311
383
|
"import { ConfigModule, Module } from '@dunx/core';",
|
|
312
384
|
...needsLogger ? ["import { LoggerModule } from '@dunx/infra/logger';"] : [],
|
|
313
385
|
"import { AppConfigService, validate } from './config.js';",
|
|
@@ -341,7 +413,10 @@ var appModule = (name, features) => {
|
|
|
341
413
|
imports: [
|
|
342
414
|
${moduleImports.map((line) => ` ${line}`).join(`
|
|
343
415
|
`)}
|
|
344
|
-
]
|
|
416
|
+
],${documentsAuth ? `
|
|
417
|
+
// Better Auth serves its own routes, so the document is the only place they
|
|
418
|
+
// appear - and \`betterAuthDocument\` needs the instance.
|
|
419
|
+
exports: [Auth],` : ""}
|
|
345
420
|
})
|
|
346
421
|
export class AppModule {}
|
|
347
422
|
`;
|
|
@@ -406,17 +481,37 @@ var bootstrap = (name, features) => {
|
|
|
406
481
|
const openapi = has(features, "openapi");
|
|
407
482
|
const websockets = has(features, "websockets");
|
|
408
483
|
const http = has(features, "http");
|
|
484
|
+
const documentsAuth = openapi && has(features, "auth");
|
|
485
|
+
const assets = has(features, "assets");
|
|
486
|
+
const throttle = has(features, "throttle");
|
|
409
487
|
const imports = [
|
|
410
|
-
|
|
488
|
+
...documentsAuth ? ["import { Auth, betterAuthDocument } from '@dunx/auth';"] : [],
|
|
489
|
+
`import { ${[
|
|
490
|
+
"HttpFactory",
|
|
491
|
+
...websockets ? ["RedisRelay"] : [],
|
|
492
|
+
...assets ? ["StaticFiles"] : [],
|
|
493
|
+
...throttle ? ["ThrottleGuard"] : [],
|
|
494
|
+
"type HttpApp"
|
|
495
|
+
].join(", ")} } from '@dunx/http';`,
|
|
411
496
|
...openapi ? ["import { OpenApiModule } from '@dunx/openapi';"] : [],
|
|
412
497
|
"import { AppModule } from './app.module.js';",
|
|
413
498
|
`import { ${[
|
|
414
499
|
...http ? ["AppConfigService"] : [],
|
|
415
500
|
...websockets ? ["RELAY_CHANNEL"] : []
|
|
416
501
|
].join(", ")} } from './config.js';`,
|
|
417
|
-
...http ? ["import {
|
|
502
|
+
...http ? ["import { RequestTrailMiddleware } from './http/request-trail.js';"] : []
|
|
418
503
|
].filter((line) => !line.includes("{ }"));
|
|
419
|
-
const root =
|
|
504
|
+
const root = documentsAuth ? `OpenApiModule.forRootAsync({
|
|
505
|
+
root: AppModule,
|
|
506
|
+
inject: [Auth] as const,
|
|
507
|
+
useFactory: (auth: Auth) => ({
|
|
508
|
+
title: '__DUNX_APP_NAME__',
|
|
509
|
+
version: '0.1.0',
|
|
510
|
+
contribute: [
|
|
511
|
+
betterAuthDocument(auth, { basePath: '/api/auth', tag: 'Auth' }),
|
|
512
|
+
],
|
|
513
|
+
}),
|
|
514
|
+
})` : openapi ? `OpenApiModule.forRoot({
|
|
420
515
|
title: '__DUNX_APP_NAME__',
|
|
421
516
|
version: '0.1.0',
|
|
422
517
|
root: AppModule,
|
|
@@ -431,15 +526,17 @@ var bootstrap = (name, features) => {
|
|
|
431
526
|
] : [];
|
|
432
527
|
const shaping = [
|
|
433
528
|
"app.setGlobalPrefix('api');",
|
|
529
|
+
...assets ? ["app.use(StaticFiles);"] : [],
|
|
434
530
|
...http ? [
|
|
435
|
-
"app.use(
|
|
531
|
+
"app.use(RequestTrailMiddleware);",
|
|
436
532
|
"app.set('trust proxy', true);",
|
|
437
533
|
"app.enableCors({",
|
|
438
534
|
" origin: app.get(AppConfigService).get('corsOrigin'),",
|
|
439
535
|
" credentials: true,",
|
|
440
536
|
" maxAge: 600,",
|
|
441
537
|
"});"
|
|
442
|
-
] : []
|
|
538
|
+
] : [],
|
|
539
|
+
...throttle ? ["app.use(ThrottleGuard);"] : []
|
|
443
540
|
];
|
|
444
541
|
return `${HEADER(name)}${imports.join(`
|
|
445
542
|
`)}
|
|
@@ -694,6 +791,3 @@ var scaffold = async (options) => {
|
|
|
694
791
|
};
|
|
695
792
|
|
|
696
793
|
export { FEATURES, featureNames, impliedBy, TEMPLATES, VERSION_PLACEHOLDER, ScaffoldError, scaffold };
|
|
697
|
-
|
|
698
|
-
//# debugId=BC087157153A09C764756E2164756E21
|
|
699
|
-
//# sourceMappingURL=chunk-yzz4z6jv.js.map
|
package/dist/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
featureNames,
|
|
8
8
|
impliedBy,
|
|
9
9
|
scaffold
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-nn9ekg83.js";
|
|
11
11
|
|
|
12
12
|
// src/cli.ts
|
|
13
13
|
import { parseArgs } from "util";
|
|
@@ -129,6 +129,3 @@ try {
|
|
|
129
129
|
fail(error.message);
|
|
130
130
|
throw error;
|
|
131
131
|
}
|
|
132
|
-
|
|
133
|
-
//# debugId=01853E264154F96764756E2164756E21
|
|
134
|
-
//# sourceMappingURL=cli.js.map
|
package/dist/index.js
CHANGED
|
@@ -4,13 +4,10 @@ import {
|
|
|
4
4
|
TEMPLATES,
|
|
5
5
|
VERSION_PLACEHOLDER,
|
|
6
6
|
scaffold
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-nn9ekg83.js";
|
|
8
8
|
export {
|
|
9
9
|
ScaffoldError,
|
|
10
10
|
TEMPLATES,
|
|
11
11
|
VERSION_PLACEHOLDER,
|
|
12
12
|
scaffold
|
|
13
13
|
};
|
|
14
|
-
|
|
15
|
-
//# debugId=2DFAD801C180F0A064756E2164756E21
|
|
16
|
-
//# sourceMappingURL=index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dunx/create-app",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Scaffold a new dunx application - bunx @dunx/create-app my-api",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -58,6 +58,6 @@
|
|
|
58
58
|
}
|
|
59
59
|
},
|
|
60
60
|
"engines": {
|
|
61
|
-
"bun": ">=1.
|
|
61
|
+
"bun": ">=1.4.0"
|
|
62
62
|
}
|
|
63
63
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The two cache policies, and the traversal refusal.
|
|
5
|
+
*
|
|
6
|
+
* There is no `index.html` fallback and no SPA rewrite here, because `StaticFiles`
|
|
7
|
+
* ships neither: building one in would mean the middleware deciding what a 404
|
|
8
|
+
* means for paths it does not own.
|
|
9
|
+
*/
|
|
10
|
+
export class AssetsDemo {
|
|
11
|
+
constructor(private readonly logger: Logger) {}
|
|
12
|
+
|
|
13
|
+
async demonstrate(url: string): Promise<void> {
|
|
14
|
+
for (const path of ['assets/site.css', 'assets/app.a1b2c3d4.js']) {
|
|
15
|
+
const response = await fetch(new URL(path, url));
|
|
16
|
+
this.logger.info(
|
|
17
|
+
`GET /${path} -> ${response.status} ` +
|
|
18
|
+
`${response.headers.get('content-type')}, ` +
|
|
19
|
+
`cache-control: ${response.headers.get('cache-control')}`,
|
|
20
|
+
);
|
|
21
|
+
await response.arrayBuffer();
|
|
22
|
+
}
|
|
23
|
+
this.logger.info(
|
|
24
|
+
'the hashed name is immutable, the plain one is max-age=60 - a content ' +
|
|
25
|
+
'hash is the only honest reason to promise forever',
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// Resolved against the root at construction, and checked on every request.
|
|
29
|
+
const escaped = await fetch(new URL('assets/../../package.json', url));
|
|
30
|
+
this.logger.info(
|
|
31
|
+
`GET /assets/../../package.json -> ${escaped.status} (never leaves the root)`,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Anything outside the mount falls straight through, so the app's own routes
|
|
35
|
+
// and its 404 behave exactly as they did before this was registered.
|
|
36
|
+
const through = await fetch(new URL('api/notes', url));
|
|
37
|
+
this.logger.info(
|
|
38
|
+
`GET /api/notes -> ${through.status} (outside /assets, untouched)`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Module } from '@dunx/core';
|
|
2
|
+
import { StaticModule } from '@dunx/http';
|
|
3
|
+
import { AssetsDemo } from './assets.demo.js';
|
|
4
|
+
|
|
5
|
+
/** A content hash, so a change produces a different URL. */
|
|
6
|
+
const HASHED = /\.[0-9a-f]{8}\.(js|css)$/;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `public/` directory next to this file, served at `/assets`.
|
|
10
|
+
*
|
|
11
|
+
* `StaticModule` binds `StaticFiles`; the **app** registers it, in `bootstrap.ts`.
|
|
12
|
+
* Position in the chain is the app's decision and no default can make it: assets
|
|
13
|
+
* usually want to be outside an auth guard and inside request logging.
|
|
14
|
+
*
|
|
15
|
+
* The mount is outside `setGlobalPrefix('api')`, because middleware is not a
|
|
16
|
+
* discovered route and never gets the prefix.
|
|
17
|
+
*/
|
|
18
|
+
@Module({
|
|
19
|
+
imports: [
|
|
20
|
+
StaticModule.forRoot({
|
|
21
|
+
// Inside the feature folder rather than at the app root, so the folder is
|
|
22
|
+
// self-contained: `@dunx/create-app` vendors this directory wholesale, and
|
|
23
|
+
// an asset kept outside it would need machinery to travel with it.
|
|
24
|
+
root: new URL('./public', import.meta.url).pathname,
|
|
25
|
+
path: '/assets',
|
|
26
|
+
// Short, because a long max-age on a name that can change is a promise the
|
|
27
|
+
// server cannot keep.
|
|
28
|
+
maxAge: 60,
|
|
29
|
+
// Only honest for a content-addressed name. Guessing wrong here is a stale
|
|
30
|
+
// asset nobody can flush, which is why the default claims nothing.
|
|
31
|
+
immutable: (pathname) => HASHED.test(pathname),
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
providers: [AssetsDemo],
|
|
35
|
+
exports: [AssetsDemo],
|
|
36
|
+
})
|
|
37
|
+
export class AssetsModule {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
globalThis.dunxExample = 'content-addressed, so cacheable forever';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<main>
|
|
2
|
+
<h1>dunx static assets</h1>
|
|
3
|
+
<p>
|
|
4
|
+
Served by <code>StaticFiles</code> from <code>examples/full/public</code>,
|
|
5
|
+
on <code>Bun.file</code> - which streams, sets the content type, and answers
|
|
6
|
+
a Range request with sendfile(2).
|
|
7
|
+
</p>
|
|
8
|
+
<script src="/assets/app.a1b2c3d4.js"></script>
|
|
9
|
+
</main>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { AuthModule, bunPassword } from '@dunx/auth';
|
|
1
|
+
import { Auth, AuthModule, bunPassword } from '@dunx/auth';
|
|
2
2
|
import { drizzleDatabase } from '@dunx/auth/drizzle';
|
|
3
3
|
import { Module } from '@dunx/core';
|
|
4
4
|
import { DbConnection } from '@dunx/infra/db';
|
|
5
|
-
import { admin, bearer } from 'better-auth/plugins';
|
|
5
|
+
import { admin, bearer, openAPI } from 'better-auth/plugins';
|
|
6
6
|
import { AppConfigService } from '../config.js';
|
|
7
7
|
import { DatabaseModule } from '../database/database.module.js';
|
|
8
8
|
import { AuthDemo } from './auth.demo.js';
|
|
@@ -46,7 +46,15 @@ import { ProfileController } from './profile.controller.js';
|
|
|
46
46
|
// `admin` puts `role` on the user, which `@Roles()` then reads. `bearer`
|
|
47
47
|
// lets a non-browser client send `Authorization: Bearer <token>` instead of
|
|
48
48
|
// a cookie - which is what the tour does.
|
|
49
|
-
|
|
49
|
+
// `openAPI()` is what makes `generateOpenAPISchema` exist, and
|
|
50
|
+
// `betterAuthDocument` in bootstrap.ts is what puts its paths in the app's
|
|
51
|
+
// document. `disableDefaultReference` because dunx already serves an
|
|
52
|
+
// explorer at /api/docs and two reference pages is one too many.
|
|
53
|
+
plugins: [
|
|
54
|
+
admin(),
|
|
55
|
+
bearer(),
|
|
56
|
+
openAPI({ disableDefaultReference: true }),
|
|
57
|
+
],
|
|
50
58
|
}),
|
|
51
59
|
inject: [AppConfigService, DbConnection] as const,
|
|
52
60
|
},
|
|
@@ -59,6 +67,9 @@ import { ProfileController } from './profile.controller.js';
|
|
|
59
67
|
providers: [AuthTables, Audit, AuthDemo],
|
|
60
68
|
// `AuthTables` creates them on the app's own handle, so this module needs the
|
|
61
69
|
// drizzle handle as well as the connection the factory above used.
|
|
62
|
-
|
|
70
|
+
//
|
|
71
|
+
// `Auth` comes back out because `OpenApiModule` wraps the root and can only
|
|
72
|
+
// inject what the root exports - see bootstrap.ts.
|
|
73
|
+
exports: [Audit, AuthDemo, Auth],
|
|
63
74
|
})
|
|
64
75
|
export class AccountsModule {}
|
|
@@ -12,14 +12,17 @@ import { Sessions } from './sessions.service.js';
|
|
|
12
12
|
|
|
13
13
|
const SessionKey = z
|
|
14
14
|
.object({ id: z.string().min(1).max(80) })
|
|
15
|
-
.meta({ id: 'SessionKey',
|
|
15
|
+
.meta({ id: 'SessionKey', description: 'A session id' });
|
|
16
16
|
|
|
17
17
|
const StoreSession = z
|
|
18
18
|
.object({
|
|
19
19
|
data: z.record(z.string(), z.unknown()),
|
|
20
20
|
ttl: z.coerce.number().int().min(1).max(3600).default(60),
|
|
21
21
|
})
|
|
22
|
-
.meta({
|
|
22
|
+
.meta({
|
|
23
|
+
id: 'StoreSession',
|
|
24
|
+
description: 'Session payload and its lifetime',
|
|
25
|
+
});
|
|
23
26
|
|
|
24
27
|
const oneSession = { params: SessionKey } as const;
|
|
25
28
|
const putSession = { params: SessionKey, body: StoreSession } as const;
|
|
@@ -14,14 +14,14 @@ import type { Entry } from './schema.js';
|
|
|
14
14
|
|
|
15
15
|
const EntryIndex = z
|
|
16
16
|
.object({ id: z.coerce.number().int().min(1) })
|
|
17
|
-
.meta({ id: 'EntryIndex',
|
|
17
|
+
.meta({ id: 'EntryIndex', description: 'A ledger entry id in the path' });
|
|
18
18
|
|
|
19
19
|
const CreateEntry = z
|
|
20
20
|
.object({
|
|
21
21
|
memo: z.string().min(1).max(80),
|
|
22
22
|
amount: z.number().int(),
|
|
23
23
|
})
|
|
24
|
-
.meta({ id: 'CreateEntry',
|
|
24
|
+
.meta({ id: 'CreateEntry', description: 'A single ledger movement' });
|
|
25
25
|
|
|
26
26
|
/** Both legs succeed or neither does - the rollback is the point of the route. */
|
|
27
27
|
const Transfer = z
|
|
@@ -36,7 +36,7 @@ const Transfer = z
|
|
|
36
36
|
*/
|
|
37
37
|
fail: z.boolean().default(false),
|
|
38
38
|
})
|
|
39
|
-
.meta({ id: 'Transfer',
|
|
39
|
+
.meta({ id: 'Transfer', description: 'Move an amount between two memos' });
|
|
40
40
|
|
|
41
41
|
const listEntries = {
|
|
42
42
|
query: z.object({
|
|
@@ -68,7 +68,10 @@ const pageQuery = z
|
|
|
68
68
|
.optional()
|
|
69
69
|
.describe('Opaque cursor from meta.nextCursor. Omit for the first page.'),
|
|
70
70
|
})
|
|
71
|
-
.meta({
|
|
71
|
+
.meta({
|
|
72
|
+
id: 'LedgerPageQuery',
|
|
73
|
+
description: 'Keyset pagination over the ledger',
|
|
74
|
+
});
|
|
72
75
|
|
|
73
76
|
const pagedEntries = { query: pageQuery } as const;
|
|
74
77
|
const oneEntry = { params: EntryIndex } as const;
|
|
@@ -12,12 +12,21 @@ import { z } from 'zod';
|
|
|
12
12
|
import { AuthGuard, RolesGuard } from './auth.guard.js';
|
|
13
13
|
import { ReportsService } from './reports.service.js';
|
|
14
14
|
|
|
15
|
+
const CreateReport = z
|
|
16
|
+
.object({ title: z.string().min(1) })
|
|
17
|
+
.meta({ id: 'CreateReport', description: 'A report to file' });
|
|
18
|
+
|
|
19
|
+
const RenameReport = z.object({ title: z.string().min(1) }).meta({
|
|
20
|
+
id: 'RenameReport',
|
|
21
|
+
description: 'A new title for an existing report',
|
|
22
|
+
});
|
|
23
|
+
|
|
15
24
|
const renameReport = {
|
|
16
25
|
params: z.object({ id: z.coerce.number().int() }),
|
|
17
|
-
body:
|
|
26
|
+
body: RenameReport,
|
|
18
27
|
} as const;
|
|
19
28
|
|
|
20
|
-
const createReport = { body:
|
|
29
|
+
const createReport = { body: CreateReport } as const;
|
|
21
30
|
|
|
22
31
|
// `@UseGuards(AuthGuard)` at class scope rather than as global middleware: every
|
|
23
32
|
// other route in this app is meant to be reachable without credentials, and a
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import { Readiness, type HealthReport } from '@dunx/http';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The two probes an orchestrator reads, and the one thing about them that is worth
|
|
6
|
+
* demonstrating rather than describing: `Readiness.hold()` takes the pod out of
|
|
7
|
+
* rotation while liveness keeps passing, so a migration sheds traffic without
|
|
8
|
+
* inviting a restart.
|
|
9
|
+
*/
|
|
10
|
+
export class HealthDemo {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly logger: Logger,
|
|
13
|
+
private readonly readiness: Readiness,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async demonstrate(url: string): Promise<void> {
|
|
17
|
+
const get = async (
|
|
18
|
+
path: string,
|
|
19
|
+
): Promise<{ status: number; body: HealthReport }> => {
|
|
20
|
+
const response = await fetch(new URL(`api/health/${path}`, url));
|
|
21
|
+
return {
|
|
22
|
+
status: response.status,
|
|
23
|
+
body: (await response.json()) as HealthReport,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const live = await get('live');
|
|
28
|
+
this.logger.info(
|
|
29
|
+
`GET /api/health/live -> ${live.status} ${live.body.status}, ` +
|
|
30
|
+
`${this.describe(live.body)}`,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const ready = await get('ready');
|
|
34
|
+
this.logger.info(
|
|
35
|
+
`GET /api/health/ready -> ${ready.status} ${ready.body.status}, ` +
|
|
36
|
+
`${this.describe(ready.body)}`,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// A non-critical check that is down does not shed traffic, which is the whole
|
|
40
|
+
// reason `critical` exists. With no Redis running, this is that case observed.
|
|
41
|
+
const soft = ready.body.checks.filter(
|
|
42
|
+
(check) => !check.critical && check.state !== 'up',
|
|
43
|
+
);
|
|
44
|
+
this.logger.info(
|
|
45
|
+
soft.length === 0
|
|
46
|
+
? 'every check up, so critical and non-critical read the same today'
|
|
47
|
+
: `non-critical and down: ${soft.map((c) => c.name).join(', ')} - ` +
|
|
48
|
+
`readiness is still ${ready.body.status}`,
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
// Taking the pod out by hand, the way a migration would.
|
|
52
|
+
this.readiness.hold('migrating');
|
|
53
|
+
const held = await get('ready');
|
|
54
|
+
const heldLive = await get('live');
|
|
55
|
+
this.logger.info(
|
|
56
|
+
`readiness.hold("migrating") -> ready ${held.status} ` +
|
|
57
|
+
`${held.body.checks[0]?.detail ?? ''}, live still ${heldLive.status}`,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
this.readiness.release();
|
|
61
|
+
this.logger.info(
|
|
62
|
+
`readiness.release() -> ready ${(await get('ready')).status}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private describe(report: HealthReport): string {
|
|
67
|
+
const checks = report.checks
|
|
68
|
+
.map((check) => `${check.name}=${check.state}`)
|
|
69
|
+
.join(' ');
|
|
70
|
+
return `${report.uptimeMs} ms up, ${checks || 'no checks'}`;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -1,14 +1,75 @@
|
|
|
1
|
-
import { Module } from '@dunx/core';
|
|
1
|
+
import { Module, provide } from '@dunx/core';
|
|
2
|
+
import { HealthModule } from '@dunx/http';
|
|
3
|
+
import { DbConnection } from '@dunx/infra/db';
|
|
4
|
+
import { RedisConnection } from '@dunx/infra/redis';
|
|
2
5
|
import { CacheModule } from '../cache/cache.module.js';
|
|
3
6
|
import { DatabaseModule } from '../database/database.module.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
7
|
+
import { Ledger } from '../database/ledger.service.js';
|
|
8
|
+
import { WorkspaceModule } from '../storage/storage.module.js';
|
|
9
|
+
import { Workspace } from '../storage/workspace.js';
|
|
10
|
+
import { HealthDemo } from './health.demo.js';
|
|
11
|
+
import { AppIndicators } from './indicators.js';
|
|
6
12
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The indicators, in a module of their own for the reason `WorkspaceModule` is:
|
|
15
|
+
* `HealthModule.forRootAsync` registers its provider in its own scope, so a factory
|
|
16
|
+
* injecting `AppIndicators` has to name the module it comes from - and pointing that
|
|
17
|
+
* back at `ProbesModule` would be a cycle.
|
|
18
|
+
*
|
|
19
|
+
* A health check is still the feature that imports the most, so this list is an
|
|
20
|
+
* accurate statement of what it touches.
|
|
21
|
+
*/
|
|
10
22
|
@Module({
|
|
11
|
-
imports: [DatabaseModule, CacheModule,
|
|
12
|
-
|
|
23
|
+
imports: [DatabaseModule, CacheModule, WorkspaceModule],
|
|
24
|
+
providers: [
|
|
25
|
+
provide(AppIndicators, {
|
|
26
|
+
// Async because the upload root is: `Workspace.create()` is idempotent, so
|
|
27
|
+
// this is the directory `FilesModule` already made rather than a second one.
|
|
28
|
+
useFactory: async (
|
|
29
|
+
db: DbConnection,
|
|
30
|
+
redis: RedisConnection,
|
|
31
|
+
ledger: Ledger,
|
|
32
|
+
workspace: Workspace,
|
|
33
|
+
) =>
|
|
34
|
+
new AppIndicators({
|
|
35
|
+
db,
|
|
36
|
+
redis,
|
|
37
|
+
ledger,
|
|
38
|
+
uploadRoot: await workspace.create(),
|
|
39
|
+
}),
|
|
40
|
+
inject: [DbConnection, RedisConnection, Ledger, Workspace] as const,
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
exports: [AppIndicators],
|
|
13
44
|
})
|
|
14
|
-
export class
|
|
45
|
+
export class IndicatorsModule {}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* `HealthModule` from `@dunx/http`, which mounts `/api/health/live` and
|
|
49
|
+
* `/api/health/ready`. Both are `@Public()` and hidden from the OpenAPI document:
|
|
50
|
+
* a probe carries no credentials and is not an API a consumer calls.
|
|
51
|
+
*
|
|
52
|
+
* There is no indicator for `@dunx/infra/files` or `@dunx/infra/images`. Both are
|
|
53
|
+
* in-process, so "it booted" is already answered by the port answering at all, and
|
|
54
|
+
* a check that cannot fail tells an operator nothing.
|
|
55
|
+
*/
|
|
56
|
+
@Module({
|
|
57
|
+
imports: [
|
|
58
|
+
IndicatorsModule,
|
|
59
|
+
HealthModule.forRootAsync({
|
|
60
|
+
imports: [IndicatorsModule],
|
|
61
|
+
useFactory: (indicators: AppIndicators) => ({
|
|
62
|
+
readiness: indicators.readiness,
|
|
63
|
+
liveness: indicators.liveness,
|
|
64
|
+
// A real deployment sets a few probe intervals here, so a load balancer
|
|
65
|
+
// sees readiness fail before the socket closes. Short enough that
|
|
66
|
+
// `bun run tour` and the suites are not waiting on it.
|
|
67
|
+
drainDelayMs: 250,
|
|
68
|
+
}),
|
|
69
|
+
inject: [AppIndicators] as const,
|
|
70
|
+
}),
|
|
71
|
+
],
|
|
72
|
+
providers: [HealthDemo],
|
|
73
|
+
exports: [AppIndicators, HealthDemo],
|
|
74
|
+
})
|
|
75
|
+
export class ProbesModule {}
|