@fastls/fastls 0.11.0 → 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 CHANGED
@@ -1,9 +1,14 @@
1
1
  # Fastls Node.js SDK
2
2
 
3
- 开发候选新增 Session `addressPolicy`、`profileSpec: new ProfileSpec(...)` 和
3
+ Node 0.11.0 起支持 Session `addressPolicy`、`profileSpec: new ProfileSpec(...)` 和
4
4
  `httpVersion: "h3"`;Session/fetch 可选择完整 Spec 或显式协议。
5
5
  `addressPolicy` 只能创建可信 owner,不能由 `session.fetch()` 或请求替换。
6
- 现有发布库不具备新能力,旧库或缺符号会明确报错,见[能力契约](../CAPABILITIES.md)。
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)。
7
12
 
8
13
  ```typescript
9
14
  import { Session, ProfileSpec } from '@fastls/fastls';
@@ -16,7 +21,7 @@ const h3 = new Session({ httpVersion: 'h3' });
16
21
  try { await h3.get('https://example.com'); } finally { await h3.closeAsync(); }
17
22
  ```
18
23
 
19
- Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明、ESM 和 CommonJS 入口。本文对应 **0.10.1**。
24
+ Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明、ESM 和 CommonJS 入口。本文对应 **0.12.0**。
20
25
 
21
26
  支持 HTTP/1.1、HTTP/2、流式上传下载、双工、SSE、WebSocket、会话 Cookie 和 HTTP/HTTPS/SOCKS 代理。网络请求由 Go 执行,通过 Koffi 在当前 Node 进程中调用;使用者无需 Python 或 RPC 服务。
22
27
 
@@ -32,7 +37,7 @@ Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明
32
37
  安装指定版本:
33
38
 
34
39
  ```sh
35
- npm install @fastls/fastls@0.10.1
40
+ npm install @fastls/fastls@0.12.0
36
41
  ```
37
42
 
38
43
  主包通过同版本的可选依赖自动安装当前平台的 Fastls 原生库。保留 optional dependencies;支持 Windows x64、Linux glibc x64/arm64、macOS x64/arm64。Linux musl 和其它架构没有内置包。具体系统最低版本见原生包的 `native/manifest.json`。
package/dist/fetch.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Session, type RequestOptions } from "./session.js";
2
2
  import type { ProfileSpec, AddressPolicy } from "./profiles.js";
3
3
  export interface FetchOptions extends RequestInit {
4
+ retry?: RequestOptions["retry"];
5
+ bodyFactory?: RequestOptions["bodyFactory"];
4
6
  profileSpec?: ProfileSpec;
5
7
  httpVersion?: "auto" | "h3";
6
8
  /** Only standalone fetch can create a new trusted policy owner. */
package/dist/fetch.js CHANGED
@@ -11,7 +11,7 @@ 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", "profileSpec", "httpVersion", "addressPolicy", "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
15
  if (!owned && init.addressPolicy !== undefined)
16
16
  throw new TypeError("addressPolicy is a Session creation option");
17
17
  for (const key of Object.keys(init))
@@ -20,6 +20,10 @@ async function fetchWithSession(session, input, init = {}, owned = false) {
20
20
  // Request performs the platform's normal URL/method/header validation and
21
21
  // serializes URLSearchParams, Blob and FormData without a second encoder.
22
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");
23
27
  const headers = Object.fromEntries(request.headers);
24
28
  let body;
25
29
  if (request.body) {
@@ -31,7 +35,8 @@ async function fetchWithSession(session, input, init = {}, owned = false) {
31
35
  body = node_stream_1.Readable.fromWeb(request.body);
32
36
  }
33
37
  const stream = await session.stream(request.method, request.url, {
34
- headers, body, profile: init.profile, profileSpec: init.profileSpec, httpVersion: init.httpVersion, proxy: init.proxy, proxyCaFile: init.proxyCaFile,
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,
35
40
  timeoutMs: init.timeoutMs, maxRedirects: init.maxRedirects,
36
41
  allowRedirects: request.redirect === "follow", signal: request.signal,
37
42
  });
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
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";
2
5
  export { ProfileSpec } from "./profiles.js";
3
6
  export type { AddressPolicy } from "./profiles.js";
4
7
  export { fetch } from "./fetch.js";
package/dist/index.js CHANGED
@@ -1,8 +1,13 @@
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.ProfileSpec = 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; } });
6
11
  var profiles_js_1 = require("./profiles.js");
7
12
  Object.defineProperty(exports, "ProfileSpec", { enumerable: true, get: function () { return profiles_js_1.ProfileSpec; } });
