@daloyjs/core 1.2.0 → 1.3.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 +3 -2
- package/dist/adapters/cloudflare.d.ts +8 -1
- package/dist/adapters/cloudflare.js +15 -1
- package/dist/adapters/lambda.js +15 -5
- package/dist/adapters/vercel.d.ts +5 -0
- package/dist/adapters/vercel.js +27 -2
- package/dist/app.d.ts +136 -10
- package/dist/app.js +382 -69
- package/dist/combine.js +14 -8
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/jobs.d.ts +665 -0
- package/dist/jobs.js +1102 -0
- package/dist/metrics.d.ts +23 -9
- package/dist/metrics.js +33 -10
- package/dist/otlp.d.ts +31 -5
- package/dist/otlp.js +99 -37
- package/dist/sbom.cdx.json +9 -9
- package/dist/sbom.spdx.json +5 -5
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -664,13 +664,14 @@ The framework refuses to start (or to construct) when configuration is unsafe:
|
|
|
664
664
|
- `idempotency()` with `Idempotency-Key` fingerprinting + byte-for-byte response replay, in-flight `409`, `422` on key reuse with a different payload, and a pluggable `IdempotencyStore` (in-memory default) at `@daloyjs/core/idempotency`.
|
|
665
665
|
- `responseCache()` server-side body cache (cache-key + TTL with `s-maxage`/`max-age` orchestration, request `no-store`/`no-cache` directives, recursion-safe stale-while-revalidate, proactive `varyHeaders` keying, `X-Cache` HIT/MISS/STALE marker, pluggable `ResponseCacheStore` whose in-memory default is bounded on both entry count and retained bytes) at `@daloyjs/core/response-cache`. Never caches `Set-Cookie`, `private`/`no-store`/`no-cache`, or `Vary: *` responses, and strips `Age`/hop-by-hop/`X-Request-Id` from stored entries so a hit never replays another request's correlation id. **Fail-closed on every principal dimension (CWE-524):** the key is the full _effective request URI_ including the authority (RFC 9111 §4), so hostnames never share entries; requests carrying `Authorization` **or** `Cookie` bypass the shared cache unless a `principal` names the caller (then each gets its own entry) or the header is explicitly declared shareable; a tenant resolved by `tenancy()` is folded into the key automatically — with a boot guard that refuses to start if the cache is mounted ahead of `tenancy()`; and the response's **own `Vary` header** is honoured as a secondary key (RFC 9111 §4.1), so the `Vary: Origin` written by `cors()` and the `Vary: Accept-Encoding` written by `compression()` keep one caller's allowed origin — or their gzipped bytes — from being served to the next, with each variant stored separately so they all stay warm. Complements `etag()`/`compression()`, which do not cache bodies.
|
|
666
666
|
- `paginationQuery()` / `encodeCursor()` / `decodeCursor()` / `buildPageLinks()` / `buildLinkHeader()` cursor-pagination helpers at `@daloyjs/core/pagination`: opaque base64url cursors (length-capped, prototype-pollution-safe decode → `400` on tamper), RFC 8288 `Link` header emission with CRLF / header-injection guards, and a Standard Schema that validates `cursor`/`limit` and auto-wires both into the OpenAPI spec + typed client via `toJSONSchema()`.
|
|
667
|
-
- `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`) plus process gauges, exposition-injection-safe name/label validation, a per-metric cardinality cap, and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production). The repo ships an `examples/observability/` Docker Compose stack that starts a pre-configured Prometheus + Grafana pair (with an auto-provisioned RED + heatmap dashboard) against any local app via `docker compose -f examples/observability/docker-compose.yml up`.
|
|
667
|
+
- `app.metrics()` + `MetricsRegistry` / `httpMetrics()` Prometheus / OpenMetrics exposition at `@daloyjs/core/metrics`: dependency-free counters / gauges / histograms, RED instrumentation (`http_requests_total`, `http_request_duration_seconds`, `http_requests_in_flight`, `route` from the matched template) plus unprefixed process gauges (`process_resident_memory_bytes`), exposition-injection-safe name/label validation, a per-metric cardinality cap (`daloy_metrics_series_dropped_total`), and an opt-in `/metrics` route with the same hardened posture as `app.healthcheck()` (bearer token + portable `timingSafeEqual`, per-IP rate limit, refuse-to-boot unauthenticated in production). Pull scrape is for long-lived processes; on Workers / Lambda / Vercel use `telemetry: true` (OTLP push). The repo ships an `examples/observability/` Docker Compose stack that starts a pre-configured Prometheus + Grafana pair (with an auto-provisioned RED + heatmap dashboard) against any local app via `docker compose -f examples/observability/docker-compose.yml up`.
|
|
668
668
|
- `otelTracing()` OpenTelemetry-compatible distributed tracing at `@daloyjs/core/tracing`: a dependency-free `Hooks` bundle that opens one `SERVER` span per request, attaches HTTP semantic-convention attributes (`http.request.method`, `url.path`, `server.address` / `server.port`, `http.response.status_code`, …), records exceptions + escalates `5xx` to `ERROR`, guarantees a single `span.end()`, and exposes the live span on `ctx.state.otelSpan`. Bring any tracer matching the small `TracingTracer` interface (the real `@opentelemetry/api` SDK on Node, or a custom exporter on Workers/Deno) plus your own propagator via `contextFromRequest` for `traceparent` continuation — no OTel SDK is forced into your install. The `examples/observability/` stack also runs **Jaeger**, and `examples/otel-tracing-demo.ts` ships a ~120-line dependency-free OTLP/HTTP exporter that streams spans straight to it.
|
|
669
|
-
- `new App({ telemetry: true })` native OpenTelemetry OTLP push export at `@daloyjs/core/otlp`: one flag tees the app logger to the collector as OTLP logs and records `http.server.request.duration` per the OTel HTTP semantic conventions (spec attributes incl. `http.route` from the matched route template via the new `ctx.routePath`, spec bucket boundaries), pushed as dependency-free OTLP/HTTP JSON to the endpoint in the standard `OTEL_EXPORTER_OTLP_*` env vars — zero config on collector-based platforms that inject them, silent no-op without them. Standalone `createOtlpLogExporter()` / `createOtlpMetricsExporter()` (cumulative temporality — totals survive failed pushes) / `semconvHttpMetrics()` exports; fail-safe by contract (bounded queues, series-cardinality cap, a dead collector never affects serving, tenant-routing header values never logged).
|
|
669
|
+
- `new App({ telemetry: true })` native OpenTelemetry OTLP push export at `@daloyjs/core/otlp`: one flag tees the app logger to the collector as OTLP logs and records `http.server.request.duration` per the OTel HTTP semantic conventions (spec attributes incl. `http.route` from the matched route template via the new `ctx.routePath`, spec bucket boundaries), pushed as dependency-free OTLP/HTTP JSON to the endpoint in the standard `OTEL_EXPORTER_OTLP_*` env vars — zero config on collector-based platforms that inject them, silent no-op without them. Standalone `createOtlpLogExporter()` / `createOtlpMetricsExporter()` (cumulative temporality — totals survive failed pushes) / `semconvHttpMetrics()` exports; fail-safe by contract (bounded queues, series-cardinality cap, a 5s export timeout, a dead collector never affects serving, tenant-routing header values never logged). Isolate runtimes flush per request through `toFetchHandler` (Cloudflare `waitUntil`, Vercel) and `toLambdaHandler` (awaited); Node/Bun/Deno flush on an interval plus shutdown.
|
|
670
670
|
- `tenancy()` secure-by-default multitenancy at `@daloyjs/core/tenancy`: a dependency-free `Hooks` bundle that resolves the calling tenant once per request and exposes it on `ctx.state.tenant`. Pluggable resolution (`tenantFromSubdomain` PSL-aware, `tenantFromHeader`, `tenantFromPathPrefix`, `tenantFromClaim`, or a custom `(ctx) => string`, tried in array order). **Refuse-unresolved by default** (no ambient "default" tenant leak), **format-validated ids** (rejects key/log-injection + cache-poisoning payloads before they reach a key), **no-enumeration `404`** for unknown tenants, and **host-spoof-safe** subdomain resolution. A `tenantScope()` key helper drops straight into `rateLimit` `keyGenerator` and `concurrencyLimit` / `idempotency` `scope` to partition each per tenant (CWE-524 cross-tenant cache defense); `responseCache()` needs no wiring at all — it reads the resolved tenant itself and refuses to boot if mounted ahead of `tenancy()`. Runnable `examples/multitenancy-demo.ts`.
|
|
671
671
|
- `resilientFetch()` + `CircuitBreaker` outbound resilience at `@daloyjs/core/fetch-resilience`: a dependency-free circuit breaker (`closed → open → half-open`), retry-with-backoff (exponential + full jitter, idempotent-method/transient-status scoped, honours `Retry-After`), and a per-call timeout (`AbortController` → `FetchTimeoutError`) designed to layer **on top of** `fetchGuard()` — an `SsrfBlockedError` is a terminal refusal that is never retried and never trips the breaker, so SSRF protection stays intact under the resilience layer.
|
|
672
672
|
- `createWebhookSender()` + `MemoryWebhookDeadLetterSink` outbound webhook delivery at `@daloyjs/core/webhook-delivery`: the outbound counterpart to `verifyWebhookSignature()` — timestamped HMAC-signed `POST`s (`webhook-id` / `webhook-timestamp` / `webhook-signature`, computed over `"<timestamp>.<body>"` and reused across retries for safe deduping), bounded retry-with-backoff (transient-status + network scoped, honours `Retry-After`), per-attempt timeout, and dead-letter semantics. Transport defaults to `fetchGuard()`, so a subscriber URL pointing at cloud metadata or a private range is refused with a terminal `SsrfBlockedError` (never retried, dead-lettered once). Zero runtime dependencies.
|
|
673
673
|
- `app.cron()` + standalone `Scheduler` in-process scheduled tasks at `@daloyjs/core/scheduler`: a queue-agnostic schedule primitive for periodic housekeeping (cache sweeps, token refresh, reconciliation). Fixed intervals or 5-field cron expressions (lists / ranges / steps / named months & days / `@hourly`–`@yearly` aliases / optional IANA `timeZone`), arithmetic cron parsing (no backtracking regex), fixed-rate **single-flight** (overlapping ticks are skipped, never run concurrently), per-run `timeoutMs` with `AbortSignal`, and graceful-shutdown integration (stop arming → await in-flight → abort after grace). Timers are `unref`'d. `parseCron()` / `nextCronRun()` exported standalone. Zero runtime dependencies.
|
|
674
|
+
- Background jobs (queue-agnostic) at `@daloyjs/core/jobs`: durable `{ name, payload }` units that outlive the HTTP request and the process — `JobStore` SPI (all durability lives behind it; Redis/Postgres/SQS are user adapters, never core deps), `MemoryJobStore` for tests and single-process apps, `createJobQueue()` (name/queue charset allowlists, plain-JSON payloads capped at 64 KiB with prototype-pollution rejection, idempotency-key dedupe with conflict-on-reuse, delayed `runAt`, integer `priority`), and `createJobWorker()` (atomic claims with leases + fencing, auto-heartbeat plus `ctx.heartbeat()`, bounded concurrency, per-attempt `timeoutMs` via `AbortSignal`, retries with exponential backoff + full jitter, dead letters, graceful `stop()` drain, `runOnce()` for tests). Delivery is **at-least-once** — handlers must be idempotent (pass a key through to downstream APIs like Stripe's `Idempotency-Key`); `EnqueueOptions.idempotencyKey` collapses duplicate producers the way `idempotency()` collapses duplicate POSTs. `app.useJobs({ store, handlers, startWorker })` drains the worker on graceful shutdown and warns on a Memory store under production config; `app.cronEnqueue()` turns a cron tick into an idempotent enqueue so multi-replica crons do not double-fire. This is not a workflow/replay engine — no durable functions, no worlds, no `await sleep("7 days")`. Zero runtime dependencies.
|
|
674
675
|
- `clientCertAuth()` mTLS / client-certificate auth at `@daloyjs/core/mtls`: authenticate zero-trust / service-to-service callers by their TLS client certificate from two sources — **native TLS** (the Node adapter lazily reads the peer cert off the socket; plain requests pay nothing) or a **TLS-terminating proxy** (Envoy `X-Forwarded-Client-Cert` and nginx/HAProxy-style structured headers). `requireVerified` by default, exact `allowSubjectCNs` / `allowIssuerCNs`, **constant-time** `allowFingerprints`, `allowSANs` (SPIFFE/DNS/URI/IP, `TYPE:value` or bare), validity-window enforcement, and a custom async `verify()` hook. Missing cert → `401` problem+json with `Cache-Control: no-store`; any failed check → `403` (never echoes cert details). The accepted `ClientCertificate` is stamped on `ctx.state`. `parseForwardedClientCert()` / `normalizePeerCertificate()` exported standalone. Zero runtime dependencies.
|
|
675
676
|
- `autoBan()` adaptive auto-ban (fail2ban-style) at `@daloyjs/core/auto-ban`: temporarily ban abusive clients after repeated suspicious responses (default `401` / `403` / `429`, configurable `watchStatuses`) within a rolling `windowMs`. Bans **escalate** exponentially for repeat offenders (`banMs` → `2×` → `4×`, capped at `maxBanMs`) and **decay** once the client goes quiet. Observes the outgoing status via `onSend` (counts failures from any downstream middleware/handler), enforces in `beforeHandle`. Secure-by-default identity attribution — refuses to construct without `keyGenerator`, `trustedHops`, or `trustProxyHeaders` so one offender can never ban everyone; unattributable requests are skipped. Proxy-header identity is **spoof-resistant**: the client IP is read from the rightmost `X-Forwarded-For` entry (the one your proxy appended) via `resolveForwardedClientIp()`, so rotating spoofed left entries can neither evade strike accumulation nor frame a victim IP for banning; multi-hop chains declare their hop count with `trustedHops` (shared by `rateLimit()`, `loginThrottle()`, `concurrencyLimit()`, `geoBlock()`, `ipRestriction()`, `ipReputation()`, and `botGuard()`). For deployments where the origin itself is reachable, `trustedProxies` (a CIDR allowlist of your proxy peer addresses, accepted by every guard in that list) goes further: the immediate TCP peer is verified against the allowlist before any forwarded header is believed, so a direct-to-origin caller's spoofed `X-Forwarded-For` is ignored entirely — closing victim-IP framing and ban/limit evasion at the framework layer, and failing closed on peer-less edge platforms. Pluggable `AutoBanStore` (mirrors the `rateLimit()` store; in-memory default, Redis-able for multi-instance), `groupId` sharing across route groups, `429`/`403` ban response with `Retry-After`, and `onBan` / `onStrike` hooks. Zero runtime dependencies.
|
|
676
677
|
- `botGuard()` bot / User-Agent management at `@daloyjs/core/bot-guard`: the in-app equivalent of Nginx/WAF bot rules. Blocks empty/missing `User-Agent` (default on) and known-abusive `User-Agent` strings / `RegExp`s, and **verifies declared crawlers** — a request claiming to be Googlebot/Bingbot is confirmed via reverse-DNS + forward-confirm (the method Google and Bing document), so a spoofed `User-Agent` can't impersonate a trusted crawler. Ships `GOOGLEBOT` / `BINGBOT` / `WELL_KNOWN_BOTS` presets and accepts custom `VerifiedBotRule`s. Allowlist-first (`allowUserAgents` bypasses every rule), secure-by-default (`verifiedBots` refuses to construct without an IP source; unverifiable crawlers blocked unless `blockUnverifiableBots: false`), subdomain-boundary-safe domain matching, per-IP verification cache to keep DNS off the hot path, `mode: "log"` monitor mode, `onBlock` callback, and a pluggable `BotResolver` (default lazy `node:dns/promises`). Zero runtime dependencies.
|
|
@@ -18,7 +18,11 @@
|
|
|
18
18
|
import type { App } from "../app.js";
|
|
19
19
|
/** Module shape expected by the Cloudflare Workers runtime as `export default`. */
|
|
20
20
|
export interface ExportedFetchHandler<Env = unknown> {
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Worker entry point: forwards the request to {@link App.fetch}. After the
|
|
23
|
+
* response is produced, `ctx.waitUntil` is used to flush OTLP telemetry so
|
|
24
|
+
* the isolate stays alive long enough for the export POST to finish.
|
|
25
|
+
*/
|
|
22
26
|
fetch: (request: Request, env?: Env, ctx?: ExecutionContextLike) => Promise<Response>;
|
|
23
27
|
}
|
|
24
28
|
interface ExecutionContextLike {
|
|
@@ -28,6 +32,9 @@ interface ExecutionContextLike {
|
|
|
28
32
|
/**
|
|
29
33
|
* Wrap an {@link App} in the `{ fetch }` object expected by Cloudflare Workers and other web-standard hosts.
|
|
30
34
|
*
|
|
35
|
+
* After each request, OTLP telemetry is flushed via `ctx.waitUntil` so the
|
|
36
|
+
* isolate stays alive long enough for the export POST to finish.
|
|
37
|
+
*
|
|
31
38
|
* @param app - The DaloyJS {@link App} that serves each incoming request.
|
|
32
39
|
* @returns An {@link ExportedFetchHandler} suitable as the module's `export default`.
|
|
33
40
|
*/
|
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Wrap an {@link App} in the `{ fetch }` object expected by Cloudflare Workers and other web-standard hosts.
|
|
3
3
|
*
|
|
4
|
+
* After each request, OTLP telemetry is flushed via `ctx.waitUntil` so the
|
|
5
|
+
* isolate stays alive long enough for the export POST to finish.
|
|
6
|
+
*
|
|
4
7
|
* @param app - The DaloyJS {@link App} that serves each incoming request.
|
|
5
8
|
* @returns An {@link ExportedFetchHandler} suitable as the module's `export default`.
|
|
6
9
|
*/
|
|
7
10
|
export function toFetchHandler(app) {
|
|
8
11
|
return {
|
|
9
|
-
fetch
|
|
12
|
+
async fetch(req, _env, ctx) {
|
|
13
|
+
const res = await app.fetch(req);
|
|
14
|
+
const telemetry = app.telemetry;
|
|
15
|
+
if (telemetry !== undefined) {
|
|
16
|
+
const pending = telemetry.flush();
|
|
17
|
+
if (ctx?.waitUntil !== undefined)
|
|
18
|
+
ctx.waitUntil(pending);
|
|
19
|
+
else
|
|
20
|
+
void pending;
|
|
21
|
+
}
|
|
22
|
+
return res;
|
|
23
|
+
},
|
|
10
24
|
};
|
|
11
25
|
}
|
package/dist/adapters/lambda.js
CHANGED
|
@@ -20,6 +20,9 @@ export function toLambdaHandler(app) {
|
|
|
20
20
|
return responseToLambda(badRequestResponse(), isV2Event(event));
|
|
21
21
|
}
|
|
22
22
|
const response = await app.fetch(request);
|
|
23
|
+
// Lambda freezes when the handler resolves; await the export so OTLP
|
|
24
|
+
// actually leaves the isolate. `flush()` never rejects.
|
|
25
|
+
await app.telemetry?.flush();
|
|
23
26
|
return responseToLambda(response, isV2Event(event));
|
|
24
27
|
};
|
|
25
28
|
}
|
|
@@ -49,6 +52,7 @@ export function toLambdaStreamHandler(app) {
|
|
|
49
52
|
return;
|
|
50
53
|
}
|
|
51
54
|
await streamLambdaResponse(await app.fetch(request), rawStream, runtime);
|
|
55
|
+
await app.telemetry?.flush();
|
|
52
56
|
});
|
|
53
57
|
}
|
|
54
58
|
function eventToRequest(event) {
|
|
@@ -75,13 +79,17 @@ function eventToRequest(event) {
|
|
|
75
79
|
: (event.path ?? event.requestContext?.path ?? "/");
|
|
76
80
|
const host = headers.get("host") ?? event.requestContext?.domainName ?? "localhost";
|
|
77
81
|
const proto = headers.get("x-forwarded-proto") ?? "https";
|
|
78
|
-
const rawQueryString = isV2Event(event)
|
|
82
|
+
const rawQueryString = isV2Event(event)
|
|
83
|
+
? (event.rawQueryString ?? "")
|
|
84
|
+
: queryStringForV1(event);
|
|
79
85
|
const qs = rawQueryString ? `?${rawQueryString}` : "";
|
|
80
86
|
const path = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
|
81
87
|
const url = `${proto}://${host}${path}${qs}`;
|
|
82
88
|
const init = { method, headers };
|
|
83
89
|
if (method !== "GET" && method !== "HEAD" && event.body != null) {
|
|
84
|
-
init.body = event.isBase64Encoded
|
|
90
|
+
init.body = event.isBase64Encoded
|
|
91
|
+
? base64ToBytes(event.body)
|
|
92
|
+
: event.body;
|
|
85
93
|
}
|
|
86
94
|
const request = new Request(url, init);
|
|
87
95
|
// Fulfil the conn-info contract with the caller address API Gateway saw
|
|
@@ -171,8 +179,7 @@ function badRequestResponse() {
|
|
|
171
179
|
}, { status: 400, headers: { "content-type": "application/problem+json" } });
|
|
172
180
|
}
|
|
173
181
|
function lambdaStreamingRuntime() {
|
|
174
|
-
const runtime = globalThis
|
|
175
|
-
.awslambda;
|
|
182
|
+
const runtime = globalThis.awslambda;
|
|
176
183
|
if (!runtime ||
|
|
177
184
|
typeof runtime.streamifyResponse !== "function" ||
|
|
178
185
|
typeof runtime.HttpResponseStream?.from !== "function") {
|
|
@@ -182,7 +189,10 @@ function lambdaStreamingRuntime() {
|
|
|
182
189
|
}
|
|
183
190
|
async function streamLambdaResponse(response, rawStream, runtime) {
|
|
184
191
|
const { headers, cookies } = responseHeaders(response);
|
|
185
|
-
const metadata = {
|
|
192
|
+
const metadata = {
|
|
193
|
+
statusCode: response.status,
|
|
194
|
+
headers,
|
|
195
|
+
};
|
|
186
196
|
if (cookies.length)
|
|
187
197
|
metadata.multiValueHeaders = { "set-cookie": cookies };
|
|
188
198
|
const responseStream = runtime.HttpResponseStream.from(rawStream, metadata);
|
|
@@ -32,6 +32,11 @@ export type RouteHandlers = Record<(typeof NEXT_METHODS)[number], WebHandler>;
|
|
|
32
32
|
/**
|
|
33
33
|
* Wrap an {@link App} as a single web-standard fetch handler.
|
|
34
34
|
*
|
|
35
|
+
* After the response is produced, any OTLP telemetry is flushed. When the
|
|
36
|
+
* runtime exposes `globalThis.waitUntil` (Vercel Fluid / Edge), that is used
|
|
37
|
+
* so the export can finish after the response is sent; otherwise the flush
|
|
38
|
+
* is started fire-and-forget.
|
|
39
|
+
*
|
|
35
40
|
* @param app - The DaloyJS {@link App} that serves each incoming request.
|
|
36
41
|
* @returns A {@link WebHandler} delegating to {@link App.fetch}.
|
|
37
42
|
*/
|
package/dist/adapters/vercel.js
CHANGED
|
@@ -1,12 +1,37 @@
|
|
|
1
|
-
const NEXT_METHODS = [
|
|
1
|
+
const NEXT_METHODS = [
|
|
2
|
+
"GET",
|
|
3
|
+
"POST",
|
|
4
|
+
"PUT",
|
|
5
|
+
"PATCH",
|
|
6
|
+
"DELETE",
|
|
7
|
+
"OPTIONS",
|
|
8
|
+
"HEAD",
|
|
9
|
+
];
|
|
2
10
|
/**
|
|
3
11
|
* Wrap an {@link App} as a single web-standard fetch handler.
|
|
4
12
|
*
|
|
13
|
+
* After the response is produced, any OTLP telemetry is flushed. When the
|
|
14
|
+
* runtime exposes `globalThis.waitUntil` (Vercel Fluid / Edge), that is used
|
|
15
|
+
* so the export can finish after the response is sent; otherwise the flush
|
|
16
|
+
* is started fire-and-forget.
|
|
17
|
+
*
|
|
5
18
|
* @param app - The DaloyJS {@link App} that serves each incoming request.
|
|
6
19
|
* @returns A {@link WebHandler} delegating to {@link App.fetch}.
|
|
7
20
|
*/
|
|
8
21
|
export function toWebHandler(app) {
|
|
9
|
-
return (req) =>
|
|
22
|
+
return async (req) => {
|
|
23
|
+
const res = await app.fetch(req);
|
|
24
|
+
const telemetry = app.telemetry;
|
|
25
|
+
if (telemetry !== undefined) {
|
|
26
|
+
const pending = telemetry.flush();
|
|
27
|
+
const waitUntil = globalThis.waitUntil;
|
|
28
|
+
if (typeof waitUntil === "function")
|
|
29
|
+
waitUntil(pending);
|
|
30
|
+
else
|
|
31
|
+
void pending;
|
|
32
|
+
}
|
|
33
|
+
return res;
|
|
34
|
+
};
|
|
10
35
|
}
|
|
11
36
|
/**
|
|
12
37
|
* Build the default `{ fetch }` export expected by Vercel Node.js Functions
|
package/dist/app.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type SecureHeadersOptions } from "./middleware.js";
|
|
|
9
9
|
import { type LoadSheddingOptions } from "./load-shedding.js";
|
|
10
10
|
import { MetricsRegistry, type HttpMetricsOptions } from "./metrics.js";
|
|
11
11
|
import { Scheduler, type TaskDefinition, type TaskHandler } from "./scheduler.js";
|
|
12
|
+
import { type JobHandler, type JobQueue, type JobQueueOptions, type JobStore, type JobWorker, type JobWorkerOptions } from "./jobs.js";
|
|
12
13
|
import { type BehindProxyConfig } from "./conn-info.js";
|
|
13
14
|
/** @internal Test-only helper to reset the latch between tests. */
|
|
14
15
|
export declare function _resetCrashHandlersForTests(): void;
|
|
@@ -81,8 +82,9 @@ export interface AppOptions {
|
|
|
81
82
|
* per the OTel HTTP semantic conventions, pushed to the collector named by
|
|
82
83
|
* the standard `OTEL_EXPORTER_OTLP_*` environment variables. A silent no-op
|
|
83
84
|
* when no endpoint is configured, so it is safe to keep enabled in
|
|
84
|
-
* development. Export failures never affect request serving.
|
|
85
|
-
*
|
|
85
|
+
* development. Export failures never affect request serving. Isolate
|
|
86
|
+
* runtimes must go through `toFetchHandler` / `toLambdaHandler` so a
|
|
87
|
+
* per-request flush actually runs. See {@link TelemetryOptions}.
|
|
86
88
|
*
|
|
87
89
|
* @since 1.2.0
|
|
88
90
|
*/
|
|
@@ -656,15 +658,15 @@ export interface MetricsRouteOptions {
|
|
|
656
658
|
*/
|
|
657
659
|
registry?: MetricsRegistry;
|
|
658
660
|
/**
|
|
659
|
-
* Resolve the low-cardinality `route` label.
|
|
660
|
-
*
|
|
661
|
-
*
|
|
661
|
+
* Resolve the low-cardinality `route` label. When omitted, the matched
|
|
662
|
+
* route template (`ctx.routePath`, e.g. `/books/:id`) is used. Forwarded
|
|
663
|
+
* to {@link httpMetrics}.
|
|
662
664
|
*/
|
|
663
665
|
route?: HttpMetricsOptions["route"];
|
|
664
666
|
/**
|
|
665
|
-
* Maximum distinct values for the
|
|
666
|
-
*
|
|
667
|
-
*
|
|
667
|
+
* Maximum distinct values for the pathname fallback `route` label (no
|
|
668
|
+
* template on the context). Forwarded to {@link httpMetrics}. Default
|
|
669
|
+
* `100`.
|
|
668
670
|
*/
|
|
669
671
|
maxRouteCardinality?: number;
|
|
670
672
|
/** Latency histogram buckets, in seconds. Forwarded to {@link httpMetrics}. */
|
|
@@ -1000,6 +1002,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1000
1002
|
* is tied to graceful shutdown.
|
|
1001
1003
|
*/
|
|
1002
1004
|
private scheduler?;
|
|
1005
|
+
/**
|
|
1006
|
+
* Job queue / worker attached by {@link App.useJobs}. Both stay `undefined`
|
|
1007
|
+
* unless the app opts in — serverless isolates must never start a poll
|
|
1008
|
+
* loop implicitly.
|
|
1009
|
+
*/
|
|
1010
|
+
private jobQueue?;
|
|
1011
|
+
private jobWorkerRef?;
|
|
1003
1012
|
/** Idle-connection close hooks (adapter-registered, sync). */
|
|
1004
1013
|
private idleConnectionCloseHooks;
|
|
1005
1014
|
private pluginInstalledListeners;
|
|
@@ -1443,8 +1452,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1443
1452
|
*
|
|
1444
1453
|
* Call this **before** registering the routes you want measured — like any
|
|
1445
1454
|
* `app.use(...)` middleware, the instrumentation only wraps routes added
|
|
1446
|
-
* afterwards.
|
|
1447
|
-
*
|
|
1455
|
+
* afterwards. Calling it after routes already exist logs a
|
|
1456
|
+
* `metrics.late_install` warning listing the uninstrumented paths.
|
|
1457
|
+
* Pass `opts.registry` to register custom application metrics that are
|
|
1458
|
+
* rendered alongside the built-in HTTP series. Default series names are
|
|
1459
|
+
* unprefixed (`http_requests_total`, `process_resident_memory_bytes`);
|
|
1460
|
+
* construct the registry with `prefix: "daloy_"` if you want the old
|
|
1461
|
+
* names.
|
|
1448
1462
|
*
|
|
1449
1463
|
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1450
1464
|
* @returns `this` for chaining.
|
|
@@ -1486,6 +1500,118 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1486
1500
|
* is owned by the app.
|
|
1487
1501
|
*/
|
|
1488
1502
|
get scheduledTasks(): Scheduler | undefined;
|
|
1503
|
+
/**
|
|
1504
|
+
* Attach a queue-agnostic background-job queue (and optionally a worker)
|
|
1505
|
+
* to this app. Opt-in by design: nothing in `new App()` starts a poll
|
|
1506
|
+
* loop, so serverless isolates that only ever *enqueue* never pay for (or
|
|
1507
|
+
* accidentally run) a worker.
|
|
1508
|
+
*
|
|
1509
|
+
* The queue answers *“run this work somewhere, eventually”* —
|
|
1510
|
+
* durable, retried, at-least-once units of `{ name, payload }` that
|
|
1511
|
+
* outlive the HTTP request and, with a durable {@link JobStore} adapter,
|
|
1512
|
+
* the process itself. This is not a workflow engine: there is no replay,
|
|
1513
|
+
* no durable function, no `await sleep("7 days")`.
|
|
1514
|
+
*
|
|
1515
|
+
* - `startWorker: true` creates a {@link JobWorker} over the same store,
|
|
1516
|
+
* starts it, and registers an `onClose` hook so in-flight jobs are
|
|
1517
|
+
* drained (then aborted past the grace period) on graceful shutdown.
|
|
1518
|
+
* Use it on long-lived Node/Bun/Deno processes only — never on Lambda /
|
|
1519
|
+
* Cloudflare Workers isolates.
|
|
1520
|
+
* - {@link MemoryJobStore} under production config logs a high-severity
|
|
1521
|
+
* warning (it is process-local and loses every job on restart); pass
|
|
1522
|
+
* `strictProduction: true` to refuse to boot instead.
|
|
1523
|
+
*
|
|
1524
|
+
* @example
|
|
1525
|
+
* ```ts
|
|
1526
|
+
* app.useJobs({
|
|
1527
|
+
* store: new MemoryJobStore(), // production: your Redis/Postgres JobStore
|
|
1528
|
+
* handlers: {
|
|
1529
|
+
* "email.welcome": async ({ job, signal }) => {
|
|
1530
|
+
* await sendEmail(job.payload, { signal });
|
|
1531
|
+
* },
|
|
1532
|
+
* },
|
|
1533
|
+
* startWorker: true,
|
|
1534
|
+
* });
|
|
1535
|
+
*
|
|
1536
|
+
* app.post("/users", contract, async (ctx) => {
|
|
1537
|
+
* const user = await db.insertUser(ctx.body);
|
|
1538
|
+
* await app.jobs!.enqueue({
|
|
1539
|
+
* name: "email.welcome",
|
|
1540
|
+
* payload: { userId: user.id },
|
|
1541
|
+
* idempotencyKey: jobIdempotencyKey({ tenant: ctx.state.tenant, name: "email.welcome", key: user.id }),
|
|
1542
|
+
* });
|
|
1543
|
+
* return { status: 201 as const, body: user };
|
|
1544
|
+
* });
|
|
1545
|
+
* ```
|
|
1546
|
+
*
|
|
1547
|
+
* @param opts - Store, optional handlers, worker and queue tuning.
|
|
1548
|
+
* @returns This `App` instance for chaining.
|
|
1549
|
+
* @throws {@link JobConfigError} when jobs are already configured, when
|
|
1550
|
+
* `startWorker` lacks handlers, or when `strictProduction` rejects a
|
|
1551
|
+
* {@link MemoryJobStore} under production config.
|
|
1552
|
+
* @since 1.3.0
|
|
1553
|
+
*/
|
|
1554
|
+
useJobs(opts: {
|
|
1555
|
+
store: JobStore;
|
|
1556
|
+
handlers?: Record<string, JobHandler<any>>;
|
|
1557
|
+
startWorker?: boolean;
|
|
1558
|
+
worker?: Omit<JobWorkerOptions, "queue" | "handlers">;
|
|
1559
|
+
queue?: Omit<JobQueueOptions, "store">;
|
|
1560
|
+
strictProduction?: boolean;
|
|
1561
|
+
}): this;
|
|
1562
|
+
/**
|
|
1563
|
+
* The {@link JobQueue} attached by {@link App.useJobs}, or `undefined`
|
|
1564
|
+
* when jobs are not configured. Route handlers enqueue through this;
|
|
1565
|
+
* delivery is at-least-once, so handlers must be idempotent.
|
|
1566
|
+
*
|
|
1567
|
+
* @since 1.3.0
|
|
1568
|
+
*/
|
|
1569
|
+
get jobs(): JobQueue | undefined;
|
|
1570
|
+
/**
|
|
1571
|
+
* The {@link JobWorker} created by {@link App.useJobs} with
|
|
1572
|
+
* `startWorker: true`, or `undefined`. Exposed for inspection
|
|
1573
|
+
* (`getState()`) and tests (`runOnce()`); the lifecycle is owned by the app.
|
|
1574
|
+
*
|
|
1575
|
+
* @since 1.3.0
|
|
1576
|
+
*/
|
|
1577
|
+
get jobWorker(): JobWorker | undefined;
|
|
1578
|
+
/**
|
|
1579
|
+
* Register a cron task whose tick enqueues a job instead of running the
|
|
1580
|
+
* side effect in-process. This is the production posture for scheduled
|
|
1581
|
+
* work with global side effects (nightly reconciliation, invoice runs):
|
|
1582
|
+
* every replica may tick, but the deterministic idempotency key
|
|
1583
|
+
* `cron:{taskName}:{floor(scheduledFor / tickGranularity)}` collapses the
|
|
1584
|
+
* duplicate enqueues into one job, and exactly one worker claims it.
|
|
1585
|
+
*
|
|
1586
|
+
* `tickGranularity` is the task's `intervalMs` for interval schedules and
|
|
1587
|
+
* one minute for cron expressions (the finest cadence a cron expression
|
|
1588
|
+
* can fire), so two replicas ticking the same slot always derive the same
|
|
1589
|
+
* key. Use plain {@link App.cron} for process-local maintenance (cache
|
|
1590
|
+
* sweeps that must happen in *this* process); use `cronEnqueue` for work
|
|
1591
|
+
* that must happen once, cluster-wide, and survive a restart.
|
|
1592
|
+
*
|
|
1593
|
+
* @example
|
|
1594
|
+
* ```ts
|
|
1595
|
+
* app.cronEnqueue(
|
|
1596
|
+
* { name: "nightly-reconcile", cron: "0 2 * * *" },
|
|
1597
|
+
* { name: "ops.reconcile", payload: {} },
|
|
1598
|
+
* );
|
|
1599
|
+
* ```
|
|
1600
|
+
*
|
|
1601
|
+
* @param def - The task definition (schedule), same shape as {@link App.cron}.
|
|
1602
|
+
* @param job - The job to enqueue on each tick. `payload` defaults to
|
|
1603
|
+
* `{ scheduledFor: <ISO time of the tick> }`.
|
|
1604
|
+
* @returns This `App` instance for chaining.
|
|
1605
|
+
* @throws {@link JobConfigError} (`store_required`) when called before
|
|
1606
|
+
* {@link App.useJobs} — fail fast at registration, not at the first tick.
|
|
1607
|
+
* @since 1.3.0
|
|
1608
|
+
*/
|
|
1609
|
+
cronEnqueue(def: TaskDefinition, job: {
|
|
1610
|
+
name: string;
|
|
1611
|
+
payload?: unknown;
|
|
1612
|
+
queue?: string;
|
|
1613
|
+
priority?: number;
|
|
1614
|
+
}): this;
|
|
1489
1615
|
private registerHealthRoute;
|
|
1490
1616
|
/**
|
|
1491
1617
|
* Register a built-in receiver for CSP / Reporting API
|