@fastls/fastls 0.10.1-dev.0 → 0.10.2
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 +68 -69
- package/dist/cookies.d.ts +9 -2
- package/dist/cookies.js +53 -10
- package/dist/fetch.d.ts +20 -0
- package/dist/fetch.js +113 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.mjs +1 -0
- package/dist/native.js +3 -0
- package/dist/response.d.ts +5 -1
- package/dist/response.js +13 -2
- package/dist/session.d.ts +9 -0
- package/dist/session.js +89 -24
- package/dist/websocket.js +2 -1
- package/package.json +8 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Fastls Node.js SDK
|
|
2
2
|
|
|
3
|
-
Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明、ESM 和 CommonJS
|
|
3
|
+
Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明、ESM 和 CommonJS 入口。本文对应 **0.10.1**。
|
|
4
4
|
|
|
5
5
|
支持 HTTP/1.1、HTTP/2、流式上传下载、双工、SSE、WebSocket、会话 Cookie 和 HTTP/HTTPS/SOCKS 代理。网络请求由 Go 执行,通过 Koffi 在当前 Node 进程中调用;使用者无需 Python 或 RPC 服务。
|
|
6
6
|
|
|
@@ -13,10 +13,10 @@ Node.js 22+ 的 Fastls C ABI v1 绑定,提供 Promise API、TypeScript 声明
|
|
|
13
13
|
|
|
14
14
|
## 安装与快速开始
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
安装指定版本:
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
npm install @fastls/fastls@
|
|
19
|
+
npm install @fastls/fastls@0.10.1
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
主包通过同版本的可选依赖自动安装当前平台的 Fastls 原生库。保留 optional dependencies;支持 Windows x64、Linux glibc x64/arm64、macOS x64/arm64。Linux musl 和其它架构没有内置包。具体系统最低版本见原生包的 `native/manifest.json`。
|
|
@@ -42,24 +42,57 @@ try {
|
|
|
42
42
|
|
|
43
43
|
后续片段假定 `session` 已创建且未关闭;`consume`、`produceBytes` 等是应用自行实现的业务函数。每次实际使用均应关闭会话和未消费完的响应。
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
源码构建、测试和发包见 [开发指南](DEVELOPMENT.md)。普通 npm 用户不需要这些步骤。
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
47
|
+
## 设置浏览器请求头
|
|
48
|
+
|
|
49
|
+
Python 0.1.4 / Node 0.10.1 所用的默认 `chrome/156/windows` 配置没有为 `Accept-Encoding`、`Accept-Language` 和 `Priority` 设置默认值。下面的示例显式补充它们;其它配置的默认值可能不同。
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { fetch } from '@fastls/fastls';
|
|
53
|
+
|
|
54
|
+
const response = await fetch('https://tls.peet.ws/api/all', {
|
|
55
|
+
profile: 'chrome/156/windows',
|
|
56
|
+
headers: {
|
|
57
|
+
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
|
58
|
+
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
|
|
59
|
+
'Priority': 'u=0, i',
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
63
|
+
console.log(await response.json());
|
|
52
64
|
```
|
|
53
65
|
|
|
54
|
-
|
|
66
|
+
`Accept-Language` 表示语言偏好,请按应用需要调整;`Priority` 的值也与请求场景有关,示例不是所有浏览器请求的固定模板。请求头顺序配置只排列已有字段,不会为其中的字段自动生成值。支持自动解压 gzip/deflate/br/zstd,不等于会自动发送对应的 `Accept-Encoding`。显式传入的头值优先于配置默认值;复用会话时,也可通过 Session 的 `headers` 设置。
|
|
55
67
|
|
|
56
|
-
|
|
68
|
+
## 从 Node fetch 迁移
|
|
57
69
|
|
|
70
|
+
新增 `fetch` 入口沿用 Node 原生 fetch 的主要调用习惯,并增加指纹、代理和证书配置:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { fetch } from '@fastls/fastls';
|
|
74
|
+
|
|
75
|
+
const response = await fetch('https://tls.peet.ws/api/all', {
|
|
76
|
+
profile: 'chrome/156/windows',
|
|
77
|
+
headers: new Headers({ Accept: 'application/json' }),
|
|
78
|
+
signal: AbortSignal.timeout(10_000),
|
|
79
|
+
});
|
|
80
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
81
|
+
console.log(await response.json());
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
接受字符串、URL、Request,以及标准 Headers、FormData、URLSearchParams、Blob、字节和可读流正文。流式请求使用 `duplex: 'half'`,一次性正文不会为 307/308 自动重放。返回 Node 标准 `Response`,在响应头到达时返回;`text/json/arrayBuffer()` 为异步方法,`body` 为 Web ReadableStream,`ok` 为 200–299。正文只消费一次;不用时必须 `await response.body?.cancel()`。另附只读 `match` 和 `httpVersion`。
|
|
85
|
+
|
|
86
|
+
顶层 `fetch` 与 Node 一样不保存 Cookie,每次请求的临时资源会在正文读完或取消后关闭。需要 Cookie 和连接长期复用时,使用 `session.fetch(url, options)`,最后 `await session.closeAsync()`。会话级 `library/caFile` 应在构造 Session 时设置。HTTP 错误状态仍返回响应;`redirect` 支持 `follow/manual/error`。默认最多跟随 30 次,可设置 `maxRedirects`。
|
|
87
|
+
|
|
88
|
+
这不是浏览器环境:不实现 CORS、缓存、自动认证、referrer、integrity 或 keepalive 策略。显式传入 `cache/credentials/integrity/keepalive/mode/referrer/referrerPolicy` 会报错。默认校验 TLS,不支持关闭验证;不接收 Node http/https Agent、dispatcher 或 Axios 配置。超时以毫秒计,使用 `timeoutMs` 或 AbortSignal。
|
|
89
|
+
|
|
90
|
+
Fastls 原有 `Session.request/stream/duplex/sse/websocket` 保留,适合需要分阶段超时、原始字节、Trailer 和更精细资源控制的场景。Cookie、重定向和默认参数由 JavaScript SDK 管理;动态库负责网络传输、指纹、证书与连接池。旧 C Cookie 接口仅供旧客户端兼容,新 SDK 不使用原生 Cookie 存储。
|
|
58
91
|
|
|
59
92
|
## 请求和响应
|
|
60
93
|
|
|
61
|
-
- `await session.request(method, url, options)`,以及 `get
|
|
62
|
-
- 请求选项包括 `profile`、`headers`、`trailers`、`params`、`json`、`form`、`files`、`body`、`timeoutMs`、`allowRedirects`、`signal`、`proxy`、`proxyCaFile`、`decodeContent`、`maxDecodedBytes`、`duplex`。会话级选项还包括 `library`、`caFile`、`cookies`、`notifications`;以公开 TypeScript 类型为准。
|
|
94
|
+
- `await session.request(method, url, options)`,以及 `get/post/put/patch/delete/head/options`,返回完整缓存的 Response。
|
|
95
|
+
- 请求选项包括 `profile`、`headers`、`trailers`、`params`、`json`、`form`、`files`、`body`、`timeoutMs`、`allowRedirects`、`maxRedirects`、`signal`、`proxy`、`proxyCaFile`、`decodeContent`、`maxDecodedBytes`、`duplex`。会话级选项还包括 `library`、`caFile`、`cookies`、`notifications`;以公开 TypeScript 类型为准。
|
|
63
96
|
- `body` 为字符串、Buffer、Uint8Array、同步/异步字节迭代器、Node Readable 或接收 AbortSignal 的生产者工厂。超过512 KiB的字符串/字节自动使用上传通道。
|
|
64
97
|
- `timeoutMs` 为毫秒整数或 `{ total, connect?, write?, headers?, read? }`,每项范围1..86400000;默认总时限30000。请求级数字覆盖会清除会话分层限制,结构化覆盖替换整份策略。
|
|
65
98
|
- 会话默认头和请求头按大小写不敏感方式合并,请求值优先,包括空字符串。
|
|
@@ -76,7 +109,7 @@ Windows 测试服务文件名使用 `build/ffi-fixture.exe`,原生构建需要
|
|
|
76
109
|
|
|
77
110
|
未知 profile 报错;Match 保留生成身份和协议基线信息,不承诺与真实浏览器指纹完全等价。
|
|
78
111
|
|
|
79
|
-
`request('PUT', url, options)`
|
|
112
|
+
`request('PUT', url, options)` 等可使用其它合法方法;便捷方法包括 `get/post/put/patch/delete/head/options`。请求头的值为字符串;`trailers` 的值为字符串或只读字符串数组,名称和值由原生层继续验证,且要求 `request_trailers` 能力。
|
|
80
113
|
|
|
81
114
|
`url` 是最终响应 URL,`httpVersion` 是实际 HTTP 协议版本。缓存响应已经读到 EOF,包含最终 `trailers`;流式响应先消费正文,再读取最终尾部字段。提前关闭不代表已经收到全部 trailers。
|
|
82
115
|
|
|
@@ -113,7 +146,7 @@ try {
|
|
|
113
146
|
- JSON/表单在内存中编码;超过512KiB自动转为流式上传,不是大型数据增量编码器。默认Content-Type仅在没有会话/请求自定义头时设置;编码正文拒绝Content-Length/Transfer-Encoding。原始body沿用原有规则。
|
|
114
147
|
- `files`接受对象、Map或重复字段对数组。每个文件是Uint8Array、`MultipartReader`,或`[filename, body, contentType?]`;filename为null省略filename参数。默认从读取器name/字段名取文件名;内置固定MIME推断只覆盖txt/json/html/csv/png/jpg/jpeg/pdf,其它类型请显式指定,缺省application/octet-stream。不支持自定义分部头。
|
|
115
148
|
- multipart可配合form字段,每次read最多64KiB,空Uint8Array表示EOF;支持同步/Promise读取器。无需seek/长度/临时文件,不全量缓存文件;调用方负责close和重新定位,成功、失败、取消均保留读取器所有权。提供给read的AbortSignal可中断自定义等待;不可取消的Promise会延后closeAsync清理。Node Readable及FileHandle不能直接作为此读取器,应适配其读取接口;现有原始body的Readable销毁规则不变。
|
|
116
|
-
- multipart管理随机boundary及Content-Type,拒绝会话或请求中显式Content-Type;元数据拒绝控制字符。正文仅使用一次,307/308不自动重放,301/302/303
|
|
149
|
+
- multipart管理随机boundary及Content-Type,拒绝会话或请求中显式Content-Type;元数据拒绝控制字符。正文仅使用一次,307/308不自动重放,301/302/303按 SDK 规则转换方法和丢弃正文。
|
|
117
150
|
- request/get/post/stream/sse与自动duplex共享参数处理;multipart不支持duplex。手动duplex只接受params,正文继续send()/finishUpload()。自动双工支持JSON/表单且关闭重定向。
|
|
118
151
|
|
|
119
152
|
## 流式下载和取消
|
|
@@ -150,7 +183,7 @@ try {
|
|
|
150
183
|
|
|
151
184
|
Session 复用原生会话的 H1 keep-alive 和 H2 多路复用;不同 Session 隔离。
|
|
152
185
|
`session.closeIdleConnections()` 释放空闲连接,保留 Cookie。
|
|
153
|
-
Cookie 自动管理和重定向处理均由
|
|
186
|
+
Cookie 自动管理和重定向处理均由 JavaScript SDK 完成;原生上下文只负责传输:
|
|
154
187
|
|
|
155
188
|
```js
|
|
156
189
|
session.cookies.set('token', 'value', { url: 'https://example.com', path: '/', secure: true });
|
|
@@ -180,22 +213,16 @@ try {
|
|
|
180
213
|
} finally { await session.closeAsync(); }
|
|
181
214
|
```
|
|
182
215
|
|
|
183
|
-
Session和请求的`proxy`接受URL字符串或null。请求省略或undefined继承会话默认值;null或空字符串直连。普通请求、stream、sse、自动/手动duplex遵循相同规则。传入其它类型会报TypeError。显式代理要求原生`proxy_tunnels
|
|
216
|
+
Session和请求的`proxy`接受URL字符串或null。请求省略或undefined继承会话默认值;null或空字符串直连。普通请求、stream、sse、自动/手动duplex遵循相同规则。传入其它类型会报TypeError。显式代理要求原生`proxy_tunnels`能力;当前 SDK 始终要求 transport_context_v1;缺少代理能力时,代理请求在登记句柄前报错。
|
|
184
217
|
|
|
185
218
|
`http://`对HTTP和HTTPS目标均使用HTTP/1.1 CONNECT,默认端口80。`socks5://`本机解析目标域名,`socks5h://`交给代理解析,默认端口1080。支持HTTP Basic和SOCKS用户名/密码认证,凭据按URL规则转义,例如密码中的@写成%40。不读取HTTP_PROXY、HTTPS_PROXY、ALL_PROXY、NO_PROXY及其小写形式。`https://`在代理TLS中运行HTTP/1.1 CONNECT,默认端口443,证书和主机名验证始终开启,不回退到明文。HTTPS代理另要求原生`https_proxy_tls`能力;暂不支持SOCKS4或PAC,详情见上级PROXY.md。
|
|
186
219
|
|
|
187
220
|
`proxyCaFile`只控制HTTPS代理信任,`caFile`只控制目标信任,两者互不代替。未设置代理CA使用系统根;提供文件则替换系统根。构造Session时读取默认代理CA一次,请求指定路径时重新读取;请求undefined继承默认,null恢复系统根。直连、HTTP和SOCKS忽略代理CA默认值及请求覆盖。代理地址及凭据、profile、代理CA和目标CA共同隔离连接池。目标证书仍需验证;目标请求禁止设置Proxy-Authorization和Proxy-Connection。认证只用于代理握手,错误消息不回显凭据或代理任意响应短语。握手计入connect和total预算,AbortSignal取消会关闭未建立的隧道;取消单条H2流继续复用健康的共享隧道。代理拒绝后不降级或自动重试。
|
|
188
221
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
`npm run test:proxy:soak`默认以32路并发运行60秒,轮换正常请求、共享H2流取消、错误认证和停滞握手取消;四种scheme分别使用真实loopback代理。FASTLS_NODE_PROXY_SOAK_SECONDS可设为1..300。Linux当前Go/Node组合执行该独立测试,沿用现有RSS、Go堆、goroutine及句柄阈值,逐批记录写入build/node-proxy-recovery-*.json,不计入225项主套件。
|
|
192
|
-
|
|
193
|
-
代理独立长测与SSE长测一样,显式使用`--max-semi-space-size=16`固定测试进程的V8半空间上限。本机Windows Node24默认策略在持续分配时将new_space保留空间从4MiB扩大至128MiB,而Go堆、原生请求和隧道已回收。该参数仅作用于独立测试进程,不修改SDK用户进程的运行配置,也不放宽RSS或句柄阈值。证据保存运行参数、V8空间快照,以及阈值判定之后的完整GC诊断;判定使用GC前的最终采样。
|
|
222
|
+
代理测试和资源长测的运行方式见 [开发说明](https://github.com/FastTLS/fastls-ffi/blob/main/node/DEVELOPMENT.md#代理长测)。
|
|
194
223
|
|
|
195
224
|
私有HTTPS代理示例:`new Session({ proxy: 'https://proxy.example:443', proxyCaFile: '/path/to/proxy-ca.pem', caFile: '/path/to/target-ca.pem' })`。两类CA分别验证代理与目标;请求可用`{ proxyCaFile: null }`恢复代理系统信任,或指定其它代理CA文件,均不会复用不相符的已有隧道。
|
|
196
225
|
|
|
197
|
-
代理长测按靶场握手阶段同步取消,避免固定毫秒计时在慢runner上提前中止TLS而未触达待测CONNECT阶段。HTTPS靶场延迟50毫秒处理CONNECT,确保该时序在本地也能覆盖。H2可合并同一代理的在途建连;每批每种scheme至少触发一次真实握手取消,并逐批核对启动/回收计数,不依赖请求数等于物理建连数。资源阈值保持不变。
|
|
198
|
-
|
|
199
226
|
## HTTP/2 双工
|
|
200
227
|
|
|
201
228
|
`duplex: true` 允许响应头到达后继续上传,可同时读取响应。仅支持 HTTP/2,并始终禁止自动重定向;普通上传仍拒绝未完成上传时提前返回的 2xx。
|
|
@@ -299,17 +326,17 @@ send/ping/pong等到原生写入完成;同时最多一名读者和一名写入
|
|
|
299
326
|
|
|
300
327
|
正常远端关闭抛出WebSocketClosed(code/reason),连接保留closeCode/closeReason。closeAsync的整个握手受closeTimeoutMs约束(最多60000),超时直接释放;abort立即中断。连接级AbortSignal中断当前及后续操作,Session关闭回收全部所属WS。读/写/握手超时使连接终止,FastlsTimeoutError.phase为read/write/handshake;HTTP会话总超时不限定WS寿命。
|
|
301
328
|
|
|
302
|
-
复用Session的profile、头、CA、代理与代理CA;params、请求级覆盖/显式直连沿用HTTP规则。wss始终验证证书和主机名,ALPN适配HTTP/1.1;不承诺适配后的Upgrade头序与浏览器完全一致。使用websocket_v1和三个可选ABI
|
|
329
|
+
复用Session的profile、头、CA、代理与代理CA;params、请求级覆盖/显式直连沿用HTTP规则。wss始终验证证书和主机名,ALPN适配HTTP/1.1;不承诺适配后的Upgrade头序与浏览器完全一致。使用websocket_v1和三个可选ABI符号,当前 SDK 还要求 transport_context_v1;ABI 号相同不能代替能力检查。通知默认共享一条会话连接,poll可显式选择,不阻塞主线程或增加网络线程。
|
|
303
330
|
|
|
304
|
-
默认与HTTP共享Cookie
|
|
331
|
+
默认与 HTTP 共享 SDK Cookie 罐;原生库不保存 Cookie。cookieMode:"manual" 可禁用本次握手的自动 Cookie 收发。显式Cookie头(包括空值)优先,默认仍接受握手Set-Cookie;Session的cookies:false禁用自动收发。域/路径/Secure/过期规则与HTTP一致。有效101及普通HTTP拒绝/重定向响应可写回;无效101或TLS失败不写回。Authorization/Origin由调用方设置。无压缩、H2/H3 CONNECT、握手重定向、自动心跳/重连或消息重放。ping只证明写入完成,可用info.websocket.pongs/last_pong_base64观察响应。协议细节与原生资源限制见根目录WEBSOCKET.md。
|
|
305
332
|
|
|
306
|
-
npm run test:quick先运行类型、清单及WS/原生用例;test:ws针对25项WS和16项共享Cookie;test:ws:soak运行独立60秒/32路资源回收。快速反馈不能替代
|
|
333
|
+
npm run test:quick先运行类型、清单及WS/原生用例;test:ws针对25项WS和16项共享Cookie;test:ws:soak运行独立60秒/32路资源回收。快速反馈不能替代231项源码/安装包完整回归与六组平台验收。
|
|
307
334
|
|
|
308
|
-
|
|
335
|
+
0.10.1 SDK 使用不含 Cookie 的原生传输上下文,并在 JavaScript 中完成 Cookie 选择及响应更新。旧原生 Cookie 能力只为历史客户端保留。npm run test:compat 验证当前完整库的收发、显式头和失败握手。
|
|
309
336
|
|
|
310
337
|
## 原生就绪通知
|
|
311
338
|
|
|
312
|
-
默认使用`notifications: 'auto'`:新库带session_notifications
|
|
339
|
+
默认使用`notifications: 'auto'`:新库带session_notifications能力且符号完整时启用,缺少通知能力时轮询,但库仍须满足 transport_context_v1 等 SDK 基础能力。`required`缺少能力时在创建原生会话前报错;`poll`显式使用原调度器。
|
|
313
340
|
|
|
314
341
|
```js
|
|
315
342
|
const session = await Session.open({ notifications: 'required' });
|
|
@@ -332,55 +359,27 @@ try {
|
|
|
332
359
|
网络由 Go goroutine 执行。新库默认通过每会话一条本机TCP连接接收就绪提示,Node事件循环随后重查非阻塞C接口,不定时查询空闲请求。
|
|
333
360
|
通知是重查提示,不保证读写一定成功;通过代次快照防止漏唤醒,并独立取消双工的读写等待。
|
|
334
361
|
立即调用会让出事件循环;不承诺固定吞吐量或延迟,原生控制调用及JSON/Buffer转换仍在主线程执行。
|
|
335
|
-
显式poll
|
|
362
|
+
显式 poll 或配套库缺少通知能力时使用共享约 10ms 定时器,保留每轮处理数量及时间上限。
|
|
336
363
|
|
|
337
364
|
句柄使用 bigint,无损解析和序列化 ABI JSON 数字;返回的 C 字符串逐次由 fastls_free 释放。
|
|
338
|
-
外部动态库基础能力要求为 `stream_decode`、`phase_timeouts`、`stream_upload`、`write_timeout`;双工、代理、WebSocket 和请求 trailers 在调用时另检查对应能力。加载时检查 ABI 及所需能力,并使用系统级固定加载(Windows PIN / POSIX NODELETE),避免Node环境清理时卸载仍有Go后台线程的动态库。
|
|
365
|
+
外部动态库基础能力要求为 `transport_context_v1`、`stream_decode`、`phase_timeouts`、`stream_upload`、`write_timeout`;双工、代理、WebSocket 和请求 trailers 在调用时另检查对应能力。加载时检查 ABI 及所需能力,并使用系统级固定加载(Windows PIN / POSIX NODELETE),避免Node环境清理时卸载仍有Go后台线程的动态库。
|
|
339
366
|
原生限制仍适用:最多 64 会话、256 未关闭请求,每会话最多 16 种传输配置。
|
|
340
|
-
代理支持HTTP/HTTPS CONNECT、socks5及socks5h
|
|
341
|
-
|
|
342
|
-
## 候选包与原生库加载
|
|
343
|
-
|
|
344
|
-
构建本平台Go动态库后,运行 `npm run pack:native`,产物位于上级 `dist/`。
|
|
345
|
-
例如Windows x64:`fastls-fastls-0.10.1-dev.0-win32-x64.tgz`;Linux包名另带`-glibc`。
|
|
346
|
-
在目标项目执行 `npm install <候选包绝对路径> --ignore-scripts`,随后即可 `new Session()`,无需Go、Python、编译器或FASTLS_LIBRARY。
|
|
347
|
-
安装时保留npm可选依赖,Koffi使用对应平台的预编译组件。`npm run test:package`在空工具搜索路径、清除FASTLS_LIBRARY且关闭安装脚本的临时项目中重复完整225项测试。靶场是仓库中已构建的独立测试服务,SDK和动态库均从安装目录加载。
|
|
348
|
-
|
|
349
|
-
当前候选覆盖Windows x64、Linux glibc x64/arm64、macOS x64/arm64,Node22+。每个tarball只含一个系统/架构,不能将同名版本的这些变体当成可直接发布到npm的通用包。npm的os/cpu/libc字段限制安装目标;首次使用还检查平台、ABI声明、库SHA256及Linux的glibc版本。Linux最低glibc从ELF符号版本提取,macOS最低版本从Mach-O提取,具体要求及系统动态依赖见包内`native/manifest.json`,不将CI宿主机上的通过扩大为所有旧系统均已测试。musl不在当前范围。
|
|
350
|
-
|
|
351
|
-
加载顺序:显式 `SessionOptions.library` → `FASTLS_LIBRARY` → 包内 `native/manifest.json` 对应的库 → 已安装主包的本平台可选依赖;源码目录没有内置 manifest 时使用仓库 `dist/`。已安装的 thin 包没有仓库回退。可选原生包必须与主包同版本;缺包、错误平台、glibc 不兼容或摘要不一致均报错。显式路径覆盖仍检查 ABI 与必需能力。SHA-256 只验证内容完整性,不能替代来源签名。
|
|
352
|
-
|
|
353
|
-
清单还保存Go构建来源摘要、模块版本/校验和、FastTLS仓库提交和收集到的许可证文本。`native/THIRD_PARTY_NOTICES.txt`仅是可用材料清单。依赖许可证按实际构建输入收集;imitate已补齐Apache-2.0许可证,npm发布要求所有平台的licenseReviewRequired均为空。GitHub内置库候选保持private且没有安装/构建脚本;npm主包与平台包的发布流程见NPM_RELEASE.md。
|
|
354
|
-
|
|
355
|
-
npm 拆分包通过 `npm run test:package` 后再执行 `npm run test:npm` 生成,主包与本机原生包位于 `dist/npm/`。五个同名同版本的 GitHub 内置库候选不能作为五个 npm 版本发布。首次 registry 发布必须使用同提交 main CI 汇总得到的六包组,详见 NPM_RELEASE.md。
|
|
356
|
-
|
|
357
|
-
## 验证
|
|
358
|
-
|
|
359
|
-
所有命令从仓库根目录执行,先完成动态库和测试服务构建。
|
|
360
|
-
|
|
361
|
-
```sh
|
|
362
|
-
npm run test:quick --prefix node
|
|
363
|
-
npm test --prefix node
|
|
364
|
-
npm run test:package --prefix node
|
|
365
|
-
npm run test:npm --prefix node
|
|
366
|
-
python node/scripts/test-npm-release.py
|
|
367
|
-
```
|
|
368
|
-
|
|
369
|
-
`test:quick` 提供类型、映射、原生绑定、WebSocket 和 Cookie 兼容的短反馈。`npm test`、`test:package`、`test:npm` 分别运行源码、内置库候选、npm 拆分包的完整 225 项套件:十五组 Python 的 209 项行为映射,加 2 项绑定和 14 项 Node 专有测试。类型、Cookie 真实库兼容、安装元数据和发布边界检查另计;当前发布边界测试为 9 项。
|
|
370
|
-
|
|
371
|
-
两类安装测试都在独立消费项目运行,关闭安装脚本、清除外部库环境变量并移除编译工具搜索路径。拆分包测试由临时 registry 提供尚未发布的本项目平台包,npm 自行按平台筛选;其它依赖仍使用正常 registry。
|
|
372
|
-
|
|
373
|
-
主 CI 覆盖五个平台的 Node 24,以及 Linux x64 的 Node 22 组合,复用固定的原生构建输入。源码证据位于 `build/node-*.json`;内置候选和摘要在 `dist/`;拆分包、`npm-package-info.json` 和独立安装证据在 `dist/npm/`。具体映射、资源阈值及长测命令见 PARITY.md。功能检查和资源长测分开执行,不把清单存在或历史绿色结果当作新提交已通过。
|
|
367
|
+
代理支持 HTTP/HTTPS CONNECT、socks5 及 socks5h,见代理章节;WebSocket见对应章节。
|
|
374
368
|
|
|
375
369
|
## 与 Python 的语言差异
|
|
376
370
|
|
|
377
371
|
Node统一使用Promise API,不提供Python同步Session或共享同步会话多线程调用;对应场景采用同步迭代器输入和96路Promise请求验证,不宣称线程安全。Node Readable按自身流的生命周期管理文件句柄,区别于Python保留调用方文件对象。
|
|
378
372
|
JavaScript无法强行中断任意Promise:取消会立即释放原生请求,并请求迭代器return/destroy;生产者正在等待的自定义I/O必须支持取消,才能保证其finally及时执行。推荐使用接收AbortSignal的上传工厂。Python的协程强制取消场景对应为工厂信号取消,报告中明确标记适配。
|
|
379
373
|
|
|
380
|
-
##
|
|
374
|
+
## 文档、许可证与使用声明
|
|
375
|
+
|
|
376
|
+
[完整 Node 文档](https://fasttls.github.io/node/) · [Apache-2.0](LICENSE)。原生第三方声明随平台包提供。
|
|
377
|
+
|
|
378
|
+
## 使用边界与免责声明
|
|
379
|
+
|
|
380
|
+
仅在拥有权限的系统、数据和网络上使用,并遵守适用法律、服务条款及隐私要求。软件按许可证约定以“现状”提供,不承诺可用性、特定用途适用性、第三方服务放行、匿名性或与真实浏览器完全一致。浏览器名称和版本用于描述配置,不表示获得相应厂商认证或背书。部署方负责访问控制、密钥和个人信息保护、请求限额及数据备份;担保排除与责任限制以随附许可证及适用法律为准。本说明不改变许可证授予的权利或新增许可限制。
|
|
381
381
|
|
|
382
|
-
维护者在 main 的同提交完整 CI 成功后,运行 `npm-publish.yml` 准备或发布 npm 六包组;首次发布、账号权限、2FA、Trusted Publisher 和重试步骤见 NPM_RELEASE.md。
|
|
383
382
|
|
|
384
|
-
|
|
383
|
+
## 动态库选择与安装排错
|
|
385
384
|
|
|
386
|
-
|
|
385
|
+
加载优先级:显式 SessionOptions.library → FASTLS_LIBRARY → 包内 manifest → 同版本平台可选依赖;源码目录可回退仓库 dist,已安装 thin 包不回退仓库。普通 npm 用户应保留 optionalDependencies,不必设置外部库路径。外部旧库可能覆盖新包,ABI 号相同也不保证扩展能力相同。Linux glibc 与 macOS 最低版本、摘要及动态依赖以 native/manifest.json 为准。更多排错见 [在线帮助](https://fasttls.github.io/help/)。
|
package/dist/cookies.d.ts
CHANGED
|
@@ -13,11 +13,18 @@ export interface Cookie {
|
|
|
13
13
|
value: string;
|
|
14
14
|
}
|
|
15
15
|
export declare class Cookies {
|
|
16
|
-
private readonly
|
|
17
|
-
|
|
16
|
+
private readonly ensureOpen;
|
|
17
|
+
private readonly enabled;
|
|
18
|
+
private readonly jar;
|
|
19
|
+
constructor(ensureOpen: () => void, enabled?: boolean);
|
|
20
|
+
private url;
|
|
18
21
|
getAll(url: string): Cookie[];
|
|
19
22
|
get(name: string, url: string): string | undefined;
|
|
20
23
|
set(name: string, value: string, options: CookieOptions): void;
|
|
21
24
|
delete(name: string, options: Pick<CookieOptions, "url" | "path" | "domain">): void;
|
|
22
25
|
clear(): void;
|
|
26
|
+
/** @internal */
|
|
27
|
+
header(url: string): string;
|
|
28
|
+
/** @internal */
|
|
29
|
+
extract(url: string, fields: string[]): void;
|
|
23
30
|
}
|
package/dist/cookies.js
CHANGED
|
@@ -1,27 +1,70 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.Cookies = void 0;
|
|
4
|
+
const tough_cookie_1 = require("tough-cookie");
|
|
5
|
+
const errors_js_1 = require("./errors.js");
|
|
4
6
|
class Cookies {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
ensureOpen;
|
|
8
|
+
enabled;
|
|
9
|
+
jar = new tough_cookie_1.CookieJar();
|
|
10
|
+
constructor(ensureOpen, enabled = true) {
|
|
11
|
+
this.ensureOpen = ensureOpen;
|
|
12
|
+
this.enabled = enabled;
|
|
13
|
+
}
|
|
14
|
+
url(value) {
|
|
15
|
+
try {
|
|
16
|
+
const url = new URL(value.replace(/^ws:/, "http:").replace(/^wss:/, "https:"));
|
|
17
|
+
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
|
|
18
|
+
throw new Error();
|
|
19
|
+
return url.href;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new errors_js_1.FastlsError("cookie URL must be absolute HTTP(S) without credentials");
|
|
23
|
+
}
|
|
8
24
|
}
|
|
9
25
|
getAll(url) {
|
|
10
|
-
|
|
26
|
+
this.ensureOpen();
|
|
27
|
+
const target = this.url(url);
|
|
28
|
+
return this.enabled ? this.jar.getCookiesSync(target).map(cookie => ({ name: cookie.key, value: cookie.value })) : [];
|
|
11
29
|
}
|
|
12
30
|
get(name, url) {
|
|
13
31
|
return this.getAll(url).find(cookie => cookie.name === name)?.value;
|
|
14
32
|
}
|
|
15
33
|
set(name, value, options) {
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
this.ensureOpen();
|
|
35
|
+
if (!this.enabled)
|
|
36
|
+
throw new errors_js_1.FastlsError("cookie store is disabled");
|
|
37
|
+
if (!/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name) || /[^\x20-\x7e]|[;"\\]/.test(value) || Buffer.byteLength(name + value) > 4096)
|
|
38
|
+
throw new errors_js_1.FastlsError("invalid cookie name or value");
|
|
39
|
+
if (options.path !== undefined && (!options.path.startsWith("/") || /[\x00-\x1f;]/.test(options.path)))
|
|
40
|
+
throw new errors_js_1.FastlsError("invalid cookie path");
|
|
41
|
+
if (options.maxAge !== undefined && (!Number.isInteger(options.maxAge) || options.maxAge < -2147483648 || options.maxAge > 2147483647))
|
|
42
|
+
throw new errors_js_1.FastlsError("invalid cookie maxAge");
|
|
43
|
+
const expires = options.expires === undefined ? undefined : new Date(options.expires);
|
|
44
|
+
if (expires && (!Number.isFinite(expires.getTime()) || !/^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/.test(options.expires)))
|
|
45
|
+
throw new errors_js_1.FastlsError("expires must use RFC3339 format");
|
|
46
|
+
const cookie = new tough_cookie_1.Cookie({ key: name, value, path: options.path, domain: options.domain?.replace(/^\./, ""), secure: options.secure, httpOnly: options.httpOnly, maxAge: options.maxAge, expires });
|
|
47
|
+
try {
|
|
48
|
+
this.jar.setCookieSync(cookie, this.url(options.url));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
throw new errors_js_1.FastlsError("invalid cookie domain or attributes");
|
|
52
|
+
}
|
|
21
53
|
}
|
|
22
54
|
delete(name, options) {
|
|
23
55
|
this.set(name, "", { ...options, maxAge: 0 });
|
|
24
56
|
}
|
|
25
|
-
clear() { this.
|
|
57
|
+
clear() { this.ensureOpen(); this.jar.removeAllCookiesSync(); }
|
|
58
|
+
/** @internal */
|
|
59
|
+
header(url) { return this.getAll(url).map(cookie => `${cookie.name}=${cookie.value}`).join("; "); }
|
|
60
|
+
/** @internal */
|
|
61
|
+
extract(url, fields) {
|
|
62
|
+
this.ensureOpen();
|
|
63
|
+
if (!this.enabled)
|
|
64
|
+
return;
|
|
65
|
+
const target = this.url(url);
|
|
66
|
+
for (const field of fields)
|
|
67
|
+
this.jar.setCookieSync(field, target, { ignoreError: true });
|
|
68
|
+
}
|
|
26
69
|
}
|
|
27
70
|
exports.Cookies = Cookies;
|
package/dist/fetch.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Session, type RequestOptions } from "./session.js";
|
|
2
|
+
export interface FetchOptions extends RequestInit {
|
|
3
|
+
duplex?: "half";
|
|
4
|
+
profile?: string;
|
|
5
|
+
proxy?: string | null;
|
|
6
|
+
proxyCaFile?: string | null;
|
|
7
|
+
caFile?: string;
|
|
8
|
+
library?: string;
|
|
9
|
+
timeoutMs?: RequestOptions["timeoutMs"];
|
|
10
|
+
maxRedirects?: number;
|
|
11
|
+
}
|
|
12
|
+
/** A standard Node Response with Fastls transport metadata. */
|
|
13
|
+
export interface FetchResponse extends globalThis.Response {
|
|
14
|
+
readonly match: Readonly<Record<string, unknown>>;
|
|
15
|
+
readonly httpVersion: string;
|
|
16
|
+
}
|
|
17
|
+
/** @internal Used by Session.fetch; ownership is explicit to avoid closing shared pools. */
|
|
18
|
+
export declare function fetchWithSession(session: Session, input: string | URL | Request, init?: FetchOptions, owned?: boolean): Promise<FetchResponse>;
|
|
19
|
+
/** Fetch-compatible entry point. No persistent cookie jar is created. */
|
|
20
|
+
export declare function fetch(input: string | URL | Request, init?: FetchOptions): Promise<FetchResponse>;
|
package/dist/fetch.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.fetchWithSession = fetchWithSession;
|
|
4
|
+
exports.fetch = fetch;
|
|
5
|
+
const node_stream_1 = require("node:stream");
|
|
6
|
+
const session_js_1 = require("./session.js");
|
|
7
|
+
/** @internal Used by Session.fetch; ownership is explicit to avoid closing shared pools. */
|
|
8
|
+
async function fetchWithSession(session, input, init = {}, owned = false) {
|
|
9
|
+
try {
|
|
10
|
+
for (const key of ["cache", "credentials", "integrity", "keepalive", "mode", "referrer", "referrerPolicy"]) {
|
|
11
|
+
if (init[key] !== undefined)
|
|
12
|
+
throw new TypeError(`Fastls fetch does not support ${key}`);
|
|
13
|
+
}
|
|
14
|
+
const allowed = new Set(["method", "headers", "body", "redirect", "signal", "duplex", "profile", "proxy", "proxyCaFile", "caFile", "library", "timeoutMs", "maxRedirects"]);
|
|
15
|
+
for (const key of Object.keys(init))
|
|
16
|
+
if (!allowed.has(key))
|
|
17
|
+
throw new TypeError(`Fastls fetch does not support ${key}`);
|
|
18
|
+
// Request performs the platform's normal URL/method/header validation and
|
|
19
|
+
// serializes URLSearchParams, Blob and FormData without a second encoder.
|
|
20
|
+
const request = new Request(input, init);
|
|
21
|
+
const headers = Object.fromEntries(request.headers);
|
|
22
|
+
let body;
|
|
23
|
+
if (request.body) {
|
|
24
|
+
if (typeof init.body === "string" || init.body instanceof Uint8Array)
|
|
25
|
+
body = init.body;
|
|
26
|
+
else if (init.body instanceof URLSearchParams)
|
|
27
|
+
body = init.body.toString();
|
|
28
|
+
else
|
|
29
|
+
body = node_stream_1.Readable.fromWeb(request.body);
|
|
30
|
+
}
|
|
31
|
+
const stream = await session.stream(request.method, request.url, {
|
|
32
|
+
headers, body, profile: init.profile, proxy: init.proxy, proxyCaFile: init.proxyCaFile,
|
|
33
|
+
timeoutMs: init.timeoutMs, maxRedirects: init.maxRedirects,
|
|
34
|
+
allowRedirects: request.redirect === "follow", signal: request.signal,
|
|
35
|
+
});
|
|
36
|
+
let closing;
|
|
37
|
+
const close = () => closing ??= (async () => {
|
|
38
|
+
stream.closedSignal.removeEventListener("abort", abort);
|
|
39
|
+
try {
|
|
40
|
+
await stream.closeAsync();
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
if (owned)
|
|
44
|
+
await session.closeAsync();
|
|
45
|
+
}
|
|
46
|
+
})();
|
|
47
|
+
const abort = () => { void close().catch(() => { }); };
|
|
48
|
+
stream.closedSignal.addEventListener("abort", abort, { once: true });
|
|
49
|
+
if (stream.closedSignal.aborted) {
|
|
50
|
+
await close();
|
|
51
|
+
throw stream.closedSignal.reason;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
if (request.redirect === "error" && [301, 302, 303, 307, 308].includes(stream.status))
|
|
55
|
+
throw new TypeError("redirect encountered with redirect: error");
|
|
56
|
+
const responseHeaders = new globalThis.Headers();
|
|
57
|
+
for (const [name, value] of stream.headers.multiItems())
|
|
58
|
+
responseHeaders.append(name, value);
|
|
59
|
+
const empty = request.method === "HEAD" || [204, 205, 304].includes(stream.status);
|
|
60
|
+
const iterator = stream.body[Symbol.asyncIterator]();
|
|
61
|
+
const responseBody = empty ? null : new ReadableStream({
|
|
62
|
+
async pull(controller) {
|
|
63
|
+
try {
|
|
64
|
+
const item = await iterator.next();
|
|
65
|
+
if (item.done) {
|
|
66
|
+
await close();
|
|
67
|
+
controller.close();
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
controller.enqueue(item.value);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
try {
|
|
74
|
+
await close();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
controller.error(error);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async cancel() { try {
|
|
82
|
+
await iterator.return?.();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await close();
|
|
86
|
+
} },
|
|
87
|
+
}, { highWaterMark: 0 });
|
|
88
|
+
const response = new globalThis.Response(responseBody, { status: stream.status, headers: responseHeaders });
|
|
89
|
+
Object.defineProperties(response, {
|
|
90
|
+
url: { value: stream.url }, redirected: { value: stream.url !== request.url },
|
|
91
|
+
match: { value: stream.match }, httpVersion: { value: stream.httpVersion },
|
|
92
|
+
});
|
|
93
|
+
if (empty)
|
|
94
|
+
await close();
|
|
95
|
+
return response;
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
await close();
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (owned)
|
|
104
|
+
await session.closeAsync();
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Fetch-compatible entry point. No persistent cookie jar is created. */
|
|
109
|
+
async function fetch(input, init = {}) {
|
|
110
|
+
const options = { cookies: false, profile: init.profile, proxy: init.proxy,
|
|
111
|
+
proxyCaFile: init.proxyCaFile, caFile: init.caFile, library: init.library, timeoutMs: init.timeoutMs };
|
|
112
|
+
return fetchWithSession(new session_js_1.Session(options), input, init, true);
|
|
113
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { Session } from "./session.js";
|
|
2
|
+
export { fetch } from "./fetch.js";
|
|
3
|
+
export type { FetchOptions, FetchResponse } from "./fetch.js";
|
|
2
4
|
export type { SessionOptions, RequestOptions, DuplexOptions, Stats } from "./session.js";
|
|
3
5
|
export { DuplexExchange } from "./duplex.js";
|
|
4
6
|
export { WebSocket, WebSocketClosed } from "./websocket.js";
|
package/dist/index.js
CHANGED
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
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;
|
|
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 fetch_js_1 = require("./fetch.js");
|
|
7
|
+
Object.defineProperty(exports, "fetch", { enumerable: true, get: function () { return fetch_js_1.fetch; } });
|
|
6
8
|
var duplex_js_1 = require("./duplex.js");
|
|
7
9
|
Object.defineProperty(exports, "DuplexExchange", { enumerable: true, get: function () { return duplex_js_1.DuplexExchange; } });
|
|
8
10
|
var websocket_js_1 = require("./websocket.js");
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
import sdk from './index.js';
|
|
2
|
+
export const fetch = sdk.fetch;
|
|
2
3
|
export const { Session, Response, StreamResponse, DuplexExchange, WebSocket, WebSocketClosed, ServerSentEvent, SSEStream, Headers, Cookies, FastlsError, FastlsHTTPError, AbortError, FastlsDecodeError, FastlsTimeoutError } = sdk;
|
package/dist/native.js
CHANGED
|
@@ -103,6 +103,9 @@ class Native {
|
|
|
103
103
|
this.functions.read = lib.func("int fastls_read(uint64_t handle, _Out_ void *buffer, size_t capacity, _Out_ size_t *length)");
|
|
104
104
|
this.functions.write = lib.func("int fastls_write(uint64_t handle, const void *buffer, size_t length, int end)");
|
|
105
105
|
const features = new Set(this.call("features"));
|
|
106
|
+
if (features.has("transport_context_v1")) {
|
|
107
|
+
this.functions.transport_create = lib.func("void *fastls_transport_create(void)");
|
|
108
|
+
}
|
|
106
109
|
let websocketAvailable = false;
|
|
107
110
|
if (features.has("websocket_v1")) {
|
|
108
111
|
try {
|
package/dist/response.d.ts
CHANGED
|
@@ -16,13 +16,15 @@ export declare class Transfer {
|
|
|
16
16
|
private readonly external?;
|
|
17
17
|
readonly duplex: boolean;
|
|
18
18
|
private readonly notifier?;
|
|
19
|
+
private readonly acceptHeaders?;
|
|
19
20
|
private readonly controller;
|
|
20
21
|
private closed;
|
|
21
22
|
private failure?;
|
|
22
23
|
private timer?;
|
|
23
24
|
private deadline?;
|
|
25
|
+
private acceptedHeaders;
|
|
24
26
|
private readonly externalAbort;
|
|
25
|
-
constructor(native: Native, handle: bigint, release: () => void, external?: AbortSignal | undefined, duplex?: boolean, notifier?: Notifier | undefined);
|
|
27
|
+
constructor(native: Native, handle: bigint, release: () => void, external?: AbortSignal | undefined, duplex?: boolean, notifier?: Notifier | undefined, acceptHeaders?: ((info: NativeInfo) => void) | undefined);
|
|
26
28
|
private wait;
|
|
27
29
|
attachSignal(total?: number, started?: number): void;
|
|
28
30
|
get signal(): AbortSignal;
|
|
@@ -71,6 +73,8 @@ export declare class StreamResponse {
|
|
|
71
73
|
raiseForStatus(): void;
|
|
72
74
|
/** @internal Check cancellation even when a parser still holds buffered data. */
|
|
73
75
|
ensureOpen(): void;
|
|
76
|
+
/** @internal Notification for adapters that own a temporary Session. */
|
|
77
|
+
get closedSignal(): AbortSignal;
|
|
74
78
|
private iterate;
|
|
75
79
|
read(): Promise<Buffer>;
|
|
76
80
|
text(): Promise<string>;
|
package/dist/response.js
CHANGED
|
@@ -17,19 +17,22 @@ class Transfer {
|
|
|
17
17
|
external;
|
|
18
18
|
duplex;
|
|
19
19
|
notifier;
|
|
20
|
+
acceptHeaders;
|
|
20
21
|
controller = new AbortController();
|
|
21
22
|
closed = false;
|
|
22
23
|
failure;
|
|
23
24
|
timer;
|
|
24
25
|
deadline;
|
|
26
|
+
acceptedHeaders = false;
|
|
25
27
|
externalAbort = () => this.close(new errors_js_1.AbortError(undefined, { cause: this.external?.reason }));
|
|
26
|
-
constructor(native, handle, release, external, duplex = false, notifier) {
|
|
28
|
+
constructor(native, handle, release, external, duplex = false, notifier, acceptHeaders) {
|
|
27
29
|
this.native = native;
|
|
28
30
|
this.handle = handle;
|
|
29
31
|
this.release = release;
|
|
30
32
|
this.external = external;
|
|
31
33
|
this.duplex = duplex;
|
|
32
34
|
this.notifier = notifier;
|
|
35
|
+
this.acceptHeaders = acceptHeaders;
|
|
33
36
|
}
|
|
34
37
|
wait(probe, signal) {
|
|
35
38
|
return this.notifier ? this.notifier.probe(this.handle, probe, signal) : scheduler_js_1.scheduler.wait(probe, signal);
|
|
@@ -69,7 +72,13 @@ class Transfer {
|
|
|
69
72
|
return this.wait(() => {
|
|
70
73
|
this.ensureOpen();
|
|
71
74
|
const info = this.info();
|
|
72
|
-
|
|
75
|
+
if (info.state === "pending")
|
|
76
|
+
return undefined;
|
|
77
|
+
if (!this.acceptedHeaders) {
|
|
78
|
+
this.acceptHeaders?.(info);
|
|
79
|
+
this.acceptedHeaders = true;
|
|
80
|
+
}
|
|
81
|
+
return info;
|
|
73
82
|
}, this.controller.signal);
|
|
74
83
|
}
|
|
75
84
|
async next() {
|
|
@@ -177,6 +186,8 @@ class StreamResponse {
|
|
|
177
186
|
throw new errors_js_1.FastlsHTTPError(this); }
|
|
178
187
|
/** @internal Check cancellation even when a parser still holds buffered data. */
|
|
179
188
|
ensureOpen() { this.transfer.ensureOpen(); }
|
|
189
|
+
/** @internal Notification for adapters that own a temporary Session. */
|
|
190
|
+
get closedSignal() { return this.transfer.signal; }
|
|
180
191
|
async *iterate() {
|
|
181
192
|
if (this.consumed)
|
|
182
193
|
throw new errors_js_1.FastlsError("response body already has a consumer");
|
package/dist/session.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { DuplexExchange } from "./duplex.js";
|
|
|
6
6
|
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
|
+
import { type FetchOptions, type FetchResponse } from "./fetch.js";
|
|
9
10
|
export interface RequestOptions {
|
|
10
11
|
/** Undefined inherits the session proxy; null or an empty string connects directly. */
|
|
11
12
|
proxy?: string | null;
|
|
@@ -17,6 +18,7 @@ export interface RequestOptions {
|
|
|
17
18
|
/** Total deadline including response body consumption, in milliseconds. */
|
|
18
19
|
timeoutMs?: Timeout;
|
|
19
20
|
allowRedirects?: boolean;
|
|
21
|
+
maxRedirects?: number;
|
|
20
22
|
body?: UploadBody;
|
|
21
23
|
params?: QueryParams;
|
|
22
24
|
json?: JSONValue;
|
|
@@ -84,6 +86,7 @@ export declare class Session {
|
|
|
84
86
|
/** Initialize the shared wakeup channel before any HTTP request is allocated. */
|
|
85
87
|
ready(signal?: AbortSignal): Promise<void>;
|
|
86
88
|
private begin;
|
|
89
|
+
private streamHop;
|
|
87
90
|
stream(method: string, url: string, options?: RequestOptions): Promise<StreamResponse>;
|
|
88
91
|
sse(method: string, url: string, options?: SSEOptions): Promise<SSEStream>;
|
|
89
92
|
request(method: string, url: string, options?: RequestOptions): Promise<Response>;
|
|
@@ -91,6 +94,12 @@ export declare class Session {
|
|
|
91
94
|
duplex(method: string, url: string, options?: DuplexOptions): DuplexExchange;
|
|
92
95
|
get(url: string, options?: RequestOptions): Promise<Response>;
|
|
93
96
|
post(url: string, options?: RequestOptions): Promise<Response>;
|
|
97
|
+
put(url: string, options?: RequestOptions): Promise<Response>;
|
|
98
|
+
patch(url: string, options?: RequestOptions): Promise<Response>;
|
|
99
|
+
delete(url: string, options?: RequestOptions): Promise<Response>;
|
|
100
|
+
head(url: string, options?: RequestOptions): Promise<Response>;
|
|
101
|
+
options(url: string, options?: RequestOptions): Promise<Response>;
|
|
102
|
+
fetch(input: string | URL | Request, options?: FetchOptions): Promise<FetchResponse>;
|
|
94
103
|
websocket(url: string, options?: WebSocketOptions): Promise<WebSocket>;
|
|
95
104
|
listProfiles(): string[];
|
|
96
105
|
stats(): Stats;
|
package/dist/session.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Session = void 0;
|
|
4
4
|
const node_fs_1 = require("node:fs");
|
|
5
5
|
const cookies_js_1 = require("./cookies.js");
|
|
6
|
+
const headers_js_1 = require("./headers.js");
|
|
6
7
|
const errors_js_1 = require("./errors.js");
|
|
7
8
|
const native_js_1 = require("./native.js");
|
|
8
9
|
const response_js_1 = require("./response.js");
|
|
@@ -13,6 +14,7 @@ const sse_js_1 = require("./sse.js");
|
|
|
13
14
|
const notifications_js_1 = require("./notifications.js");
|
|
14
15
|
const request_options_js_1 = require("./request-options.js");
|
|
15
16
|
const websocket_js_1 = require("./websocket.js");
|
|
17
|
+
const fetch_js_1 = require("./fetch.js");
|
|
16
18
|
function decoding(decode, maximum) {
|
|
17
19
|
if (typeof decode !== "boolean")
|
|
18
20
|
throw new TypeError("decodeContent must be a boolean");
|
|
@@ -100,22 +102,10 @@ class Session {
|
|
|
100
102
|
if (notifications === "required" && !supported)
|
|
101
103
|
throw new errors_js_1.FastlsError("notifications require a newer Fastls native library");
|
|
102
104
|
this.notificationMode = notifications !== "poll" && supported ? "async-events" : "poll";
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return this.native.call("session_cookies", this.handle, raw, raw.length);
|
|
108
|
-
});
|
|
109
|
-
try {
|
|
110
|
-
if (options.cookies === false) {
|
|
111
|
-
const raw = (0, native_js_1.encodeNativeJSON)({ action: "configure", enabled: false });
|
|
112
|
-
this.native.call("session_cookies", this.handle, raw, raw.length);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
catch (error) {
|
|
116
|
-
this.close();
|
|
117
|
-
throw error;
|
|
118
|
-
}
|
|
105
|
+
if (!this.native.call("features").includes("transport_context_v1"))
|
|
106
|
+
throw new errors_js_1.FastlsError("SDK-owned sessions require the Fastls 0.1.4 native library or newer");
|
|
107
|
+
this.handle = (0, native_js_1.asHandle)(this.native.call("transport_create"));
|
|
108
|
+
this.cookies = new cookies_js_1.Cookies(() => this.ensureOpen(), options.cookies !== false);
|
|
119
109
|
}
|
|
120
110
|
ensureOpen() {
|
|
121
111
|
if (this.closed)
|
|
@@ -165,14 +155,14 @@ class Session {
|
|
|
165
155
|
}
|
|
166
156
|
}
|
|
167
157
|
}
|
|
168
|
-
begin(method, url, options, manual = false) {
|
|
158
|
+
begin(method, url, options, manual = false, completeHeaders = false) {
|
|
169
159
|
this.ensureOpen();
|
|
170
160
|
if (options.signal !== undefined && !(options.signal instanceof AbortSignal)) {
|
|
171
161
|
throw new TypeError("signal must be an AbortSignal");
|
|
172
162
|
}
|
|
173
163
|
if (options.signal?.aborted)
|
|
174
164
|
throw new errors_js_1.AbortError(undefined, { cause: options.signal.reason });
|
|
175
|
-
const headers = mergeHeaders(this.headers, options.headers);
|
|
165
|
+
const headers = mergeHeaders(completeHeaders ? undefined : this.headers, options.headers);
|
|
176
166
|
for (const name of ["proxy-authorization", "proxy-connection"]) {
|
|
177
167
|
if (name in headers)
|
|
178
168
|
throw new errors_js_1.FastlsError("proxy headers must not be sent as target headers; configure proxy URL credentials");
|
|
@@ -195,6 +185,11 @@ class Session {
|
|
|
195
185
|
}
|
|
196
186
|
const prepared = (0, request_options_js_1.prepare)(url, options, headers);
|
|
197
187
|
url = prepared.url;
|
|
188
|
+
if (!("cookie" in headers)) {
|
|
189
|
+
const cookie = this.cookies.header(url);
|
|
190
|
+
if (cookie)
|
|
191
|
+
headers.cookie = cookie;
|
|
192
|
+
}
|
|
198
193
|
const body = prepared.body === undefined && duplex && !manual ? Buffer.alloc(0) : prepared.body;
|
|
199
194
|
const streaming = (0, upload_js_1.isStreamingBody)(body) || duplex;
|
|
200
195
|
const bodyBytes = streaming || body === undefined ? undefined : Buffer.from(body);
|
|
@@ -224,13 +219,13 @@ class Session {
|
|
|
224
219
|
session: this.handle, url, method, headers, trailers, proxy: proxy || undefined, proxy_ca_pem: proxyCaPem,
|
|
225
220
|
profile: options.profile ?? this.profile,
|
|
226
221
|
...timeouts, decode_content: decodeContent, max_decoded_bytes: maxDecodedBytes,
|
|
227
|
-
disable_redirect:
|
|
222
|
+
disable_redirect: true,
|
|
228
223
|
ca_pem: this.caPem,
|
|
229
224
|
body_base64: bodyBytes?.toString("base64"),
|
|
230
225
|
stream_body: streaming || undefined,
|
|
231
226
|
duplex: duplex || undefined,
|
|
232
227
|
});
|
|
233
|
-
const transfer = new response_js_1.Transfer(this.native, handle, () => { this.active.delete(transfer); this.notifier?.forget(handle); }, options.signal, duplex, this.notifier);
|
|
228
|
+
const transfer = new response_js_1.Transfer(this.native, handle, () => { this.active.delete(transfer); this.notifier?.forget(handle); }, options.signal, duplex, this.notifier, info => this.cookies.extract(info.final_url ?? url, new headers_js_1.Headers(info.header_values, info.headers).getAll("set-cookie")));
|
|
234
229
|
this.active.add(transfer);
|
|
235
230
|
try {
|
|
236
231
|
this.notifier?.track(handle);
|
|
@@ -242,9 +237,9 @@ class Session {
|
|
|
242
237
|
throw error;
|
|
243
238
|
}
|
|
244
239
|
}
|
|
245
|
-
async
|
|
240
|
+
async streamHop(method, url, options) {
|
|
246
241
|
await this.ready(options.signal);
|
|
247
|
-
const { transfer, body, streaming } = this.begin(method, url, options);
|
|
242
|
+
const { transfer, body, streaming } = this.begin(method, url, options, false, true);
|
|
248
243
|
try {
|
|
249
244
|
const upload = streaming ? (0, upload_js_1.startUpload)(transfer, body) : undefined;
|
|
250
245
|
if (upload) {
|
|
@@ -265,13 +260,60 @@ class Session {
|
|
|
265
260
|
}
|
|
266
261
|
}
|
|
267
262
|
transfer.ensureOpen();
|
|
268
|
-
|
|
263
|
+
const response = new response_js_1.StreamResponse(transfer, info, transfer.duplex ? upload : undefined);
|
|
264
|
+
return response;
|
|
269
265
|
}
|
|
270
266
|
catch (error) {
|
|
271
267
|
transfer.close();
|
|
272
268
|
throw error;
|
|
273
269
|
}
|
|
274
270
|
}
|
|
271
|
+
async stream(method, url, options = {}) {
|
|
272
|
+
const headers = mergeHeaders(this.headers, options.headers);
|
|
273
|
+
const prepared = (0, request_options_js_1.prepare)(url, options, headers);
|
|
274
|
+
url = prepared.url;
|
|
275
|
+
options = { ...options, headers, body: prepared.body };
|
|
276
|
+
for (const key of ["params", "json", "form", "files"])
|
|
277
|
+
delete options[key];
|
|
278
|
+
const maximum = options.maxRedirects ?? 30;
|
|
279
|
+
if (!Number.isSafeInteger(maximum) || maximum < 0)
|
|
280
|
+
throw new RangeError("maxRedirects must be a non-negative integer");
|
|
281
|
+
const policy = options.timeoutMs === undefined ? this.timeouts : (0, timeout_js_1.timeoutFields)(options.timeoutMs);
|
|
282
|
+
const deadline = performance.now() + policy.timeout_ms;
|
|
283
|
+
for (let redirects = 0;; redirects++) {
|
|
284
|
+
const remaining = Math.ceil(deadline - performance.now());
|
|
285
|
+
if (remaining <= 0)
|
|
286
|
+
throw new errors_js_1.FastlsTimeoutError("total deadline exceeded", "total");
|
|
287
|
+
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
|
+
const response = await this.streamHop(method, url, options);
|
|
289
|
+
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, upload_js_1.isStreamingBody)(options.body)))
|
|
291
|
+
return response;
|
|
292
|
+
try {
|
|
293
|
+
if (redirects >= maximum)
|
|
294
|
+
throw new errors_js_1.FastlsError("maximum redirects exceeded");
|
|
295
|
+
const previous = new URL(url), target = new URL(location, url);
|
|
296
|
+
if (!["http:", "https:"].includes(target.protocol) || target.username || target.password)
|
|
297
|
+
throw new errors_js_1.FastlsError("invalid redirect URL");
|
|
298
|
+
target.hash = "";
|
|
299
|
+
if (previous.origin !== target.origin)
|
|
300
|
+
for (const name of ["authorization", "cookie", "host"])
|
|
301
|
+
delete headers[name];
|
|
302
|
+
if (([302, 303].includes(response.status) && method.toUpperCase() !== "HEAD") || (response.status === 301 && method.toUpperCase() === "POST")) {
|
|
303
|
+
method = "GET";
|
|
304
|
+
delete options.body;
|
|
305
|
+
delete options.trailers;
|
|
306
|
+
for (const name of ["content-type", "content-length", "transfer-encoding"])
|
|
307
|
+
delete headers[name];
|
|
308
|
+
}
|
|
309
|
+
url = target.href;
|
|
310
|
+
await response.read();
|
|
311
|
+
}
|
|
312
|
+
finally {
|
|
313
|
+
await response.closeAsync();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
275
317
|
async sse(method, url, options = {}) {
|
|
276
318
|
const { maxEventBytes = 1024 * 1024, lastEventId, ...request } = options;
|
|
277
319
|
(0, sse_js_1.validateSSEOptions)(maxEventBytes, lastEventId);
|
|
@@ -314,6 +356,16 @@ class Session {
|
|
|
314
356
|
}
|
|
315
357
|
get(url, options) { return this.request("GET", url, options); }
|
|
316
358
|
post(url, options) { return this.request("POST", url, options); }
|
|
359
|
+
put(url, options) { return this.request("PUT", url, options); }
|
|
360
|
+
patch(url, options) { return this.request("PATCH", url, options); }
|
|
361
|
+
delete(url, options) { return this.request("DELETE", url, options); }
|
|
362
|
+
head(url, options = {}) { return this.request("HEAD", url, { allowRedirects: false, ...options }); }
|
|
363
|
+
options(url, options) { return this.request("OPTIONS", url, options); }
|
|
364
|
+
fetch(input, options = {}) {
|
|
365
|
+
if (options.library !== undefined || options.caFile !== undefined)
|
|
366
|
+
throw new TypeError("library and caFile must be configured on Session");
|
|
367
|
+
return (0, fetch_js_1.fetchWithSession)(this, input, options);
|
|
368
|
+
}
|
|
317
369
|
async websocket(url, options = {}) {
|
|
318
370
|
await this.ready(options.signal);
|
|
319
371
|
this.ensureOpen();
|
|
@@ -335,11 +387,16 @@ class Session {
|
|
|
335
387
|
throw new RangeError("maxMessageBytes must be 1..16777216");
|
|
336
388
|
const close = (0, websocket_js_1.wsBudget)((options.closeTimeoutMs === undefined ? 1000 : options.closeTimeoutMs), "closeTimeoutMs", 60000);
|
|
337
389
|
const headers = mergeHeaders(this.headers, options.headers), prepared = (0, request_options_js_1.prepare)(url, { params: options.params }, headers);
|
|
390
|
+
if (cookieMode === "session" && !("cookie" in headers)) {
|
|
391
|
+
const cookie = this.cookies.header(prepared.url);
|
|
392
|
+
if (cookie)
|
|
393
|
+
headers.cookie = cookie;
|
|
394
|
+
}
|
|
338
395
|
const proxy = proxyValue(options.proxy === undefined ? this.proxy : options.proxy);
|
|
339
396
|
const proxyCA = proxy?.toLowerCase().startsWith("https://") ? (options.proxyCaFile === undefined ? this.proxyCaPem : proxyTrust(options.proxyCaFile)) : undefined;
|
|
340
397
|
if (options.subprotocols !== undefined && (!Array.isArray(options.subprotocols) || options.subprotocols.some(p => typeof p !== "string")))
|
|
341
398
|
throw new TypeError("subprotocols must be an array of strings");
|
|
342
|
-
const handle = this.native.wsStart({ url: prepared.url, session: this.handle, disable_cookies:
|
|
399
|
+
const handle = this.native.wsStart({ url: prepared.url, session: this.handle, disable_cookies: true, headers, profile: options.profile ?? this.profile, proxy: proxy || undefined, ca_pem: this.caPem, proxy_ca_pem: proxyCA, subprotocols: options.subprotocols ?? [], max_message_bytes: maximum,
|
|
343
400
|
timeout_ms: (0, websocket_js_1.wsBudget)((options.handshakeTimeoutMs === undefined ? 30000 : options.handshakeTimeoutMs), "handshakeTimeoutMs"), read_timeout_ms: options.readTimeoutMs === null || options.readTimeoutMs === undefined ? 0 : (0, websocket_js_1.wsBudget)(options.readTimeoutMs, "readTimeoutMs"), write_timeout_ms: (0, websocket_js_1.wsBudget)((options.writeTimeoutMs === undefined ? 30000 : options.writeTimeoutMs), "writeTimeoutMs") });
|
|
344
401
|
const ws = new websocket_js_1.WebSocket(this.native, handle, () => { this.websockets.delete(ws); this.notifier?.forget(handle); }, maximum, close, this.notifier, options.signal);
|
|
345
402
|
this.websockets.add(ws);
|
|
@@ -351,6 +408,13 @@ class Session {
|
|
|
351
408
|
ws.abort();
|
|
352
409
|
throw error;
|
|
353
410
|
}
|
|
411
|
+
finally {
|
|
412
|
+
if (cookieMode === "session" && !this.closed) {
|
|
413
|
+
const info = ws.info;
|
|
414
|
+
if (info.status !== 101 || info.state === "headers" || info.state === "eof")
|
|
415
|
+
this.cookies.extract(prepared.url, ws.headers.getAll("set-cookie"));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
354
418
|
}
|
|
355
419
|
listProfiles() { this.ensureOpen(); return this.native.call("profiles"); }
|
|
356
420
|
stats() { this.ensureOpen(); return this.native.call("stats"); }
|
|
@@ -358,6 +422,7 @@ class Session {
|
|
|
358
422
|
close() {
|
|
359
423
|
if (this.closed)
|
|
360
424
|
return;
|
|
425
|
+
this.cookies.clear();
|
|
361
426
|
this.closed = true;
|
|
362
427
|
try {
|
|
363
428
|
for (const transfer of this.active)
|
package/dist/websocket.js
CHANGED
|
@@ -69,7 +69,8 @@ class WebSocket {
|
|
|
69
69
|
ensure() { if (this.closed)
|
|
70
70
|
throw this.failure ?? new errors_js_1.FastlsError("WebSocket is closed"); }
|
|
71
71
|
get info() { if (this.closed && this.last)
|
|
72
|
-
return this.last; this.ensure();
|
|
72
|
+
return this.last; this.ensure(); const info = this.last = this.native.call("info", this.handle); if (info.status)
|
|
73
|
+
this.headers = new headers_js_1.Headers(info.header_values); return info; }
|
|
73
74
|
get closeCode() { return (this.closed ? this.last : this.info)?.websocket?.close_code ?? 1006; }
|
|
74
75
|
get closeReason() { return (this.closed ? this.last : this.info)?.websocket?.close_reason ?? ""; }
|
|
75
76
|
check(info) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastls/fastls",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Node.js bindings for the Fastls C ABI",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"koffi": "3.3.3",
|
|
24
|
-
"lossless-json": "4.3.3"
|
|
24
|
+
"lossless-json": "4.3.3",
|
|
25
|
+
"tough-cookie": "6.0.0"
|
|
25
26
|
},
|
|
26
27
|
"license": "Apache-2.0",
|
|
27
28
|
"repository": {
|
|
@@ -34,10 +35,10 @@
|
|
|
34
35
|
"registry": "https://registry.npmjs.org/"
|
|
35
36
|
},
|
|
36
37
|
"optionalDependencies": {
|
|
37
|
-
"@fastls/fastls-win32-x64": "0.10.
|
|
38
|
-
"@fastls/fastls-linux-x64-glibc": "0.10.
|
|
39
|
-
"@fastls/fastls-linux-arm64-glibc": "0.10.
|
|
40
|
-
"@fastls/fastls-darwin-x64": "0.10.
|
|
41
|
-
"@fastls/fastls-darwin-arm64": "0.10.
|
|
38
|
+
"@fastls/fastls-win32-x64": "0.10.2",
|
|
39
|
+
"@fastls/fastls-linux-x64-glibc": "0.10.2",
|
|
40
|
+
"@fastls/fastls-linux-arm64-glibc": "0.10.2",
|
|
41
|
+
"@fastls/fastls-darwin-x64": "0.10.2",
|
|
42
|
+
"@fastls/fastls-darwin-arm64": "0.10.2"
|
|
42
43
|
}
|
|
43
44
|
}
|