kinetex 1.2.0 → 1.4.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 +1164 -453
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +3127 -715
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +137 -20
- package/dist/cjs/cache.js +101 -21
- package/dist/cjs/circuit-breaker.js +69 -7
- package/dist/cjs/client.js +838 -191
- package/dist/cjs/cookie-parser.js +110 -9
- package/dist/cjs/cookie-store.js +141 -36
- package/dist/cjs/core.js +501 -63
- package/dist/cjs/dedup.js +58 -18
- package/dist/cjs/digest.js +185 -23
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +362 -48
- package/dist/cjs/interceptors.js +285 -29
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +169 -16
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +261 -28
- package/dist/cjs/progress.js +282 -52
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +167 -36
- package/dist/cjs/sse.js +201 -34
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/worker.js +6 -6
- package/dist/cjs/ws.js +32 -16
- package/dist/esm/aws-sigv4.js +137 -20
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +101 -21
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +69 -7
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +838 -191
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +110 -9
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +141 -36
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +501 -63
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +58 -18
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +185 -23
- package/dist/esm/digest.js.map +1 -1
- package/dist/esm/graphql.js +164 -24
- package/dist/esm/graphql.js.map +1 -1
- package/dist/esm/headers.js +362 -48
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +285 -29
- package/dist/esm/interceptors.js.map +1 -1
- package/dist/esm/lifecycle.js +89 -40
- package/dist/esm/lifecycle.js.map +1 -1
- package/dist/esm/logging.js +169 -16
- package/dist/esm/logging.js.map +1 -1
- package/dist/esm/mod.js +3 -2
- package/dist/esm/mod.js.map +1 -1
- package/dist/esm/pagination.js +261 -28
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +282 -52
- package/dist/esm/progress.js.map +1 -1
- package/dist/esm/proxy.js +413 -0
- package/dist/esm/proxy.js.map +1 -0
- package/dist/esm/response.js +316 -47
- package/dist/esm/response.js.map +1 -1
- package/dist/esm/socks5.js +167 -36
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +201 -34
- package/dist/esm/sse.js.map +1 -1
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/url.js +191 -45
- package/dist/esm/url.js.map +1 -1
- package/dist/esm/utils.js +222 -48
- package/dist/esm/utils.js.map +1 -1
- package/dist/esm/worker.js +6 -6
- package/dist/esm/worker.js.map +1 -1
- package/dist/esm/ws.js +32 -16
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +27 -2
- package/dist/types/cache.d.ts.map +1 -1
- package/dist/types/circuit-breaker.d.ts +14 -1
- package/dist/types/circuit-breaker.d.ts.map +1 -1
- package/dist/types/client.d.ts +98 -23
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/cookie-parser.d.ts +0 -17
- package/dist/types/cookie-parser.d.ts.map +1 -1
- package/dist/types/cookie-store.d.ts.map +1 -1
- package/dist/types/core.d.ts +109 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts +0 -7
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +31 -37
- package/dist/types/digest.d.ts.map +1 -1
- package/dist/types/graphql.d.ts.map +1 -1
- package/dist/types/headers.d.ts +62 -29
- package/dist/types/headers.d.ts.map +1 -1
- package/dist/types/interceptors.d.ts +102 -0
- package/dist/types/interceptors.d.ts.map +1 -1
- package/dist/types/lifecycle.d.ts +19 -2
- package/dist/types/lifecycle.d.ts.map +1 -1
- package/dist/types/logging.d.ts +23 -4
- package/dist/types/logging.d.ts.map +1 -1
- package/dist/types/mod.d.ts +5 -3
- package/dist/types/mod.d.ts.map +1 -1
- package/dist/types/pagination.d.ts +0 -25
- package/dist/types/pagination.d.ts.map +1 -1
- package/dist/types/progress.d.ts +1 -1
- package/dist/types/progress.d.ts.map +1 -1
- package/dist/types/proxy.d.ts +50 -0
- package/dist/types/proxy.d.ts.map +1 -0
- package/dist/types/response.d.ts +7 -1
- package/dist/types/response.d.ts.map +1 -1
- package/dist/types/socks5.d.ts.map +1 -1
- package/dist/types/sse.d.ts.map +1 -1
- package/dist/types/types.d.ts +139 -5
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/url.d.ts +0 -14
- package/dist/types/url.d.ts.map +1 -1
- package/dist/types/utils.d.ts.map +1 -1
- package/dist/types/worker.d.ts +6 -6
- package/dist/types/worker.d.ts.map +1 -1
- package/dist/types/ws.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/cjs/socks5.js
CHANGED
|
@@ -136,6 +136,12 @@ class BufReader {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
function withTimeout(promise, ms, message) {
|
|
139
|
+
// A non-positive budget means "no timeout", matching `denoTcpConnector`,
|
|
140
|
+
// which documents and tests exactly that. Passing 0 to `setTimeout` fired on
|
|
141
|
+
// the next tick, so `connectTimeoutMs: 0` — the conventional way to say
|
|
142
|
+
// "wait as long as it takes" — failed the connection instantly.
|
|
143
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
144
|
+
return promise;
|
|
139
145
|
return new Promise((resolve, reject) => {
|
|
140
146
|
const timer = setTimeout(() => reject(new Socks5Error(message, "SOCKS5_TIMEOUT", true)), ms);
|
|
141
147
|
promise.then((v) => {
|
|
@@ -179,8 +185,46 @@ function encodeAddress(host, remoteDns) {
|
|
|
179
185
|
}
|
|
180
186
|
return new Uint8Array([AddrType.Domain, enc.length, ...enc]);
|
|
181
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* A single hextet: one to four hex digits.
|
|
190
|
+
*
|
|
191
|
+
* `parseInt(g, 16)` is not a validation. It accepts trailing garbage
|
|
192
|
+
* (`parseInt("12zz", 16)` is 18) and returns `NaN` for anything that is not
|
|
193
|
+
* hex at all — and `NaN` then sailed through the `g < 0 || g > 0xffff` range
|
|
194
|
+
* check, because every comparison with `NaN` is false. `NaN >> 8 & 0xff` is
|
|
195
|
+
* 0, so such a group contributed a zero pair.
|
|
196
|
+
*/
|
|
197
|
+
const HEX_GROUP = /^[0-9a-fA-F]{1,4}$/;
|
|
198
|
+
/**
|
|
199
|
+
* True when `s` is a syntactically valid IPv6 literal.
|
|
200
|
+
*
|
|
201
|
+
* Structural, NOT "contains a colon". The old test accepted any string with a
|
|
202
|
+
* colon in it, so a hostname like `g:h:i:j:k:l:m:n` was taken for an IPv6
|
|
203
|
+
* literal: eight groups, every one of them `NaN`, all eight of which passed
|
|
204
|
+
* the range check, and the result encoded as sixteen zero bytes. The proxy
|
|
205
|
+
* was then asked to connect to the unspecified address `::` and the tunnel
|
|
206
|
+
* was established — to the wrong place, with no error anywhere. A name that
|
|
207
|
+
* is not a valid IPv6 literal is now left to the domain-name encoding, where
|
|
208
|
+
* the proxy resolves it and reports host-unreachable if it is not real.
|
|
209
|
+
*/
|
|
182
210
|
function isIPv6(s) {
|
|
183
|
-
|
|
211
|
+
if (!s.includes(":"))
|
|
212
|
+
return false;
|
|
213
|
+
// At most one compressed run ("::"). Two would be ambiguous.
|
|
214
|
+
const halves = s.split("::");
|
|
215
|
+
if (halves.length > 2)
|
|
216
|
+
return false;
|
|
217
|
+
const groupsOf = (part) => part === undefined || part === "" ? [] : part.split(":");
|
|
218
|
+
const left = groupsOf(halves[0]);
|
|
219
|
+
if (halves.length === 2) {
|
|
220
|
+
const right = groupsOf(halves[1]);
|
|
221
|
+
// "::" must stand for at least one omitted group.
|
|
222
|
+
if (left.length + right.length > 7)
|
|
223
|
+
return false;
|
|
224
|
+
return [...left, ...right].every((g) => HEX_GROUP.test(g));
|
|
225
|
+
}
|
|
226
|
+
// Uncompressed: all eight groups must be present and well formed.
|
|
227
|
+
return left.length === 8 && left.every((g) => HEX_GROUP.test(g));
|
|
184
228
|
}
|
|
185
229
|
/**
|
|
186
230
|
* Expand a compressed IPv6 address to 8 groups (16 bytes).
|
|
@@ -198,7 +242,7 @@ function isIPv6(s) {
|
|
|
198
242
|
function expandIPv6(addr) {
|
|
199
243
|
// Expand :: and return 16 bytes
|
|
200
244
|
const halves = addr.split("::");
|
|
201
|
-
const expand = (part) => part ? part.split(":").map((g) =>
|
|
245
|
+
const expand = (part) => part ? part.split(":").map((g) => (HEX_GROUP.test(g) ? parseInt(g, 16) : Number.NaN)) : [];
|
|
202
246
|
let groups;
|
|
203
247
|
if (halves.length === 2) {
|
|
204
248
|
const left = expand(halves[0]);
|
|
@@ -212,9 +256,11 @@ function expandIPv6(addr) {
|
|
|
212
256
|
if (groups.length !== 8) {
|
|
213
257
|
throw new Socks5Error(`Invalid IPv6 address: ${addr}`, "SOCKS5_INVALID_ADDR");
|
|
214
258
|
}
|
|
215
|
-
// Validate each group is
|
|
259
|
+
// Validate each group is a finite 16-bit value. `Number.isFinite` is the
|
|
260
|
+
// load-bearing half: a bare `g < 0 || g > 0xffff` is false for every NaN,
|
|
261
|
+
// so an unparseable group passed and then encoded as a zero pair.
|
|
216
262
|
for (const g of groups) {
|
|
217
|
-
if (g < 0 || g > 0xffff) {
|
|
263
|
+
if (!Number.isFinite(g) || g < 0 || g > 0xffff) {
|
|
218
264
|
throw new Socks5Error(`Invalid IPv6 group: ${g} out of range (0-65535)`, "SOCKS5_INVALID_ADDR");
|
|
219
265
|
}
|
|
220
266
|
}
|
|
@@ -357,6 +403,17 @@ function sleep(ms) {
|
|
|
357
403
|
// ---------------------------------------------------------------------------
|
|
358
404
|
// 8. CONFIG NORMALISATION
|
|
359
405
|
// ---------------------------------------------------------------------------
|
|
406
|
+
/** TCP-level errors worth another attempt. */
|
|
407
|
+
const TRANSIENT_DIAL_CODES = new Set([
|
|
408
|
+
"ECONNREFUSED",
|
|
409
|
+
"ECONNRESET",
|
|
410
|
+
"EHOSTUNREACH",
|
|
411
|
+
"ENETUNREACH",
|
|
412
|
+
"ENETDOWN",
|
|
413
|
+
"ETIMEDOUT",
|
|
414
|
+
"EPIPE",
|
|
415
|
+
"EAI_AGAIN",
|
|
416
|
+
]);
|
|
360
417
|
function resolveConfig(cfg) {
|
|
361
418
|
return {
|
|
362
419
|
host: cfg.host,
|
|
@@ -398,8 +455,33 @@ function resolveConfig(cfg) {
|
|
|
398
455
|
export async function createSocks5Tunnel(proxyConfig, target, connector) {
|
|
399
456
|
const config = resolveConfig(proxyConfig);
|
|
400
457
|
return await withRetry(async () => {
|
|
401
|
-
// Open raw TCP to proxy
|
|
402
|
-
|
|
458
|
+
// Open raw TCP to proxy.
|
|
459
|
+
//
|
|
460
|
+
// A rejection from the connector used to escape UNCHANGED, so a proxy
|
|
461
|
+
// that was not listening surfaced as a bare
|
|
462
|
+
// `Error: connect ECONNREFUSED 127.0.0.1:1` rather than a
|
|
463
|
+
// Socks5Error. Two things broke at once. The documented contract says
|
|
464
|
+
// this function throws Socks5Error, and a caller switching on `code`
|
|
465
|
+
// now had to know about two vocabularies — `ECONNREFUSED` from node:net
|
|
466
|
+
// and `SOCKS5_*` from here. And `withRetry` only retries an error that
|
|
467
|
+
// is a Socks5Error with `retriable` set, so the one failure most worth
|
|
468
|
+
// retrying — the proxy not being up yet — was the one failure never
|
|
469
|
+
// retried, and the exponential backoff this module is built around went
|
|
470
|
+
// unused for it.
|
|
471
|
+
const conn = await withTimeout(connector(config.host, config.port, config.connectTimeoutMs), config.connectTimeoutMs, `TCP connection to SOCKS5 proxy ${config.host}:${config.port} timed out`).catch((err) => {
|
|
472
|
+
// An abort is the caller's own doing and is already an AbortError,
|
|
473
|
+
// and anything already in this module's vocabulary passes through.
|
|
474
|
+
if (err instanceof Socks5Error || err instanceof DOMException)
|
|
475
|
+
throw err;
|
|
476
|
+
const code = err?.code;
|
|
477
|
+
const systemCode = typeof code === "string" ? code : "UNKNOWN";
|
|
478
|
+
throw new Socks5Error(`Cannot reach SOCKS5 proxy ${config.host}:${config.port}: ${err instanceof Error ? err.message : String(err)}`, "SOCKS5_CONNECT_FAILED",
|
|
479
|
+
// Transient at the TCP layer: the proxy may come back, or the path
|
|
480
|
+
// to it may. ENOTFOUND is deliberately excluded — a proxy hostname
|
|
481
|
+
// that does not resolve is a configuration mistake, and retrying it
|
|
482
|
+
// only delays saying so.
|
|
483
|
+
TRANSIENT_DIAL_CODES.has(systemCode));
|
|
484
|
+
});
|
|
403
485
|
try {
|
|
404
486
|
const { boundAddr, boundPort } = await performHandshake(conn, target, config);
|
|
405
487
|
return { conn, boundAddr, boundPort };
|
|
@@ -459,11 +541,23 @@ export function parseSocks5Url(url) {
|
|
|
459
541
|
port,
|
|
460
542
|
remoteDns,
|
|
461
543
|
};
|
|
544
|
+
// Percent-decoding can itself fail: `decodeURIComponent("%ZZ")` throws a
|
|
545
|
+
// `URIError`, which escaped this function as a raw URIError rather than the
|
|
546
|
+
// documented Socks5Error, so a caller mapping error codes never saw one.
|
|
547
|
+
// Nothing here includes the URL, so a clean SOCKS5_BAD_URL leaks nothing.
|
|
548
|
+
const decode = (raw, field) => {
|
|
549
|
+
try {
|
|
550
|
+
return decodeURIComponent(raw);
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
throw new Socks5Error(`Invalid SOCKS5 proxy URL: malformed percent-encoding in the ${field}`, "SOCKS5_BAD_URL");
|
|
554
|
+
}
|
|
555
|
+
};
|
|
462
556
|
if (parsed.username) {
|
|
463
|
-
config.username =
|
|
557
|
+
config.username = decode(parsed.username, "username");
|
|
464
558
|
}
|
|
465
559
|
if (parsed.password) {
|
|
466
|
-
config.password =
|
|
560
|
+
config.password = decode(parsed.password, "password");
|
|
467
561
|
}
|
|
468
562
|
return config;
|
|
469
563
|
}
|
|
@@ -501,31 +595,34 @@ export const denoTcpConnector = async (host, port, timeoutMs) => {
|
|
|
501
595
|
// Type assertion for Deno global which has the connect method
|
|
502
596
|
// Must use type assertion as Deno namespace is not in standard TypeScript types
|
|
503
597
|
const denoGlobal = globalThis;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
else {
|
|
513
|
-
conn = await denoGlobal.Deno.connect({ hostname: host, port, transport: "tcp" });
|
|
514
|
-
}
|
|
515
|
-
const wrappedRead = async (buf) => {
|
|
516
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
517
|
-
return Promise.race([
|
|
518
|
-
conn.read(buf).catch(() => null),
|
|
519
|
-
new Promise((_, rej) => setTimeout(() => rej(new Socks5Error("TCP read timed out", "SOCKS5_TIMEOUT", true)), timeoutMs)),
|
|
520
|
-
]);
|
|
521
|
-
}
|
|
598
|
+
// NOTE: every timeout below is created and cleared explicitly. The previous
|
|
599
|
+
// Promise.race timers were never cleared, so each read left a pending timer
|
|
600
|
+
// (holding a closure, and keeping the Deno event loop alive) for the full
|
|
601
|
+
// timeout window — one per chunk on a streaming tunnel.
|
|
602
|
+
const withTimeout = async (work, message) => {
|
|
603
|
+
if (!timeoutMs || timeoutMs <= 0)
|
|
604
|
+
return work;
|
|
605
|
+
let timer;
|
|
522
606
|
try {
|
|
523
|
-
return await
|
|
607
|
+
return await Promise.race([
|
|
608
|
+
work,
|
|
609
|
+
new Promise((_, rej) => {
|
|
610
|
+
timer = setTimeout(() => rej(new Socks5Error(message, "SOCKS5_TIMEOUT", true)), timeoutMs);
|
|
611
|
+
}),
|
|
612
|
+
]);
|
|
524
613
|
}
|
|
525
|
-
|
|
526
|
-
|
|
614
|
+
finally {
|
|
615
|
+
if (timer !== undefined)
|
|
616
|
+
clearTimeout(timer);
|
|
527
617
|
}
|
|
528
618
|
};
|
|
619
|
+
const conn = await withTimeout(denoGlobal.Deno.connect({ hostname: host, port, transport: "tcp" }), "TCP connect to proxy timed out");
|
|
620
|
+
// Not `async`: it returns withTimeout()'s promise directly, so there is no
|
|
621
|
+
// await to make the function async.
|
|
622
|
+
const wrappedRead = (buf) => {
|
|
623
|
+
const read = conn.read(buf).catch(() => null);
|
|
624
|
+
return withTimeout(read, "TCP read timed out");
|
|
625
|
+
};
|
|
529
626
|
return {
|
|
530
627
|
read: wrappedRead,
|
|
531
628
|
write: (data) => conn.write(data),
|
|
@@ -553,10 +650,20 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
553
650
|
import("node:net")
|
|
554
651
|
.then(({ createConnection }) => {
|
|
555
652
|
const socket = createConnection({ host, port });
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
653
|
+
// Same rule as the Deno connector: 0 (or any non-positive budget)
|
|
654
|
+
// disables the timeout rather than firing on the next tick. Every
|
|
655
|
+
// `clearTimeout(timer)` below is guarded on `timer` being defined.
|
|
656
|
+
let timer;
|
|
657
|
+
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
658
|
+
timer = setTimeout(() => {
|
|
659
|
+
socket.destroy();
|
|
660
|
+
reject(new Socks5Error("TCP connect to proxy timed out", "SOCKS5_TIMEOUT", true));
|
|
661
|
+
}, timeoutMs);
|
|
662
|
+
}
|
|
663
|
+
const clear = () => {
|
|
664
|
+
if (timer !== undefined)
|
|
665
|
+
clearTimeout(timer);
|
|
666
|
+
};
|
|
560
667
|
let buffer = new Uint8Array(0);
|
|
561
668
|
let pendingRead = null;
|
|
562
669
|
let pendingReject = null;
|
|
@@ -590,7 +697,12 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
590
697
|
buffer = next;
|
|
591
698
|
flushBuffer();
|
|
592
699
|
});
|
|
700
|
+
// Latch EOF. Without this, a read issued AFTER `end` parked forever:
|
|
701
|
+
// the socket will never emit data again, nothing rejects, and the caller
|
|
702
|
+
// hangs with no error. Callers now get an immediate null (clean EOF).
|
|
703
|
+
let ended = false;
|
|
593
704
|
socket.on("end", () => {
|
|
705
|
+
ended = true;
|
|
594
706
|
if (pendingRead) {
|
|
595
707
|
pendingRead(null);
|
|
596
708
|
pendingRead = null;
|
|
@@ -598,14 +710,17 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
598
710
|
pendingBuf = null;
|
|
599
711
|
}
|
|
600
712
|
});
|
|
713
|
+
socket.on("close", () => {
|
|
714
|
+
ended = true;
|
|
715
|
+
});
|
|
601
716
|
socket.once("error", (err) => {
|
|
602
|
-
|
|
717
|
+
clear();
|
|
603
718
|
lastError = err;
|
|
604
719
|
rejectPending(err);
|
|
605
720
|
reject(err);
|
|
606
721
|
});
|
|
607
722
|
socket.once("connect", () => {
|
|
608
|
-
|
|
723
|
+
clear();
|
|
609
724
|
socket.on("error", (err) => {
|
|
610
725
|
lastError = err;
|
|
611
726
|
rejectPending(err);
|
|
@@ -616,6 +731,10 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
616
731
|
rej(lastError);
|
|
617
732
|
return;
|
|
618
733
|
}
|
|
734
|
+
if (ended && buffer.length === 0) {
|
|
735
|
+
res(null);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
619
738
|
if (buffer.length > 0) {
|
|
620
739
|
const n = Math.min(buffer.length, buf.length);
|
|
621
740
|
buf.set(buffer.subarray(0, n));
|
|
@@ -675,7 +794,19 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
675
794
|
*/
|
|
676
795
|
export function socks5Connector(proxyConfig, baseConnector) {
|
|
677
796
|
return async (host, port, timeoutMs) => {
|
|
678
|
-
|
|
797
|
+
// The per-call `timeoutMs` is the more specific value, so it must be
|
|
798
|
+
// spread LAST. It was spread first, so a `connectTimeoutMs` in the proxy
|
|
799
|
+
// config silently overrode it — a caller asking for 5s was dialled with
|
|
800
|
+
// the config's 99s, and nothing said so. It also now bounds the
|
|
801
|
+
// handshake, which is the same "how long may this connection take" budget
|
|
802
|
+
// the caller expressed and was otherwise ignored entirely.
|
|
803
|
+
const perCall = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : undefined;
|
|
804
|
+
const connectTimeoutMs = perCall ?? proxyConfig.connectTimeoutMs;
|
|
805
|
+
const tunnel = await createSocks5Tunnel({
|
|
806
|
+
...proxyConfig,
|
|
807
|
+
...(connectTimeoutMs !== undefined ? { connectTimeoutMs } : {}),
|
|
808
|
+
...(perCall !== undefined ? { handshakeTimeoutMs: perCall } : {}),
|
|
809
|
+
}, { host, port }, baseConnector);
|
|
679
810
|
return tunnel.conn;
|
|
680
811
|
};
|
|
681
812
|
}
|
package/dist/cjs/sse.js
CHANGED
|
@@ -113,6 +113,12 @@ export class SSEParser {
|
|
|
113
113
|
}
|
|
114
114
|
switch (field) {
|
|
115
115
|
case "id":
|
|
116
|
+
// A field value containing U+0000 is ignored per spec. This string is
|
|
117
|
+
// replayed verbatim in the `Last-Event-ID` request header on every
|
|
118
|
+
// reconnect, and a NUL is not a legal header value at all — so
|
|
119
|
+
// storing one turns a reconnection into a thrown TypeError.
|
|
120
|
+
if (value.includes("\u0000"))
|
|
121
|
+
break;
|
|
116
122
|
// Empty id resets the last event id to null per spec
|
|
117
123
|
this.id = value || null;
|
|
118
124
|
break;
|
|
@@ -123,9 +129,12 @@ export class SSEParser {
|
|
|
123
129
|
this.data.push(value);
|
|
124
130
|
break;
|
|
125
131
|
case "retry": {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
// Spec: the field is honoured only when every character is an ASCII
|
|
133
|
+
// digit. `parseInt` accepted a trailing "abc", a leading "+", and the
|
|
134
|
+
// "1e3" exponent, so a proxy rewriting the line as
|
|
135
|
+
// `retry: 3000; path=/` silently set the reconnection back-off.
|
|
136
|
+
if (/^[0-9]+$/.test(value))
|
|
137
|
+
this.retry = Number(value);
|
|
129
138
|
break;
|
|
130
139
|
}
|
|
131
140
|
// Unknown fields are ignored per spec
|
|
@@ -288,13 +297,35 @@ export class SSEClient {
|
|
|
288
297
|
async collect(options = {}) {
|
|
289
298
|
const events = [];
|
|
290
299
|
let count = 0;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
300
|
+
// The signal has to reach the read loop, not just the check after an
|
|
301
|
+
// event. Cancelling a collection is for a stream that has gone quiet, and
|
|
302
|
+
// a quiet stream is precisely the one parked in `reader.read()` with no
|
|
303
|
+
// event to reach the check — so the abort was only ever observed once
|
|
304
|
+
// something arrived, which for a stalled endpoint meant never. Aborting
|
|
305
|
+
// the stream's own controller is what makes the pending read settle, so
|
|
306
|
+
// the generator unwinds and the socket is released.
|
|
307
|
+
// An `abort` event never fires on a signal that is already aborted, so a
|
|
308
|
+
// listener alone would silently ignore the one case that needs no
|
|
309
|
+
// waiting: a request cancelled before it starts must not connect at all.
|
|
310
|
+
const signal = options.signal;
|
|
311
|
+
if (signal?.aborted)
|
|
312
|
+
return [];
|
|
313
|
+
const onAbort = () => {
|
|
314
|
+
this._streamController?.abort();
|
|
315
|
+
};
|
|
316
|
+
signal?.addEventListener("abort", onAbort);
|
|
317
|
+
try {
|
|
318
|
+
for await (const event of this._stream()) {
|
|
319
|
+
events.push(event);
|
|
320
|
+
count++;
|
|
321
|
+
if (options.limit && count >= options.limit)
|
|
322
|
+
break;
|
|
323
|
+
if (options.signal?.aborted)
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
signal?.removeEventListener("abort", onAbort);
|
|
298
329
|
}
|
|
299
330
|
return events;
|
|
300
331
|
}
|
|
@@ -334,7 +365,13 @@ export class SSEClient {
|
|
|
334
365
|
let reconnectAttempt = 0;
|
|
335
366
|
// Create abort controller for this stream
|
|
336
367
|
this._streamController = new AbortController();
|
|
337
|
-
|
|
368
|
+
// `close()` aborts this controller, but the reconnect back-off below used
|
|
369
|
+
// to sleep on the *caller's* signal only. A `close()` during a back-off
|
|
370
|
+
// therefore left the generator parked for the full delay — up to
|
|
371
|
+
// `maxReconnectDelayMs` — so `for await (const e of client.stream())` hung
|
|
372
|
+
// long after the client was told to shut down. The back-off watches both.
|
|
373
|
+
const streamSignal = this._streamController.signal;
|
|
374
|
+
while (!this._closed && !streamSignal.aborted) {
|
|
338
375
|
// Snapshot _closed to avoid race with setter firing during yield
|
|
339
376
|
const closedSnapshot = this._closed;
|
|
340
377
|
if (closedSnapshot || cfg.signal?.aborted || this._streamController?.signal.aborted)
|
|
@@ -402,7 +439,17 @@ export class SSEClient {
|
|
|
402
439
|
while (!closedSnapshot) {
|
|
403
440
|
if (cfg.signal?.aborted)
|
|
404
441
|
break;
|
|
405
|
-
|
|
442
|
+
// `closedSnapshot` is a snapshot, and neither signal is wired into
|
|
443
|
+
// the fetch (the request carries the caller's signal, not the
|
|
444
|
+
// stream's), so a `close()` — documented as aborting any active
|
|
445
|
+
// stream — was simply not observed until the server happened to
|
|
446
|
+
// send something. On a long-lived stream, which is the normal
|
|
447
|
+
// case, `for await (const e of client.stream())` never returned
|
|
448
|
+
// after `client.close()`. The read is raced against both signals.
|
|
449
|
+
const result = await readOrAbort(reader.read(), cfg.signal, streamSignal);
|
|
450
|
+
if (result === null)
|
|
451
|
+
break;
|
|
452
|
+
const { done, value } = result;
|
|
406
453
|
if (done)
|
|
407
454
|
break;
|
|
408
455
|
const text = decoder.decode(value, { stream: true });
|
|
@@ -426,15 +473,21 @@ export class SSEClient {
|
|
|
426
473
|
finally {
|
|
427
474
|
if (heartbeatTimer)
|
|
428
475
|
clearTimeout(heartbeatTimer);
|
|
476
|
+
// The returned promise is what has to be handled, not the call.
|
|
477
|
+
// `cancel()` on a reader whose stream is already errored — which is
|
|
478
|
+
// exactly what an abort leaves behind — rejects with that stored
|
|
479
|
+
// error, and a rejection nobody awaits terminates a Node process.
|
|
429
480
|
try {
|
|
430
|
-
reader.cancel();
|
|
481
|
+
void reader.cancel().catch(() => { });
|
|
431
482
|
}
|
|
432
483
|
catch {
|
|
433
484
|
/* ignore */
|
|
434
485
|
}
|
|
435
486
|
}
|
|
436
|
-
// Flush remaining
|
|
437
|
-
|
|
487
|
+
// Flush remaining — but never after an abort or a close(): a
|
|
488
|
+
// half-received event belonging to a stream the caller has already
|
|
489
|
+
// torn down is not something to deliver.
|
|
490
|
+
const final = streamSignal.aborted || cfg.signal?.aborted ? null : parser.flush();
|
|
438
491
|
if (final) {
|
|
439
492
|
this.health.totalEvents++;
|
|
440
493
|
this.health.lastEventAt = Date.now();
|
|
@@ -455,9 +508,16 @@ export class SSEClient {
|
|
|
455
508
|
}
|
|
456
509
|
if (!cfg.reconnect)
|
|
457
510
|
throw err;
|
|
458
|
-
// Max reconnects reached
|
|
459
|
-
|
|
460
|
-
|
|
511
|
+
// Max reconnects reached.
|
|
512
|
+
//
|
|
513
|
+
// Checked against the number of reconnects actually performed, NOT
|
|
514
|
+
// `reconnectAttempt`. That counter is reset on every successful connect
|
|
515
|
+
// (so back-off restarts), which meant a server that accepted the
|
|
516
|
+
// connection and then dropped the stream reset the counter each time —
|
|
517
|
+
// the cap could never be reached and the client reconnected forever,
|
|
518
|
+
// regardless of maxReconnects. `attempts` is the real attempt count.
|
|
519
|
+
if (cfg.maxReconnects > 0 && this.health.totalReconnects >= cfg.maxReconnects) {
|
|
520
|
+
throw new SSEMaxReconnectsError(this.health.totalReconnects, cfg.url);
|
|
461
521
|
}
|
|
462
522
|
reconnectAttempt++;
|
|
463
523
|
this.health.totalReconnects++;
|
|
@@ -472,7 +532,12 @@ export class SSEClient {
|
|
|
472
532
|
clearTimeout(heartbeatTimer);
|
|
473
533
|
heartbeatTimer = null;
|
|
474
534
|
}
|
|
475
|
-
|
|
535
|
+
// Abortable by the caller's signal *and* by close(). Without this, an
|
|
536
|
+
// abort or a close() during back-off had to wait out the full delay (up
|
|
537
|
+
// to maxReconnectDelayMs) before being noticed.
|
|
538
|
+
await sleepOrAbort(delay, cfg.signal, streamSignal);
|
|
539
|
+
if (cfg.signal?.aborted || streamSignal.aborted)
|
|
540
|
+
break;
|
|
476
541
|
parser.reset();
|
|
477
542
|
continue;
|
|
478
543
|
}
|
|
@@ -480,21 +545,33 @@ export class SSEClient {
|
|
|
480
545
|
this.health.connected = false;
|
|
481
546
|
if (!cfg.reconnect)
|
|
482
547
|
break;
|
|
483
|
-
// Max reconnects reached
|
|
484
|
-
|
|
485
|
-
|
|
548
|
+
// Max reconnects reached. Same reasoning as the error path above: the
|
|
549
|
+
// cap bounds reconnects performed, and a successful connect zeroes
|
|
550
|
+
// `reconnectAttempt`, so that counter cannot be used here.
|
|
551
|
+
if (cfg.maxReconnects > 0 && this.health.totalReconnects >= cfg.maxReconnects) {
|
|
552
|
+
throw new SSEMaxReconnectsError(this.health.totalReconnects, cfg.url);
|
|
486
553
|
}
|
|
487
554
|
// Reconnect after stream closed by server
|
|
488
555
|
reconnectAttempt++;
|
|
489
556
|
this.health.totalReconnects++;
|
|
490
|
-
|
|
557
|
+
this.health.reconnectAttempt = reconnectAttempt;
|
|
558
|
+
// Same jitter formula as the error path — a clean server close used to
|
|
559
|
+
// reconnect with zero jitter, so every client in a fleet reconnected in
|
|
560
|
+
// lockstep after a server restart.
|
|
561
|
+
const delay = Math.min(reconnectDelay + reconnectDelay * cfg.reconnectJitter * Math.random(), cfg.maxReconnectDelayMs);
|
|
562
|
+
reconnectDelay = Math.min(reconnectDelay * 2, cfg.maxReconnectDelayMs);
|
|
491
563
|
cfg.onReconnect(reconnectAttempt, delay);
|
|
492
564
|
// Clear orphaned heartbeat timer before sleep to avoid firing during back-off
|
|
493
565
|
if (heartbeatTimer) {
|
|
494
566
|
clearTimeout(heartbeatTimer);
|
|
495
567
|
heartbeatTimer = null;
|
|
496
568
|
}
|
|
497
|
-
|
|
569
|
+
// Same as the error path: without watching the stream signal here too, a
|
|
570
|
+
// close()/abort() during a clean-close back-off was not noticed until the
|
|
571
|
+
// full delay elapsed.
|
|
572
|
+
await sleepOrAbort(delay, cfg.signal, streamSignal);
|
|
573
|
+
if (cfg.signal?.aborted || streamSignal.aborted)
|
|
574
|
+
break;
|
|
498
575
|
}
|
|
499
576
|
this.health.connected = false;
|
|
500
577
|
}
|
|
@@ -565,13 +642,19 @@ export class SSERouter {
|
|
|
565
642
|
*/
|
|
566
643
|
onJSON(eventType, handler) {
|
|
567
644
|
return this.on(eventType, async (data, evt) => {
|
|
645
|
+
let parsed;
|
|
568
646
|
try {
|
|
569
|
-
|
|
570
|
-
await handler(parsed, evt);
|
|
647
|
+
parsed = sanitizeParsedJSON(JSON.parse(data));
|
|
571
648
|
}
|
|
572
649
|
catch {
|
|
573
|
-
|
|
650
|
+
// Only the PARSE is swallowed, which is what the method documents. The
|
|
651
|
+
// handler used to run inside the same `try`, so a handler that threw
|
|
652
|
+
// was discarded with no trace of why — and `dispatch()` / `consume()`
|
|
653
|
+
// awaited every handler, so a caller relying on them to surface
|
|
654
|
+
// failures never saw one.
|
|
655
|
+
return;
|
|
574
656
|
}
|
|
657
|
+
await handler(parsed, evt);
|
|
575
658
|
});
|
|
576
659
|
}
|
|
577
660
|
/** Register a handler for "message" events (default event type). */
|
|
@@ -612,6 +695,24 @@ export class SSERouter {
|
|
|
612
695
|
// ============================================================================
|
|
613
696
|
// §7 SSE SERVER BUILDER
|
|
614
697
|
// ============================================================================
|
|
698
|
+
/**
|
|
699
|
+
* Make a value safe to place after a field name in an SSE stream.
|
|
700
|
+
*
|
|
701
|
+
* The event-stream format has no escaping: a CR, LF or NUL inside a field
|
|
702
|
+
* value ends that line, and whatever follows is parsed as a field of its own.
|
|
703
|
+
* `id` and `event` are written verbatim, so a caller passing a row id, a
|
|
704
|
+
* filename or any other user-controlled string could forge `event:` or
|
|
705
|
+
* `data:` lines into the stream — the receiving parser reads them as
|
|
706
|
+
* first-class fields, not as a fragment of the value. The characters are
|
|
707
|
+
* removed rather than escaped, because there is nothing to escape them with.
|
|
708
|
+
*/
|
|
709
|
+
function asFieldValue(value) {
|
|
710
|
+
// The control characters are the point: CR, LF and NUL are exactly the
|
|
711
|
+
// bytes that would terminate the SSE field line and let the rest of the
|
|
712
|
+
// value be read as fields of its own.
|
|
713
|
+
// deno-lint-ignore no-control-regex
|
|
714
|
+
return value.replace(/[\r\n\u0000]/g, "");
|
|
715
|
+
}
|
|
615
716
|
/**
|
|
616
717
|
* Builder for creating SSE-compatible server responses.
|
|
617
718
|
* Works with any runtime that supports the WHATWG Streams API.
|
|
@@ -639,7 +740,7 @@ export class SSEServerResponse {
|
|
|
639
740
|
/** Send a comment (heartbeat ping). */
|
|
640
741
|
comment(text = "") {
|
|
641
742
|
if (!this._closed)
|
|
642
|
-
this.controller.enqueue(`: ${text}\n\n`);
|
|
743
|
+
this.controller.enqueue(`: ${asFieldValue(text)}\n\n`);
|
|
643
744
|
return this;
|
|
644
745
|
}
|
|
645
746
|
/** Send a "message" event. */
|
|
@@ -652,13 +753,15 @@ export class SSEServerResponse {
|
|
|
652
753
|
return this;
|
|
653
754
|
let msg = "";
|
|
654
755
|
if (options.id !== undefined)
|
|
655
|
-
msg += `id: ${options.id}\n`;
|
|
756
|
+
msg += `id: ${asFieldValue(options.id)}\n`;
|
|
656
757
|
if (event !== "message")
|
|
657
|
-
msg += `event: ${event}\n`;
|
|
758
|
+
msg += `event: ${asFieldValue(event)}\n`;
|
|
658
759
|
if (options.retry !== undefined)
|
|
659
760
|
msg += `retry: ${options.retry}\n`;
|
|
660
|
-
// Multi-line data support
|
|
661
|
-
|
|
761
|
+
// Multi-line data support. Split on all three SSE line endings, not just
|
|
762
|
+
// LF: a bare CR ends a line for every conforming reader, so leaving it
|
|
763
|
+
// inside a `data:` value silently turned one event into two on the way in.
|
|
764
|
+
for (const line of data.split(/\r\n|\r|\n/)) {
|
|
662
765
|
msg += `data: ${line}\n`;
|
|
663
766
|
}
|
|
664
767
|
msg += "\n";
|
|
@@ -825,6 +928,70 @@ export function parseSSEText(text) {
|
|
|
825
928
|
// ============================================================================
|
|
826
929
|
// §10 UTILITIES
|
|
827
930
|
// ============================================================================
|
|
828
|
-
|
|
829
|
-
|
|
931
|
+
/**
|
|
932
|
+
* Sleep for `ms`, or until any of `signals` aborts.
|
|
933
|
+
*
|
|
934
|
+
* `sleep` above only watches one signal, which is not enough for the reconnect
|
|
935
|
+
* back-off: that sleep has to be interruptible both by the caller's signal and
|
|
936
|
+
* by `close()`, and those are two different signals.
|
|
937
|
+
*/
|
|
938
|
+
/**
|
|
939
|
+
* A read that gives up when any of `signals` aborts.
|
|
940
|
+
*
|
|
941
|
+
* Resolves `null` on abort, and otherwise passes the read through — including
|
|
942
|
+
* its rejection, which must still surface (a reset mid-stream is a real
|
|
943
|
+
* error, not an abort). The pending read is left with a no-op rejection
|
|
944
|
+
* handler when the race is won by an abort, or it would surface later as an
|
|
945
|
+
* unhandled rejection and take the host process down.
|
|
946
|
+
*/
|
|
947
|
+
function readOrAbort(read, ...signals) {
|
|
948
|
+
const live = signals.filter((s) => Boolean(s));
|
|
949
|
+
if (live.some((s) => s.aborted)) {
|
|
950
|
+
read.then(undefined, () => { });
|
|
951
|
+
return Promise.resolve(null);
|
|
952
|
+
}
|
|
953
|
+
return new Promise((resolve, reject) => {
|
|
954
|
+
const onAbort = () => {
|
|
955
|
+
detach();
|
|
956
|
+
read.then(undefined, () => { });
|
|
957
|
+
resolve(null);
|
|
958
|
+
};
|
|
959
|
+
const detach = () => {
|
|
960
|
+
for (const s of live)
|
|
961
|
+
s.removeEventListener("abort", onAbort);
|
|
962
|
+
};
|
|
963
|
+
for (const s of live)
|
|
964
|
+
s.addEventListener("abort", onAbort, { once: true });
|
|
965
|
+
read.then((v) => {
|
|
966
|
+
detach();
|
|
967
|
+
resolve(v);
|
|
968
|
+
}, (e) => {
|
|
969
|
+
detach();
|
|
970
|
+
reject(e);
|
|
971
|
+
});
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
function sleepOrAbort(ms, ...signals) {
|
|
975
|
+
const live = signals.filter((s) => Boolean(s));
|
|
976
|
+
if (live.some((s) => s.aborted))
|
|
977
|
+
return Promise.resolve();
|
|
978
|
+
return new Promise((r) => {
|
|
979
|
+
const handlers = [];
|
|
980
|
+
const finish = () => {
|
|
981
|
+
clearTimeout(timer);
|
|
982
|
+
for (const h of handlers)
|
|
983
|
+
h();
|
|
984
|
+
r();
|
|
985
|
+
};
|
|
986
|
+
const timer = setTimeout(finish, ms);
|
|
987
|
+
for (const s of live) {
|
|
988
|
+
const onAbort = () => {
|
|
989
|
+
clearTimeout(timer);
|
|
990
|
+
s.removeEventListener("abort", onAbort);
|
|
991
|
+
r();
|
|
992
|
+
};
|
|
993
|
+
handlers.push(() => s.removeEventListener("abort", onAbort));
|
|
994
|
+
s.addEventListener("abort", onAbort, { once: true });
|
|
995
|
+
}
|
|
996
|
+
});
|
|
830
997
|
}
|