@npy/fetch 0.1.1 → 0.1.3
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 +143 -50
- package/bun.lock +68 -0
- package/examples/custom-proxy-client.ts +32 -0
- package/examples/http-client.ts +47 -0
- package/examples/proxy.ts +16 -0
- package/examples/simple.ts +15 -0
- package/package.json +36 -25
- package/src/_internal/consts.ts +3 -0
- package/src/_internal/decode-stream-error.ts +16 -0
- package/src/_internal/error-mapping.ts +160 -0
- package/src/_internal/guards.ts +78 -0
- package/src/_internal/net.ts +173 -0
- package/src/_internal/promises.ts +22 -0
- package/src/_internal/streams.ts +52 -0
- package/src/_internal/symbols.ts +1 -0
- package/src/agent-pool.ts +157 -0
- package/src/agent.ts +408 -0
- package/src/body.ts +179 -0
- package/src/dialers/index.ts +3 -0
- package/src/dialers/proxy.ts +102 -0
- package/src/dialers/tcp.ts +162 -0
- package/src/encoding.ts +222 -0
- package/src/errors.ts +357 -0
- package/src/fetch.ts +626 -0
- package/src/http-client.ts +111 -0
- package/src/index.ts +14 -0
- package/src/io/_utils.ts +82 -0
- package/src/io/buf-writer.ts +183 -0
- package/src/io/io.ts +322 -0
- package/src/io/readers.ts +576 -0
- package/src/io/writers.ts +331 -0
- package/src/types/agent.ts +98 -0
- package/src/types/{dialer.d.cts → dialer.ts} +22 -9
- package/src/types/index.ts +2 -0
- package/tests/agent-pool.test.ts +111 -0
- package/tests/agent.test.ts +134 -0
- package/tests/body.test.ts +228 -0
- package/tests/errors.test.ts +152 -0
- package/tests/fetch.test.ts +421 -0
- package/tests/io-options.test.ts +127 -0
- package/tests/multipart.test.ts +348 -0
- package/tests/test-utils.ts +335 -0
- package/tsconfig.json +15 -0
- package/LICENSE +0 -21
- package/_internal/consts.cjs +0 -4
- package/_internal/consts.js +0 -4
- package/_internal/error-adapters.cjs +0 -146
- package/_internal/error-adapters.js +0 -142
- package/_internal/guards.cjs +0 -24
- package/_internal/guards.js +0 -17
- package/_internal/net.cjs +0 -95
- package/_internal/net.js +0 -92
- package/_internal/promises.cjs +0 -18
- package/_internal/promises.js +0 -18
- package/_internal/streams.cjs +0 -37
- package/_internal/streams.js +0 -36
- package/_virtual/_rolldown/runtime.cjs +0 -23
- package/agent-pool.cjs +0 -78
- package/agent-pool.js +0 -77
- package/agent.cjs +0 -257
- package/agent.js +0 -256
- package/body.cjs +0 -154
- package/body.js +0 -151
- package/dialers/proxy.cjs +0 -49
- package/dialers/proxy.js +0 -48
- package/dialers/tcp.cjs +0 -70
- package/dialers/tcp.js +0 -67
- package/encoding.cjs +0 -95
- package/encoding.js +0 -91
- package/errors.cjs +0 -275
- package/errors.js +0 -259
- package/fetch.cjs +0 -117
- package/fetch.js +0 -115
- package/http-client.cjs +0 -33
- package/http-client.js +0 -33
- package/index.cjs +0 -45
- package/index.d.cts +0 -1
- package/index.d.ts +0 -1
- package/index.js +0 -9
- package/io/_utils.cjs +0 -56
- package/io/_utils.js +0 -51
- package/io/buf-writer.cjs +0 -149
- package/io/buf-writer.js +0 -148
- package/io/io.cjs +0 -135
- package/io/io.js +0 -134
- package/io/readers.cjs +0 -377
- package/io/readers.js +0 -373
- package/io/writers.cjs +0 -191
- package/io/writers.js +0 -190
- package/src/_internal/consts.d.cts +0 -3
- package/src/_internal/consts.d.ts +0 -3
- package/src/_internal/error-adapters.d.cts +0 -22
- package/src/_internal/error-adapters.d.ts +0 -22
- package/src/_internal/guards.d.cts +0 -13
- package/src/_internal/guards.d.ts +0 -13
- package/src/_internal/net.d.cts +0 -12
- package/src/_internal/net.d.ts +0 -12
- package/src/_internal/promises.d.cts +0 -1
- package/src/_internal/promises.d.ts +0 -1
- package/src/_internal/streams.d.cts +0 -21
- package/src/_internal/streams.d.ts +0 -21
- package/src/agent-pool.d.cts +0 -2
- package/src/agent-pool.d.ts +0 -2
- package/src/agent.d.cts +0 -3
- package/src/agent.d.ts +0 -3
- package/src/body.d.cts +0 -23
- package/src/body.d.ts +0 -23
- package/src/dialers/index.d.cts +0 -3
- package/src/dialers/index.d.ts +0 -3
- package/src/dialers/proxy.d.cts +0 -19
- package/src/dialers/proxy.d.ts +0 -19
- package/src/dialers/tcp.d.cts +0 -36
- package/src/dialers/tcp.d.ts +0 -36
- package/src/encoding.d.cts +0 -24
- package/src/encoding.d.ts +0 -24
- package/src/errors.d.cts +0 -110
- package/src/errors.d.ts +0 -110
- package/src/fetch.d.cts +0 -36
- package/src/fetch.d.ts +0 -36
- package/src/http-client.d.cts +0 -23
- package/src/http-client.d.ts +0 -23
- package/src/index.d.cts +0 -7
- package/src/index.d.ts +0 -7
- package/src/io/_utils.d.cts +0 -10
- package/src/io/_utils.d.ts +0 -10
- package/src/io/buf-writer.d.cts +0 -13
- package/src/io/buf-writer.d.ts +0 -13
- package/src/io/io.d.cts +0 -5
- package/src/io/io.d.ts +0 -5
- package/src/io/readers.d.cts +0 -199
- package/src/io/readers.d.ts +0 -199
- package/src/io/writers.d.cts +0 -22
- package/src/io/writers.d.ts +0 -22
- package/src/types/agent.d.cts +0 -128
- package/src/types/agent.d.ts +0 -128
- package/src/types/dialer.d.ts +0 -27
- package/src/types/index.d.cts +0 -2
- package/src/types/index.d.ts +0 -2
- package/tests/test-utils.d.cts +0 -8
- package/tests/test-utils.d.ts +0 -8
package/src/agent.ts
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { Deferred } from "@fuman/utils";
|
|
2
|
+
import { toConnectError, toSendError } from "./_internal/error-mapping";
|
|
3
|
+
import { raceSignal } from "./_internal/promises";
|
|
4
|
+
import { bodyErrorMapperSymbol } from "./_internal/symbols";
|
|
5
|
+
import {
|
|
6
|
+
AgentBusyError,
|
|
7
|
+
AgentClosedError,
|
|
8
|
+
OriginMismatchError,
|
|
9
|
+
RequestAbortedError,
|
|
10
|
+
UnsupportedAlpnProtocolError,
|
|
11
|
+
UnsupportedMethodError,
|
|
12
|
+
UnsupportedProtocolError,
|
|
13
|
+
} from "./errors";
|
|
14
|
+
import { readResponse, writeRequest } from "./io/io";
|
|
15
|
+
import type { Agent } from "./types/agent";
|
|
16
|
+
import type { Dialer } from "./types/dialer";
|
|
17
|
+
|
|
18
|
+
const PORT_MAP = {
|
|
19
|
+
"http:": 80,
|
|
20
|
+
"https:": 443,
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ALPN_PROTOCOLS = ["http/1.1"] as const;
|
|
24
|
+
|
|
25
|
+
function resolvedDeferred(): Deferred<void> {
|
|
26
|
+
const deferred = new Deferred<void>();
|
|
27
|
+
deferred.resolve();
|
|
28
|
+
return deferred;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function withSignal<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
32
|
+
return signal ? raceSignal(promise, signal) : promise;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isTlsConnection(
|
|
36
|
+
conn: Dialer.ConnectionLike,
|
|
37
|
+
): conn is Dialer.ConnectionLike & { getAlpnProtocol(): string | null } {
|
|
38
|
+
return (
|
|
39
|
+
"getAlpnProtocol" in conn && typeof conn.getAlpnProtocol === "function"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createAgent(
|
|
44
|
+
dialer: Dialer,
|
|
45
|
+
baseUrl: string,
|
|
46
|
+
options: Agent.Options = {},
|
|
47
|
+
): Agent {
|
|
48
|
+
const base = new URL(baseUrl);
|
|
49
|
+
|
|
50
|
+
if (base.protocol !== "http:" && base.protocol !== "https:") {
|
|
51
|
+
throw new UnsupportedProtocolError(base.protocol, {
|
|
52
|
+
origin: base.origin,
|
|
53
|
+
scheme: base.protocol,
|
|
54
|
+
host: base.hostname,
|
|
55
|
+
port: base.port ? Number.parseInt(base.port, 10) : undefined,
|
|
56
|
+
url: base.toString(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const secure = base.protocol === "https:";
|
|
61
|
+
const hostname = base.hostname;
|
|
62
|
+
const port = base.port
|
|
63
|
+
? Number.parseInt(base.port, 10)
|
|
64
|
+
: PORT_MAP[base.protocol];
|
|
65
|
+
|
|
66
|
+
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
|
67
|
+
throw new TypeError(`Invalid port in base URL: ${baseUrl}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const target: Dialer.Target = {
|
|
71
|
+
address: hostname,
|
|
72
|
+
port,
|
|
73
|
+
secure,
|
|
74
|
+
sni: secure ? hostname : undefined,
|
|
75
|
+
alpnProtocols: secure ? [...DEFAULT_ALPN_PROTOCOLS] : undefined,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const connectOptions = options.connect ?? {};
|
|
79
|
+
const readerOptions = options.io?.reader ?? {};
|
|
80
|
+
const writerOptions = options.io?.writer ?? {};
|
|
81
|
+
|
|
82
|
+
let conn: Dialer.ConnectionLike | undefined;
|
|
83
|
+
let connectPromise: Promise<Dialer.ConnectionLike> | undefined;
|
|
84
|
+
|
|
85
|
+
let closed = false;
|
|
86
|
+
let isBusy = false;
|
|
87
|
+
let lastUsedTime = Date.now();
|
|
88
|
+
let idleDeferred = resolvedDeferred();
|
|
89
|
+
|
|
90
|
+
function createBaseErrorContext() {
|
|
91
|
+
return {
|
|
92
|
+
origin: base.origin,
|
|
93
|
+
scheme: base.protocol,
|
|
94
|
+
host: hostname,
|
|
95
|
+
port,
|
|
96
|
+
} as const;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function createRequestErrorContext(url: URL, method?: string) {
|
|
100
|
+
return {
|
|
101
|
+
...createBaseErrorContext(),
|
|
102
|
+
url: url.toString(),
|
|
103
|
+
method,
|
|
104
|
+
} as const;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function markIdle(): void {
|
|
108
|
+
isBusy = false;
|
|
109
|
+
lastUsedTime = Date.now();
|
|
110
|
+
idleDeferred.resolve();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function disposeConn(): void {
|
|
114
|
+
const current = conn;
|
|
115
|
+
conn = undefined;
|
|
116
|
+
if (!current) return;
|
|
117
|
+
try {
|
|
118
|
+
current.close();
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function forceClose(): void {
|
|
123
|
+
if (closed) return;
|
|
124
|
+
closed = true;
|
|
125
|
+
disposeConn();
|
|
126
|
+
if (!isBusy) markIdle();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assertUsable(): void {
|
|
130
|
+
if (closed) throw new AgentClosedError(createBaseErrorContext());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertSameOrigin(url: URL): void {
|
|
134
|
+
if (url.origin !== base.origin) {
|
|
135
|
+
throw new OriginMismatchError(base.origin, url.origin, {
|
|
136
|
+
...createBaseErrorContext(),
|
|
137
|
+
url: url.toString(),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function configureConnection(nextConn: Dialer.ConnectionLike): void {
|
|
143
|
+
nextConn.setNoDelay(connectOptions.noDelay ?? true);
|
|
144
|
+
if (connectOptions.keepAlive !== null) {
|
|
145
|
+
nextConn.setKeepAlive(connectOptions.keepAlive ?? true);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function connect(
|
|
150
|
+
signal?: AbortSignal,
|
|
151
|
+
): Promise<Dialer.ConnectionLike> {
|
|
152
|
+
assertUsable();
|
|
153
|
+
if (conn) return conn;
|
|
154
|
+
if (connectPromise) return withSignal(connectPromise, signal);
|
|
155
|
+
|
|
156
|
+
let timedOut = false;
|
|
157
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
158
|
+
const abortController = new AbortController();
|
|
159
|
+
|
|
160
|
+
const onAbort = () => abortController.abort(signal?.reason);
|
|
161
|
+
const cleanup = () => {
|
|
162
|
+
if (timeoutId !== undefined) {
|
|
163
|
+
clearTimeout(timeoutId);
|
|
164
|
+
timeoutId = undefined;
|
|
165
|
+
}
|
|
166
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
if (signal) {
|
|
170
|
+
if (signal.aborted) abortController.abort(signal.reason);
|
|
171
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (
|
|
175
|
+
connectOptions.timeout != null &&
|
|
176
|
+
Number.isFinite(connectOptions.timeout) &&
|
|
177
|
+
connectOptions.timeout > 0
|
|
178
|
+
) {
|
|
179
|
+
timeoutId = setTimeout(() => {
|
|
180
|
+
timedOut = true;
|
|
181
|
+
abortController.abort(
|
|
182
|
+
new DOMException("Connection timed out", "TimeoutError"),
|
|
183
|
+
);
|
|
184
|
+
}, connectOptions.timeout);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
connectPromise = (async () => {
|
|
188
|
+
try {
|
|
189
|
+
const nextConn = await dialer.dial(target, {
|
|
190
|
+
signal: abortController.signal,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (closed) {
|
|
194
|
+
try {
|
|
195
|
+
nextConn.close();
|
|
196
|
+
} catch {}
|
|
197
|
+
throw new AgentClosedError(createBaseErrorContext());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
configureConnection(nextConn);
|
|
201
|
+
|
|
202
|
+
if (secure && isTlsConnection(nextConn)) {
|
|
203
|
+
const alpn = nextConn.getAlpnProtocol();
|
|
204
|
+
if (alpn != null && alpn !== "" && alpn !== "http/1.1") {
|
|
205
|
+
try {
|
|
206
|
+
nextConn.close();
|
|
207
|
+
} catch {}
|
|
208
|
+
throw new UnsupportedAlpnProtocolError(
|
|
209
|
+
alpn,
|
|
210
|
+
createBaseErrorContext(),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
conn = nextConn;
|
|
216
|
+
return nextConn;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw toConnectError(error, {
|
|
219
|
+
signal,
|
|
220
|
+
timedOut,
|
|
221
|
+
context: createBaseErrorContext(),
|
|
222
|
+
});
|
|
223
|
+
} finally {
|
|
224
|
+
cleanup();
|
|
225
|
+
connectPromise = undefined;
|
|
226
|
+
}
|
|
227
|
+
})();
|
|
228
|
+
|
|
229
|
+
return withSignal(connectPromise, signal);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function executeRequest(
|
|
233
|
+
sendOptions: Agent.SendOptions,
|
|
234
|
+
mapBodyError?: (err: unknown) => unknown,
|
|
235
|
+
): Promise<Response> {
|
|
236
|
+
assertUsable();
|
|
237
|
+
|
|
238
|
+
const url =
|
|
239
|
+
typeof sendOptions.url === "string"
|
|
240
|
+
? new URL(sendOptions.url)
|
|
241
|
+
: sendOptions.url;
|
|
242
|
+
|
|
243
|
+
const method = sendOptions.method.toUpperCase();
|
|
244
|
+
const errorContext = createRequestErrorContext(url, method);
|
|
245
|
+
|
|
246
|
+
if (sendOptions.signal?.aborted) {
|
|
247
|
+
throw new RequestAbortedError(
|
|
248
|
+
sendOptions.signal.reason,
|
|
249
|
+
errorContext,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (isBusy) throw new AgentBusyError(errorContext);
|
|
253
|
+
assertSameOrigin(url);
|
|
254
|
+
if (method === "CONNECT")
|
|
255
|
+
throw new UnsupportedMethodError("CONNECT", errorContext);
|
|
256
|
+
|
|
257
|
+
isBusy = true;
|
|
258
|
+
idleDeferred = new Deferred<void>();
|
|
259
|
+
|
|
260
|
+
let finalized = false;
|
|
261
|
+
let activeConn: Dialer.ConnectionLike | undefined;
|
|
262
|
+
|
|
263
|
+
const finalize = (reusable: boolean) => {
|
|
264
|
+
if (finalized) return;
|
|
265
|
+
finalized = true;
|
|
266
|
+
if (!reusable || closed) {
|
|
267
|
+
if (conn === activeConn) disposeConn();
|
|
268
|
+
else if (activeConn) {
|
|
269
|
+
try {
|
|
270
|
+
activeConn.close();
|
|
271
|
+
} catch {}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
markIdle();
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const abortListener = () => {
|
|
278
|
+
if (activeConn) {
|
|
279
|
+
if (conn === activeConn) conn = undefined;
|
|
280
|
+
try {
|
|
281
|
+
activeConn.close();
|
|
282
|
+
} catch {}
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
activeConn = await connect(sendOptions.signal);
|
|
288
|
+
|
|
289
|
+
sendOptions.signal?.addEventListener("abort", abortListener, {
|
|
290
|
+
once: true,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
await withSignal(
|
|
295
|
+
writeRequest(
|
|
296
|
+
activeConn,
|
|
297
|
+
{
|
|
298
|
+
url,
|
|
299
|
+
method,
|
|
300
|
+
headers: sendOptions.headers,
|
|
301
|
+
body: sendOptions.body ?? null,
|
|
302
|
+
signal: sendOptions.signal,
|
|
303
|
+
},
|
|
304
|
+
writerOptions,
|
|
305
|
+
),
|
|
306
|
+
sendOptions.signal,
|
|
307
|
+
);
|
|
308
|
+
} catch (error) {
|
|
309
|
+
throw toSendError(error, {
|
|
310
|
+
signal: sendOptions.signal,
|
|
311
|
+
context: errorContext,
|
|
312
|
+
phase: "request",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const isHeadRequest = method === "HEAD";
|
|
317
|
+
const shouldIgnoreBody = (status: number) =>
|
|
318
|
+
isHeadRequest ||
|
|
319
|
+
(status >= 100 && status < 200) ||
|
|
320
|
+
status === 204 ||
|
|
321
|
+
status === 304;
|
|
322
|
+
|
|
323
|
+
let response: Response;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
response = await withSignal(
|
|
327
|
+
readResponse(
|
|
328
|
+
activeConn,
|
|
329
|
+
readerOptions,
|
|
330
|
+
shouldIgnoreBody,
|
|
331
|
+
(reusable) => {
|
|
332
|
+
sendOptions.signal?.removeEventListener(
|
|
333
|
+
"abort",
|
|
334
|
+
abortListener,
|
|
335
|
+
);
|
|
336
|
+
finalize(reusable);
|
|
337
|
+
},
|
|
338
|
+
mapBodyError,
|
|
339
|
+
),
|
|
340
|
+
sendOptions.signal,
|
|
341
|
+
);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
throw toSendError(error, {
|
|
344
|
+
signal: sendOptions.signal,
|
|
345
|
+
context: errorContext,
|
|
346
|
+
phase: "response",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return response;
|
|
351
|
+
} catch (error) {
|
|
352
|
+
sendOptions.signal?.removeEventListener("abort", abortListener);
|
|
353
|
+
|
|
354
|
+
if (activeConn) {
|
|
355
|
+
if (conn === activeConn) conn = undefined;
|
|
356
|
+
try {
|
|
357
|
+
activeConn.close();
|
|
358
|
+
} catch {}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
finalize(false);
|
|
362
|
+
throw error;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function send(sendOptions: Agent.SendOptions): Promise<Response> {
|
|
367
|
+
const url =
|
|
368
|
+
typeof sendOptions.url === "string"
|
|
369
|
+
? new URL(sendOptions.url)
|
|
370
|
+
: sendOptions.url;
|
|
371
|
+
const errorContext = createRequestErrorContext(
|
|
372
|
+
url,
|
|
373
|
+
sendOptions.method.toUpperCase(),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
const mapBodyError =
|
|
377
|
+
(
|
|
378
|
+
sendOptions as {
|
|
379
|
+
[bodyErrorMapperSymbol]?: (err: unknown) => unknown;
|
|
380
|
+
}
|
|
381
|
+
)[bodyErrorMapperSymbol] ??
|
|
382
|
+
((error: unknown) =>
|
|
383
|
+
toSendError(error, {
|
|
384
|
+
signal: sendOptions.signal,
|
|
385
|
+
context: errorContext,
|
|
386
|
+
phase: "body",
|
|
387
|
+
}));
|
|
388
|
+
|
|
389
|
+
return executeRequest(sendOptions, mapBodyError);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
[Symbol.dispose]: forceClose,
|
|
394
|
+
close: forceClose,
|
|
395
|
+
hostname,
|
|
396
|
+
port,
|
|
397
|
+
send,
|
|
398
|
+
whenIdle(): Promise<void> {
|
|
399
|
+
return idleDeferred.promise;
|
|
400
|
+
},
|
|
401
|
+
get isIdle(): boolean {
|
|
402
|
+
return !isBusy;
|
|
403
|
+
},
|
|
404
|
+
get lastUsed(): number {
|
|
405
|
+
return lastUsedTime;
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
}
|
package/src/body.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { isAnyArrayBuffer } from "node:util/types";
|
|
4
|
+
import type { IClosable, IReadable } from "@fuman/io";
|
|
5
|
+
import { webReadableToFuman } from "@fuman/io";
|
|
6
|
+
import { utf8 } from "@fuman/utils";
|
|
7
|
+
import { CRLF_LENGTH, CRLF_STR } from "./_internal/consts";
|
|
8
|
+
import {
|
|
9
|
+
type FormDataPolyfill,
|
|
10
|
+
isBlob,
|
|
11
|
+
isFormData,
|
|
12
|
+
isFumanReadable,
|
|
13
|
+
isIterable,
|
|
14
|
+
isMultipartFormDataStream,
|
|
15
|
+
isReadable,
|
|
16
|
+
isReadableStream,
|
|
17
|
+
isURLSearchParameters,
|
|
18
|
+
} from "./_internal/guards";
|
|
19
|
+
|
|
20
|
+
export type BodyInit =
|
|
21
|
+
| Exclude<RequestInit["body"], undefined | null>
|
|
22
|
+
| FormDataPolyfill
|
|
23
|
+
| Readable
|
|
24
|
+
| (IReadable & IClosable);
|
|
25
|
+
|
|
26
|
+
export interface BodyState {
|
|
27
|
+
contentLength: number | null;
|
|
28
|
+
contentType: string | null;
|
|
29
|
+
|
|
30
|
+
body:
|
|
31
|
+
| Readable
|
|
32
|
+
| ReadableStream
|
|
33
|
+
| Uint8Array
|
|
34
|
+
| (IReadable & IClosable)
|
|
35
|
+
| null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Bytes = Uint8Array<ArrayBufferLike>;
|
|
39
|
+
|
|
40
|
+
const BOUNDARY = "-".repeat(2);
|
|
41
|
+
|
|
42
|
+
const makeFormBoundary = (): string =>
|
|
43
|
+
`formdata-${randomBytes(8).toString("hex")}`;
|
|
44
|
+
|
|
45
|
+
const getFormHeader = (
|
|
46
|
+
boundary: string,
|
|
47
|
+
name: string,
|
|
48
|
+
field: File | Blob | string,
|
|
49
|
+
): string => {
|
|
50
|
+
let header = `${BOUNDARY}${boundary}${CRLF_STR}`;
|
|
51
|
+
header += `Content-Disposition: form-data; name="${name}"`;
|
|
52
|
+
if (isBlob(field)) {
|
|
53
|
+
header += `; filename="${(field as File).name ?? "blob"}"${CRLF_STR}`;
|
|
54
|
+
header += `Content-Type: ${field.type || "application/octet-stream"}`;
|
|
55
|
+
}
|
|
56
|
+
return `${header}${CRLF_STR}${CRLF_STR}`;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const getFormFooter = (boundary: string) =>
|
|
60
|
+
`${BOUNDARY}${boundary}${BOUNDARY}${CRLF_STR}${CRLF_STR}`;
|
|
61
|
+
|
|
62
|
+
export const getFormDataLength = (form: FormData, boundary: string) => {
|
|
63
|
+
let length = Buffer.byteLength(getFormFooter(boundary));
|
|
64
|
+
for (const [name, value] of form)
|
|
65
|
+
length +=
|
|
66
|
+
Buffer.byteLength(getFormHeader(boundary, name, value)) +
|
|
67
|
+
(isBlob(value) ? value.size : Buffer.byteLength(`${value}`)) +
|
|
68
|
+
CRLF_LENGTH;
|
|
69
|
+
return length;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
async function* generatorOfFormData(
|
|
73
|
+
form: FormData,
|
|
74
|
+
boundary: string,
|
|
75
|
+
): AsyncGenerator<Bytes> {
|
|
76
|
+
for (const [name, value] of form) {
|
|
77
|
+
if (isBlob(value)) {
|
|
78
|
+
yield utf8.encoder.encode(
|
|
79
|
+
getFormHeader(boundary, name, value),
|
|
80
|
+
) as Bytes;
|
|
81
|
+
|
|
82
|
+
for await (const chunk of value.stream() as any as AsyncIterable<Bytes>) {
|
|
83
|
+
yield chunk;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
yield utf8.encoder.encode(CRLF_STR) as Bytes;
|
|
87
|
+
} else {
|
|
88
|
+
yield utf8.encoder.encode(
|
|
89
|
+
getFormHeader(boundary, name, value) + value + CRLF_STR,
|
|
90
|
+
) as Bytes;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
yield utf8.encoder.encode(getFormFooter(boundary)) as Bytes;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const extractBody = (object: BodyInit | null): BodyState => {
|
|
97
|
+
let type: string | null = null;
|
|
98
|
+
let body:
|
|
99
|
+
| Readable
|
|
100
|
+
| ReadableStream
|
|
101
|
+
| Uint8Array
|
|
102
|
+
| (IReadable & IClosable)
|
|
103
|
+
| null;
|
|
104
|
+
let size: number | null = null;
|
|
105
|
+
|
|
106
|
+
if (object == null) {
|
|
107
|
+
body = null;
|
|
108
|
+
size = 0;
|
|
109
|
+
} else if (typeof object === "string") {
|
|
110
|
+
const bytes = utf8.encoder.encode(`${object}`);
|
|
111
|
+
type = "text/plain;charset=UTF-8";
|
|
112
|
+
size = bytes.byteLength;
|
|
113
|
+
body = bytes;
|
|
114
|
+
} else if (isURLSearchParameters(object)) {
|
|
115
|
+
const bytes = utf8.encoder.encode(object.toString());
|
|
116
|
+
body = bytes;
|
|
117
|
+
size = bytes.byteLength;
|
|
118
|
+
type = "application/x-www-form-urlencoded;charset=UTF-8";
|
|
119
|
+
} else if (isBlob(object)) {
|
|
120
|
+
size = object.size;
|
|
121
|
+
type = object.type || null;
|
|
122
|
+
body = object.stream();
|
|
123
|
+
} else if (object instanceof Uint8Array) {
|
|
124
|
+
body = object;
|
|
125
|
+
size = object.byteLength;
|
|
126
|
+
} else if (isAnyArrayBuffer(object)) {
|
|
127
|
+
const bytes = new Uint8Array(object);
|
|
128
|
+
body = bytes;
|
|
129
|
+
size = bytes.byteLength;
|
|
130
|
+
} else if (ArrayBuffer.isView(object)) {
|
|
131
|
+
const bytes = new Uint8Array(
|
|
132
|
+
object.buffer,
|
|
133
|
+
object.byteOffset,
|
|
134
|
+
object.byteLength,
|
|
135
|
+
);
|
|
136
|
+
body = bytes;
|
|
137
|
+
size = bytes.byteLength;
|
|
138
|
+
} else if (isReadableStream(object)) {
|
|
139
|
+
body = object;
|
|
140
|
+
} else if (isFumanReadable(object)) {
|
|
141
|
+
body = object;
|
|
142
|
+
} else if (isFormData(object)) {
|
|
143
|
+
const boundary = makeFormBoundary();
|
|
144
|
+
type = `multipart/form-data; boundary=${boundary}`;
|
|
145
|
+
size = getFormDataLength(object, boundary);
|
|
146
|
+
body = Readable.from(generatorOfFormData(object, boundary));
|
|
147
|
+
} else if (isMultipartFormDataStream(object)) {
|
|
148
|
+
type = `multipart/form-data; boundary=${object.getBoundary()}`;
|
|
149
|
+
size = object.hasKnownLength() ? object.getLengthSync() : null;
|
|
150
|
+
body = object as Readable;
|
|
151
|
+
} else if (isReadable(object)) {
|
|
152
|
+
body = object as Readable;
|
|
153
|
+
} else if (isIterable(object)) {
|
|
154
|
+
body = Readable.from(object);
|
|
155
|
+
} else {
|
|
156
|
+
const bytes = utf8.encoder.encode(`${object}`);
|
|
157
|
+
type = "text/plain;charset=UTF-8";
|
|
158
|
+
body = bytes;
|
|
159
|
+
size = bytes.byteLength;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
contentLength: size,
|
|
164
|
+
contentType: type,
|
|
165
|
+
body,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export function fromRequestBody(request: Request): BodyInit | null {
|
|
170
|
+
if (request.bodyUsed) {
|
|
171
|
+
throw new TypeError("Request body has already been used");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (request.body == null) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return webReadableToFuman(request.body);
|
|
179
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { ITcpConnection } from "@fuman/net";
|
|
2
|
+
import type { NodeTlsUpgradeOptions } from "@fuman/node";
|
|
3
|
+
import {
|
|
4
|
+
createProxyConnection,
|
|
5
|
+
type ProxyConnectionFn,
|
|
6
|
+
type ProxyInfo,
|
|
7
|
+
parse as parseProxy,
|
|
8
|
+
} from "@npy/proxy-kit";
|
|
9
|
+
import { connectTcp, upgradeTls } from "../_internal/net";
|
|
10
|
+
import type { Dialer } from "../types/dialer";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_HTTP_ALPN_PROTOCOLS = ["http/1.1"] as const;
|
|
13
|
+
|
|
14
|
+
type ProxyConnectOptions = Parameters<typeof connectTcp>[0];
|
|
15
|
+
type UpgradableTcpConnection = Parameters<typeof upgradeTls>[0];
|
|
16
|
+
type ResolvedProxy = Parameters<typeof createProxyConnection>[0]["proxy"];
|
|
17
|
+
|
|
18
|
+
function normalizeProxy(proxy: ProxyDialer.Input): ResolvedProxy {
|
|
19
|
+
if (typeof proxy !== "string") {
|
|
20
|
+
return proxy;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parsed = parseProxy(proxy, { strict: true });
|
|
24
|
+
if (parsed == null) {
|
|
25
|
+
throw new TypeError(`Invalid proxy string: ${proxy}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Dialer that routes connections through an HTTP, HTTPS or SOCKS proxy.
|
|
33
|
+
*
|
|
34
|
+
* @remarks
|
|
35
|
+
* Secure targets are tunneled and then upgraded to TLS after the proxy connection
|
|
36
|
+
* has been established.
|
|
37
|
+
*/
|
|
38
|
+
export class ProxyDialer implements Dialer {
|
|
39
|
+
readonly proxy: ResolvedProxy;
|
|
40
|
+
readonly #options: Readonly<ProxyDialer.Options>;
|
|
41
|
+
readonly #connectThroughProxy: ProxyConnectionFn<ProxyConnectOptions>;
|
|
42
|
+
|
|
43
|
+
constructor(proxy: ProxyDialer.Input, options: ProxyDialer.Options = {}) {
|
|
44
|
+
this.proxy = normalizeProxy(proxy);
|
|
45
|
+
this.#options = { ...options };
|
|
46
|
+
this.#connectThroughProxy = createProxyConnection({
|
|
47
|
+
proxy: this.proxy,
|
|
48
|
+
connectionFn: connectTcp,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async dial(
|
|
53
|
+
target: Dialer.Target,
|
|
54
|
+
options: Dialer.Options = {},
|
|
55
|
+
): Promise<Dialer.ConnectionLike> {
|
|
56
|
+
const tunneled = await this.#connectThroughProxy({
|
|
57
|
+
address: target.address,
|
|
58
|
+
port: target.port,
|
|
59
|
+
signal: options.signal,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!target.secure) {
|
|
63
|
+
return tunneled;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return this.#upgradeSecureTarget(tunneled, target, options.signal);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async #upgradeSecureTarget(
|
|
70
|
+
conn: ITcpConnection,
|
|
71
|
+
target: Dialer.Target,
|
|
72
|
+
signal?: AbortSignal,
|
|
73
|
+
): Promise<Dialer.ConnectionLike> {
|
|
74
|
+
const sni = target.sni ?? this.#options.sni ?? target.address;
|
|
75
|
+
const extraOptions =
|
|
76
|
+
this.#options.extraOptions || target.extraOptions
|
|
77
|
+
? { ...this.#options.extraOptions, ...target.extraOptions }
|
|
78
|
+
: undefined;
|
|
79
|
+
|
|
80
|
+
const tlsOptions: NodeTlsUpgradeOptions & { signal?: AbortSignal } = {
|
|
81
|
+
signal,
|
|
82
|
+
caCerts: this.#options.caCerts,
|
|
83
|
+
sni,
|
|
84
|
+
alpnProtocols: target.alpnProtocols ??
|
|
85
|
+
this.#options.alpnProtocols ?? [...DEFAULT_HTTP_ALPN_PROTOCOLS],
|
|
86
|
+
extraOptions,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return upgradeTls(conn as UpgradableTcpConnection, tlsOptions);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export namespace ProxyDialer {
|
|
94
|
+
export type Input = string | ProxyInfo;
|
|
95
|
+
|
|
96
|
+
export interface Options {
|
|
97
|
+
caCerts?: string[];
|
|
98
|
+
sni?: string;
|
|
99
|
+
alpnProtocols?: string[];
|
|
100
|
+
extraOptions?: NodeTlsUpgradeOptions["extraOptions"];
|
|
101
|
+
}
|
|
102
|
+
}
|