@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
|
|
3
|
+
/** Large enough that every branch below is the interesting one. */
|
|
4
|
+
const DOCUMENT = 'api/openapi.json';
|
|
5
|
+
/** Two fields. Under the 1024-byte threshold, so it is sent as it is. */
|
|
6
|
+
const SMALL = 'api/notes/whoami';
|
|
7
|
+
|
|
8
|
+
interface Measured {
|
|
9
|
+
readonly encoding: string;
|
|
10
|
+
readonly wire: number;
|
|
11
|
+
readonly decoded: number;
|
|
12
|
+
readonly vary: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `fetch` decodes the body itself but leaves `content-encoding` and the encoded
|
|
17
|
+
* `content-length` on the response, so one request measures both sides.
|
|
18
|
+
*/
|
|
19
|
+
const measure = async (
|
|
20
|
+
url: string,
|
|
21
|
+
path: string,
|
|
22
|
+
accept: string,
|
|
23
|
+
): Promise<Measured> => {
|
|
24
|
+
const response = await fetch(new URL(path, url), {
|
|
25
|
+
headers: { 'accept-encoding': accept },
|
|
26
|
+
});
|
|
27
|
+
const decoded = await response.text();
|
|
28
|
+
const wire = response.headers.get('content-length');
|
|
29
|
+
return {
|
|
30
|
+
encoding: response.headers.get('content-encoding') ?? 'identity',
|
|
31
|
+
wire: wire === null ? decoded.length : Number(wire),
|
|
32
|
+
decoded: decoded.length,
|
|
33
|
+
vary: response.headers.get('vary') ?? '-',
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const ratio = (m: Measured): string =>
|
|
38
|
+
`${((m.wire / m.decoded) * 100).toFixed(1)}%`;
|
|
39
|
+
|
|
40
|
+
export class CompressionDemo {
|
|
41
|
+
constructor(private readonly logger: Logger) {}
|
|
42
|
+
|
|
43
|
+
async demonstrate(url: string): Promise<void> {
|
|
44
|
+
const { logger } = this;
|
|
45
|
+
|
|
46
|
+
// This folder is vendored by `@dunx/create-app`, and a scaffold that took
|
|
47
|
+
// `http` without `openapi` has no document to encode. Same convention as a
|
|
48
|
+
// part whose backing service is absent: say so and carry on.
|
|
49
|
+
const available = await fetch(new URL(DOCUMENT, url));
|
|
50
|
+
await available.body?.cancel();
|
|
51
|
+
if (!available.ok) {
|
|
52
|
+
logger.info(`skipping: no ${DOCUMENT} in this app to encode`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// `identity` names a coding the app does not offer, so negotiation picks
|
|
57
|
+
// nothing and the body goes out unencoded.
|
|
58
|
+
for (const accept of ['identity', 'gzip', 'zstd', 'gzip, zstd']) {
|
|
59
|
+
const m = await measure(url, DOCUMENT, accept);
|
|
60
|
+
logger.info(
|
|
61
|
+
`accept-encoding: ${accept.padEnd(11)} -> ${m.encoding.padEnd(8)} ` +
|
|
62
|
+
`${String(m.wire).padStart(6)} of ${m.decoded} bytes (${ratio(m)})`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Both offered at the same q, so the server's own order decides. The app
|
|
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.
|
|
69
|
+
const preferred = await measure(url, DOCUMENT, 'gzip, zstd');
|
|
70
|
+
logger.info(
|
|
71
|
+
`a tie in the client's q-values is broken by the server order -> ${preferred.encoding}`,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// An explicit q-value outranks that order.
|
|
75
|
+
const forced = await measure(url, DOCUMENT, 'zstd;q=0.1, gzip;q=0.9');
|
|
76
|
+
logger.info(`zstd;q=0.1, gzip;q=0.9 -> ${forced.encoding}`);
|
|
77
|
+
|
|
78
|
+
const small = await measure(url, SMALL, 'gzip, zstd');
|
|
79
|
+
logger.info(
|
|
80
|
+
`under the 1024-byte threshold: ${SMALL} -> ${small.encoding} ` +
|
|
81
|
+
`(${small.decoded} bytes, encoding it would add bytes)`,
|
|
82
|
+
);
|
|
83
|
+
logger.info(
|
|
84
|
+
`vary: ${small.vary} - set even when nothing was encoded, so a shared ` +
|
|
85
|
+
`cache does not serve one client's encoding to another`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -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,39 @@
|
|
|
1
1
|
import { Module } from '@dunx/core';
|
|
2
|
+
import { CompressionModule } from '@dunx/http';
|
|
3
|
+
import { CompressionDemo } from './compression.demo.js';
|
|
4
|
+
import { TraceController } from './trace.controller.js';
|
|
5
|
+
import { TraceDemo } from './trace.demo.js';
|
|
2
6
|
import { HttpDemo } from './http.demo.js';
|
|
3
|
-
import {
|
|
7
|
+
import { RequestTrail, RequestTrailMiddleware } from './request-trail.js';
|
|
4
8
|
|
|
5
9
|
// `use()` resolves middleware from the container, and every class self-binds - so
|
|
6
10
|
// declaring them here is for the reader, not for the resolver.
|
|
7
11
|
@Module({
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
imports: [
|
|
13
|
+
// Binds `Compression`; the **app** registers it, in `bootstrap.ts`, for the
|
|
14
|
+
// same reason `StaticFiles` is registered there. Nothing is installed by
|
|
15
|
+
// importing this, so an app that never calls `app.use(Compression)` has no
|
|
16
|
+
// branch in the request path to skip.
|
|
17
|
+
//
|
|
18
|
+
// Defaults left alone apart from the threshold, which is here to be seen: a
|
|
19
|
+
// body under it is sent as it is, because gzip's header and trailer alone are
|
|
20
|
+
// 18 bytes and a short JSON response comes out larger.
|
|
21
|
+
CompressionModule.forRoot({ threshold: 1024 }),
|
|
22
|
+
],
|
|
23
|
+
controllers: [TraceController],
|
|
24
|
+
providers: [
|
|
25
|
+
RequestTrail,
|
|
26
|
+
RequestTrailMiddleware,
|
|
27
|
+
HttpDemo,
|
|
28
|
+
CompressionDemo,
|
|
29
|
+
TraceDemo,
|
|
30
|
+
],
|
|
31
|
+
exports: [
|
|
32
|
+
RequestTrail,
|
|
33
|
+
RequestTrailMiddleware,
|
|
34
|
+
HttpDemo,
|
|
35
|
+
CompressionDemo,
|
|
36
|
+
TraceDemo,
|
|
37
|
+
],
|
|
10
38
|
})
|
|
11
39
|
export class HttpModule {}
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { RequestContext } from '@dunx/core';
|
|
2
|
+
import { Controller, Get, type Input, type RouteSchemas } from '@dunx/http';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What the request's W3C trace looks like from inside a handler.
|
|
6
|
+
*
|
|
7
|
+
* `RequestContext` is bound by `@dunx/core` whatever else the app imports, and
|
|
8
|
+
* `requestLogging: { trace: true }` is what puts the trace fields into it. Every
|
|
9
|
+
* log line this request writes carries the same three values.
|
|
10
|
+
*/
|
|
11
|
+
@Controller('trace')
|
|
12
|
+
export class TraceController {
|
|
13
|
+
constructor(private readonly context: RequestContext) {}
|
|
14
|
+
|
|
15
|
+
@Get('/')
|
|
16
|
+
current(input: Input<RouteSchemas>): {
|
|
17
|
+
traceId: string | undefined;
|
|
18
|
+
spanId: string | undefined;
|
|
19
|
+
parentSpanId: string | undefined;
|
|
20
|
+
inbound: string | null;
|
|
21
|
+
} {
|
|
22
|
+
const { traceId, spanId, parentSpanId } = this.context.getContext();
|
|
23
|
+
return {
|
|
24
|
+
traceId: traceId as string | undefined,
|
|
25
|
+
spanId: spanId as string | undefined,
|
|
26
|
+
parentSpanId: parentSpanId as string | undefined,
|
|
27
|
+
inbound: input.req.headers.get('traceparent'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Logger } from '@dunx/core';
|
|
2
|
+
|
|
3
|
+
interface Seen {
|
|
4
|
+
readonly traceId: string | undefined;
|
|
5
|
+
readonly spanId: string | undefined;
|
|
6
|
+
readonly parentSpanId: string | undefined;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const UPSTREAM_TRACE = '4bf92f3577b34da6a3ce929d0e0e4736';
|
|
10
|
+
const UPSTREAM_SPAN = '00f067aa0ba902b7';
|
|
11
|
+
|
|
12
|
+
const ask = async (url: string, traceparent?: string): Promise<Seen> => {
|
|
13
|
+
const response = await fetch(new URL('api/trace', url), {
|
|
14
|
+
headers: traceparent === undefined ? {} : { traceparent },
|
|
15
|
+
});
|
|
16
|
+
return (await response.json()) as Seen;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export class TraceDemo {
|
|
20
|
+
constructor(private readonly logger: Logger) {}
|
|
21
|
+
|
|
22
|
+
async demonstrate(url: string): Promise<void> {
|
|
23
|
+
const { logger } = this;
|
|
24
|
+
|
|
25
|
+
const fresh = await ask(url);
|
|
26
|
+
logger.info(
|
|
27
|
+
`no traceparent in: trace ${fresh.traceId} span ${fresh.spanId} ` +
|
|
28
|
+
`(no parent - this service started the trace)`,
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const continued = await ask(
|
|
32
|
+
url,
|
|
33
|
+
`00-${UPSTREAM_TRACE}-${UPSTREAM_SPAN}-01`,
|
|
34
|
+
);
|
|
35
|
+
logger.info(
|
|
36
|
+
`traceparent in: trace ${continued.traceId} span ${continued.spanId} ` +
|
|
37
|
+
`parent ${continued.parentSpanId}`,
|
|
38
|
+
);
|
|
39
|
+
logger.info(
|
|
40
|
+
`the caller's trace id survived: ${continued.traceId === UPSTREAM_TRACE}, ` +
|
|
41
|
+
`and its span became this one's parent: ${continued.parentSpanId === UPSTREAM_SPAN}`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// Discarded rather than repaired. An unparseable header means the caller's
|
|
45
|
+
// trace is unknown, so this request starts one of its own.
|
|
46
|
+
const broken = await ask(url, '00-not-a-trace-id-01');
|
|
47
|
+
logger.info(
|
|
48
|
+
`malformed traceparent in: trace ${broken.traceId} ` +
|
|
49
|
+
`(a fresh one, not the caller's)`,
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// Two requests are two traces, and two spans within one.
|
|
53
|
+
const second = await ask(url);
|
|
54
|
+
logger.info(
|
|
55
|
+
`each request gets its own span: ${fresh.spanId !== second.spanId}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -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({
|