8
13
  var fetch_js_1 = require("./fetch.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, ProfileSpec, 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;
@@ -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,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
@@ -8,7 +8,10 @@ import { type QueryParams, type JSONValue, type FormFields, type MultipartFiles
8
8
  import { WebSocket, type WebSocketOptions } from "./websocket.js";
9
9
  import { type FetchOptions, type FetchResponse } from "./fetch.js";
10
10
  import { ProfileSpec, type AddressPolicy } from "./profiles.js";
11
+ import { RetryPolicy } from "./retry.js";
11
12
  export interface RequestOptions {
13
+ retry?: RetryPolicy | null;
14
+ bodyFactory?: () => UploadBody;
12
15
  profileSpec?: ProfileSpec;
13
16
  httpVersion?: "auto" | "h3";
14
17
  /** Undefined inherits the session proxy; null or an empty string connects directly. */
@@ -35,6 +38,7 @@ export interface RequestOptions {
35
38
  }
36
39
  export type DuplexOptions = Omit<RequestOptions, "body" | "duplex" | "json" | "form" | "files">;
37
40
  export interface SessionOptions {
41
+ retry?: RetryPolicy;
38
42
  addressPolicy?: AddressPolicy;
39
43
  profileSpec?: ProfileSpec;
40
44
  httpVersion?: "auto" | "h3";
@@ -85,6 +89,8 @@ export declare class Session {
85
89
  private readonly websockets;
86
90
  private readonly producers;
87
91
  private closed;
92
+ private readonly closing;
93
+ private readonly retry?;
88
94
  private notifier?;
89
95
  private setup?;
90
96
  constructor(options?: SessionOptions);
package/dist/session.js CHANGED
@@ -16,6 +16,7 @@ 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
18
  const profiles_js_1 = require("./profiles.js");
19
+ const retry_js_1 = require("./retry.js");
19
20
  function decoding(decode, maximum) {
20
21
  if (typeof decode !== "boolean")
21
22
  throw new TypeError("decodeContent must be a boolean");
@@ -79,9 +80,14 @@ class Session {
79
80
  websockets = new Set();
80
81
  producers = new Set();
81
82
  closed = false;
83
+ closing = new AbortController();
84
+ retry;
82
85
  notifier;
83
86
  setup;
84
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;
85
91
  this.proxy = proxyValue(options.proxy);
86
92
  this.proxyCaPem = proxyTrust(options.proxyCaFile);
87
93
  const notifications = options.notifications === undefined ? "auto" : options.notifications;
@@ -324,9 +330,24 @@ class Session {
324
330
  }
325
331
  }
326
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]);
327
346
  const headers = mergeHeaders(this.headers, options.headers);
328
347
  const prepared = (0, request_options_js_1.prepare)(url, options, headers);
329
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.
330
351
  options = { ...options, headers, body: prepared.body };
331
352
  for (const key of ["params", "json", "form", "files"])
332
353
  delete options[key];
@@ -335,14 +356,37 @@ class Session {
335
356
  throw new RangeError("maxRedirects must be a non-negative integer");
336
357
  const policy = options.timeoutMs === undefined ? this.timeouts : (0, timeout_js_1.timeoutFields)(options.timeoutMs);
337
358
  const deadline = performance.now() + policy.timeout_ms;
338
- for (let redirects = 0;; redirects++) {
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();
339
366
  const remaining = Math.ceil(deadline - performance.now());
340
367
  if (remaining <= 0)
341
368
  throw new errors_js_1.FastlsTimeoutError("total deadline exceeded", "total");
342
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 };
343
- const response = await this.streamHop(method, url, options);
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
+ }
344
388
  const location = response.headers.get("location");
345
- if (options.duplex || options.allowRedirects === false || ![301, 302, 303, 307, 308].includes(response.status) || !location || ([307, 308].includes(response.status) && (0, upload_js_1.isStreamingBody)(options.body)))
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)))
346
390
  return response;
347
391
  try {
348
392
  if (redirects >= maximum)
@@ -358,10 +402,12 @@ class Session {
358
402
  method = "GET";
359
403
  delete options.body;
360
404
  delete options.trailers;
405
+ factory = undefined;
361
406
  for (const name of ["content-type", "content-length", "transfer-encoding"])
362
407
  delete headers[name];
363
408
  }
364
409
  url = target.href;
410
+ redirects++;
365
411
  await response.read();
366
412
  }
367
413
  finally {
@@ -396,6 +442,8 @@ class Session {
396
442
  }
397
443
  /** Open a manual exchange; send() and response reads can run concurrently. */
398
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");
399
447
  if (["body", "duplex", "json", "form", "files"].some(key => key in options))
400
448
  throw new TypeError("manual duplex uses send() and finishUpload(), not body, json, form, files or duplex options");
401
449
  this.ensureOpen();
@@ -479,6 +527,7 @@ class Session {
479
527
  return;
480
528
  this.cookies.clear();
481
529
  this.closed = true;
530
+ this.closing.abort(new errors_js_1.AbortError("session is closed"));
482
531
  try {
483
532
  for (const transfer of this.active)
484
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.11.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.11.0",
39
- "@fastls/fastls-linux-x64-glibc": "0.11.0",
40
- "@fastls/fastls-linux-arm64-glibc": "0.11.0",
41
- "@fastls/fastls-darwin-x64": "0.11.0",
42
- "@fastls/fastls-darwin-arm64": "0.11.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
  }