@daloyjs/core 0.35.2 → 0.37.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/README.md +22 -2
- package/bin/daloy.mjs +2 -0
- package/dist/adapters/bun.js +16 -9
- package/dist/adapters/deno.js +7 -1
- package/dist/adapters/node.d.ts +11 -0
- package/dist/adapters/node.js +24 -0
- package/dist/app.d.ts +223 -1
- package/dist/app.js +358 -8
- package/dist/asyncapi.d.ts +98 -0
- package/dist/asyncapi.js +212 -0
- package/dist/auto-ban.d.ts +205 -0
- package/dist/auto-ban.js +222 -0
- package/dist/bot-guard.d.ts +209 -0
- package/dist/bot-guard.js +291 -0
- package/dist/cli.d.ts +8 -0
- package/dist/cli.js +88 -4
- package/dist/concurrency-limit.d.ts +135 -0
- package/dist/concurrency-limit.js +254 -0
- package/dist/docs.d.ts +57 -6
- package/dist/docs.js +34 -3
- package/dist/errors.d.ts +20 -0
- package/dist/errors.js +27 -0
- package/dist/fetch-guard.js +4 -0
- package/dist/fetch-resilience.d.ts +295 -0
- package/dist/fetch-resilience.js +485 -0
- package/dist/geo-block.d.ts +184 -0
- package/dist/geo-block.js +153 -0
- package/dist/hashing.d.ts +2 -1
- package/dist/hashing.js +12 -1
- package/dist/http-signatures.d.ts +303 -0
- package/dist/http-signatures.js +782 -0
- package/dist/idempotency.d.ts +204 -0
- package/dist/idempotency.js +341 -0
- package/dist/index.d.ts +38 -4
- package/dist/index.js +18 -1
- package/dist/ip-reputation.d.ts +198 -0
- package/dist/ip-reputation.js +253 -0
- package/dist/jwk.d.ts +15 -0
- package/dist/jwk.js +24 -2
- package/dist/load-shedding.d.ts +5 -0
- package/dist/logger.js +6 -2
- package/dist/metrics.d.ts +208 -0
- package/dist/metrics.js +452 -0
- package/dist/middleware.js +0 -10
- package/dist/mtls.d.ts +266 -0
- package/dist/mtls.js +488 -0
- package/dist/multipart.js +1 -1
- package/dist/openapi-diff.d.ts +79 -0
- package/dist/openapi-diff.js +246 -0
- package/dist/openapi.js +4 -1
- package/dist/pagination.d.ts +210 -0
- package/dist/pagination.js +353 -0
- package/dist/rate-limit-redis.d.ts +8 -0
- package/dist/rate-limit-redis.js +8 -0
- package/dist/request-decompression.d.ts +200 -0
- package/dist/request-decompression.js +363 -0
- package/dist/response-cache.d.ts +205 -0
- package/dist/response-cache.js +374 -0
- package/dist/router.d.ts +22 -0
- package/dist/router.js +64 -7
- package/dist/safe-redirect.d.ts +2 -2
- package/dist/safe-redirect.js +3 -8
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/dist/scheduler.d.ts +315 -0
- package/dist/scheduler.js +546 -0
- package/dist/security.d.ts +27 -7
- package/dist/security.js +27 -7
- package/dist/session.js +3 -3
- package/dist/types.d.ts +33 -0
- package/dist/waf.d.ts +213 -0
- package/dist/waf.js +334 -0
- package/dist/webhook-delivery.d.ts +263 -0
- package/dist/webhook-delivery.js +311 -0
- package/dist/websocket.d.ts +52 -0
- package/dist/websocket.js +13 -0
- package/package.json +76 -2
package/dist/app.js
CHANGED
|
@@ -10,6 +10,8 @@ import { secureHeaders as secureHeadersMiddleware, CORS_HOOK_MARKER, CORS_ORIGIN
|
|
|
10
10
|
import { COMPRESSION_HOOK_MARKER } from "./compression.js";
|
|
11
11
|
import { SESSION_HOOK_MARKER, SESSION_SECRETS_MARKER, } from "./session.js";
|
|
12
12
|
import { loadShedding as loadSheddingMiddleware } from "./load-shedding.js";
|
|
13
|
+
import { httpMetrics, MetricsRegistry, PROMETHEUS_CONTENT_TYPE, } from "./metrics.js";
|
|
14
|
+
import { Scheduler, } from "./scheduler.js";
|
|
13
15
|
import { securitySchemeRequiresPayloadAuth } from "./security-schemes.js";
|
|
14
16
|
import { assertBehindProxy } from "./conn-info.js";
|
|
15
17
|
const AUTO_SECURE_HEADERS_MARKER = Symbol.for("daloyjs.app.autoSecureHeaders");
|
|
@@ -40,6 +42,36 @@ let insecureDefaultsLoggedThisProcess = false;
|
|
|
40
42
|
export function _resetInsecureDefaultsLogForTests() {
|
|
41
43
|
insecureDefaultsLoggedThisProcess = false;
|
|
42
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* The exact set of fields the `"internal-service"` preset flips off when
|
|
47
|
+
* the caller has not set them explicitly. Surfaced through the boot
|
|
48
|
+
* audit log entry so operators can see which guards the preset turned
|
|
49
|
+
* off without re-reading the framework source.
|
|
50
|
+
*/
|
|
51
|
+
const INTERNAL_SERVICE_PRESET_DISABLED = Object.freeze([
|
|
52
|
+
"secureHeaders auto-install",
|
|
53
|
+
"corsCrossOriginGuard (state-changing cross-origin write rejection)",
|
|
54
|
+
"csrf boot guard (session() + state-changing route)",
|
|
55
|
+
"unconfigured X-Forwarded-* / trustProxy guard",
|
|
56
|
+
]);
|
|
57
|
+
/**
|
|
58
|
+
* Defaults that the `"internal-service"` preset keeps on. Logged at boot
|
|
59
|
+
* alongside the disabled list so the audit entry shows the full posture.
|
|
60
|
+
*/
|
|
61
|
+
const INTERNAL_SERVICE_PRESET_KEPT = Object.freeze([
|
|
62
|
+
"bodyLimitBytes (1 MiB default)",
|
|
63
|
+
"requestTimeoutMs (30 s default)",
|
|
64
|
+
"crashOnUnhandledRejection (production)",
|
|
65
|
+
"weak session secret refuse-to-boot",
|
|
66
|
+
"cors({ origin: '*' }) refuse-to-boot",
|
|
67
|
+
"anonymous stateful plugin refuse-to-boot",
|
|
68
|
+
"stripServerHeaders",
|
|
69
|
+
"RFC 9457 problem+json prod redaction",
|
|
70
|
+
"JWT algorithm allowlist + timingSafeEqual credential comparison",
|
|
71
|
+
"prototype-pollution-safe parsers + isForbiddenObjectKey",
|
|
72
|
+
"fetchGuard() SSRF defaults",
|
|
73
|
+
"schema .strict() + response validation when enabled",
|
|
74
|
+
]);
|
|
43
75
|
/**
|
|
44
76
|
* List of secure-by-default surfaces disabled when `secureDefaults: false`
|
|
45
77
|
* is set. Surfaced through the once-per-process `error` log so the operator
|
|
@@ -74,6 +106,46 @@ const CANONICAL_HTTP_METHODS = new Set([
|
|
|
74
106
|
"HEAD",
|
|
75
107
|
"OPTIONS",
|
|
76
108
|
]);
|
|
109
|
+
/**
|
|
110
|
+
* Apply a topology-aware security preset on top of caller-supplied
|
|
111
|
+
* options. Returns a new options object where preset defaults fill in
|
|
112
|
+
* any field the caller left `undefined`; explicit caller values always
|
|
113
|
+
* win. Pure / no side effects — the boot audit log is emitted
|
|
114
|
+
* separately by {@link App.logSecurityPresetIfApplied} so this helper is
|
|
115
|
+
* safe to call from `new App({ preset: ... })` in test setups.
|
|
116
|
+
*
|
|
117
|
+
* The `"internal-service"` preset turns off:
|
|
118
|
+
* - `secureHeaders` auto-install (browser-only headers)
|
|
119
|
+
* - `corsCrossOriginGuard` (no browser Origin to guard against)
|
|
120
|
+
* - `csrf` (set to `"off"` — service-to-service callers aren't browsers)
|
|
121
|
+
* - `trustProxy` (set to `false` — explicitly ignore `X-Forwarded-*`
|
|
122
|
+
* and silence the unconfigured-proxy 500 guard; the immediate peer
|
|
123
|
+
* inside the mesh *is* the caller)
|
|
124
|
+
*
|
|
125
|
+
* Everything else (body limits, request timeouts, JWT allowlist,
|
|
126
|
+
* `crashOnUnhandledRejection`, weak-secret refuse-to-boot, cors-wildcard
|
|
127
|
+
* refuse-to-boot, anonymous stateful plugin refuse-to-boot,
|
|
128
|
+
* `stripServerHeaders`, RFC 9457 prod redaction, schema strictness,
|
|
129
|
+
* `fetchGuard`, parser safety) stays at its standard secure-by-default
|
|
130
|
+
* value.
|
|
131
|
+
*
|
|
132
|
+
* @internal
|
|
133
|
+
*/
|
|
134
|
+
function applySecurityPreset(options) {
|
|
135
|
+
if (options.preset !== "internal-service")
|
|
136
|
+
return options;
|
|
137
|
+
const out = { ...options };
|
|
138
|
+
if (out.secureHeaders === undefined)
|
|
139
|
+
out.secureHeaders = false;
|
|
140
|
+
if (out.corsCrossOriginGuard === undefined)
|
|
141
|
+
out.corsCrossOriginGuard = false;
|
|
142
|
+
if (out.csrf === undefined)
|
|
143
|
+
out.csrf = "off";
|
|
144
|
+
if (out.trustProxy === undefined && out.behindProxy === undefined) {
|
|
145
|
+
out.trustProxy = false;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
77
149
|
const DEFAULTS = {
|
|
78
150
|
bodyLimitBytes: 1024 * 1024,
|
|
79
151
|
requestTimeoutMs: 30_000,
|
|
@@ -191,6 +263,12 @@ export class App {
|
|
|
191
263
|
installedPlugins = new Set();
|
|
192
264
|
closeHooks = [];
|
|
193
265
|
closeHooksRun = false;
|
|
266
|
+
/**
|
|
267
|
+
* Lazily-created in-process scheduler backing {@link App.cron}. Started on
|
|
268
|
+
* the first `cron()` call and stopped from an `onClose` hook so its lifecycle
|
|
269
|
+
* is tied to graceful shutdown.
|
|
270
|
+
*/
|
|
271
|
+
scheduler;
|
|
194
272
|
/** Idle-connection close hooks (adapter-registered, sync). */
|
|
195
273
|
idleConnectionCloseHooks = [];
|
|
196
274
|
pluginInstalledListeners = [];
|
|
@@ -245,11 +323,12 @@ export class App {
|
|
|
245
323
|
return this._globalCorsAllowsCache;
|
|
246
324
|
}
|
|
247
325
|
constructor(options = {}) {
|
|
326
|
+
const resolved = applySecurityPreset(options);
|
|
248
327
|
this.options = {
|
|
249
|
-
validateResponses:
|
|
250
|
-
bodyLimitBytes:
|
|
251
|
-
requestTimeoutMs:
|
|
252
|
-
...
|
|
328
|
+
validateResponses: resolved.validateResponses ?? DEFAULTS.validateResponses,
|
|
329
|
+
bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
|
|
330
|
+
requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
|
|
331
|
+
...resolved,
|
|
253
332
|
};
|
|
254
333
|
this.log =
|
|
255
334
|
options.logger === false
|
|
@@ -264,6 +343,7 @@ export class App {
|
|
|
264
343
|
if (this.options.hooks)
|
|
265
344
|
this.assertSecureHookConfig(this.options.hooks);
|
|
266
345
|
this.assertInsecureDefaultsAcknowledged();
|
|
346
|
+
this.logSecurityPresetIfApplied(options);
|
|
267
347
|
this.installSecureDefaults();
|
|
268
348
|
this.maybeInstallCrashHandlers();
|
|
269
349
|
this.maybeMountDocs();
|
|
@@ -318,6 +398,68 @@ export class App {
|
|
|
318
398
|
}, `app({ secureDefaults: false }) disables: ${DISABLED_BY_INSECURE_DEFAULTS.join(", ")}.`);
|
|
319
399
|
}
|
|
320
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Emit the one-time boot audit entry for an applied security preset.
|
|
403
|
+
* Called from the constructor with the *original* (pre-preset) options
|
|
404
|
+
* so the log captures which fields the preset filled in vs. which the
|
|
405
|
+
* caller set explicitly. Logged at `info` so the line shows up in
|
|
406
|
+
* standard production log shipping without being noisy.
|
|
407
|
+
*
|
|
408
|
+
* Operators can audit the live posture at any time through
|
|
409
|
+
* {@link App.getSecurityPosture}.
|
|
410
|
+
*
|
|
411
|
+
* @since 0.34.0
|
|
412
|
+
*/
|
|
413
|
+
logSecurityPresetIfApplied(originalOptions) {
|
|
414
|
+
if (originalOptions.preset !== "internal-service")
|
|
415
|
+
return;
|
|
416
|
+
const userOverrode = [];
|
|
417
|
+
if (originalOptions.secureHeaders !== undefined)
|
|
418
|
+
userOverrode.push("secureHeaders");
|
|
419
|
+
if (originalOptions.corsCrossOriginGuard !== undefined) {
|
|
420
|
+
userOverrode.push("corsCrossOriginGuard");
|
|
421
|
+
}
|
|
422
|
+
if (originalOptions.csrf !== undefined)
|
|
423
|
+
userOverrode.push("csrf");
|
|
424
|
+
if (originalOptions.trustProxy !== undefined)
|
|
425
|
+
userOverrode.push("trustProxy");
|
|
426
|
+
if (originalOptions.behindProxy !== undefined)
|
|
427
|
+
userOverrode.push("behindProxy");
|
|
428
|
+
this.log.info({
|
|
429
|
+
event: "security.preset.applied",
|
|
430
|
+
preset: "internal-service",
|
|
431
|
+
disabled: INTERNAL_SERVICE_PRESET_DISABLED,
|
|
432
|
+
kept: INTERNAL_SERVICE_PRESET_KEPT,
|
|
433
|
+
userOverrode,
|
|
434
|
+
}, `Applied security preset "internal-service": disabled ${INTERNAL_SERVICE_PRESET_DISABLED.length} topology-dependent guards; kept ${INTERNAL_SERVICE_PRESET_KEPT.length} input/credential/SSRF guards on. See app.getSecurityPosture() for the live snapshot.`);
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Structured snapshot of the live security posture. Returns the same
|
|
438
|
+
* data the constructor logs under the `security.preset.applied` audit
|
|
439
|
+
* event plus the resolved values of every secure-by-default knob, so
|
|
440
|
+
* operators can build a `/__security` introspection route or a CI
|
|
441
|
+
* audit without parsing the framework source.
|
|
442
|
+
*
|
|
443
|
+
* @since 0.34.0
|
|
444
|
+
*/
|
|
445
|
+
getSecurityPosture() {
|
|
446
|
+
const o = this.options;
|
|
447
|
+
return Object.freeze({
|
|
448
|
+
preset: o.preset,
|
|
449
|
+
secureDefaults: o.secureDefaults !== false,
|
|
450
|
+
secureHeaders: o.secureDefaults !== false && o.secureHeaders !== false,
|
|
451
|
+
corsCrossOriginGuard: o.secureDefaults !== false && o.corsCrossOriginGuard !== false,
|
|
452
|
+
csrf: o.csrf === "off" ? "off" : "on",
|
|
453
|
+
crashOnUnhandledRejection: o.crashOnUnhandledRejection === undefined
|
|
454
|
+
? "default"
|
|
455
|
+
: o.crashOnUnhandledRejection,
|
|
456
|
+
trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
|
|
457
|
+
bodyLimitBytes: this.options.bodyLimitBytes,
|
|
458
|
+
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
459
|
+
stripServerHeaders: o.stripServerHeaders !== false,
|
|
460
|
+
production: this.isProduction(),
|
|
461
|
+
});
|
|
462
|
+
}
|
|
321
463
|
/**
|
|
322
464
|
* Install the secure-by-default global hooks. Currently:
|
|
323
465
|
* - {@link secureHeaders} as a group-level hook so every response carries
|
|
@@ -727,11 +869,16 @@ export class App {
|
|
|
727
869
|
handler: async () => {
|
|
728
870
|
const title = opts.title ?? (await resolveInfo()).title;
|
|
729
871
|
const html = ui === "swagger"
|
|
730
|
-
? swaggerUiHtml({
|
|
872
|
+
? swaggerUiHtml({
|
|
873
|
+
specUrl: openapiPath,
|
|
874
|
+
title,
|
|
875
|
+
assets: opts.assets,
|
|
876
|
+
})
|
|
731
877
|
: scalarHtml({
|
|
732
878
|
specUrl: openapiPath,
|
|
733
879
|
title,
|
|
734
880
|
configuration: opts.scalar,
|
|
881
|
+
assets: opts.assets,
|
|
735
882
|
});
|
|
736
883
|
return {
|
|
737
884
|
status: 200,
|
|
@@ -796,6 +943,13 @@ export class App {
|
|
|
796
943
|
auth: def.auth ?? this.groupAuth,
|
|
797
944
|
};
|
|
798
945
|
this.assertRouteAuthPayloadConfig(merged);
|
|
946
|
+
// Normalize an optional RFC 8594 sunset date to a stable IMF-fixdate
|
|
947
|
+
// (HTTP date) string once, at registration time, so the hot response
|
|
948
|
+
// path can emit the `Sunset` header without re-parsing per request and
|
|
949
|
+
// a bad value fails fast rather than silently emitting garbage.
|
|
950
|
+
if (merged.sunset !== undefined) {
|
|
951
|
+
merged.sunset = normalizeSunset(merged.sunset, merged.method, fullPath);
|
|
952
|
+
}
|
|
799
953
|
const sources = [...this.groupHooks, def.hooks ?? {}];
|
|
800
954
|
const hooks = mergeHooks(sources);
|
|
801
955
|
const corsOriginAllows = corsOriginAllowsFromHooks(sources);
|
|
@@ -948,6 +1102,166 @@ export class App {
|
|
|
948
1102
|
});
|
|
949
1103
|
return this;
|
|
950
1104
|
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Register an opt-in, auth-guarded Prometheus / OpenMetrics scrape route
|
|
1107
|
+
* and install RED (Rate / Errors / Duration) instrumentation for every
|
|
1108
|
+
* route registered **after** this call. The third observability pillar
|
|
1109
|
+
* alongside the structured logger and the OpenTelemetry tracer.
|
|
1110
|
+
*
|
|
1111
|
+
* Exposes, in the Prometheus text exposition format:
|
|
1112
|
+
* - `<prefix>http_requests_total{method,route,status}` — request counter,
|
|
1113
|
+
* - `<prefix>http_request_duration_seconds{method,route}` — latency histogram,
|
|
1114
|
+
* - `<prefix>http_requests_in_flight` — concurrency gauge,
|
|
1115
|
+
* - process gauges (resident memory, heap used, uptime) on Node-like runtimes.
|
|
1116
|
+
*
|
|
1117
|
+
* The scrape route inherits the same hardened posture as
|
|
1118
|
+
* {@link App.healthcheck}: optional bearer token compared via
|
|
1119
|
+
* {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
|
|
1120
|
+
* refuse-to-boot guard in production (an unauthenticated `/metrics`
|
|
1121
|
+
* endpoint leaks internal route names, latency, and traffic volume) unless
|
|
1122
|
+
* a token is supplied or `acknowledgeUnauthenticated: true` is passed.
|
|
1123
|
+
*
|
|
1124
|
+
* Call this **before** registering the routes you want measured — like any
|
|
1125
|
+
* `app.use(...)` middleware, the instrumentation only wraps routes added
|
|
1126
|
+
* afterwards. Pass `opts.registry` to register custom application metrics
|
|
1127
|
+
* that are rendered alongside the built-in HTTP series.
|
|
1128
|
+
*
|
|
1129
|
+
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1130
|
+
* @returns `this` for chaining.
|
|
1131
|
+
* @since 0.37.0
|
|
1132
|
+
*/
|
|
1133
|
+
metrics(opts = {}) {
|
|
1134
|
+
const path = (opts.path ?? "/metrics");
|
|
1135
|
+
const registry = opts.registry ?? new MetricsRegistry();
|
|
1136
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
1137
|
+
? null
|
|
1138
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1139
|
+
const token = opts.token;
|
|
1140
|
+
// Refuse-to-boot: an unauthenticated metrics scrape in production is a
|
|
1141
|
+
// documented info-disclosure surface (route inventory, latency
|
|
1142
|
+
// distributions, request volume, process memory). Force an explicit
|
|
1143
|
+
// acknowledgement, mirroring app.healthcheck().
|
|
1144
|
+
if (this.options.secureDefaults !== false &&
|
|
1145
|
+
this.isProduction() &&
|
|
1146
|
+
token === undefined &&
|
|
1147
|
+
opts.acknowledgeUnauthenticated !== true) {
|
|
1148
|
+
throw new Error(`app.metrics() refused in production: provide opts.token to require ` +
|
|
1149
|
+
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1150
|
+
`to acknowledge that this scrape endpoint is reachable without credentials.`);
|
|
1151
|
+
}
|
|
1152
|
+
// Install RED instrumentation as a group hook so it wraps every route
|
|
1153
|
+
// registered after this call. Always exclude the scrape path itself, plus
|
|
1154
|
+
// any caller-supplied predicate.
|
|
1155
|
+
const exclude = (p) => p === path || (opts.exclude ? opts.exclude(p) : false);
|
|
1156
|
+
this.groupHooks.push(httpMetrics({
|
|
1157
|
+
registry,
|
|
1158
|
+
route: opts.route,
|
|
1159
|
+
maxRouteCardinality: opts.maxRouteCardinality,
|
|
1160
|
+
buckets: opts.buckets,
|
|
1161
|
+
exclude,
|
|
1162
|
+
}));
|
|
1163
|
+
const buckets = rateLimitConfig
|
|
1164
|
+
? new Map()
|
|
1165
|
+
: null;
|
|
1166
|
+
this.route({
|
|
1167
|
+
method: "GET",
|
|
1168
|
+
path,
|
|
1169
|
+
operationId: "metrics",
|
|
1170
|
+
tags: ["Observability"],
|
|
1171
|
+
summary: "Prometheus metrics scrape endpoint",
|
|
1172
|
+
handler: async ({ request }) => {
|
|
1173
|
+
if (buckets && rateLimitConfig) {
|
|
1174
|
+
const key = healthRouteKey(request);
|
|
1175
|
+
const now = Date.now();
|
|
1176
|
+
const entry = buckets.get(key);
|
|
1177
|
+
if (!entry || entry.resetMs <= now) {
|
|
1178
|
+
buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
entry.count++;
|
|
1182
|
+
if (entry.count > rateLimitConfig.limit) {
|
|
1183
|
+
throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (token !== undefined) {
|
|
1188
|
+
const h = request.headers.get("authorization") ?? "";
|
|
1189
|
+
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
1190
|
+
if (!m) {
|
|
1191
|
+
throw new HttpError(401, {
|
|
1192
|
+
type: "https://daloyjs.dev/errors/unauthorized",
|
|
1193
|
+
title: "Unauthorized",
|
|
1194
|
+
detail: "Metrics scrape requires a bearer token.",
|
|
1195
|
+
}, { "www-authenticate": 'Bearer realm="metrics"' });
|
|
1196
|
+
}
|
|
1197
|
+
if (!timingSafeEqual(m[1], token)) {
|
|
1198
|
+
throw new ForbiddenError("Invalid metrics scrape token.");
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return {
|
|
1202
|
+
status: 200,
|
|
1203
|
+
body: registry.render(),
|
|
1204
|
+
headers: {
|
|
1205
|
+
"content-type": PROMETHEUS_CONTENT_TYPE,
|
|
1206
|
+
"cache-control": "no-store",
|
|
1207
|
+
},
|
|
1208
|
+
};
|
|
1209
|
+
},
|
|
1210
|
+
responses: {
|
|
1211
|
+
200: { description: "Prometheus metrics exposition." },
|
|
1212
|
+
429: { description: "Too many scrape requests." },
|
|
1213
|
+
},
|
|
1214
|
+
});
|
|
1215
|
+
return this;
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Register an in-process scheduled task (cron). The first call lazily creates
|
|
1219
|
+
* an app-managed {@link Scheduler}, wires it to the app logger, starts it,
|
|
1220
|
+
* and registers an `onClose` hook so it is drained on graceful shutdown
|
|
1221
|
+
* (in-flight runs are awaited, then aborted if they outlast the shutdown
|
|
1222
|
+
* grace period).
|
|
1223
|
+
*
|
|
1224
|
+
* The schedule is **queue-agnostic** — it runs work in *this* process on a
|
|
1225
|
+
* fixed interval or cron expression. Use it for periodic maintenance
|
|
1226
|
+
* (cache sweeps, token refresh, reconciliation) rather than as a distributed
|
|
1227
|
+
* job queue. Each task is **single-flight**: if a tick fires while the
|
|
1228
|
+
* previous run is still in progress, the tick is skipped and counted, so a
|
|
1229
|
+
* slow task can never pile up overlapping runs.
|
|
1230
|
+
*
|
|
1231
|
+
* @example
|
|
1232
|
+
* ```ts
|
|
1233
|
+
* app.cron({ name: "sweep", cron: "0 * * * *" }, async ({ signal }) => {
|
|
1234
|
+
* await purgeExpiredSessions({ signal });
|
|
1235
|
+
* });
|
|
1236
|
+
* ```
|
|
1237
|
+
*
|
|
1238
|
+
* @param def - The task definition. Exactly one of `intervalMs` or `cron`.
|
|
1239
|
+
* @param handler - The function to run on each tick.
|
|
1240
|
+
* @returns This `App` instance for chaining.
|
|
1241
|
+
* @throws {RangeError} on invalid options (see {@link Scheduler.define}).
|
|
1242
|
+
* @throws {@link CronParseError} if a `cron` expression is malformed.
|
|
1243
|
+
*/
|
|
1244
|
+
cron(def, handler) {
|
|
1245
|
+
if (this.scheduler === undefined) {
|
|
1246
|
+
const scheduler = new Scheduler({ logger: this.log.child({ component: "scheduler" }) });
|
|
1247
|
+
this.scheduler = scheduler;
|
|
1248
|
+
scheduler.start();
|
|
1249
|
+
// Drain the scheduler during the post-drain close phase so periodic
|
|
1250
|
+
// work stops cleanly alongside database pools and other resources.
|
|
1251
|
+
this.onClose(() => scheduler.stop());
|
|
1252
|
+
}
|
|
1253
|
+
this.scheduler.define(def, handler);
|
|
1254
|
+
return this;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* The app-managed {@link Scheduler} backing {@link App.cron}, or `undefined`
|
|
1258
|
+
* if no scheduled task has been registered. Exposed for inspection
|
|
1259
|
+
* (`getState()` / `list()`) and manual triggering (`runNow()`); the lifecycle
|
|
1260
|
+
* is owned by the app.
|
|
1261
|
+
*/
|
|
1262
|
+
get scheduledTasks() {
|
|
1263
|
+
return this.scheduler;
|
|
1264
|
+
}
|
|
951
1265
|
registerHealthRoute(kind, opts, handler) {
|
|
952
1266
|
const isHealth = kind === "healthcheck";
|
|
953
1267
|
const defaultPath = (isHealth ? "/healthz" : "/readyz");
|
|
@@ -1499,7 +1813,7 @@ export class App {
|
|
|
1499
1813
|
});
|
|
1500
1814
|
}
|
|
1501
1815
|
this.inflight++;
|
|
1502
|
-
|
|
1816
|
+
let requestId = randomId();
|
|
1503
1817
|
// Skip the per-request child-logger allocation when the app was
|
|
1504
1818
|
// constructed with `{ logger: false }`. noopLogger.child() returns
|
|
1505
1819
|
// itself, so the binding is wasted work on every request.
|
|
@@ -1662,6 +1976,13 @@ export class App {
|
|
|
1662
1976
|
if (allHooks.beforeHandle !== undefined) {
|
|
1663
1977
|
const beforeResult = allHooks.beforeHandle(ctx);
|
|
1664
1978
|
const before = isPromiseLike(beforeResult) ? await beforeResult : beforeResult;
|
|
1979
|
+
// Honor any request id override applied by middleware (e.g. the
|
|
1980
|
+
// `requestId()` Hooks bundle replaces the framework-generated value
|
|
1981
|
+
// with a trusted incoming header or a user-supplied generator).
|
|
1982
|
+
const overriddenId = state.requestId;
|
|
1983
|
+
if (typeof overriddenId === "string" && overriddenId.length > 0) {
|
|
1984
|
+
requestId = overriddenId;
|
|
1985
|
+
}
|
|
1665
1986
|
if (before instanceof Response) {
|
|
1666
1987
|
copyContextHeaders(ctx, before);
|
|
1667
1988
|
if (!before.headers.has("x-request-id"))
|
|
@@ -1686,8 +2007,10 @@ export class App {
|
|
|
1686
2007
|
const serializeResultRes = serializeResult(result, def, this.options.validateResponses ?? true);
|
|
1687
2008
|
let response = isPromiseLike(serializeResultRes) ? await serializeResultRes : serializeResultRes;
|
|
1688
2009
|
copyContextHeaders(ctx, response);
|
|
1689
|
-
|
|
1690
|
-
|
|
2010
|
+
// `serializeResult` always builds a fresh Response with no request id —
|
|
2011
|
+
// skip the `has()` probe and set directly. Saves one undici contains()
|
|
2012
|
+
// call per request on the hot path.
|
|
2013
|
+
response.headers.set("x-request-id", requestId);
|
|
1691
2014
|
let finalized;
|
|
1692
2015
|
if (hasFinalizeHook) {
|
|
1693
2016
|
const fin = finalizeResponse(response, ctx, allHooks, stripFingerprint);
|
|
@@ -2523,6 +2846,23 @@ async function readBody(req, ct, limit, multipart) {
|
|
|
2523
2846
|
const bytes = await readBodyLimited(req, limit);
|
|
2524
2847
|
return new TextDecoder().decode(bytes);
|
|
2525
2848
|
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Validate and normalize a route's RFC 8594 `sunset` value to an IMF-fixdate
|
|
2851
|
+
* (HTTP date) string. Accepts an ISO-8601/parseable string or a `Date`.
|
|
2852
|
+
* Throws at registration time when the value cannot be parsed into a valid
|
|
2853
|
+
* date so a typo never silently ships a malformed `Sunset` header.
|
|
2854
|
+
*
|
|
2855
|
+
* @internal
|
|
2856
|
+
*/
|
|
2857
|
+
function normalizeSunset(value, method, path) {
|
|
2858
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
2859
|
+
if (Number.isNaN(date.getTime())) {
|
|
2860
|
+
throw new Error(`app.route(): invalid sunset date for ${method} ${path}: ` +
|
|
2861
|
+
`${JSON.stringify(value)}. Provide an ISO-8601 string, an HTTP date, ` +
|
|
2862
|
+
`or a Date instance.`);
|
|
2863
|
+
}
|
|
2864
|
+
return date.toUTCString();
|
|
2865
|
+
}
|
|
2526
2866
|
function serializeResult(result, def, validateResponses) {
|
|
2527
2867
|
const spec = def.responses[result.status];
|
|
2528
2868
|
if (!spec) {
|
|
@@ -2534,6 +2874,16 @@ function serializeResult(result, def, validateResponses) {
|
|
|
2534
2874
|
const treatAsJson = !explicitCt || explicitCt.includes("application/json");
|
|
2535
2875
|
if (!explicitCt)
|
|
2536
2876
|
headers.set("content-type", "application/json");
|
|
2877
|
+
// RFC 8594 deprecation lifecycle headers. A route with an explicit
|
|
2878
|
+
// `sunset` date is implicitly deprecated. Never overwrite a value the
|
|
2879
|
+
// handler set deliberately.
|
|
2880
|
+
if (def.deprecated === true || def.sunset !== undefined) {
|
|
2881
|
+
if (!headers.has("deprecation"))
|
|
2882
|
+
headers.set("deprecation", "true");
|
|
2883
|
+
if (def.sunset !== undefined && !headers.has("sunset")) {
|
|
2884
|
+
headers.set("sunset", def.sunset);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2537
2887
|
let body;
|
|
2538
2888
|
let rawBody = null;
|
|
2539
2889
|
let isStream = false;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AsyncAPI 3.0 document generator for WebSocket surfaces.
|
|
3
|
+
*
|
|
4
|
+
* Built-in, dependency-free, and a deliberate mirror of the OpenAPI 3.1
|
|
5
|
+
* generator in `./openapi.ts`: it turns every `app.ws()` route into an
|
|
6
|
+
* AsyncAPI **channel** (the socket address + path parameters) and one or more
|
|
7
|
+
* **operations** (`receive` for client→server messages, `send` for
|
|
8
|
+
* server→client messages). The RFC 6455 stack and its CSWSH defenses finally
|
|
9
|
+
* get a contract/doc artifact, extending the contract-first story past HTTP.
|
|
10
|
+
*
|
|
11
|
+
* If a message schema exposes a `toJSONSchema()` method (Zod 4, Valibot, ...)
|
|
12
|
+
* we use it; otherwise we emit a permissive `{}` placeholder rather than fail
|
|
13
|
+
* — docs and tooling still work, just with looser types for that payload.
|
|
14
|
+
*/
|
|
15
|
+
import type { App } from "./app.js";
|
|
16
|
+
/** AsyncAPI [Info Object](https://www.asyncapi.com/docs/reference/specification/v3.0.0#infoObject) header fields. */
|
|
17
|
+
export interface AsyncAPIInfo {
|
|
18
|
+
/** Human-readable API title shown by AsyncAPI Studio / docs. */
|
|
19
|
+
title: string;
|
|
20
|
+
/** Semantic API version (independent of your package version). */
|
|
21
|
+
version: string;
|
|
22
|
+
/** Optional CommonMark long description rendered at the top of the docs. */
|
|
23
|
+
description?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* AsyncAPI [Server Object](https://www.asyncapi.com/docs/reference/specification/v3.0.0#serverObject).
|
|
27
|
+
* Unlike OpenAPI's `servers` array, AsyncAPI keys servers by name.
|
|
28
|
+
*/
|
|
29
|
+
export interface AsyncAPIServer {
|
|
30
|
+
/** Host (and optional port) the socket is reachable at, e.g. `api.example.com`. */
|
|
31
|
+
host: string;
|
|
32
|
+
/** Transport protocol, typically `ws` or `wss`. */
|
|
33
|
+
protocol: string;
|
|
34
|
+
/** Optional protocol version. */
|
|
35
|
+
protocolVersion?: string;
|
|
36
|
+
/** Optional base path prefixing channel addresses, e.g. `/realtime`. */
|
|
37
|
+
pathname?: string;
|
|
38
|
+
/** Optional human-readable server description. */
|
|
39
|
+
description?: string;
|
|
40
|
+
}
|
|
41
|
+
/** Options for {@link generateAsyncAPI}. */
|
|
42
|
+
export interface AsyncAPIOptions {
|
|
43
|
+
/** Required `info` block (title + version). */
|
|
44
|
+
info: AsyncAPIInfo;
|
|
45
|
+
/**
|
|
46
|
+
* Optional named servers exposed in the document. AsyncAPI keys servers by
|
|
47
|
+
* name (`{ production: { host, protocol } }`), not by an array.
|
|
48
|
+
*/
|
|
49
|
+
servers?: Record<string, AsyncAPIServer>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Generate an AsyncAPI 3.0 document from a registered {@link App}'s WebSocket
|
|
53
|
+
* routes.
|
|
54
|
+
*
|
|
55
|
+
* Every `app.ws()` route becomes one channel (its address + path parameters)
|
|
56
|
+
* and one or more operations:
|
|
57
|
+
*
|
|
58
|
+
* - a `receive` operation for client→server messages — payload taken from the
|
|
59
|
+
* route's `meta.receive` schema, falling back to the handler's
|
|
60
|
+
* `request.body` schema (the same schema used for payload-size checks).
|
|
61
|
+
* - a `send` operation for server→client messages — emitted only when the
|
|
62
|
+
* route declares a `meta.send` schema.
|
|
63
|
+
*
|
|
64
|
+
* The output is a plain JSON-serializable object: hand it to AsyncAPI Studio,
|
|
65
|
+
* write it to disk for codegen, or serve it from a route. When the app has no
|
|
66
|
+
* WebSocket routes the document still validates, with empty `channels` and
|
|
67
|
+
* `operations` maps.
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* import { generateAsyncAPI } from "@daloyjs/core/asyncapi";
|
|
72
|
+
* import { writeFileSync } from "node:fs";
|
|
73
|
+
*
|
|
74
|
+
* const doc = generateAsyncAPI(app, {
|
|
75
|
+
* info: { title: "Realtime API", version: "1.0.0" },
|
|
76
|
+
* servers: { production: { host: "api.example.com", protocol: "wss" } },
|
|
77
|
+
* });
|
|
78
|
+
* writeFileSync("./generated/asyncapi.json", JSON.stringify(doc, null, 2));
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* @param app - The application whose WebSocket routes are documented.
|
|
82
|
+
* @param options - Document metadata and optional named servers.
|
|
83
|
+
* @returns A JSON-serializable AsyncAPI 3.0 document.
|
|
84
|
+
* @since 0.37.0
|
|
85
|
+
*/
|
|
86
|
+
export declare function generateAsyncAPI(app: App, options: AsyncAPIOptions): Record<string, unknown>;
|
|
87
|
+
/**
|
|
88
|
+
* Serialize an AsyncAPI document to YAML.
|
|
89
|
+
*
|
|
90
|
+
* Thin alias over the dependency-free YAML 1.2 emitter shared with the
|
|
91
|
+
* OpenAPI generator ({@link openapiToYAML}) — AsyncAPI and OpenAPI documents
|
|
92
|
+
* are both plain JSON-compatible objects, so the same emitter applies.
|
|
93
|
+
*
|
|
94
|
+
* @param doc - The AsyncAPI document produced by {@link generateAsyncAPI}.
|
|
95
|
+
* @returns The document rendered as a YAML string.
|
|
96
|
+
* @since 0.37.0
|
|
97
|
+
*/
|
|
98
|
+
export declare function asyncapiToYAML(doc: Record<string, unknown>): string;
|