@smart-byte/leani-sdk 0.1.0-rc.1 → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/backfill.js CHANGED
@@ -1,5 +1,6 @@
1
- import { LeaniError, responseError } from "./errors.js";
2
- export { LeaniError } from "./errors.js";
1
+ import { LeaniError, TransportError, responseError } from "./errors.js";
2
+ export { LeaniError, TransportError } from "./errors.js";
3
+ import { concatBytes, idleWatchdog, readChunks, readJson, readText, resolveUnder, send, withDeadline, } from "./transport.js";
3
4
  const BACKFILL_BATCHING_PROFILES = {
4
5
  latency: {
5
6
  targetEncodedBytes: 256 * 1024,
@@ -53,18 +54,19 @@ export function createBackfillSubscriptionClient(options) {
53
54
  const timeoutMs = options.timeoutMs ?? 30_000;
54
55
  assertPositiveInteger(timeoutMs, "timeoutMs");
55
56
  const requestJson = async (method, path, body, signal, credential, sessionToken) => {
56
- const response = await fetchImpl(new URL(path, baseUrl), {
57
+ const headers = requestHeaders(options.token, "application/json", credential, sessionToken);
58
+ const response = await send(fetchImpl, resolveUnder(baseUrl, path), {
57
59
  method,
58
- headers: requestHeaders(options.token, "application/json", credential, sessionToken),
60
+ headers: method === "GET" || body !== undefined
61
+ ? headers
62
+ : markLeaniRequest(headers),
59
63
  body: body === undefined ? undefined : JSON.stringify(body),
60
- signal: signal
61
- ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
62
- : AbortSignal.timeout(timeoutMs),
63
- });
64
+ signal: withDeadline(signal, timeoutMs),
65
+ }, signal);
64
66
  if (!response.ok) {
65
67
  throw await responseError(response);
66
68
  }
67
- return (await response.json());
69
+ return readJson(response, signal);
68
70
  };
69
71
  return Object.freeze({
70
72
  baseUrl,
@@ -130,26 +132,17 @@ export function createBackfillSubscriptionClient(options) {
130
132
  delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, deleteOptions?.signal),
131
133
  changes: (subscription, consumer, changeOptions = {}) => {
132
134
  const path = consumerPath(subscription, consumer, "changes");
133
- const url = new URL(path, baseUrl);
134
- if (changeOptions.limit !== undefined) {
135
- url.searchParams.set("limit", String(assertPositiveInteger(changeOptions.limit, "limit")));
136
- }
137
- return fetchImpl(url, {
138
- headers: requestHeaders(options.token, "application/json", changeOptions.credential),
139
- signal: changeOptions.signal,
140
- }).then(async (response) => {
141
- if (!response.ok) {
142
- throw await responseError(response);
143
- }
144
- return (await response.json());
145
- });
135
+ const query = changeOptions.limit === undefined
136
+ ? ""
137
+ : `?limit=${assertPositiveInteger(changeOptions.limit, "limit")}`;
138
+ return requestJson("GET", `${path}${query}`, undefined, changeOptions.signal, changeOptions.credential);
146
139
  },
147
- stream: (subscription, consumer, streamOptions = {}) => openBackfillStream(fetchImpl, baseUrl, options.token, subscription, consumer, streamOptions.credential, streamOptions.batching, streamOptions.signal),
148
- streamLive: (processor, consumer, streamOptions = {}) => openLiveStream(fetchImpl, baseUrl, options.token, processor, consumer, streamOptions.credential, streamOptions.signal),
149
- subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, request),
140
+ stream: (subscription, consumer, streamOptions = {}) => openBackfillStream(fetchImpl, baseUrl, options.token, timeoutMs, subscription, consumer, streamOptions.credential, streamOptions.batching, streamOptions.signal),
141
+ streamLive: (processor, consumer, streamOptions = {}) => openLiveStream(fetchImpl, baseUrl, options.token, timeoutMs, processor, consumer, streamOptions.credential, streamOptions.signal),
142
+ subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, timeoutMs, request),
150
143
  });
151
144
  }
