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.
Files changed (120) hide show
  1. package/README.md +246 -9
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +2545 -550
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +133 -19
  6. package/dist/cjs/cache.js +49 -7
  7. package/dist/cjs/circuit-breaker.js +45 -3
  8. package/dist/cjs/client.js +387 -104
  9. package/dist/cjs/cookie-parser.js +103 -5
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +465 -66
  12. package/dist/cjs/dedup.js +49 -11
  13. package/dist/cjs/digest.js +160 -24
  14. package/dist/cjs/graphql.js +164 -24
  15. package/dist/cjs/headers.js +303 -45
  16. package/dist/cjs/interceptors.js +221 -7
  17. package/dist/cjs/lifecycle.js +89 -40
  18. package/dist/cjs/logging.js +168 -15
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +177 -27
  22. package/dist/cjs/proxy.js +412 -0
  23. package/dist/cjs/response.js +316 -47
  24. package/dist/cjs/socks5.js +131 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +222 -48
  28. package/dist/cjs/ws.js +19 -10
  29. package/dist/esm/aws-sigv4.js +133 -19
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +49 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +45 -3
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +387 -104
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +103 -5
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +465 -66
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +49 -11
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +160 -24
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +164 -24
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +303 -45
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +221 -7
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +89 -40
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +168 -15
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +177 -27
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +413 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +316 -47
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +131 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +222 -48
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +19 -10
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +14 -1
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +103 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts.map +1 -1
  91. package/dist/types/digest.d.ts +17 -37
  92. package/dist/types/digest.d.ts.map +1 -1
  93. package/dist/types/graphql.d.ts.map +1 -1
  94. package/dist/types/headers.d.ts +45 -27
  95. package/dist/types/headers.d.ts.map +1 -1
  96. package/dist/types/interceptors.d.ts +102 -0
  97. package/dist/types/interceptors.d.ts.map +1 -1
  98. package/dist/types/lifecycle.d.ts +19 -2
  99. package/dist/types/lifecycle.d.ts.map +1 -1
  100. package/dist/types/logging.d.ts +22 -3
  101. package/dist/types/logging.d.ts.map +1 -1
  102. package/dist/types/mod.d.ts +5 -3
  103. package/dist/types/mod.d.ts.map +1 -1
  104. package/dist/types/pagination.d.ts +0 -25
  105. package/dist/types/pagination.d.ts.map +1 -1
  106. package/dist/types/progress.d.ts +1 -1
  107. package/dist/types/progress.d.ts.map +1 -1
  108. package/dist/types/proxy.d.ts +50 -0
  109. package/dist/types/proxy.d.ts.map +1 -0
  110. package/dist/types/response.d.ts +7 -1
  111. package/dist/types/response.d.ts.map +1 -1
  112. package/dist/types/socks5.d.ts.map +1 -1
  113. package/dist/types/sse.d.ts.map +1 -1
  114. package/dist/types/types.d.ts +114 -3
  115. package/dist/types/types.d.ts.map +1 -1
  116. package/dist/types/url.d.ts +0 -14
  117. package/dist/types/url.d.ts.map +1 -1
  118. package/dist/types/utils.d.ts.map +1 -1
  119. package/dist/types/ws.d.ts.map +1 -1
  120. package/package.json +1 -1
@@ -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
- return s.includes(":");
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) => parseInt(g || "0", 16)) : [];
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 within 16-bit range (0-0xFFFF)
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
- const conn = await withTimeout(connector(config.host, config.port, config.connectTimeoutMs), config.connectTimeoutMs, `TCP connection to SOCKS5 proxy ${config.host}:${config.port} timed out`);
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 = decodeURIComponent(parsed.username);
557
+ config.username = decode(parsed.username, "username");
464
558
  }
465
559
  if (parsed.password) {
466
- config.password = decodeURIComponent(parsed.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
- const timer = setTimeout(() => {
560
- socket.destroy();
561
- reject(new Socks5Error("TCP connect to proxy timed out", "SOCKS5_TIMEOUT", true));
562
- }, timeoutMs);
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
- clearTimeout(timer);
717
+ clear();
614
718
  lastError = err;
615
719
  rejectPending(err);
616
720
  reject(err);
617
721
  });
618
722
  socket.once("connect", () => {
619
- clearTimeout(timer);
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
- const tunnel = await createSocks5Tunnel({ connectTimeoutMs: timeoutMs, ...proxyConfig }, { host, port }, baseConnector);
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
- const ms = parseInt(value, 10);
127
- if (!isNaN(ms) && ms >= 0)
128
- this.retry = ms;
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
- for await (const event of this._stream()) {
292
- events.push(event);
293
- count++;
294
- if (options.limit && count >= options.limit)
295
- break;
296
- if (options.signal?.aborted)
297
- break;
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
- while (!this._closed && !this._streamController?.signal.aborted) {
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
- const { done, value } = await reader.read();
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
- const final = parser.flush();
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: without the signal an abort() during back-off had to wait
483
- // out the full delay (up to maxReconnectDelayMs) before being noticed.
484
- await sleep(delay, cfg.signal);
485
- if (cfg.signal?.aborted)
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
- await sleep(delay, cfg.signal);
516
- // Same as the error path: without this, a close()/abort() during a
517
- // clean-close back-off was not noticed until the full delay elapsed.
518
- if (cfg.signal?.aborted)
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
- const parsed = sanitizeParsedJSON(JSON.parse(data));
592
- await handler(parsed, evt);
647
+ parsed = sanitizeParsedJSON(JSON.parse(data));
593
648
  }
594
649
  catch {
595
- /* ignore parse error */
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
- for (const line of data.split("\n")) {
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
- function sleep(ms, signal) {
851
- return new Promise((r) => {
852
- if (signal?.aborted) {
853
- r();
854
- return;
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
- signal?.removeEventListener("abort", onAbort);
982
+ for (const h of handlers)
983
+ h();
859
984
  r();
860
985
  };
861
- const timer = setTimeout(() => {
862
- signal?.removeEventListener("abort", onAbort);
863
- r();
864
- }, ms);
865
- signal?.addEventListener("abort", onAbort, { once: true });
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
  }