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 +32 -4
- package/package.json +1 -1
- package/packages/core/src/api.ts +659 -95
- package/packages/core/src/index.ts +1 -1
package/CLAUDE.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
# CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.
|
|
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.
|
|
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).
|
|
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
|
-
- **
|
|
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
package/packages/core/src/api.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
305
|
-
|
|
834
|
+
this.performRequest(nextMethod, nextUrl, nextHeaders, nextData, redirectsLeft - 1).then(resolve);
|
|
835
|
+
return;
|
|
306
836
|
}
|
|
307
837
|
|
|
308
|
-
|
|
309
|
-
|
|
838
|
+
resolve({ kind: "response", res });
|
|
839
|
+
});
|
|
310
840
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
841
|
+
req.on("timeout", () => {
|
|
842
|
+
req.destroy();
|
|
843
|
+
resolve({ kind: "error", error: `Request timed out after ${this.timeout}s` });
|
|
844
|
+
});
|
|
314
845
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
850
|
+
if (data) {
|
|
851
|
+
req.write(data);
|
|
852
|
+
}
|
|
853
|
+
req.end();
|
|
854
|
+
});
|
|
855
|
+
}
|
|
330
856
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
360
|
-
|
|
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";
|