152
- async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
145
+ async function openMultiLaneDelivery(fetchImpl, baseUrl, token, timeoutMs, request) {
153
146
  const processor = assertIdentity(request.processor, "processor");
154
147
  const consumer = assertIdentity(request.consumer, "consumer");
155
148
  const history = request.lanes.history ?? [];
@@ -164,11 +157,11 @@ async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
164
157
  const lanes = [];
165
158
  try {
166
159
  if (request.lanes.live) {
167
- const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, processor, consumer, request.credential, signal);
160
+ const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, request.credential, signal);
168
161
  lanes.push(unifiedLiveLane(await open(), open, request.signal));
169
162
  }
170
163
  for (const subscriptionId of historyIds) {
171
- const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, subscriptionId, consumer, request.credential, request.batching, signal);
164
+ const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscriptionId, consumer, request.credential, request.batching, signal);
172
165
  lanes.push(unifiedHistoryLane(subscriptionId, await open(), open, request.signal));
173
166
  }
174
167
  }
@@ -289,33 +282,10 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
289
282
  return { done: true, value: undefined };
290
283
  if (next.value.type === "heartbeat")
291
284
  continue;
292
- if (next.value.type === "backfill_complete") {
293
- complete = true;
294
- const record = next.value;
295
- return {
296
- done: false,
297
- value: {
298
- laneKind: "history",
299
- laneId: subscriptionId,
300
- streamId: record.streamId,
301
- ackCursor: record.cursor,
302
- events: [],
303
- completion: record,
304
- record,
305
- },
306
- };
307
- }
308
- const record = next.value;
285
+ complete = next.value.type === "backfill_complete";
309
286
  return {
310
287
  done: false,
311
- value: {
312
- laneKind: "history",
313
- laneId: subscriptionId,
314
- streamId: record.streamId,
315
- ackCursor: record.acknowledgeableCursor,
316
- events: record.changes,
317
- record,
318
- },
288
+ value: historyDeliveryBatch(subscriptionId, next.value),
319
289
  };
320
290
  }
321
291
  },
@@ -323,6 +293,28 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
323
293
  close: connection.close,
324
294
  };
325
295
  }
296
+ /** A history batch or the completion with the cursor that acknowledges it. */
297
+ function historyDeliveryBatch(laneId, record) {
298
+ if (record.type === "backfill_complete") {
299
+ return {
300
+ laneKind: "history",
301
+ laneId,
302
+ streamId: record.streamId,
303
+ ackCursor: record.cursor,
304
+ events: [],
305
+ completion: record,
306
+ record,
307
+ };
308
+ }
309
+ return {
310
+ laneKind: "history",
311
+ laneId,
312
+ streamId: record.streamId,
313
+ ackCursor: record.acknowledgeableCursor,
314
+ events: record.changes,
315
+ record,
316
+ };
317
+ }
326
318
  function reconnectingLaneConnection(initialSession, open, signal) {
327
319
  const expected = initialSession.hello;
328
320
  const stop = new AbortController();
@@ -399,8 +391,10 @@ function reconnectingLaneConnection(initialSession, open, signal) {
399
391
  closed = true;
400
392
  stop.abort();
401
393
  await session.close();
394
+ // A stopped reconnect rejects with the abort reason, which may be the
395
+ // caller's own, of any type.
402
396
  await reconnectTask?.catch((error) => {
403
- if (!isAbortError(error))
397
+ if (!reconnectSignal.aborted)
404
398
  throw error;
405
399
  });
406
400
  },
@@ -418,26 +412,16 @@ function validateReplacementHello(expected, replacement) {
418
412
  throw streamProtocolError("live", "replacement connection changed its durable lane identity");
419
413
  }
420
414
  }
