@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 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
- /** Worker entry point: forwards the request to {@link App.fetch}. `env`/`ctx` are accepted but unused by the adapter. */
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: (req) => app.fetch(req),
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
  }
@@ -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) ? (event.rawQueryString ?? "") : queryStringForV1(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 ? base64ToBytes(event.body) : event.body;
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 = { statusCode: response.status, headers };
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
  */
@@ -1,12 +1,37 @@
1
- const NEXT_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"];
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) => app.fetch(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. See
85
- * {@link TelemetryOptions}.
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. Strongly recommended: return
660
- * the route template (e.g. `/books/:id`) instead of the raw path.
661
- * Forwarded to {@link httpMetrics}.
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 default pathname-derived `route` label
666
- * before further values collapse to `"<other>"`. Forwarded to
667
- * {@link httpMetrics}. Default `100`.
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. Pass `opts.registry` to register custom application metrics
1447
- * that are rendered alongside the built-in HTTP series.
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 *&ldquo;run this work somewhere, eventually&rdquo;* —
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