@daloyjs/core 1.2.0 → 1.2.1
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 +2 -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 +16 -10
- package/dist/app.js +218 -69
- package/dist/combine.js +14 -8
- 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 +1 -1
package/README.md
CHANGED
|
@@ -664,9 +664,9 @@ 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.
|
|
@@ -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
|
@@ -81,8 +81,9 @@ export interface AppOptions {
|
|
|
81
81
|
* per the OTel HTTP semantic conventions, pushed to the collector named by
|
|
82
82
|
* the standard `OTEL_EXPORTER_OTLP_*` environment variables. A silent no-op
|
|
83
83
|
* when no endpoint is configured, so it is safe to keep enabled in
|
|
84
|
-
* development. Export failures never affect request serving.
|
|
85
|
-
*
|
|
84
|
+
* development. Export failures never affect request serving. Isolate
|
|
85
|
+
* runtimes must go through `toFetchHandler` / `toLambdaHandler` so a
|
|
86
|
+
* per-request flush actually runs. See {@link TelemetryOptions}.
|
|
86
87
|
*
|
|
87
88
|
* @since 1.2.0
|
|
88
89
|
*/
|
|
@@ -656,15 +657,15 @@ export interface MetricsRouteOptions {
|
|
|
656
657
|
*/
|
|
657
658
|
registry?: MetricsRegistry;
|
|
658
659
|
/**
|
|
659
|
-
* Resolve the low-cardinality `route` label.
|
|
660
|
-
*
|
|
661
|
-
*
|
|
660
|
+
* Resolve the low-cardinality `route` label. When omitted, the matched
|
|
661
|
+
* route template (`ctx.routePath`, e.g. `/books/:id`) is used. Forwarded
|
|
662
|
+
* to {@link httpMetrics}.
|
|
662
663
|
*/
|
|
663
664
|
route?: HttpMetricsOptions["route"];
|
|
664
665
|
/**
|
|
665
|
-
* Maximum distinct values for the
|
|
666
|
-
*
|
|
667
|
-
*
|
|
666
|
+
* Maximum distinct values for the pathname fallback `route` label (no
|
|
667
|
+
* template on the context). Forwarded to {@link httpMetrics}. Default
|
|
668
|
+
* `100`.
|
|
668
669
|
*/
|
|
669
670
|
maxRouteCardinality?: number;
|
|
670
671
|
/** Latency histogram buckets, in seconds. Forwarded to {@link httpMetrics}. */
|
|
@@ -1443,8 +1444,13 @@ export declare class App<Routes extends readonly RouteDefinition<any, any, any,
|
|
|
1443
1444
|
*
|
|
1444
1445
|
* Call this **before** registering the routes you want measured — like any
|
|
1445
1446
|
* `app.use(...)` middleware, the instrumentation only wraps routes added
|
|
1446
|
-
* afterwards.
|
|
1447
|
-
*
|
|
1447
|
+
* afterwards. Calling it after routes already exist logs a
|
|
1448
|
+
* `metrics.late_install` warning listing the uninstrumented paths.
|
|
1449
|
+
* Pass `opts.registry` to register custom application metrics that are
|
|
1450
|
+
* rendered alongside the built-in HTTP series. Default series names are
|
|
1451
|
+
* unprefixed (`http_requests_total`, `process_resident_memory_bytes`);
|
|
1452
|
+
* construct the registry with `prefix: "daloy_"` if you want the old
|
|
1453
|
+
* names.
|
|
1448
1454
|
*
|
|
1449
1455
|
* @param opts - Path, auth, rate-limit, registry, and label configuration.
|
|
1450
1456
|
* @returns `this` for chaining.
|