@dunx/create-app 2.3.0 → 2.4.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-mpa5nv1v.js} +110 -13
- package/dist/chunk-mpa5nv1v.js.map +12 -0
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- 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/http.demo.ts +6 -4
- package/templates/features/http/http.module.ts +3 -3
- package/templates/features/http/{request-log.ts → request-trail.ts} +10 -11
- 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.schemas.ts +22 -8
- package/dist/chunk-yzz4z6jv.js.map +0 -12
- package/templates/features/health/health.controller.ts +0 -65
|
@@ -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 {}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DatabaseIndicator,
|
|
3
|
+
DiskIndicator,
|
|
4
|
+
DiskOptions,
|
|
5
|
+
HealthIndicator,
|
|
6
|
+
MemoryIndicator,
|
|
7
|
+
MemoryOptions,
|
|
8
|
+
RedisIndicator,
|
|
9
|
+
type ProbeResult,
|
|
10
|
+
} from '@dunx/http';
|
|
11
|
+
import type { DbConnection } from '@dunx/infra/db';
|
|
12
|
+
import type { RedisConnection } from '@dunx/infra/redis';
|
|
13
|
+
import { Ledger } from '../database/ledger.service.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The custom-indicator path: an app-specific query rather than a round trip.
|
|
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
|
+
*/
|
|
21
|
+
export class LedgerIndicator extends HealthIndicator {
|
|
22
|
+
readonly name = 'ledger';
|
|
23
|
+
|
|
24
|
+
constructor(private readonly ledger: Ledger) {
|
|
25
|
+
super();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
check(): ProbeResult {
|
|
29
|
+
const rows = this.ledger.rows();
|
|
30
|
+
return rows > 0
|
|
31
|
+
? {
|
|
32
|
+
state: 'up',
|
|
33
|
+
detail: `${rows} rows, balance ${this.ledger.balance()}`,
|
|
34
|
+
}
|
|
35
|
+
: { state: 'down', detail: 'no rows - the seeds did not run' };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `RedisIndicator` with the criticality flipped, because this app treats a missing
|
|
41
|
+
* cache as degraded rather than fatal - the same promise `CacheModule` makes with
|
|
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.
|
|
46
|
+
*/
|
|
47
|
+
export class CacheIndicator extends RedisIndicator {
|
|
48
|
+
override readonly critical = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AppIndicatorsInit {
|
|
52
|
+
readonly db: DbConnection;
|
|
53
|
+
readonly redis: RedisConnection;
|
|
54
|
+
readonly ledger: Ledger;
|
|
55
|
+
/** Where uploads land, so a full disk here is a real failure. */
|
|
56
|
+
readonly uploadRoot: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The one declaration of what this service probes.
|
|
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.
|
|
66
|
+
*/
|
|
67
|
+
export class AppIndicators {
|
|
68
|
+
readonly readiness: readonly HealthIndicator[];
|
|
69
|
+
readonly liveness: readonly HealthIndicator[];
|
|
70
|
+
/**
|
|
71
|
+
* The readiness list minus what the dashboard already sources itself:
|
|
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
|
+
*/
|
|
75
|
+
readonly dashboardProbes: readonly HealthIndicator[];
|
|
76
|
+
|
|
77
|
+
constructor(init: AppIndicatorsInit) {
|
|
78
|
+
this.readiness = [
|
|
79
|
+
new DatabaseIndicator(init.db),
|
|
80
|
+
new LedgerIndicator(init.ledger),
|
|
81
|
+
new CacheIndicator(init.redis),
|
|
82
|
+
// Non-critical, because no other pod's disk is any emptier.
|
|
83
|
+
new DiskIndicator(
|
|
84
|
+
new DiskOptions({ path: init.uploadRoot, maxUsedFraction: 0.95 }),
|
|
85
|
+
),
|
|
86
|
+
];
|
|
87
|
+
// A ceiling belongs on liveness, where the orchestrator restarts the process
|
|
88
|
+
// rather than routing around it.
|
|
89
|
+
this.liveness = [
|
|
90
|
+
new MemoryIndicator(
|
|
91
|
+
new MemoryOptions({ maxRssBytes: 1024 * 1024 * 1024 }),
|
|
92
|
+
),
|
|
93
|
+
];
|
|
94
|
+
this.dashboardProbes = this.readiness.filter(
|
|
95
|
+
(indicator) => !(indicator instanceof CacheIndicator),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Logger } from '@dunx/core';
|
|
2
2
|
import type { HttpApp } from '@dunx/http';
|
|
3
3
|
import { AppConfigService } from '../config.js';
|
|
4
|
-
import {
|
|
4
|
+
import { RequestTrail } from './request-trail.js';
|
|
5
5
|
|
|
6
6
|
const CORS_HEADERS = [
|
|
7
7
|
'access-control-allow-origin',
|
|
@@ -49,7 +49,7 @@ const whoami = async (url: string, forwarded: boolean): Promise<string> => {
|
|
|
49
49
|
|
|
50
50
|
export class HttpDemo {
|
|
51
51
|
constructor(
|
|
52
|
-
private readonly
|
|
52
|
+
private readonly trail: RequestTrail,
|
|
53
53
|
private readonly config: AppConfigService,
|
|
54
54
|
private readonly logger: Logger,
|
|
55
55
|
) {}
|
|
@@ -70,10 +70,12 @@ export class HttpDemo {
|
|
|
70
70
|
|
|
71
71
|
const created = await postNote(url, 'ship it');
|
|
72
72
|
logger.info(
|
|
73
|
-
`use(
|
|
73
|
+
`use(RequestTrailMiddleware): POST /api/notes -> ${created.status}, ` +
|
|
74
74
|
`x-handled-by: ${created.headers.get('x-handled-by')}`,
|
|
75
75
|
);
|
|
76
|
-
logger.info(
|
|
76
|
+
logger.info(
|
|
77
|
+
`RequestTrail -> ${JSON.stringify(this.trail.entries.slice(-2))}`,
|
|
78
|
+
);
|
|
77
79
|
|
|
78
80
|
const rejected = await postNote(url, 7);
|
|
79
81
|
logger.info(
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Module } from '@dunx/core';
|
|
2
2
|
import { HttpDemo } from './http.demo.js';
|
|
3
|
-
import {
|
|
3
|
+
import { RequestTrail, RequestTrailMiddleware } from './request-trail.js';
|
|
4
4
|
|
|
5
5
|
// `use()` resolves middleware from the container, and every class self-binds - so
|
|
6
6
|
// declaring them here is for the reader, not for the resolver.
|
|
7
7
|
@Module({
|
|
8
|
-
providers: [
|
|
9
|
-
exports: [
|
|
8
|
+
providers: [RequestTrail, RequestTrailMiddleware, HttpDemo],
|
|
9
|
+
exports: [RequestTrail, RequestTrailMiddleware, HttpDemo],
|
|
10
10
|
})
|
|
11
11
|
export class HttpModule {}
|
|
@@ -2,7 +2,7 @@ import type { BunRequest } from 'bun';
|
|
|
2
2
|
import type { Middleware, Next, RouteContext } from '@dunx/http';
|
|
3
3
|
|
|
4
4
|
/** The observable side effect: whatever the middleware saw is readable after. */
|
|
5
|
-
export class
|
|
5
|
+
export class RequestTrail {
|
|
6
6
|
readonly entries: string[] = [];
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -11,15 +11,14 @@ export class RequestLog {
|
|
|
11
11
|
* what lets it inject. Chains are folded into one closure per route at boot, and
|
|
12
12
|
* `ctx` is the route it was folded into: names, method, path, and its metadata.
|
|
13
13
|
*
|
|
14
|
-
* **This does not log
|
|
15
|
-
* `RequestLoggingMiddleware`
|
|
16
|
-
*
|
|
17
|
-
* own would
|
|
18
|
-
*
|
|
19
|
-
* asserts on it and because it is the smallest possible example of the seam.
|
|
14
|
+
* **This does not log**, which is what the name says now and did not before.
|
|
15
|
+
* `@dunx/http` installs `RequestLoggingMiddleware` itself - one structured entry
|
|
16
|
+
* per request, tuned through `requestLogging` in bootstrap.ts - so an app writing
|
|
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.
|
|
20
19
|
*/
|
|
21
|
-
export class
|
|
22
|
-
constructor(private readonly
|
|
20
|
+
export class RequestTrailMiddleware implements Middleware {
|
|
21
|
+
constructor(private readonly trail: RequestTrail) {}
|
|
23
22
|
|
|
24
23
|
async handle(
|
|
25
24
|
req: BunRequest,
|
|
@@ -27,11 +26,11 @@ export class RequestLoggerMiddleware implements Middleware {
|
|
|
27
26
|
next: Next,
|
|
28
27
|
): Promise<Response> {
|
|
29
28
|
const response = await next();
|
|
30
|
-
this.
|
|
29
|
+
this.trail.entries.push(
|
|
31
30
|
`${req.method} ${new URL(req.url).pathname} -> ${response.status} ` +
|
|
32
31
|
`(${ctx.controller}.${ctx.handler})`,
|
|
33
32
|
);
|
|
34
|
-
response.headers.set('x-handled-by', 'request-
|
|
33
|
+
response.headers.set('x-handled-by', 'request-trail');
|
|
35
34
|
return response;
|
|
36
35
|
}
|
|
37
36
|
}
|
|
@@ -17,8 +17,13 @@ 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
|
-
|
|
21
|
-
.
|
|
20
|
+
// `.strict()` **after** `.meta()` discards the metadata - `meta()` then returns
|
|
21
|
+
// null and the schema is inlined despite declaring an id. Order matters.
|
|
22
|
+
.strict()
|
|
23
|
+
.meta({
|
|
24
|
+
id: 'EnqueueRender',
|
|
25
|
+
description: 'A thumbnail to render off the request',
|
|
26
|
+
});
|
|
22
27
|
|
|
23
28
|
const enqueue = { body: Enqueue } as const;
|
|
24
29
|
const oneJob = { params: z.object({ id: z.string().min(1) }) } as const;
|
|
@@ -37,6 +37,7 @@ export class ThumbnailJobs {
|
|
|
37
37
|
// in this process's stream.
|
|
38
38
|
@JobHandler({ queue: THUMBNAIL_QUEUE, name: 'render', background: true })
|
|
39
39
|
async render(job: Job<RenderRequest>): Promise<RenderResult> {
|
|
40
|
+
const started = Bun.nanoseconds();
|
|
40
41
|
const encoded = await this.thumbnails.render({
|
|
41
42
|
width: job.data.width,
|
|
42
43
|
fit: ImageFit.INSIDE,
|
|
@@ -48,8 +49,13 @@ export class ThumbnailJobs {
|
|
|
48
49
|
height: encoded.height,
|
|
49
50
|
bytes: encoded.bytes.byteLength,
|
|
50
51
|
};
|
|
52
|
+
const elapsedMs = (Bun.nanoseconds() - started) / 1e6;
|
|
51
53
|
// Written in the child, and visible here: that is the point of the sandbox.
|
|
52
|
-
this.logger.info(
|
|
54
|
+
this.logger.info(
|
|
55
|
+
`rendered job ${job.id ?? '?'} in ${elapsedMs.toFixed(2)} ms`,
|
|
56
|
+
result,
|
|
57
|
+
);
|
|
58
|
+
|
|
53
59
|
return result;
|
|
54
60
|
}
|
|
55
61
|
}
|
|
@@ -13,7 +13,7 @@ import { NotesService } from './notes.service.js';
|
|
|
13
13
|
|
|
14
14
|
const CreateNote = z
|
|
15
15
|
.object({ text: z.string().min(1) })
|
|
16
|
-
.meta({ id: 'CreateNote',
|
|
16
|
+
.meta({ id: 'CreateNote', description: 'Add a note' });
|
|
17
17
|
|
|
18
18
|
// An explicit status, unlike the users controller which takes the POST default.
|
|
19
19
|
const createNote = {
|
|
@@ -11,13 +11,16 @@ const Resize = z
|
|
|
11
11
|
format: z.enum(EncodableFormat).default(EncodableFormat.PNG),
|
|
12
12
|
quality: z.coerce.number().int().min(1).max(100).optional(),
|
|
13
13
|
})
|
|
14
|
-
.meta({
|
|
14
|
+
.meta({
|
|
15
|
+
id: 'Resize',
|
|
16
|
+
description: 'How to render the generated source image',
|
|
17
|
+
});
|
|
15
18
|
|
|
16
19
|
const render = { query: Resize } as const;
|
|
17
20
|
const describe = {
|
|
18
21
|
body: z
|
|
19
22
|
.object({ base64: z.string().min(1) })
|
|
20
|
-
.meta({ id: 'InlineImage',
|
|
23
|
+
.meta({ id: 'InlineImage', description: 'Any image, base64-encoded' }),
|
|
21
24
|
} as const;
|
|
22
25
|
|
|
23
26
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The three decorators, on one class.
|
|
6
|
+
*
|
|
7
|
+
* The runner finds them by walking the prototype chains of the classes the modules
|
|
8
|
+
* already declare, so none of these needs a second registration - the same
|
|
9
|
+
* discovery routes, gateways and `@JobHandler` use.
|
|
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.
|
|
20
|
+
*/
|
|
21
|
+
export class Maintenance {
|
|
22
|
+
#sweeps = 0;
|
|
23
|
+
#compactions = 0;
|
|
24
|
+
#warmed = false;
|
|
25
|
+
|
|
26
|
+
constructor(private readonly logger: Logger) {}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `0`, so it fires on the next macrotask after the container is ready - and
|
|
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
|
+
*/
|
|
34
|
+
@OnceOnBoot(0, { name: 'maintenance.warm' })
|
|
35
|
+
warmCaches(): void {
|
|
36
|
+
this.#warmed = true;
|
|
37
|
+
this.logger.info('@OnceOnBoot(0): caches warmed, before listen() resolved');
|
|
38
|
+
}
|
|
39
|
+
|
|
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
|
+
*/
|
|
44
|
+
@Interval(600_000, { name: 'maintenance.sweep' })
|
|
45
|
+
sweepSessions(): number {
|
|
46
|
+
this.#sweeps = this.#sweeps + 1;
|
|
47
|
+
return this.#sweeps;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Minute resolution: `Bun.cron` rejects a sixth field with "seconds are not
|
|
52
|
+
* supported", so anything sub-minute is `@Interval`.
|
|
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.
|
|
56
|
+
*/
|
|
57
|
+
@Cron('0 3 * * *', { name: 'maintenance.compact' })
|
|
58
|
+
async compactLedger(): Promise<number> {
|
|
59
|
+
await Bun.sleep(1);
|
|
60
|
+
this.#compactions = this.#compactions + 1;
|
|
61
|
+
return this.#compactions;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get counts(): { sweeps: number; compactions: number; warmed: boolean } {
|
|
65
|
+
return {
|
|
66
|
+
sweeps: this.#sweeps,
|
|
67
|
+
compactions: this.#compactions,
|
|
68
|
+
warmed: this.#warmed,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
import { ScheduleRegistry } from '@dunx/infra/schedule';
|
|
3
|
+
import { Maintenance } from './maintenance.service.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What is armed, and what a schedule does when it is not waiting for a clock.
|
|
7
|
+
*
|
|
8
|
+
* `trigger` is the reason `ScheduleRegistry` is injectable: a cron at 03:00 is
|
|
9
|
+
* otherwise untestable without waiting, and an operator forcing a nightly job has
|
|
10
|
+
* nowhere else to go.
|
|
11
|
+
*/
|
|
12
|
+
export class ScheduleDemo {
|
|
13
|
+
constructor(
|
|
14
|
+
private readonly logger: Logger,
|
|
15
|
+
private readonly registry: ScheduleRegistry,
|
|
16
|
+
private readonly maintenance: Maintenance,
|
|
17
|
+
) {}
|
|
18
|
+
|
|
19
|
+
async demonstrate(): Promise<void> {
|
|
20
|
+
for (const entry of this.registry.list()) {
|
|
21
|
+
const next =
|
|
22
|
+
entry.nextRunAt === undefined
|
|
23
|
+
? 'a timer, so no next fire to compute'
|
|
24
|
+
: `next ${entry.nextRunAt.toISOString()}`;
|
|
25
|
+
this.logger.info(
|
|
26
|
+
`${entry.kind.padEnd(8)} ${entry.name} at ${String(entry.at)} - ${next}`,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The @OnceOnBoot already ran, before listen() bound the port.
|
|
31
|
+
this.logger.info(
|
|
32
|
+
`@OnceOnBoot fired at boot -> warmed=${this.maintenance.counts.warmed}`,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// Off its own cadence, honouring overlap. The cron is a 03:00 daily.
|
|
36
|
+
await this.registry.trigger('maintenance.compact');
|
|
37
|
+
await this.registry.trigger('maintenance.sweep');
|
|
38
|
+
const counts = this.maintenance.counts;
|
|
39
|
+
this.logger.info(
|
|
40
|
+
`trigger() x2 -> ${counts.compactions} compaction, ${counts.sweeps} sweep, ` +
|
|
41
|
+
'neither waited for a clock',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const entry = this.registry.get('maintenance.compact');
|
|
45
|
+
this.logger.info(
|
|
46
|
+
`runs recorded on the entry -> ${entry?.runs ?? 0}, lastError ` +
|
|
47
|
+
`${entry?.lastError?.message ?? 'none'}`,
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
// Removing one disarms it. A feature flag has nowhere else to live.
|
|
51
|
+
this.logger.info(
|
|
52
|
+
`remove("maintenance.sweep") -> ${this.registry.remove('maintenance.sweep')}, ` +
|
|
53
|
+
`${this.registry.list().length} left armed`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Module } from '@dunx/core';
|
|
2
|
+
import { ScheduleModule } from '@dunx/infra/schedule';
|
|
3
|
+
import { AppConfigService } from '../config.js';
|
|
4
|
+
import { Maintenance } from './maintenance.service.js';
|
|
5
|
+
import { ScheduleDemo } from './schedule.demo.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `Bun.cron` behind `@Cron`, `@Interval` and `@OnceOnBoot`, armed at boot.
|
|
9
|
+
*
|
|
10
|
+
* `keepAlive: false`, unlike the default. `Bun.cron` holds the event loop open so a
|
|
11
|
+
* process with an armed schedule and nothing else to do waits for the next fire;
|
|
12
|
+
* this app has a server holding it open already, and `bun run tour` has to exit.
|
|
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.
|
|
17
|
+
*/
|
|
18
|
+
@Module({
|
|
19
|
+
imports: [
|
|
20
|
+
ScheduleModule.forRootAsync({
|
|
21
|
+
useFactory: (config: AppConfigService) => ({
|
|
22
|
+
tz: config.get('schedule').tz,
|
|
23
|
+
keepAlive: false,
|
|
24
|
+
}),
|
|
25
|
+
inject: [AppConfigService] as const,
|
|
26
|
+
}),
|
|
27
|
+
],
|
|
28
|
+
providers: [Maintenance, ScheduleDemo],
|
|
29
|
+
exports: [Maintenance, ScheduleDemo],
|
|
30
|
+
})
|
|
31
|
+
export class MaintenanceModule {}
|
|
@@ -16,13 +16,14 @@ import { z } from 'zod';
|
|
|
16
16
|
* by `Storage` itself rather than by a pattern here, which is the behaviour worth
|
|
17
17
|
* seeing: try `?key=../../etc/passwd`.
|
|
18
18
|
*/
|
|
19
|
-
const FileKey = z
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const FileKey = z.object({ key: z.string().min(1).max(200) }).meta({
|
|
20
|
+
id: 'FileKey',
|
|
21
|
+
description: 'An object key inside the storage root',
|
|
22
|
+
});
|
|
22
23
|
|
|
23
24
|
const WriteFile = z
|
|
24
25
|
.object({ content: z.string().max(64 * 1024) })
|
|
25
|
-
.meta({ id: 'WriteFile',
|
|
26
|
+
.meta({ id: 'WriteFile', description: 'Text to store under the key' });
|
|
26
27
|
|
|
27
28
|
const listFiles = {
|
|
28
29
|
query: z.object({
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Controller, Get, SkipThrottle, Throttle } from '@dunx/http';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the limit looks like from the outside.
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
@Controller('limits')
|
|
12
|
+
export class LimitsController {
|
|
13
|
+
/**
|
|
14
|
+
* Three per minute, per subject. The fourth is a 429 carrying `retry-after`,
|
|
15
|
+
* thrown rather than returned, so it comes out in the app's own error shape.
|
|
16
|
+
*/
|
|
17
|
+
@Throttle({ limit: 3, windowSeconds: 60 })
|
|
18
|
+
@Get('/burst')
|
|
19
|
+
burst(): { allowed: true } {
|
|
20
|
+
return { allowed: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Exempt. A probe or an internal callback that must never be counted. */
|
|
24
|
+
@SkipThrottle()
|
|
25
|
+
@Get('/exempt')
|
|
26
|
+
exempt(): { counted: false } {
|
|
27
|
+
return { counted: false };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The module default, which is what every other route in this app gets. */
|
|
31
|
+
@Get('/default')
|
|
32
|
+
standard(): { ok: true } {
|
|
33
|
+
return { ok: true };
|
|
34
|
+
}
|
|
35
|
+
}
|