@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/.projen/deps.json +29 -0
- package/.projen/files.json +11 -0
- package/.projen/tasks.json +121 -0
- package/README.md +220 -0
- package/index.ts +26 -0
- package/package.json +43 -0
- package/src/async.ts +209 -0
- package/src/error.ts +178 -0
- package/src/function.ts +91 -0
- package/src/hash.ts +261 -0
- package/src/http.ts +223 -0
- package/src/iterable.ts +790 -0
- package/src/log.ts +380 -0
- package/src/net.ts +535 -0
- package/src/object.ts +165 -0
- package/src/predicate.ts +151 -0
- package/src/string.ts +483 -0
- package/src/token.ts +136 -0
- package/test/tsconfig.json +14 -0
- package/tsconfig.json +40 -0
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
|
+
}
|