@durable-streams/client 0.1.5 → 0.2.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/dist/index.cjs CHANGED
@@ -506,7 +506,10 @@ async function* parseSSEStream(stream$1, signal) {
506
506
  streamCursor: control.streamCursor,
507
507
  upToDate: control.upToDate
508
508
  };
509
- } catch {}
509
+ } catch (err) {
510
+ const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
511
+ throw new DurableStreamError(`Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
512
+ }
510
513
  }
511
514
  currentEvent = { data: [] };
512
515
  } else if (line.startsWith(`event:`)) currentEvent.type = line.slice(6).trim();
@@ -531,7 +534,10 @@ async function* parseSSEStream(stream$1, signal) {
531
534
  streamCursor: control.streamCursor,
532
535
  upToDate: control.upToDate
533
536
  };
534
- } catch {}
537
+ } catch (err) {
538
+ const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
539
+ throw new DurableStreamError(`Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
540
+ }
535
541
  }
536
542
  } finally {
537
543
  reader.releaseLock();
@@ -557,9 +563,9 @@ var StreamResponseImpl = class {
557
563
  #statusText;
558
564
  #ok;
559
565
  #isLoading;
560
- offset;
561
- cursor;
562
- upToDate;
566
+ #offset;
567
+ #cursor;
568
+ #upToDate;
563
569
  #isJsonMode;
564
570
  #abortController;
565
571
  #fetchNext;
@@ -585,9 +591,9 @@ var StreamResponseImpl = class {
585
591
  this.contentType = config.contentType;
586
592
  this.live = config.live;
587
593
  this.startOffset = config.startOffset;
588
- this.offset = config.initialOffset;
589
- this.cursor = config.initialCursor;
590
- this.upToDate = config.initialUpToDate;
594
+ this.#offset = config.initialOffset;
595
+ this.#cursor = config.initialCursor;
596
+ this.#upToDate = config.initialUpToDate;
591
597
  this.#headers = config.firstResponse.headers;
592
598
  this.#status = config.firstResponse.status;
593
599
  this.#statusText = config.firstResponse.statusText;
@@ -678,6 +684,15 @@ var StreamResponseImpl = class {
678
684
  get isLoading() {
679
685
  return this.#isLoading;
680
686
  }
687
+ get offset() {
688
+ return this.#offset;
689
+ }
690
+ get cursor() {
691
+ return this.#cursor;
692
+ }
693
+ get upToDate() {
694
+ return this.#upToDate;
695
+ }
681
696
  #ensureJsonMode() {
682
697
  if (!this.#isJsonMode) throw new DurableStreamError(`JSON methods are only valid for JSON-mode streams. Content-Type is "${this.contentType}" and json hint was not set.`, `BAD_REQUEST`);
683
698
  }
@@ -711,10 +726,10 @@ var StreamResponseImpl = class {
711
726
  */
712
727
  #updateStateFromResponse(response) {
713
728
  const offset = response.headers.get(STREAM_OFFSET_HEADER);
714
- if (offset) this.offset = offset;
729
+ if (offset) this.#offset = offset;
715
730
  const cursor = response.headers.get(STREAM_CURSOR_HEADER);
716
- if (cursor) this.cursor = cursor;
717
- this.upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
731
+ if (cursor) this.#cursor = cursor;
732
+ this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
718
733
  this.#headers = response.headers;
719
734
  this.#status = response.status;
720
735
  this.#statusText = response.statusText;
@@ -756,9 +771,9 @@ var StreamResponseImpl = class {
756
771
  * Update instance state from an SSE control event.
757
772
  */
758
773
  #updateStateFromSSEControl(controlEvent) {
759
- this.offset = controlEvent.streamNextOffset;
760
- if (controlEvent.streamCursor) this.cursor = controlEvent.streamCursor;
761
- if (controlEvent.upToDate !== void 0) this.upToDate = controlEvent.upToDate;
774
+ this.#offset = controlEvent.streamNextOffset;
775
+ if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
776
+ if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
762
777
  }
763
778
  /**
764
779
  * Mark the start of an SSE connection for duration tracking.
@@ -1024,7 +1039,13 @@ var StreamResponseImpl = class {
1024
1039
  const wasUpToDate = this.upToDate;
1025
1040
  const text = await result.value.text();
1026
1041
  const content = text.trim() || `[]`;
1027
- const parsed = JSON.parse(content);
1042
+ let parsed;
1043
+ try {
1044
+ parsed = JSON.parse(content);
1045
+ } catch (err) {
1046
+ const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1047
+ throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1048
+ }
1028
1049
  if (Array.isArray(parsed)) items.push(...parsed);
1029
1050
  else items.push(parsed);
1030
1051
  if (wasUpToDate) break;
@@ -1121,7 +1142,13 @@ var StreamResponseImpl = class {
1121
1142
  }
1122
1143
  const text = await response.text();
1123
1144
  const content = text.trim() || `[]`;
1124
- const parsed = JSON.parse(content);
1145
+ let parsed;
1146
+ try {
1147
+ parsed = JSON.parse(content);
1148
+ } catch (err) {
1149
+ const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1150
+ throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1151
+ }
1125
1152
  pendingItems = Array.isArray(parsed) ? parsed : [parsed];
1126
1153
  if (pendingItems.length > 0) controller.enqueue(pendingItems.shift());
1127
1154
  },
@@ -1160,7 +1187,13 @@ var StreamResponseImpl = class {
1160
1187
  const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
1161
1188
  const text = await response.text();
1162
1189
  const content = text.trim() || `[]`;
1163
- const parsed = JSON.parse(content);
1190
+ let parsed;
1191
+ try {
1192
+ parsed = JSON.parse(content);
1193
+ } catch (err) {
1194
+ const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1195
+ throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1196
+ }
1164
1197
  const items = Array.isArray(parsed) ? parsed : [parsed];
1165
1198
  await subscriber({
1166
1199
  items,
@@ -1405,7 +1438,7 @@ function _resetHttpWarningForTesting() {
1405
1438
  * url,
1406
1439
  * auth,
1407
1440
  * offset: savedOffset,
1408
- * live: "auto",
1441
+ * live: true,
1409
1442
  * })
1410
1443
  * live.subscribeJson(async (batch) => {
1411
1444
  * for (const item of batch.items) {
@@ -1446,10 +1479,11 @@ async function stream(options) {
1446
1479
  */
1447
1480
  async function streamInternal(options) {
1448
1481
  const url = options.url instanceof URL ? options.url.toString() : options.url;
1482
+ warnIfUsingHttpInBrowser(url, options.warnOnHttp);
1449
1483
  const fetchUrl = new URL(url);
1450
1484
  const startOffset = options.offset ?? `-1`;
1451
1485
  fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, startOffset);
1452
- const live = options.live ?? `auto`;
1486
+ const live = options.live ?? true;
1453
1487
  if (live === `long-poll` || live === `sse`) fetchUrl.searchParams.set(LIVE_QUERY_PARAM, live);
1454
1488
  const params = await resolveParams(options.params);
1455
1489
  for (const [key, value] of Object.entries(params)) fetchUrl.searchParams.set(key, value);
@@ -1479,8 +1513,8 @@ async function streamInternal(options) {
1479
1513
  const nextUrl = new URL(url);
1480
1514
  nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
1481
1515
  if (!resumingFromPause) {
1482
- if (live === `auto` || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
1483
- else if (live === `sse`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
1516
+ if (live === `sse`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
1517
+ else if (live === true || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
1484
1518
  }
1485
1519
  if (cursor) nextUrl.searchParams.set(`cursor`, cursor);
1486
1520
  const nextParams = await resolveParams(options.params);
@@ -1528,405 +1562,822 @@ async function streamInternal(options) {
1528
1562
  }
1529
1563
 
1530
1564
  //#endregion
1531
- //#region src/stream.ts
1565
+ //#region src/idempotent-producer.ts
1566
+ /**
1567
+ * Error thrown when a producer's epoch is stale (zombie fencing).
1568
+ */
1569
+ var StaleEpochError = class extends Error {
1570
+ /**
1571
+ * The current epoch on the server.
1572
+ */
1573
+ currentEpoch;
1574
+ constructor(currentEpoch) {
1575
+ super(`Producer epoch is stale. Current server epoch: ${currentEpoch}. Call restart() or create a new producer with a higher epoch.`);
1576
+ this.name = `StaleEpochError`;
1577
+ this.currentEpoch = currentEpoch;
1578
+ }
1579
+ };
1580
+ /**
1581
+ * Error thrown when an unrecoverable sequence gap is detected.
1582
+ *
1583
+ * With maxInFlight > 1, HTTP requests can arrive out of order at the server,
1584
+ * causing temporary 409 responses. The client automatically handles these
1585
+ * by waiting for earlier sequences to complete, then retrying.
1586
+ *
1587
+ * This error is only thrown when the gap cannot be resolved (e.g., the
1588
+ * expected sequence is >= our sequence, indicating a true protocol violation).
1589
+ */
1590
+ var SequenceGapError = class extends Error {
1591
+ expectedSeq;
1592
+ receivedSeq;
1593
+ constructor(expectedSeq, receivedSeq) {
1594
+ super(`Producer sequence gap: expected ${expectedSeq}, received ${receivedSeq}`);
1595
+ this.name = `SequenceGapError`;
1596
+ this.expectedSeq = expectedSeq;
1597
+ this.receivedSeq = receivedSeq;
1598
+ }
1599
+ };
1532
1600
  /**
1533
1601
  * Normalize content-type by extracting the media type (before any semicolon).
1534
- * Handles cases like "application/json; charset=utf-8".
1535
1602
  */
1536
1603
  function normalizeContentType$1(contentType) {
1537
1604
  if (!contentType) return ``;
1538
1605
  return contentType.split(`;`)[0].trim().toLowerCase();
1539
1606
  }
1540
1607
  /**
1541
- * Check if a value is a Promise or Promise-like (thenable).
1542
- */
1543
- function isPromiseLike(value) {
1544
- return value !== null && typeof value === `object` && `then` in value && typeof value.then === `function`;
1545
- }
1546
- /**
1547
- * A handle to a remote durable stream for read/write operations.
1608
+ * An idempotent producer for exactly-once writes to a durable stream.
1548
1609
  *
1549
- * This is a lightweight, reusable handle - not a persistent connection.
1550
- * It does not automatically start reading or listening.
1551
- * Create sessions as needed via stream().
1610
+ * Features:
1611
+ * - Fire-and-forget: append() returns immediately, batches in background
1612
+ * - Exactly-once: server deduplicates using (producerId, epoch, seq)
1613
+ * - Batching: multiple appends batched into single HTTP request
1614
+ * - Pipelining: up to maxInFlight concurrent batches
1615
+ * - Zombie fencing: stale producers rejected via epoch validation
1552
1616
  *
1553
1617
  * @example
1554
1618
  * ```typescript
1555
- * // Create a new stream
1556
- * const stream = await DurableStream.create({
1557
- * url: "https://streams.example.com/my-stream",
1558
- * headers: { Authorization: "Bearer my-token" },
1559
- * contentType: "application/json"
1619
+ * const stream = new DurableStream({ url: "https://..." });
1620
+ * const producer = new IdempotentProducer(stream, "order-service-1", {
1621
+ * epoch: 0,
1622
+ * autoClaim: true,
1560
1623
  * });
1561
1624
  *
1562
- * // Write data
1563
- * await stream.append({ message: "hello" });
1625
+ * // Fire-and-forget writes (synchronous, returns immediately)
1626
+ * producer.append("message 1");
1627
+ * producer.append("message 2");
1564
1628
  *
1565
- * // Read with the new API
1566
- * const res = await stream.stream<{ message: string }>();
1567
- * res.subscribeJson(async (batch) => {
1568
- * for (const item of batch.items) {
1569
- * console.log(item.message);
1570
- * }
1571
- * });
1629
+ * // Ensure all messages are delivered before shutdown
1630
+ * await producer.flush();
1631
+ * await producer.close();
1572
1632
  * ```
1573
1633
  */
1574
- var DurableStream = class DurableStream {
1575
- /**
1576
- * The URL of the durable stream.
1577
- */
1578
- url;
1579
- /**
1580
- * The content type of the stream (populated after connect/head/read).
1581
- */
1582
- contentType;
1583
- #options;
1634
+ var IdempotentProducer = class {
1635
+ #stream;
1636
+ #producerId;
1637
+ #epoch;
1638
+ #nextSeq = 0;
1639
+ #autoClaim;
1640
+ #maxBatchBytes;
1641
+ #lingerMs;
1584
1642
  #fetchClient;
1643
+ #signal;
1585
1644
  #onError;
1586
- #batchingEnabled;
1645
+ #pendingBatch = [];
1646
+ #batchBytes = 0;
1647
+ #lingerTimeout = null;
1587
1648
  #queue;
1588
- #buffer = [];
1589
- /**
1590
- * Create a cold handle to a stream.
1591
- * No network IO is performed by the constructor.
1592
- */
1593
- constructor(opts) {
1594
- validateOptions(opts);
1595
- const urlStr = opts.url instanceof URL ? opts.url.toString() : opts.url;
1596
- this.url = urlStr;
1597
- this.#options = {
1598
- ...opts,
1599
- url: urlStr
1600
- };
1601
- this.#onError = opts.onError;
1602
- if (opts.contentType) this.contentType = opts.contentType;
1603
- this.#batchingEnabled = opts.batching !== false;
1604
- if (this.#batchingEnabled) this.#queue = fastq.default.promise(this.#batchWorker.bind(this), 1);
1605
- const baseFetchClient = opts.fetch ?? ((...args) => fetch(...args));
1606
- const backOffOpts = { ...opts.backoffOptions ?? BackoffDefaults };
1607
- const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, backOffOpts);
1608
- this.#fetchClient = createFetchWithConsumedBody(fetchWithBackoffClient);
1609
- }
1649
+ #maxInFlight;
1650
+ #closed = false;
1651
+ #epochClaimed;
1652
+ #seqState = new Map();
1610
1653
  /**
1611
- * Create a new stream (create-only PUT) and return a handle.
1612
- * Fails with DurableStreamError(code="CONFLICT_EXISTS") if it already exists.
1654
+ * Create an idempotent producer for a stream.
1655
+ *
1656
+ * @param stream - The DurableStream to write to
1657
+ * @param producerId - Stable identifier for this producer (e.g., "order-service-1")
1658
+ * @param opts - Producer options
1613
1659
  */
1614
- static async create(opts) {
1615
- const stream$1 = new DurableStream(opts);
1616
- await stream$1.create({
1617
- contentType: opts.contentType,
1618
- ttlSeconds: opts.ttlSeconds,
1619
- expiresAt: opts.expiresAt,
1620
- body: opts.body
1621
- });
1622
- return stream$1;
1660
+ constructor(stream$1, producerId, opts) {
1661
+ const epoch = opts?.epoch ?? 0;
1662
+ const maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024;
1663
+ const maxInFlight = opts?.maxInFlight ?? 5;
1664
+ const lingerMs = opts?.lingerMs ?? 5;
1665
+ if (epoch < 0) throw new Error(`epoch must be >= 0`);
1666
+ if (maxBatchBytes <= 0) throw new Error(`maxBatchBytes must be > 0`);
1667
+ if (maxInFlight <= 0) throw new Error(`maxInFlight must be > 0`);
1668
+ if (lingerMs < 0) throw new Error(`lingerMs must be >= 0`);
1669
+ this.#stream = stream$1;
1670
+ this.#producerId = producerId;
1671
+ this.#epoch = epoch;
1672
+ this.#autoClaim = opts?.autoClaim ?? false;
1673
+ this.#maxBatchBytes = maxBatchBytes;
1674
+ this.#lingerMs = lingerMs;
1675
+ this.#signal = opts?.signal;
1676
+ this.#onError = opts?.onError;
1677
+ this.#fetchClient = opts?.fetch ?? ((...args) => fetch(...args));
1678
+ this.#maxInFlight = maxInFlight;
1679
+ this.#epochClaimed = !this.#autoClaim;
1680
+ this.#queue = fastq.default.promise(this.#batchWorker.bind(this), this.#maxInFlight);
1681
+ if (this.#signal) this.#signal.addEventListener(`abort`, () => {
1682
+ this.#rejectPendingBatch(new DurableStreamError(`Producer aborted`, `ALREADY_CLOSED`, void 0, void 0));
1683
+ }, { once: true });
1623
1684
  }
1624
1685
  /**
1625
- * Validate that a stream exists and fetch metadata via HEAD.
1626
- * Returns a handle with contentType populated (if sent by server).
1686
+ * Append data to the stream.
1627
1687
  *
1628
- * **Important**: This only performs a HEAD request for validation - it does
1629
- * NOT open a session or start reading data. To read from the stream, call
1630
- * `stream()` on the returned handle.
1688
+ * This is fire-and-forget: returns immediately after adding to the batch.
1689
+ * The message is batched and sent when:
1690
+ * - maxBatchBytes is reached
1691
+ * - lingerMs elapses
1692
+ * - flush() is called
1693
+ *
1694
+ * Errors are reported via onError callback if configured. Use flush() to
1695
+ * wait for all pending messages to be sent.
1696
+ *
1697
+ * For JSON streams, pass pre-serialized JSON strings.
1698
+ * For byte streams, pass string or Uint8Array.
1699
+ *
1700
+ * @param body - Data to append (string or Uint8Array)
1631
1701
  *
1632
1702
  * @example
1633
1703
  * ```typescript
1634
- * // Validate stream exists before reading
1635
- * const handle = await DurableStream.connect({ url })
1636
- * const res = await handle.stream() // Now actually read
1704
+ * // JSON stream
1705
+ * producer.append(JSON.stringify({ message: "hello" }));
1706
+ *
1707
+ * // Byte stream
1708
+ * producer.append("raw text data");
1709
+ * producer.append(new Uint8Array([1, 2, 3]));
1637
1710
  * ```
1638
1711
  */
1639
- static async connect(opts) {
1640
- const stream$1 = new DurableStream(opts);
1641
- await stream$1.head();
1642
- return stream$1;
1712
+ append(body) {
1713
+ if (this.#closed) throw new DurableStreamError(`Producer is closed`, `ALREADY_CLOSED`, void 0, void 0);
1714
+ let bytes;
1715
+ if (typeof body === `string`) bytes = new TextEncoder().encode(body);
1716
+ else if (body instanceof Uint8Array) bytes = body;
1717
+ else throw new DurableStreamError(`append() requires string or Uint8Array. For objects, use JSON.stringify().`, `BAD_REQUEST`, 400, void 0);
1718
+ this.#pendingBatch.push({ body: bytes });
1719
+ this.#batchBytes += bytes.length;
1720
+ if (this.#batchBytes >= this.#maxBatchBytes) this.#enqueuePendingBatch();
1721
+ else if (!this.#lingerTimeout) this.#lingerTimeout = setTimeout(() => {
1722
+ this.#lingerTimeout = null;
1723
+ if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
1724
+ }, this.#lingerMs);
1643
1725
  }
1644
1726
  /**
1645
- * HEAD metadata for a stream without creating a handle.
1727
+ * Send any pending batch immediately and wait for all in-flight batches.
1728
+ *
1729
+ * Call this before shutdown to ensure all messages are delivered.
1646
1730
  */
1647
- static async head(opts) {
1648
- const stream$1 = new DurableStream(opts);
1649
- return stream$1.head();
1731
+ async flush() {
1732
+ if (this.#lingerTimeout) {
1733
+ clearTimeout(this.#lingerTimeout);
1734
+ this.#lingerTimeout = null;
1735
+ }
1736
+ if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
1737
+ await this.#queue.drained();
1650
1738
  }
1651
1739
  /**
1652
- * Delete a stream without creating a handle.
1740
+ * Flush pending messages and close the producer.
1741
+ *
1742
+ * After calling close(), further append() calls will throw.
1653
1743
  */
1654
- static async delete(opts) {
1655
- const stream$1 = new DurableStream(opts);
1656
- return stream$1.delete();
1744
+ async close() {
1745
+ if (this.#closed) return;
1746
+ this.#closed = true;
1747
+ try {
1748
+ await this.flush();
1749
+ } catch {}
1657
1750
  }
1658
1751
  /**
1659
- * HEAD metadata for this stream.
1752
+ * Increment epoch and reset sequence.
1753
+ *
1754
+ * Call this when restarting the producer to establish a new session.
1755
+ * Flushes any pending messages first.
1660
1756
  */
1661
- async head(opts) {
1662
- const { requestHeaders, fetchUrl } = await this.#buildRequest();
1663
- const response = await this.#fetchClient(fetchUrl.toString(), {
1664
- method: `HEAD`,
1665
- headers: requestHeaders,
1666
- signal: opts?.signal ?? this.#options.signal
1667
- });
1668
- if (!response.ok) await handleErrorResponse(response, this.url);
1669
- const contentType = response.headers.get(`content-type`) ?? void 0;
1670
- const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
1671
- const etag = response.headers.get(`etag`) ?? void 0;
1672
- const cacheControl = response.headers.get(`cache-control`) ?? void 0;
1673
- if (contentType) this.contentType = contentType;
1674
- return {
1675
- exists: true,
1676
- contentType,
1677
- offset,
1678
- etag,
1679
- cacheControl
1680
- };
1681
- }
1682
- /**
1683
- * Create this stream (create-only PUT) using the URL/auth from the handle.
1757
+ async restart() {
1758
+ await this.flush();
1759
+ this.#epoch++;
1760
+ this.#nextSeq = 0;
1761
+ }
1762
+ /**
1763
+ * Current epoch for this producer.
1684
1764
  */
1685
- async create(opts) {
1686
- const { requestHeaders, fetchUrl } = await this.#buildRequest();
1687
- const contentType = opts?.contentType ?? this.#options.contentType;
1688
- if (contentType) requestHeaders[`content-type`] = contentType;
1689
- if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
1690
- if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
1691
- const body = encodeBody(opts?.body);
1692
- const response = await this.#fetchClient(fetchUrl.toString(), {
1693
- method: `PUT`,
1694
- headers: requestHeaders,
1695
- body,
1696
- signal: this.#options.signal
1697
- });
1698
- if (!response.ok) await handleErrorResponse(response, this.url, { operation: `create` });
1699
- const responseContentType = response.headers.get(`content-type`);
1700
- if (responseContentType) this.contentType = responseContentType;
1701
- else if (contentType) this.contentType = contentType;
1702
- return this;
1765
+ get epoch() {
1766
+ return this.#epoch;
1703
1767
  }
1704
1768
  /**
1705
- * Delete this stream.
1769
+ * Next sequence number to be assigned.
1706
1770
  */
1707
- async delete(opts) {
1708
- const { requestHeaders, fetchUrl } = await this.#buildRequest();
1709
- const response = await this.#fetchClient(fetchUrl.toString(), {
1710
- method: `DELETE`,
1711
- headers: requestHeaders,
1712
- signal: opts?.signal ?? this.#options.signal
1713
- });
1714
- if (!response.ok) await handleErrorResponse(response, this.url);
1771
+ get nextSeq() {
1772
+ return this.#nextSeq;
1715
1773
  }
1716
1774
  /**
1717
- * Append a single payload to the stream.
1718
- *
1719
- * When batching is enabled (default), multiple append() calls made while
1720
- * a POST is in-flight will be batched together into a single request.
1721
- * This significantly improves throughput for high-frequency writes.
1722
- *
1723
- * - `body` may be Uint8Array, string, or any JSON-serializable value (for JSON streams).
1724
- * - `body` may also be a Promise that resolves to any of the above types.
1725
- * - Strings are encoded as UTF-8.
1726
- * - `seq` (if provided) is sent as stream-seq (writer coordination).
1727
- *
1728
- * @example
1729
- * ```typescript
1730
- * // Direct value
1731
- * await stream.append({ message: "hello" });
1732
- *
1733
- * // Promise value - awaited before buffering
1734
- * await stream.append(fetchData());
1735
- * await stream.append(Promise.all([a, b, c]));
1736
- * ```
1775
+ * Number of messages in the current pending batch.
1737
1776
  */
1738
- async append(body, opts) {
1739
- const resolvedBody = isPromiseLike(body) ? await body : body;
1740
- if (this.#batchingEnabled && this.#queue) return this.#appendWithBatching(resolvedBody, opts);
1741
- return this.#appendDirect(resolvedBody, opts);
1777
+ get pendingCount() {
1778
+ return this.#pendingBatch.length;
1742
1779
  }
1743
1780
  /**
1744
- * Direct append without batching (used when batching is disabled).
1781
+ * Number of batches currently in flight.
1745
1782
  */
1746
- async #appendDirect(body, opts) {
1747
- const { requestHeaders, fetchUrl } = await this.#buildRequest();
1748
- const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
1749
- if (contentType) requestHeaders[`content-type`] = contentType;
1750
- if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
1751
- const isJson = normalizeContentType$1(contentType) === `application/json`;
1752
- const bodyToEncode = isJson ? [body] : body;
1753
- const encodedBody = encodeBody(bodyToEncode);
1754
- const response = await this.#fetchClient(fetchUrl.toString(), {
1755
- method: `POST`,
1756
- headers: requestHeaders,
1757
- body: encodedBody,
1758
- signal: opts?.signal ?? this.#options.signal
1759
- });
1760
- if (!response.ok) await handleErrorResponse(response, this.url);
1783
+ get inFlightCount() {
1784
+ return this.#queue.length();
1761
1785
  }
1762
1786
  /**
1763
- * Append with batching - buffers messages and sends them in batches.
1787
+ * Enqueue the current pending batch for processing.
1764
1788
  */
1765
- async #appendWithBatching(body, opts) {
1766
- return new Promise((resolve, reject) => {
1767
- this.#buffer.push({
1768
- data: body,
1769
- seq: opts?.seq,
1770
- contentType: opts?.contentType,
1771
- signal: opts?.signal,
1772
- resolve,
1773
- reject
1774
- });
1775
- if (this.#queue.idle()) {
1776
- const batch = this.#buffer.splice(0);
1777
- this.#queue.push(batch).catch((err) => {
1778
- for (const msg of batch) msg.reject(err);
1779
- });
1780
- }
1789
+ #enqueuePendingBatch() {
1790
+ if (this.#pendingBatch.length === 0) return;
1791
+ const batch = this.#pendingBatch;
1792
+ const seq = this.#nextSeq;
1793
+ this.#pendingBatch = [];
1794
+ this.#batchBytes = 0;
1795
+ this.#nextSeq++;
1796
+ if (this.#autoClaim && !this.#epochClaimed && this.#queue.length() > 0) this.#queue.drained().then(() => {
1797
+ this.#queue.push({
1798
+ batch,
1799
+ seq
1800
+ }).catch(() => {});
1781
1801
  });
1802
+ else this.#queue.push({
1803
+ batch,
1804
+ seq
1805
+ }).catch(() => {});
1782
1806
  }
1783
1807
  /**
1784
- * Batch worker - processes batches of messages.
1808
+ * Batch worker - processes batches via fastq.
1785
1809
  */
1786
- async #batchWorker(batch) {
1810
+ async #batchWorker(task) {
1811
+ const { batch, seq } = task;
1812
+ const epoch = this.#epoch;
1787
1813
  try {
1788
- await this.#sendBatch(batch);
1789
- for (const msg of batch) msg.resolve();
1790
- if (this.#buffer.length > 0) {
1791
- const nextBatch = this.#buffer.splice(0);
1792
- this.#queue.push(nextBatch).catch((err) => {
1793
- for (const msg of nextBatch) msg.reject(err);
1794
- });
1795
- }
1814
+ await this.#doSendBatch(batch, seq, epoch);
1815
+ if (!this.#epochClaimed) this.#epochClaimed = true;
1816
+ this.#signalSeqComplete(epoch, seq, void 0);
1796
1817
  } catch (error) {
1797
- for (const msg of batch) msg.reject(error);
1798
- for (const msg of this.#buffer) msg.reject(error);
1799
- this.#buffer = [];
1818
+ this.#signalSeqComplete(epoch, seq, error);
1819
+ if (this.#onError) this.#onError(error);
1800
1820
  throw error;
1801
1821
  }
1802
1822
  }
1803
1823
  /**
1804
- * Send a batch of messages as a single POST request.
1824
+ * Signal that a sequence has completed (success or failure).
1805
1825
  */
1806
- async #sendBatch(batch) {
1807
- if (batch.length === 0) return;
1808
- const { requestHeaders, fetchUrl } = await this.#buildRequest();
1809
- const contentType = batch[0]?.contentType ?? this.#options.contentType ?? this.contentType;
1810
- if (contentType) requestHeaders[`content-type`] = contentType;
1811
- let highestSeq;
1812
- for (let i = batch.length - 1; i >= 0; i--) if (batch[i].seq !== void 0) {
1813
- highestSeq = batch[i].seq;
1814
- break;
1826
+ #signalSeqComplete(epoch, seq, error) {
1827
+ let epochMap = this.#seqState.get(epoch);
1828
+ if (!epochMap) {
1829
+ epochMap = new Map();
1830
+ this.#seqState.set(epoch, epochMap);
1815
1831
  }
1816
- if (highestSeq) requestHeaders[STREAM_SEQ_HEADER] = highestSeq;
1832
+ const state = epochMap.get(seq);
1833
+ if (state) {
1834
+ state.resolved = true;
1835
+ state.error = error;
1836
+ for (const waiter of state.waiters) waiter(error);
1837
+ state.waiters = [];
1838
+ } else epochMap.set(seq, {
1839
+ resolved: true,
1840
+ error,
1841
+ waiters: []
1842
+ });
1843
+ const cleanupThreshold = seq - this.#maxInFlight * 3;
1844
+ if (cleanupThreshold > 0) {
1845
+ for (const oldSeq of epochMap.keys()) if (oldSeq < cleanupThreshold) epochMap.delete(oldSeq);
1846
+ }
1847
+ }
1848
+ /**
1849
+ * Wait for a specific sequence to complete.
1850
+ * Returns immediately if already completed.
1851
+ * Throws if the sequence failed.
1852
+ */
1853
+ #waitForSeq(epoch, seq) {
1854
+ let epochMap = this.#seqState.get(epoch);
1855
+ if (!epochMap) {
1856
+ epochMap = new Map();
1857
+ this.#seqState.set(epoch, epochMap);
1858
+ }
1859
+ const state = epochMap.get(seq);
1860
+ if (state?.resolved) {
1861
+ if (state.error) return Promise.reject(state.error);
1862
+ return Promise.resolve();
1863
+ }
1864
+ return new Promise((resolve, reject) => {
1865
+ const waiter = (err) => {
1866
+ if (err) reject(err);
1867
+ else resolve();
1868
+ };
1869
+ if (state) state.waiters.push(waiter);
1870
+ else epochMap.set(seq, {
1871
+ resolved: false,
1872
+ waiters: [waiter]
1873
+ });
1874
+ });
1875
+ }
1876
+ /**
1877
+ * Actually send the batch to the server.
1878
+ * Handles auto-claim retry on 403 (stale epoch) if autoClaim is enabled.
1879
+ * Does NOT implement general retry/backoff for network errors or 5xx responses.
1880
+ */
1881
+ async #doSendBatch(batch, seq, epoch) {
1882
+ const contentType = this.#stream.contentType ?? `application/octet-stream`;
1817
1883
  const isJson = normalizeContentType$1(contentType) === `application/json`;
1818
1884
  let batchedBody;
1819
1885
  if (isJson) {
1820
- const values = batch.map((m) => m.data);
1821
- batchedBody = JSON.stringify(values);
1886
+ const jsonStrings = batch.map((e) => new TextDecoder().decode(e.body));
1887
+ batchedBody = `[${jsonStrings.join(`,`)}]`;
1822
1888
  } else {
1823
- const totalSize = batch.reduce((sum, m) => {
1824
- const size = typeof m.data === `string` ? new TextEncoder().encode(m.data).length : m.data.length;
1825
- return sum + size;
1826
- }, 0);
1889
+ const totalSize = batch.reduce((sum, e) => sum + e.body.length, 0);
1827
1890
  const concatenated = new Uint8Array(totalSize);
1828
1891
  let offset = 0;
1829
- for (const msg of batch) {
1830
- const bytes = typeof msg.data === `string` ? new TextEncoder().encode(msg.data) : msg.data;
1831
- concatenated.set(bytes, offset);
1832
- offset += bytes.length;
1892
+ for (const entry of batch) {
1893
+ concatenated.set(entry.body, offset);
1894
+ offset += entry.body.length;
1833
1895
  }
1834
1896
  batchedBody = concatenated;
1835
1897
  }
1836
- const signals = [];
1837
- if (this.#options.signal) signals.push(this.#options.signal);
1838
- for (const msg of batch) if (msg.signal) signals.push(msg.signal);
1839
- const combinedSignal = signals.length > 0 ? AbortSignal.any(signals) : void 0;
1840
- const response = await this.#fetchClient(fetchUrl.toString(), {
1898
+ const url = this.#stream.url;
1899
+ const headers = {
1900
+ "content-type": contentType,
1901
+ [PRODUCER_ID_HEADER]: this.#producerId,
1902
+ [PRODUCER_EPOCH_HEADER]: epoch.toString(),
1903
+ [PRODUCER_SEQ_HEADER]: seq.toString()
1904
+ };
1905
+ const response = await this.#fetchClient(url, {
1841
1906
  method: `POST`,
1842
- headers: requestHeaders,
1907
+ headers,
1843
1908
  body: batchedBody,
1844
- signal: combinedSignal
1909
+ signal: this.#signal
1845
1910
  });
1846
- if (!response.ok) await handleErrorResponse(response, this.url);
1847
- }
1848
- /**
1849
- * Append a streaming body to the stream.
1850
- *
1851
- * Supports piping from any ReadableStream or async iterable:
1852
- * - `source` yields Uint8Array or string chunks.
1853
- * - Strings are encoded as UTF-8; no delimiters are added.
1854
- * - Internally uses chunked transfer or HTTP/2 streaming.
1855
- *
1856
- * @example
1857
- * ```typescript
1858
- * // Pipe from a ReadableStream
1859
- * const readable = new ReadableStream({
1860
- * start(controller) {
1861
- * controller.enqueue("chunk 1");
1862
- * controller.enqueue("chunk 2");
1863
- * controller.close();
1864
- * }
1865
- * });
1866
- * await stream.appendStream(readable);
1867
- *
1868
- * // Pipe from an async generator
1869
- * async function* generate() {
1870
- * yield "line 1\n";
1871
- * yield "line 2\n";
1872
- * }
1873
- * await stream.appendStream(generate());
1874
- *
1875
- * // Pipe from fetch response body
1876
- * const response = await fetch("https://example.com/data");
1877
- * await stream.appendStream(response.body!);
1911
+ if (response.status === 204) return {
1912
+ offset: ``,
1913
+ duplicate: true
1914
+ };
1915
+ if (response.status === 200) {
1916
+ const resultOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
1917
+ return {
1918
+ offset: resultOffset,
1919
+ duplicate: false
1920
+ };
1921
+ }
1922
+ if (response.status === 403) {
1923
+ const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
1924
+ const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : epoch;
1925
+ if (this.#autoClaim) {
1926
+ const newEpoch = currentEpoch + 1;
1927
+ this.#epoch = newEpoch;
1928
+ this.#nextSeq = 1;
1929
+ return this.#doSendBatch(batch, 0, newEpoch);
1930
+ }
1931
+ throw new StaleEpochError(currentEpoch);
1932
+ }
1933
+ if (response.status === 409) {
1934
+ const expectedSeqStr = response.headers.get(PRODUCER_EXPECTED_SEQ_HEADER);
1935
+ const expectedSeq = expectedSeqStr ? parseInt(expectedSeqStr, 10) : 0;
1936
+ if (expectedSeq < seq) {
1937
+ const waitPromises = [];
1938
+ for (let s = expectedSeq; s < seq; s++) waitPromises.push(this.#waitForSeq(epoch, s));
1939
+ await Promise.all(waitPromises);
1940
+ return this.#doSendBatch(batch, seq, epoch);
1941
+ }
1942
+ const receivedSeqStr = response.headers.get(PRODUCER_RECEIVED_SEQ_HEADER);
1943
+ const receivedSeq = receivedSeqStr ? parseInt(receivedSeqStr, 10) : seq;
1944
+ throw new SequenceGapError(expectedSeq, receivedSeq);
1945
+ }
1946
+ if (response.status === 400) {
1947
+ const error$1 = await DurableStreamError.fromResponse(response, url);
1948
+ throw error$1;
1949
+ }
1950
+ const error = await FetchError.fromResponse(response, url);
1951
+ throw error;
1952
+ }
1953
+ /**
1954
+ * Clear pending batch and report error.
1955
+ */
1956
+ #rejectPendingBatch(error) {
1957
+ if (this.#onError && this.#pendingBatch.length > 0) this.#onError(error);
1958
+ this.#pendingBatch = [];
1959
+ this.#batchBytes = 0;
1960
+ if (this.#lingerTimeout) {
1961
+ clearTimeout(this.#lingerTimeout);
1962
+ this.#lingerTimeout = null;
1963
+ }
1964
+ }
1965
+ };
1966
+
1967
+ //#endregion
1968
+ //#region src/stream.ts
1969
+ /**
1970
+ * Normalize content-type by extracting the media type (before any semicolon).
1971
+ * Handles cases like "application/json; charset=utf-8".
1972
+ */
1973
+ function normalizeContentType(contentType) {
1974
+ if (!contentType) return ``;
1975
+ return contentType.split(`;`)[0].trim().toLowerCase();
1976
+ }
1977
+ /**
1978
+ * Check if a value is a Promise or Promise-like (thenable).
1979
+ */
1980
+ function isPromiseLike(value) {
1981
+ return value != null && typeof value.then === `function`;
1982
+ }
1983
+ /**
1984
+ * A handle to a remote durable stream for read/write operations.
1985
+ *
1986
+ * This is a lightweight, reusable handle - not a persistent connection.
1987
+ * It does not automatically start reading or listening.
1988
+ * Create sessions as needed via stream().
1989
+ *
1990
+ * @example
1991
+ * ```typescript
1992
+ * // Create a new stream
1993
+ * const stream = await DurableStream.create({
1994
+ * url: "https://streams.example.com/my-stream",
1995
+ * headers: { Authorization: "Bearer my-token" },
1996
+ * contentType: "application/json"
1997
+ * });
1998
+ *
1999
+ * // Write data
2000
+ * await stream.append(JSON.stringify({ message: "hello" }));
2001
+ *
2002
+ * // Read with the new API
2003
+ * const res = await stream.stream<{ message: string }>();
2004
+ * res.subscribeJson(async (batch) => {
2005
+ * for (const item of batch.items) {
2006
+ * console.log(item.message);
2007
+ * }
2008
+ * });
2009
+ * ```
2010
+ */
2011
+ var DurableStream = class DurableStream {
2012
+ /**
2013
+ * The URL of the durable stream.
2014
+ */
2015
+ url;
2016
+ /**
2017
+ * The content type of the stream (populated after connect/head/read).
2018
+ */
2019
+ contentType;
2020
+ #options;
2021
+ #fetchClient;
2022
+ #onError;
2023
+ #batchingEnabled;
2024
+ #queue;
2025
+ #buffer = [];
2026
+ /**
2027
+ * Create a cold handle to a stream.
2028
+ * No network IO is performed by the constructor.
2029
+ */
2030
+ constructor(opts) {
2031
+ validateOptions(opts);
2032
+ const urlStr = opts.url instanceof URL ? opts.url.toString() : opts.url;
2033
+ this.url = urlStr;
2034
+ this.#options = {
2035
+ ...opts,
2036
+ url: urlStr
2037
+ };
2038
+ this.#onError = opts.onError;
2039
+ if (opts.contentType) this.contentType = opts.contentType;
2040
+ this.#batchingEnabled = opts.batching !== false;
2041
+ if (this.#batchingEnabled) this.#queue = fastq.default.promise(this.#batchWorker.bind(this), 1);
2042
+ const baseFetchClient = opts.fetch ?? ((...args) => fetch(...args));
2043
+ const backOffOpts = { ...opts.backoffOptions ?? BackoffDefaults };
2044
+ const fetchWithBackoffClient = createFetchWithBackoff(baseFetchClient, backOffOpts);
2045
+ this.#fetchClient = createFetchWithConsumedBody(fetchWithBackoffClient);
2046
+ }
2047
+ /**
2048
+ * Create a new stream (create-only PUT) and return a handle.
2049
+ * Fails with DurableStreamError(code="CONFLICT_EXISTS") if it already exists.
2050
+ */
2051
+ static async create(opts) {
2052
+ const stream$1 = new DurableStream(opts);
2053
+ await stream$1.create({
2054
+ contentType: opts.contentType,
2055
+ ttlSeconds: opts.ttlSeconds,
2056
+ expiresAt: opts.expiresAt,
2057
+ body: opts.body
2058
+ });
2059
+ return stream$1;
2060
+ }
2061
+ /**
2062
+ * Validate that a stream exists and fetch metadata via HEAD.
2063
+ * Returns a handle with contentType populated (if sent by server).
2064
+ *
2065
+ * **Important**: This only performs a HEAD request for validation - it does
2066
+ * NOT open a session or start reading data. To read from the stream, call
2067
+ * `stream()` on the returned handle.
2068
+ *
2069
+ * @example
2070
+ * ```typescript
2071
+ * // Validate stream exists before reading
2072
+ * const handle = await DurableStream.connect({ url })
2073
+ * const res = await handle.stream() // Now actually read
1878
2074
  * ```
1879
2075
  */
1880
- async appendStream(source, opts) {
2076
+ static async connect(opts) {
2077
+ const stream$1 = new DurableStream(opts);
2078
+ await stream$1.head();
2079
+ return stream$1;
2080
+ }
2081
+ /**
2082
+ * HEAD metadata for a stream without creating a handle.
2083
+ */
2084
+ static async head(opts) {
2085
+ const stream$1 = new DurableStream(opts);
2086
+ return stream$1.head();
2087
+ }
2088
+ /**
2089
+ * Delete a stream without creating a handle.
2090
+ */
2091
+ static async delete(opts) {
2092
+ const stream$1 = new DurableStream(opts);
2093
+ return stream$1.delete();
2094
+ }
2095
+ /**
2096
+ * HEAD metadata for this stream.
2097
+ */
2098
+ async head(opts) {
1881
2099
  const { requestHeaders, fetchUrl } = await this.#buildRequest();
1882
- const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
2100
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2101
+ method: `HEAD`,
2102
+ headers: requestHeaders,
2103
+ signal: opts?.signal ?? this.#options.signal
2104
+ });
2105
+ if (!response.ok) await handleErrorResponse(response, this.url);
2106
+ const contentType = response.headers.get(`content-type`) ?? void 0;
2107
+ const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
2108
+ const etag = response.headers.get(`etag`) ?? void 0;
2109
+ const cacheControl = response.headers.get(`cache-control`) ?? void 0;
2110
+ if (contentType) this.contentType = contentType;
2111
+ return {
2112
+ exists: true,
2113
+ contentType,
2114
+ offset,
2115
+ etag,
2116
+ cacheControl
2117
+ };
2118
+ }
2119
+ /**
2120
+ * Create this stream (create-only PUT) using the URL/auth from the handle.
2121
+ */
2122
+ async create(opts) {
2123
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2124
+ const contentType = opts?.contentType ?? this.#options.contentType;
1883
2125
  if (contentType) requestHeaders[`content-type`] = contentType;
1884
- if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
1885
- const body = toReadableStream(source);
2126
+ if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
2127
+ if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
2128
+ const body = encodeBody(opts?.body);
1886
2129
  const response = await this.#fetchClient(fetchUrl.toString(), {
1887
- method: `POST`,
2130
+ method: `PUT`,
1888
2131
  headers: requestHeaders,
1889
2132
  body,
1890
- duplex: `half`,
2133
+ signal: this.#options.signal
2134
+ });
2135
+ if (!response.ok) await handleErrorResponse(response, this.url, { operation: `create` });
2136
+ const responseContentType = response.headers.get(`content-type`);
2137
+ if (responseContentType) this.contentType = responseContentType;
2138
+ else if (contentType) this.contentType = contentType;
2139
+ return this;
2140
+ }
2141
+ /**
2142
+ * Delete this stream.
2143
+ */
2144
+ async delete(opts) {
2145
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2146
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2147
+ method: `DELETE`,
2148
+ headers: requestHeaders,
1891
2149
  signal: opts?.signal ?? this.#options.signal
1892
2150
  });
1893
2151
  if (!response.ok) await handleErrorResponse(response, this.url);
1894
2152
  }
1895
2153
  /**
1896
- * Create a writable stream that pipes data to this durable stream.
2154
+ * Append a single payload to the stream.
1897
2155
  *
1898
- * Returns a WritableStream that can be used with `pipeTo()` or
1899
- * `pipeThrough()` from any ReadableStream source.
2156
+ * When batching is enabled (default), multiple append() calls made while
2157
+ * a POST is in-flight will be batched together into a single request.
2158
+ * This significantly improves throughput for high-frequency writes.
2159
+ *
2160
+ * - `body` must be string or Uint8Array.
2161
+ * - For JSON streams, pass pre-serialized JSON strings.
2162
+ * - `body` may also be a Promise that resolves to string or Uint8Array.
2163
+ * - Strings are encoded as UTF-8.
2164
+ * - `seq` (if provided) is sent as stream-seq (writer coordination).
1900
2165
  *
1901
2166
  * @example
1902
2167
  * ```typescript
1903
- * // Pipe from fetch response
1904
- * const response = await fetch("https://example.com/data");
1905
- * await response.body!.pipeTo(stream.writable());
2168
+ * // JSON stream - pass pre-serialized JSON
2169
+ * await stream.append(JSON.stringify({ message: "hello" }));
1906
2170
  *
1907
- * // Pipe through a transform
1908
- * const readable = someStream.pipeThrough(new TextEncoderStream());
1909
- * await readable.pipeTo(stream.writable());
2171
+ * // Byte stream
2172
+ * await stream.append("raw text data");
2173
+ * await stream.append(new Uint8Array([1, 2, 3]));
2174
+ *
2175
+ * // Promise value - awaited before buffering
2176
+ * await stream.append(fetchData());
1910
2177
  * ```
1911
2178
  */
1912
- writable(opts) {
1913
- const chunks = [];
1914
- const stream$1 = this;
1915
- return new WritableStream({
2179
+ async append(body, opts) {
2180
+ const resolvedBody = isPromiseLike(body) ? await body : body;
2181
+ if (this.#batchingEnabled && this.#queue) return this.#appendWithBatching(resolvedBody, opts);
2182
+ return this.#appendDirect(resolvedBody, opts);
2183
+ }
2184
+ /**
2185
+ * Direct append without batching (used when batching is disabled).
2186
+ */
2187
+ async #appendDirect(body, opts) {
2188
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2189
+ const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
2190
+ if (contentType) requestHeaders[`content-type`] = contentType;
2191
+ if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
2192
+ const isJson = normalizeContentType(contentType) === `application/json`;
2193
+ const bodyStr = typeof body === `string` ? body : new TextDecoder().decode(body);
2194
+ const encodedBody = isJson ? `[${bodyStr}]` : bodyStr;
2195
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2196
+ method: `POST`,
2197
+ headers: requestHeaders,
2198
+ body: encodedBody,
2199
+ signal: opts?.signal ?? this.#options.signal
2200
+ });
2201
+ if (!response.ok) await handleErrorResponse(response, this.url);
2202
+ }
2203
+ /**
2204
+ * Append with batching - buffers messages and sends them in batches.
2205
+ */
2206
+ async #appendWithBatching(body, opts) {
2207
+ return new Promise((resolve, reject) => {
2208
+ this.#buffer.push({
2209
+ data: body,
2210
+ seq: opts?.seq,
2211
+ contentType: opts?.contentType,
2212
+ signal: opts?.signal,
2213
+ resolve,
2214
+ reject
2215
+ });
2216
+ if (this.#queue.idle()) {
2217
+ const batch = this.#buffer.splice(0);
2218
+ this.#queue.push(batch).catch((err) => {
2219
+ for (const msg of batch) msg.reject(err);
2220
+ });
2221
+ }
2222
+ });
2223
+ }
2224
+ /**
2225
+ * Batch worker - processes batches of messages.
2226
+ */
2227
+ async #batchWorker(batch) {
2228
+ try {
2229
+ await this.#sendBatch(batch);
2230
+ for (const msg of batch) msg.resolve();
2231
+ if (this.#buffer.length > 0) {
2232
+ const nextBatch = this.#buffer.splice(0);
2233
+ this.#queue.push(nextBatch).catch((err) => {
2234
+ for (const msg of nextBatch) msg.reject(err);
2235
+ });
2236
+ }
2237
+ } catch (error) {
2238
+ for (const msg of batch) msg.reject(error);
2239
+ for (const msg of this.#buffer) msg.reject(error);
2240
+ this.#buffer = [];
2241
+ throw error;
2242
+ }
2243
+ }
2244
+ /**
2245
+ * Send a batch of messages as a single POST request.
2246
+ */
2247
+ async #sendBatch(batch) {
2248
+ if (batch.length === 0) return;
2249
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2250
+ const contentType = batch[0]?.contentType ?? this.#options.contentType ?? this.contentType;
2251
+ if (contentType) requestHeaders[`content-type`] = contentType;
2252
+ let highestSeq;
2253
+ for (let i = batch.length - 1; i >= 0; i--) if (batch[i].seq !== void 0) {
2254
+ highestSeq = batch[i].seq;
2255
+ break;
2256
+ }
2257
+ if (highestSeq) requestHeaders[STREAM_SEQ_HEADER] = highestSeq;
2258
+ const isJson = normalizeContentType(contentType) === `application/json`;
2259
+ let batchedBody;
2260
+ if (isJson) {
2261
+ const jsonStrings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
2262
+ batchedBody = `[${jsonStrings.join(`,`)}]`;
2263
+ } else {
2264
+ const strings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
2265
+ batchedBody = strings.join(``);
2266
+ }
2267
+ const signals = [];
2268
+ if (this.#options.signal) signals.push(this.#options.signal);
2269
+ for (const msg of batch) if (msg.signal) signals.push(msg.signal);
2270
+ const combinedSignal = signals.length > 0 ? AbortSignal.any(signals) : void 0;
2271
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2272
+ method: `POST`,
2273
+ headers: requestHeaders,
2274
+ body: batchedBody,
2275
+ signal: combinedSignal
2276
+ });
2277
+ if (!response.ok) await handleErrorResponse(response, this.url);
2278
+ }
2279
+ /**
2280
+ * Append a streaming body to the stream.
2281
+ *
2282
+ * Supports piping from any ReadableStream or async iterable:
2283
+ * - `source` yields Uint8Array or string chunks.
2284
+ * - Strings are encoded as UTF-8; no delimiters are added.
2285
+ * - Internally uses chunked transfer or HTTP/2 streaming.
2286
+ *
2287
+ * @example
2288
+ * ```typescript
2289
+ * // Pipe from a ReadableStream
2290
+ * const readable = new ReadableStream({
2291
+ * start(controller) {
2292
+ * controller.enqueue("chunk 1");
2293
+ * controller.enqueue("chunk 2");
2294
+ * controller.close();
2295
+ * }
2296
+ * });
2297
+ * await stream.appendStream(readable);
2298
+ *
2299
+ * // Pipe from an async generator
2300
+ * async function* generate() {
2301
+ * yield "line 1\n";
2302
+ * yield "line 2\n";
2303
+ * }
2304
+ * await stream.appendStream(generate());
2305
+ *
2306
+ * // Pipe from fetch response body
2307
+ * const response = await fetch("https://example.com/data");
2308
+ * await stream.appendStream(response.body!);
2309
+ * ```
2310
+ */
2311
+ async appendStream(source, opts) {
2312
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2313
+ const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
2314
+ if (contentType) requestHeaders[`content-type`] = contentType;
2315
+ if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
2316
+ const body = toReadableStream(source);
2317
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2318
+ method: `POST`,
2319
+ headers: requestHeaders,
2320
+ body,
2321
+ duplex: `half`,
2322
+ signal: opts?.signal ?? this.#options.signal
2323
+ });
2324
+ if (!response.ok) await handleErrorResponse(response, this.url);
2325
+ }
2326
+ /**
2327
+ * Create a writable stream that pipes data to this durable stream.
2328
+ *
2329
+ * Returns a WritableStream that can be used with `pipeTo()` or
2330
+ * `pipeThrough()` from any ReadableStream source.
2331
+ *
2332
+ * Uses IdempotentProducer internally for:
2333
+ * - Automatic batching (controlled by lingerMs, maxBatchBytes)
2334
+ * - Exactly-once delivery semantics
2335
+ * - Streaming writes (doesn't buffer entire content in memory)
2336
+ *
2337
+ * @example
2338
+ * ```typescript
2339
+ * // Pipe from fetch response
2340
+ * const response = await fetch("https://example.com/data");
2341
+ * await response.body!.pipeTo(stream.writable());
2342
+ *
2343
+ * // Pipe through a transform
2344
+ * const readable = someStream.pipeThrough(new TextEncoderStream());
2345
+ * await readable.pipeTo(stream.writable());
2346
+ *
2347
+ * // With custom producer options
2348
+ * await source.pipeTo(stream.writable({
2349
+ * producerId: "my-producer",
2350
+ * lingerMs: 10,
2351
+ * maxBatchBytes: 64 * 1024,
2352
+ * }));
2353
+ * ```
2354
+ */
2355
+ writable(opts) {
2356
+ const producerId = opts?.producerId ?? `writable-${crypto.randomUUID().slice(0, 8)}`;
2357
+ let writeError = null;
2358
+ const producer = new IdempotentProducer(this, producerId, {
2359
+ autoClaim: true,
2360
+ lingerMs: opts?.lingerMs,
2361
+ maxBatchBytes: opts?.maxBatchBytes,
2362
+ onError: (error) => {
2363
+ if (!writeError) writeError = error;
2364
+ opts?.onError?.(error);
2365
+ },
2366
+ signal: opts?.signal ?? this.#options.signal
2367
+ });
2368
+ return new WritableStream({
1916
2369
  write(chunk) {
1917
- chunks.push(chunk);
2370
+ producer.append(chunk);
1918
2371
  },
1919
2372
  async close() {
1920
- if (chunks.length > 0) {
1921
- const readable = new ReadableStream({ start(controller) {
1922
- for (const chunk of chunks) controller.enqueue(chunk);
1923
- controller.close();
1924
- } });
1925
- await stream$1.appendStream(readable, opts);
1926
- }
2373
+ await producer.flush();
2374
+ await producer.close();
2375
+ if (writeError) throw writeError;
1927
2376
  },
1928
- abort(reason) {
1929
- console.error(`WritableStream aborted:`, reason);
2377
+ abort(_reason) {
2378
+ producer.close().catch((err) => {
2379
+ opts?.onError?.(err);
2380
+ });
1930
2381
  }
1931
2382
  });
1932
2383
  }
@@ -2053,403 +2504,6 @@ function validateOptions(options) {
2053
2504
  warnIfUsingHttpInBrowser(options.url, options.warnOnHttp);
2054
2505
  }
2055
2506
 
2056
- //#endregion
2057
- //#region src/idempotent-producer.ts
2058
- /**
2059
- * Error thrown when a producer's epoch is stale (zombie fencing).
2060
- */
2061
- var StaleEpochError = class extends Error {
2062
- /**
2063
- * The current epoch on the server.
2064
- */
2065
- currentEpoch;
2066
- constructor(currentEpoch) {
2067
- super(`Producer epoch is stale. Current server epoch: ${currentEpoch}. Call restart() or create a new producer with a higher epoch.`);
2068
- this.name = `StaleEpochError`;
2069
- this.currentEpoch = currentEpoch;
2070
- }
2071
- };
2072
- /**
2073
- * Error thrown when an unrecoverable sequence gap is detected.
2074
- *
2075
- * With maxInFlight > 1, HTTP requests can arrive out of order at the server,
2076
- * causing temporary 409 responses. The client automatically handles these
2077
- * by waiting for earlier sequences to complete, then retrying.
2078
- *
2079
- * This error is only thrown when the gap cannot be resolved (e.g., the
2080
- * expected sequence is >= our sequence, indicating a true protocol violation).
2081
- */
2082
- var SequenceGapError = class extends Error {
2083
- expectedSeq;
2084
- receivedSeq;
2085
- constructor(expectedSeq, receivedSeq) {
2086
- super(`Producer sequence gap: expected ${expectedSeq}, received ${receivedSeq}`);
2087
- this.name = `SequenceGapError`;
2088
- this.expectedSeq = expectedSeq;
2089
- this.receivedSeq = receivedSeq;
2090
- }
2091
- };
2092
- /**
2093
- * Normalize content-type by extracting the media type (before any semicolon).
2094
- */
2095
- function normalizeContentType(contentType) {
2096
- if (!contentType) return ``;
2097
- return contentType.split(`;`)[0].trim().toLowerCase();
2098
- }
2099
- /**
2100
- * An idempotent producer for exactly-once writes to a durable stream.
2101
- *
2102
- * Features:
2103
- * - Fire-and-forget: append() returns immediately, batches in background
2104
- * - Exactly-once: server deduplicates using (producerId, epoch, seq)
2105
- * - Batching: multiple appends batched into single HTTP request
2106
- * - Pipelining: up to maxInFlight concurrent batches
2107
- * - Zombie fencing: stale producers rejected via epoch validation
2108
- *
2109
- * @example
2110
- * ```typescript
2111
- * const stream = new DurableStream({ url: "https://..." });
2112
- * const producer = new IdempotentProducer(stream, "order-service-1", {
2113
- * epoch: 0,
2114
- * autoClaim: true,
2115
- * });
2116
- *
2117
- * // Fire-and-forget writes (synchronous, returns immediately)
2118
- * producer.append("message 1");
2119
- * producer.append("message 2");
2120
- *
2121
- * // Ensure all messages are delivered before shutdown
2122
- * await producer.flush();
2123
- * await producer.close();
2124
- * ```
2125
- */
2126
- var IdempotentProducer = class {
2127
- #stream;
2128
- #producerId;
2129
- #epoch;
2130
- #nextSeq = 0;
2131
- #autoClaim;
2132
- #maxBatchBytes;
2133
- #lingerMs;
2134
- #fetchClient;
2135
- #signal;
2136
- #onError;
2137
- #pendingBatch = [];
2138
- #batchBytes = 0;
2139
- #lingerTimeout = null;
2140
- #queue;
2141
- #maxInFlight;
2142
- #closed = false;
2143
- #epochClaimed;
2144
- #seqState = new Map();
2145
- /**
2146
- * Create an idempotent producer for a stream.
2147
- *
2148
- * @param stream - The DurableStream to write to
2149
- * @param producerId - Stable identifier for this producer (e.g., "order-service-1")
2150
- * @param opts - Producer options
2151
- */
2152
- constructor(stream$1, producerId, opts) {
2153
- this.#stream = stream$1;
2154
- this.#producerId = producerId;
2155
- this.#epoch = opts?.epoch ?? 0;
2156
- this.#autoClaim = opts?.autoClaim ?? false;
2157
- this.#maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024;
2158
- this.#lingerMs = opts?.lingerMs ?? 5;
2159
- this.#signal = opts?.signal;
2160
- this.#onError = opts?.onError;
2161
- this.#fetchClient = opts?.fetch ?? ((...args) => fetch(...args));
2162
- this.#maxInFlight = opts?.maxInFlight ?? 5;
2163
- this.#epochClaimed = !this.#autoClaim;
2164
- this.#queue = fastq.default.promise(this.#batchWorker.bind(this), this.#maxInFlight);
2165
- if (this.#signal) this.#signal.addEventListener(`abort`, () => {
2166
- this.#rejectPendingBatch(new DurableStreamError(`Producer aborted`, `ALREADY_CLOSED`, void 0, void 0));
2167
- }, { once: true });
2168
- }
2169
- /**
2170
- * Append data to the stream.
2171
- *
2172
- * This is fire-and-forget: returns immediately after adding to the batch.
2173
- * The message is batched and sent when:
2174
- * - maxBatchBytes is reached
2175
- * - lingerMs elapses
2176
- * - flush() is called
2177
- *
2178
- * Errors are reported via onError callback if configured. Use flush() to
2179
- * wait for all pending messages to be sent.
2180
- *
2181
- * For JSON streams, pass native objects (which will be serialized internally).
2182
- * For byte streams, pass string or Uint8Array.
2183
- *
2184
- * @param body - Data to append (object for JSON streams, string or Uint8Array for byte streams)
2185
- */
2186
- append(body) {
2187
- if (this.#closed) throw new DurableStreamError(`Producer is closed`, `ALREADY_CLOSED`, void 0, void 0);
2188
- const isJson = normalizeContentType(this.#stream.contentType) === `application/json`;
2189
- let bytes;
2190
- let data;
2191
- if (isJson) {
2192
- const json = JSON.stringify(body);
2193
- bytes = new TextEncoder().encode(json);
2194
- data = body;
2195
- } else {
2196
- if (typeof body === `string`) bytes = new TextEncoder().encode(body);
2197
- else if (body instanceof Uint8Array) bytes = body;
2198
- else throw new DurableStreamError(`Non-JSON streams require string or Uint8Array`, `BAD_REQUEST`, 400, void 0);
2199
- data = bytes;
2200
- }
2201
- this.#pendingBatch.push({
2202
- data,
2203
- body: bytes
2204
- });
2205
- this.#batchBytes += bytes.length;
2206
- if (this.#batchBytes >= this.#maxBatchBytes) this.#enqueuePendingBatch();
2207
- else if (!this.#lingerTimeout) this.#lingerTimeout = setTimeout(() => {
2208
- this.#lingerTimeout = null;
2209
- if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
2210
- }, this.#lingerMs);
2211
- }
2212
- /**
2213
- * Send any pending batch immediately and wait for all in-flight batches.
2214
- *
2215
- * Call this before shutdown to ensure all messages are delivered.
2216
- */
2217
- async flush() {
2218
- if (this.#lingerTimeout) {
2219
- clearTimeout(this.#lingerTimeout);
2220
- this.#lingerTimeout = null;
2221
- }
2222
- if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
2223
- await this.#queue.drained();
2224
- }
2225
- /**
2226
- * Flush pending messages and close the producer.
2227
- *
2228
- * After calling close(), further append() calls will throw.
2229
- */
2230
- async close() {
2231
- if (this.#closed) return;
2232
- this.#closed = true;
2233
- try {
2234
- await this.flush();
2235
- } catch {}
2236
- }
2237
- /**
2238
- * Increment epoch and reset sequence.
2239
- *
2240
- * Call this when restarting the producer to establish a new session.
2241
- * Flushes any pending messages first.
2242
- */
2243
- async restart() {
2244
- await this.flush();
2245
- this.#epoch++;
2246
- this.#nextSeq = 0;
2247
- }
2248
- /**
2249
- * Current epoch for this producer.
2250
- */
2251
- get epoch() {
2252
- return this.#epoch;
2253
- }
2254
- /**
2255
- * Next sequence number to be assigned.
2256
- */
2257
- get nextSeq() {
2258
- return this.#nextSeq;
2259
- }
2260
- /**
2261
- * Number of messages in the current pending batch.
2262
- */
2263
- get pendingCount() {
2264
- return this.#pendingBatch.length;
2265
- }
2266
- /**
2267
- * Number of batches currently in flight.
2268
- */
2269
- get inFlightCount() {
2270
- return this.#queue.length();
2271
- }
2272
- /**
2273
- * Enqueue the current pending batch for processing.
2274
- */
2275
- #enqueuePendingBatch() {
2276
- if (this.#pendingBatch.length === 0) return;
2277
- const batch = this.#pendingBatch;
2278
- const seq = this.#nextSeq;
2279
- this.#pendingBatch = [];
2280
- this.#batchBytes = 0;
2281
- this.#nextSeq++;
2282
- if (this.#autoClaim && !this.#epochClaimed && this.#queue.length() > 0) this.#queue.drained().then(() => {
2283
- this.#queue.push({
2284
- batch,
2285
- seq
2286
- }).catch(() => {});
2287
- });
2288
- else this.#queue.push({
2289
- batch,
2290
- seq
2291
- }).catch(() => {});
2292
- }
2293
- /**
2294
- * Batch worker - processes batches via fastq.
2295
- */
2296
- async #batchWorker(task) {
2297
- const { batch, seq } = task;
2298
- const epoch = this.#epoch;
2299
- try {
2300
- await this.#doSendBatch(batch, seq, epoch);
2301
- if (!this.#epochClaimed) this.#epochClaimed = true;
2302
- this.#signalSeqComplete(epoch, seq, void 0);
2303
- } catch (error) {
2304
- this.#signalSeqComplete(epoch, seq, error);
2305
- if (this.#onError) this.#onError(error);
2306
- throw error;
2307
- }
2308
- }
2309
- /**
2310
- * Signal that a sequence has completed (success or failure).
2311
- */
2312
- #signalSeqComplete(epoch, seq, error) {
2313
- let epochMap = this.#seqState.get(epoch);
2314
- if (!epochMap) {
2315
- epochMap = new Map();
2316
- this.#seqState.set(epoch, epochMap);
2317
- }
2318
- const state = epochMap.get(seq);
2319
- if (state) {
2320
- state.resolved = true;
2321
- state.error = error;
2322
- for (const waiter of state.waiters) waiter(error);
2323
- state.waiters = [];
2324
- } else epochMap.set(seq, {
2325
- resolved: true,
2326
- error,
2327
- waiters: []
2328
- });
2329
- const cleanupThreshold = seq - this.#maxInFlight * 3;
2330
- if (cleanupThreshold > 0) {
2331
- for (const oldSeq of epochMap.keys()) if (oldSeq < cleanupThreshold) epochMap.delete(oldSeq);
2332
- }
2333
- }
2334
- /**
2335
- * Wait for a specific sequence to complete.
2336
- * Returns immediately if already completed.
2337
- * Throws if the sequence failed.
2338
- */
2339
- #waitForSeq(epoch, seq) {
2340
- let epochMap = this.#seqState.get(epoch);
2341
- if (!epochMap) {
2342
- epochMap = new Map();
2343
- this.#seqState.set(epoch, epochMap);
2344
- }
2345
- const state = epochMap.get(seq);
2346
- if (state?.resolved) {
2347
- if (state.error) return Promise.reject(state.error);
2348
- return Promise.resolve();
2349
- }
2350
- return new Promise((resolve, reject) => {
2351
- const waiter = (err) => {
2352
- if (err) reject(err);
2353
- else resolve();
2354
- };
2355
- if (state) state.waiters.push(waiter);
2356
- else epochMap.set(seq, {
2357
- resolved: false,
2358
- waiters: [waiter]
2359
- });
2360
- });
2361
- }
2362
- /**
2363
- * Actually send the batch to the server.
2364
- * Handles auto-claim retry on 403 (stale epoch) if autoClaim is enabled.
2365
- * Does NOT implement general retry/backoff for network errors or 5xx responses.
2366
- */
2367
- async #doSendBatch(batch, seq, epoch) {
2368
- const contentType = this.#stream.contentType ?? `application/octet-stream`;
2369
- const isJson = normalizeContentType(contentType) === `application/json`;
2370
- let batchedBody;
2371
- if (isJson) {
2372
- const values = batch.map((e) => e.data);
2373
- batchedBody = JSON.stringify(values);
2374
- } else {
2375
- const totalSize = batch.reduce((sum, e) => sum + e.body.length, 0);
2376
- const concatenated = new Uint8Array(totalSize);
2377
- let offset = 0;
2378
- for (const entry of batch) {
2379
- concatenated.set(entry.body, offset);
2380
- offset += entry.body.length;
2381
- }
2382
- batchedBody = concatenated;
2383
- }
2384
- const url = this.#stream.url;
2385
- const headers = {
2386
- "content-type": contentType,
2387
- [PRODUCER_ID_HEADER]: this.#producerId,
2388
- [PRODUCER_EPOCH_HEADER]: epoch.toString(),
2389
- [PRODUCER_SEQ_HEADER]: seq.toString()
2390
- };
2391
- const response = await this.#fetchClient(url, {
2392
- method: `POST`,
2393
- headers,
2394
- body: batchedBody,
2395
- signal: this.#signal
2396
- });
2397
- if (response.status === 204) return {
2398
- offset: ``,
2399
- duplicate: true
2400
- };
2401
- if (response.status === 200) {
2402
- const resultOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
2403
- return {
2404
- offset: resultOffset,
2405
- duplicate: false
2406
- };
2407
- }
2408
- if (response.status === 403) {
2409
- const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
2410
- const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : epoch;
2411
- if (this.#autoClaim) {
2412
- const newEpoch = currentEpoch + 1;
2413
- this.#epoch = newEpoch;
2414
- this.#nextSeq = 1;
2415
- return this.#doSendBatch(batch, 0, newEpoch);
2416
- }
2417
- throw new StaleEpochError(currentEpoch);
2418
- }
2419
- if (response.status === 409) {
2420
- const expectedSeqStr = response.headers.get(PRODUCER_EXPECTED_SEQ_HEADER);
2421
- const expectedSeq = expectedSeqStr ? parseInt(expectedSeqStr, 10) : 0;
2422
- if (expectedSeq < seq) {
2423
- const waitPromises = [];
2424
- for (let s = expectedSeq; s < seq; s++) waitPromises.push(this.#waitForSeq(epoch, s));
2425
- await Promise.all(waitPromises);
2426
- return this.#doSendBatch(batch, seq, epoch);
2427
- }
2428
- const receivedSeqStr = response.headers.get(PRODUCER_RECEIVED_SEQ_HEADER);
2429
- const receivedSeq = receivedSeqStr ? parseInt(receivedSeqStr, 10) : seq;
2430
- throw new SequenceGapError(expectedSeq, receivedSeq);
2431
- }
2432
- if (response.status === 400) {
2433
- const error$1 = await DurableStreamError.fromResponse(response, url);
2434
- throw error$1;
2435
- }
2436
- const error = await FetchError.fromResponse(response, url);
2437
- throw error;
2438
- }
2439
- /**
2440
- * Clear pending batch and report error.
2441
- */
2442
- #rejectPendingBatch(error) {
2443
- if (this.#onError && this.#pendingBatch.length > 0) this.#onError(error);
2444
- this.#pendingBatch = [];
2445
- this.#batchBytes = 0;
2446
- if (this.#lingerTimeout) {
2447
- clearTimeout(this.#lingerTimeout);
2448
- this.#lingerTimeout = null;
2449
- }
2450
- }
2451
- };
2452
-
2453
2507
  //#endregion
2454
2508
  exports.BackoffDefaults = BackoffDefaults
2455
2509
  exports.CURSOR_QUERY_PARAM = CURSOR_QUERY_PARAM