@dbx-tools/shared 0.1.18

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/common.ts ADDED
@@ -0,0 +1,422 @@
1
+ import fastDeepEqual from "fast-deep-equal";
2
+
3
+ /** Minimal shape for objects that expose an optional `name` (e.g. AppKit plugins). */
4
+ export interface NameLike {
5
+ name?: string;
6
+ }
7
+
8
+ type MemoizeKeyFn<TArgs extends readonly unknown[]> = (...args: TArgs) => string;
9
+
10
+ export interface MemoizeOptions<TArgs extends readonly unknown[]> {
11
+ /** Build a cache key from call arguments. Defaults to `JSON.stringify(args)`. */
12
+ key?: MemoizeKeyFn<TArgs>;
13
+ }
14
+
15
+ /**
16
+ * Run a zero-argument factory once; later calls return the same result.
17
+ *
18
+ * Concurrent callers share one in-flight promise until the factory settles.
19
+ * Thenable returns (anything with a `.then` method) are accepted; the
20
+ * cached value is always a native `Promise<T>` because we route through
21
+ * `Promise.resolve().then(factory)`.
22
+ */
23
+ export function memoize<T>(factory: () => T | PromiseLike<T>): () => Promise<T>;
24
+
25
+ /**
26
+ * Memoize by call arguments. Sync `fn` returns values directly; if `fn`
27
+ * returns a thenable (`Promise` or any object with a `.then` method),
28
+ * concurrent calls for the same key share one in-flight promise.
29
+ *
30
+ * Input is `T | PromiseLike<T>` so foreign thenables (e.g. third-party
31
+ * promise libraries, hand-rolled `{ then }` shims) are accepted; the
32
+ * async branch wraps them with `Promise.resolve(...)` so the cached
33
+ * entry is always a native `Promise<T>` even when the caller hands us a
34
+ * non-spec-compliant thenable.
35
+ */
36
+ export function memoize<TArgs extends readonly unknown[], TReturn>(
37
+ fn: (...args: TArgs) => TReturn | PromiseLike<TReturn>,
38
+ options?: MemoizeOptions<TArgs>,
39
+ ): (...args: TArgs) => TReturn | Promise<TReturn>;
40
+
41
+ export function memoize<TArgs extends readonly unknown[], TReturn>(
42
+ fn:
43
+ | ((...args: TArgs) => TReturn | PromiseLike<TReturn>)
44
+ | (() => TReturn | PromiseLike<TReturn>),
45
+ options?: MemoizeOptions<TArgs>,
46
+ ): ((...args: TArgs) => TReturn | Promise<TReturn>) | (() => Promise<TReturn>) {
47
+ if (fn.length === 0) {
48
+ const factory = fn as () => TReturn | PromiseLike<TReturn>;
49
+ let cache: Promise<TReturn> | undefined;
50
+ return () => {
51
+ if (cache === undefined) {
52
+ cache = Promise.resolve().then(factory);
53
+ }
54
+ return cache;
55
+ };
56
+ }
57
+
58
+ const keyOf = options?.key ?? defaultMemoizeKey<TArgs>;
59
+ const syncCache = new Map<string, TReturn>();
60
+ const asyncCache = new Map<string, Promise<TReturn>>();
61
+
62
+ return (...args: TArgs) => {
63
+ const key = keyOf(...args);
64
+ if (asyncCache.has(key)) {
65
+ return asyncCache.get(key)!;
66
+ }
67
+ if (syncCache.has(key)) {
68
+ return syncCache.get(key)!;
69
+ }
70
+
71
+ const result = (fn as (...args: TArgs) => TReturn | PromiseLike<TReturn>)(...args);
72
+ if (isThenable(result)) {
73
+ const pending = Promise.resolve(result);
74
+ asyncCache.set(key, pending);
75
+ void pending.catch(() => {
76
+ asyncCache.delete(key);
77
+ });
78
+ return pending;
79
+ }
80
+
81
+ syncCache.set(key, result);
82
+ return result;
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Method decorator: memoizes the decorated method by its arguments.
88
+ *
89
+ * Requires `experimentalDecorators` in the consumer's `tsconfig.json`.
90
+ */
91
+ export function memoized(
92
+ _target: object,
93
+ _propertyKey: string | symbol,
94
+ descriptor: PropertyDescriptor,
95
+ ): PropertyDescriptor {
96
+ const original = descriptor.value;
97
+ if (typeof original !== "function") {
98
+ throw new TypeError("@memoized can only decorate methods");
99
+ }
100
+ descriptor.value = memoize(original as (...args: readonly unknown[]) => unknown);
101
+ return descriptor;
102
+ }
103
+
104
+ /**
105
+ * Per-iteration context handed to {@link PollProducer} and the
106
+ * predicate on each step of a {@link poll} loop. Bundles the
107
+ * iteration metadata so the call signatures stay stable as `poll`
108
+ * grows additional fields.
109
+ *
110
+ * `signal` is owned by `poll`: it tracks the external
111
+ * `PollOptions.signal` (when supplied) and also fires when the
112
+ * consumer breaks out of the loop, so producers can forward it to
113
+ * any in-flight work (`fetch`, SDK calls, etc.) and have a single
114
+ * cancellation source tear down both the request and the loop.
115
+ *
116
+ * `attributes` is a mutable scratchpad shared across every
117
+ * iteration of a single `poll` run. The same object reference is
118
+ * passed each call so writes from one iteration are visible to the
119
+ * next - useful for stashing per-loop state (retry counters, start
120
+ * timestamps, anything you'd otherwise close over via a let).
121
+ * Generic `A` lets callers type the bag; defaults to
122
+ * `Record<string, unknown>`.
123
+ */
124
+ export interface PollContext<T, A = Record<string, unknown>> {
125
+ /** Zero-based iteration index (`0` on the first call). */
126
+ attempt: number;
127
+ /** Value yielded on the prior iteration; `undefined` on the first. */
128
+ previous: T | undefined;
129
+ /** Cancellation handle. Always defined; forward to in-flight work. */
130
+ signal: AbortSignal;
131
+ /** Per-run mutable scratchpad shared across iterations. */
132
+ attributes: A;
133
+ }
134
+
135
+ /** One step of a {@link poll} loop. See {@link PollContext}. */
136
+ export type PollProducer<T, A = Record<string, unknown>> = (
137
+ ctx: PollContext<T, A>,
138
+ ) => T | PromiseLike<T>;
139
+
140
+ export interface PollOptions<T, A = Record<string, unknown>> {
141
+ /** Milliseconds to wait between polls. */
142
+ intervalMs: number;
143
+ /**
144
+ * Predicate evaluated against each yielded value: return `true` to
145
+ * keep polling, `false` to stop. May be sync or async - a
146
+ * `PromiseLike<boolean>` is awaited before the decision is made.
147
+ * Receives the same {@link PollContext} as the producer (same
148
+ * `signal`, same `attributes` bag), so an async predicate can
149
+ * forward the signal to its own in-flight work or read/write
150
+ * shared state.
151
+ *
152
+ * Omit to poll forever (the consumer stops by breaking out of the
153
+ * loop or by aborting `signal`).
154
+ */
155
+ filter?:
156
+ | ((value: T, ctx: PollContext<T, A>) => boolean | PromiseLike<boolean>)
157
+ | "distinct";
158
+ predicate?: (value: T, ctx: PollContext<T, A>) => boolean | PromiseLike<boolean>;
159
+ /**
160
+ * External cancellation handle. Tied into the internal signal that
161
+ * `poll` hands to `producer`, so aborting it tears down both the
162
+ * in-flight request and the inter-poll sleep.
163
+ */
164
+ signal?: AbortSignal;
165
+ /**
166
+ * Initial value for `ctx.attributes`. Defaults to `{}`. The same
167
+ * object is reused across iterations, so callers can pre-populate
168
+ * fields (timers, retry counters, etc.) and the producer /
169
+ * predicate can mutate them in place.
170
+ */
171
+ attributes?: A;
172
+ }
173
+
174
+ /**
175
+ * Async iterable that drives a periodic poll. Each iteration:
176
+ *
177
+ * 1. Builds a {@link PollContext} (`attempt`, `previous`, `signal`,
178
+ * shared `attributes`) and calls `producer(ctx)`; yields the
179
+ * resolved value.
180
+ * 2. Evaluates `options.predicate(value, ctx)`; stops when it
181
+ * returns (or resolves to) `false`.
182
+ * 3. Sleeps `options.intervalMs` before the next attempt.
183
+ *
184
+ * The first call runs immediately (no leading sleep) so the consumer
185
+ * sees a value without waiting an interval. Errors thrown by
186
+ * `producer` propagate through the generator.
187
+ *
188
+ * `poll` always creates an internal `AbortController` and exposes
189
+ * `internal.signal` as `ctx.signal`, so producers can rely on a
190
+ * defined signal without a nullish check. The external
191
+ * `options.signal` is tied in, and a `try/finally` aborts the
192
+ * internal signal when the consumer breaks out of the `for await`
193
+ * (or the loop throws), so any producer work still holding the
194
+ * signal sees the cancellation too.
195
+ *
196
+ * @example
197
+ * for await (const msg of poll(
198
+ * async ({ signal }) =>
199
+ * client.genie.getMessage({ ... }, { abortSignal: signal }),
200
+ * {
201
+ * intervalMs: 250,
202
+ * predicate: (m) => !TERMINAL_STATUSES.has(m.status),
203
+ * signal: controller.signal,
204
+ * },
205
+ * )) {
206
+ * render(msg);
207
+ * }
208
+ *
209
+ * @example
210
+ * // Typed attributes for per-run state.
211
+ * type Stats = { failures: number; startedAt: number };
212
+ * for await (const x of poll<Thing, Stats>(
213
+ * async ({ attributes, signal }) => {
214
+ * try {
215
+ * return await fetchThing(signal);
216
+ * } catch (err) {
217
+ * attributes.failures += 1;
218
+ * throw err;
219
+ * }
220
+ * },
221
+ * {
222
+ * intervalMs: 500,
223
+ * attributes: { failures: 0, startedAt: Date.now() },
224
+ * predicate: (_v, { attempt, attributes }) =>
225
+ * attempt < 20 && attributes.failures < 3,
226
+ * },
227
+ * )) {
228
+ * handle(x);
229
+ * }
230
+ */
231
+ export async function* poll<T, A = Record<string, unknown>>(
232
+ producer: PollProducer<T, A>,
233
+ options: PollOptions<T, A>,
234
+ ): AsyncGenerator<T, void, void> {
235
+ const { intervalMs, predicate, signal, attributes } = options;
236
+ const controller = new AbortController();
237
+ if (signal) tieAbortSignal(controller, signal);
238
+ // Single shared attributes object so writes from one iteration are
239
+ // visible on the next. `{} as A` is safe because either the caller
240
+ // supplied `attributes` (typed) or `A` defaulted to the unknown
241
+ // record shape (in which case `{}` satisfies it).
242
+ const sharedAttributes = attributes ?? ({} as A);
243
+ try {
244
+ let previous: T | undefined;
245
+ for (let attempt = 0; ; attempt++) {
246
+ controller.signal.throwIfAborted();
247
+ const ctx: PollContext<T, A> = {
248
+ attempt,
249
+ previous,
250
+ signal: controller.signal,
251
+ attributes: sharedAttributes,
252
+ };
253
+ const value = await producer(ctx);
254
+ if (options.filter) {
255
+ if (options.filter === "distinct") {
256
+ if (fastDeepEqual(previous, value)) continue;
257
+ } else if (!(await options.filter(value, ctx))) continue;
258
+ }
259
+ yield value;
260
+ if (predicate && !(await predicate(value, ctx))) return;
261
+ await sleep(intervalMs, controller.signal);
262
+ previous = value;
263
+ }
264
+ } finally {
265
+ controller.abort();
266
+ }
267
+ }
268
+
269
+ function defaultMemoizeKey<TArgs extends readonly unknown[]>(...args: TArgs): string {
270
+ return JSON.stringify(args);
271
+ }
272
+
273
+ function isThenable<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
274
+ return (
275
+ value !== null &&
276
+ typeof value === "object" &&
277
+ "then" in value &&
278
+ typeof (value as PromiseLike<T>).then === "function"
279
+ );
280
+ }
281
+
282
+ /**
283
+ * Tie a child `AbortController` to a parent signal. The child
284
+ * aborts whenever the parent aborts; aborting the child does not
285
+ * affect the parent (so a fetch-level cancel doesn't tear down the
286
+ * main poll loop).
287
+ */
288
+ export function tieAbortSignal(child: AbortController, parent?: AbortSignal): void {
289
+ if (!parent) return;
290
+ else if (parent.aborted) {
291
+ child.abort(parent.reason);
292
+ return;
293
+ }
294
+ parent.addEventListener("abort", () => child.abort(parent.reason), {
295
+ once: true,
296
+ });
297
+ }
298
+
299
+ /**
300
+ * Promisified `setTimeout` that wakes up early (and rejects with
301
+ * `signal.reason`) when `signal` aborts mid-wait. Short-circuits to a
302
+ * rejected promise when the signal is already aborted on entry.
303
+ */
304
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
305
+ if (signal?.aborted) return Promise.reject(signal.reason);
306
+ return new Promise((resolve, reject) => {
307
+ const onAbort = (): void => {
308
+ clearTimeout(timer);
309
+ reject(signal!.reason);
310
+ };
311
+ const timer = setTimeout(() => {
312
+ signal?.removeEventListener("abort", onAbort);
313
+ resolve();
314
+ }, ms);
315
+ signal?.addEventListener("abort", onAbort, { once: true });
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Mint a short, collision-resistant id by sampling the first `length`
321
+ * hex chars of a v4 UUID. `length` defaults to 8 (collision odds
322
+ * ~1 in 4 billion - safe within a single conversation turn / job /
323
+ * batch). Uses `globalThis.crypto.randomUUID()` so it works in
324
+ * both Node (>= 19) and modern browsers.
325
+ *
326
+ * Use for ids that the caller cares about being typeable / short
327
+ * (e.g. chart ids the LLM types into `[[chart:<id>]]` markers).
328
+ * For ids that need to survive across long-running batches or be
329
+ * globally unique, use a full UUID instead.
330
+ */
331
+ export function shortId(length: number = 8): string {
332
+ return globalThis.crypto.randomUUID().replace(/-/g, "").slice(0, length);
333
+ }
334
+
335
+ export function fnvHash(...values: string[]): string {
336
+ return fnvHashWithOptions({}, ...values);
337
+ }
338
+
339
+ export function fnvHashWithOptions(
340
+ options: { length?: number; alphabet?: string } = {},
341
+ ...values: string[]
342
+ ): string {
343
+ const { length = 6 } = options;
344
+
345
+ let digest = 0x811c9dc5;
346
+
347
+ for (const value of values) {
348
+ for (let i = 0; i < value.length; i++) {
349
+ digest ^= value.charCodeAt(i);
350
+ digest = Math.imul(digest, 0x01000193);
351
+ }
352
+ }
353
+ const alphabet = base32Alphabet(options.alphabet);
354
+ return toBase32(digest, alphabet, true)
355
+ .padStart(7, alphabet[0])
356
+ .slice(0, Math.min(length, 7));
357
+ }
358
+
359
+ const BASE32_ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
360
+
361
+ function base32Alphabet(alphabet?: string): string {
362
+ if (alphabet === undefined) return BASE32_ALPHABET;
363
+ else if (new Set(alphabet).size !== 32) {
364
+ throw new Error("Base32 alphabet must contain 32 unique characters");
365
+ }
366
+ return alphabet;
367
+ }
368
+
369
+ export function toBase32(
370
+ value: number,
371
+ alphabet?: string,
372
+ disableAlphabetValidation?: boolean,
373
+ ): string {
374
+ if (!disableAlphabetValidation) {
375
+ alphabet = base32Alphabet(alphabet);
376
+ }
377
+ if (alphabet!.length !== 32) {
378
+ throw new Error(
379
+ `Base32 alphabet must contain exactly 32 characters, got ${alphabet!.length}`,
380
+ );
381
+ }
382
+ value >>>= 0;
383
+ if (value === 0) {
384
+ return alphabet![0]!;
385
+ }
386
+ let result = "";
387
+ while (value > 0) {
388
+ result = alphabet![value & 31] + result;
389
+ value >>>= 5;
390
+ }
391
+ return result;
392
+ }
393
+
394
+ export function isDatabricksAppEnv(env?: Record<string, string | undefined>): boolean {
395
+ env ??= typeof process !== "undefined" && process.env ? process.env : undefined;
396
+ if (!env) {
397
+ return false;
398
+ }
399
+ const appName = env.DATABRICKS_APP_NAME?.trim();
400
+ const host = env.DATABRICKS_HOST?.trim();
401
+ const port = env.DATABRICKS_APP_PORT?.trim();
402
+
403
+ if (!appName || !host || !port) {
404
+ return false;
405
+ }
406
+
407
+ try {
408
+ const url = new URL(host);
409
+ if (!["http:", "https:"].includes(url.protocol)) {
410
+ return false;
411
+ }
412
+ } catch {
413
+ return false;
414
+ }
415
+
416
+ const portNumber = Number(port);
417
+ if (!Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
418
+ return false;
419
+ }
420
+
421
+ return true;
422
+ }
package/src/http.ts ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * HTTP header helpers shared across AppKit plugins: framework
3
+ * agnostic readers for HTTP headers and cookies that work uniformly
4
+ * across Express, Node `IncomingMessage`, WHATWG `Request` / `Response`
5
+ * / `Headers`, Hono, and any object that exposes a `headers` field of
6
+ * one of those shapes.
7
+ *
8
+ * Public API: {@link forEachHeaderValue}, {@link parseCookies}.
9
+ * Everything else (the header guards `isHeaders` / `isWrapped` /
10
+ * `unwrap`, the single cookie-header parser `parseCookieString`) is
11
+ * private to this module.
12
+ *
13
+ * URL parsing and path joining moved to `netUtils` (`./net.browser.ts`)
14
+ * so the URL surface lives next to the rest of the browser-safe
15
+ * networking helpers. The Databricks-aware REST helper that used to
16
+ * live here moved to `apiUtils.fetchApi` (`./api.ts`) so this module
17
+ * can stay dependency-free and browser-safe.
18
+ */
19
+
20
+ // ────────────────────────────────────────────────────────────────
21
+ // Types
22
+ // ────────────────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Anything that contains HTTP headers. Accepts:
26
+ *
27
+ * - A WHATWG `Headers` instance (fetch / undici / Hono `c.req.raw.headers`).
28
+ * - A header record (`Record<string, string | string[] | undefined>`),
29
+ * Node / Express style.
30
+ * - Any object with a `headers` field of one of the above. This covers
31
+ * Express `req`, Node `IncomingMessage`, WHATWG `Request` / `Response`,
32
+ * Hono `c.req.raw`, and similar shapes.
33
+ */
34
+ export type HeaderLike = Headers | HeaderRecord | { headers: Headers | HeaderRecord };
35
+
36
+ /**
37
+ * Single header value as exposed by Node `IncomingMessage.headers` and
38
+ * Express `req.headers` (string for most headers, array for repeated
39
+ * headers such as `Set-Cookie`).
40
+ */
41
+ type HeaderValueLike = string[] | string | undefined;
42
+
43
+ /** Header bag with case-insensitive keys (Node / Express style). */
44
+ type HeaderRecord = Record<string, HeaderValueLike>;
45
+
46
+ // ────────────────────────────────────────────────────────────────
47
+ // Header helpers
48
+ // ────────────────────────────────────────────────────────────────
49
+
50
+ /**
51
+ * Invokes `consumer` once per value for `headerName`, case-insensitive.
52
+ *
53
+ * - **Record input:** if the field is an array (e.g. repeated `Set-Cookie`),
54
+ * `consumer` runs once per array item.
55
+ * - **`Headers` input:** uses `get(name)` (which spec-joins repeats with
56
+ * `, `) except for `Set-Cookie`, which uses `getSetCookie()` so each
57
+ * cookie is delivered separately.
58
+ *
59
+ * @example
60
+ * forEachHeaderValue(req, "x-trace-id", (v) => spans.push(v)); // Express
61
+ * forEachHeaderValue(c.req.raw, "set-cookie", (v) => log(v)); // Hono
62
+ * forEachHeaderValue(headersInstance, "cookie", parse); // fetch
63
+ */
64
+ export function forEachHeaderValue(
65
+ input: HeaderLike | null | undefined,
66
+ headerName: string,
67
+ consumer: (value: string) => void,
68
+ ): void {
69
+ const headers = unwrap(input);
70
+ if (!headers) return;
71
+
72
+ const target = headerName.toLowerCase();
73
+
74
+ if (isHeaders(headers)) {
75
+ // `Headers.get` joins repeated values with `, ` per spec, which
76
+ // mangles `Set-Cookie` (cookies legitimately contain commas in
77
+ // their `expires=` attribute). `getSetCookie` is the dedicated
78
+ // splitter and is the only safe path for that header.
79
+ if (target === "set-cookie") {
80
+ for (const value of headers.getSetCookie()) consumer(value);
81
+ return;
82
+ }
83
+ const value = headers.get(headerName);
84
+ if (value !== null) consumer(value);
85
+ return;
86
+ }
87
+
88
+ for (const [key, value] of Object.entries(headers)) {
89
+ if (value == null || key.toLowerCase() !== target) continue;
90
+ if (Array.isArray(value)) {
91
+ for (const item of value) consumer(item);
92
+ } else {
93
+ consumer(value);
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Parses `Cookie` header values into a name-to-value map (URI-decoded).
100
+ *
101
+ * Accepts:
102
+ *
103
+ * - A raw `Cookie` string (`"a=1; b=2"`).
104
+ * - An array of such strings (e.g. multiple `Cookie` headers).
105
+ * - Any {@link HeaderLike}: a WHATWG `Headers` instance, a header
106
+ * record, or a request-like object with a `headers` field.
107
+ *
108
+ * First occurrence of each cookie name wins; later duplicates are ignored.
109
+ *
110
+ * @example
111
+ * parseCookies("session=abc; theme=dark");
112
+ * // { session: "abc", theme: "dark" }
113
+ *
114
+ * parseCookies(req); // Express / Node
115
+ * parseCookies(c.req.raw); // Hono
116
+ * parseCookies(request); // fetch Request
117
+ * parseCookies(request.headers); // WHATWG Headers directly
118
+ */
119
+ export function parseCookies(
120
+ input: HeaderLike | HeaderValueLike | null,
121
+ ): Record<string, string> {
122
+ if (input == null) return {};
123
+ const out: Record<string, string> = {};
124
+
125
+ if (typeof input === "string") {
126
+ parseCookieString(input, out);
127
+ return out;
128
+ }
129
+
130
+ if (Array.isArray(input)) {
131
+ for (const item of input) {
132
+ if (typeof item === "string") parseCookieString(item, out);
133
+ }
134
+ return out;
135
+ }
136
+
137
+ forEachHeaderValue(input, "cookie", (value) => {
138
+ parseCookieString(value, out);
139
+ });
140
+ return out;
141
+ }
142
+
143
+ // ────────────────────────────────────────────────────────────────
144
+ // Private helpers
145
+ // ────────────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * Type guard for WHATWG `Headers`. Duck-types on the two methods that
149
+ * matter to this module (`get` and `getSetCookie`) so polyfilled
150
+ * implementations and Hono's `HonoHeaders` are accepted without
151
+ * pulling `Headers` in as a hard dependency.
152
+ */
153
+ function isHeaders(value: unknown): value is Headers {
154
+ return (
155
+ typeof value === "object" &&
156
+ value !== null &&
157
+ typeof (value as Headers).get === "function" &&
158
+ typeof (value as Headers).getSetCookie === "function"
159
+ );
160
+ }
161
+
162
+ /**
163
+ * `HeaderRecord` is an index signature, so `"headers" in input` cannot
164
+ * discriminate it from the wrapped `{ headers }` shape at the type
165
+ * level. This guard inspects the runtime value of `headers`: only
166
+ * objects (`Headers` or a nested record) qualify as the wrapper shape,
167
+ * never stray string/array values that happen to live under a `headers`
168
+ * key on a header record.
169
+ */
170
+ function isWrapped(
171
+ input: HeaderRecord | { headers: Headers | HeaderRecord },
172
+ ): input is { headers: Headers | HeaderRecord } {
173
+ const headers = (input as { headers?: unknown }).headers;
174
+ return headers != null && typeof headers === "object" && !Array.isArray(headers);
175
+ }
176
+
177
+ /**
178
+ * Parse a single `Cookie`-style header string (`"a=1; b=2"`) into
179
+ * `out`. Names without a value are skipped; first occurrence wins so
180
+ * later duplicates are ignored. Cookie values are URI-decoded.
181
+ */
182
+ function parseCookieString(input: string, out: Record<string, string>): void {
183
+ for (const part of input.split(";")) {
184
+ const eq = part.indexOf("=");
185
+ if (eq < 0) continue;
186
+ const name = part.slice(0, eq).trim();
187
+ if (!name || name in out) continue;
188
+ const raw = part.slice(eq + 1).trim();
189
+ out[name] = decodeURIComponent(raw);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Normalize a {@link HeaderLike} input down to either a `Headers`
195
+ * instance or a header `Record`. Returns `null` for missing input so
196
+ * callers can short-circuit without a separate nullish check.
197
+ */
198
+ function unwrap(input: HeaderLike | null | undefined): Headers | HeaderRecord | null {
199
+ if (input == null) return null;
200
+ if (isHeaders(input)) return input;
201
+ if (isWrapped(input)) return input.headers;
202
+ return input;
203
+ }