@jongleberry/api-server 1.2.2 → 2.1.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
@@ -42,17 +42,19 @@ app.route("/").get((ctx) => ctx.json({ ok: true }));
42
42
 
43
43
  - **Trie router** — `find-my-way` under the hood; zero regex overhead on the hot path
44
44
  - **Buffered responses** — `ctx.json()`, `ctx.response.text()`, `.html()`, `.xml()`, `.buffer()`
45
- - **Streaming** — `ctx.pipeline(readable, ...transforms)` with back-pressure and error propagation
45
+ - **Streaming** — `ctx.pipeline(readable, ...transforms)` and `streamJsonObject()` with back-pressure and error propagation
46
46
  - **Automatic ETag** — SHA-256 ETag on every buffered 2xx; `If-None-Match` → 304
47
47
  - **Compression** — `br` / `gzip` / `deflate` negotiation; 1 KB threshold; `SYNC_FLUSH` for streams
48
48
  - **Server-Timing** — response latency as a `Server-Timing` header (buffered) or trailer (streaming)
49
49
  - **Abort signals** — `ctx.signal` / `ctx.abortController` wired to client disconnect
50
50
  - **Request body limits** — `ctx.request.buffer()` and `.json()` use a safe 1 MB default, with per-call overrides
51
- - **Opt-in hardening** — fallback CSP, close-on-oversize, and strict HTTP method policies without default behavior changes
51
+ - **Mutation media-type safety** — body-bearing POST/PUT/PATCH/DELETE requests default to JSON, with route-level exceptions
52
52
  - **AsyncLocalStorage** — per-request store via `app.setAsyncLocalStorage(als)`
53
53
  - **Cookies** — `ctx.cookies.get()` / `.set()` with full `Set-Cookie` options
54
54
  - **Cache-Control** — `ctx.cacheControl(visibility, maxAge)` helper
55
55
  - **Trusted client IP** — proxy headers are opt-in via `trustProxy`; standalone helpers support Node, Deno, and Bun
56
+ - **HTTP retry primitives** — strict Retry-After parsing, code-based network classification, and pure backoff calculation
57
+ - **SHA-256** — standalone Node digest bytes shared by ETag generation
56
58
  - **Dev logger** — concurrent-request bar, color-coded status codes, timing thresholds; silent in `NODE_ENV=production` and `NODE_ENV=test`
57
59
  - **Error safety net** — error handlers that throw or return without a response still guarantee the client receives a response
58
60
 
@@ -65,24 +67,26 @@ app.route("/").get((ctx) => ctx.json({ ok: true }));
65
67
 
66
68
  See [docs/](docs/README.md) for full API reference:
67
69
 
68
- | Topic | Description |
69
- | -------------------------------------------------- | --------------------------------------------------- |
70
- | [Getting started](docs/getting-started.md) | Install, hello world, mounting on http.createServer |
71
- | [Routing](docs/routing.md) | Route registration, params, notFoundHandler |
72
- | [Context](docs/context.md) | Full `ctx` API surface |
73
- | [Request](docs/request.md) | Body parsing, size limits, content-type detection |
74
- | [Response](docs/response.md) | Buffered and streaming responses |
75
- | [ETag and caching](docs/etag-and-caching.md) | Automatic ETags, 304s, Cache-Control |
76
- | [Compression](docs/compression.md) | br/gzip/deflate negotiation |
77
- | [Server-Timing](docs/server-timing.md) | Response latency headers and trailers |
78
- | [Cookies](docs/cookies.md) | Reading and writing cookies |
79
- | [Error handling](docs/error-handling.md) | errorHandler, notFoundHandler, http-errors |
80
- | [Async local storage](docs/async-local-storage.md) | Per-request store |
81
- | [Abort signals](docs/abort-signals.md) | Client-disconnect propagation |
82
- | [Logger](docs/logger.md) | Dev logger configuration |
83
- | [Trusted client IP](docs/trusted-client-ip.md) | Node, Deno, and Bun client IP helpers |
84
- | [Extending context](docs/extending-context.md) | Adding methods to ctx |
85
- | [Testing](docs/testing.md) | Testing patterns with vitest and supertest |
70
+ | Topic | Description |
71
+ | -------------------------------------------------- | -------------------------------------------------------- |
72
+ | [Getting started](docs/getting-started.md) | Install, hello world, mounting on http.createServer |
73
+ | [Routing](docs/routing.md) | Route registration, params, notFoundHandler |
74
+ | [Context](docs/context.md) | Full `ctx` API surface |
75
+ | [Request](docs/request.md) | Body parsing, size limits, content-type detection |
76
+ | [Response](docs/response.md) | Buffered and streaming responses |
77
+ | [ETag and caching](docs/etag-and-caching.md) | Automatic ETags, 304s, Cache-Control |
78
+ | [Compression](docs/compression.md) | br/gzip/deflate negotiation |
79
+ | [Server-Timing](docs/server-timing.md) | Response latency headers and trailers |
80
+ | [Cookies](docs/cookies.md) | Reading and writing cookies |
81
+ | [Error handling](docs/error-handling.md) | errorHandler, notFoundHandler, http-errors |
82
+ | [Async local storage](docs/async-local-storage.md) | Per-request store |
83
+ | [Abort signals](docs/abort-signals.md) | Client-disconnect propagation |
84
+ | [Logger](docs/logger.md) | Dev logger configuration |
85
+ | [Trusted client IP](docs/trusted-client-ip.md) | Node, Deno, and Bun client IP helpers |
86
+ | [HTTP retry](docs/http-retry.md) | Strict header, Retry-After, network, and backoff helpers |
87
+ | [SHA-256](docs/sha256.md) | Node digest helper and ETag reuse |
88
+ | [Extending context](docs/extending-context.md) | Adding methods to ctx |
89
+ | [Testing](docs/testing.md) | Testing patterns with vitest and supertest |
86
90
 
