@prisma/composer-prisma-cloud 0.1.0-dev.1 → 0.1.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/control.d.mts +41 -1
  2. package/dist/control.mjs +137 -17
  3. package/dist/control.mjs.map +1 -1
  4. package/dist/cron/index.mjs +72 -7
  5. package/dist/cron/index.mjs.map +1 -1
  6. package/dist/cron/scheduler-entrypoint.mjs +158 -93
  7. package/dist/cron/scheduler-entrypoint.mjs.map +1 -1
  8. package/dist/cron/scheduler-service.mjs +72 -7
  9. package/dist/cron/scheduler-service.mjs.map +1 -1
  10. package/dist/index.d.mts +34 -15
  11. package/dist/index.mjs +4 -5
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/{param-DB0B8m15-IvzNq9BM.mjs → provisioned-edges-Bb7WiV49-DeSPi3Ax.mjs} +86 -27
  14. package/dist/provisioned-edges-Bb7WiV49-DeSPi3Ax.mjs.map +1 -0
  15. package/dist/{serializer-DAEWRfnm-D3GW9dOZ.mjs → serializer-CX4VYdf_-KKGoAxfx.mjs} +29 -3
  16. package/dist/{serializer-DAEWRfnm-D3GW9dOZ.mjs.map → serializer-CX4VYdf_-KKGoAxfx.mjs.map} +1 -1
  17. package/dist/serializer-Cx5slrV4-xJfH6EWS.d.mts +36 -0
  18. package/dist/storage/index.d.mts +1 -0
  19. package/dist/storage/index.mjs +73 -7
  20. package/dist/storage/index.mjs.map +1 -1
  21. package/dist/storage/storage-entrypoint.mjs +7197 -12
  22. package/dist/storage/storage-entrypoint.mjs.map +1 -1
  23. package/dist/storage/storage-service.mjs +73 -7
  24. package/dist/storage/storage-service.mjs.map +1 -1
  25. package/dist/streams/index.d.mts +219 -16
  26. package/dist/streams/index.mjs +3354 -30
  27. package/dist/streams/index.mjs.map +1 -1
  28. package/dist/streams/streams-entrypoint.mjs +7731 -221
  29. package/dist/streams/streams-entrypoint.mjs.map +1 -1
  30. package/dist/streams/streams-service.mjs +399 -20
  31. package/dist/streams/streams-service.mjs.map +1 -1
  32. package/dist/testing.mjs +1 -1
  33. package/dist/testing.mjs.map +1 -1
  34. package/package.json +13 -13
  35. package/dist/param-DB0B8m15-IvzNq9BM.mjs.map +0 -1
@@ -1,6 +1,7 @@
1
- import { dependency, hydrateSecrets, hydrateSync, module, number, resource, secret, service, string } from "@prisma/composer";
1
+ import { dependency, hydrateSecrets, hydrateSync, module, number, provisionNeed, resource, service, string } from "@prisma/composer";
2
2
  import { blindCast } from "@prisma/composer/casts";
3
- import "@prisma/composer/rpc";
3
+ import { RPC_PEER_KEY } from "@prisma/composer/rpc";
4
+ import { type } from "arktype";
4
5
  import node from "@prisma/composer/node";
5
6
  blindCast(Symbol.for("prisma:prisma-cloud-secret-source"));
6
7
  /**
@@ -164,6 +165,32 @@ const stashSecrets = (node, address) => {
164
165
  process.env[secretKey("", slot)] = name;
165
166
  }
166
167
  };
168
+ /**
169
+ * Boot: for each reserved provider param, read its address-scoped row through
170
+ * the same `coerce` a declared param uses (JSON-decode, schema-validate), and
171
+ * re-emit it address-free — `stash`'s counterpart for this separate
172
+ * declaration space. A param is declared optional here unconditionally: an
173
+ * absent row means "never provisioned" (local dev, tests, a provider with no
174
+ * registered value for this deploy), never a boot failure, so nothing is
175
+ * stashed and the runtime reader that owns this slot falls back to its own
176
+ * pass-through behavior.
177
+ */
178
+ function stashProviderParams(entries, address) {
179
+ for (const entry of entries) {
180
+ const d = {
181
+ owner: "service",
182
+ name: entry.name,
183
+ param: {
184
+ schema: entry.schema,
185
+ optional: true
186
+ }
187
+ };
188
+ const key = configKey(address, d);
189
+ const value = coerce(process.env[key], d, key);
190
+ if (value === void 0) continue;
191
+ process.env[configKey("", d)] = encode("service", value);
192
+ }
193
+ }
167
194
  /** Synchronous Standard Schema validation — see the matching note in core's `config.ts`. */
