kinetex 1.3.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 +246 -9
- package/dist/browser/kinetex.esm.js +38 -22
- package/dist/browser/kinetex.js +2545 -550
- package/dist/browser/kinetex.min.js +38 -22
- package/dist/cjs/aws-sigv4.js +133 -19
- package/dist/cjs/cache.js +49 -7
- package/dist/cjs/circuit-breaker.js +45 -3
- package/dist/cjs/client.js +387 -104
- package/dist/cjs/cookie-parser.js +103 -5
- package/dist/cjs/cookie-store.js +125 -28
- package/dist/cjs/core.js +465 -66
- package/dist/cjs/dedup.js +49 -11
- package/dist/cjs/digest.js +160 -24
- package/dist/cjs/graphql.js +164 -24
- package/dist/cjs/headers.js +303 -45
- package/dist/cjs/interceptors.js +221 -7
- package/dist/cjs/lifecycle.js +89 -40
- package/dist/cjs/logging.js +168 -15
- package/dist/cjs/mod.js +3 -2
- package/dist/cjs/pagination.js +247 -22
- package/dist/cjs/progress.js +177 -27
- package/dist/cjs/proxy.js +412 -0
- package/dist/cjs/response.js +316 -47
- package/dist/cjs/socks5.js +131 -15
- package/dist/cjs/sse.js +173 -43
- package/dist/cjs/url.js +191 -45
- package/dist/cjs/utils.js +222 -48
- package/dist/cjs/ws.js +19 -10
- package/dist/esm/aws-sigv4.js +133 -19
- package/dist/esm/aws-sigv4.js.map +1 -1
- package/dist/esm/cache.js +49 -7
- package/dist/esm/cache.js.map +1 -1
- package/dist/esm/circuit-breaker.js +45 -3
- package/dist/esm/circuit-breaker.js.map +1 -1
- package/dist/esm/client.js +387 -104
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/cookie-parser.js +103 -5
- package/dist/esm/cookie-parser.js.map +1 -1
- package/dist/esm/cookie-store.js +125 -28
- package/dist/esm/cookie-store.js.map +1 -1
- package/dist/esm/core.js +465 -66
- package/dist/esm/core.js.map +1 -1
- package/dist/esm/dedup.js +49 -11
- package/dist/esm/dedup.js.map +1 -1
- package/dist/esm/digest.js +160 -24
- 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 +303 -45
- package/dist/esm/headers.js.map +1 -1
- package/dist/esm/interceptors.js +221 -7
- 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 +168 -15
- 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 +247 -22
- package/dist/esm/pagination.js.map +1 -1
- package/dist/esm/progress.js +177 -27
- 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 +131 -15
- package/dist/esm/socks5.js.map +1 -1
- package/dist/esm/sse.js +173 -43
- 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/ws.js +19 -10
- package/dist/esm/ws.js.map +1 -1
- package/dist/types/aws-sigv4.d.ts.map +1 -1
- package/dist/types/cache.d.ts +19 -1
- 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 +69 -11
- 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 +103 -25
- package/dist/types/core.d.ts.map +1 -1
- package/dist/types/dedup.d.ts.map +1 -1
- package/dist/types/digest.d.ts +17 -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 +45 -27
- 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 +22 -3
- 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 +114 -3
- 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/ws.d.ts.map +1 -1
- package/package.json +1 -1
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
|
}
|
|
@@ -556,10 +650,20 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
556
650
|
import("node:net")
|
|
557
651
|
.then(({ createConnection }) => {
|
|
558
652
|
const socket = createConnection({ host, port });
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
+
};
|
|
563
667
|
let buffer = new Uint8Array(0);
|
|
564
668
|
let pendingRead = null;
|
|
565
669
|
let pendingReject = null;
|
|
@@ -610,13 +714,13 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
610
714
|
ended = true;
|
|
611
715
|
});
|
|
612
716
|
socket.once("error", (err) => {
|
|
613
|
-
|
|
717
|
+
clear();
|
|
614
718
|
lastError = err;
|
|
615
719
|
rejectPending(err);
|
|
616
720
|
reject(err);
|
|
617
721
|
});
|
|
618
722
|
socket.once("connect", () => {
|
|
619
|
-
|
|
723
|
+
clear();
|
|
620
724
|
socket.on("error", (err) => {
|
|
621
725
|
lastError = err;
|
|
622
726
|
rejectPending(err);
|
|
@@ -690,7 +794,19 @@ export const nodeTcpConnector = (host, port, timeoutMs) => {
|
|
|
690
794
|
*/
|
|
691
795
|
export function socks5Connector(proxyConfig, baseConnector) {
|
|
692
796
|
return async (host, port, timeoutMs) => {
|
|
693
|
-
|
|
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);
|
|
694
810
|
return tunnel.conn;
|
|
695
811
|
};
|
|
696
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();
|
|
@@ -479,10 +532,11 @@ export class SSEClient {
|
|
|
479
532
|
clearTimeout(heartbeatTimer);
|
|
480
533
|
heartbeatTimer = null;
|
|
481
534
|
}
|
|
482
|
-
// Abortable
|
|
483
|
-
// out the full delay (up
|
|
484
|
-
|
|
485
|
-
|
|
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)
|
|
486
540
|
break;
|
|
487
541
|
parser.reset();
|
|
488
542
|
continue;
|
|
@@ -512,10 +566,11 @@ export class SSEClient {
|
|
|
512
566
|
clearTimeout(heartbeatTimer);
|
|
513
567
|
heartbeatTimer = null;
|
|
514
568
|
}
|
|
515
|
-
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
|
|
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)
|
|
519
574
|
break;
|
|
520
575
|
}
|
|
521
576
|
this.health.connected = false;
|
|
@@ -587,13 +642,19 @@ export class SSERouter {
|
|
|
587
642
|
*/
|
|
588
643
|
onJSON(eventType, handler) {
|
|
589
644
|
return this.on(eventType, async (data, evt) => {
|
|
645
|
+
let parsed;
|
|
590
646
|
try {
|
|
591
|
-
|
|
592
|
-
await handler(parsed, evt);
|
|
647
|
+
parsed = sanitizeParsedJSON(JSON.parse(data));
|
|
593
648
|
}
|
|
594
649
|
catch {
|
|
595
|
-
|
|
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;
|
|
596
656
|
}
|
|
657
|
+
await handler(parsed, evt);
|
|
597
658
|
});
|
|
598
659
|
}
|
|
599
660
|
/** Register a handler for "message" events (default event type). */
|
|
@@ -634,6 +695,24 @@ export class SSERouter {
|
|
|
634
695
|
// ============================================================================
|
|
635
696
|
// §7 SSE SERVER BUILDER
|
|
636
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
|
+
}
|
|
637
716
|
/**
|
|
638
717
|
* Builder for creating SSE-compatible server responses.
|
|
639
718
|
* Works with any runtime that supports the WHATWG Streams API.
|
|
@@ -661,7 +740,7 @@ export class SSEServerResponse {
|
|
|
661
740
|
/** Send a comment (heartbeat ping). */
|
|
662
741
|
comment(text = "") {
|
|
663
742
|
if (!this._closed)
|
|
664
|
-
this.controller.enqueue(`: ${text}\n\n`);
|
|
743
|
+
this.controller.enqueue(`: ${asFieldValue(text)}\n\n`);
|
|
665
744
|
return this;
|
|
666
745
|
}
|
|
667
746
|
/** Send a "message" event. */
|
|
@@ -674,13 +753,15 @@ export class SSEServerResponse {
|
|
|
674
753
|
return this;
|
|
675
754
|
let msg = "";
|
|
676
755
|
if (options.id !== undefined)
|
|
677
|
-
msg += `id: ${options.id}\n`;
|
|
756
|
+
msg += `id: ${asFieldValue(options.id)}\n`;
|
|
678
757
|
if (event !== "message")
|
|
679
|
-
msg += `event: ${event}\n`;
|
|
758
|
+
msg += `event: ${asFieldValue(event)}\n`;
|
|
680
759
|
if (options.retry !== undefined)
|
|
681
760
|
msg += `retry: ${options.retry}\n`;
|
|
682
|
-
// Multi-line data support
|
|
683
|
-
|
|
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/)) {
|
|
684
765
|
msg += `data: ${line}\n`;
|
|
685
766
|
}
|
|
686
767
|
msg += "\n";
|
|
@@ -847,21 +928,70 @@ export function parseSSEText(text) {
|
|
|
847
928
|
// ============================================================================
|
|
848
929
|
// §10 UTILITIES
|
|
849
930
|
// ============================================================================
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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) => {
|
|
856
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 = () => {
|
|
857
981
|
clearTimeout(timer);
|
|
858
|
-
|
|
982
|
+
for (const h of handlers)
|
|
983
|
+
h();
|
|
859
984
|
r();
|
|
860
985
|
};
|
|
861
|
-
const timer = setTimeout(
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
+
}
|
|
866
996
|
});
|
|
867
997
|
}
|