@dbx-tools/shared-core 0.1.2

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/src/http.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Framework-agnostic readers for HTTP requests, shared across AppKit
3
+ * plugins: header and cookie extraction. Works uniformly across
4
+ * Express, Node `IncomingMessage`, WHATWG `Request` / `Response` /
5
+ * `Headers`, Hono, and any object that exposes a `headers` field of one
6
+ * of those shapes. Dependency-free and browser-safe so it can run in
7
+ * either a server or a client bundle.
8
+ *
9
+ * URL path matching lives in `./net.browser.ts` (`pathMatch`).
10
+ */
11
+
12
+ // ────────────────────────────────────────────────────────────────
13
+ // Types
14
+ // ────────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Anything that contains HTTP headers. Accepts:
18
+ *
19
+ * - A WHATWG `Headers` instance (fetch / undici / Hono `c.req.raw.headers`).
20
+ * - A header record (`Record<string, string | string[] | undefined>`),
21
+ * Node / Express style.
22
+ * - Any object with a `headers` field of one of the above. This covers
23
+ * Express `req`, Node `IncomingMessage`, WHATWG `Request` / `Response`,
24
+ * Hono `c.req.raw`, and similar shapes.
25
+ */
26
+ export type HeaderLike = Headers | HeaderRecord | { headers: Headers | HeaderRecord };
27
+
28
+ /**
29
+ * Single header value as exposed by Node `IncomingMessage.headers` and
30
+ * Express `req.headers` (string for most headers, array for repeated
31
+ * headers such as `Set-Cookie`).
32
+ */
33
+ type HeaderValueLike = string[] | string | undefined;
34
+
35
+ /** Header bag with case-insensitive keys (Node / Express style). */
36
+ type HeaderRecord = Record<string, HeaderValueLike>;
37
+
38
+ // ────────────────────────────────────────────────────────────────
39
+ // Header helpers
40
+ // ────────────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Invokes `consumer` once per value for `headerName`, case-insensitive.
44
+ *
45
+ * - **Record input:** if the field is an array (e.g. repeated `Set-Cookie`),
46
+ * `consumer` runs once per array item.
47
+ * - **`Headers` input:** uses `get(name)` (which spec-joins repeats with
48
+ * `, `) except for `Set-Cookie`, which uses `getSetCookie()` so each
49
+ * cookie is delivered separately.
50
+ *
51
+ * @example
52
+ * forEachHeaderValue(req, "x-trace-id", (v) => spans.push(v)); // Express
53
+ * forEachHeaderValue(c.req.raw, "set-cookie", (v) => log(v)); // Hono
54
+ * forEachHeaderValue(headersInstance, "cookie", parse); // fetch
55
+ */
56
+ export function forEachHeaderValue(
57
+ input: HeaderLike | null | undefined,
58
+ headerName: string,
59
+ consumer: (value: string) => void,
60
+ ): void {
61
+ const headers = unwrap(input);
62
+ if (!headers) return;
63
+
64
+ const target = headerName.toLowerCase();
65
+
66
+ if (isHeaders(headers)) {
67
+ // `Headers.get` joins repeated values with `, ` per spec, which
68
+ // mangles `Set-Cookie` (cookies legitimately contain commas in
69
+ // their `expires=` attribute). `getSetCookie` is the dedicated
70
+ // splitter and is the only safe path for that header.
71
+ if (target === "set-cookie") {
72
+ for (const value of headers.getSetCookie()) consumer(value);
73
+ return;
74
+ }
75
+ const value = headers.get(headerName);
76
+ if (value !== null) consumer(value);
77
+ return;
78
+ }
79
+
80
+ for (const [key, value] of Object.entries(headers)) {
81
+ if (value == null || key.toLowerCase() !== target) continue;
82
+ if (Array.isArray(value)) {
83
+ for (const item of value) consumer(item);
84
+ } else {
85
+ consumer(value);
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Parses `Cookie` header values into a name-to-value map (URI-decoded).
92
+ *
93
+ * Accepts:
94
+ *
95
+ * - A raw `Cookie` string (`"a=1; b=2"`).
96
+ * - An array of such strings (e.g. multiple `Cookie` headers).
97
+ * - Any {@link HeaderLike}: a WHATWG `Headers` instance, a header
98
+ * record, or a request-like object with a `headers` field.
99
+ *
100
+ * First occurrence of each cookie name wins; later duplicates are ignored.
101
+ *
102
+ * @example
103
+ * parseCookies("session=abc; theme=dark");
104
+ * // { session: "abc", theme: "dark" }
105
+ *
106
+ * parseCookies(req); // Express / Node
107
+ * parseCookies(c.req.raw); // Hono
108
+ * parseCookies(request); // fetch Request
109
+ * parseCookies(request.headers); // WHATWG Headers directly
110
+ */
111
+ export function parseCookies(input: HeaderLike | HeaderValueLike | null): Record<string, string> {
112
+ if (input == null) return {};
113
+ const out: Record<string, string> = {};
114
+
115
+ if (typeof input === "string") {
116
+ parseCookieString(input, out);
117
+ return out;
118
+ }
119
+
120
+ if (Array.isArray(input)) {
121
+ for (const item of input) {
122
+ if (typeof item === "string") parseCookieString(item, out);
123
+ }
124
+ return out;
125
+ }
126
+
127
+ forEachHeaderValue(input, "cookie", (value) => {
128
+ parseCookieString(value, out);
129
+ });
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * Build an `Error` describing a failed `fetch` response: status line,
135
+ * the URL(s) involved (the requested `url` and the final `response.url`
136
+ * after redirects, de-duplicated), and a truncated snapshot of the
137
+ * response body. Reads the body defensively - a body that can't be read
138
+ * is simply omitted. Call only on a non-`ok` response.
139
+ */
140
+ export async function createFetchError(response: Response, url?: string): Promise<Error> {
141
+ let body = "";
142
+ try {
143
+ body = (await response.text()).trim();
144
+ } catch {
145
+ // ignore
146
+ }
147
+ if (body.length > 1_000) {
148
+ body = `${body.slice(0, 1_000)}…`;
149
+ }
150
+ const urlSummary = [...new Set([url, response.url].filter(Boolean))].join(" -> ");
151
+ return new Error(
152
+ [
153
+ `Request failed.`,
154
+ `${response.status} ${response.statusText}`,
155
+ `${urlSummary}`,
156
+ body && `Response:\n${body}`,
157
+ ]
158
+ .filter(Boolean)
159
+ .join("\n"),
160
+ );
161
+ }
162
+
163
+ // ────────────────────────────────────────────────────────────────
164
+ // Private helpers
165
+ // ────────────────────────────────────────────────────────────────
166
+
167
+ /**
168
+ * Type guard for WHATWG `Headers`. Duck-types on the two methods that
169
+ * matter to this module (`get` and `getSetCookie`) so polyfilled
170
+ * implementations and Hono's `HonoHeaders` are accepted without
171
+ * pulling `Headers` in as a hard dependency.
172
+ */
173
+ function isHeaders(value: unknown): value is Headers {
174
+ return (
175
+ typeof value === "object" &&
176
+ value !== null &&
177
+ typeof (value as Headers).get === "function" &&
178
+ typeof (value as Headers).getSetCookie === "function"
179
+ );
180
+ }
181
+
182
+ /**
183
+ * `HeaderRecord` is an index signature, so `"headers" in input` cannot
184
+ * discriminate it from the wrapped `{ headers }` shape at the type
185
+ * level. This guard inspects the runtime value of `headers`: only
186
+ * objects (`Headers` or a nested record) qualify as the wrapper shape,
187
+ * never stray string/array values that happen to live under a `headers`
188
+ * key on a header record.
189
+ */
190
+ function isWrapped(
191
+ input: HeaderRecord | { headers: Headers | HeaderRecord },
192
+ ): input is { headers: Headers | HeaderRecord } {
193
+ const headers = (input as { headers?: unknown }).headers;
194
+ return headers != null && typeof headers === "object" && !Array.isArray(headers);
195
+ }
196
+
197
+ /**
198
+ * Parse a single `Cookie`-style header string (`"a=1; b=2"`) into
199
+ * `out`. Names without a value are skipped; first occurrence wins so
200
+ * later duplicates are ignored. Cookie values are URI-decoded.
201
+ */
202
+ function parseCookieString(input: string, out: Record<string, string>): void {
203
+ for (const part of input.split(";")) {
204
+ const eq = part.indexOf("=");
205
+ if (eq < 0) continue;
206
+ const name = part.slice(0, eq).trim();
207
+ if (!name || name in out) continue;
208
+ const raw = part.slice(eq + 1).trim();
209
+ out[name] = decodeURIComponent(raw);
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Normalize a {@link HeaderLike} input down to either a `Headers`
215
+ * instance or a header `Record`. Returns `null` for missing input so
216
+ * callers can short-circuit without a separate nullish check.
217
+ */
218
+ function unwrap(input: HeaderLike | null | undefined): Headers | HeaderRecord | null {
219
+ if (input == null) return null;
220
+ if (isHeaders(input)) return input;
221
+ if (isWrapped(input)) return input.headers;
222
+ return input;
223
+ }