168
195
  function standardValidateSync(schema, value) {
169
196
  const result = schema["~standard"].validate(value);
@@ -172,12 +199,60 @@ function standardValidateSync(schema, value) {
172
199
  return result.value;
173
200
  }
174
201
  //#endregion
175
- //#region ../../1-prisma-cloud/1-extensions/target/dist/param-DB0B8m15.mjs
176
- /** The reserved accepted-keys env var: COMPOSER_<addr>_RPC_ACCEPTED_KEYS ("" ↦ @internal/rpc's RPC_ACCEPTED_KEYS_ENV). */
177
- const serviceKeyEnvName = (address) => configKey(address, {
202
+ //#region ../../1-prisma-cloud/1-extensions/target/dist/provisioned-edges-Bb7WiV49.mjs
203
+ /**
204
+ * RPC's reserved provider param (ADR-0030/ADR-0031): the declaration
205
+ * name + schema + brand — for the accepted-keys set a provider stores, shared
206
+ * by `control.ts` (which registers the deploy-side `value(refs)` that mints
207
+ * and aggregates it — see its `rpcAcceptedKeysValue`) and `compute.ts` (which
208
+ * validates and stashes it at boot), so writer and reader cannot drift.
209
+ * Finding the edges themselves is `provisioned-edges.ts`'s generic,
210
+ * brand-blind scan — RPC is not special-cased anywhere in this target.
211
+ *
212
+ * This module is reachable from the RUNTIME/authoring side — it must never
213
+ * import `@internal/lowering` or `effect`, or those tokens leak into a user
214
+ * service's bundle (the deploy-side `value(refs)` lives in control.ts, the
215
+ * control-plane-only entry).
216
+ */
217
+ /**
218
+ * The reserved provider param for RPC's accepted-keys set: the var name is
219
+ * `RPC_ACCEPTED_KEYS`, derived through `configKey` at both ends
220
+ * (`configKey(address, …)` at deploy, `configKey('', …)` at boot — the
221
+ * address-free form is `@internal/rpc`'s `RPC_ACCEPTED_KEYS_ENV`). `brand` is
222
+ * `RPC_PEER_KEY`, the same brand `perBindingToken()`'s need carries — control.ts
223
+ * looks its `value(refs)` up by this field.
224
+ */
225
+ const RPC_ACCEPTED_KEYS_PARAM = {
226
+ name: "RPC_ACCEPTED_KEYS",
227
+ schema: type("string[]"),
228
+ brand: RPC_PEER_KEY
229
+ };
230
+ /** ADR-0031's need brand for the streams module's bearer key — control.ts registers the provisioner under this. */
231
+ const STREAMS_API_KEY = Symbol.for("prisma:streams/api-key");
232
+ /**
233
+ * The provisioning need `durableStreams()`'s `apiKey` param declares: an
234
+ * unguessable value the target mints ONCE PER PROVIDER (not per edge) —
235
+ * `@prisma/streams-server` authenticates a single `API_KEY`, so every
236
+ * consumer of one streams module must present the same value. Per-provider
237
+ * cardinality is provisioner policy (ADR-0031), invisible to core.
238
+ */
239
+ const streamsApiKeyNeed = () => provisionNeed(STREAMS_API_KEY);
240
+ /**
241
+ * The reserved provider param for the streams bearer key: the var name is
242
+ * `STREAMS_API_KEY`. `brand` is `STREAMS_API_KEY` itself (the same symbol
243
+ * `streamsApiKeyNeed()`'s need carries) — control.ts looks its `value(refs)`
244
+ * up by this field.
245
+ */
246
+ const STREAMS_API_KEY_PARAM = {
247
+ name: "STREAMS_API_KEY",
248
+ schema: type("string"),
249
+ brand: STREAMS_API_KEY
250
+ };
251
+ configKey("", {
178
252
  owner: "service",
179
- name: "RPC_ACCEPTED_KEYS"
253
+ name: STREAMS_API_KEY_PARAM.name
180
254
  });
255
+ const RESERVED_PROVIDER_PARAMS = [RPC_ACCEPTED_KEYS_PARAM, STREAMS_API_KEY_PARAM];
181
256
  blindCast(Symbol.for("prisma:prisma-cloud-param-source"));
182
257
  //#endregion
183
258
  //#region ../../1-prisma-cloud/1-extensions/target/dist/index.mjs
@@ -234,9 +309,8 @@ const compute = (def) => {
234
309
  async run(address, boot) {
235
310
  const config = deserialize(node, address);
236
311
  stash(node, config);
312
+ stashProviderParams(RESERVED_PROVIDER_PARAMS, address);
237
313
  stashSecrets(node, address);
238
- const accepted = process.env[serviceKeyEnvName(address)];
239
- if (accepted !== void 0) process.env[serviceKeyEnvName("")] = accepted;
240
314
  const port = config.service["port"];
241
315
  if (typeof port === "number") process.env["PORT"] = String(port);
242
316
  return boot();
@@ -391,41 +465,3293 @@ function storageService(opts) {
391
465
  }
392
466
  storageService({ bucket: "storage" });
393
467
  //#endregion
394
- //#region ../../1-prisma-cloud/2-shared-modules/streams/dist/streams-service-Dx0z-whA.mjs
395
- const streamsContract = Object.freeze({
468
+ //#region ../../1-prisma-cloud/2-shared-modules/streams/dist/streams-service-CSgUWaVm.mjs
469
+ var __create = Object.create;
470
+ var __defProp = Object.defineProperty;
471
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
472
+ var __getOwnPropNames = Object.getOwnPropertyNames;
473
+ var __getProtoOf = Object.getPrototypeOf;
474
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
475
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
476
+ var __copyProps = (to, from, except, desc) => {
477
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
478
+ key = keys[i];
479
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
480
+ get: ((k) => from[k]).bind(null, key),
481
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
482
+ });
483
+ }
484
+ return to;
485
+ };
486
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
487
+ value: mod,
488
+ enumerable: true
489
+ }) : target, mod));
490
+ var require_reusify = /* @__PURE__ */ __commonJSMin(((exports, module) => {
491
+ function reusify(Constructor) {
492
+ var head = new Constructor();
493
+ var tail = head;
494
+ function get() {
495
+ var current = head;
496
+ if (current.next) head = current.next;
497
+ else {
498
+ head = new Constructor();
499
+ tail = head;
500
+ }
501
+ current.next = null;
502
+ return current;
503
+ }
504
+ function release(obj) {
505
+ tail.next = obj;
506
+ tail = obj;
507
+ }
508
+ return {
509
+ get,
510
+ release
511
+ };
512
+ }
513
+ module.exports = reusify;
514
+ }));
515
+ var import_queue = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
516
+ var reusify = require_reusify();
517
+ function fastqueue(context, worker, _concurrency) {
518
+ if (typeof context === "function") {
519
+ _concurrency = worker;
520
+ worker = context;
521
+ context = null;
522
+ }
523
+ if (!(_concurrency >= 1)) throw new Error("fastqueue concurrency must be equal to or greater than 1");
524
+ var cache = reusify(Task);
525
+ var queueHead = null;
526
+ var queueTail = null;
527
+ var _running = 0;
528
+ var errorHandler = null;
529
+ var self = {
530
+ push,
531
+ drain: noop,
532
+ saturated: noop,
533
+ pause,
534
+ paused: false,
535
+ get concurrency() {
536
+ return _concurrency;
537
+ },
538
+ set concurrency(value) {
539
+ if (!(value >= 1)) throw new Error("fastqueue concurrency must be equal to or greater than 1");
540
+ _concurrency = value;
541
+ if (self.paused) return;
542
+ for (; queueHead && _running < _concurrency;) {
543
+ _running++;
544
+ release();
545
+ }
546
+ },
547
+ running,
548
+ resume,
549
+ idle,
550
+ length,
551
+ getQueue,
552
+ unshift,
553
+ empty: noop,
554
+ kill,
555
+ killAndDrain,
556
+ error,
557
+ abort
558
+ };
559
+ return self;
560
+ function running() {
561
+ return _running;
562
+ }
563
+ function pause() {
564
+ self.paused = true;
565
+ }
566
+ function length() {
567
+ var current = queueHead;
568
+ var counter = 0;
569
+ while (current) {
570
+ current = current.next;
571
+ counter++;
572
+ }
573
+ return counter;
574
+ }
575
+ function getQueue() {
576
+ var current = queueHead;
577
+ var tasks = [];
578
+ while (current) {
579
+ tasks.push(current.value);
580
+ current = current.next;
581
+ }
582
+ return tasks;
583
+ }
584
+ function resume() {
585
+ if (!self.paused) return;
586
+ self.paused = false;
587
+ if (queueHead === null) {
588
+ _running++;
589
+ release();
590
+ return;
591
+ }
592
+ for (; queueHead && _running < _concurrency;) {
593
+ _running++;
594
+ release();
595
+ }
596
+ }
597
+ function idle() {
598
+ return _running === 0 && self.length() === 0;
599
+ }
600
+ function push(value, done) {
601
+ var current = cache.get();
602
+ current.context = context;
603
+ current.release = release;
604
+ current.value = value;
605
+ current.callback = done || noop;
606
+ current.errorHandler = errorHandler;
607
+ if (_running >= _concurrency || self.paused) if (queueTail) {
608
+ queueTail.next = current;
609
+ queueTail = current;
610
+ } else {
611
+ queueHead = current;
612
+ queueTail = current;
613
+ self.saturated();
614
+ }
615
+ else {
616
+ _running++;
617
+ worker.call(context, current.value, current.worked);
618
+ }
619
+ }
620
+ function unshift(value, done) {
621
+ var current = cache.get();
622
+ current.context = context;
623
+ current.release = release;
624
+ current.value = value;
625
+ current.callback = done || noop;
626
+ current.errorHandler = errorHandler;
627
+ if (_running >= _concurrency || self.paused) if (queueHead) {
628
+ current.next = queueHead;
629
+ queueHead = current;
630
+ } else {
631
+ queueHead = current;
632
+ queueTail = current;
633
+ self.saturated();
634
+ }
635
+ else {
636
+ _running++;
637
+ worker.call(context, current.value, current.worked);
638
+ }
639
+ }
640
+ function release(holder) {
641
+ if (holder) cache.release(holder);
642
+ var next = queueHead;
643
+ if (next && _running <= _concurrency) if (!self.paused) {
644
+ if (queueTail === queueHead) queueTail = null;
645
+ queueHead = next.next;
646
+ next.next = null;
647
+ worker.call(context, next.value, next.worked);
648
+ if (queueTail === null) self.empty();
649
+ } else _running--;
650
+ else if (--_running === 0) self.drain();
651
+ }
652
+ function kill() {
653
+ queueHead = null;
654
+ queueTail = null;
655
+ self.drain = noop;
656
+ }
657
+ function killAndDrain() {
658
+ queueHead = null;
659
+ queueTail = null;
660
+ self.drain();
661
+ self.drain = noop;
662
+ }
663
+ function abort() {
664
+ var current = queueHead;
665
+ queueHead = null;
666
+ queueTail = null;
667
+ while (current) {
668
+ var next = current.next;
669
+ var callback = current.callback;
670
+ var errorHandler = current.errorHandler;
671
+ var val = current.value;
672
+ var context = current.context;
673
+ current.value = null;
674
+ current.callback = noop;
675
+ current.errorHandler = null;
676
+ if (errorHandler) errorHandler(/* @__PURE__ */ new Error("abort"), val);
677
+ callback.call(context, /* @__PURE__ */ new Error("abort"));
678
+ current.release(current);
679
+ current = next;
680
+ }
681
+ self.drain = noop;
682
+ }
683
+ function error(handler) {
684
+ errorHandler = handler;
685
+ }
686
+ }
687
+ function noop() {}
688
+ function Task() {
689
+ this.value = null;
690
+ this.callback = noop;
691
+ this.next = null;
692
+ this.release = noop;
693
+ this.context = null;
694
+ this.errorHandler = null;
695
+ var self = this;
696
+ this.worked = function worked(err, result) {
697
+ var callback = self.callback;
698
+ var errorHandler = self.errorHandler;
699
+ var val = self.value;
700
+ self.value = null;
701
+ self.callback = noop;
702
+ if (self.errorHandler) errorHandler(err, val);
703
+ callback.call(self.context, err, result);
704
+ self.release(self);
705
+ };
706
+ }
707
+ function queueAsPromised(context, worker, _concurrency) {
708
+ if (typeof context === "function") {
709
+ _concurrency = worker;
710
+ worker = context;
711
+ context = null;
712
+ }
713
+ function asyncWrapper(arg, cb) {
714
+ worker.call(this, arg).then(function(res) {
715
+ cb(null, res);
716
+ }, cb);
717
+ }
718
+ var queue = fastqueue(context, asyncWrapper, _concurrency);
719
+ var pushCb = queue.push;
720
+ var unshiftCb = queue.unshift;
721
+ queue.push = push;
722
+ queue.unshift = unshift;
723
+ queue.drained = drained;
724
+ return queue;
725
+ function push(value) {
726
+ var p = new Promise(function(resolve, reject) {
727
+ pushCb(value, function(err, result) {
728
+ if (err) {
729
+ reject(err);
730
+ return;
731
+ }
732
+ resolve(result);
733
+ });
734
+ });
735
+ p.catch(noop);
736
+ return p;
737
+ }
738
+ function unshift(value) {
739
+ var p = new Promise(function(resolve, reject) {
740
+ unshiftCb(value, function(err, result) {
741
+ if (err) {
742
+ reject(err);
743
+ return;
744
+ }
745
+ resolve(result);
746
+ });
747
+ });
748
+ p.catch(noop);
749
+ return p;
750
+ }
751
+ function drained() {
752
+ return new Promise(function(resolve) {
753
+ process.nextTick(function() {
754
+ if (queue.idle()) resolve();
755
+ else {
756
+ var previousDrain = queue.drain;
757
+ queue.drain = function() {
758
+ if (typeof previousDrain === "function") previousDrain();
759
+ resolve();
760
+ queue.drain = previousDrain;
761
+ };
762
+ }
763
+ });
764
+ });
765
+ }
766
+ }
767
+ module.exports = fastqueue;
768
+ module.exports.promise = queueAsPromised;
769
+ })))(), 1);
770
+ /**
771
+ * Durable Streams Protocol Constants
772
+ *
773
+ * Header and query parameter names following the Electric Durable Stream Protocol.
774
+ */
775
+ /**
776
+ * Response header containing the next offset to read from.
777
+ * Offsets are opaque tokens - clients MUST NOT interpret the format.
778
+ */
779
+ const STREAM_OFFSET_HEADER = `Stream-Next-Offset`;
780
+ /**
781
+ * Response header for cursor (used for CDN collapsing).
782
+ * Echo this value in subsequent long-poll requests.
783
+ */
784
+ const STREAM_CURSOR_HEADER = `Stream-Cursor`;
785
+ /**
786
+ * Presence header indicating response ends at current end of stream.
787
+ * When present (any value), indicates up-to-date.
788
+ */
789
+ const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`;
790
+ /**
791
+ * Response/request header indicating stream is closed (EOF).
792
+ * When present with value "true", the stream is permanently closed.
793
+ */
794
+ const STREAM_CLOSED_HEADER = `Stream-Closed`;
795
+ /**
796
+ * Request header for writer coordination sequence.
797
+ * Monotonic, lexicographic. If lower than last appended seq -> 409 Conflict.
798
+ */
799
+ const STREAM_SEQ_HEADER = `Stream-Seq`;
800
+ /**
801
+ * Request header for stream TTL in seconds (on create).
802
+ */
803
+ const STREAM_TTL_HEADER = `Stream-TTL`;
804
+ /**
805
+ * Request header for absolute stream expiry time (RFC3339, on create).
806
+ */
807
+ const STREAM_EXPIRES_AT_HEADER = `Stream-Expires-At`;
808
+ /**
809
+ * Request header for producer ID (client-supplied stable identifier).
810
+ */
811
+ const PRODUCER_ID_HEADER = `Producer-Id`;
812
+ /**
813
+ * Request/response header for producer epoch.
814
+ * Client-declared, server-validated monotonically increasing.
815
+ */
816
+ const PRODUCER_EPOCH_HEADER = `Producer-Epoch`;
817
+ /**
818
+ * Request header for producer sequence number.
819
+ * Monotonically increasing per epoch, per-batch (not per-message).
820
+ */
821
+ const PRODUCER_SEQ_HEADER = `Producer-Seq`;
822
+ /**
823
+ * Response header indicating expected sequence number on 409 Conflict.
824
+ */
825
+ const PRODUCER_EXPECTED_SEQ_HEADER = `Producer-Expected-Seq`;
826
+ /**
827
+ * Response header indicating received sequence number on 409 Conflict.
828
+ */
829
+ const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`;
830
+ /**
831
+ * Query parameter for starting offset.
832
+ */
833
+ const OFFSET_QUERY_PARAM = `offset`;
834
+ /**
835
+ * Query parameter for live mode.
836
+ * Values: "long-poll", "sse"
837
+ */
838
+ const LIVE_QUERY_PARAM = `live`;
839
+ /**
840
+ * Response header indicating SSE data encoding (e.g., base64 for binary streams).
841
+ */
842
+ const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`;
843
+ /**
844
+ * Error thrown for transport/network errors.
845
+ * Following the @electric-sql/client FetchError pattern.
846
+ */
847
+ var FetchError = class FetchError extends Error {
848
+ status;
849
+ text;
850
+ json;
851
+ headers;
852
+ constructor(status, text, json, headers, url, message) {
853
+ super(message || `HTTP Error ${status} at ${url}: ${text ?? JSON.stringify(json)}`);
854
+ this.url = url;
855
+ this.name = `FetchError`;
856
+ this.status = status;
857
+ this.text = text;
858
+ this.json = json;
859
+ this.headers = headers;
860
+ }
861
+ static async fromResponse(response, url) {
862
+ const status = response.status;
863
+ const headers = Object.fromEntries([...response.headers.entries()]);
864
+ let text = void 0;
865
+ let json = void 0;
866
+ const contentType = response.headers.get(`content-type`);
867
+ if (!response.bodyUsed) if (contentType && contentType.includes(`application/json`)) try {
868
+ json = await response.json();
869
+ } catch {
870
+ text = await response.text();
871
+ }
872
+ else text = await response.text();
873
+ return new FetchError(status, text, json, headers, url);
874
+ }
875
+ };
876
+ /**
877
+ * Error thrown when a fetch operation is aborted during backoff.
878
+ */
879
+ var FetchBackoffAbortError = class extends Error {
880
+ constructor() {
881
+ super(`Fetch with backoff aborted`);
882
+ this.name = `FetchBackoffAbortError`;
883
+ }
884
+ };
885
+ /**
886
+ * Protocol-level error for Durable Streams operations.
887
+ * Provides structured error handling with error codes.
888
+ */
889
+ var DurableStreamError = class DurableStreamError extends Error {
890
+ /**
891
+ * HTTP status code, if applicable.
892
+ */
893
+ status;
894
+ /**
895
+ * Structured error code for programmatic handling.
896
+ */
897
+ code;
898
+ /**
899
+ * Additional error details (e.g., raw response body).
900
+ */
901
+ details;
902
+ constructor(message, code, status, details) {
903
+ super(message);
904
+ this.name = `DurableStreamError`;
905
+ this.code = code;
906
+ this.status = status;
907
+ this.details = details;
908
+ }
909
+ /**
910
+ * Create a DurableStreamError from an HTTP response.
911
+ */
912
+ static async fromResponse(response, url) {
913
+ const status = response.status;
914
+ let details;
915
+ const contentType = response.headers.get(`content-type`);
916
+ if (!response.bodyUsed) if (contentType && contentType.includes(`application/json`)) try {
917
+ details = await response.json();
918
+ } catch {
919
+ details = await response.text();
920
+ }
921
+ else details = await response.text();
922
+ const code = statusToCode(status);
923
+ const message = `Durable stream error at ${url}: ${response.statusText || status}`;
924
+ return new DurableStreamError(message, code, status, details);
925
+ }
926
+ /**
927
+ * Create a DurableStreamError from a FetchError.
928
+ */
929
+ static fromFetchError(error) {
930
+ const code = statusToCode(error.status);
931
+ return new DurableStreamError(error.message, code, error.status, error.json ?? error.text);
932
+ }
933
+ };
934
+ /**
935
+ * Map HTTP status codes to DurableStreamErrorCode.
936
+ */
937
+ function statusToCode(status) {
938
+ switch (status) {
939
+ case 400: return `BAD_REQUEST`;
940
+ case 401: return `UNAUTHORIZED`;
941
+ case 403: return `FORBIDDEN`;
942
+ case 404: return `NOT_FOUND`;
943
+ case 409: return `CONFLICT_SEQ`;
944
+ case 429: return `RATE_LIMITED`;
945
+ case 503: return `BUSY`;
946
+ default: return `UNKNOWN`;
947
+ }
948
+ }
949
+ /**
950
+ * Error thrown when stream URL is missing.
951
+ */
952
+ var MissingStreamUrlError = class extends Error {
953
+ constructor() {
954
+ super(`Invalid stream options: missing required url parameter`);
955
+ this.name = `MissingStreamUrlError`;
956
+ }
957
+ };
958
+ /**
959
+ * Error thrown when attempting to append to a closed stream.
960
+ */
961
+ var StreamClosedError = class extends DurableStreamError {
962
+ code = `STREAM_CLOSED`;
963
+ status = 409;
964
+ streamClosed = true;
965
+ /**
966
+ * The final offset of the stream, if available from the response.
967
+ */
968
+ finalOffset;
969
+ constructor(url, finalOffset) {
970
+ super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url);
971
+ this.name = `StreamClosedError`;
972
+ this.finalOffset = finalOffset;
973
+ }
974
+ };
975
+ /**
976
+ * Error thrown when signal option is invalid.
977
+ */
978
+ var InvalidSignalError = class extends Error {
979
+ constructor() {
980
+ super(`Invalid signal option. It must be an instance of AbortSignal.`);
981
+ this.name = `InvalidSignalError`;
982
+ }
983
+ };
984
+ /**
985
+ * HTTP status codes that should be retried.
986
+ */
987
+ const HTTP_RETRY_STATUS_CODES = [429, 503];
988
+ /**
989
+ * Default backoff options.
990
+ */
991
+ const BackoffDefaults = {
992
+ initialDelay: 100,
993
+ maxDelay: 6e4,
994
+ multiplier: 1.3,
995
+ maxRetries: Infinity
996
+ };
997
+ /**
998
+ * Parse Retry-After header value and return delay in milliseconds.
999
+ * Supports both delta-seconds format and HTTP-date format.
1000
+ * Returns 0 if header is not present or invalid.
1001
+ */
1002
+ function parseRetryAfterHeader(retryAfter) {
1003
+ if (!retryAfter) return 0;
1004
+ const retryAfterSec = Number(retryAfter);
1005
+ if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) return retryAfterSec * 1e3;
1006
+ const retryDate = Date.parse(retryAfter);
1007
+ if (!isNaN(retryDate)) {
1008
+ const deltaMs = retryDate - Date.now();
1009
+ return Math.max(0, Math.min(deltaMs, 36e5));
1010
+ }
1011
+ return 0;
1012
+ }
1013
+ /**
1014
+ * Creates a fetch client that retries failed requests with exponential backoff.
1015
+ *
1016
+ * @param fetchClient - The base fetch client to wrap
1017
+ * @param backoffOptions - Options for retry behavior
1018
+ * @returns A fetch function with automatic retry
1019
+ */
1020
+ function createFetchWithBackoff(fetchClient, backoffOptions = BackoffDefaults) {
1021
+ const { initialDelay, maxDelay, multiplier, debug = false, onFailedAttempt, maxRetries = Infinity } = backoffOptions;
1022
+ return async (...args) => {
1023
+ const url = args[0];
1024
+ const options = args[1];
1025
+ let delay = initialDelay;
1026
+ let attempt = 0;
1027
+ while (true) try {
1028
+ const result = await fetchClient(...args);
1029
+ if (result.ok) return result;
1030
+ throw await FetchError.fromResponse(result, url.toString());
1031
+ } catch (e) {
1032
+ onFailedAttempt?.();
1033
+ if (options?.signal?.aborted) throw new FetchBackoffAbortError();
1034
+ else if (e instanceof FetchError && !HTTP_RETRY_STATUS_CODES.includes(e.status) && e.status >= 400 && e.status < 500) throw e;
1035
+ else {
1036
+ attempt++;
1037
+ if (attempt > maxRetries) {
1038
+ if (debug) console.log(`Max retries reached (${attempt}/${maxRetries}), giving up`);
1039
+ throw e;
1040
+ }
1041
+ const serverMinimumMs = e instanceof FetchError ? parseRetryAfterHeader(e.headers[`retry-after`]) : 0;
1042
+ const jitter = Math.random() * delay;
1043
+ const clientBackoffMs = Math.min(jitter, maxDelay);
1044
+ const waitMs = Math.max(serverMinimumMs, clientBackoffMs);
1045
+ if (debug) console.log(`Retry attempt #${attempt} after ${waitMs}ms (${serverMinimumMs > 0 ? `server+client` : `client`}, serverMin=${serverMinimumMs}ms, clientBackoff=${clientBackoffMs}ms)`);
1046
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
1047
+ delay = Math.min(delay * multiplier, maxDelay);
1048
+ }
1049
+ }
1050
+ };
1051
+ }
1052
+ /**
1053
+ * Status codes where we shouldn't try to read the body.
1054
+ */
1055
+ const NO_BODY_STATUS_CODES = [
1056
+ 201,
1057
+ 204,
1058
+ 205
1059
+ ];
1060
+ /**
1061
+ * Creates a fetch client that ensures the response body is fully consumed.
1062
+ * This prevents issues with connection pooling when bodies aren't read.
1063
+ *
1064
+ * Uses arrayBuffer() instead of text() to preserve binary data integrity.
1065
+ *
1066
+ * @param fetchClient - The base fetch client to wrap
1067
+ * @returns A fetch function that consumes response bodies
1068
+ */
1069
+ function createFetchWithConsumedBody(fetchClient) {
1070
+ return async (...args) => {
1071
+ const url = args[0];
1072
+ const res = await fetchClient(...args);
1073
+ try {
1074
+ if (res.status < 200 || NO_BODY_STATUS_CODES.includes(res.status)) return res;
1075
+ const buf = await res.arrayBuffer();
1076
+ return new Response(buf, {
1077
+ status: res.status,
1078
+ statusText: res.statusText,
1079
+ headers: res.headers
1080
+ });
1081
+ } catch (err) {
1082
+ if (args[1]?.signal?.aborted) throw new FetchBackoffAbortError();
1083
+ throw new FetchError(res.status, void 0, void 0, Object.fromEntries([...res.headers.entries()]), url.toString(), err instanceof Error ? err.message : typeof err === `string` ? err : `failed to read body`);
1084
+ }
1085
+ };
1086
+ }
1087
+ /**
1088
+ * Check if a value has Symbol.asyncIterator defined.
1089
+ */
1090
+ function hasAsyncIterator(stream$1) {
1091
+ return typeof Symbol !== `undefined` && typeof Symbol.asyncIterator === `symbol` && typeof stream$1[Symbol.asyncIterator] === `function`;
1092
+ }
1093
+ /**
1094
+ * Define [Symbol.asyncIterator] and .values() on a ReadableStream instance.
1095
+ *
1096
+ * Uses getReader().read() to implement spec-consistent iteration.
1097
+ * On completion or early exit (break/return/throw), releases lock and cancels as appropriate.
1098
+ *
1099
+ * **Iterator behavior notes:**
1100
+ * - `return(value?)` accepts an optional cancellation reason passed to `reader.cancel()`
1101
+ * - `return()` always resolves with `{ done: true, value: undefined }` regardless of the
1102
+ * input value. This matches `for await...of` semantics where the return value is ignored.
1103
+ * Manual iteration users should be aware of this behavior.
1104
+ */
1105
+ function defineAsyncIterator(stream$1) {
1106
+ if (typeof Symbol === `undefined` || typeof Symbol.asyncIterator !== `symbol`) return;
1107
+ if (typeof stream$1[Symbol.asyncIterator] === `function`) return;
1108
+ const createIterator = function() {
1109
+ const reader = this.getReader();
1110
+ let finished = false;
1111
+ let pendingReads = 0;
1112
+ return {
1113
+ async next() {
1114
+ if (finished) return {
1115
+ done: true,
1116
+ value: void 0
1117
+ };
1118
+ pendingReads++;
1119
+ try {
1120
+ const { value, done } = await reader.read();
1121
+ if (done) {
1122
+ finished = true;
1123
+ reader.releaseLock();
1124
+ return {
1125
+ done: true,
1126
+ value: void 0
1127
+ };
1128
+ }
1129
+ return {
1130
+ done: false,
1131
+ value
1132
+ };
1133
+ } catch (err) {
1134
+ finished = true;
1135
+ try {
1136
+ reader.releaseLock();
1137
+ } catch {}
1138
+ throw err;
1139
+ } finally {
1140
+ pendingReads--;
1141
+ }
1142
+ },
1143
+ async return(value) {
1144
+ if (pendingReads > 0) throw new TypeError(`Cannot close a readable stream reader when it has pending read requests`);
1145
+ finished = true;
1146
+ const cancelPromise = reader.cancel(value);
1147
+ reader.releaseLock();
1148
+ await cancelPromise;
1149
+ return {
1150
+ done: true,
1151
+ value: void 0
1152
+ };
1153
+ },
1154
+ async throw(err) {
1155
+ if (pendingReads > 0) throw new TypeError(`Cannot close a readable stream reader when it has pending read requests`);
1156
+ finished = true;
1157
+ const cancelPromise = reader.cancel(err);
1158
+ reader.releaseLock();
1159
+ await cancelPromise;
1160
+ throw err;
1161
+ },
1162
+ [Symbol.asyncIterator]() {
1163
+ return this;
1164
+ }
1165
+ };
1166
+ };
1167
+ try {
1168
+ Object.defineProperty(stream$1, Symbol.asyncIterator, {
1169
+ configurable: true,
1170
+ writable: true,
1171
+ value: createIterator
1172
+ });
1173
+ } catch {
1174
+ return;
1175
+ }
1176
+ try {
1177
+ Object.defineProperty(stream$1, `values`, {
1178
+ configurable: true,
1179
+ writable: true,
1180
+ value: createIterator
1181
+ });
1182
+ } catch {}
1183
+ }
1184
+ /**
1185
+ * Ensure a ReadableStream is async-iterable.
1186
+ *
1187
+ * If the stream already has [Symbol.asyncIterator] defined (native or polyfilled),
1188
+ * it is returned as-is. Otherwise, [Symbol.asyncIterator] is defined on the
1189
+ * stream instance (not the prototype).
1190
+ *
1191
+ * The returned value is the same ReadableStream instance, so:
1192
+ * - `stream instanceof ReadableStream` remains true
1193
+ * - Any code relying on native branding/internal slots continues to work
1194
+ *
1195
+ * @example
1196
+ * ```typescript
1197
+ * const stream = someApiReturningReadableStream();
1198
+ * const iterableStream = asAsyncIterableReadableStream(stream);
1199
+ *
1200
+ * // Now works on Safari/iOS:
1201
+ * for await (const chunk of iterableStream) {
1202
+ * console.log(chunk);
1203
+ * }
1204
+ * ```
1205
+ */
1206
+ function asAsyncIterableReadableStream(stream$1) {
1207
+ if (!hasAsyncIterator(stream$1)) defineAsyncIterator(stream$1);
1208
+ return stream$1;
1209
+ }
1210
+ /**
1211
+ * Parse SSE events from a ReadableStream<Uint8Array>.
1212
+ * Yields parsed events as they arrive.
1213
+ */
1214
+ async function* parseSSEStream(stream$1, signal) {
1215
+ const reader = stream$1.getReader();
1216
+ const decoder = new TextDecoder();
1217
+ let buffer = ``;
1218
+ let currentEvent = { data: [] };
1219
+ try {
1220
+ while (true) {
1221
+ if (signal?.aborted) break;
1222
+ const { done, value } = await reader.read();
1223
+ if (done) break;
1224
+ buffer += decoder.decode(value, { stream: true });
1225
+ buffer = buffer.replace(/\r\n/g, `\n`).replace(/\r/g, `\n`);
1226
+ const lines = buffer.split(`\n`);
1227
+ buffer = lines.pop() ?? ``;
1228
+ for (const line of lines) if (line === ``) {
1229
+ if (currentEvent.type && currentEvent.data.length > 0) {
1230
+ const dataStr = currentEvent.data.join(`\n`);
1231
+ if (currentEvent.type === `data`) yield {
1232
+ type: `data`,
1233
+ data: dataStr
1234
+ };
1235
+ else if (currentEvent.type === `control`) try {
1236
+ const control = JSON.parse(dataStr);
1237
+ yield {
1238
+ type: `control`,
1239
+ streamNextOffset: control.streamNextOffset,
1240
+ streamCursor: control.streamCursor,
1241
+ upToDate: control.upToDate,
1242
+ streamClosed: control.streamClosed
1243
+ };
1244
+ } catch (err) {
1245
+ const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
1246
+ throw new DurableStreamError(`Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1247
+ }
1248
+ }
1249
+ currentEvent = { data: [] };
1250
+ } else if (line.startsWith(`event:`)) {
1251
+ const eventType = line.slice(6);
1252
+ currentEvent.type = eventType.startsWith(` `) ? eventType.slice(1) : eventType;
1253
+ } else if (line.startsWith(`data:`)) {
1254
+ const content = line.slice(5);
1255
+ currentEvent.data.push(content.startsWith(` `) ? content.slice(1) : content);
1256
+ }
1257
+ }
1258
+ const remaining = decoder.decode();
1259
+ if (remaining) buffer += remaining;
1260
+ if (buffer && currentEvent.type && currentEvent.data.length > 0) {
1261
+ const dataStr = currentEvent.data.join(`\n`);
1262
+ if (currentEvent.type === `data`) yield {
1263
+ type: `data`,
1264
+ data: dataStr
1265
+ };
1266
+ else if (currentEvent.type === `control`) try {
1267
+ const control = JSON.parse(dataStr);
1268
+ yield {
1269
+ type: `control`,
1270
+ streamNextOffset: control.streamNextOffset,
1271
+ streamCursor: control.streamCursor,
1272
+ upToDate: control.upToDate,
1273
+ streamClosed: control.streamClosed
1274
+ };
1275
+ } catch (err) {
1276
+ const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
1277
+ throw new DurableStreamError(`Failed to parse SSE control event: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1278
+ }
1279
+ }
1280
+ } finally {
1281
+ reader.releaseLock();
1282
+ }
1283
+ }
1284
+ /**
1285
+ * Constant used as abort reason when pausing the stream due to visibility change.
1286
+ */
1287
+ const PAUSE_STREAM = `PAUSE_STREAM`;
1288
+ /**
1289
+ * Implementation of the StreamResponse interface.
1290
+ */
1291
+ var StreamResponseImpl = class {
1292
+ url;
1293
+ contentType;
1294
+ live;
1295
+ startOffset;
1296
+ #headers;
1297
+ #status;
1298
+ #statusText;
1299
+ #ok;
1300
+ #isLoading;
1301
+ #offset;
1302
+ #cursor;
1303
+ #upToDate;
1304
+ #streamClosed;
1305
+ #isJsonMode;
1306
+ #abortController;
1307
+ #fetchNext;
1308
+ #startSSE;
1309
+ #closedResolve;
1310
+ #closedReject;
1311
+ #closed;
1312
+ #stopAfterUpToDate = false;
1313
+ #consumptionMethod = null;
1314
+ #state = `active`;
1315
+ #requestAbortController;
1316
+ #unsubscribeFromVisibilityChanges;
1317
+ #pausePromise;
1318
+ #pauseResolve;
1319
+ #justResumedFromPause = false;
1320
+ #sseResilience;
1321
+ #lastSSEConnectionStartTime;
1322
+ #consecutiveShortSSEConnections = 0;
1323
+ #sseFallbackToLongPoll = false;
1324
+ #encoding;
1325
+ #responseStream;
1326
+ constructor(config) {
1327
+ this.url = config.url;
1328
+ this.contentType = config.contentType;
1329
+ this.live = config.live;
1330
+ this.startOffset = config.startOffset;
1331
+ this.#offset = config.initialOffset;
1332
+ this.#cursor = config.initialCursor;
1333
+ this.#upToDate = config.initialUpToDate;
1334
+ this.#streamClosed = config.initialStreamClosed;
1335
+ this.#headers = config.firstResponse.headers;
1336
+ this.#status = config.firstResponse.status;
1337
+ this.#statusText = config.firstResponse.statusText;
1338
+ this.#ok = config.firstResponse.ok;
1339
+ this.#isLoading = false;
1340
+ this.#isJsonMode = config.isJsonMode;
1341
+ this.#abortController = config.abortController;
1342
+ this.#fetchNext = config.fetchNext;
1343
+ this.#startSSE = config.startSSE;
1344
+ this.#sseResilience = {
1345
+ minConnectionDuration: config.sseResilience?.minConnectionDuration ?? 1e3,
1346
+ maxShortConnections: config.sseResilience?.maxShortConnections ?? 3,
1347
+ backoffBaseDelay: config.sseResilience?.backoffBaseDelay ?? 100,
1348
+ backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5e3,
1349
+ logWarnings: config.sseResilience?.logWarnings ?? true
1350
+ };
1351
+ this.#encoding = config.encoding;
1352
+ this.#closed = new Promise((resolve, reject) => {
1353
+ this.#closedResolve = resolve;
1354
+ this.#closedReject = reject;
1355
+ });
1356
+ this.#responseStream = this.#createResponseStream(config.firstResponse);
1357
+ this.#abortController.signal.addEventListener(`abort`, () => {
1358
+ this.#requestAbortController?.abort(this.#abortController.signal.reason);
1359
+ this.#pauseResolve?.();
1360
+ this.#pausePromise = void 0;
1361
+ this.#pauseResolve = void 0;
1362
+ }, { once: true });
1363
+ this.#subscribeToVisibilityChanges();
1364
+ }
1365
+ /**
1366
+ * Subscribe to document visibility changes to pause/resume syncing.
1367
+ * When the page is hidden, we pause to save battery and bandwidth.
1368
+ * When visible again, we resume syncing.
1369
+ */
1370
+ #subscribeToVisibilityChanges() {
1371
+ if (typeof document === `object` && typeof document.hidden === `boolean` && typeof document.addEventListener === `function`) {
1372
+ const visibilityHandler = () => {
1373
+ if (document.hidden) this.#pause();
1374
+ else this.#resume();
1375
+ };
1376
+ document.addEventListener(`visibilitychange`, visibilityHandler);
1377
+ this.#unsubscribeFromVisibilityChanges = () => {
1378
+ if (typeof document === `object`) document.removeEventListener(`visibilitychange`, visibilityHandler);
1379
+ };
1380
+ if (document.hidden) this.#pause();
1381
+ }
1382
+ }
1383
+ /**
1384
+ * Pause the stream when page becomes hidden.
1385
+ * Aborts any in-flight request to free resources.
1386
+ * Creates a promise that pull() will await while paused.
1387
+ */
1388
+ #pause() {
1389
+ if (this.#state === `active`) {
1390
+ this.#state = `pause-requested`;
1391
+ this.#pausePromise = new Promise((resolve) => {
1392
+ this.#pauseResolve = resolve;
1393
+ });
1394
+ this.#requestAbortController?.abort(PAUSE_STREAM);
1395
+ }
1396
+ }
1397
+ /**
1398
+ * Resume the stream when page becomes visible.
1399
+ * Resolves the pause promise to unblock pull().
1400
+ */
1401
+ #resume() {
1402
+ if (this.#state === `paused` || this.#state === `pause-requested`) {
1403
+ if (this.#abortController.signal.aborted) return;
1404
+ this.#state = `active`;
1405
+ this.#justResumedFromPause = true;
1406
+ this.#pauseResolve?.();
1407
+ this.#pausePromise = void 0;
1408
+ this.#pauseResolve = void 0;
1409
+ }
1410
+ }
1411
+ get headers() {
1412
+ return this.#headers;
1413
+ }
1414
+ get status() {
1415
+ return this.#status;
1416
+ }
1417
+ get statusText() {
1418
+ return this.#statusText;
1419
+ }
1420
+ get ok() {
1421
+ return this.#ok;
1422
+ }
1423
+ get isLoading() {
1424
+ return this.#isLoading;
1425
+ }
1426
+ get offset() {
1427
+ return this.#offset;
1428
+ }
1429
+ get cursor() {
1430
+ return this.#cursor;
1431
+ }
1432
+ get upToDate() {
1433
+ return this.#upToDate;
1434
+ }
1435
+ get streamClosed() {
1436
+ return this.#streamClosed;
1437
+ }
1438
+ #ensureJsonMode() {
1439
+ 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`);
1440
+ }
1441
+ #markClosed() {
1442
+ this.#unsubscribeFromVisibilityChanges?.();
1443
+ this.#closedResolve();
1444
+ }
1445
+ #markError(err) {
1446
+ this.#unsubscribeFromVisibilityChanges?.();
1447
+ this.#closedReject(err);
1448
+ }
1449
+ /**
1450
+ * Ensure only one consumption method is used per StreamResponse.
1451
+ * Throws if any consumption method was already called.
1452
+ */
1453
+ #ensureNoConsumption(method) {
1454
+ if (this.#consumptionMethod !== null) throw new DurableStreamError(`Cannot call ${method}() - this StreamResponse is already being consumed via ${this.#consumptionMethod}()`, `ALREADY_CONSUMED`);
1455
+ this.#consumptionMethod = method;
1456
+ }
1457
+ /**
1458
+ * Determine if we should continue with live updates based on live mode
1459
+ * and whether we've received upToDate or streamClosed.
1460
+ */
1461
+ #shouldContinueLive() {
1462
+ if (this.#stopAfterUpToDate && this.upToDate) return false;
1463
+ if (this.live === false) return false;
1464
+ if (this.#streamClosed) return false;
1465
+ return true;
1466
+ }
1467
+ /**
1468
+ * Update state from response headers.
1469
+ */
1470
+ #updateStateFromResponse(response) {
1471
+ const offset = response.headers.get(STREAM_OFFSET_HEADER);
1472
+ if (offset) this.#offset = offset;
1473
+ const cursor = response.headers.get(STREAM_CURSOR_HEADER);
1474
+ if (cursor) this.#cursor = cursor;
1475
+ this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
1476
+ if (response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`) this.#streamClosed = true;
1477
+ this.#headers = response.headers;
1478
+ this.#status = response.status;
1479
+ this.#statusText = response.statusText;
1480
+ this.#ok = response.ok;
1481
+ }
1482
+ /**
1483
+ * Extract stream metadata from Response headers.
1484
+ * Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
1485
+ * specific Response, rather than reading from `this` which may be stale due to
1486
+ * ReadableStream prefetching or timing issues.
1487
+ */
1488
+ #getMetadataFromResponse(response) {
1489
+ const offset = response.headers.get(STREAM_OFFSET_HEADER);
1490
+ const cursor = response.headers.get(STREAM_CURSOR_HEADER);
1491
+ const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
1492
+ const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
1493
+ return {
1494
+ offset: offset ?? this.offset,
1495
+ cursor: cursor ?? this.cursor,
1496
+ upToDate,
1497
+ streamClosed: streamClosed || this.streamClosed
1498
+ };
1499
+ }
1500
+ /**
1501
+ * Decode base64 string to Uint8Array.
1502
+ * Per protocol: concatenate data lines, remove \n and \r, then decode.
1503
+ */
1504
+ #decodeBase64(base64Str) {
1505
+ const cleaned = base64Str.replace(/[\n\r]/g, ``);
1506
+ if (cleaned.length === 0) return /* @__PURE__ */ new Uint8Array(0);
1507
+ if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
1508
+ try {
1509
+ if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
1510
+ else {
1511
+ const binaryStr = atob(cleaned);
1512
+ const bytes = new Uint8Array(binaryStr.length);
1513
+ for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
1514
+ return bytes;
1515
+ }
1516
+ } catch (err) {
1517
+ throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
1518
+ }
1519
+ }
1520
+ /**
1521
+ * Create a synthetic Response from SSE data with proper headers.
1522
+ * Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
1523
+ */
1524
+ #createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed) {
1525
+ return this.#createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed);
1526
+ }
1527
+ /**
1528
+ * Create a synthetic Response from multiple SSE data parts.
1529
+ * For base64 mode, each part is independently encoded, so we decode each
1530
+ * separately and concatenate the binary results.
1531
+ * For text mode, parts are simply concatenated as strings.
1532
+ */
1533
+ #createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed) {
1534
+ const headers = {
1535
+ "content-type": this.contentType ?? `application/json`,
1536
+ [STREAM_OFFSET_HEADER]: String(offset)
1537
+ };
1538
+ if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
1539
+ if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
1540
+ if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
1541
+ let body;
1542
+ if (this.#encoding === `base64`) {
1543
+ const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => this.#decodeBase64(part));
1544
+ if (decodedParts.length === 0) body = /* @__PURE__ */ new ArrayBuffer(0);
1545
+ else if (decodedParts.length === 1) {
1546
+ const decoded = decodedParts[0];
1547
+ body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
1548
+ } else {
1549
+ const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
1550
+ const combined = new Uint8Array(totalLength);
1551
+ let offset$1 = 0;
1552
+ for (const part of decodedParts) {
1553
+ combined.set(part, offset$1);
1554
+ offset$1 += part.length;
1555
+ }
1556
+ body = combined.buffer;
1557
+ }
1558
+ } else body = dataParts.join(``);
1559
+ return new Response(body, {
1560
+ status: 200,
1561
+ headers
1562
+ });
1563
+ }
1564
+ /**
1565
+ * Update instance state from an SSE control event.
1566
+ */
1567
+ #updateStateFromSSEControl(controlEvent) {
1568
+ this.#offset = controlEvent.streamNextOffset;
1569
+ if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
1570
+ if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
1571
+ if (controlEvent.streamClosed) {
1572
+ this.#streamClosed = true;
1573
+ this.#upToDate = true;
1574
+ }
1575
+ }
1576
+ /**
1577
+ * Mark the start of an SSE connection for duration tracking.
1578
+ */
1579
+ #markSSEConnectionStart() {
1580
+ this.#lastSSEConnectionStartTime = Date.now();
1581
+ }
1582
+ /**
1583
+ * Handle SSE connection end - check duration and manage fallback state.
1584
+ * Returns a delay to wait before reconnecting, or null if should not reconnect.
1585
+ */
1586
+ async #handleSSEConnectionEnd() {
1587
+ if (this.#lastSSEConnectionStartTime === void 0) return 0;
1588
+ const connectionDuration = Date.now() - this.#lastSSEConnectionStartTime;
1589
+ const wasAborted = this.#abortController.signal.aborted;
1590
+ if (connectionDuration < this.#sseResilience.minConnectionDuration && !wasAborted) {
1591
+ this.#consecutiveShortSSEConnections++;
1592
+ if (this.#consecutiveShortSSEConnections >= this.#sseResilience.maxShortConnections) {
1593
+ this.#sseFallbackToLongPoll = true;
1594
+ if (this.#sseResilience.logWarnings) console.warn("[Durable Streams] SSE connections are closing immediately (possibly due to proxy buffering or misconfiguration). Falling back to long polling. Your proxy must support streaming SSE responses (not buffer the complete response). Configuration: Nginx add 'X-Accel-Buffering: no', Caddy add 'flush_interval -1' to reverse_proxy.");
1595
+ return null;
1596
+ } else {
1597
+ const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, this.#consecutiveShortSSEConnections));
1598
+ const delayMs = Math.floor(Math.random() * maxDelay);
1599
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
1600
+ return delayMs;
1601
+ }
1602
+ } else if (connectionDuration >= this.#sseResilience.minConnectionDuration) this.#consecutiveShortSSEConnections = 0;
1603
+ return 0;
1604
+ }
1605
+ /**
1606
+ * Try to reconnect SSE and return the new iterator, or null if reconnection
1607
+ * is not possible or fails.
1608
+ */
1609
+ async #trySSEReconnect() {
1610
+ if (this.#sseFallbackToLongPoll) return null;
1611
+ if (!this.#shouldContinueLive() || !this.#startSSE) return null;
1612
+ if (await this.#handleSSEConnectionEnd() === null) return null;
1613
+ this.#markSSEConnectionStart();
1614
+ this.#requestAbortController = new AbortController();
1615
+ const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
1616
+ if (newSSEResponse.body) return parseSSEStream(newSSEResponse.body, this.#requestAbortController.signal);
1617
+ return null;
1618
+ }
1619
+ /**
1620
+ * Process SSE events from the iterator.
1621
+ * Returns an object indicating the result:
1622
+ * - { type: 'response', response, newIterator? } - yield this response
1623
+ * - { type: 'closed' } - stream should be closed
1624
+ * - { type: 'error', error } - an error occurred
1625
+ * - { type: 'continue', newIterator? } - continue processing (control-only event)
1626
+ */
1627
+ async #processSSEEvents(sseEventIterator) {
1628
+ const { done, value: event } = await sseEventIterator.next();
1629
+ if (done) {
1630
+ try {
1631
+ const newIterator = await this.#trySSEReconnect();
1632
+ if (newIterator) return {
1633
+ type: `continue`,
1634
+ newIterator
1635
+ };
1636
+ } catch (err) {
1637
+ return {
1638
+ type: `error`,
1639
+ error: err instanceof Error ? err : /* @__PURE__ */ new Error(`SSE reconnection failed`)
1640
+ };
1641
+ }
1642
+ return { type: `closed` };
1643
+ }
1644
+ if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
1645
+ this.#updateStateFromSSEControl(event);
1646
+ if (event.upToDate) return {
1647
+ type: `response`,
1648
+ response: this.#createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false)
1649
+ };
1650
+ return { type: `continue` };
1651
+ }
1652
+ /**
1653
+ * Process an SSE data event by waiting for its corresponding control event.
1654
+ * In SSE protocol, control events come AFTER data events.
1655
+ * Multiple data events may arrive before a single control event - we buffer them.
1656
+ *
1657
+ * For base64 mode, each data event is independently base64 encoded, so we
1658
+ * collect them as an array and decode each separately.
1659
+ */
1660
+ async #processSSEDataEvent(pendingData, sseEventIterator) {
1661
+ const bufferedDataParts = [pendingData];
1662
+ while (true) {
1663
+ const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
1664
+ if (controlDone) {
1665
+ const response = this.#createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed);
1666
+ try {
1667
+ return {
1668
+ type: `response`,
1669
+ response,
1670
+ newIterator: await this.#trySSEReconnect() ?? void 0
1671
+ };
1672
+ } catch (err) {
1673
+ return {
1674
+ type: `error`,
1675
+ error: err instanceof Error ? err : /* @__PURE__ */ new Error(`SSE reconnection failed`)
1676
+ };
1677
+ }
1678
+ }
1679
+ if (controlEvent.type === `control`) {
1680
+ this.#updateStateFromSSEControl(controlEvent);
1681
+ return {
1682
+ type: `response`,
1683
+ response: this.#createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false)
1684
+ };
1685
+ }
1686
+ bufferedDataParts.push(controlEvent.data);
1687
+ }
1688
+ }
1689
+ /**
1690
+ * Create the core ReadableStream<Response> that yields responses.
1691
+ * This is consumed once - all consumption methods use this same stream.
1692
+ *
1693
+ * For long-poll mode: yields actual Response objects.
1694
+ * For SSE mode: yields synthetic Response objects created from SSE data events.
1695
+ */
1696
+ #createResponseStream(firstResponse) {
1697
+ let firstResponseYielded = false;
1698
+ let sseEventIterator = null;
1699
+ return new ReadableStream({
1700
+ pull: async (controller) => {
1701
+ try {
1702
+ if (!firstResponseYielded) {
1703
+ firstResponseYielded = true;
1704
+ if ((firstResponse.headers.get(`content-type`)?.includes(`text/event-stream`) ?? false) && firstResponse.body) {
1705
+ this.#markSSEConnectionStart();
1706
+ this.#requestAbortController = new AbortController();
1707
+ sseEventIterator = parseSSEStream(firstResponse.body, this.#requestAbortController.signal);
1708
+ } else {
1709
+ controller.enqueue(firstResponse);
1710
+ if (this.upToDate && !this.#shouldContinueLive()) {
1711
+ this.#markClosed();
1712
+ controller.close();
1713
+ return;
1714
+ }
1715
+ return;
1716
+ }
1717
+ }
1718
+ if (sseEventIterator) {
1719
+ if (this.#state === `pause-requested` || this.#state === `paused`) {
1720
+ this.#state = `paused`;
1721
+ if (this.#pausePromise) await this.#pausePromise;
1722
+ if (this.#abortController.signal.aborted) {
1723
+ this.#markClosed();
1724
+ controller.close();
1725
+ return;
1726
+ }
1727
+ const newIterator = await this.#trySSEReconnect();
1728
+ if (newIterator) sseEventIterator = newIterator;
1729
+ else {
1730
+ this.#markClosed();
1731
+ controller.close();
1732
+ return;
1733
+ }
1734
+ }
1735
+ while (true) {
1736
+ const result = await this.#processSSEEvents(sseEventIterator);
1737
+ switch (result.type) {
1738
+ case `response`:
1739
+ if (result.newIterator) sseEventIterator = result.newIterator;
1740
+ controller.enqueue(result.response);
1741
+ return;
1742
+ case `closed`:
1743
+ this.#markClosed();
1744
+ controller.close();
1745
+ return;
1746
+ case `error`:
1747
+ this.#markError(result.error);
1748
+ controller.error(result.error);
1749
+ return;
1750
+ case `continue`:
1751
+ if (result.newIterator) sseEventIterator = result.newIterator;
1752
+ continue;
1753
+ }
1754
+ }
1755
+ }
1756
+ if (this.#shouldContinueLive()) {
1757
+ if (this.#state === `pause-requested` || this.#state === `paused`) {
1758
+ this.#state = `paused`;
1759
+ if (this.#pausePromise) await this.#pausePromise;
1760
+ if (this.#abortController.signal.aborted) {
1761
+ this.#markClosed();
1762
+ controller.close();
1763
+ return;
1764
+ }
1765
+ }
1766
+ if (this.#abortController.signal.aborted) {
1767
+ this.#markClosed();
1768
+ controller.close();
1769
+ return;
1770
+ }
1771
+ const resumingFromPause = this.#justResumedFromPause;
1772
+ this.#justResumedFromPause = false;
1773
+ this.#requestAbortController = new AbortController();
1774
+ const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, resumingFromPause);
1775
+ this.#updateStateFromResponse(response);
1776
+ controller.enqueue(response);
1777
+ return;
1778
+ }
1779
+ this.#markClosed();
1780
+ controller.close();
1781
+ } catch (err) {
1782
+ if (this.#requestAbortController?.signal.aborted && this.#requestAbortController.signal.reason === PAUSE_STREAM) {
1783
+ if (this.#state === `pause-requested`) this.#state = `paused`;
1784
+ return;
1785
+ }
1786
+ if (this.#abortController.signal.aborted) {
1787
+ this.#markClosed();
1788
+ controller.close();
1789
+ } else {
1790
+ this.#markError(err instanceof Error ? err : new Error(String(err)));
1791
+ controller.error(err);
1792
+ }
1793
+ }
1794
+ },
1795
+ cancel: () => {
1796
+ this.#abortController.abort();
1797
+ this.#unsubscribeFromVisibilityChanges?.();
1798
+ this.#markClosed();
1799
+ }
1800
+ });
1801
+ }
1802
+ /**
1803
+ * Get the response stream reader. Can only be called once.
1804
+ */
1805
+ #getResponseReader() {
1806
+ return this.#responseStream.getReader();
1807
+ }
1808
+ async body() {
1809
+ this.#ensureNoConsumption(`body`);
1810
+ this.#stopAfterUpToDate = true;
1811
+ const reader = this.#getResponseReader();
1812
+ const blobs = [];
1813
+ try {
1814
+ let result = await reader.read();
1815
+ while (!result.done) {
1816
+ const wasUpToDate = this.upToDate;
1817
+ const blob = await result.value.blob();
1818
+ if (blob.size > 0) blobs.push(blob);
1819
+ if (wasUpToDate) break;
1820
+ result = await reader.read();
1821
+ }
1822
+ } finally {
1823
+ reader.releaseLock();
1824
+ }
1825
+ this.#markClosed();
1826
+ if (blobs.length === 0) return /* @__PURE__ */ new Uint8Array(0);
1827
+ if (blobs.length === 1) return new Uint8Array(await blobs[0].arrayBuffer());
1828
+ const combined = new Blob(blobs);
1829
+ return new Uint8Array(await combined.arrayBuffer());
1830
+ }
1831
+ async json() {
1832
+ this.#ensureNoConsumption(`json`);
1833
+ this.#ensureJsonMode();
1834
+ this.#stopAfterUpToDate = true;
1835
+ const reader = this.#getResponseReader();
1836
+ const items = [];
1837
+ try {
1838
+ let result = await reader.read();
1839
+ while (!result.done) {
1840
+ const wasUpToDate = this.upToDate;
1841
+ const content = (await result.value.text()).trim() || `[]`;
1842
+ let parsed;
1843
+ try {
1844
+ parsed = JSON.parse(content);
1845
+ } catch (err) {
1846
+ const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1847
+ throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1848
+ }
1849
+ if (Array.isArray(parsed)) items.push(...parsed);
1850
+ else items.push(parsed);
1851
+ if (wasUpToDate) break;
1852
+ result = await reader.read();
1853
+ }
1854
+ } finally {
1855
+ reader.releaseLock();
1856
+ }
1857
+ this.#markClosed();
1858
+ return items;
1859
+ }
1860
+ async text() {
1861
+ this.#ensureNoConsumption(`text`);
1862
+ this.#stopAfterUpToDate = true;
1863
+ const reader = this.#getResponseReader();
1864
+ const parts = [];
1865
+ try {
1866
+ let result = await reader.read();
1867
+ while (!result.done) {
1868
+ const wasUpToDate = this.upToDate;
1869
+ const text = await result.value.text();
1870
+ if (text) parts.push(text);
1871
+ if (wasUpToDate) break;
1872
+ result = await reader.read();
1873
+ }
1874
+ } finally {
1875
+ reader.releaseLock();
1876
+ }
1877
+ this.#markClosed();
1878
+ return parts.join(``);
1879
+ }
1880
+ /**
1881
+ * Internal helper to create the body stream without consumption check.
1882
+ * Used by both bodyStream() and textStream().
1883
+ */
1884
+ #createBodyStreamInternal() {
1885
+ const { readable, writable } = new TransformStream();
1886
+ const reader = this.#getResponseReader();
1887
+ const pipeBodyStream = async () => {
1888
+ try {
1889
+ let result = await reader.read();
1890
+ while (!result.done) {
1891
+ const wasUpToDate = this.upToDate;
1892
+ const body = result.value.body;
1893
+ if (body) await body.pipeTo(writable, {
1894
+ preventClose: true,
1895
+ preventAbort: true,
1896
+ preventCancel: true
1897
+ });
1898
+ if (wasUpToDate && !this.#shouldContinueLive()) break;
1899
+ result = await reader.read();
1900
+ }
1901
+ await writable.close();
1902
+ this.#markClosed();
1903
+ } catch (err) {
1904
+ if (this.#abortController.signal.aborted) {
1905
+ try {
1906
+ await writable.close();
1907
+ } catch {}
1908
+ this.#markClosed();
1909
+ } else {
1910
+ try {
1911
+ await writable.abort(err);
1912
+ } catch {}
1913
+ this.#markError(err instanceof Error ? err : new Error(String(err)));
1914
+ }
1915
+ } finally {
1916
+ reader.releaseLock();
1917
+ }
1918
+ };
1919
+ pipeBodyStream();
1920
+ return readable;
1921
+ }
1922
+ bodyStream() {
1923
+ this.#ensureNoConsumption(`bodyStream`);
1924
+ return asAsyncIterableReadableStream(this.#createBodyStreamInternal());
1925
+ }
1926
+ jsonStream() {
1927
+ this.#ensureNoConsumption(`jsonStream`);
1928
+ this.#ensureJsonMode();
1929
+ const reader = this.#getResponseReader();
1930
+ let pendingItems = [];
1931
+ return asAsyncIterableReadableStream(new ReadableStream({
1932
+ pull: async (controller) => {
1933
+ if (pendingItems.length > 0) {
1934
+ controller.enqueue(pendingItems.shift());
1935
+ return;
1936
+ }
1937
+ const { done, value: response } = await reader.read();
1938
+ if (done) {
1939
+ this.#markClosed();
1940
+ controller.close();
1941
+ return;
1942
+ }
1943
+ const content = (await response.text()).trim() || `[]`;
1944
+ let parsed;
1945
+ try {
1946
+ parsed = JSON.parse(content);
1947
+ } catch (err) {
1948
+ const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1949
+ throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1950
+ }
1951
+ pendingItems = Array.isArray(parsed) ? parsed : [parsed];
1952
+ if (pendingItems.length > 0) controller.enqueue(pendingItems.shift());
1953
+ },
1954
+ cancel: () => {
1955
+ reader.releaseLock();
1956
+ this.cancel();
1957
+ }
1958
+ }));
1959
+ }
1960
+ textStream() {
1961
+ this.#ensureNoConsumption(`textStream`);
1962
+ const decoder = new TextDecoder();
1963
+ return asAsyncIterableReadableStream(this.#createBodyStreamInternal().pipeThrough(new TransformStream({
1964
+ transform(chunk, controller) {
1965
+ controller.enqueue(decoder.decode(chunk, { stream: true }));
1966
+ },
1967
+ flush(controller) {
1968
+ const remaining = decoder.decode();
1969
+ if (remaining) controller.enqueue(remaining);
1970
+ }
1971
+ })));
1972
+ }
1973
+ subscribeJson(subscriber) {
1974
+ this.#ensureNoConsumption(`subscribeJson`);
1975
+ this.#ensureJsonMode();
1976
+ const abortController = new AbortController();
1977
+ const reader = this.#getResponseReader();
1978
+ const consumeJsonSubscription = async () => {
1979
+ try {
1980
+ let result = await reader.read();
1981
+ while (!result.done) {
1982
+ if (abortController.signal.aborted) break;
1983
+ const response = result.value;
1984
+ const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
1985
+ const content = (await response.text()).trim() || `[]`;
1986
+ let parsed;
1987
+ try {
1988
+ parsed = JSON.parse(content);
1989
+ } catch (err) {
1990
+ const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
1991
+ throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
1992
+ }
1993
+ await subscriber({
1994
+ items: Array.isArray(parsed) ? parsed : [parsed],
1995
+ offset,
1996
+ cursor,
1997
+ upToDate,
1998
+ streamClosed
1999
+ });
2000
+ result = await reader.read();
2001
+ }
2002
+ this.#markClosed();
2003
+ } catch (e) {
2004
+ const isAborted = abortController.signal.aborted;
2005
+ const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
2006
+ if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
2007
+ else this.#markClosed();
2008
+ } finally {
2009
+ reader.releaseLock();
2010
+ }
2011
+ };
2012
+ consumeJsonSubscription();
2013
+ return () => {
2014
+ abortController.abort();
2015
+ this.cancel();
2016
+ };
2017
+ }
2018
+ subscribeBytes(subscriber) {
2019
+ this.#ensureNoConsumption(`subscribeBytes`);
2020
+ const abortController = new AbortController();
2021
+ const reader = this.#getResponseReader();
2022
+ const consumeBytesSubscription = async () => {
2023
+ try {
2024
+ let result = await reader.read();
2025
+ while (!result.done) {
2026
+ if (abortController.signal.aborted) break;
2027
+ const response = result.value;
2028
+ const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
2029
+ const buffer = await response.arrayBuffer();
2030
+ await subscriber({
2031
+ data: new Uint8Array(buffer),
2032
+ offset,
2033
+ cursor,
2034
+ upToDate,
2035
+ streamClosed
2036
+ });
2037
+ result = await reader.read();
2038
+ }
2039
+ this.#markClosed();
2040
+ } catch (e) {
2041
+ const isAborted = abortController.signal.aborted;
2042
+ const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
2043
+ if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
2044
+ else this.#markClosed();
2045
+ } finally {
2046
+ reader.releaseLock();
2047
+ }
2048
+ };
2049
+ consumeBytesSubscription();
2050
+ return () => {
2051
+ abortController.abort();
2052
+ this.cancel();
2053
+ };
2054
+ }
2055
+ subscribeText(subscriber) {
2056
+ this.#ensureNoConsumption(`subscribeText`);
2057
+ const abortController = new AbortController();
2058
+ const reader = this.#getResponseReader();
2059
+ const consumeTextSubscription = async () => {
2060
+ try {
2061
+ let result = await reader.read();
2062
+ while (!result.done) {
2063
+ if (abortController.signal.aborted) break;
2064
+ const response = result.value;
2065
+ const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
2066
+ await subscriber({
2067
+ text: await response.text(),
2068
+ offset,
2069
+ cursor,
2070
+ upToDate,
2071
+ streamClosed
2072
+ });
2073
+ result = await reader.read();
2074
+ }
2075
+ this.#markClosed();
2076
+ } catch (e) {
2077
+ const isAborted = abortController.signal.aborted;
2078
+ const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
2079
+ if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
2080
+ else this.#markClosed();
2081
+ } finally {
2082
+ reader.releaseLock();
2083
+ }
2084
+ };
2085
+ consumeTextSubscription();
2086
+ return () => {
2087
+ abortController.abort();
2088
+ this.cancel();
2089
+ };
2090
+ }
2091
+ cancel(reason) {
2092
+ this.#abortController.abort(reason);
2093
+ this.#unsubscribeFromVisibilityChanges?.();
2094
+ this.#markClosed();
2095
+ }
2096
+ get closed() {
2097
+ return this.#closed;
2098
+ }
2099
+ };
2100
+ /**
2101
+ * Resolve headers from HeadersRecord (supports async functions).
2102
+ * Unified implementation used by both stream() and DurableStream.
2103
+ */
2104
+ async function resolveHeaders(headers) {
2105
+ const resolved = {};
2106
+ if (!headers) return resolved;
2107
+ for (const [key, value] of Object.entries(headers)) if (typeof value === `function`) resolved[key] = await value();
2108
+ else resolved[key] = value;
2109
+ return resolved;
2110
+ }
2111
+ /**
2112
+ * Handle error responses from the server.
2113
+ * Throws appropriate DurableStreamError based on status code.
2114
+ */
2115
+ async function handleErrorResponse(response, url, context) {
2116
+ const status = response.status;
2117
+ if (status === 404) throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404);
2118
+ if (status === 409) {
2119
+ if (response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`) throw new StreamClosedError(url, response.headers.get(STREAM_OFFSET_HEADER) ?? void 0);
2120
+ throw new DurableStreamError(context?.operation === `create` ? `Stream already exists: ${url}` : `Sequence conflict: seq is lower than last appended`, context?.operation === `create` ? `CONFLICT_EXISTS` : `CONFLICT_SEQ`, 409);
2121
+ }
2122
+ if (status === 400) throw new DurableStreamError(`Bad request (possibly content-type mismatch)`, `BAD_REQUEST`, 400);
2123
+ throw await DurableStreamError.fromResponse(response, url);
2124
+ }
2125
+ /**
2126
+ * Resolve params from ParamsRecord (supports async functions).
2127
+ */
2128
+ async function resolveParams(params) {
2129
+ const resolved = {};
2130
+ if (!params) return resolved;
2131
+ for (const [key, value] of Object.entries(params)) if (value !== void 0) if (typeof value === `function`) resolved[key] = await value();
2132
+ else resolved[key] = value;
2133
+ return resolved;
2134
+ }
2135
+ const warnedOrigins = /* @__PURE__ */ new Set();
2136
+ /**
2137
+ * Safely read NODE_ENV without triggering "process is not defined" errors.
2138
+ * Works in both browser and Node.js environments.
2139
+ */
2140
+ function getNodeEnvSafely() {
2141
+ if (typeof process === `undefined`) return void 0;
2142
+ return process.env?.NODE_ENV;
2143
+ }
2144
+ /**
2145
+ * Check if we're in a browser environment.
2146
+ */
2147
+ function isBrowserEnvironment() {
2148
+ return typeof globalThis.window !== `undefined`;
2149
+ }
2150
+ /**
2151
+ * Get window.location.href safely, returning undefined if not available.
2152
+ */
2153
+ function getWindowLocationHref() {
2154
+ if (typeof globalThis.window !== `undefined` && typeof globalThis.window.location !== `undefined`) return globalThis.window.location.href;
2155
+ }
2156
+ /**
2157
+ * Resolve a URL string, handling relative URLs in browser environments.
2158
+ * Returns undefined if the URL cannot be parsed.
2159
+ */
2160
+ function resolveUrlMaybe(urlString) {
2161
+ try {
2162
+ return new URL(urlString);
2163
+ } catch {
2164
+ const base = getWindowLocationHref();
2165
+ if (base) try {
2166
+ return new URL(urlString, base);
2167
+ } catch {
2168
+ return;
2169
+ }
2170
+ return;
2171
+ }
2172
+ }
2173
+ /**
2174
+ * Warn if using HTTP (not HTTPS) URL in a browser environment.
2175
+ * HTTP typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1,
2176
+ * which can cause slow streams and app freezes with multiple active streams.
2177
+ *
2178
+ * Features:
2179
+ * - Warns only once per origin to prevent log spam
2180
+ * - Handles relative URLs by resolving against window.location.href
2181
+ * - Safe to call in Node.js environments (no-op)
2182
+ * - Skips warning during tests (NODE_ENV=test)
2183
+ */
2184
+ function warnIfUsingHttpInBrowser(url, warnOnHttp) {
2185
+ if (warnOnHttp === false) return;
2186
+ if (getNodeEnvSafely() === `test`) return;
2187
+ if (!isBrowserEnvironment() || typeof console === `undefined` || typeof console.warn !== `function`) return;
2188
+ const parsedUrl = resolveUrlMaybe(url instanceof URL ? url.toString() : url);
2189
+ if (!parsedUrl) return;
2190
+ if (parsedUrl.protocol === `http:`) {
2191
+ if (!warnedOrigins.has(parsedUrl.origin)) {
2192
+ warnedOrigins.add(parsedUrl.origin);
2193
+ console.warn("[DurableStream] Using HTTP (not HTTPS) typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1. This can cause slow streams and app freezes with multiple active streams. Use HTTPS for HTTP/2 support. See https://electric-sql.com/r/electric-http2 for more information.");
2194
+ }
2195
+ }
2196
+ }
2197
+ /**
2198
+ * Create a streaming session to read from a durable stream.
2199
+ *
2200
+ * This is a fetch-like API:
2201
+ * - The promise resolves after the first network request succeeds
2202
+ * - It rejects for auth/404/other protocol errors
2203
+ * - Returns a StreamResponse for consuming the data
2204
+ *
2205
+ * @example
2206
+ * ```typescript
2207
+ * // Catch-up JSON:
2208
+ * const res = await stream<{ message: string }>({
2209
+ * url,
2210
+ * auth,
2211
+ * offset: "0",
2212
+ * live: false,
2213
+ * })
2214
+ * const items = await res.json()
2215
+ *
2216
+ * // Live JSON:
2217
+ * const live = await stream<{ message: string }>({
2218
+ * url,
2219
+ * auth,
2220
+ * offset: savedOffset,
2221
+ * live: true,
2222
+ * })
2223
+ * live.subscribeJson(async (batch) => {
2224
+ * for (const item of batch.items) {
2225
+ * handle(item)
2226
+ * }
2227
+ * })
2228
+ * ```
2229
+ */
2230
+ async function stream(options) {
2231
+ if (!options.url) throw new DurableStreamError(`Invalid stream options: missing required url parameter`, `BAD_REQUEST`);
2232
+ let currentHeaders = options.headers;
2233
+ let currentParams = options.params;
2234
+ while (true) try {
2235
+ return await streamInternal({
2236
+ ...options,
2237
+ headers: currentHeaders,
2238
+ params: currentParams
2239
+ });
2240
+ } catch (err) {
2241
+ if (options.onError) {
2242
+ const retryOpts = await options.onError(err instanceof Error ? err : new Error(String(err)));
2243
+ if (retryOpts === void 0) throw err;
2244
+ if (retryOpts.params) currentParams = {
2245
+ ...currentParams,
2246
+ ...retryOpts.params
2247
+ };
2248
+ if (retryOpts.headers) currentHeaders = {
2249
+ ...currentHeaders,
2250
+ ...retryOpts.headers
2251
+ };
2252
+ continue;
2253
+ }
2254
+ throw err;
2255
+ }
2256
+ }
2257
+ /**
2258
+ * Internal implementation of stream that doesn't handle onError retries.
2259
+ */
2260
+ async function streamInternal(options) {
2261
+ const url = options.url instanceof URL ? options.url.toString() : options.url;
2262
+ warnIfUsingHttpInBrowser(url, options.warnOnHttp);
2263
+ const fetchUrl = new URL(url);
2264
+ const startOffset = options.offset ?? `-1`;
2265
+ fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, startOffset);
2266
+ const live = options.live ?? true;
2267
+ if (live === `long-poll` || live === `sse`) fetchUrl.searchParams.set(LIVE_QUERY_PARAM, live);
2268
+ const params = await resolveParams(options.params);
2269
+ for (const [key, value] of Object.entries(params)) fetchUrl.searchParams.set(key, value);
2270
+ const headers = await resolveHeaders(options.headers);
2271
+ const abortController = new AbortController();
2272
+ if (options.signal) options.signal.addEventListener(`abort`, () => abortController.abort(options.signal?.reason), { once: true });
2273
+ const fetchClient = createFetchWithBackoff(options.fetch ?? ((...args) => fetch(...args)), options.backoffOptions ?? BackoffDefaults);
2274
+ let firstResponse;
2275
+ try {
2276
+ firstResponse = await fetchClient(fetchUrl.toString(), {
2277
+ method: `GET`,
2278
+ headers,
2279
+ signal: abortController.signal
2280
+ });
2281
+ } catch (err) {
2282
+ if (err instanceof FetchBackoffAbortError) throw new DurableStreamError(`Stream request was aborted`, `UNKNOWN`);
2283
+ throw err;
2284
+ }
2285
+ const contentType = firstResponse.headers.get(`content-type`) ?? void 0;
2286
+ const initialOffset = firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset;
2287
+ const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
2288
+ const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
2289
+ const initialStreamClosed = firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
2290
+ const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
2291
+ const encoding = firstResponse.headers.get(STREAM_SSE_DATA_ENCODING_HEADER) === `base64` ? `base64` : void 0;
2292
+ const fetchNext = async (offset, cursor, signal, resumingFromPause) => {
2293
+ const nextUrl = new URL(url);
2294
+ nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
2295
+ if (!resumingFromPause) {
2296
+ if (live === `sse`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
2297
+ else if (live === true || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
2298
+ }
2299
+ if (cursor) nextUrl.searchParams.set(`cursor`, cursor);
2300
+ const nextParams = await resolveParams(options.params);
2301
+ for (const [key, value] of Object.entries(nextParams)) nextUrl.searchParams.set(key, value);
2302
+ const nextHeaders = await resolveHeaders(options.headers);
2303
+ const response = await fetchClient(nextUrl.toString(), {
2304
+ method: `GET`,
2305
+ headers: nextHeaders,
2306
+ signal
2307
+ });
2308
+ if (!response.ok) await handleErrorResponse(response, url);
2309
+ return response;
2310
+ };
2311
+ return new StreamResponseImpl({
2312
+ url,
2313
+ contentType,
2314
+ live,
2315
+ startOffset,
2316
+ isJsonMode,
2317
+ initialOffset,
2318
+ initialCursor,
2319
+ initialUpToDate,
2320
+ initialStreamClosed,
2321
+ firstResponse,
2322
+ abortController,
2323
+ fetchNext,
2324
+ startSSE: live === `sse` ? async (offset, cursor, signal) => {
2325
+ const sseUrl = new URL(url);
2326
+ sseUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
2327
+ sseUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
2328
+ if (cursor) sseUrl.searchParams.set(`cursor`, cursor);
2329
+ const sseParams = await resolveParams(options.params);
2330
+ for (const [key, value] of Object.entries(sseParams)) sseUrl.searchParams.set(key, value);
2331
+ const sseHeaders = await resolveHeaders(options.headers);
2332
+ const response = await fetchClient(sseUrl.toString(), {
2333
+ method: `GET`,
2334
+ headers: sseHeaders,
2335
+ signal
2336
+ });
2337
+ if (!response.ok) await handleErrorResponse(response, url);
2338
+ return response;
2339
+ } : void 0,
2340
+ sseResilience: options.sseResilience,
2341
+ encoding
2342
+ });
2343
+ }
2344
+ /**
2345
+ * Error thrown when a producer's epoch is stale (zombie fencing).
2346
+ */
2347
+ var StaleEpochError = class extends Error {
2348
+ /**
2349
+ * The current epoch on the server.
2350
+ */
2351
+ currentEpoch;
2352
+ constructor(currentEpoch) {
2353
+ super(`Producer epoch is stale. Current server epoch: ${currentEpoch}. Call restart() or create a new producer with a higher epoch.`);
2354
+ this.name = `StaleEpochError`;
2355
+ this.currentEpoch = currentEpoch;
2356
+ }
2357
+ };
2358
+ /**
2359
+ * Error thrown when an unrecoverable sequence gap is detected.
2360
+ *
2361
+ * With maxInFlight > 1, HTTP requests can arrive out of order at the server,
2362
+ * causing temporary 409 responses. The client automatically handles these
2363
+ * by waiting for earlier sequences to complete, then retrying.
2364
+ *
2365
+ * This error is only thrown when the gap cannot be resolved (e.g., the
2366
+ * expected sequence is >= our sequence, indicating a true protocol violation).
2367
+ */
2368
+ var SequenceGapError = class extends Error {
2369
+ expectedSeq;
2370
+ receivedSeq;
2371
+ constructor(expectedSeq, receivedSeq) {
2372
+ super(`Producer sequence gap: expected ${expectedSeq}, received ${receivedSeq}`);
2373
+ this.name = `SequenceGapError`;
2374
+ this.expectedSeq = expectedSeq;
2375
+ this.receivedSeq = receivedSeq;
2376
+ }
2377
+ };
2378
+ /**
2379
+ * Normalize content-type by extracting the media type (before any semicolon).
2380
+ */
2381
+ function normalizeContentType$1(contentType) {
2382
+ if (!contentType) return ``;
2383
+ return contentType.split(`;`)[0].trim().toLowerCase();
2384
+ }
2385
+ /**
2386
+ * An idempotent producer for exactly-once writes to a durable stream.
2387
+ *
2388
+ * Features:
2389
+ * - Fire-and-forget: append() returns immediately, batches in background
2390
+ * - Exactly-once: server deduplicates using (producerId, epoch, seq)
2391
+ * - Batching: multiple appends batched into single HTTP request
2392
+ * - Pipelining: up to maxInFlight concurrent batches
2393
+ * - Zombie fencing: stale producers rejected via epoch validation
2394
+ *
2395
+ * @example
2396
+ * ```typescript
2397
+ * const stream = new DurableStream({ url: "https://..." });
2398
+ * const producer = new IdempotentProducer(stream, "order-service-1", {
2399
+ * epoch: 0,
2400
+ * autoClaim: true,
2401
+ * });
2402
+ *
2403
+ * // Fire-and-forget writes (synchronous, returns immediately)
2404
+ * producer.append("message 1");
2405
+ * producer.append("message 2");
2406
+ *
2407
+ * // Ensure all messages are delivered before shutdown
2408
+ * await producer.flush();
2409
+ * await producer.close();
2410
+ * ```
2411
+ */
2412
+ var IdempotentProducer = class {
2413
+ #stream;
2414
+ #producerId;
2415
+ #epoch;
2416
+ #nextSeq = 0;
2417
+ #autoClaim;
2418
+ #maxBatchBytes;
2419
+ #lingerMs;
2420
+ #fetchClient;
2421
+ #signal;
2422
+ #onError;
2423
+ #pendingBatch = [];
2424
+ #batchBytes = 0;
2425
+ #lingerTimeout = null;
2426
+ #queue;
2427
+ #maxInFlight;
2428
+ #closed = false;
2429
+ #closeResult = null;
2430
+ #pendingFinalMessage;
2431
+ #epochClaimed;
2432
+ #seqState = /* @__PURE__ */ new Map();
2433
+ /**
2434
+ * Create an idempotent producer for a stream.
2435
+ *
2436
+ * @param stream - The DurableStream to write to
2437
+ * @param producerId - Stable identifier for this producer (e.g., "order-service-1")
2438
+ * @param opts - Producer options
2439
+ */
2440
+ constructor(stream$1, producerId, opts) {
2441
+ const epoch = opts?.epoch ?? 0;
2442
+ const maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024;
2443
+ const maxInFlight = opts?.maxInFlight ?? 5;
2444
+ const lingerMs = opts?.lingerMs ?? 5;
2445
+ if (epoch < 0) throw new Error(`epoch must be >= 0`);
2446
+ if (maxBatchBytes <= 0) throw new Error(`maxBatchBytes must be > 0`);
2447
+ if (maxInFlight <= 0) throw new Error(`maxInFlight must be > 0`);
2448
+ if (lingerMs < 0) throw new Error(`lingerMs must be >= 0`);
2449
+ this.#stream = stream$1;
2450
+ this.#producerId = producerId;
2451
+ this.#epoch = epoch;
2452
+ this.#autoClaim = opts?.autoClaim ?? false;
2453
+ this.#maxBatchBytes = maxBatchBytes;
2454
+ this.#lingerMs = lingerMs;
2455
+ this.#signal = opts?.signal;
2456
+ this.#onError = opts?.onError;
2457
+ this.#fetchClient = opts?.fetch ?? ((...args) => fetch(...args));
2458
+ this.#maxInFlight = maxInFlight;
2459
+ this.#epochClaimed = !this.#autoClaim;
2460
+ this.#queue = import_queue.default.promise(this.#batchWorker.bind(this), this.#maxInFlight);
2461
+ if (this.#signal) this.#signal.addEventListener(`abort`, () => {
2462
+ this.#rejectPendingBatch(new DurableStreamError(`Producer aborted`, `ALREADY_CLOSED`, void 0, void 0));
2463
+ }, { once: true });
2464
+ }
2465
+ /**
2466
+ * Append data to the stream.
2467
+ *
2468
+ * This is fire-and-forget: returns immediately after adding to the batch.
2469
+ * The message is batched and sent when:
2470
+ * - maxBatchBytes is reached
2471
+ * - lingerMs elapses
2472
+ * - flush() is called
2473
+ *
2474
+ * Errors are reported via onError callback if configured. Use flush() to
2475
+ * wait for all pending messages to be sent.
2476
+ *
2477
+ * For JSON streams, pass pre-serialized JSON strings.
2478
+ * For byte streams, pass string or Uint8Array.
2479
+ *
2480
+ * @param body - Data to append (string or Uint8Array)
2481
+ *
2482
+ * @example
2483
+ * ```typescript
2484
+ * // JSON stream
2485
+ * producer.append(JSON.stringify({ message: "hello" }));
2486
+ *
2487
+ * // Byte stream
2488
+ * producer.append("raw text data");
2489
+ * producer.append(new Uint8Array([1, 2, 3]));
2490
+ * ```
2491
+ */
2492
+ append(body) {
2493
+ if (this.#closed) throw new DurableStreamError(`Producer is closed`, `ALREADY_CLOSED`, void 0, void 0);
2494
+ let bytes;
2495
+ if (typeof body === `string`) bytes = new TextEncoder().encode(body);
2496
+ else if (body instanceof Uint8Array) bytes = body;
2497
+ else throw new DurableStreamError(`append() requires string or Uint8Array. For objects, use JSON.stringify().`, `BAD_REQUEST`, 400, void 0);
2498
+ this.#pendingBatch.push({ body: bytes });
2499
+ this.#batchBytes += bytes.length;
2500
+ if (this.#batchBytes >= this.#maxBatchBytes) this.#enqueuePendingBatch();
2501
+ else if (!this.#lingerTimeout) this.#lingerTimeout = setTimeout(() => {
2502
+ this.#lingerTimeout = null;
2503
+ if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
2504
+ }, this.#lingerMs);
2505
+ }
2506
+ /**
2507
+ * Send any pending batch immediately and wait for all in-flight batches.
2508
+ *
2509
+ * Call this before shutdown to ensure all messages are delivered.
2510
+ */
2511
+ async flush() {
2512
+ if (this.#lingerTimeout) {
2513
+ clearTimeout(this.#lingerTimeout);
2514
+ this.#lingerTimeout = null;
2515
+ }
2516
+ if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
2517
+ await this.#queue.drained();
2518
+ }
2519
+ /**
2520
+ * Stop the producer without closing the underlying stream.
2521
+ *
2522
+ * Use this when you want to:
2523
+ * - Hand off writing to another producer
2524
+ * - Keep the stream open for future writes
2525
+ * - Stop this producer but not signal EOF to readers
2526
+ *
2527
+ * Flushes any pending messages before detaching.
2528
+ * After calling detach(), further append() calls will throw.
2529
+ */
2530
+ async detach() {
2531
+ if (this.#closed) return;
2532
+ this.#closed = true;
2533
+ try {
2534
+ await this.flush();
2535
+ } catch {}
2536
+ }
2537
+ /**
2538
+ * Flush pending messages and close the underlying stream (EOF).
2539
+ *
2540
+ * This is the typical way to end a producer session. It:
2541
+ * 1. Flushes all pending messages
2542
+ * 2. Optionally appends a final message
2543
+ * 3. Closes the stream (no further appends permitted)
2544
+ *
2545
+ * **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
2546
+ * idempotent even with a final message because it uses producer headers
2547
+ * for deduplication. Safe to retry on network failures.
2548
+ *
2549
+ * @param finalMessage - Optional final message to append atomically with close
2550
+ * @returns CloseResult with the final offset
2551
+ */
2552
+ async close(finalMessage) {
2553
+ if (this.#closed) {
2554
+ if (this.#closeResult) return this.#closeResult;
2555
+ await this.flush();
2556
+ const result$1 = await this.#doClose(this.#pendingFinalMessage);
2557
+ this.#closeResult = result$1;
2558
+ return result$1;
2559
+ }
2560
+ this.#closed = true;
2561
+ this.#pendingFinalMessage = finalMessage;
2562
+ await this.flush();
2563
+ const result = await this.#doClose(finalMessage);
2564
+ this.#closeResult = result;
2565
+ return result;
2566
+ }
2567
+ /**
2568
+ * Actually close the stream with optional final message.
2569
+ * Uses producer headers for idempotency.
2570
+ */
2571
+ async #doClose(finalMessage) {
2572
+ const contentType = this.#stream.contentType ?? `application/octet-stream`;
2573
+ const isJson = normalizeContentType$1(contentType) === `application/json`;
2574
+ let body;
2575
+ if (finalMessage !== void 0) {
2576
+ const bodyBytes = typeof finalMessage === `string` ? new TextEncoder().encode(finalMessage) : finalMessage;
2577
+ if (isJson) body = `[${new TextDecoder().decode(bodyBytes)}]`;
2578
+ else body = bodyBytes;
2579
+ }
2580
+ const seqForThisRequest = this.#nextSeq;
2581
+ const headers = {
2582
+ "content-type": contentType,
2583
+ [PRODUCER_ID_HEADER]: this.#producerId,
2584
+ [PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
2585
+ [PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
2586
+ [STREAM_CLOSED_HEADER]: `true`
2587
+ };
2588
+ const response = await this.#fetchClient(this.#stream.url, {
2589
+ method: `POST`,
2590
+ headers,
2591
+ body,
2592
+ signal: this.#signal
2593
+ });
2594
+ if (response.status === 204) {
2595
+ this.#nextSeq = seqForThisRequest + 1;
2596
+ return { finalOffset: response.headers.get(STREAM_OFFSET_HEADER) ?? `` };
2597
+ }
2598
+ if (response.status === 200) {
2599
+ this.#nextSeq = seqForThisRequest + 1;
2600
+ return { finalOffset: response.headers.get(STREAM_OFFSET_HEADER) ?? `` };
2601
+ }
2602
+ if (response.status === 403) {
2603
+ const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
2604
+ const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : this.#epoch;
2605
+ if (this.#autoClaim) {
2606
+ const newEpoch = currentEpoch + 1;
2607
+ this.#epoch = newEpoch;
2608
+ this.#nextSeq = 0;
2609
+ return this.#doClose(finalMessage);
2610
+ }
2611
+ throw new StaleEpochError(currentEpoch);
2612
+ }
2613
+ throw await FetchError.fromResponse(response, this.#stream.url);
2614
+ }
2615
+ /**
2616
+ * Increment epoch and reset sequence.
2617
+ *
2618
+ * Call this when restarting the producer to establish a new session.
2619
+ * Flushes any pending messages first.
2620
+ */
2621
+ async restart() {
2622
+ await this.flush();
2623
+ this.#epoch++;
2624
+ this.#nextSeq = 0;
2625
+ }
2626
+ /**
2627
+ * Current epoch for this producer.
2628
+ */
2629
+ get epoch() {
2630
+ return this.#epoch;
2631
+ }
2632
+ /**
2633
+ * Next sequence number to be assigned.
2634
+ */
2635
+ get nextSeq() {
2636
+ return this.#nextSeq;
2637
+ }
2638
+ /**
2639
+ * Number of messages in the current pending batch.
2640
+ */
2641
+ get pendingCount() {
2642
+ return this.#pendingBatch.length;
2643
+ }
2644
+ /**
2645
+ * Number of batches currently in flight.
2646
+ */
2647
+ get inFlightCount() {
2648
+ return this.#queue.length();
2649
+ }
2650
+ /**
2651
+ * Enqueue the current pending batch for processing.
2652
+ */
2653
+ #enqueuePendingBatch() {
2654
+ if (this.#pendingBatch.length === 0) return;
2655
+ const batch = this.#pendingBatch;
2656
+ const seq = this.#nextSeq;
2657
+ this.#pendingBatch = [];
2658
+ this.#batchBytes = 0;
2659
+ this.#nextSeq++;
2660
+ if (this.#autoClaim && !this.#epochClaimed && this.#queue.length() > 0) this.#queue.drained().then(() => {
2661
+ this.#queue.push({
2662
+ batch,
2663
+ seq
2664
+ }).catch(() => {});
2665
+ });
2666
+ else this.#queue.push({
2667
+ batch,
2668
+ seq
2669
+ }).catch(() => {});
2670
+ }
2671
+ /**
2672
+ * Batch worker - processes batches via fastq.
2673
+ */
2674
+ async #batchWorker(task) {
2675
+ const { batch, seq } = task;
2676
+ const epoch = this.#epoch;
2677
+ try {
2678
+ await this.#doSendBatch(batch, seq, epoch);
2679
+ if (!this.#epochClaimed) this.#epochClaimed = true;
2680
+ this.#signalSeqComplete(epoch, seq, void 0);
2681
+ } catch (error) {
2682
+ this.#signalSeqComplete(epoch, seq, error);
2683
+ if (this.#onError) this.#onError(error);
2684
+ throw error;
2685
+ }
2686
+ }
2687
+ /**
2688
+ * Signal that a sequence has completed (success or failure).
2689
+ */
2690
+ #signalSeqComplete(epoch, seq, error) {
2691
+ let epochMap = this.#seqState.get(epoch);
2692
+ if (!epochMap) {
2693
+ epochMap = /* @__PURE__ */ new Map();
2694
+ this.#seqState.set(epoch, epochMap);
2695
+ }
2696
+ const state = epochMap.get(seq);
2697
+ if (state) {
2698
+ state.resolved = true;
2699
+ state.error = error;
2700
+ for (const waiter of state.waiters) waiter(error);
2701
+ state.waiters = [];
2702
+ } else epochMap.set(seq, {
2703
+ resolved: true,
2704
+ error,
2705
+ waiters: []
2706
+ });
2707
+ const cleanupThreshold = seq - this.#maxInFlight * 3;
2708
+ if (cleanupThreshold > 0) {
2709
+ for (const oldSeq of epochMap.keys()) if (oldSeq < cleanupThreshold) epochMap.delete(oldSeq);
2710
+ }
2711
+ }
2712
+ /**
2713
+ * Wait for a specific sequence to complete.
2714
+ * Returns immediately if already completed.
2715
+ * Throws if the sequence failed.
2716
+ */
2717
+ #waitForSeq(epoch, seq) {
2718
+ let epochMap = this.#seqState.get(epoch);
2719
+ if (!epochMap) {
2720
+ epochMap = /* @__PURE__ */ new Map();
2721
+ this.#seqState.set(epoch, epochMap);
2722
+ }
2723
+ const state = epochMap.get(seq);
2724
+ if (state?.resolved) {
2725
+ if (state.error) return Promise.reject(state.error);
2726
+ return Promise.resolve();
2727
+ }
2728
+ return new Promise((resolve, reject) => {
2729
+ const waiter = (err) => {
2730
+ if (err) reject(err);
2731
+ else resolve();
2732
+ };
2733
+ if (state) state.waiters.push(waiter);
2734
+ else epochMap.set(seq, {
2735
+ resolved: false,
2736
+ waiters: [waiter]
2737
+ });
2738
+ });
2739
+ }
2740
+ /**
2741
+ * Actually send the batch to the server.
2742
+ * Handles auto-claim retry on 403 (stale epoch) if autoClaim is enabled.
2743
+ * Does NOT implement general retry/backoff for network errors or 5xx responses.
2744
+ */
2745
+ async #doSendBatch(batch, seq, epoch) {
2746
+ const contentType = this.#stream.contentType ?? `application/octet-stream`;
2747
+ const isJson = normalizeContentType$1(contentType) === `application/json`;
2748
+ let batchedBody;
2749
+ if (isJson) batchedBody = `[${batch.map((e) => new TextDecoder().decode(e.body)).join(`,`)}]`;
2750
+ else {
2751
+ const totalSize = batch.reduce((sum, e) => sum + e.body.length, 0);
2752
+ const concatenated = new Uint8Array(totalSize);
2753
+ let offset = 0;
2754
+ for (const entry of batch) {
2755
+ concatenated.set(entry.body, offset);
2756
+ offset += entry.body.length;
2757
+ }
2758
+ batchedBody = concatenated;
2759
+ }
2760
+ const url = this.#stream.url;
2761
+ const headers = {
2762
+ "content-type": contentType,
2763
+ [PRODUCER_ID_HEADER]: this.#producerId,
2764
+ [PRODUCER_EPOCH_HEADER]: epoch.toString(),
2765
+ [PRODUCER_SEQ_HEADER]: seq.toString()
2766
+ };
2767
+ const response = await this.#fetchClient(url, {
2768
+ method: `POST`,
2769
+ headers,
2770
+ body: batchedBody,
2771
+ signal: this.#signal
2772
+ });
2773
+ if (response.status === 204) return {
2774
+ offset: ``,
2775
+ duplicate: true
2776
+ };
2777
+ if (response.status === 200) return {
2778
+ offset: response.headers.get(STREAM_OFFSET_HEADER) ?? ``,
2779
+ duplicate: false
2780
+ };
2781
+ if (response.status === 403) {
2782
+ const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
2783
+ const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : epoch;
2784
+ if (this.#autoClaim) {
2785
+ const newEpoch = currentEpoch + 1;
2786
+ this.#epoch = newEpoch;
2787
+ this.#nextSeq = 1;
2788
+ return this.#doSendBatch(batch, 0, newEpoch);
2789
+ }
2790
+ throw new StaleEpochError(currentEpoch);
2791
+ }
2792
+ if (response.status === 409) {
2793
+ const expectedSeqStr = response.headers.get(PRODUCER_EXPECTED_SEQ_HEADER);
2794
+ const expectedSeq = expectedSeqStr ? parseInt(expectedSeqStr, 10) : 0;
2795
+ if (expectedSeq < seq) {
2796
+ const waitPromises = [];
2797
+ for (let s = expectedSeq; s < seq; s++) waitPromises.push(this.#waitForSeq(epoch, s));
2798
+ await Promise.all(waitPromises);
2799
+ return this.#doSendBatch(batch, seq, epoch);
2800
+ }
2801
+ const receivedSeqStr = response.headers.get(PRODUCER_RECEIVED_SEQ_HEADER);
2802
+ throw new SequenceGapError(expectedSeq, receivedSeqStr ? parseInt(receivedSeqStr, 10) : seq);
2803
+ }
2804
+ if (response.status === 400) throw await DurableStreamError.fromResponse(response, url);
2805
+ throw await FetchError.fromResponse(response, url);
2806
+ }
2807
+ /**
2808
+ * Clear pending batch and report error.
2809
+ */
2810
+ #rejectPendingBatch(error) {
2811
+ if (this.#onError && this.#pendingBatch.length > 0) this.#onError(error);
2812
+ this.#pendingBatch = [];
2813
+ this.#batchBytes = 0;
2814
+ if (this.#lingerTimeout) {
2815
+ clearTimeout(this.#lingerTimeout);
2816
+ this.#lingerTimeout = null;
2817
+ }
2818
+ }
2819
+ };
2820
+ /**
2821
+ * Normalize content-type by extracting the media type (before any semicolon).
2822
+ * Handles cases like "application/json; charset=utf-8".
2823
+ */
2824
+ function normalizeContentType(contentType) {
2825
+ if (!contentType) return ``;
2826
+ return contentType.split(`;`)[0].trim().toLowerCase();
2827
+ }
2828
+ /**
2829
+ * Check if a value is a Promise or Promise-like (thenable).
2830
+ */
2831
+ function isPromiseLike(value) {
2832
+ return value != null && typeof value.then === `function`;
2833
+ }
2834
+ /**
2835
+ * A handle to a remote durable stream for read/write operations.
2836
+ *
2837
+ * This is a lightweight, reusable handle - not a persistent connection.
2838
+ * It does not automatically start reading or listening.
2839
+ * Create sessions as needed via stream().
2840
+ *
2841
+ * @example
2842
+ * ```typescript
2843
+ * // Create a new stream
2844
+ * const stream = await DurableStream.create({
2845
+ * url: "https://streams.example.com/my-stream",
2846
+ * headers: { Authorization: "Bearer my-token" },
2847
+ * contentType: "application/json"
2848
+ * });
2849
+ *
2850
+ * // Write data
2851
+ * await stream.append(JSON.stringify({ message: "hello" }));
2852
+ *
2853
+ * // Read with the new API
2854
+ * const res = await stream.stream<{ message: string }>();
2855
+ * res.subscribeJson(async (batch) => {
2856
+ * for (const item of batch.items) {
2857
+ * console.log(item.message);
2858
+ * }
2859
+ * });
2860
+ * ```
2861
+ */
2862
+ var DurableStream = class DurableStream {
2863
+ /**
2864
+ * The URL of the durable stream.
2865
+ */
2866
+ url;
2867
+ /**
2868
+ * The content type of the stream (populated after connect/head/read).
2869
+ */
2870
+ contentType;
2871
+ #options;
2872
+ #fetchClient;
2873
+ #onError;
2874
+ #batchingEnabled;
2875
+ #queue;
2876
+ #buffer = [];
2877
+ /**
2878
+ * Create a cold handle to a stream.
2879
+ * No network IO is performed by the constructor.
2880
+ */
2881
+ constructor(opts) {
2882
+ validateOptions(opts);
2883
+ const urlStr = opts.url instanceof URL ? opts.url.toString() : opts.url;
2884
+ this.url = urlStr;
2885
+ this.#options = {
2886
+ ...opts,
2887
+ url: urlStr
2888
+ };
2889
+ this.#onError = opts.onError;
2890
+ if (opts.contentType) this.contentType = opts.contentType;
2891
+ this.#batchingEnabled = opts.batching !== false;
2892
+ if (this.#batchingEnabled) this.#queue = import_queue.default.promise(this.#batchWorker.bind(this), 1);
2893
+ const fetchWithBackoffClient = createFetchWithBackoff(opts.fetch ?? ((...args) => fetch(...args)), { ...opts.backoffOptions ?? BackoffDefaults });
2894
+ this.#fetchClient = createFetchWithConsumedBody(fetchWithBackoffClient);
2895
+ }
2896
+ /**
2897
+ * Create a new stream (create-only PUT) and return a handle.
2898
+ * Fails with DurableStreamError(code="CONFLICT_EXISTS") if it already exists.
2899
+ */
2900
+ static async create(opts) {
2901
+ const stream$1 = new DurableStream(opts);
2902
+ await stream$1.create({
2903
+ contentType: opts.contentType,
2904
+ ttlSeconds: opts.ttlSeconds,
2905
+ expiresAt: opts.expiresAt,
2906
+ body: opts.body,
2907
+ closed: opts.closed
2908
+ });
2909
+ return stream$1;
2910
+ }
2911
+ /**
2912
+ * Validate that a stream exists and fetch metadata via HEAD.
2913
+ * Returns a handle with contentType populated (if sent by server).
2914
+ *
2915
+ * **Important**: This only performs a HEAD request for validation - it does
2916
+ * NOT open a session or start reading data. To read from the stream, call
2917
+ * `stream()` on the returned handle.
2918
+ *
2919
+ * @example
2920
+ * ```typescript
2921
+ * // Validate stream exists before reading
2922
+ * const handle = await DurableStream.connect({ url })
2923
+ * const res = await handle.stream() // Now actually read
2924
+ * ```
2925
+ */
2926
+ static async connect(opts) {
2927
+ const stream$1 = new DurableStream(opts);
2928
+ await stream$1.head();
2929
+ return stream$1;
2930
+ }
2931
+ /**
2932
+ * HEAD metadata for a stream without creating a handle.
2933
+ */
2934
+ static async head(opts) {
2935
+ return new DurableStream(opts).head();
2936
+ }
2937
+ /**
2938
+ * Delete a stream without creating a handle.
2939
+ */
2940
+ static async delete(opts) {
2941
+ return new DurableStream(opts).delete();
2942
+ }
2943
+ /**
2944
+ * HEAD metadata for this stream.
2945
+ */
2946
+ async head(opts) {
2947
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2948
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2949
+ method: `HEAD`,
2950
+ headers: requestHeaders,
2951
+ signal: opts?.signal ?? this.#options.signal
2952
+ });
2953
+ if (!response.ok) await handleErrorResponse(response, this.url);
2954
+ const contentType = response.headers.get(`content-type`) ?? void 0;
2955
+ const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
2956
+ const etag = response.headers.get(`etag`) ?? void 0;
2957
+ const cacheControl = response.headers.get(`cache-control`) ?? void 0;
2958
+ const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
2959
+ if (contentType) this.contentType = contentType;
2960
+ return {
2961
+ exists: true,
2962
+ contentType,
2963
+ offset,
2964
+ etag,
2965
+ cacheControl,
2966
+ streamClosed
2967
+ };
2968
+ }
2969
+ /**
2970
+ * Create this stream (create-only PUT) using the URL/auth from the handle.
2971
+ */
2972
+ async create(opts) {
2973
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2974
+ const contentType = opts?.contentType ?? this.#options.contentType;
2975
+ if (contentType) requestHeaders[`content-type`] = contentType;
2976
+ if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
2977
+ if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
2978
+ if (opts?.closed) requestHeaders[STREAM_CLOSED_HEADER] = `true`;
2979
+ const body = encodeBody(opts?.body);
2980
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2981
+ method: `PUT`,
2982
+ headers: requestHeaders,
2983
+ body,
2984
+ signal: this.#options.signal
2985
+ });
2986
+ if (!response.ok) await handleErrorResponse(response, this.url, { operation: `create` });
2987
+ const responseContentType = response.headers.get(`content-type`);
2988
+ if (responseContentType) this.contentType = responseContentType;
2989
+ else if (contentType) this.contentType = contentType;
2990
+ return this;
2991
+ }
2992
+ /**
2993
+ * Delete this stream.
2994
+ */
2995
+ async delete(opts) {
2996
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
2997
+ const response = await this.#fetchClient(fetchUrl.toString(), {
2998
+ method: `DELETE`,
2999
+ headers: requestHeaders,
3000
+ signal: opts?.signal ?? this.#options.signal
3001
+ });
3002
+ if (!response.ok) await handleErrorResponse(response, this.url);
3003
+ }
3004
+ /**
3005
+ * Close the stream, optionally with a final message.
3006
+ *
3007
+ * After closing:
3008
+ * - No further appends are permitted (server returns 409)
3009
+ * - Readers can observe the closed state and treat it as EOF
3010
+ * - The stream's data remains fully readable
3011
+ *
3012
+ * Closing is:
3013
+ * - **Durable**: The closed state is persisted
3014
+ * - **Monotonic**: Once closed, a stream cannot be reopened
3015
+ *
3016
+ * **Idempotency:**
3017
+ * - `close()` without body: Idempotent — safe to call multiple times
3018
+ * - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
3019
+ * if stream is already closed (use `IdempotentProducer.close()` for
3020
+ * idempotent close-with-body semantics)
3021
+ *
3022
+ * @returns CloseResult with the final offset
3023
+ * @throws StreamClosedError if called with body on an already-closed stream
3024
+ */
3025
+ async close(opts) {
3026
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
3027
+ const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
3028
+ if (contentType) requestHeaders[`content-type`] = contentType;
3029
+ requestHeaders[STREAM_CLOSED_HEADER] = `true`;
3030
+ let body;
3031
+ if (opts?.body !== void 0) if (normalizeContentType(contentType) === `application/json`) body = `[${typeof opts.body === `string` ? opts.body : new TextDecoder().decode(opts.body)}]`;
3032
+ else body = typeof opts.body === `string` ? opts.body : opts.body;
3033
+ const response = await this.#fetchClient(fetchUrl.toString(), {
3034
+ method: `POST`,
3035
+ headers: requestHeaders,
3036
+ body,
3037
+ signal: opts?.signal ?? this.#options.signal
3038
+ });
3039
+ if (response.status === 409) {
3040
+ if (response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`) {
3041
+ const finalOffset$1 = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
3042
+ throw new StreamClosedError(this.url, finalOffset$1);
3043
+ }
3044
+ }
3045
+ if (!response.ok) await handleErrorResponse(response, this.url);
3046
+ return { finalOffset: response.headers.get(STREAM_OFFSET_HEADER) ?? `` };
3047
+ }
3048
+ /**
3049
+ * Append a single payload to the stream.
3050
+ *
3051
+ * When batching is enabled (default), multiple append() calls made while
3052
+ * a POST is in-flight will be batched together into a single request.
3053
+ * This significantly improves throughput for high-frequency writes.
3054
+ *
3055
+ * - `body` must be string or Uint8Array.
3056
+ * - For JSON streams, pass pre-serialized JSON strings.
3057
+ * - `body` may also be a Promise that resolves to string or Uint8Array.
3058
+ * - Strings are encoded as UTF-8.
3059
+ * - `seq` (if provided) is sent as stream-seq (writer coordination).
3060
+ *
3061
+ * @example
3062
+ * ```typescript
3063
+ * // JSON stream - pass pre-serialized JSON
3064
+ * await stream.append(JSON.stringify({ message: "hello" }));
3065
+ *
3066
+ * // Byte stream
3067
+ * await stream.append("raw text data");
3068
+ * await stream.append(new Uint8Array([1, 2, 3]));
3069
+ *
3070
+ * // Promise value - awaited before buffering
3071
+ * await stream.append(fetchData());
3072
+ * ```
3073
+ */
3074
+ async append(body, opts) {
3075
+ const resolvedBody = isPromiseLike(body) ? await body : body;
3076
+ if (this.#batchingEnabled && this.#queue) return this.#appendWithBatching(resolvedBody, opts);
3077
+ return this.#appendDirect(resolvedBody, opts);
3078
+ }
3079
+ /**
3080
+ * Direct append without batching (used when batching is disabled).
3081
+ */
3082
+ async #appendDirect(body, opts) {
3083
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
3084
+ const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
3085
+ if (contentType) requestHeaders[`content-type`] = contentType;
3086
+ if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
3087
+ const isJson = normalizeContentType(contentType) === `application/json`;
3088
+ let encodedBody;
3089
+ if (isJson) encodedBody = `[${typeof body === `string` ? body : new TextDecoder().decode(body)}]`;
3090
+ else if (typeof body === `string`) encodedBody = body;
3091
+ else encodedBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
3092
+ const response = await this.#fetchClient(fetchUrl.toString(), {
3093
+ method: `POST`,
3094
+ headers: requestHeaders,
3095
+ body: encodedBody,
3096
+ signal: opts?.signal ?? this.#options.signal
3097
+ });
3098
+ if (!response.ok) await handleErrorResponse(response, this.url);
3099
+ }
3100
+ /**
3101
+ * Append with batching - buffers messages and sends them in batches.
3102
+ */
3103
+ async #appendWithBatching(body, opts) {
3104
+ return new Promise((resolve, reject) => {
3105
+ this.#buffer.push({
3106
+ data: body,
3107
+ seq: opts?.seq,
3108
+ contentType: opts?.contentType,
3109
+ signal: opts?.signal,
3110
+ resolve,
3111
+ reject
3112
+ });
3113
+ if (this.#queue.idle()) {
3114
+ const batch = this.#buffer.splice(0);
3115
+ this.#queue.push(batch).catch((err) => {
3116
+ for (const msg of batch) msg.reject(err);
3117
+ });
3118
+ }
3119
+ });
3120
+ }
3121
+ /**
3122
+ * Batch worker - processes batches of messages.
3123
+ */
3124
+ async #batchWorker(batch) {
3125
+ try {
3126
+ await this.#sendBatch(batch);
3127
+ for (const msg of batch) msg.resolve();
3128
+ if (this.#buffer.length > 0) {
3129
+ const nextBatch = this.#buffer.splice(0);
3130
+ this.#queue.push(nextBatch).catch((err) => {
3131
+ for (const msg of nextBatch) msg.reject(err);
3132
+ });
3133
+ }
3134
+ } catch (error) {
3135
+ for (const msg of batch) msg.reject(error);
3136
+ for (const msg of this.#buffer) msg.reject(error);
3137
+ this.#buffer = [];
3138
+ throw error;
3139
+ }
3140
+ }
3141
+ /**
3142
+ * Send a batch of messages as a single POST request.
3143
+ */
3144
+ async #sendBatch(batch) {
3145
+ if (batch.length === 0) return;
3146
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
3147
+ const contentType = batch[0]?.contentType ?? this.#options.contentType ?? this.contentType;
3148
+ if (contentType) requestHeaders[`content-type`] = contentType;
3149
+ let highestSeq;
3150
+ for (let i = batch.length - 1; i >= 0; i--) if (batch[i].seq !== void 0) {
3151
+ highestSeq = batch[i].seq;
3152
+ break;
3153
+ }
3154
+ if (highestSeq) requestHeaders[STREAM_SEQ_HEADER] = highestSeq;
3155
+ const isJson = normalizeContentType(contentType) === `application/json`;
3156
+ let batchedBody;
3157
+ if (isJson) batchedBody = `[${batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data)).join(`,`)}]`;
3158
+ else {
3159
+ const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array);
3160
+ const hasString = batch.some((m) => typeof m.data === `string`);
3161
+ if (hasUint8Array && !hasString) {
3162
+ const chunks = batch.map((m) => m.data);
3163
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
3164
+ const combined = new Uint8Array(totalLength);
3165
+ let offset = 0;
3166
+ for (const chunk of chunks) {
3167
+ combined.set(chunk, offset);
3168
+ offset += chunk.length;
3169
+ }
3170
+ batchedBody = combined;
3171
+ } else if (hasString && !hasUint8Array) batchedBody = batch.map((m) => m.data).join(``);
3172
+ else {
3173
+ const encoder = new TextEncoder();
3174
+ const chunks = batch.map((m) => typeof m.data === `string` ? encoder.encode(m.data) : m.data);
3175
+ const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
3176
+ const combined = new Uint8Array(totalLength);
3177
+ let offset = 0;
3178
+ for (const chunk of chunks) {
3179
+ combined.set(chunk, offset);
3180
+ offset += chunk.length;
3181
+ }
3182
+ batchedBody = combined;
3183
+ }
3184
+ }
3185
+ const signals = [];
3186
+ if (this.#options.signal) signals.push(this.#options.signal);
3187
+ for (const msg of batch) if (msg.signal) signals.push(msg.signal);
3188
+ const combinedSignal = signals.length > 0 ? AbortSignal.any(signals) : void 0;
3189
+ const response = await this.#fetchClient(fetchUrl.toString(), {
3190
+ method: `POST`,
3191
+ headers: requestHeaders,
3192
+ body: batchedBody,
3193
+ signal: combinedSignal
3194
+ });
3195
+ if (!response.ok) await handleErrorResponse(response, this.url);
3196
+ }
3197
+ /**
3198
+ * Append a streaming body to the stream.
3199
+ *
3200
+ * Supports piping from any ReadableStream or async iterable:
3201
+ * - `source` yields Uint8Array or string chunks.
3202
+ * - Strings are encoded as UTF-8; no delimiters are added.
3203
+ * - Internally uses chunked transfer or HTTP/2 streaming.
3204
+ *
3205
+ * @example
3206
+ * ```typescript
3207
+ * // Pipe from a ReadableStream
3208
+ * const readable = new ReadableStream({
3209
+ * start(controller) {
3210
+ * controller.enqueue("chunk 1");
3211
+ * controller.enqueue("chunk 2");
3212
+ * controller.close();
3213
+ * }
3214
+ * });
3215
+ * await stream.appendStream(readable);
3216
+ *
3217
+ * // Pipe from an async generator
3218
+ * async function* generate() {
3219
+ * yield "line 1\n";
3220
+ * yield "line 2\n";
3221
+ * }
3222
+ * await stream.appendStream(generate());
3223
+ *
3224
+ * // Pipe from fetch response body
3225
+ * const response = await fetch("https://example.com/data");
3226
+ * await stream.appendStream(response.body!);
3227
+ * ```
3228
+ */
3229
+ async appendStream(source, opts) {
3230
+ const { requestHeaders, fetchUrl } = await this.#buildRequest();
3231
+ const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
3232
+ if (contentType) requestHeaders[`content-type`] = contentType;
3233
+ if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
3234
+ const body = toReadableStream(source);
3235
+ const response = await this.#fetchClient(fetchUrl.toString(), {
3236
+ method: `POST`,
3237
+ headers: requestHeaders,
3238
+ body,
3239
+ duplex: `half`,
3240
+ signal: opts?.signal ?? this.#options.signal
3241
+ });
3242
+ if (!response.ok) await handleErrorResponse(response, this.url);
3243
+ }
3244
+ /**
3245
+ * Create a writable stream that pipes data to this durable stream.
3246
+ *
3247
+ * Returns a WritableStream that can be used with `pipeTo()` or
3248
+ * `pipeThrough()` from any ReadableStream source.
3249
+ *
3250
+ * Uses IdempotentProducer internally for:
3251
+ * - Automatic batching (controlled by lingerMs, maxBatchBytes)
3252
+ * - Exactly-once delivery semantics
3253
+ * - Streaming writes (doesn't buffer entire content in memory)
3254
+ *
3255
+ * @example
3256
+ * ```typescript
3257
+ * // Pipe from fetch response
3258
+ * const response = await fetch("https://example.com/data");
3259
+ * await response.body!.pipeTo(stream.writable());
3260
+ *
3261
+ * // Pipe through a transform
3262
+ * const readable = someStream.pipeThrough(new TextEncoderStream());
3263
+ * await readable.pipeTo(stream.writable());
3264
+ *
3265
+ * // With custom producer options
3266
+ * await source.pipeTo(stream.writable({
3267
+ * producerId: "my-producer",
3268
+ * lingerMs: 10,
3269
+ * maxBatchBytes: 64 * 1024,
3270
+ * }));
3271
+ * ```
3272
+ */
3273
+ writable(opts) {
3274
+ const producerId = opts?.producerId ?? `writable-${crypto.randomUUID().slice(0, 8)}`;
3275
+ let writeError = null;
3276
+ const producer = new IdempotentProducer(this, producerId, {
3277
+ autoClaim: true,
3278
+ lingerMs: opts?.lingerMs,
3279
+ maxBatchBytes: opts?.maxBatchBytes,
3280
+ onError: (error) => {
3281
+ if (!writeError) writeError = error;
3282
+ opts?.onError?.(error);
3283
+ },
3284
+ signal: opts?.signal ?? this.#options.signal
3285
+ });
3286
+ return new WritableStream({
3287
+ write(chunk) {
3288
+ producer.append(chunk);
3289
+ },
3290
+ async close() {
3291
+ await producer.close();
3292
+ if (writeError) throw writeError;
3293
+ },
3294
+ abort(_reason) {
3295
+ producer.detach().catch((err) => {
3296
+ opts?.onError?.(err);
3297
+ });
3298
+ }
3299
+ });
3300
+ }
3301
+ /**
3302
+ * Start a fetch-like streaming session against this handle's URL/headers/params.
3303
+ * The first request is made inside this method; it resolves when we have
3304
+ * a valid first response, or rejects on errors.
3305
+ *
3306
+ * Call-specific headers and params are merged with handle-level ones,
3307
+ * with call-specific values taking precedence.
3308
+ *
3309
+ * @example
3310
+ * ```typescript
3311
+ * const handle = await DurableStream.connect({
3312
+ * url,
3313
+ * headers: { Authorization: `Bearer ${token}` }
3314
+ * });
3315
+ * const res = await handle.stream<{ message: string }>();
3316
+ *
3317
+ * // Accumulate all JSON items
3318
+ * const items = await res.json();
3319
+ *
3320
+ * // Or stream live with ReadableStream
3321
+ * const reader = res.jsonStream().getReader();
3322
+ * let result = await reader.read();
3323
+ * while (!result.done) {
3324
+ * console.log(result.value);
3325
+ * result = await reader.read();
3326
+ * }
3327
+ *
3328
+ * // Or use subscriber for backpressure-aware consumption
3329
+ * res.subscribeJson(async (batch) => {
3330
+ * for (const item of batch.items) {
3331
+ * console.log(item);
3332
+ * }
3333
+ * });
3334
+ * ```
3335
+ */
3336
+ async stream(options) {
3337
+ const mergedHeaders = {
3338
+ ...this.#options.headers,
3339
+ ...options?.headers
3340
+ };
3341
+ const mergedParams = {
3342
+ ...this.#options.params,
3343
+ ...options?.params
3344
+ };
3345
+ return stream({
3346
+ url: this.url,
3347
+ headers: mergedHeaders,
3348
+ params: mergedParams,
3349
+ signal: options?.signal ?? this.#options.signal,
3350
+ fetch: this.#options.fetch,
3351
+ backoffOptions: this.#options.backoffOptions,
3352
+ offset: options?.offset,
3353
+ live: options?.live,
3354
+ json: options?.json,
3355
+ onError: options?.onError ?? this.#onError,
3356
+ warnOnHttp: options?.warnOnHttp ?? this.#options.warnOnHttp
3357
+ });
3358
+ }
3359
+ /**
3360
+ * Build request headers and URL.
3361
+ */
3362
+ async #buildRequest() {
3363
+ const requestHeaders = await resolveHeaders(this.#options.headers);
3364
+ const fetchUrl = new URL(this.url);
3365
+ const params = await resolveParams(this.#options.params);
3366
+ for (const [key, value] of Object.entries(params)) fetchUrl.searchParams.set(key, value);
3367
+ return {
3368
+ requestHeaders,
3369
+ fetchUrl
3370
+ };
3371
+ }
3372
+ };
3373
+ /**
3374
+ * Encode a body value to the appropriate format.
3375
+ * Strings are encoded as UTF-8.
3376
+ * Objects are JSON-serialized.
3377
+ */
3378
+ function encodeBody(body) {
3379
+ if (body === void 0) return void 0;
3380
+ if (typeof body === `string`) return new TextEncoder().encode(body);
3381
+ if (body instanceof Uint8Array) return body;
3382
+ if (body instanceof Blob || body instanceof FormData || body instanceof ReadableStream || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return body;
3383
+ return new TextEncoder().encode(JSON.stringify(body));
3384
+ }
3385
+ /**
3386
+ * Convert an async iterable to a ReadableStream.
3387
+ */
3388
+ function toReadableStream(source) {
3389
+ if (source instanceof ReadableStream) return source.pipeThrough(new TransformStream({ transform(chunk, controller) {
3390
+ if (typeof chunk === `string`) controller.enqueue(new TextEncoder().encode(chunk));
3391
+ else controller.enqueue(chunk);
3392
+ } }));
3393
+ const encoder = new TextEncoder();
3394
+ const iterator = source[Symbol.asyncIterator]();
3395
+ return new ReadableStream({
3396
+ async pull(controller) {
3397
+ try {
3398
+ const { done, value } = await iterator.next();
3399
+ if (done) controller.close();
3400
+ else if (typeof value === `string`) controller.enqueue(encoder.encode(value));
3401
+ else controller.enqueue(value);
3402
+ } catch (e) {
3403
+ controller.error(e);
3404
+ }
3405
+ },
3406
+ cancel() {
3407
+ iterator.return?.();
3408
+ }
3409
+ });
3410
+ }
3411
+ /**
3412
+ * Validate stream options.
3413
+ */
3414
+ function validateOptions(options) {
3415
+ if (!options.url) throw new MissingStreamUrlError();
3416
+ if (options.signal && !(options.signal instanceof AbortSignal)) throw new InvalidSignalError();
3417
+ warnIfUsingHttpInBrowser(options.url, options.warnOnHttp);
3418
+ }
3419
+ /**
3420
+ * The streams client a consumer's `durableStreams()` binding hydrates to —
3421
+ * the RPC parity: RPC users don't hand-roll request encoding (`rpc()` hydrates
3422
+ * through `makeClient`), and streams users don't hand-roll the Durable Streams
3423
+ * protocol. All protocol knowledge lives here: the URL layout, the bearer
3424
+ * scheme, JSON-array append framing, opaque offsets, and the long-poll dance
3425
+ * — plus the stream lifecycle (ensure-create, the proven-safe 404 heal) that
3426
+ * used to live in application code. The wire client is
3427
+ * `@durable-streams/client` (ElectricSQL's canonical protocol client,
3428
+ * Apache-2.0); this wrapper narrows it to what the module contract promises
3429
+ * and adds the platform compensations, each annotated with the ticket it
3430
+ * stands in for.
3431
+ *
3432
+ * Two classes: `StreamsClient` holds the transport (base URL, bearer header,
3433
+ * the per-stream write handles a batched append needs) and hands out one
3434
+ * `StreamHandle` per stream name, memoized so its ensure-create state
3435
+ * survives repeat calls. `StreamHandle` holds one stream's name and
3436
+ * ensure-create memo, and is what a consumer actually calls `append`/`read`/
3437
+ * `tail` on — no call site names a stream twice.
3438
+ *
3439
+ * Exported standalone (and via the umbrella) so local dev and tests can wrap
3440
+ * the stand-in's URL without a deployed binding:
3441
+ *
3442
+ * const client = new StreamsClient({ url: standIn.url, apiKey: 'unused' });
3443
+ * await client.stream('log').append({ n: 1 });
3444
+ */
3445
+ const JSON_CONTENT_TYPE = "application/json";
3446
+ /**
3447
+ * PRO-219: a scale-to-zero streams service can reset the first connection
3448
+ * while its instance boots (~3.5–8s observed), so IDEMPOTENT operations ride
3449
+ * it out with a bounded backoff. The wire client retries any failure except
3450
+ * a 4xx other than 429 — thrown network errors and 5xx statuses included —
3451
+ * so a real protocol error (401, 404, 409) surfaces on the first try. The
3452
+ * bound is ATTEMPTS, not wall-clock: each wait is jittered up to the current
3453
+ * delay, and a server Retry-After acts as a per-wait floor (capped upstream
3454
+ * at 1h). Appends never get any of this (see `StreamsClient.append`). Remove
3455
+ * when CI's "Cold-start canary (PRO-217)" goes clean — it exists to flag
3456
+ * exactly that.
3457
+ */
3458
+ const IDEMPOTENT_BACKOFF = {
3459
+ ...BackoffDefaults,
3460
+ initialDelay: 250,
3461
+ maxDelay: 5e3,
3462
+ multiplier: 2,
3463
+ maxRetries: 5
3464
+ };
3465
+ /** The wire client retries network errors by default — appends must not be (no idempotency key). */
3466
+ const NO_RETRY_BACKOFF = {
3467
+ ...BackoffDefaults,
3468
+ maxRetries: 0
3469
+ };
3470
+ const DEFAULT_TAIL_TIMEOUT_MS = 2e4;
3471
+ function isAlreadyExists(error) {
3472
+ return error instanceof DurableStreamError && error.status === 409;
3473
+ }
3474
+ /**
3475
+ * Whether a client operation failed because the stream does not exist — the
3476
+ * one failure that provably applied NOTHING, so re-creating the stream and
3477
+ * re-running the operation is safe even for an append. Deliberately exactly
3478
+ * that: ambiguous failures (socket closes, 502/504) never match. Not
3479
+ * exported — its only consumer is `StreamHandle`'s own heal, so no app code
3480
+ * needs the wire client's error shape.
3481
+ */
3482
+ function isStreamNotFound(error) {
3483
+ return (error instanceof FetchError || error instanceof DurableStreamError) && error.status === 404;
3484
+ }
3485
+ function streamUrl(base, name) {
3486
+ return `${base}/v1/stream/${encodeURIComponent(name)}`;
3487
+ }
3488
+ /**
3489
+ * The transport a consumer's `durableStreams()` binding hydrates to (bare
3490
+ * form) — holds the base URL, the bearer header, and the per-stream write
3491
+ * handles a batched append needs. `stream(name)` is the client's whole
3492
+ * public surface: a dynamic streams consumer names a stream by calling it,
3493
+ * never by any other method here.
3494
+ */
3495
+ var StreamsClient = class {
3496
+ base;
3497
+ headers;
3498
+ writers = /* @__PURE__ */ new Map();
3499
+ handles = /* @__PURE__ */ new Map();
3500
+ constructor(config) {
3501
+ this.base = config.url.replace(/\/$/, "");
3502
+ this.headers = { authorization: `Bearer ${config.apiKey}` };
3503
+ }
3504
+ /** One handle per stream name, memoized so its ensure-create state survives repeat calls. */
3505
+ stream(name) {
3506
+ let handle = this.handles.get(name);
3507
+ if (handle === void 0) {
3508
+ handle = new StreamHandle(name, this);
3509
+ this.handles.set(name, handle);
3510
+ }
3511
+ return handle;
3512
+ }
3513
+ writer(name) {
3514
+ let handle = this.writers.get(name);
3515
+ if (handle === void 0) {
3516
+ handle = new DurableStream({
3517
+ url: streamUrl(this.base, name),
3518
+ headers: this.headers,
3519
+ contentType: JSON_CONTENT_TYPE,
3520
+ batching: false,
3521
+ backoffOptions: NO_RETRY_BACKOFF
3522
+ });
3523
+ this.writers.set(name, handle);
3524
+ }
3525
+ return handle;
3526
+ }
3527
+ /** Creates the stream (idempotent: an existing stream of any content type is success). Used by `StreamHandle`'s ensure-create. */
3528
+ async create(name) {
3529
+ const handle = new DurableStream({
3530
+ url: streamUrl(this.base, name),
3531
+ headers: this.headers,
3532
+ contentType: JSON_CONTENT_TYPE,
3533
+ backoffOptions: IDEMPOTENT_BACKOFF
3534
+ });
3535
+ try {
3536
+ await handle.create();
3537
+ } catch (error) {
3538
+ if (!isAlreadyExists(error)) throw error;
3539
+ }
3540
+ }
3541
+ /**
3542
+ * Appends one JSON event. NEVER retried beyond `StreamHandle`'s one-shot
3543
+ * 404 heal: the protocol has no idempotency key, so a failed request is
3544
+ * indistinguishable from one that applied — the caller retries, because
3545
+ * only it knows whether a duplicate is acceptable.
3546
+ */
3547
+ async append(name, event) {
3548
+ await this.writer(name).append(JSON.stringify(event));
3549
+ }
3550
+ /** Reads the stream from `offset` (default: the beginning) to the current head. */
3551
+ async read(name, opts) {
3552
+ const res = await stream({
3553
+ url: streamUrl(this.base, name),
3554
+ headers: this.headers,
3555
+ offset: opts?.offset ?? "-1",
3556
+ live: false,
3557
+ json: true,
3558
+ backoffOptions: IDEMPOTENT_BACKOFF
3559
+ });
3560
+ return {
3561
+ events: await res.json(),
3562
+ nextOffset: res.offset
3563
+ };
3564
+ }
3565
+ /**
3566
+ * Waits for the next live delivery after `offset` (default: the current
3567
+ * head), via long-poll — SSE cannot traverse the Compute ingress (PRO-218).
3568
+ * Resolves with the delivered events, or `timedOut: true` after `timeoutMs`
3569
+ * (default 20s) with nothing new.
3570
+ */
3571
+ async tail(name, opts) {
3572
+ const abort = new AbortController();
3573
+ const onCallerAbort = () => abort.abort();
3574
+ opts?.signal?.addEventListener("abort", onCallerAbort, { once: true });
3575
+ const timer = setTimeout(() => abort.abort(), opts?.timeoutMs ?? DEFAULT_TAIL_TIMEOUT_MS);
3576
+ try {
3577
+ const res = await stream({
3578
+ url: streamUrl(this.base, name),
3579
+ headers: this.headers,
3580
+ offset: opts?.offset ?? "now",
3581
+ live: "long-poll",
3582
+ json: true,
3583
+ backoffOptions: IDEMPOTENT_BACKOFF,
3584
+ signal: abort.signal
3585
+ });
3586
+ return await new Promise((resolve, reject) => {
3587
+ abort.signal.addEventListener("abort", () => resolve({
3588
+ events: [],
3589
+ nextOffset: res.offset,
3590
+ timedOut: true
3591
+ }), { once: true });
3592
+ try {
3593
+ res.subscribeJson((batch) => {
3594
+ if (batch.items.length === 0) return;
3595
+ resolve({
3596
+ events: batch.items,
3597
+ nextOffset: batch.offset,
3598
+ timedOut: false
3599
+ });
3600
+ abort.abort();
3601
+ });
3602
+ } catch (error) {
3603
+ reject(error);
3604
+ }
3605
+ });
3606
+ } catch (error) {
3607
+ if (abort.signal.aborted) return {
3608
+ events: [],
3609
+ nextOffset: opts?.offset ?? "now",
3610
+ timedOut: true
3611
+ };
3612
+ throw error;
3613
+ } finally {
3614
+ clearTimeout(timer);
3615
+ opts?.signal?.removeEventListener("abort", onCallerAbort);
3616
+ }
3617
+ }
3618
+ };
3619
+ /**
3620
+ * One stream's handle — the name and the ensure-create memo. Everything a
3621
+ * `durableStreams(contract)` handle or a `durableStreams()` client's
3622
+ * `stream(name)` result exposes; no call site passes a name again.
3623
+ *
3624
+ * Owns the lifecycle the app used to hand-roll: the first operation creates
3625
+ * the stream (memoized here; upstream create is already ensure-style, so a
3626
+ * racing second instance is harmless — using a stream is sufficient to
3627
+ * create it), and a 404 on any operation heals by dropping the memo,
3628
+ * re-creating, and retrying that operation once. A 404 is generated INSTEAD
3629
+ * OF a write at every layer, so it proves nothing was applied — retrying
3630
+ * once cannot duplicate an event, even an append. Ambiguous failures (socket
3631
+ * closes, 502/504) never match `isStreamNotFound` and surface raw.
3632
+ */
3633
+ var StreamHandle = class {
3634
+ name;
3635
+ transport;
3636
+ ensured;
3637
+ constructor(name, transport) {
3638
+ this.name = name;
3639
+ this.transport = transport;
3640
+ }
3641
+ ensureCreate() {
3642
+ if (this.ensured === void 0) this.ensured = this.transport.create(this.name).catch((error) => {
3643
+ this.ensured = void 0;
3644
+ throw error;
3645
+ });
3646
+ return this.ensured;
3647
+ }
3648
+ async withHeal(op) {
3649
+ await this.ensureCreate();
3650
+ try {
3651
+ return await op();
3652
+ } catch (error) {
3653
+ if (!isStreamNotFound(error)) throw error;
3654
+ this.ensured = void 0;
3655
+ await this.ensureCreate();
3656
+ return op();
3657
+ }
3658
+ }
3659
+ /**
3660
+ * Appends one JSON event. NEVER retried beyond the one-shot 404 heal above:
3661
+ * the protocol has no idempotency key, so a failed request is
3662
+ * indistinguishable from one that applied — the caller retries, because
3663
+ * only it knows whether a duplicate is acceptable.
3664
+ */
3665
+ append(event) {
3666
+ return this.withHeal(() => this.transport.append(this.name, event));
3667
+ }
3668
+ /** Reads the stream from `offset` (default: the beginning) to the current head. */
3669
+ read(opts) {
3670
+ return this.withHeal(() => this.transport.read(this.name, opts));
3671
+ }
3672
+ /**
3673
+ * Waits for the next live delivery after `offset` (default: the current
3674
+ * head), via long-poll. Resolves with the delivered events, or
3675
+ * `timedOut: true` after `timeoutMs` (default 20s) with nothing new.
3676
+ */
3677
+ tail(opts) {
3678
+ return this.withHeal(() => this.transport.tail(this.name, opts));
3679
+ }
3680
+ };
3681
+ /** Declares an untyped stream in a `streamsContract` def map. */
3682
+ function streamDef() {
3683
+ return Object.freeze({ kind: "stream-def" });
3684
+ }
3685
+ /**
3686
+ * Names the streams a contract transports, each with an optional def:
3687
+ * `streamsContract({ jobs: streamDef(), audit: streamDef() })`. The
3688
+ * `durableStreams(contract)` dependency built from it hydrates to one handle
3689
+ * per declared name.
3690
+ */
3691
+ function streamsContract(defs) {
3692
+ return Object.freeze({
3693
+ kind: "streams",
3694
+ __cmp: defs,
3695
+ satisfies: (required) => required.kind === "streams"
3696
+ });
3697
+ }
3698
+ /**
3699
+ * The `streams()` module's own exposed port: a general streams provider,
3700
+ * satisfied by kind alone — the `postgresContract` pattern. The module
3701
+ * cannot know its eventual consumers' stream names (different consumers of
3702
+ * one module each name their own), and the server genuinely serves any
3703
+ * stream, so what a consumer requires of its provider is only "is a streams
3704
+ * provider". That is exactly what this wide type says, and the empty def
3705
+ * map is a legitimate `StreamDefs` value — a placeholder nobody reads, like
3706
+ * postgres's `{ url: '' }`. Consumers keep their literal handle typing from
3707
+ * `durableStreams(contract)`'s generic parameter, which is independent of
3708
+ * the wiring-compatibility type here.
3709
+ */
3710
+ const streamsProviderContract = Object.freeze({
396
3711
  kind: "streams",
397
- __cmp: { url: "" },
3712
+ __cmp: {},
398
3713
  satisfies: (required) => required.kind === "streams"
399
3714
  });
400
- /** A consumer's dependency on a durable-streams server. */
401
- function durableStreams() {
3715
+ const connectionParams = {
3716
+ url: string(),
3717
+ apiKey: string({ provision: streamsApiKeyNeed() })
3718
+ };
3719
+ function durableStreams(contract) {
402
3720
  return dependency({
403
3721
  type: "streams",
404
3722
  connection: {
405
- params: { url: string() },
406
- hydrate: (v) => v
3723
+ params: connectionParams,
3724
+ hydrate: (v) => {
3725
+ const client = new StreamsClient(v);
3726
+ if (contract === void 0) return client;
3727
+ const handles = {};
3728
+ for (const name of Object.keys(contract.__cmp)) handles[name] = client.stream(name);
3729
+ return handles;
3730
+ }
407
3731
  },
408
- required: streamsContract
3732
+ required: contract ?? streamsProviderContract
409
3733
  });
410
3734
  }
411
3735
  /**
412
- * The streams service node: a plain `compute` service (no lowering extension
413
- * the contract binding is `{ url }`, which compute's deploy outputs already
414
- * carry). It declares the `store` dependency (`s3()`, the storage module's
415
- * port), the `apiKey` secret slot, and the `streams` expose. The deploy
416
- * bootstrap runs the default-exported bare node; the real wiring arrives
417
- * through serialized config at runtime exactly like `storage-service.ts`.
3736
+ * The streams service node: a plain `compute` service the contract binding's
3737
+ * `url` is a producer output compute's deploy already carries, and its
3738
+ * `apiKey` is minted by the target's registered provisioner (ADR-0031), so
3739
+ * nothing is left for a bespoke lowering to extend. It declares the `store`
3740
+ * dependency (`s3()`, the storage module's port) and the `streams` expose; the
3741
+ * bearer key reaches this service through the target's reserved provider
3742
+ * param, not through a dependency. The deploy bootstrap runs the
3743
+ * default-exported bare node; the real wiring arrives through serialized
3744
+ * config at runtime — exactly like `storage-service.ts`.
418
3745
  */
419
3746
  function streamsService() {
420
3747
  return compute({
421
3748
  name: "streams",
422
3749
  deps: { store: s3() },
423
- secrets: { apiKey: secret() },
424
3750
  build: node({
425
3751
  module: new URL("./streams-service.mjs", import.meta.url).href,
426
3752
  entry: "./streams-entrypoint.mjs"
427
3753
  }),
428
- expose: { streams: streamsContract }
3754
+ expose: { streams: streamsProviderContract }
429
3755
  });
430
3756
  }
431
3757
  streamsService();
@@ -434,17 +3760,15 @@ streamsService();
434
3760
  function streams(opts) {
435
3761
  return module(opts?.name ?? "streams", {
436
3762
  deps: { store: s3() },
437
- secrets: { apiKey: secret() },
438
- expose: { streams: streamsContract }
439
- }, ({ inputs, secrets, provision }) => {
3763
+ expose: { streams: streamsProviderContract }
3764
+ }, ({ inputs, provision }) => {
440
3765
  return { streams: provision(streamsService(), {
441
3766
  id: "service",
442
- deps: { store: inputs.store },
443
- secrets: { apiKey: secrets.apiKey }
3767
+ deps: { store: inputs.store }
444
3768
  }).streams };
445
3769
  });
446
3770
  }
447
3771
  //#endregion
448
- export { durableStreams, streams, streamsContract, streamsService };
3772
+ export { StreamHandle, StreamsClient, durableStreams, streamDef, streams, streamsContract, streamsService };
449
3773
 
450
3774
  //# sourceMappingURL=index.mjs.map