87
91
  ## Design
88
92
 
@@ -17,7 +17,6 @@ export declare class Application extends EventEmitter {
17
17
  private bodyLimit;
18
18
  private readonly securityHeaders;
19
19
  private trustProxy;
20
- private strictJsonContentType;
21
20
  private readonly oversizedBodyStrategy;
22
21
  private readonly fallbackContentSecurityPolicy;
23
22
  private readonly strictHttpMethods;
@@ -1,11 +1,12 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { Context, createContextClass } from "./context.mjs";
3
- import { createRouteBuilder, isSupportedHttpMethod } from "./router.mjs";
3
+ import { createRouteBuilder, getAcceptedMediaTypes, isSupportedHttpMethod, } from "./router.mjs";
4
4
  import Router from "find-my-way";
5
5
  import { ServerTiming } from "./server-timing.mjs";
6
6
  import { Logger } from "./logger.mjs";
7
7
  import { getRawPath } from "./request-path.mjs";
8
8
  import { createRequestAbortController } from "./request-abort.mjs";
9
+ import { assertAcceptedMutationMediaType, drainUnreadHttp2Mutation, } from "./mutation-media-type.mjs";
9
10
  import { applySecurityHeaders, ensureFallbackHeaders, getFallbackBody, getFallbackStatus, resolveSecurityHeaders, safeString, sendFallback, } from "./fallback-response.mjs";
10
11
  export class Application extends EventEmitter {
11
12
  router = Router();
@@ -18,7 +19,6 @@ export class Application extends EventEmitter {
18
19
  bodyLimit;
19
20
  securityHeaders;
20
21
  trustProxy;
21
- strictJsonContentType;
22
22
  oversizedBodyStrategy;
23
23
  fallbackContentSecurityPolicy;
24
24
  strictHttpMethods;
@@ -28,7 +28,6 @@ export class Application extends EventEmitter {
28
28
  this.bodyLimit = options?.bodyLimit ?? "1mb";
29
29
  this.securityHeaders = resolveSecurityHeaders(options?.securityHeaders);
30
30
  this.trustProxy = options?.trustProxy ?? false;
31
- this.strictJsonContentType = options?.strictJsonContentType ?? false;
32
31
  this.oversizedBodyStrategy = options?.oversizedBodyStrategy ?? "drain";
33
32
  this.fallbackContentSecurityPolicy = options?.fallbackContentSecurityPolicy ?? false;
34
33
  this.strictHttpMethods = options?.strictHttpMethods ?? false;
@@ -56,18 +55,13 @@ export class Application extends EventEmitter {
56
55
  }
57
56
  handleRequest(req, res) {
58
57
  const run = () => this.runRequest(req, res).catch((err) => {
59
- // Safety net: if runRequest rejects before its own try-catch (e.g. during
60
- // context/timing setup), ensure the client always gets a response instead
61
- // of a socket hang-up from an unhandled promise rejection.
62
58
  const error = err instanceof Error ? err : new Error(safeString(err));
63
59
  try {
64
60
  if (this.listenerCount("error") > 0) {
65
61
  this.emit("error", error);
66
62
  }
67
63
  }
68
- catch {
69
- // Swallow listener throws so the 500 response still goes out.
70
- }
64
+ catch { }
71
65
  if (!res.headersSent) {
72
66
  ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
73
67
  res.writeHead(500);
@@ -86,7 +80,7 @@ export class Application extends EventEmitter {
86
80
  const timing = new ServerTiming();
87
81
  const ContextClass = this.contextClass;
88
82
  const { onWriteHead, onFinish } = this.logger.onRequestStart(req);
89
- const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.strictJsonContentType, this.oversizedBodyStrategy);
83
+ const ctx = new ContextClass(req, res, {}, timing, this.asyncLocalStorage, abortController, this.bodyLimit, this.trustProxy, onWriteHead, this.oversizedBodyStrategy);
90
84
  applySecurityHeaders(res, this.securityHeaders);
91
85
  try {
92
86
  const method = req.method ?? "GET";
@@ -97,21 +91,29 @@ export class Application extends EventEmitter {
97
91
  const rawPath = getRawPath(url);
98
92
  const routePath = rawPath.replace(/^\/+/, "/") || "/";
99
93
  const found = this.router.find(method, routePath);
100
- if (found) {
101
- ctx.params = found.params;
102
- await found.handler(req, res, found.params, ctx, found.searchParams);
103
- }
104
- if (!ctx.response.sent) {
105
- if (this.notFoundHandlerFn) {
106
- await this.notFoundHandlerFn(ctx);
94
+ const mediaTypeCheck = assertAcceptedMutationMediaType(req, found ? getAcceptedMediaTypes(found.handler) : undefined);
95
+ if (mediaTypeCheck)
96
+ await mediaTypeCheck;
97
+ try {
98
+ if (found) {
99
+ ctx.params = found.params;
100
+ await found.handler(req, res, found.params, ctx, found.searchParams);
107
101
  }
108
- else {
109
- ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
110
- res.writeHead(404);
111
- res.end("Not Found");
102
+ if (!ctx.response.sent) {
103
+ if (this.notFoundHandlerFn) {
104
+ await this.notFoundHandlerFn(ctx);
105
+ }
106
+ else {
107
+ ensureFallbackHeaders(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
108
+ res.writeHead(404);
109
+ res.end("Not Found");
110
+ }
112
111
  }
112
+ onFinish(res.statusCode);
113
+ }
114
+ finally {
115
+ drainUnreadHttp2Mutation(req);
113
116
  }
114
- onFinish(res.statusCode);
115
117
  }
116
118
  catch (err) {
117
119
  const error = err instanceof Error ? err : new Error(safeString(err));
@@ -127,9 +129,6 @@ export class Application extends EventEmitter {
127
129
  this.emit("error", handlerErr);
128
130
  }
129
131
  }
130
- // Safety net: ensure the client always receives a response, even if the
131
- // registered error handler threw or returned without sending one. Without
132
- // this, requests hang until the socket times out (issue #1948).
133
132
  if (!res.headersSent) {
134
133
  sendFallback(res, this.securityHeaders, this.fallbackContentSecurityPolicy);
135
134
  }
@@ -19,7 +19,7 @@ export declare class Context {
19
19
  private queryCache;
20
20
  private asyncLocalStorage;
21
21
  private trustProxy;
22
- constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, bodyLimit: string | number | false, trustProxy: boolean, onWriteHead?: () => void, strictJsonContentType?: boolean, oversizedBodyStrategy?: OversizedBodyStrategy);
22
+ constructor(req: IncomingMessage, res: ServerResponse, params: Record<string, string | undefined>, timing: ServerTiming, als: AsyncLocalStorage<unknown> | null, abortController: AbortController, bodyLimit: string | number | false, trustProxy: boolean, onWriteHead?: () => void, oversizedBodyStrategy?: OversizedBodyStrategy);
23
23
  get query(): Record<string, string | string[]>;
24
24
  get store(): unknown;
25
25
  get ip(): string | undefined;
package/dist/context.mjs CHANGED
@@ -26,11 +26,11 @@ export class Context {
26
26
  queryCache = null;
27
27
  asyncLocalStorage;
28
28
  trustProxy;
29
- constructor(req, res, params, timing, als, abortController, bodyLimit, trustProxy, onWriteHead, strictJsonContentType, oversizedBodyStrategy) {
29
+ constructor(req, res, params, timing, als, abortController, bodyLimit, trustProxy, onWriteHead, oversizedBodyStrategy) {
30
30
  this.req = req;
31
31
  this.res = res;
32
32
  this.params = params;
33
- this.request = new Request(req, res, bodyLimit, strictJsonContentType ?? false, oversizedBodyStrategy ?? "drain");
33
+ this.request = new Request(req, res, bodyLimit, oversizedBodyStrategy ?? "drain");
34
34
  this.response = new Response(req, res, timing, onWriteHead);
35
35
  this.cookies = new Cookies(req, res);
36
36
  this.abortController = abortController;
package/dist/etag.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import crypto from "node:crypto";
1
+ import { sha256 } from "./sha256.mjs";
2
2
  export function generateETag(body) {
3
- const hash = crypto.createHash("sha256").update(body).digest("base64url");
3
+ const hash = sha256(body).toString("base64url");
4
4
  return `"${hash}"`;
5
5
  }
6
6
  export function isFresh(req, etag) {
@@ -0,0 +1,19 @@
1
+ export type HeaderValue = string | string[];
2
+ export type HeaderGetter = {
3
+ get(name: string): string | null;
4
+ };
5
+ export type HeaderSource = HeaderGetter | Record<string, unknown> | null | undefined;
6
+ export interface ExponentialBackoffOptions {
7
+ attempt: number;
8
+ baseDelayMs: number;
9
+ maxDelayMs: number;
10
+ random: number;
11
+ }
12
+ /** Reads Fetch or Node-style headers without assuming a particular casing. */
13
+ export declare function getHeaderValue(headers: HeaderSource, name: string): HeaderValue | undefined;
14
+ /** Parses a single Retry-After field into milliseconds using the supplied or current clock. */
15
+ export declare function parseRetryAfter(value: HeaderValue | null | undefined, now?: number): number | null;
16
+ /** Classifies only known network error codes, following a finite error cause chain. */
17
+ export declare function isRetryableNetworkError(error: unknown): boolean;
18
+ /** Returns capped exponential delay with deterministic full jitter; it never sleeps. */
19
+ export declare function computeExponentialBackoffMs(options: ExponentialBackoffOptions): number;
@@ -0,0 +1,117 @@
1
+ const RETRYABLE_NETWORK_CODES = new Set([
2
+ "EAI_AGAIN",
3
+ "ECONNREFUSED",
4
+ "ECONNRESET",
5
+ "EHOSTUNREACH",
6
+ "ENETUNREACH",
7
+ "ENOTFOUND",
8
+ "EPIPE",
9
+ "ETIMEDOUT",
10
+ "UND_ERR_CONNECT_TIMEOUT",
11
+ "UND_ERR_BODY_TIMEOUT",
12
+ "UND_ERR_HEADERS_TIMEOUT",
13
+ "UND_ERR_SOCKET",
14
+ ]);
15
+ const DECIMAL_SECONDS = /^(?:0|[1-9]\d*)$/;
16
+ const HTTP_DATE = /^[A-Z][a-z]{2}, \d{2} [A-Z][a-z]{2} \d{4} \d{2}:\d{2}:\d{2} GMT$/;
17
+ function isHeaderGetter(headers) {
18
+ return typeof headers?.get === "function";
19
+ }
20
+ function normalizeHeaderValue(value) {
21
+ if (typeof value === "string")
22
+ return value;
23
+ if (typeof value === "number")
24
+ return String(value);
25
+ if (Array.isArray(value) && value.every((item) => typeof item === "string"))
26
+ return value;
27
+ return undefined;
28
+ }
29
+ /** Reads Fetch or Node-style headers without assuming a particular casing. */
30
+ export function getHeaderValue(headers, name) {
31
+ if (!headers)
32
+ return undefined;
33
+ if (isHeaderGetter(headers))
34
+ return headers.get(name) ?? undefined;
35
+ const matchingEntries = Object.entries(headers).filter(([key]) => key.toLowerCase() === name.toLowerCase());
36
+ if (matchingEntries.length !== 1)
37
+ return undefined;
38
+ return normalizeHeaderValue(matchingEntries[0][1]);
39
+ }
40
+ /** Parses a single Retry-After field into milliseconds using the supplied or current clock. */
41
+ export function parseRetryAfter(value, now = Date.now()) {
42
+ if (typeof value !== "string" || !Number.isFinite(now))
43
+ return null;
44
+ if (DECIMAL_SECONDS.test(value)) {
45
+ const seconds = Number(value);
46
+ return Number.isSafeInteger(seconds) && Number.isSafeInteger(seconds * 1_000)
47
+ ? seconds * 1_000
48
+ : null;
49
+ }
50
+ if (!HTTP_DATE.test(value))
51
+ return null;
52
+ const retryAt = Date.parse(value);
53
+ if (Number.isNaN(retryAt) || new Date(retryAt).toUTCString() !== value)
54
+ return null;
55
+ return Math.max(0, retryAt - now);
56
+ }
57
+ function isObject(value) {
58
+ return (typeof value === "object" && value !== null) || typeof value === "function";
59
+ }
60
+ function errorDetails(value) {
61
+ try {
62
+ const error = value;
63
+ return { code: error.code, cause: error.cause, name: error.name };
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ /** Classifies only known network error codes, following a finite error cause chain. */
70
+ export function isRetryableNetworkError(error) {
71
+ const seen = new Set();
72
+ let retryable = false;
73
+ let current = error;
74
+ while (isObject(current) && !seen.has(current)) {
75
+ seen.add(current);
76
+ const details = errorDetails(current);
77
+ if (!details || details.name === "AbortError")
78
+ return false;
79
+ if (typeof details.code === "string" && RETRYABLE_NETWORK_CODES.has(details.code)) {
80
+ retryable = true;
81
+ }
82
+ current = details.cause;
83
+ }
84
+ return retryable;
85
+ }
86
+ function assertBackoffOptions(options) {
87
+ const { attempt, baseDelayMs, maxDelayMs, random } = options;
88
+ if (!Number.isSafeInteger(attempt) ||
89
+ attempt < 0 ||
90
+ !Number.isFinite(baseDelayMs) ||
91
+ baseDelayMs < 0 ||
92
+ !Number.isFinite(maxDelayMs) ||
93
+ maxDelayMs < 0 ||
94
+ !Number.isFinite(random) ||
95
+ random < 0 ||
96
+ random > 1) {
97
+ throw new RangeError("Invalid exponential backoff options");
98
+ }
99
+ }
100
+ /** Returns capped exponential delay with deterministic full jitter; it never sleeps. */
101
+ export function computeExponentialBackoffMs(options) {
102
+ assertBackoffOptions(options);
103
+ const { attempt, baseDelayMs, maxDelayMs, random } = options;
104
+ if (baseDelayMs === 0 || maxDelayMs === 0 || random === 0)
105
+ return 0;
106
+ let capped = Math.min(baseDelayMs, maxDelayMs);
107
+ let remainingAttempts = attempt;
108
+ while (remainingAttempts > 0 && capped < maxDelayMs) {
109
+ if (capped >= maxDelayMs / 2) {
110
+ capped = maxDelayMs;
111
+ break;
112
+ }
113
+ capped *= 2;
114
+ remainingAttempts -= 1;
115
+ }
116
+ return Math.floor(capped * random);
117
+ }
package/dist/index.d.mts CHANGED
@@ -10,5 +10,8 @@ export type { LoggerOptions } from "./logger.mts";
10
10
  export * from "./etag.mts";
11
11
  export * from "./cache-control.mts";
12
12
  export * from "./compression.mts";
13
+ export { streamJsonObject } from "./stream-json-object.mts";
14
+ export type { StreamJsonObjectInput } from "./stream-json-object.mts";
13
15
  export type { Handler, RouteBuilder } from "./router.mts";
16
+ export type { MutationRouteOptions } from "./mutation-media-type.mts";
14
17
  export type { CookieOptions, ApplicationOptions, OversizedBodyStrategy, SecurityHeaderName, SecurityHeadersOptions, } from "./types.mts";
package/dist/index.mjs CHANGED
@@ -8,3 +8,4 @@ export * from "./logger.mjs";
8
8
  export * from "./etag.mjs";
9
9
  export * from "./cache-control.mjs";
10
10
  export * from "./compression.mjs";
11
+ export { streamJsonObject } from "./stream-json-object.mjs";
@@ -0,0 +1,8 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ export declare const DEFAULT_MUTATION_MEDIA_TYPES: readonly ["application/json", "application/*+json"];
3
+ export interface MutationRouteOptions {
4
+ /** Replaces the default JSON media types for this mutation route. */
5
+ acceptedMediaTypes: readonly string[];
6
+ }
7
+ export declare function assertAcceptedMutationMediaType(req: IncomingMessage, acceptedMediaTypes?: readonly string[]): Promise<void> | undefined;
8
+ export declare function drainUnreadHttp2Mutation(req: IncomingMessage): void;
@@ -0,0 +1,92 @@
1
+ export const DEFAULT_MUTATION_MEDIA_TYPES = ["application/json", "application/*+json"];
2
+ export function assertAcceptedMutationMediaType(req, acceptedMediaTypes = DEFAULT_MUTATION_MEDIA_TYPES) {
3
+ const contentType = req.headers["content-type"];
4
+ const accepted = typeof contentType === "string" &&
5
+ acceptedMediaTypes.some((type) => matchesMediaType(contentType, type));
6
+ if (!isMutationMethod(req.method))
7
+ return;
8
+ if (req.headers["transfer-encoding"] !== undefined || Number(req.headers["content-length"]) > 0) {
9
+ if (!accepted)
10
+ rejectUnsupportedMediaType(req);
11
+ return;
12
+ }
13
+ if (req.httpVersionMajor === 2 && !accepted) {
14
+ return waitForRejectedHttp2Body(req).then((hasBody) => {
15
+ if (hasBody)
16
+ rejectUnsupportedMediaType(req);
17
+ });
18
+ }
19
+ return undefined;
20
+ }
21
+ export function drainUnreadHttp2Mutation(req) {
22
+ if (req.httpVersionMajor === 2 && isMutationMethod(req.method) && !req.readableEnded)
23
+ drainRequest(req);
24
+ }
25
+ function isMutationMethod(method) {
26
+ return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
27
+ }
28
+ function matchesMediaType(contentType, acceptedMediaType) {
29
+ const actual = parseMediaType(contentType);
30
+ const expected = parseMediaType(acceptedMediaType);
31
+ return (actual !== null &&
32
+ expected !== null &&
33
+ matchesToken(actual[0], expected[0]) &&
34
+ matchesToken(actual[1], expected[1]));
35
+ }
36
+ function parseMediaType(value) {
37
+ const [type, subtype, ...extra] = value.split(";", 1)[0]?.trim().toLowerCase().split("/") ?? [];
38
+ return type && subtype && extra.length === 0 ? [type, subtype] : null;
39
+ }
40
+ function matchesToken(actual, expected) {
41
+ if (!expected.includes("*"))
42
+ return actual === expected;
43
+ const parts = expected.split("*");
44
+ if (!actual.startsWith(parts[0] ?? ""))
45
+ return false;
46
+ let offset = (parts[0] ?? "").length;
47
+ for (const part of parts.slice(1, -1)) {
48
+ const index = actual.indexOf(part, offset);
49
+ if (index === -1)
50
+ return false;
51
+ offset = index + part.length;
52
+ }
53
+ const suffix = parts.at(-1) ?? "";
54
+ return expected.endsWith("*") || actual.slice(offset).endsWith(suffix);
55
+ }
56
+ function rejectUnsupportedMediaType(req) {
57
+ drainRequest(req);
58
+ throw Object.assign(new Error("Unsupported Media Type"), { status: 415 });
59
+ }
60
+ function waitForRejectedHttp2Body(req) {
61
+ if (req.readableEnded)
62
+ return Promise.resolve(false);
63
+ return new Promise((resolve, reject) => {
64
+ const cleanup = () => {
65
+ req.removeListener("data", onData);
66
+ req.removeListener("end", onEnd);
67
+ req.removeListener("error", onError);
68
+ };
69
+ const onData = () => {
70
+ drainRequest(req);
71
+ cleanup();
72
+ resolve(true);
73
+ };
74
+ const onEnd = () => {
75
+ cleanup();
76
+ resolve(false);
77
+ };
78
+ const onError = (error) => {
79
+ cleanup();
80
+ reject(error);
81
+ };
82
+ req.once("data", onData);
83
+ req.once("end", onEnd);
84
+ req.once("error", onError);
85
+ req.resume();
86
+ });
87
+ }
88
+ function drainRequest(req) {
89
+ req.on("error", noop);
90
+ req.resume();
91
+ }
92
+ function noop() { }
@@ -5,9 +5,8 @@ export declare class Request {
5
5
  private res;
6
6
  private bodyPromise;
7
7
  private defaultLimit;
8
- private strictJsonContentType;
9
8
  private readonly oversizedBodyStrategy;
10
- constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false, strictJsonContentType?: boolean, oversizedBodyStrategy?: OversizedBodyStrategy);
9
+ constructor(req: IncomingMessage, res: ServerResponse, defaultLimit?: string | number | false, oversizedBodyStrategy?: OversizedBodyStrategy);
11
10
  is(type: string | string[]): string | false | null;
12
11
  buffer(limit?: string | number | false): Promise<Buffer>;
13
12
  json<T = unknown>(limit?: string | number | false): Promise<T>;
package/dist/request.mjs CHANGED
@@ -8,13 +8,11 @@ export class Request {
8
8
  res;
9
9
  bodyPromise = null;
10
10
  defaultLimit;
11
- strictJsonContentType;
12
11
  oversizedBodyStrategy;
13
- constructor(req, res, defaultLimit = "1mb", strictJsonContentType = false, oversizedBodyStrategy = "drain") {
12
+ constructor(req, res, defaultLimit = "1mb", oversizedBodyStrategy = "drain") {
14
13
  this.req = req;
15
14
  this.res = res;
16
15
  this.defaultLimit = defaultLimit;
17
- this.strictJsonContentType = strictJsonContentType;
18
16
  this.oversizedBodyStrategy = oversizedBodyStrategy;
19
17
  }
20
18
  is(type) {
@@ -31,23 +29,6 @@ export class Request {
31
29
  return this.bodyPromise;
32
30
  }
33
31
  async json(limit) {
34
- if (this.strictJsonContentType) {
35
- // type-is semantics:
36
- // null → no body (no Content-Length / Transfer-Encoding header);
37
- // skip the content-type check — buffer() will return an empty
38
- // buffer that JSON.parse rejects with 400 as usual.
39
- // false → a body is indicated but Content-Type is not a JSON type.
40
- // We 415 only when the body actually has content (CL > 0 or
41
- // Transfer-Encoding is set without CL). A Content-Length: 0
42
- // body has nothing to read, so let it fall through to 400.
43
- // truthy → recognised JSON type; proceed normally.
44
- const mediaType = this.is(["json", "application/*+json"]);
45
- const cl = this.req.headers["content-length"];
46
- const emptyBody = cl !== undefined && Number(cl) === 0;
47
- if (mediaType === false && !emptyBody) {
48
- throw Object.assign(new Error("Unsupported Media Type"), { status: 415 });
49
- }
50
- }
51
32
  const buf = await this.buffer(limit);
52
33
  try {
53
34
  return JSON.parse(buf.toString("utf8"));
@@ -67,6 +48,8 @@ function parseLimit(limit) {
67
48
  return parsed;
68
49
  }
69
50
  function readBody(req, res, limit, oversizedBodyStrategy) {
51
+ if (req.readableEnded)
52
+ return Promise.resolve(Buffer.alloc(0));
70
53
  return new Promise((resolve, reject) => {
71
54
  const maxBytes = parseLimit(limit);
72
55
  if (oversizedBodyStrategy === "close") {
package/dist/router.d.mts CHANGED
@@ -1,14 +1,16 @@
1
1
  import Router from "find-my-way";
2
2
  import type { Context } from "./context.mts";
3
+ import type { MutationRouteOptions } from "./mutation-media-type.mts";
3
4
  type RouterInstance = Router.Instance<Router.HTTPVersion.V1>;
4
5
  export type Handler = (ctx: Context) => Promise<void> | void;
5
6
  export interface RouteBuilder {
6
7
  get(handler: Handler): RouteBuilder;
7
- post(handler: Handler): RouteBuilder;
8
- put(handler: Handler): RouteBuilder;
9
- delete(handler: Handler): RouteBuilder;
10
- patch(handler: Handler): RouteBuilder;
8
+ post(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
9
+ put(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
10
+ delete(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
11
+ patch(handler: Handler, options?: MutationRouteOptions): RouteBuilder;
11
12
  }
12
13
  export declare function isSupportedHttpMethod(method: string): boolean;
13
14
  export declare function createRouteBuilder(router: RouterInstance, path: string): RouteBuilder;
15
+ export declare function getAcceptedMediaTypes(handler: Router.Handler<Router.HTTPVersion.V1>): readonly string[] | undefined;
14
16
  export {};
package/dist/router.mjs CHANGED
@@ -10,28 +10,34 @@ export function createRouteBuilder(router, path) {
10
10
  router.on("HEAD", path, wrapHandler(handler));
11
11
  return builder;
12
12
  },
13
- post(handler) {
14
- router.on("POST", path, wrapHandler(handler));
13
+ post(handler, options) {
14
+ router.on("POST", path, wrapHandler(handler, options));
15
15
  return builder;
16
16
  },
17
- put(handler) {
18
- router.on("PUT", path, wrapHandler(handler));
17
+ put(handler, options) {
18
+ router.on("PUT", path, wrapHandler(handler, options));
19
19
  return builder;
20
20
  },
21
- delete(handler) {
22
- router.on("DELETE", path, wrapHandler(handler));
21
+ delete(handler, options) {
22
+ router.on("DELETE", path, wrapHandler(handler, options));
23
23
  return builder;
24
24
  },
25
- patch(handler) {
26
- router.on("PATCH", path, wrapHandler(handler));
25
+ patch(handler, options) {
26
+ router.on("PATCH", path, wrapHandler(handler, options));
27
27
  return builder;
28
28
  },
29
29
  };
30
30
  return builder;
31
31
  }
32
- function wrapHandler(handler) {
33
- return (_req, _res, _params, store) => {
32
+ export function getAcceptedMediaTypes(handler) {
33
+ return handler.acceptedMediaTypes;
34
+ }
35
+ function wrapHandler(handler, options) {
36
+ const wrapped = (_req, _res, _params, store) => {
34
37
  const ctx = store;
35
38
  return handler(ctx);
36
39
  };
40
+ if (options)
41
+ wrapped.acceptedMediaTypes = options.acceptedMediaTypes;
42
+ return wrapped;
37
43
  }
@@ -0,0 +1,2 @@
1
+ /** Returns the SHA-256 digest bytes for a string or visible ArrayBufferView bytes. */
2
+ export declare function sha256(input: string | NodeJS.ArrayBufferView): Buffer;
@@ -0,0 +1,5 @@
1
+ import { createHash } from "node:crypto";
2
+ /** Returns the SHA-256 digest bytes for a string or visible ArrayBufferView bytes. */
3
+ export function sha256(input) {
4
+ return createHash("sha256").update(input).digest();
5
+ }
@@ -0,0 +1,7 @@
1
+ import { Readable } from "node:stream";
2
+ export type StreamJsonObjectInput<T extends object> = {
3
+ [Key in keyof T]: T[Key] | PromiseLike<T[Key]>;
4
+ };
5
+ export declare function streamJsonObject<T extends object>(input: StreamJsonObjectInput<T>): Readable;
6
+ /** @internal Test-only visibility for bounded waiter regression coverage. */
7
+ export declare function getStreamJsonObjectWaiterCountForTesting(stream: Readable): number;
@@ -0,0 +1,186 @@
1
+ import { Readable } from "node:stream";
2
+ import { isPromise } from "node:util/types";
3
+ import { JsonStreamStringify } from "json-stream-stringify";
4
+ const cancelled = Symbol("cancelled");
5
+ const states = new WeakMap();
6
+ export function streamJsonObject(input) {
7
+ const state = createState();
8
+ const stream = Readable.from(iterateObject(snapshotEntries(input, state), state));
9
+ const destroy = stream.destroy.bind(stream);
10
+ stream.destroy = ((error) => {
11
+ stop(state);
12
+ return destroy(error);
13
+ });
14
+ stream.once("close", () => stop(state));
15
+ states.set(stream, state);
16
+ return stream;
17
+ }
18
+ /** @internal Test-only visibility for bounded waiter regression coverage. */
19
+ export function getStreamJsonObjectWaiterCountForTesting(stream) {
20
+ return states.get(stream)?.listeners.size ?? 0;
21
+ }
22
+ async function* iterateObject(entries, state) {
23
+ if (state.failed)
24
+ throw state.failure;
25
+ yield "{";
26
+ let hasEntries = false;
27
+ while (!state.closed) {
28
+ const entry = await waitFor(nextEntry(entries), state);
29
+ if (entry === cancelled)
30
+ return;
31
+ if (!entry)
32
+ break;
33
+ if ("error" in entry)
34
+ throw entry.error;
35
+ hasEntries = yield* iterateEntry(entry, hasEntries, state);
36
+ }
37
+ yield "}";
38
+ }
39
+ async function* iterateEntry(entry, hasEntries, state) {
40
+ const source = entry.value instanceof Readable ? entry.value : null;
41
+ const producer = new JsonStreamStringify(entry.value);
42
+ state.producer = producer;
43
+ try {
44
+ const iterator = producer[Symbol.asyncIterator]();
45
+ const first = await waitFor(iterator.next(), state);
46
+ if (first === cancelled || first.done)
47
+ return hasEntries;
48
+ yield `${hasEntries ? "," : ""}${JSON.stringify(entry.key)}:${String(first.value)}`;
49
+ for (;;) {
50
+ const next = await waitFor(iterator.next(), state);
51
+ if (next === cancelled || next.done)
52
+ return true;
53
+ yield String(next.value);
54
+ }
55
+ }
56
+ finally {
57
+ if (state.producer === producer)
58
+ state.producer = null;
59
+ source?.destroy();
60
+ producer.destroy();
61
+ }
62
+ }
63
+ function createState() {
64
+ const state = {
65
+ closed: false,
66
+ failure: undefined,
67
+ failed: false,
68
+ fail: null,
69
+ listeners: new Set(),
70
+ producer: null,
71
+ sources: new Set(),
72
+ };
73
+ state.fail = (error) => {
74
+ if (!state.failed) {
75
+ state.failed = true;
76
+ state.failure = error;
77
+ for (const listener of state.listeners)
78
+ listener.fail(error);
79
+ state.listeners.clear();
80
+ state.producer?.destroy();
81
+ for (const source of state.sources)
82
+ source.destroy();
83
+ }
84
+ };
85
+ return state;
86
+ }
87
+ function stop(state) {
88
+ if (state.closed)
89
+ return;
90
+ state.closed = true;
91
+ for (const listener of state.listeners)
92
+ listener.stop();
93
+ state.listeners.clear();
94
+ state.producer?.destroy();
95
+ for (const source of state.sources)
96
+ source.destroy();
97
+ }
98
+ function snapshotEntries(input, state) {
99
+ const pending = { count: 0, entries: [], waiters: [] };
100
+ for (const [key, value] of Object.entries(input)) {
101
+ const promise = assimilate(value);
102
+ if (promise)
103
+ addPendingEntry(pending, key, promise, state);
104
+ else {
105
+ if (value instanceof Readable)
106
+ state.sources.add(value);
107
+ pending.entries.push({ key, value });
108
+ }
109
+ }
110
+ return pending;
111
+ }
112
+ function addPendingEntry(pending, key, value, state) {
113
+ pending.count += 1;
114
+ const resolve = (resolved) => settle(pending, { key, value: resolved });
115
+ const reject = (error) => {
116
+ state.fail(error);
117
+ settle(pending, { error });
118
+ };
119
+ Reflect.apply(Promise.prototype.then, value, [resolve, reject]);
120
+ }
121
+ function settle(pending, entry) {
122
+ pending.count -= 1;
123
+ const waiter = pending.waiters.shift();
124
+ if (waiter)
125
+ waiter(entry);
126
+ else
127
+ pending.entries.push(entry);
128
+ }
129
+ function nextEntry(pending) {
130
+ const entry = pending.entries.shift();
131
+ if (entry)
132
+ return Promise.resolve(entry);
133
+ return pending.count === 0
134
+ ? Promise.resolve(undefined)
135
+ : new Promise((resolve) => pending.waiters.push(resolve));
136
+ }
137
+ function waitFor(value, state) {
138
+ if (state.closed)
139
+ return Promise.resolve(cancelled);
140
+ if (state.failed)
141
+ return Promise.reject(state.failure);
142
+ return new Promise((resolve, reject) => {
143
+ let settled = false;
144
+ const finish = (settle) => {
145
+ if (settled)
146
+ return;
147
+ settled = true;
148
+ state.listeners.delete(listener);
149
+ settle();
150
+ };
151
+ const listener = {
152
+ fail: (error) => finish(() => reject(error)),
153
+ stop: () => finish(() => resolve(cancelled)),
154
+ };
155
+ state.listeners.add(listener);
156
+ Reflect.apply(Promise.prototype.then, value, [
157
+ (result) => finish(() => resolve(result)),
158
+ (error) => finish(() => reject(error)),
159
+ ]);
160
+ });
161
+ }
162
+ function assimilate(value) {
163
+ if (!value || (typeof value !== "object" && typeof value !== "function"))
164
+ return;
165
+ if (isPromise(value))
166
+ return value;
167
+ let then;
168
+ try {
169
+ then = value.then;
170
+ }
171
+ catch (error) {
172
+ return Promise.reject(error);
173
+ }
174
+ if (typeof then !== "function")
175
+ return;
176
+ return new Promise((resolve, reject) => {
177
+ queueMicrotask(() => {
178
+ try {
179
+ Reflect.apply(then, value, [resolve, reject]);
180
+ }
181
+ catch (error) {
182
+ reject(error);
183
+ }
184
+ });
185
+ });
186
+ }
package/dist/types.d.mts CHANGED
@@ -22,13 +22,4 @@ export interface ApplicationOptions {
22
22
  trustProxy?: boolean;
23
23
  /** Reject methods outside node:http.METHODS with 400. Defaults to false. */
24
24
  strictHttpMethods?: boolean;
25
- /**
26
- * When true, ctx.request.json() rejects requests whose Content-Type is not
27
- * application/json (or a compatible JSON subtype such as application/merge-patch+json)
28
- * with a 415 Unsupported Media Type error. Requests with no body are unaffected.
29
- *
30
- * Defaults to false (lenient: any Content-Type is accepted, preserving
31
- * backward-compatible behavior).
32
- */
33
- strictJsonContentType?: boolean;
34
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jongleberry/api-server",
3
- "version": "1.2.2",
3
+ "version": "2.1.0",
4
4
  "description": "A Node.js HTTP server library",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -18,6 +18,14 @@
18
18
  "./trusted-client-ip": {
19
19
  "types": "./dist/trusted-client-ip.d.mts",
20
20
  "import": "./dist/trusted-client-ip.mjs"
21
+ },
22
+ "./http-retry": {
23
+ "types": "./dist/http-retry.d.mts",
24
+ "import": "./dist/http-retry.mjs"
25
+ },
26
+ "./sha256": {
27
+ "types": "./dist/sha256.d.mts",
28
+ "import": "./dist/sha256.mjs"
21
29
  }
22
30
  },
23
31
  "engines": {
@@ -30,6 +38,7 @@
30
38
  "find-my-way": "^9.6.0",
31
39
  "http-assert": "^1.5.0",
32
40
  "http-errors": "^2.0.1",
41
+ "json-stream-stringify": "^3.1.7",
33
42
  "negotiator": "^1.0.0",
34
43
  "type-is": "^2.1.0"
35
44
  },
@@ -44,7 +53,7 @@
44
53
  "@types/type-is": "^1.6.7",
45
54
  "@vitest/coverage-v8": "^4.1.6",
46
55
  "husky": "^9.1.7",
47
- "oxfmt": "^0.59.0",
56
+ "oxfmt": "^0.63.0",
48
57
  "oxlint": "^1.65.0",
49
58
  "supertest": "^7.2.2",
50
59
  "typescript": "^7.0.2",