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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -29,6 +30,22 @@ const BACKFILL_BATCHING_PROFILES = {
29
30
  export function backfillBatchingProfile(profile) {
30
31
  return { ...BACKFILL_BATCHING_PROFILES[profile] };
31
32
  }
33
+ /**
34
+ * The `fill_missing` subscription for the blocks a live gap notice
35
+ * announces, its `LiveGapChangeData`, or any other range the destination
36
+ * lacks. Its idempotency key derives from the consumer and the range, so
37
+ * creating it again, as after the notice is delivered again, returns the
38
+ * same subscription.
39
+ */
40
+ export function liveGapBackfill(range, request) {
41
+ return {
42
+ ...request,
43
+ mode: "fill_missing",
44
+ fromBlock: range.fromBlock,
45
+ toBlock: range.toBlock,
46
+ idempotencyKey: `live-gap:${request.consumer.id}:${range.fromBlock}-${range.toBlock}`,
47
+ };
48
+ }
32
49
  export class BackfillStreamError extends Error {
33
50
  code;
34
51
  earliestAvailableSequence;
@@ -53,18 +70,19 @@ export function createBackfillSubscriptionClient(options) {
53
70
  const timeoutMs = options.timeoutMs ?? 30_000;
54
71
  assertPositiveInteger(timeoutMs, "timeoutMs");
55
72
  const requestJson = async (method, path, body, signal, credential, sessionToken) => {
56
- const response = await fetchImpl(new URL(path, baseUrl), {
73
+ const headers = requestHeaders(options.token, "application/json", credential, sessionToken);
74
+ const response = await send(fetchImpl, resolveUnder(baseUrl, path), {
57
75
  method,
58
- headers: requestHeaders(options.token, "application/json", credential, sessionToken),
76
+ headers: method === "GET" || body !== undefined
77
+ ? headers
78
+ : markLeaniRequest(headers),
59
79
  body: body === undefined ? undefined : JSON.stringify(body),
60
- signal: signal
61
- ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)])
62
- : AbortSignal.timeout(timeoutMs),
63
- });
80
+ signal: withDeadline(signal, timeoutMs),
81
+ }, signal);
64
82
  if (!response.ok) {
65
83
  throw await responseError(response);
66
84
  }
67
- return (await response.json());
85
+ return readJson(response, signal);
68
86
  };