415
+ /**
416
+ * Classified by where the failure came from, never by its message: the SDK
417
+ * reports every failure below HTTP as a retryable TransportError, and a
418
+ * caller's abort, a stream record, or a local check is never retried.
419
+ */
421
420
  function isRetryableDeliveryDisconnect(error) {
422
- if (isAbortError(error) || error instanceof BackfillStreamError)
423
- return false;
424
- if (error instanceof LeaniError) {
425
- return error.retryable || error.code === "consumer_session_active" ||
426
- error.code === "consumer_session_lost";
427
- }
428
- if (!(error instanceof Error))
429
- return false;
430
- if (error.name === "consumer_session_active" ||
431
- error.name === "consumer_session_lost") {
432
- return true;
433
- }
434
- if (error.name === "NetworkError" || error.name === "TimeoutError")
435
- return true;
436
- return (error.name === "TypeError" &&
437
- /fetch|network|load failed|socket|connection/i.test(error.message));
438
- }
439
- function isAbortError(error) {
440
- return error instanceof Error && error.name === "AbortError";
421
+ return (error instanceof LeaniError &&
422
+ (error.retryable ||
423
+ error.code === "consumer_session_active" ||
424
+ error.code === "consumer_session_lost"));
441
425
  }
442
426
  async function waitForReconnectDelay(delayMs, signal) {
443
427
  if (signal.aborted) {
@@ -511,8 +495,8 @@ function iterateUnifiedLanes(lanes, claim) {
511
495
  },
512
496
  };
513
497
  }
514
- async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consumer, credential, batching, signal) {
515
- const session = await openDeliverySession(fetchImpl, baseUrl, token, backfillStreamPath(subscription, consumer, batching), consumerPath(subscription, consumer, "ack"), consumerPath(subscription, consumer, "lease"), credential, signal, "backfill");
498
+ async function openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscription, consumer, credential, batching, signal) {
499
+ const session = await openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, backfillStreamPath(subscription, consumer, batching), consumerPath(subscription, consumer, "ack"), consumerPath(subscription, consumer, "lease"), credential, signal, "backfill");
516
500
  const batches = async function* () {
517
501
  for await (const record of session) {
518
502
  if (record.type === "batch") {
@@ -520,6 +504,15 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
520
504
  }
521
505
  }
522
506
  };
507
+ // The stream ends once the completion is acknowledged, so iteration
508
+ // continues past it rather than closing the session first.
509
+ const deliveryBatches = async function* () {
510
+ for await (const record of session) {
511
+ if (record.type !== "heartbeat") {
512
+ yield historyDeliveryBatch(session.hello.subscriptionId, record);
513
+ }
514
+ }
515
+ };
523
516
  const events = async function* () {
524
517
  for await (const batch of batches()) {
525
518
  yield* batch.changes;
@@ -530,90 +523,119 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
530
523
  acknowledge: session.acknowledge.bind(session),
531
524
  close: session.close.bind(session),
532
525
  batches,
526
+ deliveryBatches,
533
527
  events,
534
528
  [Symbol.asyncIterator]: session[Symbol.asyncIterator].bind(session),
535
529
  });
536
530
  }
537
- async function openLiveStream(fetchImpl, baseUrl, token, processor, consumer, credential, signal) {
538
- return openDeliverySession(fetchImpl, baseUrl, token, liveConsumerPath(processor, consumer, "stream"), liveConsumerPath(processor, consumer, "ack"), liveConsumerPath(processor, consumer, "lease"), credential, signal, "live");
531
+ async function openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, credential, signal) {
532
+ return openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, liveConsumerPath(processor, consumer, "stream"), liveConsumerPath(processor, consumer, "ack"), liveConsumerPath(processor, consumer, "lease"), credential, signal, "live");
539
533
  }
540
- async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
541
- const connectionStop = new AbortController();
542
- const stopFromCaller = () => connectionStop.abort(signal?.reason);
534
+ async function openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
535
+ const connection = new AbortController();
536
+ // A connection that fails on its own, rather than through the caller's
537
+ // abort or close(), keeps the failure for the iterator and acknowledge().
538
+ let failure;
539
+ const fail = (error) => {
540
+ if (connection.signal.aborted) {
541
+ return;
542
+ }
543
+ failure = error;
544
+ connection.abort(error);
545
+ };
546
+ const stopFromCaller = () => connection.abort(signal?.reason);
547
+ signal?.addEventListener("abort", stopFromCaller, { once: true });
543
548
  if (signal?.aborted) {
544
549
  stopFromCaller();
545
550
  }
