tina4-nodejs 3.13.68 → 3.13.70

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.
@@ -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
  }