69
87
  return Object.freeze({
70
88
  baseUrl,
@@ -127,29 +145,21 @@ export function createBackfillSubscriptionClient(options) {
127
145
  },
128
146
  inspect: (subscription, inspectOptions) => requestJson("GET", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, inspectOptions?.signal),
129
147
  cancel: (subscription, cancelOptions) => requestJson("POST", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}/cancel`, undefined, cancelOptions?.signal),
130
- delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}`, undefined, deleteOptions?.signal),
148
+ retry: (subscription, retryOptions) => requestJson("POST", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}/retry`, undefined, retryOptions?.signal),
149
+ delete: (subscription, deleteOptions) => requestJson("DELETE", `admin/v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}${deleteOptions?.discardUnacknowledged ? "?discardUnacknowledged=true" : ""}`, undefined, deleteOptions?.signal),
131
150
  changes: (subscription, consumer, changeOptions = {}) => {
132
151
  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
- });
152
+ const query = changeOptions.limit === undefined
153
+ ? ""
154
+ : `?limit=${assertPositiveInteger(changeOptions.limit, "limit")}`;
155
+ return requestJson("GET", `${path}${query}`, undefined, changeOptions.signal, changeOptions.credential);
146
156
  },
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),
157
+ stream: (subscription, consumer, streamOptions = {}) => openBackfillStream(fetchImpl, baseUrl, options.token, timeoutMs, subscription, consumer, streamOptions.credential, streamOptions.batching, streamOptions.signal),
158
+ streamLive: (processor, consumer, streamOptions = {}) => openLiveStream(fetchImpl, baseUrl, options.token, timeoutMs, processor, consumer, streamOptions.credential, streamOptions.signal),
159
+ subscribe: (request) => openMultiLaneDelivery(fetchImpl, baseUrl, options.token, timeoutMs, request),
150
160
  });
151
161
  }
152
- async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
162
+ async function openMultiLaneDelivery(fetchImpl, baseUrl, token, timeoutMs, request) {
153
163
  const processor = assertIdentity(request.processor, "processor");
154
164
  const consumer = assertIdentity(request.consumer, "consumer");
155
165
  const history = request.lanes.history ?? [];
@@ -164,11 +174,11 @@ async function openMultiLaneDelivery(fetchImpl, baseUrl, token, request) {
164
174
  const lanes = [];
165
175
  try {
166
176
  if (request.lanes.live) {
167
- const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, processor, consumer, request.credential, signal);
177
+ const open = (signal = request.signal) => openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, request.credential, signal);
168
178
  lanes.push(unifiedLiveLane(await open(), open, request.signal));
169
179
  }
170
180
  for (const subscriptionId of historyIds) {
171
- const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, subscriptionId, consumer, request.credential, request.batching, signal);
181
+ const open = (signal = request.signal) => openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscriptionId, consumer, request.credential, request.batching, signal);
172
182
  lanes.push(unifiedHistoryLane(subscriptionId, await open(), open, request.signal));
173
183
  }
174
184
  }
@@ -289,33 +299,10 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
289
299
  return { done: true, value: undefined };
290
300
  if (next.value.type === "heartbeat")
291
301
  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;
302
+ complete = next.value.type === "backfill_complete";
309
303
  return {
310
304
  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
- },
305
+ value: historyDeliveryBatch(subscriptionId, next.value),
319
306
  };
320
307
  }
321
308
  },
@@ -323,6 +310,28 @@ function unifiedHistoryLane(subscriptionId, initialSession, open, signal) {
323
310
  close: connection.close,
324
311
  };
325
312
  }
313
+ /** A history batch or the completion with the cursor that acknowledges it. */
314
+ function historyDeliveryBatch(laneId, record) {
315
+ if (record.type === "backfill_complete") {
316
+ return {
317
+ laneKind: "history",
318
+ laneId,
319
+ streamId: record.streamId,
320
+ ackCursor: record.cursor,
321
+ events: [],
322
+ completion: record,
323
+ record,
324
+ };
325
+ }
326
+ return {
327
+ laneKind: "history",
328
+ laneId,
329
+ streamId: record.streamId,
330
+ ackCursor: record.acknowledgeableCursor,
331
+ events: record.changes,
332
+ record,
333
+ };
334
+ }
326
335
  function reconnectingLaneConnection(initialSession, open, signal) {
327
336
  const expected = initialSession.hello;
328
337
  const stop = new AbortController();
@@ -399,8 +408,10 @@ function reconnectingLaneConnection(initialSession, open, signal) {
399
408
  closed = true;
400
409
  stop.abort();
401
410
  await session.close();
411
+ // A stopped reconnect rejects with the abort reason, which may be the
412
+ // caller's own, of any type.
402
413
  await reconnectTask?.catch((error) => {
403
- if (!isAbortError(error))
414
+ if (!reconnectSignal.aborted)
404
415
  throw error;
405
416
  });
406
417
  },
@@ -418,26 +429,16 @@ function validateReplacementHello(expected, replacement) {
418
429
  throw streamProtocolError("live", "replacement connection changed its durable lane identity");
419
430
  }
420
431
  }
432
+ /**
433
+ * Classified by where the failure came from, never by its message: the SDK
434
+ * reports every failure below HTTP as a retryable TransportError, and a
435
+ * caller's abort, a stream record, or a local check is never retried.
436
+ */
421
437
  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";
438
+ return (error instanceof LeaniError &&
439
+ (error.retryable ||
440
+ error.code === "consumer_session_active" ||
441
+ error.code === "consumer_session_lost"));
441
442
  }
442
443
  async function waitForReconnectDelay(delayMs, signal) {
443
444
  if (signal.aborted) {
@@ -511,8 +512,8 @@ function iterateUnifiedLanes(lanes, claim) {
511
512
  },
512
513
  };
513
514
  }
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");
515
+ async function openBackfillStream(fetchImpl, baseUrl, token, timeoutMs, subscription, consumer, credential, batching, signal) {
516
+ const session = await openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, backfillStreamPath(subscription, consumer, batching), consumerPath(subscription, consumer, "ack"), consumerPath(subscription, consumer, "lease"), credential, signal, "backfill");
516
517
  const batches = async function* () {
517
518
  for await (const record of session) {
518
519
  if (record.type === "batch") {
@@ -520,6 +521,15 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
520
521
  }
521
522
  }
522
523
  };
524
+ // The stream ends once the completion is acknowledged, so iteration
525
+ // continues past it rather than closing the session first.
526
+ const deliveryBatches = async function* () {
527
+ for await (const record of session) {
528
+ if (record.type !== "heartbeat") {
529
+ yield historyDeliveryBatch(session.hello.subscriptionId, record);
530
+ }
531
+ }
532
+ };
523
533
  const events = async function* () {
524
534
  for await (const batch of batches()) {
525
535
  yield* batch.changes;
@@ -530,90 +540,119 @@ async function openBackfillStream(fetchImpl, baseUrl, token, subscription, consu
530
540
  acknowledge: session.acknowledge.bind(session),
531
541
  close: session.close.bind(session),
532
542
  batches,
543
+ deliveryBatches,
533
544
  events,
534
545
  [Symbol.asyncIterator]: session[Symbol.asyncIterator].bind(session),
535
546
  });
536
547
  }
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");
548
+ async function openLiveStream(fetchImpl, baseUrl, token, timeoutMs, processor, consumer, credential, signal) {
549
+ return openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, liveConsumerPath(processor, consumer, "stream"), liveConsumerPath(processor, consumer, "ack"), liveConsumerPath(processor, consumer, "lease"), credential, signal, "live");
539
550
  }
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);
551
+ async function openDeliverySession(fetchImpl, baseUrl, token, timeoutMs, streamPath, acknowledgePath, renewPath, credential, signal, streamKind) {
552
+ const connection = new AbortController();
553
+ // A connection that fails on its own, rather than through the caller's
554
+ // abort or close(), keeps the failure for the iterator and acknowledge().
555
+ let failure;
556
+ const fail = (error) => {
557
+ if (connection.signal.aborted) {
558
+ return;
559
+ }
560
+ failure = error;
561
+ connection.abort(error);
562
+ };
563
+ const stopFromCaller = () => connection.abort(signal?.reason);
564
+ signal?.addEventListener("abort", stopFromCaller, { once: true });
543
565
  if (signal?.aborted) {
544
566
  stopFromCaller();
545
567
  }
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");
568
+ // Until its hello arrives, the stream may stay silent as long as a JSON
569
+ // request may take; afterwards, for three heartbeat intervals.
570
+ let watchdog = idleWatchdog(timeoutMs, () => fail(new TransportError(`${streamKind} stream sent no hello within ${timeoutMs} ms`)));
571
+ let records;
572
+ let wireHello;
573
+ let sessionToken;
574
+ let leaseTtlMs;
575
+ let heartbeatIntervalMs;
576
+ try {
577
+ watchdog.arm();
578
+ const response = await send(fetchImpl, resolveUnder(baseUrl, streamPath), {
579
+ headers: requestHeaders(token, "application/x-ndjson", credential),
580
+ signal: connection.signal,
581
+ }, signal);
582
+ if (!response.ok) {
583
+ throw await responseError(response);
584
+ }
585
+ if (!response.body) {
586
+ throw new BackfillStreamError({
587
+ type: "error",
588
+ code: "stream_body_missing",
589
+ message: `${streamKind} stream response has no body`,
590
+ earliestAvailableSequence: null,
591
+ latestAvailableSequence: null,
592
+ });
593
+ }
594
+ records = ndjsonRecords(response.body, connection.signal, () => watchdog.arm());
595
+ const first = await records.next();
596
+ if (first.done) {
597
+ throw streamProtocolError(streamKind, "stream ended before its hello record");
598
+ }
599
+ const firstType = first.value.type;
600
+ if (firstType === "error" || firstType === "reset_required") {
601
+ throw new BackfillStreamError(first.value);
602
+ }
603
+ if (firstType !== "hello") {
604
+ throw streamProtocolError(streamKind, "first stream record is not hello");
605
+ }
606
+ wireHello = first.value;
607
+ sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
608
+ leaseTtlMs = parseHelloDuration(wireHello.leaseTtlMs, "leaseTtlMs");
609
+ heartbeatIntervalMs = parseHelloDuration(wireHello.heartbeatIntervalMs, "heartbeatIntervalMs");
573
610
  }
574
- const firstType = first.value.type;
575
- if (firstType === "error" || firstType === "reset_required") {
576
- connectionStop.abort();
611
+ catch (error) {
612
+ connection.abort();
577
613
  signal?.removeEventListener("abort", stopFromCaller);
578
- throw new BackfillStreamError(first.value);
614
+ await records?.return(undefined).catch(() => undefined);
615
+ throw failure ?? error;
579
616
  }
580
- if (firstType !== "hello") {
581
- connectionStop.abort();
582
- signal?.removeEventListener("abort", stopFromCaller);
583
- throw streamProtocolError(streamKind, "first stream record is not hello");
617
+ finally {
618
+ watchdog.disarm();
584
619
  }
585
- const wireHello = first.value;
586
- const sessionToken = assertIdentity(wireHello.sessionToken, "stream hello sessionToken");
587
- const leaseTtlMs = parseLeaseTtl(wireHello.leaseTtlMs);
620
+ const stream = records;
588
621
  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;
622
+ const idleMs = 3 * heartbeatIntervalMs;
623
+ watchdog = idleWatchdog(idleMs, () => fail(new TransportError(`${streamKind} stream sent nothing for ${idleMs} ms`)));
624
+ const renewal = new AbortController();
625
+ const renewalTask = renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, renewal.signal).catch((error) => {
626
+ // A lease that cannot be renewed ends the connection at once instead of
627
+ // at the next record, which may never come.
628
+ if (!renewal.signal.aborted) {
629
+ fail(error);
594
630
  }
595
631
  });
596
632
  let iteratorClaimed = false;
597
633
  let closeTask;
598
634
  const close = () => {
599
635
  closeTask ??= (async () => {
600
- renewalStop.abort();
601
- connectionStop.abort();
636
+ renewal.abort();
637
+ watchdog.disarm();
638
+ connection.abort();
602
639
  signal?.removeEventListener("abort", stopFromCaller);
603
640
  try {
604
- await records.return(undefined);
641
+ await stream.return(undefined);
605
642
  }
606
643
  catch (error) {
607
- if (!connectionStop.signal.aborted) {
644
+ if (!connection.signal.aborted) {
608
645
  throw error;
609
646
  }
610
647
  }
611
648
  await renewalTask;
612
649
  try {
613
- await fetchImpl(new URL(renewPath, baseUrl), {
650
+ const released = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
614
651
  method: "DELETE",
615
- headers: requestHeaders(token, "application/json", credential, sessionToken),
616
- });
652
+ headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
653
+ signal: AbortSignal.timeout(timeoutMs),
654
+ }, undefined);
655
+ void released.body?.cancel().catch(() => undefined);
617
656
  }
618
657
  catch {
619
658
  // The lease also expires and is passively released when the stream
@@ -629,14 +668,27 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
629
668
  iteratorClaimed = true;
630
669
  try {
631
670
  while (true) {
632
- if (renewalError !== undefined) {
633
- throw renewalError;
671
+ let next;
672
+ // Only a pending read counts silence, not the time the application
673
+ // spends on a record.
674
+ watchdog.arm();
675
+ try {
676
+ next = await stream.next();
634
677
  }
635
- const next = await records.next();
636
- if (next.done) {
637
- if (renewalError !== undefined) {
638
- throw renewalError;
678
+ catch (error) {
679
+ // The caller's abort and close() end the iteration quietly.
680
+ if (failure === undefined && connection.signal.aborted) {
681
+ return;
639
682
  }
683
+ throw failure ?? error;
684
+ }
685
+ finally {
686
+ watchdog.disarm();
687
+ }
688
+ if (failure !== undefined) {
689
+ throw failure;
690
+ }
691
+ if (next.done) {
640
692
  return;
641
693
  }
642
694
  const record = next.value;
@@ -660,19 +712,20 @@ async function openDeliverySession(fetchImpl, baseUrl, token, streamPath, acknow
660
712
  if (closeTask !== undefined) {
661
713
  throw new TypeError("delivery session is closed");
662
714
  }
663
- if (renewalError !== undefined) {
664
- throw renewalError;
715
+ if (failure !== undefined) {
716
+ throw failure;
665
717
  }
666
- const acknowledge = await fetchImpl(new URL(acknowledgePath, baseUrl), {
718
+ const caller = acknowledgeOptions?.signal;
719
+ const acknowledge = await send(fetchImpl, resolveUnder(baseUrl, acknowledgePath), {
667
720
  method: "POST",
668
721
  headers: requestHeaders(token, "application/json", credential, sessionToken),
669
722
  body: JSON.stringify({ cursor: assertIdentity(cursor, "cursor") }),
670
- signal: acknowledgeOptions?.signal,
671
- });
723
+ signal: withDeadline(caller, timeoutMs),
724
+ }, caller);
672
725
  if (!acknowledge.ok) {
673
726
  throw await responseError(acknowledge);
674
727
  }
675
- return (await acknowledge.json());
728
+ return readJson(acknowledge, caller);
676
729
  },
677
730
  close,
678
731
  [Symbol.asyncIterator]: iterate,
@@ -687,79 +740,125 @@ function streamProtocolError(streamKind, message) {
687
740
  latestAvailableSequence: null,
688
741
  });
689
742
  }
690
- async function renewSessionUntilStopped(fetchImpl, baseUrl, token, renewPath, credential, sessionToken, leaseTtlMs, stop) {
743
+ async function renewSessionUntilStopped(fetchImpl, baseUrl, token, timeoutMs, renewPath, credential, sessionToken, leaseTtlMs, stop) {
691
744
  const intervalMs = Math.max(50, Math.floor(leaseTtlMs / 3));
745
+ const firstRetryMs = Math.max(10, Math.floor(intervalMs / 10));
746
+ // The node renewed the lease no earlier than the request that renewed it.
747
+ let renewedAt = performance.now();
748
+ let delayMs = intervalMs;
749
+ let retryMs = firstRetryMs;
692
750
  while (!stop.aborted) {
693
- await abortableDelay(intervalMs, stop);
751
+ await abortableDelay(delayMs, stop);
694
752
  if (stop.aborted) {
695
753
  return;
696
754
  }
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);
755
+ const attemptedAt = performance.now();
756
+ const leaseLeftMs = renewedAt + leaseTtlMs - attemptedAt;
757
+ try {
758
+ const response = await send(fetchImpl, resolveUnder(baseUrl, renewPath), {
759
+ method: "POST",
760
+ headers: markLeaniRequest(requestHeaders(token, "application/json", credential, sessionToken)),
761
+ // An answer after the lease lapsed could not keep it.
762
+ signal: withDeadline(stop, Math.max(1, Math.min(timeoutMs, Math.floor(leaseLeftMs)))),
763
+ }, stop);
764
+ if (!response.ok) {
765
+ throw await responseError(response);
766
+ }
767
+ await readText(response, stop);
768
+ renewedAt = attemptedAt;
769
+ delayMs = intervalMs;
770
+ retryMs = firstRetryMs;
771
+ }
772
+ catch (error) {
773
+ // Retry a transient failure, with backoff, while the lease holds.
774
+ const retryable = error instanceof LeaniError && error.retryable;
775
+ if (stop.aborted ||
776
+ !retryable ||
777
+ renewedAt + leaseTtlMs - performance.now() <= retryMs) {
778
+ throw error;
779
+ }
780
+ delayMs = retryMs;
781
+ retryMs *= 2;
704
782
  }
705
- await response.arrayBuffer();
706
783
  }
707
784
  }
708
785
  function abortableDelay(delayMs, signal) {
709
786
  return new Promise((resolve) => {
710
- const timeout = setTimeout(resolve, delayMs);
711
- signal.addEventListener("abort", () => {
787
+ const finish = () => {
712
788
  clearTimeout(timeout);
789
+ signal.removeEventListener("abort", finish);
713
790
  resolve();
714
- }, { once: true });
791
+ };
792
+ const timeout = setTimeout(finish, delayMs);
793
+ signal.addEventListener("abort", finish, { once: true });
715
794
  });
716
795
  }
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");
796
+ function parseHelloDuration(value, field) {
797
+ const milliseconds = Number(value);
798
+ if (!Number.isSafeInteger(milliseconds) || milliseconds <= 0) {
799
+ throw new TypeError(`stream hello contains an invalid ${field}`);
721
800
  }
722
- return ttl;
801
+ return milliseconds;
723
802
  }
724
803
  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();
804
+ yield* ndjsonRecords(body, signal);
805
+ }
806
+ /**
807
+ * The largest NDJSON record the client buffers: the node's default buffer
808
+ * for a batch's changes, 64 MiB, and room for the batch's own fields.
809
+ */
810
+ const MAX_NDJSON_RECORD_BYTES = 65 * 1024 * 1024;
811
+ const LINE_FEED = 0x0a;
812
+ /**
813
+ * Split NDJSON records at line feeds, scanning each byte once. A line feed is
814
+ * ASCII, so a scan by byte never splits a UTF-8 character.
815
+ */
816
+ async function* ndjsonRecords(body, signal, onChunk) {
730
817
  const decoder = new TextDecoder();
731
- let buffer = "";
732
- try {
733
- while (true) {
734
- if (signal?.aborted) {
735
- throw signal.reason ?? new DOMException("aborted", "AbortError");
818
+ // The bytes received so far of the record in progress.
819
+ let pending = [];
820
+ let pendingBytes = 0;
821
+ for await (const chunk of readChunks(body, signal, onChunk)) {
822
+ let lineStart = 0;
823
+ let newline = chunk.indexOf(LINE_FEED);
824
+ while (newline >= 0) {
825
+ const tail = chunk.subarray(lineStart, newline);
826
+ if (pendingBytes + tail.length > MAX_NDJSON_RECORD_BYTES) {
827
+ throw ndjsonRecordTooLarge();
736
828
  }
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");
829
+ const line = decoder
830
+ .decode(concatBytes(pending, pendingBytes, tail))
831
+ .trim();
832
+ pending = [];
833
+ pendingBytes = 0;
834
+ lineStart = newline + 1;
835
+ if (line) {
836
+ yield JSON.parse(line);
747
837
  }
748
- if (done) {
749
- const finalLine = buffer.trim();
750
- if (finalLine) {
751
- yield JSON.parse(finalLine);
752
- }
753
- return;
838
+ newline = chunk.indexOf(LINE_FEED, lineStart);
839
+ }
840
+ const rest = chunk.subarray(lineStart);
841
+ if (rest.length > 0) {
842
+ pendingBytes += rest.length;
843
+ if (pendingBytes > MAX_NDJSON_RECORD_BYTES) {
844
+ throw ndjsonRecordTooLarge();
754
845
  }
846
+ pending.push(rest);
755
847
  }
756
848
  }
757
- finally {
758
- signal?.removeEventListener("abort", cancel);
759
- await reader.cancel().catch(() => { });
760
- reader.releaseLock();
849
+ if (signal?.aborted) {
850
+ throw signal.reason ?? new DOMException("aborted", "AbortError");
851
+ }
852
+ const finalLine = decoder
853
+ .decode(concatBytes(pending, pendingBytes, new Uint8Array()))
854
+ .trim();
855
+ if (finalLine) {
856
+ yield JSON.parse(finalLine);
761
857
  }
762
858
  }
859
+ function ndjsonRecordTooLarge() {
860
+ return new LeaniError(`NDJSON record exceeds ${MAX_NDJSON_RECORD_BYTES} bytes`, { status: 200, code: "invalid_response", retryable: false });
861
+ }
763
862
  function normalizeBaseUrl(value) {
764
863
  const url = new URL(value);
765
864
  if (!url.pathname.endsWith("/")) {
@@ -783,6 +882,11 @@ function requestHeaders(token, accept, credential, sessionToken) {
783
882
  }
784
883
  return headers;
785
884
  }
885
+ /** Mark a mutation without a JSON body, which the node otherwise refuses. */
886
+ function markLeaniRequest(headers) {
887
+ headers.set("x-leani-request", "1");
888
+ return headers;
889
+ }
786
890
  function consumerPath(subscription, consumer, operation) {
787
891
  return (`v1/backfill-subscriptions/${encodeURIComponent(assertIdentity(subscription, "subscription"))}` +
788
892
  `/consumers/${encodeURIComponent(assertIdentity(consumer, "consumer"))}/${operation}`);