546
- else {
547
- signal?.addEventListener("abort", stopFromCaller, { once: true });
548
- }
549
- const response = await fetchImpl(new URL(streamPath, baseUrl), {
550
- headers: requestHeaders(token, "application/x-ndjson", credential),
551
- signal: connectionStop.signal,
552
- });
553
- if (!response.ok) {
554
- signal?.removeEventListener("abort", stopFromCaller);
555
- throw await responseError(response);
556
- }
557
- if (!response.body) {
558
- signal?.removeEventListener("abort", stopFromCaller);
559
- throw new BackfillStreamError({
560
- type: "error",
561
- code: "stream_body_missing",
562
- message: `${streamKind} stream response has no body`,
563
- earliestAvailableSequence: null,
564
- latestAvailableSequence: null,
565
- });
566
- }
567
- const records = parseNdjson(response.body, connectionStop.signal);
568
- const first = await records.next();
569
- if (first.done) {
570
- connectionStop.abort();
571
- signal?.removeEventListener("abort", stopFromCaller);
572
- throw streamProtocolError(streamKind, "stream ended before its hello record");
551
+ // Until its hello arrives, the stream may stay silent as long as a JSON
552
+ // request may take; afterwards, for three heartbeat intervals.
553
+ let watchdog = idleWatchdog(timeoutMs, () => fail(new TransportError(`${streamKind} stream sent no hello within ${timeoutMs} ms`)));
554
+ let records;
555
+ let wireHello;
556
+ let sessionToken;
557
+ let leaseTtlMs;
558
+ let heartbeatIntervalMs;
559
+ try {
560
+ watchdog.arm();
561
+ const response = await send(fetchImpl, resolveUnder(baseUrl, streamPath), {
562
+ headers: requestHeaders(token, "application/x-ndjson", credential),
563
+ signal: connection.signal,
564
+ }, signal);
565
+ if (!response.ok) {
566
+ throw await responseError(response);
567
+ }
568
+ if (!response.body) {
569
+ throw new BackfillStreamError({
570
+ type: "error",
571
+ code: "stream_body_missing",
572
+ message: `${streamKind} stream response has no body`,
573
+ earliestAvailableSequence: null,
574
+ latestAvailableSequence: null,
575
+ });
576
+ }
577
+ records = ndjsonRecords(response.body, connection.signal, () => watchdog.arm());
578
+ const first = await records.next();
579
+ if (first.done) {
580
+ throw streamProtocolError(streamKind, "stream ended before its hello record");
581
+ }
582
+ const firstType = first.value.type;
583
+ if (firstType === "error" || firstType === "reset_required") {
584
+ throw new BackfillStreamError(first.value);
585
+ }
586
+ if (firstType !== "hello") {
587
+ throw streamProtocolError(streamKind, "first stream record is not hello");
588
+ }
589
+ wireHello = first.value;
590
+ sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
591
+ leaseTtlMs = parseHelloDuration(wireHello.leaseTtlMs, "leaseTtlMs");
592
+ heartbeatIntervalMs = parseHelloDuration(wireHello.heartbeatIntervalMs, "heartbeatIntervalMs");
573
593
  }
574
- const firstType = first.value.type;
575
- if (firstType === "error" || firstType === "reset_required") {
576
- connectionStop.abort();
594
+ catch (error) {
595
+ connection.abort();
577
596
  signal?.removeEventListener("abort", stopFromCaller);
578
- throw new BackfillStreamError(first.value);
597
+ await records?.return(undefined).catch(() => undefined);
598
+ throw failure ?? error;
579
599
  }
580
- if (firstType !== "hello") {
581
- connectionStop.abort();
582
- signal?.removeEventListener("abort", stopFromCaller);
583
- throw streamProtocolError(streamKind, "first stream record is not hello");
600
+ finally {
601
+ watchdog.disarm();
584
602
  }
585
- const wireHello = first.value;
586
- const sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
587
- const leaseTtlMs = parseLeaseTtl(wireHello.leaseTtlMs);
603
+ const stream = records;
588
604
  const { sessionToken: _sessionToken, ...publicHello } = wireHello;
589
- const renewalStop = new AbortController();
590
- let renewalError;
591
- const renewalTask = renewSessionUntilStopped(fetchImpl, baseUrl, token, renewPath, credential, sessionToken, leaseTtlMs, renewalStop.signal).catch((error) => {
592
- if (!renewalStop.signal.aborted) {
593
- renewalError = error;
605
+ const idleMs = 3 * heartbeatIntervalMs;
606
+ watchdog = idleWatchdog(idleMs, () => fail(new TransportError(`${streamKind} stream sent nothing for ${idleMs} ms`)));
607
+ const renewal = new AbortController();
608
+ const renewalTask = renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, renewal.signal).catch((error) => {
609
+ // A lease that cannot be renewed ends the connection at once instead of
610
+ // at the next record, which may never come.
611
+ if (!renewal.signal.aborted) {
612
+ fail(error);
594
613
  }
595
614
  });
596
615
  let iteratorClaimed = false;
597
616
  let closeTask;
598
617
  const close = () => {
599
618
  closeTask ??= (async () => {
600
- renewalStop.abort();
601
- connectionStop.abort();
619
+ renewal.abort();
620
+ watchdog.disarm();
621
+ connection.abort();
602
622
  signal?.removeEventListener("abort", stopFromCaller);
603
623
  try {
604
- await records.return(undefined);
624
+ await stream.return(undefined);
605
625
  }
606
626
  catch (error) {
607
- if (!connectionStop.signal.aborted) {
627
+ if (!connection.signal.aborted) {
608
628
  throw error;
609
629
  }
610
630
  }
611
631
  await renewalTask;
612
632
  try {
613
- await fetchImpl(new URL(renewPath, baseUrl), {
633
+ const released = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
614
634
  method: "DELETE",
615
- headers: requestHeaders(token, "application/json", credential, sessionToken),
616
- });
635
+ headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
636
+ signal: AbortSignal.timeout(timeoutMs),
637
+ }, undefined);
638
+ void released.body?.cancel().catch(() => undefined);
617
639
  }
618
640
  catch {
619
641
  // The lease also expires and is passively released when the stream
@@ -629,14 +651,27 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
629
651
  iteratorClaimed = true;
630
652
  try {
631
653
  while (true) {
632
- if (renewalError !== undefined) {
633
- throw renewalError;
654
+ let next;
655
+ // Only a pending read counts silence, not the time the application
656
+ // spends on a record.
657
+ watchdog.arm();
658
+ try {
659
+ next = await stream.next();
634
660
  }
635
- const next = await records.next();
636
- if (next.done) {
637
- if (renewalError !== undefined) {
638
- throw renewalError;
661
+ catch (error) {
662
+ // The caller's abort and close() end the iteration quietly.
663
+ if (failure === undefined && connection.signal.aborted) {
664
+ return;
639
665
  }
666
+ throw failure ?? error;
667
+ }
668
+ finally {
669
+ watchdog.disarm();
670
+ }
671
+ if (failure !== undefined) {
672
+ throw failure;
673
+ }
674
+ if (next.done) {
640
675
  return;
641
676
  }
642
677
  const record = next.value;
@@ -660,19 +695,20 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
660
695
  if (closeTask !== undefined) {
661
696
  throw new TypeError("delivery session is closed");
662
697
  }
663
- if (renewalError !== undefined) {
664
- throw renewalError;
698
+ if (failure !== undefined) {
699
+ throw failure;
665
700
  }
666
- const acknowledge = await fetchImpl(new URL(acknowledgePath, baseUrl), {
701
+ const caller = acknowledgeOptions?.signal;
702
+ const acknowledge = await send(fetchImpl, resolveUnder(baseUrl, acknowledgePath), {
667
703
  method: "POST",
668
704
  headers: requestHeaders(token, "application/json", credential, sessionToken),
669
705
  body: JSON.stringify({ cursor: assertIdentity(cursor, "cursor") }),
670
- signal: acknowledgeOptions?.signal,
671
- });
706
+ signal: withDeadline(caller, timeoutMs),
707
+ }, caller);
672
708
  if (!acknowledge.ok) {
673
709
  throw await responseError(acknowledge);
674
710
  }
675
- return (await acknowledge.json());
711
+ return readJson(acknowledge, caller);
676
712
  },
677
713
  close,
678
714
  [Symbol.asyncIterator]: iterate,
@@ -687,79 +723,125 @@ function streamProtocolError(streamKind, message) {
687
723
  latestAvailableSequence: null,
688
724
  });
689
725
  }
690
- async function renewSessionUntilStopped(fetchImpl, baseUrl, token, renewPath, credential, sessionToken, leaseTtlMs, stop) {
726
+ async function renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, stop) {
691
727
  const intervalMs = Math.max(50, Math.floor(leaseTtlMs / 3));
728
+ const firstRetryMs = Math.max(10, Math.floor(intervalMs / 10));
729
+ // The node renewed the lease no earlier than the request that renewed it.
730
+ let renewedAt = performance.now();
731
+ let delayMs = intervalMs;
732
+ let retryMs = firstRetryMs;
692
733
  while (!stop.aborted) {
693
- await abortableDelay(intervalMs, stop);
734
+ await abortableDelay(delayMs, stop);
694
735
  if (stop.aborted) {
695
736
  return;
696
737
  }
697
- const response = await fetchImpl(new URL(renewPath, baseUrl), {
698
- method: "POST",
699
- headers: requestHeaders(token, "application/json", credential, sessionToken),
700
- signal: stop,
701
- });
702
- if (!response.ok) {
703
- throw await responseError(response);
738
+ const attemptedAt = performance.now();
739
+ const leaseLeftMs = renewedAt + leaseTtlMs - attemptedAt;
740
+ try {
741
+ const response = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
742
+ method: "POST",
743
+ headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
744
+ // An answer after the lease lapsed could not keep it.
745
+ signal: withDeadline(stop, Math.max(1, Math.min(timeoutMs, Math.floor(leaseLeftMs)))),
746
+ }, stop);
747
+ if (!response.ok) {
748
+ throw await responseError(response);
749
+ }
750
+ await readText(response, stop);
751
+ renewedAt = attemptedAt;
752
+ delayMs = intervalMs;
753
+ retryMs = firstRetryMs;
754
+ }
755
+ catch (error) {
756
+ // Retry a transient failure, with backoff, while the lease holds.
757
+ const retryable = error instanceof LeaniError && error.retryable;
758
+ if (stop.aborted ||
759
+ !retryable ||
760
+ renewedAt + leaseTtlMs - performance.now() <= retryMs) {
761
+ throw error;
762
+ }
763
+ delayMs = retryMs;
764
+ retryMs *= 2;
704
765
  }
705
- await response.arrayBuffer();
706
766
  }
707
767
  }
708
768
  function abortableDelay(delayMs, signal) {
709
769
  return new Promise((resolve) => {
710
- const timeout = setTimeout(resolve, delayMs);
711
- signal.addEventListener("abort", () => {
770
+ const finish = () => {
712
771
  clearTimeout(timeout);
772
+ signal.removeEventListener("abort", finish);
713
773
  resolve();
714
- }, { once: true });
774
+ };
775
+ const timeout = setTimeout(finish, delayMs);
776
+ signal.addEventListener("abort", finish, { once: true });
715
777
  });
716
778
  }
717
- function parseLeaseTtl(value) {
718
- const ttl = Number(value);
719
- if (!Number.isSafeInteger(ttl) || ttl <= 0) {
720
- throw new TypeError("stream hello contains an invalid leaseTtlMs");
779
+ function parseHelloDuration(value, field) {
780
+ const milliseconds = Number(value);
781
+ if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
782
+ throw new TypeError(`stream hello contains an invalid ${field}`);
721
783
  }
722
- return ttl;
784
+ return milliseconds;
723
785
  }
724
786
  export async function* parseNdjson(body, signal) {
725
- const reader = body.getReader();
726
- const cancel = () => { void reader.cancel().catch(() => { }); };
727
- signal?.addEventListener("abort", cancel, { once: true });
728
- if (signal?.aborted)
729
- cancel();
787
+ yield* ndjsonRecords(body, signal);
788
+ }
789
+ /**
790
+ * The largest NDJSON record the client buffers: the node's default buffer
791
+ * for a batch's changes, 64 MiB, and room for the batch's own fields.
792
+ */
793
+ const MAX_NDJSON_RECORD_BYTES = 65 * 1024 * 1024;
794
+ const LINE_FEED = 0x0a;
795
+ /**
796
+ * Split NDJSON records at line feeds, scanning each byte once. A line feed is
797
+ * ASCII, so a scan by byte never splits a UTF-8 character.
798
+ */
799
+ async function* ndjsonRecords(body, signal, onChunk) {
730
800
  const decoder = new TextDecoder();
731
- let buffer = "";
732
- try {
733
- while (true) {
734
- if (signal?.aborted) {
735
- throw signal.reason ?? new DOMException("aborted", "AbortError");
801
+ // The bytes received so far of the record in progress.
802
+ let pending = [];
803
+ let pendingBytes = 0;
804
+ for await (const chunk of readChunks(body, signal, onChunk)) {
805
+ let lineStart = 0;
806
+ let newline = chunk.indexOf(LINE_FEED);
807
+ while (newline >= 0) {
808
+ const tail = chunk.subarray(lineStart, newline);
809
+ if (pendingBytes + tail.length > MAX_NDJSON_RECORD_BYTES) {
810
+ throw ndjsonRecordTooLarge();
736
811
  }
737
- const { done, value } = await reader.read();
738
- buffer += decoder.decode(value, { stream: !done });
739
- let newline = buffer.indexOf("\n");
740
- while (newline >= 0) {
741
- const line = buffer.slice(0, newline).trim();
742
- buffer = buffer.slice(newline + 1);
743
- if (line) {
744
- yield JSON.parse(line);
745
- }
746
- newline = buffer.indexOf("\n");
812
+ const line = decoder
813
+ .decode(concatBytes(pending, pendingBytes, tail))
814
+ .trim();
815
+ pending = [];
816
+ pendingBytes = 0;
817
+ lineStart = newline + 1;
818
+ if (line) {
819
+ yield JSON.parse(line);
747
820
  }
748
- if (done) {
749
- const finalLine = buffer.trim();
750
- if (finalLine) {
751
- yield JSON.parse(finalLine);
752
- }
753
- return;
821
+ newline = chunk.indexOf(LINE_FEED, lineStart);
822
+ }
823
+ const rest = chunk.subarray(lineStart);
824
+ if (rest.length > 0) {
825
+ pendingBytes += rest.length;
826
+ if (pendingBytes > MAX_NDJSON_RECORD_BYTES) {
827
+ throw ndjsonRecordTooLarge();
754
828
  }
829
+ pending.push(rest);
755
830
  }
756
831
  }
757
- finally {
758
- signal?.removeEventListener("abort", cancel);
759
- await reader.cancel().catch(() => { });
760
- reader.releaseLock();
832
+ if (signal?.aborted) {
833
+ throw signal.reason ?? new DOMException("aborted", "AbortError");
834
+ }
835
+ const finalLine = decoder
836
+ .decode(concatBytes(pending, pendingBytes, new Uint8Array()))
837
+ .trim();
838
+ if (finalLine) {
839
+ yield JSON.parse(finalLine);
761
840
  }
762
841
  }
842
+ function ndjsonRecordTooLarge() {
843
+ return new LeaniError(`NDJSON record exceeds ${MAX_NDJSON_RECORD_BYTES} bytes`, { status: 200, code: "invalid_response", retryable: false });
844
+ }
763
845
  function normalizeBaseUrl(value) {
764
846
  const url = new URL(value);
765
847
  if (!url.pathname.endsWith("/")) {
@@ -783,6 +865,11 @@ function requestHeaders(token, accept, credential, sessionToken) {
783
865
  }
784
866
  return headers;
785
867
  }
868
+ /** Mark a mutation without a JSON body, which the node otherwise refuses. */
869
+ function markLeaniRequest(headers) {
870
+ headers.set("x-leani-request", "1");
871
+ return headers;
872
+ }
786
873
  function consumerPath(subscription, consumer, operation) {
787
874
  return (`v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}` +
788
875
  `/consumers/${encodeURIComponent(assertIdentity(consumer, "consumer"))}/${operation}`);