@jongleberry/api-server 2.0.0 → 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
@@ -53,6 +53,8 @@ app.route("/").get((ctx) => ctx.json({ ok: true }));
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
 
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
+ }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jongleberry/api-server",
3
- "version": "2.0.0",
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": {