tina4-nodejs 3.13.68 → 3.13.69

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/CLAUDE.md CHANGED
@@ -1,10 +1,10 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.68)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.69)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
5
5
  ## What This Project Is
6
6
 
7
- Tina4 for Node.js/TypeScript v3.13.68 The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
7
+ Tina4 for Node.js/TypeScript v3.13.69 - The Intelligent Native Application 4ramework. A convention-over-configuration structural paradigm. The developer writes TypeScript; Tina4 is invisible infrastructure.
8
8
 
9
9
  The philosophy: zero ceremony, batteries included, file system as source of truth.
10
10
 
@@ -877,7 +877,7 @@ frond.unsandbox();
877
877
 
878
878
  Zero-dep HTTP client over `node:http` / `node:https`. Used by integrations, queue producers, health checks, and tests.
879
879
 
880
- **Retry/backoff (opt-in, default off):** pass `maxRetries` (default `0`) and `retryBackoff` (default `0.5`s base, exponential) in the options bag — `new Api(url, { bearerToken, maxRetries: 3, retryBackoff: 0.5 })`. Retries a transport error (`http_code` null) or a retryable status (429/500/502/503/504); 4xx is never retried (a retried non-idempotent request may be re-sent, so retries are opt-in). Node's `node:http`/`node:https` doesn't auto-follow redirects, so there's no cross-host Authorization-leak surface (the redirect auth-strip is Python-only).
880
+ **Retry/backoff (opt-in, default off):** pass `maxRetries` (default `0`) and `retryBackoff` (default `0.5`s base, exponential) in the options bag — `new Api(url, { bearerToken, maxRetries: 3, retryBackoff: 0.5 })`. Retries a transport error (`http_code` null) or a retryable status (429/500/502/503/504); 4xx is never retried (a retried non-idempotent request may be re-sent, so retries are opt-in).
881
881
 
