@fastls/fastls 0.10.2 → 0.12.0
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/README.md +23 -2
- package/dist/errors.d.ts +1 -0
- package/dist/errors.js +4 -1
- package/dist/fetch.d.ts +7 -0
- package/dist/fetch.js +10 -2
- package/dist/index.d.ts +5 -0
- package/dist/index.js +8 -1
- package/dist/index.mjs +1 -1
- package/dist/native.d.ts +2 -0
- package/dist/native.js +22 -3
- package/dist/profile-store.d.ts +35 -0
- package/dist/profile-store.js +203 -0
- package/dist/profiles.d.ts +14 -0
- package/dist/profiles.js +16 -0
- package/dist/retry.d.ts +28 -0
- package/dist/retry.js +93 -0
- package/dist/session.d.ts +12 -0
- package/dist/session.js +110 -6
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
# Fastls Node.js SDK
|
|
2
2
|
|
|
3
|
-
Node.
|
|
3
|
+
Node 0.11.0 起支持 Session `addressPolicy`、`profileSpec: new ProfileSpec(...)` 和
|
|
4
|
+
`httpVersion: "h3"`;Session/fetch 可选择完整 Spec 或显式协议。
|
|
5
|
+
`addressPolicy` 只能创建可信 owner,不能由 `session.fetch()` 或请求替换。
|
|
6
|
+
较旧原生库缺能力或符号时会明确报错,见[能力契约](../CAPABILITIES.md)。
|
|
7
|
+
|
|
8
|
+
Node 0.12.0 新增默认关闭的 RetryPolicy、Session/fetch 的 bodyFactory 和
|
|
9
|
+
ProfileBundle/ProfileStore 离线配置工具。重试与跳转共用总超时,不重放一次性正文,
|
|
10
|
+
不重试取消、策略/证书拒绝或响应正文读取错误。见[中文指南](https://github.com/FastTLS/fastls-ffi/blob/main/RELIABILITY.zh-CN.md)
|
|
11
|
+
及 [English contract](https://github.com/FastTLS/fastls-ffi/blob/main/RELIABILITY.md)。
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Session, ProfileSpec } from '@fastls/fastls';
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
const profileSpec = new ProfileSpec(JSON.parse(readFileSync('browser.json', 'utf8')));
|
|
17
|
+
const session = new Session({ profileSpec });
|
|
18
|
+
try { await session.get('https://example.com'); } finally { await session.closeAsync(); }
|
|
19
|
+
// 强制 H3 直连,不支持时不会回退到 TCP;目标须支持 H3。
|
|
20
|
+
const h3 = new Session({ httpVersion: 'h3' });
|
|
21
|
+
try { await h3.get('https://example.com'); } finally { await h3.closeAsync(); }
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明、ESM 和 CommonJS 入口。本文对应 **0.12.0**。
|
|
4
25
|
|
|
5
26
|
支持 HTTP/1.1、HTTP/2、流式上传下载、双工、SSE、WebSocket、会话 Cookie 和 HTTP/HTTPS/SOCKS 代理。网络请求由 Go 执行,通过 Koffi 在当前 Node 进程中调用;使用者无需 Python 或 RPC 服务。
|
|
6
27
|
|
|
@@ -16,7 +37,7 @@ Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明
|
|
|
16
37
|
安装指定版本:
|
|
17
38
|
|
|
18
39
|
```sh
|
|
19
|
-
npm install @fastls/fastls@0.
|
|
40
|
+
npm install @fastls/fastls@0.12.0
|
|
20
41
|
```
|
|
21
42
|
|
|
22
43
|
主包通过同版本的可选依赖自动安装当前平台的 Fastls 原生库。保留 optional dependencies;支持 Windows x64、Linux glibc x64/arm64、macOS x64/arm64。Linux musl 和其它架构没有内置包。具体系统最低版本见原生包的 `native/manifest.json`。
|
package/dist/errors.d.ts
CHANGED
package/dist/errors.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.AbortError = exports.FastlsDecodeError = exports.FastlsTimeoutError = ex
|
|
|
4
4
|
exports.responseError = responseError;
|
|
5
5
|
class FastlsError extends Error {
|
|
6
6
|
name = "FastlsError";
|
|
7
|
+
code;
|
|
7
8
|
}
|
|
8
9
|
exports.FastlsError = FastlsError;
|
|
9
10
|
class FastlsHTTPError extends FastlsError {
|
|
@@ -36,7 +37,9 @@ function responseError(info) {
|
|
|
36
37
|
return new FastlsTimeoutError(message, (info.timeout_phase ?? "total"));
|
|
37
38
|
if (info.error_code === "decode_error")
|
|
38
39
|
return new FastlsDecodeError(message);
|
|
39
|
-
|
|
40
|
+
const error = new FastlsError(message);
|
|
41
|
+
error.code = info.error_code;
|
|
42
|
+
return error;
|
|
40
43
|
}
|
|
41
44
|
class AbortError extends FastlsError {
|
|
42
45
|
name = "AbortError";
|
package/dist/fetch.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { Session, type RequestOptions } from "./session.js";
|
|
2
|
+
import type { ProfileSpec, AddressPolicy } from "./profiles.js";
|
|
2
3
|
export interface FetchOptions extends RequestInit {
|
|
4
|
+
retry?: RequestOptions["retry"];
|
|
5
|
+
bodyFactory?: RequestOptions["bodyFactory"];
|
|
6
|
+
profileSpec?: ProfileSpec;
|
|
7
|
+
httpVersion?: "auto" | "h3";
|
|
8
|
+
/** Only standalone fetch can create a new trusted policy owner. */
|
|
9
|
+
addressPolicy?: AddressPolicy;
|
|
3
10
|
duplex?: "half";
|
|
4
11
|
profile?: string;
|
|
5
12
|
proxy?: string | null;
|
package/dist/fetch.js
CHANGED
|
@@ -11,13 +11,19 @@ async function fetchWithSession(session, input, init = {}, owned = false) {
|
|
|
11
11
|
if (init[key] !== undefined)
|
|
12
12
|
throw new TypeError(`Fastls fetch does not support ${key}`);
|
|
13
13
|
}
|
|
14
|
-
const allowed = new Set(["method", "headers", "body", "redirect", "signal", "duplex", "profile", "proxy", "proxyCaFile", "caFile", "library", "timeoutMs", "maxRedirects"]);
|
|
14
|
+
const allowed = new Set(["method", "headers", "body", "redirect", "signal", "duplex", "profile", "profileSpec", "httpVersion", "addressPolicy", "proxy", "proxyCaFile", "caFile", "library", "timeoutMs", "maxRedirects", "retry", "bodyFactory"]);
|
|
15
|
+
if (!owned && init.addressPolicy !== undefined)
|
|
16
|
+
throw new TypeError("addressPolicy is a Session creation option");
|
|
15
17
|
for (const key of Object.keys(init))
|
|
16
18
|
if (!allowed.has(key))
|
|
17
19
|
throw new TypeError(`Fastls fetch does not support ${key}`);
|
|
18
20
|
// Request performs the platform's normal URL/method/header validation and
|
|
19
21
|
// serializes URLSearchParams, Blob and FormData without a second encoder.
|
|
20
22
|
const request = new Request(input, init);
|
|
23
|
+
if (init.bodyFactory !== undefined && request.body !== null)
|
|
24
|
+
throw new TypeError("bodyFactory cannot be combined with a Request body");
|
|
25
|
+
if (init.bodyFactory !== undefined && ["GET", "HEAD"].includes(request.method))
|
|
26
|
+
throw new TypeError("fetch GET/HEAD cannot have a bodyFactory");
|
|
21
27
|
const headers = Object.fromEntries(request.headers);
|
|
22
28
|
let body;
|
|
23
29
|
if (request.body) {
|
|
@@ -29,7 +35,8 @@ async function fetchWithSession(session, input, init = {}, owned = false) {
|
|
|
29
35
|
body = node_stream_1.Readable.fromWeb(request.body);
|
|
30
36
|
}
|
|
31
37
|
const stream = await session.stream(request.method, request.url, {
|
|
32
|
-
headers, body
|
|
38
|
+
headers, ...(init.bodyFactory === undefined ? { body } : { bodyFactory: init.bodyFactory }), retry: init.retry,
|
|
39
|
+
profile: init.profile, profileSpec: init.profileSpec, httpVersion: init.httpVersion, proxy: init.proxy, proxyCaFile: init.proxyCaFile,
|
|
33
40
|
timeoutMs: init.timeoutMs, maxRedirects: init.maxRedirects,
|
|
34
41
|
allowRedirects: request.redirect === "follow", signal: request.signal,
|
|
35
42
|
});
|
|
@@ -108,6 +115,7 @@ async function fetchWithSession(session, input, init = {}, owned = false) {
|
|
|
108
115
|
/** Fetch-compatible entry point. No persistent cookie jar is created. */
|
|
109
116
|
async function fetch(input, init = {}) {
|
|
110
117
|
const options = { cookies: false, profile: init.profile, proxy: init.proxy,
|
|
118
|
+
profileSpec: init.profileSpec, httpVersion: init.httpVersion, addressPolicy: init.addressPolicy,
|
|
111
119
|
proxyCaFile: init.proxyCaFile, caFile: init.caFile, library: init.library, timeoutMs: init.timeoutMs };
|
|
112
120
|
return fetchWithSession(new session_js_1.Session(options), input, init, true);
|
|
113
121
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
export { Session } from "./session.js";
|
|
2
|
+
export { RetryPolicy } from "./retry.js";
|
|
3
|
+
export { ProfileBundle, ProfileStore } from "./profile-store.js";
|
|
4
|
+
export type { RetryOptions } from "./retry.js";
|
|
5
|
+
export { ProfileSpec } from "./profiles.js";
|
|
6
|
+
export type { AddressPolicy } from "./profiles.js";
|
|
2
7
|
export { fetch } from "./fetch.js";
|
|
3
8
|
export type { FetchOptions, FetchResponse } from "./fetch.js";
|
|
4
9
|
export type { SessionOptions, RequestOptions, DuplexOptions, Stats } from "./session.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.FastlsTimeoutError = exports.FastlsDecodeError = exports.AbortError = exports.FastlsHTTPError = exports.FastlsError = exports.Cookies = exports.Headers = exports.StreamResponse = exports.Response = exports.SSEStream = exports.ServerSentEvent = exports.WebSocketClosed = exports.WebSocket = exports.DuplexExchange = exports.Session = void 0;
|
|
3
|
+
exports.FastlsTimeoutError = exports.FastlsDecodeError = exports.AbortError = exports.FastlsHTTPError = exports.FastlsError = exports.Cookies = exports.Headers = exports.StreamResponse = exports.Response = exports.SSEStream = exports.ServerSentEvent = exports.WebSocketClosed = exports.WebSocket = exports.DuplexExchange = exports.ProfileSpec = exports.ProfileStore = exports.ProfileBundle = exports.RetryPolicy = exports.Session = void 0;
|
|
4
4
|
var session_js_1 = require("./session.js");
|
|
5
5
|
Object.defineProperty(exports, "Session", { enumerable: true, get: function () { return session_js_1.Session; } });
|
|
6
|
+
var retry_js_1 = require("./retry.js");
|
|
7
|
+
Object.defineProperty(exports, "RetryPolicy", { enumerable: true, get: function () { return retry_js_1.RetryPolicy; } });
|
|
8
|
+
var profile_store_js_1 = require("./profile-store.js");
|
|
9
|
+
Object.defineProperty(exports, "ProfileBundle", { enumerable: true, get: function () { return profile_store_js_1.ProfileBundle; } });
|
|
10
|
+
Object.defineProperty(exports, "ProfileStore", { enumerable: true, get: function () { return profile_store_js_1.ProfileStore; } });
|
|
11
|
+
var profiles_js_1 = require("./profiles.js");
|
|
12
|
+
Object.defineProperty(exports, "ProfileSpec", { enumerable: true, get: function () { return profiles_js_1.ProfileSpec; } });
|
|
6
13
|
var fetch_js_1 = require("./fetch.js");
|
|
7
14
|
Object.defineProperty(exports, "fetch", { enumerable: true, get: function () { return fetch_js_1.fetch; } });
|
|
8
15
|
var duplex_js_1 = require("./duplex.js");
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import sdk from './index.js';
|
|
2
2
|
export const fetch = sdk.fetch;
|
|
3
|
-
export const { Session, Response, StreamResponse, DuplexExchange, WebSocket, WebSocketClosed, ServerSentEvent, SSEStream, Headers, Cookies, FastlsError, FastlsHTTPError, AbortError, FastlsDecodeError, FastlsTimeoutError } = sdk;
|
|
3
|
+
export const { Session, ProfileSpec, RetryPolicy, ProfileBundle, ProfileStore, Response, StreamResponse, DuplexExchange, WebSocket, WebSocketClosed, ServerSentEvent, SSEStream, Headers, Cookies, FastlsError, FastlsHTTPError, AbortError, FastlsDecodeError, FastlsTimeoutError } = sdk;
|
package/dist/native.d.ts
CHANGED
|
@@ -31,6 +31,7 @@ export interface NativeInfo {
|
|
|
31
31
|
}
|
|
32
32
|
export declare class Native {
|
|
33
33
|
private functions;
|
|
34
|
+
readonly transportOptionsAvailable: boolean;
|
|
34
35
|
readonly notificationsAvailable: boolean;
|
|
35
36
|
readonly websocketAvailable: boolean;
|
|
36
37
|
readonly path: string;
|
|
@@ -38,6 +39,7 @@ export declare class Native {
|
|
|
38
39
|
private constructor();
|
|
39
40
|
call<T = unknown>(name: string, ...args: unknown[]): T;
|
|
40
41
|
start(input: unknown): bigint;
|
|
42
|
+
requireCapabilities(required: readonly string[]): void;
|
|
41
43
|
wsStart(input: unknown): bigint;
|
|
42
44
|
wsSend(handle: bigint, opcode: number, data: Uint8Array): number;
|
|
43
45
|
wsRead(handle: bigint, buffer: Buffer): {
|
package/dist/native.js
CHANGED
|
@@ -62,6 +62,7 @@ function pinLibrary(path) {
|
|
|
62
62
|
}
|
|
63
63
|
class Native {
|
|
64
64
|
functions = {};
|
|
65
|
+
transportOptionsAvailable = false;
|
|
65
66
|
notificationsAvailable;
|
|
66
67
|
websocketAvailable;
|
|
67
68
|
path;
|
|
@@ -103,6 +104,13 @@ class Native {
|
|
|
103
104
|
this.functions.read = lib.func("int fastls_read(uint64_t handle, _Out_ void *buffer, size_t capacity, _Out_ size_t *length)");
|
|
104
105
|
this.functions.write = lib.func("int fastls_write(uint64_t handle, const void *buffer, size_t length, int end)");
|
|
105
106
|
const features = new Set(this.call("features"));
|
|
107
|
+
if (features.has("transport_options_v1")) {
|
|
108
|
+
try {
|
|
109
|
+
this.functions.transport_create_with_options = lib.func("void *fastls_transport_create_with_options(void *json, size_t length)");
|
|
110
|
+
this.transportOptionsAvailable = true;
|
|
111
|
+
}
|
|
112
|
+
catch { /* Optional symbol: old default transport stays loadable. */ }
|
|
113
|
+
}
|
|
106
114
|
if (features.has("transport_context_v1")) {
|
|
107
115
|
this.functions.transport_create = lib.func("void *fastls_transport_create(void)");
|
|
108
116
|
}
|
|
@@ -149,19 +157,30 @@ class Native {
|
|
|
149
157
|
// Starts, sessions and notification batches contain uint64 handles. Catalogs, headers
|
|
150
158
|
// and info metadata use strings and bounded numeric fields; avoid the
|
|
151
159
|
// allocating JS numeric parser on every poll and catalog lookup.
|
|
152
|
-
envelope = (name === "start" || name === "ws_start" || name === "session_create" || name === "session_events" ? parseNativeJSON(json) : JSON.parse(json));
|
|
160
|
+
envelope = (name === "start" || name === "ws_start" || name === "session_create" || name === "transport_create" || name === "transport_create_with_options" || name === "session_events" ? parseNativeJSON(json) : JSON.parse(json));
|
|
153
161
|
}
|
|
154
162
|
finally {
|
|
155
163
|
this.functions.free(pointer);
|
|
156
164
|
}
|
|
157
|
-
if (!envelope?.ok)
|
|
158
|
-
|
|
165
|
+
if (!envelope?.ok) {
|
|
166
|
+
const error = new errors_js_1.FastlsError(envelope?.error ?? "invalid native result");
|
|
167
|
+
error.code = envelope?.error_code;
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
159
170
|
return envelope.result;
|
|
160
171
|
}
|
|
161
172
|
start(input) {
|
|
162
173
|
const raw = encodeNativeJSON(input);
|
|
163
174
|
return asHandle(this.call("start", raw, raw.length));
|
|
164
175
|
}
|
|
176
|
+
requireCapabilities(required) {
|
|
177
|
+
const features = new Set(this.call("features"));
|
|
178
|
+
if (!this.transportOptionsAvailable || required.some(name => !features.has(name))) {
|
|
179
|
+
const error = new errors_js_1.FastlsError("native transport capability is unavailable");
|
|
180
|
+
error.code = "unsupported_capability";
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
165
184
|
wsStart(input) {
|
|
166
185
|
if (!this.websocketAvailable)
|
|
167
186
|
throw new errors_js_1.FastlsError("WebSocket requires a newer Fastls native library");
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ProfileSpec } from "./profiles.js";
|
|
2
|
+
/** A pinned digest verifies exact Spec bytes, not producer authenticity. */
|
|
3
|
+
export declare class ProfileBundle {
|
|
4
|
+
readonly sha256: string;
|
|
5
|
+
readonly profileId: string;
|
|
6
|
+
readonly source: string;
|
|
7
|
+
private readonly text;
|
|
8
|
+
private readonly specText;
|
|
9
|
+
constructor(text: string, options: {
|
|
10
|
+
sha256: string;
|
|
11
|
+
library?: string;
|
|
12
|
+
});
|
|
13
|
+
static create(spec: ProfileSpec, options: {
|
|
14
|
+
source: string;
|
|
15
|
+
library?: string;
|
|
16
|
+
}): ProfileBundle;
|
|
17
|
+
get spec(): ProfileSpec;
|
|
18
|
+
dumps(): string;
|
|
19
|
+
export(path: string): void;
|
|
20
|
+
}
|
|
21
|
+
/** Trusted local directories only; callers must serialize concurrent writers. */
|
|
22
|
+
export declare class ProfileStore {
|
|
23
|
+
readonly directory: string;
|
|
24
|
+
private readonly options;
|
|
25
|
+
private readonly objects;
|
|
26
|
+
private readonly statePath;
|
|
27
|
+
constructor(directory: string, options?: {
|
|
28
|
+
library?: string;
|
|
29
|
+
});
|
|
30
|
+
private state;
|
|
31
|
+
load(sha256?: string): ProfileBundle;
|
|
32
|
+
install(path: string, sha256: string): ProfileBundle;
|
|
33
|
+
pin(sha256: string): ProfileBundle;
|
|
34
|
+
rollback(): ProfileBundle;
|
|
35
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProfileStore = exports.ProfileBundle = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const lossless_json_1 = require("lossless-json");
|
|
8
|
+
const profiles_js_1 = require("./profiles.js");
|
|
9
|
+
const session_js_1 = require("./session.js");
|
|
10
|
+
const LIMIT = 512 * 1024;
|
|
11
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
12
|
+
function strict(text) {
|
|
13
|
+
if (typeof text !== "string" || Buffer.byteLength(text) > LIMIT)
|
|
14
|
+
throw new RangeError("profile bundle exceeds 512KiB");
|
|
15
|
+
try {
|
|
16
|
+
// lossless-json accepts repeated keys when their values are equal. Inspect
|
|
17
|
+
// all object keys as well, so the bundle schema rejects every duplicate.
|
|
18
|
+
const stack = [];
|
|
19
|
+
for (const token of text.matchAll(/"(?:\\.|[^"\\])*"|[{}\[\]]/gs)) {
|
|
20
|
+
if (token[0] === "{")
|
|
21
|
+
stack.push(new Set());
|
|
22
|
+
else if (token[0] === "[")
|
|
23
|
+
stack.push(null);
|
|
24
|
+
else if (token[0] === "}" || token[0] === "]")
|
|
25
|
+
stack.pop();
|
|
26
|
+
else {
|
|
27
|
+
let offset = token.index + token[0].length;
|
|
28
|
+
while (offset < text.length && /\s/.test(text[offset]))
|
|
29
|
+
offset++;
|
|
30
|
+
if (text[offset] === ":") {
|
|
31
|
+
const keys = stack.at(-1), key = JSON.parse(token[0]);
|
|
32
|
+
if (!keys || keys.has(key))
|
|
33
|
+
throw new TypeError("duplicate profile bundle field");
|
|
34
|
+
keys.add(key);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (stack.length > 64)
|
|
38
|
+
throw new RangeError("profile bundle exceeds depth 64");
|
|
39
|
+
}
|
|
40
|
+
const value = (0, lossless_json_1.parse)(text, null, {
|
|
41
|
+
parseNumber(token) {
|
|
42
|
+
const number = Number(token);
|
|
43
|
+
if (!/^-?\d+$/.test(token) || !Number.isSafeInteger(number))
|
|
44
|
+
throw new TypeError("profile bundle requires portable integer fields");
|
|
45
|
+
return number;
|
|
46
|
+
},
|
|
47
|
+
onDuplicateKey() { throw new TypeError("duplicate profile bundle field"); },
|
|
48
|
+
});
|
|
49
|
+
function visit(item, depth = 0) {
|
|
50
|
+
if (depth > 64)
|
|
51
|
+
throw new RangeError("profile bundle exceeds depth 64");
|
|
52
|
+
if (typeof item === "string" && /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(item))
|
|
53
|
+
throw new TypeError("invalid Unicode");
|
|
54
|
+
if (item && typeof item === "object")
|
|
55
|
+
for (const [key, entry] of Object.entries(item)) {
|
|
56
|
+
visit(key, depth + 1);
|
|
57
|
+
visit(entry, depth + 1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
visit(value);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
throw new TypeError("invalid profile bundle JSON");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function record(value) { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
68
|
+
function read(path) {
|
|
69
|
+
const fd = (0, node_fs_1.openSync)(path, "r");
|
|
70
|
+
try {
|
|
71
|
+
const buffer = Buffer.alloc(LIMIT + 1);
|
|
72
|
+
let length = 0, size;
|
|
73
|
+
while (length < buffer.length && (size = (0, node_fs_1.readSync)(fd, buffer, length, buffer.length - length, null)) > 0)
|
|
74
|
+
length += size;
|
|
75
|
+
if (length > LIMIT)
|
|
76
|
+
throw new RangeError("profile bundle exceeds 512KiB");
|
|
77
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length));
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
(0, node_fs_1.closeSync)(fd);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function atomic(path, text) {
|
|
84
|
+
const temporary = (0, node_path_1.join)((0, node_path_1.dirname)(path), ".fastls-" + (0, node_crypto_1.randomBytes)(16).toString("hex"));
|
|
85
|
+
const fd = (0, node_fs_1.openSync)(temporary, "wx", 0o600);
|
|
86
|
+
try {
|
|
87
|
+
try {
|
|
88
|
+
(0, node_fs_1.writeFileSync)(fd, text, "utf8");
|
|
89
|
+
(0, node_fs_1.fsyncSync)(fd);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
(0, node_fs_1.closeSync)(fd);
|
|
93
|
+
}
|
|
94
|
+
(0, node_fs_1.renameSync)(temporary, path);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if ((0, node_fs_1.existsSync)(temporary))
|
|
98
|
+
(0, node_fs_1.unlinkSync)(temporary);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function canonical(value) {
|
|
102
|
+
if (Array.isArray(value))
|
|
103
|
+
return value.map(canonical);
|
|
104
|
+
if (record(value))
|
|
105
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])]));
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
/** A pinned digest verifies exact Spec bytes, not producer authenticity. */
|
|
109
|
+
class ProfileBundle {
|
|
110
|
+
sha256;
|
|
111
|
+
profileId;
|
|
112
|
+
source;
|
|
113
|
+
text;
|
|
114
|
+
specText;
|
|
115
|
+
constructor(text, options) {
|
|
116
|
+
if (typeof options.sha256 !== "string" || !HASH.test(options.sha256))
|
|
117
|
+
throw new TypeError("an explicit lowercase SHA-256 pin is required");
|
|
118
|
+
const envelope = strict(text);
|
|
119
|
+
const keys = ["profile_id", "schema_version", "sha256", "source", "spec_json"];
|
|
120
|
+
if (!record(envelope) || JSON.stringify(Object.keys(envelope).sort()) !== JSON.stringify(keys) || envelope.schema_version !== 1)
|
|
121
|
+
throw new TypeError("unsupported profile bundle schema");
|
|
122
|
+
const raw = envelope.spec_json, source = envelope.source;
|
|
123
|
+
if (typeof raw !== "string" || typeof source !== "string" || source.length < 1 || Buffer.byteLength(source) > 512 || /[\x00-\x1f\x7f]/.test(source))
|
|
124
|
+
throw new TypeError("invalid profile bundle fields");
|
|
125
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(raw, "utf8").digest("hex");
|
|
126
|
+
if (digest !== options.sha256 || digest !== envelope.sha256)
|
|
127
|
+
throw new TypeError("profile bundle digest mismatch");
|
|
128
|
+
const value = strict(raw);
|
|
129
|
+
if (!record(value) || typeof value.id !== "string" || value.id !== envelope.profile_id)
|
|
130
|
+
throw new TypeError("profile bundle ID mismatch");
|
|
131
|
+
const spec = new profiles_js_1.ProfileSpec(value);
|
|
132
|
+
const session = new session_js_1.Session({ library: options.library, profileSpec: spec });
|
|
133
|
+
session.close();
|
|
134
|
+
this.sha256 = digest;
|
|
135
|
+
this.profileId = value.id;
|
|
136
|
+
this.source = source;
|
|
137
|
+
this.text = JSON.stringify(canonical(envelope)) + "\n";
|
|
138
|
+
this.specText = JSON.stringify(value);
|
|
139
|
+
Object.freeze(this);
|
|
140
|
+
}
|
|
141
|
+
static create(spec, options) {
|
|
142
|
+
if (!(spec instanceof profiles_js_1.ProfileSpec))
|
|
143
|
+
throw new TypeError("spec must be ProfileSpec");
|
|
144
|
+
const raw = JSON.stringify(canonical(spec.toJSON()));
|
|
145
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(raw, "utf8").digest("hex");
|
|
146
|
+
const text = JSON.stringify({ schema_version: 1, profile_id: spec.toJSON().id, spec_json: raw, sha256: digest, source: options.source });
|
|
147
|
+
return new ProfileBundle(text, { sha256: digest, library: options.library });
|
|
148
|
+
}
|
|
149
|
+
get spec() { return new profiles_js_1.ProfileSpec(JSON.parse(this.specText)); }
|
|
150
|
+
dumps() { return this.text; }
|
|
151
|
+
export(path) { atomic(path, this.text); }
|
|
152
|
+
}
|
|
153
|
+
exports.ProfileBundle = ProfileBundle;
|
|
154
|
+
/** Trusted local directories only; callers must serialize concurrent writers. */
|
|
155
|
+
class ProfileStore {
|
|
156
|
+
directory;
|
|
157
|
+
options;
|
|
158
|
+
objects;
|
|
159
|
+
statePath;
|
|
160
|
+
constructor(directory, options = {}) {
|
|
161
|
+
this.directory = directory;
|
|
162
|
+
this.options = options;
|
|
163
|
+
this.objects = (0, node_path_1.join)(directory, "objects");
|
|
164
|
+
this.statePath = (0, node_path_1.join)(directory, "state.json");
|
|
165
|
+
(0, node_fs_1.mkdirSync)(this.objects, { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
state() {
|
|
168
|
+
if (!(0, node_fs_1.existsSync)(this.statePath))
|
|
169
|
+
return { current: null, previous: null };
|
|
170
|
+
const value = strict(read(this.statePath));
|
|
171
|
+
if (!record(value) || Object.keys(value).sort().join(",") !== "current,previous" || Object.values(value).some(pin => pin !== null && (typeof pin !== "string" || !HASH.test(pin))))
|
|
172
|
+
throw new TypeError("invalid profile store state");
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
load(sha256) {
|
|
176
|
+
const pin = sha256 ?? this.state().current;
|
|
177
|
+
if (typeof pin !== "string" || !HASH.test(pin))
|
|
178
|
+
throw new TypeError("no valid profile pin");
|
|
179
|
+
return new ProfileBundle(read((0, node_path_1.join)(this.objects, pin + ".json")), { ...this.options, sha256: pin });
|
|
180
|
+
}
|
|
181
|
+
install(path, sha256) {
|
|
182
|
+
const bundle = new ProfileBundle(read(path), { ...this.options, sha256 });
|
|
183
|
+
const target = (0, node_path_1.join)(this.objects, bundle.sha256 + ".json");
|
|
184
|
+
if ((0, node_fs_1.existsSync)(target))
|
|
185
|
+
this.load(bundle.sha256);
|
|
186
|
+
else
|
|
187
|
+
atomic(target, bundle.dumps());
|
|
188
|
+
return this.pin(bundle.sha256);
|
|
189
|
+
}
|
|
190
|
+
pin(sha256) {
|
|
191
|
+
const bundle = this.load(sha256), state = this.state();
|
|
192
|
+
if (state.current !== sha256)
|
|
193
|
+
atomic(this.statePath, JSON.stringify({ current: sha256, previous: state.current }) + "\n");
|
|
194
|
+
return bundle;
|
|
195
|
+
}
|
|
196
|
+
rollback() {
|
|
197
|
+
const previous = this.state().previous;
|
|
198
|
+
if (previous === null)
|
|
199
|
+
throw new TypeError("no previous profile pin");
|
|
200
|
+
return this.pin(previous);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
exports.ProfileStore = ProfileStore;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Trusted immutable session policy. Keys follow the native versioned schema. */
|
|
2
|
+
export interface AddressPolicy {
|
|
3
|
+
readonly allow_schemes?: readonly ("http" | "https")[];
|
|
4
|
+
readonly allow_hosts?: readonly string[];
|
|
5
|
+
readonly allow_ports?: readonly number[];
|
|
6
|
+
readonly allow_cidrs?: readonly string[];
|
|
7
|
+
readonly deny_cidrs?: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
/** Explicit snapshot of a complete schema-v1 profile, validated by the core. */
|
|
10
|
+
export declare class ProfileSpec {
|
|
11
|
+
private readonly snapshot;
|
|
12
|
+
constructor(value: Readonly<Record<string, unknown>>);
|
|
13
|
+
toJSON(): Record<string, unknown>;
|
|
14
|
+
}
|
package/dist/profiles.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ProfileSpec = void 0;
|
|
4
|
+
/** Explicit snapshot of a complete schema-v1 profile, validated by the core. */
|
|
5
|
+
class ProfileSpec {
|
|
6
|
+
snapshot;
|
|
7
|
+
constructor(value) {
|
|
8
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
9
|
+
throw new TypeError("ProfileSpec requires an object");
|
|
10
|
+
this.snapshot = JSON.stringify(value);
|
|
11
|
+
if (Buffer.byteLength(this.snapshot) > 256 * 1024)
|
|
12
|
+
throw new RangeError("profileSpec exceeds 256KiB");
|
|
13
|
+
}
|
|
14
|
+
toJSON() { return JSON.parse(this.snapshot); }
|
|
15
|
+
}
|
|
16
|
+
exports.ProfileSpec = ProfileSpec;
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { UploadBody } from "./upload.js";
|
|
2
|
+
export interface RetryOptions {
|
|
3
|
+
maxRetries?: number;
|
|
4
|
+
methods?: readonly string[];
|
|
5
|
+
statuses?: readonly number[];
|
|
6
|
+
backoffMs?: number;
|
|
7
|
+
maxDelayMs?: number;
|
|
8
|
+
jitter?: boolean;
|
|
9
|
+
retryAfter?: boolean;
|
|
10
|
+
retryNetwork?: boolean;
|
|
11
|
+
allowNonIdempotent?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/** Immutable, opt-in business retries. Durations are milliseconds. */
|
|
14
|
+
export declare class RetryPolicy {
|
|
15
|
+
readonly maxRetries: number;
|
|
16
|
+
readonly methods: readonly string[];
|
|
17
|
+
readonly statuses: readonly number[];
|
|
18
|
+
readonly backoffMs: number;
|
|
19
|
+
readonly maxDelayMs: number;
|
|
20
|
+
readonly jitter: boolean;
|
|
21
|
+
readonly retryAfter: boolean;
|
|
22
|
+
readonly retryNetwork: boolean;
|
|
23
|
+
constructor(options?: RetryOptions);
|
|
24
|
+
eligible(method: string, used: number, replayable: boolean, error?: unknown, status?: number): boolean;
|
|
25
|
+
delay(used: number, header?: string | null): number;
|
|
26
|
+
}
|
|
27
|
+
export declare function replayable(body?: UploadBody, factory?: () => UploadBody): boolean;
|
|
28
|
+
export declare function retryWait(delay: number, deadline: number, signal: AbortSignal): Promise<void>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RetryPolicy = void 0;
|
|
4
|
+
exports.replayable = replayable;
|
|
5
|
+
exports.retryWait = retryWait;
|
|
6
|
+
const promises_1 = require("node:timers/promises");
|
|
7
|
+
const errors_js_1 = require("./errors.js");
|
|
8
|
+
const SAFE = new Set(["GET", "HEAD", "OPTIONS", "PUT", "DELETE"]);
|
|
9
|
+
/** Immutable, opt-in business retries. Durations are milliseconds. */
|
|
10
|
+
class RetryPolicy {
|
|
11
|
+
maxRetries;
|
|
12
|
+
methods;
|
|
13
|
+
statuses;
|
|
14
|
+
backoffMs;
|
|
15
|
+
maxDelayMs;
|
|
16
|
+
jitter;
|
|
17
|
+
retryAfter;
|
|
18
|
+
retryNetwork;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
if (!options || typeof options !== "object" || Array.isArray(options))
|
|
21
|
+
throw new TypeError("retry options must be an object");
|
|
22
|
+
const allowed = new Set(["maxRetries", "methods", "statuses", "backoffMs", "maxDelayMs", "jitter", "retryAfter", "retryNetwork", "allowNonIdempotent"]);
|
|
23
|
+
if (Object.keys(options).some(key => !allowed.has(key)))
|
|
24
|
+
throw new TypeError("unknown retry option");
|
|
25
|
+
for (const key of ["methods", "statuses"])
|
|
26
|
+
if (options[key] !== undefined && !Array.isArray(options[key]))
|
|
27
|
+
throw new TypeError(`${key} must be an array`);
|
|
28
|
+
this.maxRetries = options.maxRetries === undefined ? 2 : options.maxRetries;
|
|
29
|
+
if (!Number.isInteger(this.maxRetries) || this.maxRetries < 0 || this.maxRetries > 100)
|
|
30
|
+
throw new RangeError("maxRetries must be 0..100");
|
|
31
|
+
this.methods = Object.freeze([...(options.methods ?? SAFE)].map(method => {
|
|
32
|
+
if (typeof method !== "string" || !/^[A-Za-z]+$/.test(method))
|
|
33
|
+
throw new TypeError("invalid retry method");
|
|
34
|
+
return method.toUpperCase();
|
|
35
|
+
}));
|
|
36
|
+
for (const key of ["jitter", "retryAfter", "retryNetwork", "allowNonIdempotent"]) {
|
|
37
|
+
if (options[key] !== undefined && typeof options[key] !== "boolean")
|
|
38
|
+
throw new TypeError(`${key} must be a boolean`);
|
|
39
|
+
}
|
|
40
|
+
if (!options.allowNonIdempotent && this.methods.some(method => !SAFE.has(method)))
|
|
41
|
+
throw new TypeError("non-idempotent methods require explicit authorization");
|
|
42
|
+
this.statuses = Object.freeze([...(options.statuses ?? [429, 502, 503, 504])]);
|
|
43
|
+
if (this.statuses.some(status => !Number.isInteger(status) || status < 400 || status > 599 || [401, 403, 407].includes(status)))
|
|
44
|
+
throw new RangeError("invalid retry status");
|
|
45
|
+
this.backoffMs = options.backoffMs === undefined ? 100 : options.backoffMs;
|
|
46
|
+
this.maxDelayMs = options.maxDelayMs === undefined ? 10000 : options.maxDelayMs;
|
|
47
|
+
for (const value of [this.backoffMs, this.maxDelayMs])
|
|
48
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 86400000)
|
|
49
|
+
throw new RangeError("retry delays must be within 0..86400000 milliseconds");
|
|
50
|
+
this.jitter = options.jitter ?? true;
|
|
51
|
+
this.retryAfter = options.retryAfter ?? true;
|
|
52
|
+
this.retryNetwork = options.retryNetwork ?? true;
|
|
53
|
+
Object.freeze(this);
|
|
54
|
+
}
|
|
55
|
+
eligible(method, used, replayable, error, status) {
|
|
56
|
+
if (used >= this.maxRetries || !this.methods.includes(method.toUpperCase()) || !replayable)
|
|
57
|
+
return false;
|
|
58
|
+
if (status !== undefined)
|
|
59
|
+
return this.statuses.includes(status);
|
|
60
|
+
return this.retryNetwork && error instanceof errors_js_1.FastlsError && (error.code === "network_error" ||
|
|
61
|
+
(error instanceof errors_js_1.FastlsTimeoutError && ["connect", "headers"].includes(error.phase)));
|
|
62
|
+
}
|
|
63
|
+
delay(used, header) {
|
|
64
|
+
let delay = Math.min(this.maxDelayMs, this.backoffMs * 2 ** used);
|
|
65
|
+
if (this.jitter)
|
|
66
|
+
delay *= Math.random();
|
|
67
|
+
if (this.retryAfter && header) {
|
|
68
|
+
const seconds = /^[0-9]+$/.test(header) ? Number(header) : undefined;
|
|
69
|
+
const value = seconds === undefined ? Date.parse(header) - Date.now() : Math.min(seconds, this.maxDelayMs / 1000) * 1000;
|
|
70
|
+
if (!Number.isNaN(value))
|
|
71
|
+
delay = Math.max(delay, Math.min(this.maxDelayMs, Math.max(0, value)));
|
|
72
|
+
}
|
|
73
|
+
return delay;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
exports.RetryPolicy = RetryPolicy;
|
|
77
|
+
function replayable(body, factory) {
|
|
78
|
+
return factory !== undefined || body === undefined || typeof body === "string" || body instanceof Uint8Array;
|
|
79
|
+
}
|
|
80
|
+
async function retryWait(delay, deadline, signal) {
|
|
81
|
+
if (signal.aborted)
|
|
82
|
+
throw new errors_js_1.AbortError(undefined, { cause: signal.reason });
|
|
83
|
+
if (deadline - performance.now() <= delay)
|
|
84
|
+
throw new errors_js_1.FastlsTimeoutError("total deadline exceeded before retry", "total");
|
|
85
|
+
try {
|
|
86
|
+
await (0, promises_1.setTimeout)(delay, undefined, { signal });
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (signal.aborted)
|
|
90
|
+
throw new errors_js_1.AbortError(undefined, { cause: signal.reason });
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/session.d.ts
CHANGED
|
@@ -7,7 +7,13 @@ import { SSEStream, type SSEOptions } from "./sse.js";
|
|
|
7
7
|
import { type QueryParams, type JSONValue, type FormFields, type MultipartFiles } from "./request-options.js";
|
|
8
8
|
import { WebSocket, type WebSocketOptions } from "./websocket.js";
|
|
9
9
|
import { type FetchOptions, type FetchResponse } from "./fetch.js";
|
|
10
|
+
import { ProfileSpec, type AddressPolicy } from "./profiles.js";
|
|
11
|
+
import { RetryPolicy } from "./retry.js";
|
|
10
12
|
export interface RequestOptions {
|
|
13
|
+
retry?: RetryPolicy | null;
|
|
14
|
+
bodyFactory?: () => UploadBody;
|
|
15
|
+
profileSpec?: ProfileSpec;
|
|
16
|
+
httpVersion?: "auto" | "h3";
|
|
11
17
|
/** Undefined inherits the session proxy; null or an empty string connects directly. */
|
|
12
18
|
proxy?: string | null;
|
|
13
19
|
/** Undefined inherits proxy trust; null uses system roots. Only HTTPS proxies use this file. */
|
|
@@ -32,6 +38,10 @@ export interface RequestOptions {
|
|
|
32
38
|
}
|
|
33
39
|
export type DuplexOptions = Omit<RequestOptions, "body" | "duplex" | "json" | "form" | "files">;
|
|
34
40
|
export interface SessionOptions {
|
|
41
|
+
retry?: RetryPolicy;
|
|
42
|
+
addressPolicy?: AddressPolicy;
|
|
43
|
+
profileSpec?: ProfileSpec;
|
|
44
|
+
httpVersion?: "auto" | "h3";
|
|
35
45
|
/** HTTP/HTTPS CONNECT, socks5 (local DNS), or socks5h (proxy DNS) URL. */
|
|
36
46
|
proxy?: string | null;
|
|
37
47
|
/** Private roots for proxy TLS, independent of the target's caFile. */
|
|
@@ -79,6 +89,8 @@ export declare class Session {
|
|
|
79
89
|
private readonly websockets;
|
|
80
90
|
private readonly producers;
|
|
81
91
|
private closed;
|
|
92
|
+
private readonly closing;
|
|
93
|
+
private readonly retry?;
|
|
82
94
|
private notifier?;
|
|
83
95
|
private setup?;
|
|
84
96
|
constructor(options?: SessionOptions);
|
package/dist/session.js
CHANGED
|
@@ -15,6 +15,8 @@ const notifications_js_1 = require("./notifications.js");
|
|
|
15
15
|
const request_options_js_1 = require("./request-options.js");
|
|
16
16
|
const websocket_js_1 = require("./websocket.js");
|
|
17
17
|
const fetch_js_1 = require("./fetch.js");
|
|
18
|
+
const profiles_js_1 = require("./profiles.js");
|
|
19
|
+
const retry_js_1 = require("./retry.js");
|
|
18
20
|
function decoding(decode, maximum) {
|
|
19
21
|
if (typeof decode !== "boolean")
|
|
20
22
|
throw new TypeError("decodeContent must be a boolean");
|
|
@@ -78,15 +80,28 @@ class Session {
|
|
|
78
80
|
websockets = new Set();
|
|
79
81
|
producers = new Set();
|
|
80
82
|
closed = false;
|
|
83
|
+
closing = new AbortController();
|
|
84
|
+
retry;
|
|
81
85
|
notifier;
|
|
82
86
|
setup;
|
|
83
87
|
constructor(options = {}) {
|
|
88
|
+
if (options.retry !== undefined && !(options.retry instanceof retry_js_1.RetryPolicy))
|
|
89
|
+
throw new TypeError("retry must be RetryPolicy");
|
|
90
|
+
this.retry = options.retry;
|
|
84
91
|
this.proxy = proxyValue(options.proxy);
|
|
85
92
|
this.proxyCaPem = proxyTrust(options.proxyCaFile);
|
|
86
93
|
const notifications = options.notifications === undefined ? "auto" : options.notifications;
|
|
87
94
|
if (!["auto", "required", "poll"].includes(notifications))
|
|
88
95
|
throw new TypeError("notifications must be auto, required or poll");
|
|
89
|
-
|
|
96
|
+
if (options.profile !== undefined && typeof options.profile !== "string")
|
|
97
|
+
throw new TypeError("profile must be a selector string");
|
|
98
|
+
if (options.profileSpec !== undefined && !(options.profileSpec instanceof profiles_js_1.ProfileSpec))
|
|
99
|
+
throw new TypeError("profileSpec must be a ProfileSpec");
|
|
100
|
+
if (options.profile !== undefined && options.profileSpec !== undefined)
|
|
101
|
+
throw new TypeError("profile and profileSpec are mutually exclusive");
|
|
102
|
+
if (options.httpVersion !== undefined && !["auto", "h3"].includes(options.httpVersion))
|
|
103
|
+
throw new TypeError("httpVersion must be auto or h3");
|
|
104
|
+
this.profile = options.profile ?? (options.profileSpec !== undefined || options.httpVersion === "h3" ? "" : "chrome/156/windows");
|
|
90
105
|
this.timeouts = (0, timeout_js_1.timeoutFields)(options.timeoutMs ?? 30000);
|
|
91
106
|
this.decodeContent = options.decodeContent ?? true;
|
|
92
107
|
this.maxDecodedBytes = options.maxDecodedBytes ?? 256 * 1024 * 1024;
|
|
@@ -104,7 +119,36 @@ class Session {
|
|
|
104
119
|
this.notificationMode = notifications !== "poll" && supported ? "async-events" : "poll";
|
|
105
120
|
if (!this.native.call("features").includes("transport_context_v1"))
|
|
106
121
|
throw new errors_js_1.FastlsError("SDK-owned sessions require the Fastls 0.1.4 native library or newer");
|
|
107
|
-
|
|
122
|
+
const configuration = { schema_version: 1 };
|
|
123
|
+
const required = [];
|
|
124
|
+
if (options.addressPolicy !== undefined) {
|
|
125
|
+
if (!options.addressPolicy || typeof options.addressPolicy !== "object" || Array.isArray(options.addressPolicy))
|
|
126
|
+
throw new TypeError("addressPolicy must be an object");
|
|
127
|
+
configuration.address_policy = options.addressPolicy;
|
|
128
|
+
required.push("transport_address_policy_v1");
|
|
129
|
+
}
|
|
130
|
+
if (options.profileSpec !== undefined) {
|
|
131
|
+
configuration.profile_spec = options.profileSpec.toJSON();
|
|
132
|
+
required.push("profile_spec_v1");
|
|
133
|
+
}
|
|
134
|
+
if (options.httpVersion === "h3") {
|
|
135
|
+
configuration.http_version = "h3";
|
|
136
|
+
required.push("http3_v1");
|
|
137
|
+
}
|
|
138
|
+
if (required.length) {
|
|
139
|
+
this.native.requireCapabilities(required);
|
|
140
|
+
if (options.profile !== undefined)
|
|
141
|
+
configuration.profile = options.profile;
|
|
142
|
+
if (this.proxy && (options.addressPolicy !== undefined || options.httpVersion === "h3")) {
|
|
143
|
+
const error = new errors_js_1.FastlsError("unsupported transport combination");
|
|
144
|
+
error.code = "unsupported_capability";
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
const raw = (0, native_js_1.encodeNativeJSON)(configuration);
|
|
148
|
+
this.handle = (0, native_js_1.asHandle)(this.native.call("transport_create_with_options", raw, raw.length));
|
|
149
|
+
}
|
|
150
|
+
else
|
|
151
|
+
this.handle = (0, native_js_1.asHandle)(this.native.call("transport_create"));
|
|
108
152
|
this.cookies = new cookies_js_1.Cookies(() => this.ensureOpen(), options.cookies !== false);
|
|
109
153
|
}
|
|
110
154
|
ensureOpen() {
|
|
@@ -157,6 +201,8 @@ class Session {
|
|
|
157
201
|
}
|
|
158
202
|
begin(method, url, options, manual = false, completeHeaders = false) {
|
|
159
203
|
this.ensureOpen();
|
|
204
|
+
if ("addressPolicy" in options)
|
|
205
|
+
throw new TypeError("addressPolicy is a Session creation option");
|
|
160
206
|
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
|
|
161
207
|
throw new TypeError("signal must be an AbortSignal");
|
|
162
208
|
}
|
|
@@ -215,9 +261,24 @@ class Session {
|
|
|
215
261
|
trailers[name] = [...values];
|
|
216
262
|
}
|
|
217
263
|
}
|
|
264
|
+
if (options.profileSpec !== undefined && !(options.profileSpec instanceof profiles_js_1.ProfileSpec))
|
|
265
|
+
throw new TypeError("profileSpec must be a ProfileSpec");
|
|
266
|
+
if (options.profileSpec !== undefined && options.profile !== undefined)
|
|
267
|
+
throw new TypeError("profile and profileSpec are mutually exclusive");
|
|
268
|
+
const required = [];
|
|
269
|
+
if (options.profileSpec !== undefined)
|
|
270
|
+
required.push("profile_spec_v1");
|
|
271
|
+
if (options.httpVersion !== undefined) {
|
|
272
|
+
if (!["auto", "h3"].includes(options.httpVersion))
|
|
273
|
+
throw new TypeError("httpVersion must be auto or h3");
|
|
274
|
+
required.push("http3_v1");
|
|
275
|
+
}
|
|
276
|
+
if (required.length)
|
|
277
|
+
this.native.requireCapabilities(required);
|
|
218
278
|
const handle = this.native.start({
|
|
219
279
|
session: this.handle, url, method, headers, trailers, proxy: proxy || undefined, proxy_ca_pem: proxyCaPem,
|
|
220
|
-
profile: options.profile ?? this.profile,
|
|
280
|
+
profile: options.profile ?? (options.profileSpec !== undefined || options.httpVersion === "h3" ? "" : this.profile),
|
|
281
|
+
profile_spec: options.profileSpec?.toJSON(), http_version: options.httpVersion,
|
|
221
282
|
...timeouts, decode_content: decodeContent, max_decoded_bytes: maxDecodedBytes,
|
|
222
283
|
disable_redirect: true,
|
|
223
284
|
ca_pem: this.caPem,
|
|
@@ -269,9 +330,24 @@ class Session {
|
|
|
269
330
|
}
|
|
270
331
|
}
|
|
271
332
|
async stream(method, url, options = {}) {
|
|
333
|
+
const retry = options.retry === undefined ? this.retry : options.retry ?? undefined;
|
|
334
|
+
if (retry !== undefined && !(retry instanceof retry_js_1.RetryPolicy))
|
|
335
|
+
throw new TypeError("retry must be RetryPolicy or null");
|
|
336
|
+
let factory = options.bodyFactory;
|
|
337
|
+
if (factory !== undefined && typeof factory !== "function")
|
|
338
|
+
throw new TypeError("bodyFactory must be a function");
|
|
339
|
+
if (factory !== undefined && ["body", "json", "form", "files"].some(key => key in options))
|
|
340
|
+
throw new TypeError("bodyFactory cannot be combined with body, json, form or files");
|
|
341
|
+
if (options.duplex && (retry !== undefined || factory !== undefined))
|
|
342
|
+
throw new TypeError("duplex does not support retry or bodyFactory");
|
|
343
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal))
|
|
344
|
+
throw new TypeError("signal must be an AbortSignal");
|
|
345
|
+
const signal = options.signal === undefined ? this.closing.signal : AbortSignal.any([options.signal, this.closing.signal]);
|
|
272
346
|
const headers = mergeHeaders(this.headers, options.headers);
|
|
273
347
|
const prepared = (0, request_options_js_1.prepare)(url, options, headers);
|
|
274
348
|
url = prepared.url;
|
|
349
|
+
// Session close has its own established transfer error. Its signal only
|
|
350
|
+
// interrupts retry backoff; forwarding it as a caller abort changes that error.
|
|
275
351
|
options = { ...options, headers, body: prepared.body };
|
|
276
352
|
for (const key of ["params", "json", "form", "files"])
|
|
277
353
|
delete options[key];
|
|
@@ -280,14 +356,37 @@ class Session {
|
|
|
280
356
|
throw new RangeError("maxRedirects must be a non-negative integer");
|
|
281
357
|
const policy = options.timeoutMs === undefined ? this.timeouts : (0, timeout_js_1.timeoutFields)(options.timeoutMs);
|
|
282
358
|
const deadline = performance.now() + policy.timeout_ms;
|
|
283
|
-
|
|
359
|
+
let used = 0, redirects = 0;
|
|
360
|
+
for (;;) {
|
|
361
|
+
this.ensureOpen();
|
|
362
|
+
if (signal.aborted)
|
|
363
|
+
throw new errors_js_1.AbortError(undefined, { cause: signal.reason });
|
|
364
|
+
if (factory !== undefined)
|
|
365
|
+
options.body = factory();
|
|
284
366
|
const remaining = Math.ceil(deadline - performance.now());
|
|
285
367
|
if (remaining <= 0)
|
|
286
368
|
throw new errors_js_1.FastlsTimeoutError("total deadline exceeded", "total");
|
|
287
369
|
options.timeoutMs = { total: remaining, connect: policy.connect_timeout_ms, write: policy.write_timeout_ms, headers: policy.headers_timeout_ms, read: policy.read_timeout_ms };
|
|
288
|
-
|
|
370
|
+
let response;
|
|
371
|
+
try {
|
|
372
|
+
response = await this.streamHop(method, url, options);
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
if (!retry?.eligible(method, used, (0, retry_js_1.replayable)(options.body, factory), error))
|
|
376
|
+
throw error;
|
|
377
|
+
await (0, retry_js_1.retryWait)(retry.delay(used), deadline, signal);
|
|
378
|
+
used++;
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (retry?.eligible(method, used, (0, retry_js_1.replayable)(options.body, factory), undefined, response.status)) {
|
|
382
|
+
const delay = retry.delay(used, response.headers.get("retry-after"));
|
|
383
|
+
await response.closeAsync();
|
|
384
|
+
await (0, retry_js_1.retryWait)(delay, deadline, signal);
|
|
385
|
+
used++;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
289
388
|
const location = response.headers.get("location");
|
|
290
|
-
if (options.duplex || options.allowRedirects === false || ![301, 302, 303, 307, 308].includes(response.status) || !location || ([307, 308].includes(response.status) && (0,
|
|
389
|
+
if (options.duplex || options.allowRedirects === false || ![301, 302, 303, 307, 308].includes(response.status) || !location || ([307, 308].includes(response.status) && !(0, retry_js_1.replayable)(options.body, factory)))
|
|
291
390
|
return response;
|
|
292
391
|
try {
|
|
293
392
|
if (redirects >= maximum)
|
|
@@ -303,10 +402,12 @@ class Session {
|
|
|
303
402
|
method = "GET";
|
|
304
403
|
delete options.body;
|
|
305
404
|
delete options.trailers;
|
|
405
|
+
factory = undefined;
|
|
306
406
|
for (const name of ["content-type", "content-length", "transfer-encoding"])
|
|
307
407
|
delete headers[name];
|
|
308
408
|
}
|
|
309
409
|
url = target.href;
|
|
410
|
+
redirects++;
|
|
310
411
|
await response.read();
|
|
311
412
|
}
|
|
312
413
|
finally {
|
|
@@ -341,6 +442,8 @@ class Session {
|
|
|
341
442
|
}
|
|
342
443
|
/** Open a manual exchange; send() and response reads can run concurrently. */
|
|
343
444
|
duplex(method, url, options = {}) {
|
|
445
|
+
if (options.retry !== undefined || options.bodyFactory !== undefined)
|
|
446
|
+
throw new TypeError("manual duplex does not support retry or bodyFactory");
|
|
344
447
|
if (["body", "duplex", "json", "form", "files"].some(key => key in options))
|
|
345
448
|
throw new TypeError("manual duplex uses send() and finishUpload(), not body, json, form, files or duplex options");
|
|
346
449
|
this.ensureOpen();
|
|
@@ -424,6 +527,7 @@ class Session {
|
|
|
424
527
|
return;
|
|
425
528
|
this.cookies.clear();
|
|
426
529
|
this.closed = true;
|
|
530
|
+
this.closing.abort(new errors_js_1.AbortError("session is closed"));
|
|
427
531
|
try {
|
|
428
532
|
for (const transfer of this.active)
|
|
429
533
|
transfer.close(new errors_js_1.FastlsError("session is closed"));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastls/fastls",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Node.js bindings for the Fastls C ABI",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -35,10 +35,10 @@
|
|
|
35
35
|
"registry": "https://registry.npmjs.org/"
|
|
36
36
|
},
|
|
37
37
|
"optionalDependencies": {
|
|
38
|
-
"@fastls/fastls-win32-x64": "0.
|
|
39
|
-
"@fastls/fastls-linux-x64-glibc": "0.
|
|
40
|
-
"@fastls/fastls-linux-arm64-glibc": "0.
|
|
41
|
-
"@fastls/fastls-darwin-x64": "0.
|
|
42
|
-
"@fastls/fastls-darwin-arm64": "0.
|
|
38
|
+
"@fastls/fastls-win32-x64": "0.12.0",
|
|
39
|
+
"@fastls/fastls-linux-x64-glibc": "0.12.0",
|
|
40
|
+
"@fastls/fastls-linux-arm64-glibc": "0.12.0",
|
|
41
|
+
"@fastls/fastls-darwin-x64": "0.12.0",
|
|
42
|
+
"@fastls/fastls-darwin-arm64": "0.12.0"
|
|
43
43
|
}
|
|
44
44
|
}
|