@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 +22 -18
- package/dist/etag.mjs +2 -2
- package/dist/http-retry.d.mts +19 -0
- package/dist/http-retry.mjs +117 -0
- package/dist/sha256.d.mts +2 -0
- package/dist/sha256.mjs +5 -0
- package/package.json +9 -1
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
|
-
| [
|
|
85
|
-
| [
|
|
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
|
|
1
|
+
import { sha256 } from "./sha256.mjs";
|
|
2
2
|
export function generateETag(body) {
|
|
3
|
-
const hash =
|
|
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/sha256.mjs
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jongleberry/api-server",
|
|
3
|
-
"version": "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": {
|