882
882
  ```typescript
883
883
  import { Api } from "@tina4/core";
@@ -902,6 +902,34 @@ await api.sendRequest("OPTIONS", "/users");
902
902
 
903
903
  `error` is non-null on transport failure or timeout; `http_code` is `null` if the request never reached the server.
904
904
 
905
+ **Multipart upload, streaming download, transport seam, cookie jar, redirects (v3.13.69, Python master parity).** All zero-dep, all opt-in and non-breaking:
906
+
907
+ ```typescript
908
+ // Multipart upload — from disk OR in-memory bytes (no temp file needed).
909
+ // Boundary is "----Tina4Boundary" + 32 hex; part Content-Type is guessed from
910
+ // the filename (fallback application/octet-stream). A missing file / no source
911
+ // returns a clean error result ({ http_code: null, error: ... }) — never throws.
912
+ await api.upload("/avatars", { filePath: "/tmp/me.png", extraFields: { user_id: "42" } });
913
+ await api.upload("/avatars", { fileBytes: buf, filename: "me.png", fieldName: "file" });
914
+
915
+ // Streaming download — writes the body to disk in 64KB chunks (never buffered
916
+ // whole). Returns { http_code, headers, error, path } (NO body field); path is
917
+ // null and no file is written on any error.
918
+ const dl = await api.download("/report.pdf", "/tmp/report.pdf", { q: "2026" });
919
+
920
+ // Transport seam — an injectable async/sync callable
921
+ // (method, url, headers, body, timeout) => { http_code, body, headers, error }
922
+ // that REPLACES the node:http/https call. For APPLICATION-developer unit tests
923
+ // only — Tina4's own suite never injects a fake (no-mock rule).
924
+ new Api(url, { transport: async (method, u, headers, body, timeout) => ({ http_code: 200, body: {}, headers: {}, error: null }) });
925
+
926
+ // Cookie jar — opt-in, in-memory, per-client. Parses Set-Cookie (leading
927
+ // name=value, last write wins) and replays the accumulated Cookie header.
928
+ new Api(url, { cookies: true });
929
+ ```
930
+
931
+ **Redirect following (all verbs + download):** unlike bare `node:http`/`node:https`, the client now follows 3xx redirects (bounded to 10 hops). 301/302/303 on a body-bearing method become GET (body dropped, matching urllib); 307/308 preserve method + body. **Security:** the `Authorization` AND `Cookie` headers are STRIPPED when the redirect target is a different origin (scheme/host/port), so a bearer token or session cookie never leaks to a host you didn't authenticate to; same-origin redirects keep them. Full parity with Python master.
932
+
905
933
  ## Module: Queue (`packages/core/src/queue.ts`)
906
934
 
907
935
  Pluggable job queue (file/RabbitMQ/Kafka/MongoDB backends). The same fluent API works against any backend — pick via env vars.
@@ -1210,7 +1238,7 @@ When adding new features, add a corresponding `test/<feature>.test.ts` file.
1210
1238
  ## v3 Features Summary
1211
1239
 
1212
1240
  - **45 built-in features**, zero third-party dependencies
1213
- - **3,752 tests** passing across all modules
1241
+ - **5,332 tests** passing across all modules
1214
1242
  - **Race-safe `getNextId()`** with atomic sequence table (`tina4_sequences`) for SQLite/MySQL/MSSQL; PostgreSQL auto-creates sequences
1215
1243
  - **Frond template engine optimizations**: pre-compiled regexes, lazy loop context (copy-on-write), filter chain caching, path split caching, inline common filters (11-15% speedup)
1216
1244
  - **Production server auto-detect**: `npx tina4nodejs serve --production` auto-uses cluster mode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tina4-nodejs",
3
- "version": "3.13.68",
3
+ "version": "3.13.69",
4
4
  "type": "module",
5
5
  "description": "Tina4 for Node.js/TypeScript - 54 built-in features, zero dependencies",
6
6
  "keywords": [
@@ -6,10 +6,20 @@
6
6
  * const api = new Api("https://api.example.com");
7
7
  * const result = await api.get("/users");
8
8
  * const result = await api.post("/users", { name: "Alice" });
9
+ *
10
+ * Multipart upload (from disk or in-memory bytes), streaming download,
11
+ * an injectable transport seam (for USERS to unit-test their own code),
12
+ * an opt-in per-client cookie jar, and redirect following with a
13
+ * cross-origin Authorization/Cookie strip are all built on the same
14
+ * zero-dependency node:http / node:https core.
9
15
  */
10
16
  import http from "node:http";
11
17
  import https from "node:https";
12
18
  import { URL } from "node:url";
19
+ import { randomBytes } from "node:crypto";
20
+ import { promises as fsp, createWriteStream } from "node:fs";
21
+ import { basename } from "node:path";
22
+ import { pipeline } from "node:stream/promises";
13
23
 
14
24
  export interface ApiResult {
15
25
  http_code: number | null;
@@ -18,6 +28,61 @@ export interface ApiResult {
18
28
  error: string | null;
19
29
  }
20
30
 
31
+ /**
32
+ * Result of {@link Api.download}. There is no `body` field — the response
33
+ * body went to disk. `path` is the destination on success and `null` on any
34
+ * error (missing dest, HTTP error status, transport failure); the file is not
35
+ * written on error. Keeps `http_code` (snake_case) for parity with
36
+ * {@link ApiResult} and the Python/PHP/Ruby `download` return.
37
+ */
38
+ export interface DownloadResult {
39
+ http_code: number | null;
40
+ headers: Record<string, string>;
41
+ error: string | null;
42
+ path: string | null;
43
+ }
44
+
45
+ /**
46
+ * An injectable transport seam (constructor option `transport`). When supplied
47
+ * it fully REPLACES the node:http/https network call. Called as
48
+ * `(method, url, headers, body, timeout)` and must return the same result
49
+ * shape every verb returns (`{ http_code, body, headers, error }`); may be sync
50
+ * or async.
51
+ *
52
+ * NOTE: Tina4's own test suite must NEVER inject a fake/canned transport — the
53
+ * no-mock rule stands, so framework tests always exercise the real network path
54
+ * against a real local server. This seam exists purely so *application*
55
+ * developers can unit-test code that calls an `Api` instance without a live
56
+ * server.
57
+ */
58
+ export type ApiTransport = (
59
+ method: string,
60
+ url: string,
61
+ headers: Record<string, string>,
62
+ body: Buffer | null,
63
+ timeout: number,
64
+ ) => ApiResult | Promise<ApiResult>;
65
+
66
+ /**
67
+ * Options for {@link Api.upload}. Supply the file EITHER as `filePath` (a file
68
+ * on disk) OR as `fileBytes` + `filename` (an in-memory payload) — a caller
69
+ * never needs a temp file.
70
+ */
71
+ export interface UploadOptions {
72
+ /** A file on disk. `filename` defaults to its basename. */
73
+ filePath?: string;
74
+ /** The form field the file is sent under (default `"file"`). */
75
+ fieldName?: string;
76
+ /** Additional text parts of the multipart body. */
77
+ extraFields?: Record<string, string>;
78
+ /** Extra per-call headers merged onto the request. */
79
+ headers?: Record<string, string>;
80
+ /** An in-memory payload (Buffer or string). Requires `filename` for a name. */
81
+ fileBytes?: Buffer | string;
82
+ /** Filename used in the Content-Disposition part header. */
83
+ filename?: string;
84
+ }
85
+
21
86
  /**
22
87
  * HTTP statuses that warrant an automatic retry when `maxRetries` > 0:
23
88
  * rate-limit (429) plus the transient server-side 5xx family. 4xx client
@@ -25,6 +90,60 @@ export interface ApiResult {
25
90
  */
26
91
  const RETRY_STATUSES: ReadonlySet<number> = new Set([429, 500, 502, 503, 504]);
27
92
 
93
+ /**
94
+ * Streaming download writes this many bytes per buffer so a multi-megabyte body
95
+ * never lands in memory in one piece (matches Python's 64 KB chunked read).
96
+ */
97
+ const DOWNLOAD_CHUNK_SIZE = 64 * 1024;
98
+
99
+ /** Bounded redirect hop count — mirrors urllib's HTTPRedirectHandler default. */
100
+ const MAX_REDIRECTS = 10;
101
+
102
+ /**
103
+ * Headers dropped when a redirect crosses to a different origin — a bearer
104
+ * token or a session cookie must never be handed to a host you didn't
105
+ * authenticate to.
106
+ */
107
+ const STRIP_ON_CROSS_ORIGIN: ReadonlySet<string> = new Set(["authorization", "cookie"]);
108
+
109
+ /**
110
+ * Minimal extension → MIME map for guessing a multipart part's Content-Type.
111
+ * Kept in-repo so the client stays zero-dependency (Node has no stdlib
112
+ * mimetypes). Values match Python's `mimetypes.guess_type` for these common
113
+ * extensions, so the multipart wire body is byte-identical across frameworks.
114
+ */
115
+ const MIME_BY_EXT: Record<string, string> = {
116
+ txt: "text/plain",
117
+ html: "text/html",
118
+ htm: "text/html",
119
+ css: "text/css",
120
+ csv: "text/csv",
121
+ js: "text/javascript",
122
+ mjs: "text/javascript",
123
+ json: "application/json",
124
+ xml: "application/xml",
125
+ pdf: "application/pdf",
126
+ zip: "application/zip",
127
+ gz: "application/gzip",
128
+ tar: "application/x-tar",
129
+ png: "image/png",
130
+ jpg: "image/jpeg",
131
+ jpeg: "image/jpeg",
132
+ gif: "image/gif",
133
+ svg: "image/svg+xml",
134
+ webp: "image/webp",
135
+ ico: "image/vnd.microsoft.icon",
136
+ bmp: "image/bmp",
137
+ mp4: "video/mp4",
138
+ webm: "video/webm",
139
+ mp3: "audio/mpeg",
140
+ wav: "audio/x-wav",
141
+ ogg: "audio/ogg",
142
+ woff: "font/woff",
143
+ woff2: "font/woff2",
144
+ ttf: "font/ttf",
145
+ };
146
+
28
147
  /**
29
148
  * Constructor options for {@link Api}. Used as the second argument to
30
149
  * `new Api(url, { ... })` — cross-framework parity with Python
@@ -50,8 +169,112 @@ export interface ApiOptions {
50
169
  maxRetries?: number;
51
170
  /** Base backoff in seconds, doubling each attempt (default 0.5). */
52
171
  retryBackoff?: number;
172
+ /**
173
+ * Injectable transport seam (default undefined = the real network path).
174
+ * When supplied it REPLACES the node:http/https call. See {@link ApiTransport}.
175
+ * Tina4's own suite never injects it (no-mock rule) — it exists so
176
+ * application developers can unit-test their own code.
177
+ */
178
+ transport?: ApiTransport;
179
+ /**
180
+ * Opt-in per-client, in-memory cookie jar (default false = off, zero
181
+ * behaviour change). When true, `Set-Cookie` response headers are parsed and
182
+ * the accumulated `Cookie` header is sent on subsequent requests. Not
183
+ * persisted; scoped to this instance.
184
+ */
185
+ cookies?: boolean;
186
+ }
187
+
188
+ /** True when two URLs share scheme + host + (effective) port. */
189
+ function sameOrigin(urlA: string, urlB: string): boolean {
190
+ try {
191
+ const a = new URL(urlA);
192
+ const b = new URL(urlB);
193
+ const defaultPort: Record<string, string> = { "http:": "80", "https:": "443" };
194
+ const portA = a.port || defaultPort[a.protocol] || "";
195
+ const portB = b.port || defaultPort[b.protocol] || "";
196
+ return a.protocol === b.protocol && a.hostname === b.hostname && portA === portB;
197
+ } catch {
198
+ return false;
199
+ }
200
+ }
201
+
202
+ /** Delete a header by name, case-insensitively (headers may use any casing). */
203
+ function deleteHeaderCaseInsensitive(headers: Record<string, string>, name: string): void {
204
+ const lower = name.toLowerCase();
205
+ for (const key of Object.keys(headers)) {
206
+ if (key.toLowerCase() === lower) {
207
+ delete headers[key];
208
+ }
209
+ }
210
+ }
211
+
212
+ /** Flatten node's IncomingHttpHeaders into a plain string map (arrays joined). */
213
+ function flattenHeaders(headers: http.IncomingHttpHeaders): Record<string, string> {
214
+ const out: Record<string, string> = {};
215
+ for (const [key, value] of Object.entries(headers)) {
216
+ if (value !== undefined) {
217
+ out[key] = Array.isArray(value) ? value.join(", ") : value;
218
+ }
219
+ }
220
+ return out;
53
221
  }
54
222
 
223
+ /** Guess a multipart part's Content-Type from the filename extension. */
224
+ function guessContentType(filename: string): string {
225
+ const dot = filename.lastIndexOf(".");
226
+ if (dot >= 0) {
227
+ const ext = filename.slice(dot + 1).toLowerCase();
228
+ const found = MIME_BY_EXT[ext];
229
+ if (found) {
230
+ return found;
231
+ }
232
+ }
233
+ return "application/octet-stream";
234
+ }
235
+
236
+ /**
237
+ * Assemble a `multipart/form-data` body as a Buffer.
238
+ *
239
+ * Text fields come first, then the file part, then the closing delimiter —
240
+ * matching the canonical Python/Ruby `build_multipart_body` layout so every
241
+ * framework produces a byte-identical body: `--<boundary>\r\n` delimited parts,
242
+ * `\r\n` line breaks, closing `--<boundary>--\r\n`.
243
+ */
244
+ function buildMultipartBody(
245
+ boundary: string,
246
+ fieldName: string,
247
+ filename: string,
248
+ fileContent: Buffer,
249
+ contentType: string,
250
+ extraFields?: Record<string, string>,
251
+ ): Buffer {
252
+ const crlf = "\r\n";
253
+ const delimiter = `--${boundary}`;
254
+ const parts: Buffer[] = [];
255
+ if (extraFields) {
256
+ for (const [key, value] of Object.entries(extraFields)) {
257
+ parts.push(Buffer.from(delimiter + crlf, "utf-8"));
258
+ parts.push(Buffer.from(`Content-Disposition: form-data; name="${key}"` + crlf + crlf, "utf-8"));
259
+ parts.push(Buffer.from(String(value) + crlf, "utf-8"));
260
+ }
261
+ }
262
+ parts.push(Buffer.from(delimiter + crlf, "utf-8"));
263
+ parts.push(
264
+ Buffer.from(`Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"` + crlf, "utf-8"),
265
+ );
266
+ parts.push(Buffer.from(`Content-Type: ${contentType}` + crlf + crlf, "utf-8"));
267
+ parts.push(fileContent);
268
+ parts.push(Buffer.from(crlf, "utf-8"));
269
+ parts.push(Buffer.from(delimiter + "--" + crlf, "utf-8"));
270
+ return Buffer.concat(parts);
271
+ }
272
+
273
+ /** Outcome of a single network exchange (after any redirects are followed). */
274
+ type NetworkResult =
275
+ | { kind: "response"; res: http.IncomingMessage }
276
+ | { kind: "error"; error: string };
277
+
55
278
  export class Api {
56
279
  private baseUrl: string;
57
280
  private headers: Record<string, string>;
@@ -60,6 +283,9 @@ export class Api {
60
283
  private ignoreSsl: boolean;
61
284
  private maxRetries: number;
62
285
  private retryBackoff: number;
286
+ private transportFn?: ApiTransport;
287
+ private cookiesEnabled: boolean;
288
+ private cookies: Record<string, string>;
63
289
 
64
290
  /**
65
291
  * Construct an Api client.
@@ -87,6 +313,10 @@ export class Api {
87
313
  * opt-in for that reason.
88
314
  *
89
315
  * new Api("https://api.example.com", { maxRetries: 3, retryBackoff: 0.5 });
316
+ *
317
+ * `transport` (default undefined = the real network path) is an injectable
318
+ * seam so USERS can unit-test their own code; `cookies` (default false)
319
+ * turns on a per-client, in-memory cookie jar.
90
320
  */
91
321
  constructor(
92
322
  baseUrl: string = "",
@@ -98,6 +328,10 @@ export class Api {
98
328
  // Retry defaults: off (0) so existing callers are unaffected.
99
329
  this.maxRetries = 0;
100
330
  this.retryBackoff = 0.5;
331
+ // Transport seam + cookie jar default to inert (zero behaviour change).
332
+ this.transportFn = undefined;
333
+ this.cookiesEnabled = false;
334
+ this.cookies = {};
101
335
 
102
336
  // Options-bag form — second arg is an object literal
103
337
  if (typeof authHeaderOrOptions === "object" && authHeaderOrOptions !== null) {
@@ -107,6 +341,8 @@ export class Api {
107
341
  this.ignoreSsl = (opts.ignoreSsl ?? false) || (opts.verifySsl === false);
108
342
  this.maxRetries = Math.max(0, opts.maxRetries ?? 0);
109
343
  this.retryBackoff = opts.retryBackoff ?? 0.5;
344
+ this.transportFn = opts.transport;
345
+ this.cookiesEnabled = opts.cookies ?? false;
110
346
 
111
347
  // Bearer wins over basic-auth when both are passed
112
348
  if (opts.bearerToken != null) {
@@ -209,6 +445,165 @@ export class Api {
209
445
  return this.execute(method.toUpperCase(), url, body, contentType);
210
446
  }
211
447
 
448
+ /**
449
+ * POST a `multipart/form-data` body — a file plus optional text fields.
450
+ *
451
+ * Two ways to supply the file, so a caller never needs a temp file:
452
+ *
453
+ * - `filePath` — a file on disk. `filename` defaults to its basename.
454
+ * - `fileBytes` + `filename` — an in-memory payload (Buffer or string).
455
+ *
456
+ * `fieldName` (default `"file"`) is the form field the file is sent under.
457
+ * `extraFields` become additional text parts. `headers` are extra per-call
458
+ * headers merged onto the request. The part's Content-Type is guessed from
459
+ * the filename (falling back to `application/octet-stream`).
460
+ *
461
+ * Returns the standard {@link ApiResult}. A missing file or no source given
462
+ * returns a clean error result (`http_code` null, `error` set) — it does NOT
463
+ * throw. Retry/backoff (if configured) applies, exactly like the verbs.
464
+ *
465
+ * await api.upload("/avatars", { filePath: "/tmp/me.png" });
466
+ * await api.upload("/avatars", { fileBytes: raw, filename: "me.png",
467
+ * extraFields: { user_id: "42" } });
468
+ */
469
+ async upload(path: string, opts: UploadOptions = {}): Promise<ApiResult> {
470
+ const { filePath, fieldName = "file", extraFields, headers, fileBytes, filename } = opts;
471
+
472
+ let content: Buffer;
473
+ let uploadName: string;
474
+
475
+ if (fileBytes !== undefined && fileBytes !== null) {
476
+ content = Buffer.isBuffer(fileBytes) ? fileBytes : Buffer.from(String(fileBytes), "utf-8");
477
+ uploadName = filename || "upload.bin";
478
+ } else if (filePath) {
479
+ let isFile = false;
480
+ try {
481
+ isFile = (await fsp.stat(filePath)).isFile();
482
+ } catch {
483
+ isFile = false;
484
+ }
485
+ if (!isFile) {
486
+ return { http_code: null, body: null, headers: {}, error: `file not found: ${filePath}` };
487
+ }
488
+ try {
489
+ content = await fsp.readFile(filePath);
490
+ } catch (err) {
491
+ return {
492
+ http_code: null,
493
+ body: null,
494
+ headers: {},
495
+ error: err instanceof Error ? err.message : String(err),
496
+ };
497
+ }
498
+ uploadName = filename || basename(filePath);
499
+ } else {
500
+ return { http_code: null, body: null, headers: {}, error: "upload requires filePath or fileBytes" };
501
+ }
502
+
503
+ const partContentType = guessContentType(uploadName);
504
+ const boundary = "----Tina4Boundary" + randomBytes(16).toString("hex");
505
+ const bodyBuffer = buildMultipartBody(boundary, fieldName, uploadName, content, partContentType, extraFields);
506
+ const contentType = `multipart/form-data; boundary=${boundary}`;
507
+ return this.execute("POST", this.buildUrl(path), bodyBuffer, contentType, headers);
508
+ }
509
+
510
+ /**
511
+ * Stream a GET response body to `destPath` in chunks.
512
+ *
513
+ * The body is written to disk `DOWNLOAD_CHUNK_SIZE` bytes at a time instead
514
+ * of being buffered whole in memory — safe for large payloads. Redirect
515
+ * following, the cross-origin auth strip, the cookie jar, and the SSL flag
516
+ * all apply, exactly like the other verbs.
517
+ *
518
+ * Returns {@link DownloadResult} — there is no `body` field (it went to
519
+ * disk). `path` is `destPath` on success and `null` on any error (missing
520
+ * dest, HTTP error status, or a transport failure); the destination file is
521
+ * not written on error.
522
+ */
523
+ async download(path: string, destPath: string, params?: Record<string, string>): Promise<DownloadResult> {
524
+ if (!destPath) {
525
+ return { http_code: null, headers: {}, error: "download requires destPath", path: null };
526
+ }
527
+
528
+ let url = this.buildUrl(path);
529
+ if (params && Object.keys(params).length > 0) {
530
+ const qs = new URLSearchParams(params).toString();
531
+ url += (url.includes("?") ? "&" : "?") + qs;
532
+ }
533
+
534
+ const { headers, data } = this.buildRequest("GET", "application/json", undefined);
535
+
536
+ // An injected transport can't stream (it returns a buffered result), so
537
+ // write its body out; only the real network path streams chunk-by-chunk.
538
+ if (this.transportFn) {
539
+ const result = await this.callTransport("GET", url, headers, data);
540
+ const code = result.http_code;
541
+ if (result.error === null && code !== null && code >= 200 && code < 300) {
542
+ const raw = result.body;
543
+ let buffer: Buffer;
544
+ if (Buffer.isBuffer(raw)) {
545
+ buffer = raw;
546
+ } else if (typeof raw === "string") {
547
+ buffer = Buffer.from(raw, "utf-8");
548
+ } else {
549
+ buffer = Buffer.from(JSON.stringify(raw ?? null), "utf-8");
550
+ }
551
+ try {
552
+ await fsp.writeFile(destPath, buffer);
553
+ } catch (err) {
554
+ return {
555
+ http_code: code,
556
+ headers: result.headers,
557
+ error: err instanceof Error ? err.message : String(err),
558
+ path: null,
559
+ };
560
+ }
561
+ return { http_code: code, headers: result.headers, error: null, path: destPath };
562
+ }
563
+ return {
564
+ http_code: code,
565
+ headers: result.headers,
566
+ error: result.error ?? `download failed (HTTP ${code})`,
567
+ path: null,
568
+ };
569
+ }
570
+
571
+ const net = await this.performRequest("GET", url, headers, data, MAX_REDIRECTS);
572
+ if (net.kind === "error") {
573
+ return { http_code: null, headers: {}, error: net.error, path: null };
574
+ }
575
+
576
+ const res = net.res;
577
+ const code = res.statusCode ?? null;
578
+ const respHeaders = flattenHeaders(res.headers);
579
+
580
+ // >= 400 mirrors Python's urllib raising HTTPError — no file written.
581
+ if (code === null || code >= 400) {
582
+ res.resume(); // drain so the socket is freed
583
+ return {
584
+ http_code: code,
585
+ headers: respHeaders,
586
+ error: `download failed (HTTP ${code})`,
587
+ path: null,
588
+ };
589
+ }
590
+
591
+ this.storeCookies(res.headers["set-cookie"]);
592
+
593
+ const fileStream = createWriteStream(destPath, { highWaterMark: DOWNLOAD_CHUNK_SIZE });
594
+ try {
595
+ await pipeline(res, fileStream);
596
+ } catch (err) {
597
+ return {
598
+ http_code: code,
599
+ headers: respHeaders,
600
+ error: err instanceof Error ? err.message : String(err),
601
+ path: null,
602
+ };
603
+ }
604
+ return { http_code: code, headers: respHeaders, error: null, path: destPath };
605
+ }
606
+
212
607
  // ── Internal helpers ──────────────────────────────────────────────
213
608
 
214
609
  private buildUrl(path: string): string {
@@ -221,6 +616,59 @@ export class Api {
221
616
  return `${this.baseUrl}/${path.replace(/^\/+/, "")}`;
222
617
  }
223
618
 
619
+ /**
620
+ * Build the request headers (auth + cookie jar + extras) and serialize the
621
+ * body to a Buffer. Shared by every verb, upload, and download so the wire
622
+ * shape is identical and the transport seam sees exactly what the network
623
+ * path would.
624
+ */
625
+ private buildRequest(
626
+ method: string,
627
+ contentType: string,
628
+ body: unknown,
629
+ extraHeaders?: Record<string, string>,
630
+ ): { headers: Record<string, string>; data: Buffer | undefined } {
631
+ const headers: Record<string, string> = { ...this.headers };
632
+ if (this.authHeader) {
633
+ headers["Authorization"] = this.authHeader;
634
+ }
635
+
636
+ // Cookie jar: attach the accumulated Cookie header when enabled.
637
+ if (this.cookiesEnabled) {
638
+ const cookieHeader = this.cookieHeader();
639
+ if (cookieHeader) {
640
+ headers["Cookie"] = cookieHeader;
641
+ }
642
+ }
643
+
644
+ let data: Buffer | undefined;
645
+ if (body !== undefined && body !== null) {
646
+ if (contentType === "application/json" && typeof body === "object" && !Buffer.isBuffer(body)) {
647
+ data = Buffer.from(JSON.stringify(body), "utf-8");
648
+ headers["Content-Type"] = "application/json";
649
+ } else if (typeof body === "string") {
650
+ data = Buffer.from(body, "utf-8");
651
+ headers["Content-Type"] = contentType;
652
+ } else if (Buffer.isBuffer(body)) {
653
+ data = body;
654
+ headers["Content-Type"] = contentType;
655
+ } else {
656
+ // Fallback: stringify anything else as JSON
657
+ data = Buffer.from(JSON.stringify(body), "utf-8");
658
+ headers["Content-Type"] = "application/json";
659
+ }
660
+ if (data) {
661
+ headers["Content-Length"] = String(data.length);
662
+ }
663
+ }
664
+
665
+ if (extraHeaders) {
666
+ Object.assign(headers, extraHeaders);
667
+ }
668
+
669
+ return { headers, data };
670
+ }
671
+
224
672
  /**
225
673
  * Execute the request with opt-in retry/backoff.
226
674
  *
@@ -235,11 +683,12 @@ export class Api {
235
683
  url: string,
236
684
  body?: unknown,
237
685
  contentType: string = "application/json",
686
+ extraHeaders?: Record<string, string>,
238
687
  ): Promise<ApiResult> {
239
688
  const attempts = this.maxRetries + 1;
240
689
  let result: ApiResult = { http_code: null, body: null, headers: {}, error: null };
241
690
  for (let attempt = 0; attempt < attempts; attempt++) {
242
- result = await this.attempt(method, url, body, contentType);
691
+ result = await this.attempt(method, url, body, contentType, extraHeaders);
243
692
  const code = result.http_code;
244
693
  const retryable = code === null || RETRY_STATUSES.has(code);
245
694
  if (!retryable || attempt === attempts - 1) {
@@ -252,122 +701,237 @@ export class Api {
252
701
  }
253
702
 
254
703
  /** A single HTTP attempt — returns the standardized result. */
255
- private attempt(
704
+ private async attempt(
256
705
  method: string,
257
706
  url: string,
258
707
  body?: unknown,
259
708
  contentType: string = "application/json",
709
+ extraHeaders?: Record<string, string>,
260
710
  ): Promise<ApiResult> {
261
- return new Promise<ApiResult>((resolve) => {
711
+ const { headers, data } = this.buildRequest(method, contentType, body, extraHeaders);
712
+
713
+ // A user-injected transport fully replaces the network call.
714
+ if (this.transportFn) {
715
+ return this.callTransport(method, url, headers, data);
716
+ }
717
+
718
+ const net = await this.performRequest(method, url, headers, data, MAX_REDIRECTS);
719
+ if (net.kind === "error") {
720
+ return { http_code: null, body: null, headers: {}, error: net.error };
721
+ }
722
+ return this.readResponse(net.res);
723
+ }
724
+
725
+ /**
726
+ * Invoke a user-injected transport and normalize its result. The transport
727
+ * is called with `(method, url, headers, body, timeout)` and its returned
728
+ * `Set-Cookie` headers (if any) feed the cookie jar.
729
+ */
730
+ private async callTransport(
731
+ method: string,
732
+ url: string,
733
+ headers: Record<string, string>,
734
+ data: Buffer | undefined,
735
+ ): Promise<ApiResult> {
736
+ let normalized: ApiResult;
737
+ try {
738
+ const result = await this.transportFn!(method, url, headers, data ?? null, this.timeout);
739
+ normalized = {
740
+ http_code: result?.http_code ?? null,
741
+ body: result?.body ?? null,
742
+ headers: result?.headers ?? {},
743
+ error: result?.error ?? null,
744
+ };
745
+ } catch (err) {
746
+ return { http_code: null, body: null, headers: {}, error: err instanceof Error ? err.message : String(err) };
747
+ }
748
+ this.storeCookiesFromRecord(normalized.headers);
749
+ return normalized;
750
+ }
751
+
752
+ /**
753
+ * Perform the network request, following up to `redirectsLeft` redirects.
754
+ *
755
+ * node:http/https `request` does NOT auto-follow redirects. On a 3xx with a
756
+ * Location, this drains the intermediate response and re-issues to the new
757
+ * URL: 301/302/303 on a non-GET/HEAD become GET (body dropped, urllib
758
+ * behaviour); 307/308 preserve method + body. When the redirect target is a
759
+ * DIFFERENT origin, the Authorization and Cookie headers are stripped so a
760
+ * bearer token / session cookie never leaks to a host you didn't
761
+ * authenticate to.
762
+ */
763
+ private performRequest(
764
+ method: string,
765
+ url: string,
766
+ headers: Record<string, string>,
767
+ data: Buffer | undefined,
768
+ redirectsLeft: number,
769
+ ): Promise<NetworkResult> {
770
+ return new Promise<NetworkResult>((resolve) => {
771
+ let parsed: URL;
262
772
  try {
263
- const parsed = new URL(url);
264
- const isHttps = parsed.protocol === "https:";
265
- const transport = isHttps ? https : http;
266
-
267
- // Build headers
268
- const reqHeaders: Record<string, string> = { ...this.headers };
269
- if (this.authHeader) {
270
- reqHeaders["Authorization"] = this.authHeader;
271
- }
773
+ parsed = new URL(url);
774
+ } catch (err) {
775
+ resolve({ kind: "error", error: err instanceof Error ? err.message : String(err) });
776
+ return;
777
+ }
778
+
779
+ const isHttps = parsed.protocol === "https:";
780
+ const protocolModule = isHttps ? https : http;
781
+
782
+ const options: http.RequestOptions = {
783
+ hostname: parsed.hostname,
784
+ port: parsed.port || (isHttps ? 443 : 80),
785
+ path: parsed.pathname + parsed.search,
786
+ method,
787
+ headers,
788
+ timeout: this.timeout * 1000,
789
+ };
790
+
791
+ if (isHttps && this.ignoreSsl) {
792
+ (options as https.RequestOptions).rejectUnauthorized = false;
793
+ }
794
+
795
+ const req = protocolModule.request(options, (res) => {
796
+ const status = res.statusCode ?? 0;
797
+ const location = res.headers.location;
272
798
 
273
- // Serialize body
274
- let data: Buffer | undefined;
275
- if (body !== undefined && body !== null) {
276
- if (contentType === "application/json" && typeof body === "object") {
277
- data = Buffer.from(JSON.stringify(body), "utf-8");
278
- reqHeaders["Content-Type"] = "application/json";
279
- } else if (typeof body === "string") {
280
- data = Buffer.from(body, "utf-8");
281
- reqHeaders["Content-Type"] = contentType;
282
- } else if (Buffer.isBuffer(body)) {
283
- data = body;
284
- reqHeaders["Content-Type"] = contentType;
285
- } else {
286
- // Fallback: stringify anything else as JSON
287
- data = Buffer.from(JSON.stringify(body), "utf-8");
288
- reqHeaders["Content-Type"] = "application/json";
799
+ if (status >= 300 && status < 400 && location && redirectsLeft > 0) {
800
+ res.resume(); // drain the redirect body, free the socket
801
+
802
+ let nextUrl: string;
803
+ try {
804
+ nextUrl = new URL(location, url).toString();
805
+ } catch {
806
+ resolve({ kind: "response", res });
807
+ return;
289
808
  }
290
- if (data) {
291
- reqHeaders["Content-Length"] = String(data.length);
809
+
810
+ const crossOrigin = !sameOrigin(url, nextUrl);
811
+ let nextMethod = method;
812
+ let nextData = data;
813
+ const nextHeaders: Record<string, string> = { ...headers };
814
+
815
+ // 301/302/303 on a body-bearing method → GET, drop the body
816
+ // (matches urllib's HTTPRedirectHandler); 307/308 preserve.
817
+ if (
818
+ (status === 301 || status === 302 || status === 303) &&
819
+ method !== "GET" &&
820
+ method !== "HEAD"
821
+ ) {
822
+ nextMethod = "GET";
823
+ nextData = undefined;
824
+ deleteHeaderCaseInsensitive(nextHeaders, "content-type");
825
+ deleteHeaderCaseInsensitive(nextHeaders, "content-length");
292
826
  }
293
- }
294
827
 
295
- const options: http.RequestOptions = {
296
- hostname: parsed.hostname,
297
- port: parsed.port || (isHttps ? 443 : 80),
298
- path: parsed.pathname + parsed.search,
299
- method,
300
- headers: reqHeaders,
301
- timeout: this.timeout * 1000,
302
- };
828
+ if (crossOrigin) {
829
+ for (const name of STRIP_ON_CROSS_ORIGIN) {
830
+ deleteHeaderCaseInsensitive(nextHeaders, name);
831
+ }
832
+ }
303
833
 
304
- if (isHttps && this.ignoreSsl) {
305
- (options as https.RequestOptions).rejectUnauthorized = false;
834
+ this.performRequest(nextMethod, nextUrl, nextHeaders, nextData, redirectsLeft - 1).then(resolve);
835
+ return;
306
836
  }
307
837
 
308
- const req = transport.request(options, (res) => {
309
- const chunks: Buffer[] = [];
838
+ resolve({ kind: "response", res });
839
+ });
310
840
 
311
- res.on("data", (chunk: Buffer) => {
312
- chunks.push(chunk);
313
- });
841
+ req.on("timeout", () => {
842
+ req.destroy();
843
+ resolve({ kind: "error", error: `Request timed out after ${this.timeout}s` });
844
+ });
314
845
 
315
- res.on("end", () => {
316
- const raw = Buffer.concat(chunks).toString("utf-8");
317
- const respHeaders: Record<string, string> = {};
318
- for (const [key, val] of Object.entries(res.headers)) {
319
- if (val !== undefined) {
320
- respHeaders[key] = Array.isArray(val) ? val.join(", ") : val;
321
- }
322
- }
846
+ req.on("error", (err) => {
847
+ resolve({ kind: "error", error: err.message });
848
+ });
323
849
 
324
- let parsed: unknown;
325
- try {
326
- parsed = JSON.parse(raw);
327
- } catch {
328
- parsed = raw;
329
- }
850
+ if (data) {
851
+ req.write(data);
852
+ }
853
+ req.end();
854
+ });
855
+ }
330
856
 
331
- resolve({
332
- http_code: res.statusCode ?? null,
333
- body: parsed,
334
- headers: respHeaders,
335
- error: null,
336
- });
337
- });
338
- });
857
+ /** Buffer a response body, parse JSON if possible, and store cookies. */
858
+ private readResponse(res: http.IncomingMessage): Promise<ApiResult> {
859
+ return new Promise<ApiResult>((resolve) => {
860
+ const chunks: Buffer[] = [];
861
+
862
+ res.on("data", (chunk: Buffer) => {
863
+ chunks.push(chunk);
864
+ });
865
+
866
+ res.on("end", () => {
867
+ const raw = Buffer.concat(chunks).toString("utf-8");
868
+ const respHeaders = flattenHeaders(res.headers);
869
+ this.storeCookies(res.headers["set-cookie"]);
870
+
871
+ let parsed: unknown;
872
+ try {
873
+ parsed = JSON.parse(raw);
874
+ } catch {
875
+ parsed = raw;
876
+ }
339
877
 
340
- req.on("timeout", () => {
341
- req.destroy();
342
- resolve({
343
- http_code: null,
344
- body: null,
345
- headers: {},
346
- error: `Request timed out after ${this.timeout}s`,
347
- });
878
+ resolve({
879
+ http_code: res.statusCode ?? null,
880
+ body: parsed,
881
+ headers: respHeaders,
882
+ error: null,
348
883
  });
884
+ });
349
885
 
350
- req.on("error", (err) => {
351
- resolve({
352
- http_code: null,
353
- body: null,
354
- headers: {},
355
- error: err.message,
356
- });
357
- });
886
+ res.on("error", (err) => {
887
+ resolve({ http_code: null, body: null, headers: {}, error: err.message });
888
+ });
889
+ });
890
+ }
358
891
 
359
- if (data) {
360
- req.write(data);
892
+ // ── cookie jar (opt-in, in-memory, per-client) ─────────────────────────
893
+
894
+ /** The accumulated `Cookie` request header, or null when the jar is empty. */
895
+ private cookieHeader(): string | null {
896
+ const names = Object.keys(this.cookies);
897
+ if (names.length === 0) {
898
+ return null;
899
+ }
900
+ return names.map((name) => `${name}=${this.cookies[name]}`).join("; ");
901
+ }
902
+
903
+ /**
904
+ * Parse `Set-Cookie` response headers into the jar (when enabled). Only the
905
+ * leading `name=value` pair of each is kept (Path/HttpOnly/Expires ignored);
906
+ * a later value for the same name overwrites an earlier one.
907
+ */
908
+ private storeCookies(setCookie: string | string[] | undefined): void {
909
+ if (!this.cookiesEnabled || !setCookie) {
910
+ return;
911
+ }
912
+ const values = Array.isArray(setCookie) ? setCookie : [setCookie];
913
+ for (const raw of values) {
914
+ const firstPair = raw.split(";", 1)[0].trim();
915
+ const eq = firstPair.indexOf("=");
916
+ if (eq > 0) {
917
+ const name = firstPair.slice(0, eq).trim();
918
+ const value = firstPair.slice(eq + 1).trim();
919
+ if (name) {
920
+ this.cookies[name] = value;
361
921
  }
362
- req.end();
363
- } catch (err) {
364
- resolve({
365
- http_code: null,
366
- body: null,
367
- headers: {},
368
- error: err instanceof Error ? err.message : String(err),
369
- });
370
922
  }
371
- });
923
+ }
924
+ }
925
+
926
+ /** Store cookies from a plain header record (the transport seam path). */
927
+ private storeCookiesFromRecord(headers: Record<string, string>): void {
928
+ if (!this.cookiesEnabled) {
929
+ return;
930
+ }
931
+ for (const [key, value] of Object.entries(headers)) {
932
+ if (key.toLowerCase() === "set-cookie" && value) {
933
+ this.storeCookies(value);
934
+ }
935
+ }
372
936
  }
373
937
  }
@@ -74,7 +74,7 @@ export type { ServiceOptions, ServiceContext, ServiceHandler, ServiceInfo } from
74
74
  export { responseCache, clearCache, cacheStats, cacheGet, cacheSet, cacheDelete, cacheClear, cacheBackendStats, createBackend, _resetBackend } from "./cache.js";
75
75
  export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
76
76
  export { Api } from "./api.js";
77
- export type { ApiResult } from "./api.js";
77
+ export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions } from "./api.js";
78
78
  export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
79
79
  export type { SearchHit } from "./context/index.js";
80
80
  export { Events } from "./events.js";