@daloyjs/core 0.36.0 → 0.38.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/LICENSE +21 -0
- package/README.md +34 -3
- 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 +25 -0
- package/dist/adapters/node.js +32 -0
- package/dist/app.d.ts +200 -6
- package/dist/app.js +235 -50
- 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 +113 -4
- package/dist/client.d.ts +23 -0
- package/dist/client.js +16 -0
- 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 +43 -0
- package/dist/errors.js +57 -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 +39 -5
- package/dist/index.js +19 -2
- 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 +61 -7
- package/dist/security.js +75 -8
- 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 +79 -3
package/dist/app.js
CHANGED
|
@@ -2,7 +2,7 @@ import { Router } from "./router.js";
|
|
|
2
2
|
import { WebSocketRegistry, normalizeWebSocketOptions, } from "./websocket.js";
|
|
3
3
|
import { BadRequestError, ForbiddenError, HttpError, InternalError, MethodNotAllowedError, NotFoundError, PayloadTooLargeError, RequestTimeoutError, TooManyRequestsError, UnsupportedMediaTypeError, ValidationError, } from "./errors.js";
|
|
4
4
|
import { validate } from "./schema.js";
|
|
5
|
-
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
|
|
5
|
+
import { readBodyLimited, safeJsonParse, randomId, assertNoDuplicateSingletonHeaders, assertNoReservedInternalHeaders, assertHeaderCountWithinLimit, DEFAULT_MAX_HEADER_COUNT, assertStrongSecret, timingSafeEqual, isForbiddenObjectKey } from "./security.js";
|
|
6
6
|
import { createLogger, noopLogger } from "./logger.js";
|
|
7
7
|
import { generateOpenAPI, openapiToYAML, } from "./openapi.js";
|
|
8
8
|
import { docsContentSecurityPolicy, scalarHtml, swaggerUiHtml, } from "./docs.js";
|
|
@@ -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");
|
|
@@ -147,6 +149,7 @@ function applySecurityPreset(options) {
|
|
|
147
149
|
const DEFAULTS = {
|
|
148
150
|
bodyLimitBytes: 1024 * 1024,
|
|
149
151
|
requestTimeoutMs: 30_000,
|
|
152
|
+
maxHeaderCount: DEFAULT_MAX_HEADER_COUNT,
|
|
150
153
|
validateResponses: true,
|
|
151
154
|
};
|
|
152
155
|
const TEXT_ENCODER = new TextEncoder();
|
|
@@ -179,58 +182,21 @@ export const DALOY_REQUEST_RAW_BODY = Symbol.for("daloyjs.request.rawBody");
|
|
|
179
182
|
*/
|
|
180
183
|
export const DALOY_RAW_STREAM = Symbol.for("daloyjs.response.rawStream");
|
|
181
184
|
/**
|
|
182
|
-
* Contract-first HTTP application.
|
|
183
|
-
*
|
|
184
|
-
* `App` is the top-level entry point: register {@link RouteDefinition routes}
|
|
185
|
-
* with {@link App.route}, layer cross-cutting behavior with
|
|
186
|
-
* {@link App.use}/{@link App.register}, then expose the application to a
|
|
187
|
-
* runtime via {@link App.fetch} (Web standard) or one of the adapter subpaths
|
|
188
|
-
* such as `@daloyjs/core/node`, `@daloyjs/core/cloudflare`, or
|
|
189
|
-
* `@daloyjs/core/lambda`.
|
|
190
|
-
*
|
|
191
|
-
* The same `App` instance powers:
|
|
192
|
-
*
|
|
193
|
-
* - request routing (`Router` under the hood)
|
|
194
|
-
* - request/response validation against Standard-Schema validators
|
|
195
|
-
* - OpenAPI 3.1 generation (`generateOpenAPI(app)`)
|
|
196
|
-
* - typed in-process client (`createClient(app)`) and generated SDK
|
|
197
|
-
* - graceful shutdown and lifecycle observability
|
|
198
|
-
*
|
|
199
|
-
* `App` is **runtime-agnostic**: the same instance runs on Node, Bun, Deno,
|
|
200
|
-
* Cloudflare Workers, Vercel Edge, AWS Lambda, and Fastly Compute via the
|
|
201
|
-
* dedicated adapters.
|
|
202
|
-
*
|
|
203
|
-
* @example
|
|
204
|
-
* ```ts
|
|
205
|
-
* import { App, secureHeaders } from "@daloyjs/core";
|
|
206
|
-
* import { z } from "zod";
|
|
207
|
-
*
|
|
208
|
-
* const app = new App({ title: "Books API", version: "1.0.0" });
|
|
209
|
-
*
|
|
210
|
-
* app.use(secureHeaders());
|
|
211
|
-
*
|
|
212
|
-
* app.route({
|
|
213
|
-
* method: "GET",
|
|
214
|
-
* path: "/books/:id",
|
|
215
|
-
* operationId: "getBook",
|
|
216
|
-
* request: { params: z.object({ id: z.uuid() }) },
|
|
217
|
-
* responses: {
|
|
218
|
-
* 200: { description: "OK", body: z.object({ id: z.string(), title: z.string() }) },
|
|
219
|
-
* },
|
|
220
|
-
* handler: ({ params }) => ({ status: 200, body: { id: params.id, title: "Dune" } }),
|
|
221
|
-
* });
|
|
222
|
-
*
|
|
223
|
-
* // Node:
|
|
224
|
-
* import { serve } from "@daloyjs/core/node";
|
|
225
|
-
* serve(app, { port: 3000 });
|
|
226
|
-
* ```
|
|
227
|
-
*
|
|
228
185
|
* @since 0.1.0
|
|
229
186
|
*/
|
|
230
187
|
export class App {
|
|
231
188
|
options;
|
|
232
189
|
log;
|
|
233
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* Public registry: enables OpenAPI gen, typed-client gen, dead-route detection.
|
|
192
|
+
*
|
|
193
|
+
* Statically the property is typed as the `Routes` tuple so that
|
|
194
|
+
* {@link App.route} can accumulate each registered route's literal
|
|
195
|
+
* `operationId`, request, and response types. The typed client
|
|
196
|
+
* (`createClient(app)`) reads this tuple to derive a precisely-typed method
|
|
197
|
+
* per route. At runtime it is an ordinary growable array — the tuple typing
|
|
198
|
+
* is a compile-time view only.
|
|
199
|
+
*/
|
|
234
200
|
routes = [];
|
|
235
201
|
router = new Router();
|
|
236
202
|
/**
|
|
@@ -261,6 +227,12 @@ export class App {
|
|
|
261
227
|
installedPlugins = new Set();
|
|
262
228
|
closeHooks = [];
|
|
263
229
|
closeHooksRun = false;
|
|
230
|
+
/**
|
|
231
|
+
* Lazily-created in-process scheduler backing {@link App.cron}. Started on
|
|
232
|
+
* the first `cron()` call and stopped from an `onClose` hook so its lifecycle
|
|
233
|
+
* is tied to graceful shutdown.
|
|
234
|
+
*/
|
|
235
|
+
scheduler;
|
|
264
236
|
/** Idle-connection close hooks (adapter-registered, sync). */
|
|
265
237
|
idleConnectionCloseHooks = [];
|
|
266
238
|
pluginInstalledListeners = [];
|
|
@@ -320,6 +292,7 @@ export class App {
|
|
|
320
292
|
validateResponses: resolved.validateResponses ?? DEFAULTS.validateResponses,
|
|
321
293
|
bodyLimitBytes: resolved.bodyLimitBytes ?? DEFAULTS.bodyLimitBytes,
|
|
322
294
|
requestTimeoutMs: resolved.requestTimeoutMs ?? DEFAULTS.requestTimeoutMs,
|
|
295
|
+
maxHeaderCount: resolved.maxHeaderCount ?? DEFAULTS.maxHeaderCount,
|
|
323
296
|
...resolved,
|
|
324
297
|
};
|
|
325
298
|
this.log =
|
|
@@ -448,6 +421,7 @@ export class App {
|
|
|
448
421
|
trustProxy: o.trustProxy === undefined ? "unconfigured" : o.trustProxy,
|
|
449
422
|
bodyLimitBytes: this.options.bodyLimitBytes,
|
|
450
423
|
requestTimeoutMs: this.options.requestTimeoutMs,
|
|
424
|
+
maxHeaderCount: this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT,
|
|
451
425
|
stripServerHeaders: o.stripServerHeaders !== false,
|
|
452
426
|
production: this.isProduction(),
|
|
453
427
|
});
|
|
@@ -861,11 +835,16 @@ export class App {
|
|
|
861
835
|
handler: async () => {
|
|
862
836
|
const title = opts.title ?? (await resolveInfo()).title;
|
|
863
837
|
const html = ui === "swagger"
|
|
864
|
-
? swaggerUiHtml({
|
|
838
|
+
? swaggerUiHtml({
|
|
839
|
+
specUrl: openapiPath,
|
|
840
|
+
title,
|
|
841
|
+
assets: opts.assets,
|
|
842
|
+
})
|
|
865
843
|
: scalarHtml({
|
|
866
844
|
specUrl: openapiPath,
|
|
867
845
|
title,
|
|
868
846
|
configuration: opts.scalar,
|
|
847
|
+
assets: opts.assets,
|
|
869
848
|
});
|
|
870
849
|
return {
|
|
871
850
|
status: 200,
|
|
@@ -902,8 +881,16 @@ export class App {
|
|
|
902
881
|
* });
|
|
903
882
|
* ```
|
|
904
883
|
*
|
|
884
|
+
* The return type widens `Routes` with the freshly-registered route so
|
|
885
|
+
* that chained registration (`new App().route(a).route(b)`) accumulates a
|
|
886
|
+
* precise tuple. The typed client (`createClient(app)`) consumes that tuple
|
|
887
|
+
* to expose a method per `operationId` with parameters and responses
|
|
888
|
+
* inferred from the route's own schemas. Non-chained calls
|
|
889
|
+
* (`app.route(a); app.route(b);`) keep the variable's original type, so
|
|
890
|
+
* chain the calls when you want the inferred client surface.
|
|
891
|
+
*
|
|
905
892
|
* @param def - The route definition.
|
|
906
|
-
* @returns This `App` instance for chaining.
|
|
893
|
+
* @returns This `App` instance (widened with the new route) for chaining.
|
|
907
894
|
*/
|
|
908
895
|
route(def) {
|
|
909
896
|
// Refuse non-canonical HTTP methods at runtime.
|
|
@@ -930,6 +917,13 @@ export class App {
|
|
|
930
917
|
auth: def.auth ?? this.groupAuth,
|
|
931
918
|
};
|
|
932
919
|
this.assertRouteAuthPayloadConfig(merged);
|
|
920
|
+
// Normalize an optional RFC 8594 sunset date to a stable IMF-fixdate
|
|
921
|
+
// (HTTP date) string once, at registration time, so the hot response
|
|
922
|
+
// path can emit the `Sunset` header without re-parsing per request and
|
|
923
|
+
// a bad value fails fast rather than silently emitting garbage.
|
|
924
|
+
if (merged.sunset !== undefined) {
|
|
925
|
+
merged.sunset = normalizeSunset(merged.sunset, merged.method, fullPath);
|
|
926
|
+
}
|
|
933
927
|
const sources = [...this.groupHooks, def.hooks ?? {}];
|
|
934
928
|
const hooks = mergeHooks(sources);
|
|
935
929
|
const corsOriginAllows = corsOriginAllowsFromHooks(sources);
|
|
@@ -945,6 +939,9 @@ export class App {
|
|
|
945
939
|
...sources,
|
|
946
940
|
]);
|
|
947
941
|
this.router.add(def.method, fullPath, { def: merged, hooks, mergedHooks, hasFinalizeHook, corsOriginAllows, fullCorsOriginAllows }, def.operationId);
|
|
942
|
+
// `routes` is statically a readonly tuple so the typed client can infer
|
|
943
|
+
// per-route methods; at runtime it is a growable array, so we push through
|
|
944
|
+
// a mutable view.
|
|
948
945
|
this.routes.push(merged);
|
|
949
946
|
this.routeSecurityMarkers.push({
|
|
950
947
|
method: merged.method,
|
|
@@ -1082,6 +1079,166 @@ export class App {
|
|
|
1082
1079
|
});
|
|
1083
1080
|
return this;
|
|
1084
1081
|
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Register an opt-in, auth-guarded Prometheus / OpenMetrics scrape route
|
|
1084
|
+
* and install RED (Rate / Errors / Duration) instrumentation for every
|
|
1085
|
+
* route registered **after** this call. The third observability pillar
|
|
1086
|
+
* alongside the structured logger and the OpenTelemetry tracer.
|
|
1087
|
+
*
|
|
1088
|
+
* Exposes, in the Prometheus text exposition format:
|
|
1089
|
+
* - `<prefix>http_requests_total{method,route,status}` — request counter,
|
|
1090
|
+
* - `<prefix>http_request_duration_seconds{method,route}` — latency histogram,
|
|
1091
|
+
* - `<prefix>http_requests_in_flight` — concurrency gauge,
|
|
1092
|
+
* - process gauges (resident memory, heap used, uptime) on Node-like runtimes.
|
|
1093
|
+
*
|
|
1094
|
+
* The scrape route inherits the same hardened posture as
|
|
1095
|
+
* {@link App.healthcheck}: optional bearer token compared via
|
|
1096
|
+
* {@link timingSafeEqual}, a per-IP fixed-window rate limit, and a
|
|
1097
|
+
* refuse-to-boot guard in production (an unauthenticated `/metrics`
|
|
1098
|
+
* endpoint leaks internal route names, latency, and traffic volume) unless
|
|
1099
|
+
* a token is supplied or `acknowledgeUnauthenticated: true` is passed.
|
|
1100
|
+
*
|
|
1101
|
+
* Call this **before** registering the routes you want measured — like any
|
|
1102
|
+
* `app.use(...)` middleware, the instrumentation only wraps routes added
|
|
1103
|
+
* afterwards. Pass `opts.registry` to register custom application metrics
|
|
1104
|
+
* that are rendered alongside the built-in HTTP series.
|
|
1105
|
+
*
|
|
1106
|
+
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1107
|
+
* @returns `this` for chaining.
|
|
1108
|
+
* @since 0.37.0
|
|
1109
|
+
*/
|
|
1110
|
+
metrics(opts = {}) {
|
|
1111
|
+
const path = (opts.path ?? "/metrics");
|
|
1112
|
+
const registry = opts.registry ?? new MetricsRegistry();
|
|
1113
|
+
const rateLimitConfig = opts.rateLimit === false
|
|
1114
|
+
? null
|
|
1115
|
+
: { limit: 60, windowMs: 60_000, ...(opts.rateLimit ?? {}) };
|
|
1116
|
+
const token = opts.token;
|
|
1117
|
+
// Refuse-to-boot: an unauthenticated metrics scrape in production is a
|
|
1118
|
+
// documented info-disclosure surface (route inventory, latency
|
|
1119
|
+
// distributions, request volume, process memory). Force an explicit
|
|
1120
|
+
// acknowledgement, mirroring app.healthcheck().
|
|
1121
|
+
if (this.options.secureDefaults !== false &&
|
|
1122
|
+
this.isProduction() &&
|
|
1123
|
+
token === undefined &&
|
|
1124
|
+
opts.acknowledgeUnauthenticated !== true) {
|
|
1125
|
+
throw new Error(`app.metrics() refused in production: provide opts.token to require ` +
|
|
1126
|
+
`Authorization: Bearer <token>, or pass acknowledgeUnauthenticated: true ` +
|
|
1127
|
+
`to acknowledge that this scrape endpoint is reachable without credentials.`);
|
|
1128
|
+
}
|
|
1129
|
+
// Install RED instrumentation as a group hook so it wraps every route
|
|
1130
|
+
// registered after this call. Always exclude the scrape path itself, plus
|
|
1131
|
+
// any caller-supplied predicate.
|
|
1132
|
+
const exclude = (p) => p === path || (opts.exclude ? opts.exclude(p) : false);
|
|
1133
|
+
this.groupHooks.push(httpMetrics({
|
|
1134
|
+
registry,
|
|
1135
|
+
route: opts.route,
|
|
1136
|
+
maxRouteCardinality: opts.maxRouteCardinality,
|
|
1137
|
+
buckets: opts.buckets,
|
|
1138
|
+
exclude,
|
|
1139
|
+
}));
|
|
1140
|
+
const buckets = rateLimitConfig
|
|
1141
|
+
? new Map()
|
|
1142
|
+
: null;
|
|
1143
|
+
this.route({
|
|
1144
|
+
method: "GET",
|
|
1145
|
+
path,
|
|
1146
|
+
operationId: "metrics",
|
|
1147
|
+
tags: ["Observability"],
|
|
1148
|
+
summary: "Prometheus metrics scrape endpoint",
|
|
1149
|
+
handler: async ({ request }) => {
|
|
1150
|
+
if (buckets && rateLimitConfig) {
|
|
1151
|
+
const key = healthRouteKey(request);
|
|
1152
|
+
const now = Date.now();
|
|
1153
|
+
const entry = buckets.get(key);
|
|
1154
|
+
if (!entry || entry.resetMs <= now) {
|
|
1155
|
+
buckets.set(key, { count: 1, resetMs: now + rateLimitConfig.windowMs });
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
entry.count++;
|
|
1159
|
+
if (entry.count > rateLimitConfig.limit) {
|
|
1160
|
+
throw new TooManyRequestsError(Math.ceil((entry.resetMs - now) / 1000));
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
if (token !== undefined) {
|
|
1165
|
+
const h = request.headers.get("authorization") ?? "";
|
|
1166
|
+
const m = /^Bearer\s+(.+)$/i.exec(h);
|
|
1167
|
+
if (!m) {
|
|
1168
|
+
throw new HttpError(401, {
|
|
1169
|
+
type: "https://daloyjs.dev/errors/unauthorized",
|
|
1170
|
+
title: "Unauthorized",
|
|
1171
|
+
detail: "Metrics scrape requires a bearer token.",
|
|
1172
|
+
}, { "www-authenticate": 'Bearer realm="metrics"' });
|
|
1173
|
+
}
|
|
1174
|
+
if (!timingSafeEqual(m[1], token)) {
|
|
1175
|
+
throw new ForbiddenError("Invalid metrics scrape token.");
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
return {
|
|
1179
|
+
status: 200,
|
|
1180
|
+
body: registry.render(),
|
|
1181
|
+
headers: {
|
|
1182
|
+
"content-type": PROMETHEUS_CONTENT_TYPE,
|
|
1183
|
+
"cache-control": "no-store",
|
|
1184
|
+
},
|
|
1185
|
+
};
|
|
1186
|
+
},
|
|
1187
|
+
responses: {
|
|
1188
|
+
200: { description: "Prometheus metrics exposition." },
|
|
1189
|
+
429: { description: "Too many scrape requests." },
|
|
1190
|
+
},
|
|
1191
|
+
});
|
|
1192
|
+
return this;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Register an in-process scheduled task (cron). The first call lazily creates
|
|
1196
|
+
* an app-managed {@link Scheduler}, wires it to the app logger, starts it,
|
|
1197
|
+
* and registers an `onClose` hook so it is drained on graceful shutdown
|
|
1198
|
+
* (in-flight runs are awaited, then aborted if they outlast the shutdown
|
|
1199
|
+
* grace period).
|
|
1200
|
+
*
|
|
1201
|
+
* The schedule is **queue-agnostic** — it runs work in *this* process on a
|
|
1202
|
+
* fixed interval or cron expression. Use it for periodic maintenance
|
|
1203
|
+
* (cache sweeps, token refresh, reconciliation) rather than as a distributed
|
|
1204
|
+
* job queue. Each task is **single-flight**: if a tick fires while the
|
|
1205
|
+
* previous run is still in progress, the tick is skipped and counted, so a
|
|
1206
|
+
* slow task can never pile up overlapping runs.
|
|
1207
|
+
*
|
|
1208
|
+
* @example
|
|
1209
|
+
* ```ts
|
|
1210
|
+
* app.cron({ name: "sweep", cron: "0 * * * *" }, async ({ signal }) => {
|
|
1211
|
+
* await purgeExpiredSessions({ signal });
|
|
1212
|
+
* });
|
|
1213
|
+
* ```
|
|
1214
|
+
*
|
|
1215
|
+
* @param def - The task definition. Exactly one of `intervalMs` or `cron`.
|
|
1216
|
+
* @param handler - The function to run on each tick.
|
|
1217
|
+
* @returns This `App` instance for chaining.
|
|
1218
|
+
* @throws {RangeError} on invalid options (see {@link Scheduler.define}).
|
|
1219
|
+
* @throws {@link CronParseError} if a `cron` expression is malformed.
|
|
1220
|
+
*/
|
|
1221
|
+
cron(def, handler) {
|
|
1222
|
+
if (this.scheduler === undefined) {
|
|
1223
|
+
const scheduler = new Scheduler({ logger: this.log.child({ component: "scheduler" }) });
|
|
1224
|
+
this.scheduler = scheduler;
|
|
1225
|
+
scheduler.start();
|
|
1226
|
+
// Drain the scheduler during the post-drain close phase so periodic
|
|
1227
|
+
// work stops cleanly alongside database pools and other resources.
|
|
1228
|
+
this.onClose(() => scheduler.stop());
|
|
1229
|
+
}
|
|
1230
|
+
this.scheduler.define(def, handler);
|
|
1231
|
+
return this;
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* The app-managed {@link Scheduler} backing {@link App.cron}, or `undefined`
|
|
1235
|
+
* if no scheduled task has been registered. Exposed for inspection
|
|
1236
|
+
* (`getState()` / `list()`) and manual triggering (`runNow()`); the lifecycle
|
|
1237
|
+
* is owned by the app.
|
|
1238
|
+
*/
|
|
1239
|
+
get scheduledTasks() {
|
|
1240
|
+
return this.scheduler;
|
|
1241
|
+
}
|
|
1085
1242
|
registerHealthRoute(kind, opts, handler) {
|
|
1086
1243
|
const isHealth = kind === "healthcheck";
|
|
1087
1244
|
const defaultPath = (isHealth ? "/healthz" : "/readyz");
|
|
@@ -1654,6 +1811,7 @@ export class App {
|
|
|
1654
1811
|
try {
|
|
1655
1812
|
assertNoDuplicateSingletonHeaders(request.headers);
|
|
1656
1813
|
assertNoReservedInternalHeaders(request.headers);
|
|
1814
|
+
assertHeaderCountWithinLimit(request.headers, this.options.maxHeaderCount ?? DEFAULT_MAX_HEADER_COUNT);
|
|
1657
1815
|
this.assertTrustProxyConfigured(request);
|
|
1658
1816
|
this.assertBootGuards();
|
|
1659
1817
|
if (globalHooks.onRequest !== undefined) {
|
|
@@ -2666,6 +2824,23 @@ async function readBody(req, ct, limit, multipart) {
|
|
|
2666
2824
|
const bytes = await readBodyLimited(req, limit);
|
|
2667
2825
|
return new TextDecoder().decode(bytes);
|
|
2668
2826
|
}
|
|
2827
|
+
/**
|
|
2828
|
+
* Validate and normalize a route's RFC 8594 `sunset` value to an IMF-fixdate
|
|
2829
|
+
* (HTTP date) string. Accepts an ISO-8601/parseable string or a `Date`.
|
|
2830
|
+
* Throws at registration time when the value cannot be parsed into a valid
|
|
2831
|
+
* date so a typo never silently ships a malformed `Sunset` header.
|
|
2832
|
+
*
|
|
2833
|
+
* @internal
|
|
2834
|
+
*/
|
|
2835
|
+
function normalizeSunset(value, method, path) {
|
|
2836
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
2837
|
+
if (Number.isNaN(date.getTime())) {
|
|
2838
|
+
throw new Error(`app.route(): invalid sunset date for ${method} ${path}: ` +
|
|
2839
|
+
`${JSON.stringify(value)}. Provide an ISO-8601 string, an HTTP date, ` +
|
|
2840
|
+
`or a Date instance.`);
|
|
2841
|
+
}
|
|
2842
|
+
return date.toUTCString();
|
|
2843
|
+
}
|
|
2669
2844
|
function serializeResult(result, def, validateResponses) {
|
|
2670
2845
|
const spec = def.responses[result.status];
|
|
2671
2846
|
if (!spec) {
|
|
@@ -2677,6 +2852,16 @@ function serializeResult(result, def, validateResponses) {
|
|
|
2677
2852
|
const treatAsJson = !explicitCt || explicitCt.includes("application/json");
|
|
2678
2853
|
if (!explicitCt)
|
|
2679
2854
|
headers.set("content-type", "application/json");
|
|
2855
|
+
// RFC 8594 deprecation lifecycle headers. A route with an explicit
|
|
2856
|
+
// `sunset` date is implicitly deprecated. Never overwrite a value the
|
|
2857
|
+
// handler set deliberately.
|
|
2858
|
+
if (def.deprecated === true || def.sunset !== undefined) {
|
|
2859
|
+
if (!headers.has("deprecation"))
|
|
2860
|
+
headers.set("deprecation", "true");
|
|
2861
|
+
if (def.sunset !== undefined && !headers.has("sunset")) {
|
|
2862
|
+
headers.set("sunset", def.sunset);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2680
2865
|
let body;
|
|
2681
2866
|
let rawBody = null;
|
|
2682
2867
|
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;
|