@prisma/composer-prisma-cloud 0.1.0-dev.1 → 0.1.0-dev.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/control.d.mts +44 -4
- package/dist/control.mjs +155 -37
- package/dist/control.mjs.map +1 -1
- package/dist/cron/index.mjs +73 -8
- package/dist/cron/index.mjs.map +1 -1
- package/dist/cron/scheduler-entrypoint.mjs +158 -93
- package/dist/cron/scheduler-entrypoint.mjs.map +1 -1
- package/dist/cron/scheduler-service.mjs +73 -8
- package/dist/cron/scheduler-service.mjs.map +1 -1
- package/dist/index.d.mts +34 -15
- package/dist/index.mjs +4 -5
- package/dist/index.mjs.map +1 -1
- package/dist/{prisma-next-COrwlg3N.mjs → prisma-next-qPB8_Az6.mjs} +2 -2
- package/dist/prisma-next-qPB8_Az6.mjs.map +1 -0
- package/dist/prisma-next.d.mts +1 -1
- package/dist/prisma-next.mjs +1 -1
- package/dist/{param-DB0B8m15-IvzNq9BM.mjs → provisioned-edges-DIQAR4q4-Bn9op-JG.mjs} +87 -28
- package/dist/provisioned-edges-DIQAR4q4-Bn9op-JG.mjs.map +1 -0
- package/dist/{serializer-DAEWRfnm-D3GW9dOZ.mjs → serializer-CX4VYdf_-KKGoAxfx.mjs} +29 -3
- package/dist/{serializer-DAEWRfnm-D3GW9dOZ.mjs.map → serializer-CX4VYdf_-KKGoAxfx.mjs.map} +1 -1
- package/dist/serializer-Cx5slrV4-xJfH6EWS.d.mts +36 -0
- package/dist/storage/index.d.mts +11 -11
- package/dist/storage/index.mjs +74 -8
- package/dist/storage/index.mjs.map +1 -1
- package/dist/storage/storage-entrypoint.mjs +7197 -12
- package/dist/storage/storage-entrypoint.mjs.map +1 -1
- package/dist/storage/storage-service.mjs +74 -8
- package/dist/storage/storage-service.mjs.map +1 -1
- package/dist/storage/testing.mjs.map +1 -1
- package/dist/streams/index.d.mts +224 -22
- package/dist/streams/index.mjs +3637 -37
- package/dist/streams/index.mjs.map +1 -1
- package/dist/streams/streams-entrypoint.mjs +7731 -221
- package/dist/streams/streams-entrypoint.mjs.map +1 -1
- package/dist/streams/streams-service.mjs +400 -21
- package/dist/streams/streams-service.mjs.map +1 -1
- package/dist/streams/testing.d.mts +1 -1
- package/dist/streams/testing.mjs.map +1 -1
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/dist/testing.mjs.map +1 -1
- package/package.json +14 -14
- package/dist/param-DB0B8m15-IvzNq9BM.mjs.map +0 -1
- package/dist/prisma-next-COrwlg3N.mjs.map +0 -1
package/dist/streams/index.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { dependency, hydrateSecrets, hydrateSync, module, number,
|
|
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/service-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/
|
|
176
|
-
/**
|
|
177
|
-
|
|
202
|
+
//#region ../../1-prisma-cloud/1-extensions/target/dist/provisioned-edges-DIQAR4q4.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/service-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:
|
|
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();
|
|
@@ -334,7 +408,7 @@ function s3StoreService(def) {
|
|
|
334
408
|
}));
|
|
335
409
|
}
|
|
336
410
|
//#endregion
|
|
337
|
-
//#region ../../1-prisma-cloud/2-shared-modules/storage/dist/storage-service-
|
|
411
|
+
//#region ../../1-prisma-cloud/2-shared-modules/storage/dist/storage-service-BQdOiMsW.mjs
|
|
338
412
|
const s3Contract = Object.freeze({
|
|
339
413
|
kind: "s3",
|
|
340
414
|
__cmp: {
|
|
@@ -391,41 +465,3569 @@ 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-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
468
|
+
//#region ../../1-prisma-cloud/2-shared-modules/streams/dist/streams-service-Br5Tj3AY.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 && response.body !== null) 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 && response.body !== null) 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
|
+
* Abstract base class for stream response state.
|
|
1286
|
+
* All state transitions return new immutable state objects.
|
|
1287
|
+
*/
|
|
1288
|
+
var StreamResponseState = class {
|
|
1289
|
+
shouldContinueLive(stopAfterUpToDate, liveMode) {
|
|
1290
|
+
if (stopAfterUpToDate && this.upToDate) return false;
|
|
1291
|
+
if (liveMode === false) return false;
|
|
1292
|
+
if (this.streamClosed) return false;
|
|
1293
|
+
return true;
|
|
1294
|
+
}
|
|
1295
|
+
};
|
|
1296
|
+
/**
|
|
1297
|
+
* State for long-poll mode. shouldUseSse() returns false.
|
|
1298
|
+
*/
|
|
1299
|
+
var LongPollState = class LongPollState extends StreamResponseState {
|
|
1300
|
+
offset;
|
|
1301
|
+
cursor;
|
|
1302
|
+
upToDate;
|
|
1303
|
+
streamClosed;
|
|
1304
|
+
constructor(fields) {
|
|
1305
|
+
super();
|
|
1306
|
+
this.offset = fields.offset;
|
|
1307
|
+
this.cursor = fields.cursor;
|
|
1308
|
+
this.upToDate = fields.upToDate;
|
|
1309
|
+
this.streamClosed = fields.streamClosed;
|
|
1310
|
+
}
|
|
1311
|
+
shouldUseSse() {
|
|
1312
|
+
return false;
|
|
1313
|
+
}
|
|
1314
|
+
withResponseMetadata(update) {
|
|
1315
|
+
return new LongPollState({
|
|
1316
|
+
offset: update.offset ?? this.offset,
|
|
1317
|
+
cursor: update.cursor ?? this.cursor,
|
|
1318
|
+
upToDate: update.upToDate,
|
|
1319
|
+
streamClosed: this.streamClosed || update.streamClosed
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
withSSEControl(event) {
|
|
1323
|
+
const streamClosed = this.streamClosed || (event.streamClosed ?? false);
|
|
1324
|
+
return new LongPollState({
|
|
1325
|
+
offset: event.streamNextOffset,
|
|
1326
|
+
cursor: event.streamCursor || this.cursor,
|
|
1327
|
+
upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
|
|
1328
|
+
streamClosed
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
pause() {
|
|
1332
|
+
return new PausedState(this);
|
|
1333
|
+
}
|
|
1334
|
+
};
|
|
1335
|
+
/**
|
|
1336
|
+
* State for SSE mode. shouldUseSse() returns true.
|
|
1337
|
+
* Tracks SSE connection resilience (short connection detection).
|
|
1338
|
+
*/
|
|
1339
|
+
var SSEState = class SSEState extends StreamResponseState {
|
|
1340
|
+
offset;
|
|
1341
|
+
cursor;
|
|
1342
|
+
upToDate;
|
|
1343
|
+
streamClosed;
|
|
1344
|
+
consecutiveShortConnections;
|
|
1345
|
+
connectionStartTime;
|
|
1346
|
+
constructor(fields) {
|
|
1347
|
+
super();
|
|
1348
|
+
this.offset = fields.offset;
|
|
1349
|
+
this.cursor = fields.cursor;
|
|
1350
|
+
this.upToDate = fields.upToDate;
|
|
1351
|
+
this.streamClosed = fields.streamClosed;
|
|
1352
|
+
this.consecutiveShortConnections = fields.consecutiveShortConnections ?? 0;
|
|
1353
|
+
this.connectionStartTime = fields.connectionStartTime;
|
|
1354
|
+
}
|
|
1355
|
+
shouldUseSse() {
|
|
1356
|
+
return true;
|
|
1357
|
+
}
|
|
1358
|
+
withResponseMetadata(update) {
|
|
1359
|
+
return new SSEState({
|
|
1360
|
+
offset: update.offset ?? this.offset,
|
|
1361
|
+
cursor: update.cursor ?? this.cursor,
|
|
1362
|
+
upToDate: update.upToDate,
|
|
1363
|
+
streamClosed: this.streamClosed || update.streamClosed,
|
|
1364
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
1365
|
+
connectionStartTime: this.connectionStartTime
|
|
1366
|
+
});
|
|
1367
|
+
}
|
|
1368
|
+
withSSEControl(event) {
|
|
1369
|
+
const streamClosed = this.streamClosed || (event.streamClosed ?? false);
|
|
1370
|
+
return new SSEState({
|
|
1371
|
+
offset: event.streamNextOffset,
|
|
1372
|
+
cursor: event.streamCursor || this.cursor,
|
|
1373
|
+
upToDate: event.streamClosed ?? false ? true : event.upToDate ?? this.upToDate,
|
|
1374
|
+
streamClosed,
|
|
1375
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
1376
|
+
connectionStartTime: this.connectionStartTime
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
startConnection(now) {
|
|
1380
|
+
return new SSEState({
|
|
1381
|
+
offset: this.offset,
|
|
1382
|
+
cursor: this.cursor,
|
|
1383
|
+
upToDate: this.upToDate,
|
|
1384
|
+
streamClosed: this.streamClosed,
|
|
1385
|
+
consecutiveShortConnections: this.consecutiveShortConnections,
|
|
1386
|
+
connectionStartTime: now
|
|
1387
|
+
});
|
|
1388
|
+
}
|
|
1389
|
+
handleConnectionEnd(now, wasAborted, config) {
|
|
1390
|
+
if (this.connectionStartTime === void 0) return {
|
|
1391
|
+
action: `healthy`,
|
|
1392
|
+
state: this
|
|
1393
|
+
};
|
|
1394
|
+
const duration = now - this.connectionStartTime;
|
|
1395
|
+
if (duration < config.minConnectionDuration && !wasAborted) {
|
|
1396
|
+
const newCount = this.consecutiveShortConnections + 1;
|
|
1397
|
+
if (newCount >= config.maxShortConnections) return {
|
|
1398
|
+
action: `fallback`,
|
|
1399
|
+
state: new LongPollState({
|
|
1400
|
+
offset: this.offset,
|
|
1401
|
+
cursor: this.cursor,
|
|
1402
|
+
upToDate: this.upToDate,
|
|
1403
|
+
streamClosed: this.streamClosed
|
|
1404
|
+
})
|
|
1405
|
+
};
|
|
1406
|
+
return {
|
|
1407
|
+
action: `reconnect`,
|
|
1408
|
+
state: new SSEState({
|
|
1409
|
+
offset: this.offset,
|
|
1410
|
+
cursor: this.cursor,
|
|
1411
|
+
upToDate: this.upToDate,
|
|
1412
|
+
streamClosed: this.streamClosed,
|
|
1413
|
+
consecutiveShortConnections: newCount,
|
|
1414
|
+
connectionStartTime: this.connectionStartTime
|
|
1415
|
+
}),
|
|
1416
|
+
backoffAttempt: newCount
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
if (duration >= config.minConnectionDuration) return {
|
|
1420
|
+
action: `healthy`,
|
|
1421
|
+
state: new SSEState({
|
|
1422
|
+
offset: this.offset,
|
|
1423
|
+
cursor: this.cursor,
|
|
1424
|
+
upToDate: this.upToDate,
|
|
1425
|
+
streamClosed: this.streamClosed,
|
|
1426
|
+
consecutiveShortConnections: 0,
|
|
1427
|
+
connectionStartTime: this.connectionStartTime
|
|
1428
|
+
})
|
|
1429
|
+
};
|
|
1430
|
+
return {
|
|
1431
|
+
action: `healthy`,
|
|
1432
|
+
state: this
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
pause() {
|
|
1436
|
+
return new PausedState(this);
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
/**
|
|
1440
|
+
* Paused state wrapper. Delegates all sync field access to the inner state.
|
|
1441
|
+
* resume() returns the wrapped state unchanged (identity preserved).
|
|
1442
|
+
*/
|
|
1443
|
+
var PausedState = class PausedState extends StreamResponseState {
|
|
1444
|
+
#inner;
|
|
1445
|
+
constructor(inner) {
|
|
1446
|
+
super();
|
|
1447
|
+
this.#inner = inner;
|
|
1448
|
+
}
|
|
1449
|
+
get offset() {
|
|
1450
|
+
return this.#inner.offset;
|
|
1451
|
+
}
|
|
1452
|
+
get cursor() {
|
|
1453
|
+
return this.#inner.cursor;
|
|
1454
|
+
}
|
|
1455
|
+
get upToDate() {
|
|
1456
|
+
return this.#inner.upToDate;
|
|
1457
|
+
}
|
|
1458
|
+
get streamClosed() {
|
|
1459
|
+
return this.#inner.streamClosed;
|
|
1460
|
+
}
|
|
1461
|
+
shouldUseSse() {
|
|
1462
|
+
return this.#inner.shouldUseSse();
|
|
1463
|
+
}
|
|
1464
|
+
withResponseMetadata(update) {
|
|
1465
|
+
const newInner = this.#inner.withResponseMetadata(update);
|
|
1466
|
+
return new PausedState(newInner);
|
|
1467
|
+
}
|
|
1468
|
+
withSSEControl(event) {
|
|
1469
|
+
const newInner = this.#inner.withSSEControl(event);
|
|
1470
|
+
return new PausedState(newInner);
|
|
1471
|
+
}
|
|
1472
|
+
pause() {
|
|
1473
|
+
return this;
|
|
1474
|
+
}
|
|
1475
|
+
resume() {
|
|
1476
|
+
return {
|
|
1477
|
+
state: this.#inner,
|
|
1478
|
+
justResumed: true
|
|
1479
|
+
};
|
|
1480
|
+
}
|
|
1481
|
+
};
|
|
1482
|
+
/**
|
|
1483
|
+
* Constant used as abort reason when pausing the stream due to visibility change.
|
|
1484
|
+
*/
|
|
1485
|
+
const PAUSE_STREAM = `PAUSE_STREAM`;
|
|
1486
|
+
/**
|
|
1487
|
+
* Implementation of the StreamResponse interface.
|
|
1488
|
+
*/
|
|
1489
|
+
var StreamResponseImpl = class {
|
|
1490
|
+
url;
|
|
1491
|
+
contentType;
|
|
1492
|
+
live;
|
|
1493
|
+
startOffset;
|
|
1494
|
+
#headers;
|
|
1495
|
+
#status;
|
|
1496
|
+
#statusText;
|
|
1497
|
+
#ok;
|
|
1498
|
+
#isLoading;
|
|
1499
|
+
#syncState;
|
|
1500
|
+
#isJsonMode;
|
|
1501
|
+
#abortController;
|
|
1502
|
+
#fetchNext;
|
|
1503
|
+
#startSSE;
|
|
1504
|
+
#closedResolve;
|
|
1505
|
+
#closedReject;
|
|
1506
|
+
#closed;
|
|
1507
|
+
#stopAfterUpToDate = false;
|
|
1508
|
+
#consumptionMethod = null;
|
|
1509
|
+
#state = `active`;
|
|
1510
|
+
#requestAbortController;
|
|
1511
|
+
#unsubscribeFromVisibilityChanges;
|
|
1512
|
+
#pausePromise;
|
|
1513
|
+
#pauseResolve;
|
|
1514
|
+
#sseResilience;
|
|
1515
|
+
#encoding;
|
|
1516
|
+
#responseStream;
|
|
1517
|
+
constructor(config) {
|
|
1518
|
+
this.url = config.url;
|
|
1519
|
+
this.contentType = config.contentType;
|
|
1520
|
+
this.live = config.live;
|
|
1521
|
+
this.startOffset = config.startOffset;
|
|
1522
|
+
const syncFields = {
|
|
1523
|
+
offset: config.initialOffset,
|
|
1524
|
+
cursor: config.initialCursor,
|
|
1525
|
+
upToDate: config.initialUpToDate,
|
|
1526
|
+
streamClosed: config.initialStreamClosed
|
|
1527
|
+
};
|
|
1528
|
+
this.#syncState = config.startSSE ? new SSEState(syncFields) : new LongPollState(syncFields);
|
|
1529
|
+
this.#headers = config.firstResponse.headers;
|
|
1530
|
+
this.#status = config.firstResponse.status;
|
|
1531
|
+
this.#statusText = config.firstResponse.statusText;
|
|
1532
|
+
this.#ok = config.firstResponse.ok;
|
|
1533
|
+
this.#isLoading = false;
|
|
1534
|
+
this.#isJsonMode = config.isJsonMode;
|
|
1535
|
+
this.#abortController = config.abortController;
|
|
1536
|
+
this.#fetchNext = config.fetchNext;
|
|
1537
|
+
this.#startSSE = config.startSSE;
|
|
1538
|
+
this.#sseResilience = {
|
|
1539
|
+
minConnectionDuration: config.sseResilience?.minConnectionDuration ?? 1e3,
|
|
1540
|
+
maxShortConnections: config.sseResilience?.maxShortConnections ?? 3,
|
|
1541
|
+
backoffBaseDelay: config.sseResilience?.backoffBaseDelay ?? 100,
|
|
1542
|
+
backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5e3,
|
|
1543
|
+
logWarnings: config.sseResilience?.logWarnings ?? true
|
|
1544
|
+
};
|
|
1545
|
+
this.#encoding = config.encoding;
|
|
1546
|
+
this.#closed = new Promise((resolve, reject) => {
|
|
1547
|
+
this.#closedResolve = resolve;
|
|
1548
|
+
this.#closedReject = reject;
|
|
1549
|
+
});
|
|
1550
|
+
this.#responseStream = this.#createResponseStream(config.firstResponse);
|
|
1551
|
+
this.#abortController.signal.addEventListener(`abort`, () => {
|
|
1552
|
+
this.#requestAbortController?.abort(this.#abortController.signal.reason);
|
|
1553
|
+
this.#pauseResolve?.();
|
|
1554
|
+
this.#pausePromise = void 0;
|
|
1555
|
+
this.#pauseResolve = void 0;
|
|
1556
|
+
}, { once: true });
|
|
1557
|
+
this.#subscribeToVisibilityChanges();
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Subscribe to document visibility changes to pause/resume syncing.
|
|
1561
|
+
* When the page is hidden, we pause to save battery and bandwidth.
|
|
1562
|
+
* When visible again, we resume syncing.
|
|
1563
|
+
*/
|
|
1564
|
+
#subscribeToVisibilityChanges() {
|
|
1565
|
+
if (typeof document === `object` && typeof document.hidden === `boolean` && typeof document.addEventListener === `function`) {
|
|
1566
|
+
const visibilityHandler = () => {
|
|
1567
|
+
if (document.hidden) this.#pause();
|
|
1568
|
+
else this.#resume();
|
|
1569
|
+
};
|
|
1570
|
+
document.addEventListener(`visibilitychange`, visibilityHandler);
|
|
1571
|
+
this.#unsubscribeFromVisibilityChanges = () => {
|
|
1572
|
+
if (typeof document === `object`) document.removeEventListener(`visibilitychange`, visibilityHandler);
|
|
1573
|
+
};
|
|
1574
|
+
if (document.hidden) this.#pause();
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Pause the stream when page becomes hidden.
|
|
1579
|
+
* Aborts any in-flight request to free resources.
|
|
1580
|
+
* Creates a promise that pull() will await while paused.
|
|
1581
|
+
*/
|
|
1582
|
+
#pause() {
|
|
1583
|
+
if (this.#state === `active`) {
|
|
1584
|
+
this.#state = `pause-requested`;
|
|
1585
|
+
this.#syncState = this.#syncState.pause();
|
|
1586
|
+
this.#pausePromise = new Promise((resolve) => {
|
|
1587
|
+
this.#pauseResolve = resolve;
|
|
1588
|
+
});
|
|
1589
|
+
this.#requestAbortController?.abort(PAUSE_STREAM);
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Resume the stream when page becomes visible.
|
|
1594
|
+
* Resolves the pause promise to unblock pull().
|
|
1595
|
+
*/
|
|
1596
|
+
#resume() {
|
|
1597
|
+
if (this.#state === `paused` || this.#state === `pause-requested`) {
|
|
1598
|
+
if (this.#abortController.signal.aborted) return;
|
|
1599
|
+
if (this.#syncState instanceof PausedState) this.#syncState = this.#syncState.resume().state;
|
|
1600
|
+
this.#state = `active`;
|
|
1601
|
+
this.#pauseResolve?.();
|
|
1602
|
+
this.#pausePromise = void 0;
|
|
1603
|
+
this.#pauseResolve = void 0;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
get headers() {
|
|
1607
|
+
return this.#headers;
|
|
1608
|
+
}
|
|
1609
|
+
get status() {
|
|
1610
|
+
return this.#status;
|
|
1611
|
+
}
|
|
1612
|
+
get statusText() {
|
|
1613
|
+
return this.#statusText;
|
|
1614
|
+
}
|
|
1615
|
+
get ok() {
|
|
1616
|
+
return this.#ok;
|
|
1617
|
+
}
|
|
1618
|
+
get isLoading() {
|
|
1619
|
+
return this.#isLoading;
|
|
1620
|
+
}
|
|
1621
|
+
get offset() {
|
|
1622
|
+
return this.#syncState.offset;
|
|
1623
|
+
}
|
|
1624
|
+
get cursor() {
|
|
1625
|
+
return this.#syncState.cursor;
|
|
1626
|
+
}
|
|
1627
|
+
get upToDate() {
|
|
1628
|
+
return this.#syncState.upToDate;
|
|
1629
|
+
}
|
|
1630
|
+
get streamClosed() {
|
|
1631
|
+
return this.#syncState.streamClosed;
|
|
1632
|
+
}
|
|
1633
|
+
#ensureJsonMode() {
|
|
1634
|
+
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`);
|
|
1635
|
+
}
|
|
1636
|
+
#markClosed() {
|
|
1637
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
1638
|
+
this.#closedResolve();
|
|
1639
|
+
}
|
|
1640
|
+
#markError(err) {
|
|
1641
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
1642
|
+
this.#closedReject(err);
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Ensure only one consumption method is used per StreamResponse.
|
|
1646
|
+
* Throws if any consumption method was already called.
|
|
1647
|
+
*/
|
|
1648
|
+
#ensureNoConsumption(method) {
|
|
1649
|
+
if (this.#consumptionMethod !== null) throw new DurableStreamError(`Cannot call ${method}() - this StreamResponse is already being consumed via ${this.#consumptionMethod}()`, `ALREADY_CONSUMED`);
|
|
1650
|
+
this.#consumptionMethod = method;
|
|
1651
|
+
}
|
|
1652
|
+
/**
|
|
1653
|
+
* Determine if we should continue with live updates based on live mode
|
|
1654
|
+
* and whether we've received upToDate or streamClosed.
|
|
1655
|
+
*/
|
|
1656
|
+
#shouldContinueLive() {
|
|
1657
|
+
return this.#syncState.shouldContinueLive(this.#stopAfterUpToDate, this.live);
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Update state from response headers.
|
|
1661
|
+
*/
|
|
1662
|
+
#updateStateFromResponse(response) {
|
|
1663
|
+
this.#syncState = this.#syncState.withResponseMetadata({
|
|
1664
|
+
offset: response.headers.get(STREAM_OFFSET_HEADER) || void 0,
|
|
1665
|
+
cursor: response.headers.get(STREAM_CURSOR_HEADER) || void 0,
|
|
1666
|
+
upToDate: response.headers.has(STREAM_UP_TO_DATE_HEADER),
|
|
1667
|
+
streamClosed: response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`
|
|
1668
|
+
});
|
|
1669
|
+
this.#headers = response.headers;
|
|
1670
|
+
this.#status = response.status;
|
|
1671
|
+
this.#statusText = response.statusText;
|
|
1672
|
+
this.#ok = response.ok;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Update instance state from an SSE control event.
|
|
1676
|
+
*/
|
|
1677
|
+
#updateStateFromSSEControl(controlEvent) {
|
|
1678
|
+
this.#syncState = this.#syncState.withSSEControl(controlEvent);
|
|
1679
|
+
}
|
|
1680
|
+
#updateEncodingFromSSEResponse(response) {
|
|
1681
|
+
this.#encoding = response.headers.get(STREAM_SSE_DATA_ENCODING_HEADER) === `base64` ? `base64` : void 0;
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Mark the start of an SSE connection for duration tracking.
|
|
1685
|
+
* If the state is not SSEState (e.g., auto-detected SSE from content-type),
|
|
1686
|
+
* transitions to SSEState first.
|
|
1687
|
+
*/
|
|
1688
|
+
#markSSEConnectionStart() {
|
|
1689
|
+
if (!(this.#syncState instanceof SSEState)) this.#syncState = new SSEState({
|
|
1690
|
+
offset: this.#syncState.offset,
|
|
1691
|
+
cursor: this.#syncState.cursor,
|
|
1692
|
+
upToDate: this.#syncState.upToDate,
|
|
1693
|
+
streamClosed: this.#syncState.streamClosed
|
|
1694
|
+
});
|
|
1695
|
+
this.#syncState = this.#syncState.startConnection(Date.now());
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Try to reconnect SSE and return the new iterator, or null if reconnection
|
|
1699
|
+
* is not possible or fails.
|
|
1700
|
+
*/
|
|
1701
|
+
async #trySSEReconnect() {
|
|
1702
|
+
if (!this.#syncState.shouldUseSse()) return null;
|
|
1703
|
+
if (!this.#shouldContinueLive() || !this.#startSSE) return null;
|
|
1704
|
+
const result = this.#syncState.handleConnectionEnd(Date.now(), this.#abortController.signal.aborted, this.#sseResilience);
|
|
1705
|
+
this.#syncState = result.state;
|
|
1706
|
+
if (result.action === `fallback`) {
|
|
1707
|
+
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.");
|
|
1708
|
+
return null;
|
|
1709
|
+
}
|
|
1710
|
+
if (result.action === `reconnect`) {
|
|
1711
|
+
const maxDelay = Math.min(this.#sseResilience.backoffMaxDelay, this.#sseResilience.backoffBaseDelay * Math.pow(2, result.backoffAttempt));
|
|
1712
|
+
const delayMs = Math.floor(Math.random() * maxDelay);
|
|
1713
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
1714
|
+
}
|
|
1715
|
+
this.#markSSEConnectionStart();
|
|
1716
|
+
this.#requestAbortController = new AbortController();
|
|
1717
|
+
const newSSEResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
|
|
1718
|
+
this.#updateEncodingFromSSEResponse(newSSEResponse);
|
|
1719
|
+
if (newSSEResponse.body) return parseSSEStream(newSSEResponse.body, this.#requestAbortController.signal);
|
|
1720
|
+
return null;
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Process SSE events from the iterator.
|
|
1724
|
+
* Returns an object indicating the result:
|
|
1725
|
+
* - { type: 'response', response, newIterator? } - yield this response
|
|
1726
|
+
* - { type: 'closed' } - stream should be closed
|
|
1727
|
+
* - { type: 'error', error } - an error occurred
|
|
1728
|
+
* - { type: 'continue', newIterator? } - continue processing (control-only event)
|
|
1729
|
+
*/
|
|
1730
|
+
async #processSSEEvents(sseEventIterator) {
|
|
1731
|
+
const { done, value: event } = await sseEventIterator.next();
|
|
1732
|
+
if (done) {
|
|
1733
|
+
try {
|
|
1734
|
+
const newIterator = await this.#trySSEReconnect();
|
|
1735
|
+
if (newIterator) return {
|
|
1736
|
+
type: `continue`,
|
|
1737
|
+
newIterator
|
|
1738
|
+
};
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
return {
|
|
1741
|
+
type: `error`,
|
|
1742
|
+
error: err instanceof Error ? err : /* @__PURE__ */ new Error(`SSE reconnection failed`)
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
return { type: `closed` };
|
|
1746
|
+
}
|
|
1747
|
+
if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
|
|
1748
|
+
this.#updateStateFromSSEControl(event);
|
|
1749
|
+
if (event.upToDate) return {
|
|
1750
|
+
type: `response`,
|
|
1751
|
+
response: createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false, this.contentType, this.#encoding)
|
|
1752
|
+
};
|
|
1753
|
+
return { type: `continue` };
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Process an SSE data event by waiting for its corresponding control event.
|
|
1757
|
+
* In SSE protocol, control events come AFTER data events.
|
|
1758
|
+
* Multiple data events may arrive before a single control event - we buffer them.
|
|
1759
|
+
*
|
|
1760
|
+
* For base64 mode, each data event is independently base64 encoded, so we
|
|
1761
|
+
* collect them as an array and decode each separately.
|
|
1762
|
+
*/
|
|
1763
|
+
async #processSSEDataEvent(pendingData, sseEventIterator) {
|
|
1764
|
+
const bufferedDataParts = [pendingData];
|
|
1765
|
+
while (true) {
|
|
1766
|
+
const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
|
|
1767
|
+
if (controlDone) {
|
|
1768
|
+
const response = createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed, this.contentType, this.#encoding, this.#isJsonMode);
|
|
1769
|
+
try {
|
|
1770
|
+
return {
|
|
1771
|
+
type: `response`,
|
|
1772
|
+
response,
|
|
1773
|
+
newIterator: await this.#trySSEReconnect() ?? void 0
|
|
1774
|
+
};
|
|
1775
|
+
} catch (err) {
|
|
1776
|
+
return {
|
|
1777
|
+
type: `error`,
|
|
1778
|
+
error: err instanceof Error ? err : /* @__PURE__ */ new Error(`SSE reconnection failed`)
|
|
1779
|
+
};
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
if (controlEvent.type === `control`) {
|
|
1783
|
+
this.#updateStateFromSSEControl(controlEvent);
|
|
1784
|
+
return {
|
|
1785
|
+
type: `response`,
|
|
1786
|
+
response: createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false, this.contentType, this.#encoding, this.#isJsonMode)
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
bufferedDataParts.push(controlEvent.data);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
/**
|
|
1793
|
+
* Create the core ReadableStream<Response> that yields responses.
|
|
1794
|
+
* This is consumed once - all consumption methods use this same stream.
|
|
1795
|
+
*
|
|
1796
|
+
* For long-poll mode: yields actual Response objects.
|
|
1797
|
+
* For SSE mode: yields synthetic Response objects created from SSE data events.
|
|
1798
|
+
*/
|
|
1799
|
+
#createResponseStream(firstResponse) {
|
|
1800
|
+
let firstResponseYielded = false;
|
|
1801
|
+
let sseEventIterator = null;
|
|
1802
|
+
return new ReadableStream({
|
|
1803
|
+
pull: async (controller) => {
|
|
1804
|
+
try {
|
|
1805
|
+
if (!firstResponseYielded) {
|
|
1806
|
+
firstResponseYielded = true;
|
|
1807
|
+
if ((firstResponse.headers.get(`content-type`)?.includes(`text/event-stream`) ?? false) && firstResponse.body) {
|
|
1808
|
+
this.#markSSEConnectionStart();
|
|
1809
|
+
this.#updateEncodingFromSSEResponse(firstResponse);
|
|
1810
|
+
this.#requestAbortController = new AbortController();
|
|
1811
|
+
sseEventIterator = parseSSEStream(firstResponse.body, this.#requestAbortController.signal);
|
|
1812
|
+
} else {
|
|
1813
|
+
controller.enqueue(firstResponse);
|
|
1814
|
+
if (this.upToDate && !this.#shouldContinueLive()) {
|
|
1815
|
+
this.#markClosed();
|
|
1816
|
+
controller.close();
|
|
1817
|
+
return;
|
|
1818
|
+
}
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
if (!sseEventIterator && this.upToDate && this.#startSSE && this.#shouldContinueLive()) {
|
|
1823
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
1824
|
+
this.#state = `paused`;
|
|
1825
|
+
if (this.#pausePromise) await this.#pausePromise;
|
|
1826
|
+
if (this.#abortController.signal.aborted) {
|
|
1827
|
+
this.#markClosed();
|
|
1828
|
+
controller.close();
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
this.#markSSEConnectionStart();
|
|
1833
|
+
this.#requestAbortController = new AbortController();
|
|
1834
|
+
const sseResponse = await this.#startSSE(this.offset, this.cursor, this.#requestAbortController.signal);
|
|
1835
|
+
this.#updateEncodingFromSSEResponse(sseResponse);
|
|
1836
|
+
if (sseResponse.body) sseEventIterator = parseSSEStream(sseResponse.body, this.#requestAbortController.signal);
|
|
1837
|
+
}
|
|
1838
|
+
if (sseEventIterator) {
|
|
1839
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
1840
|
+
this.#state = `paused`;
|
|
1841
|
+
if (this.#pausePromise) await this.#pausePromise;
|
|
1842
|
+
if (this.#abortController.signal.aborted) {
|
|
1843
|
+
this.#markClosed();
|
|
1844
|
+
controller.close();
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
const newIterator = await this.#trySSEReconnect();
|
|
1848
|
+
if (newIterator) sseEventIterator = newIterator;
|
|
1849
|
+
else {
|
|
1850
|
+
this.#markClosed();
|
|
1851
|
+
controller.close();
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
while (true) {
|
|
1856
|
+
const result = await this.#processSSEEvents(sseEventIterator);
|
|
1857
|
+
switch (result.type) {
|
|
1858
|
+
case `response`:
|
|
1859
|
+
if (result.newIterator) sseEventIterator = result.newIterator;
|
|
1860
|
+
controller.enqueue(result.response);
|
|
1861
|
+
return;
|
|
1862
|
+
case `closed`:
|
|
1863
|
+
this.#markClosed();
|
|
1864
|
+
controller.close();
|
|
1865
|
+
return;
|
|
1866
|
+
case `error`:
|
|
1867
|
+
this.#markError(result.error);
|
|
1868
|
+
controller.error(result.error);
|
|
1869
|
+
return;
|
|
1870
|
+
case `continue`:
|
|
1871
|
+
if (result.newIterator) sseEventIterator = result.newIterator;
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
if (this.#shouldContinueLive()) {
|
|
1877
|
+
let resumingFromPause = false;
|
|
1878
|
+
if (this.#state === `pause-requested` || this.#state === `paused`) {
|
|
1879
|
+
this.#state = `paused`;
|
|
1880
|
+
if (this.#pausePromise) await this.#pausePromise;
|
|
1881
|
+
if (this.#abortController.signal.aborted) {
|
|
1882
|
+
this.#markClosed();
|
|
1883
|
+
controller.close();
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
resumingFromPause = true;
|
|
1887
|
+
}
|
|
1888
|
+
if (this.#abortController.signal.aborted) {
|
|
1889
|
+
this.#markClosed();
|
|
1890
|
+
controller.close();
|
|
1891
|
+
return;
|
|
1892
|
+
}
|
|
1893
|
+
this.#requestAbortController = new AbortController();
|
|
1894
|
+
const response = await this.#fetchNext(this.offset, this.cursor, this.#requestAbortController.signal, this.upToDate, resumingFromPause);
|
|
1895
|
+
this.#updateStateFromResponse(response);
|
|
1896
|
+
controller.enqueue(response);
|
|
1897
|
+
return;
|
|
1898
|
+
}
|
|
1899
|
+
this.#markClosed();
|
|
1900
|
+
controller.close();
|
|
1901
|
+
} catch (err) {
|
|
1902
|
+
if (this.#requestAbortController?.signal.aborted && this.#requestAbortController.signal.reason === PAUSE_STREAM) {
|
|
1903
|
+
if (this.#state === `pause-requested`) this.#state = `paused`;
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (this.#abortController.signal.aborted) {
|
|
1907
|
+
this.#markClosed();
|
|
1908
|
+
controller.close();
|
|
1909
|
+
} else {
|
|
1910
|
+
this.#markError(err instanceof Error ? err : new Error(String(err)));
|
|
1911
|
+
controller.error(err);
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
},
|
|
1915
|
+
cancel: () => {
|
|
1916
|
+
this.#abortController.abort();
|
|
1917
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
1918
|
+
this.#markClosed();
|
|
1919
|
+
}
|
|
1920
|
+
});
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Get the response stream reader. Can only be called once.
|
|
1924
|
+
*/
|
|
1925
|
+
#getResponseReader() {
|
|
1926
|
+
return this.#responseStream.getReader();
|
|
1927
|
+
}
|
|
1928
|
+
async body() {
|
|
1929
|
+
this.#ensureNoConsumption(`body`);
|
|
1930
|
+
this.#stopAfterUpToDate = true;
|
|
1931
|
+
const reader = this.#getResponseReader();
|
|
1932
|
+
const blobs = [];
|
|
1933
|
+
try {
|
|
1934
|
+
let result = await reader.read();
|
|
1935
|
+
while (!result.done) {
|
|
1936
|
+
const wasUpToDate = this.upToDate;
|
|
1937
|
+
const blob = await result.value.blob();
|
|
1938
|
+
if (blob.size > 0) blobs.push(blob);
|
|
1939
|
+
if (wasUpToDate) break;
|
|
1940
|
+
result = await reader.read();
|
|
1941
|
+
}
|
|
1942
|
+
} finally {
|
|
1943
|
+
reader.releaseLock();
|
|
1944
|
+
}
|
|
1945
|
+
this.#markClosed();
|
|
1946
|
+
if (blobs.length === 0) return /* @__PURE__ */ new Uint8Array(0);
|
|
1947
|
+
if (blobs.length === 1) return new Uint8Array(await blobs[0].arrayBuffer());
|
|
1948
|
+
const combined = new Blob(blobs);
|
|
1949
|
+
return new Uint8Array(await combined.arrayBuffer());
|
|
1950
|
+
}
|
|
1951
|
+
async json() {
|
|
1952
|
+
this.#ensureNoConsumption(`json`);
|
|
1953
|
+
this.#ensureJsonMode();
|
|
1954
|
+
this.#stopAfterUpToDate = true;
|
|
1955
|
+
const reader = this.#getResponseReader();
|
|
1956
|
+
const items = [];
|
|
1957
|
+
try {
|
|
1958
|
+
let result = await reader.read();
|
|
1959
|
+
while (!result.done) {
|
|
1960
|
+
const wasUpToDate = this.upToDate;
|
|
1961
|
+
const content = (await result.value.text()).trim() || `[]`;
|
|
1962
|
+
let parsed;
|
|
1963
|
+
try {
|
|
1964
|
+
parsed = JSON.parse(content);
|
|
1965
|
+
} catch (err) {
|
|
1966
|
+
const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
|
|
1967
|
+
throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
|
|
1968
|
+
}
|
|
1969
|
+
if (Array.isArray(parsed)) items.push(...parsed);
|
|
1970
|
+
else items.push(parsed);
|
|
1971
|
+
if (wasUpToDate) break;
|
|
1972
|
+
result = await reader.read();
|
|
1973
|
+
}
|
|
1974
|
+
} finally {
|
|
1975
|
+
reader.releaseLock();
|
|
1976
|
+
}
|
|
1977
|
+
this.#markClosed();
|
|
1978
|
+
return items;
|
|
1979
|
+
}
|
|
1980
|
+
async text() {
|
|
1981
|
+
this.#ensureNoConsumption(`text`);
|
|
1982
|
+
this.#stopAfterUpToDate = true;
|
|
1983
|
+
const reader = this.#getResponseReader();
|
|
1984
|
+
const parts = [];
|
|
1985
|
+
try {
|
|
1986
|
+
let result = await reader.read();
|
|
1987
|
+
while (!result.done) {
|
|
1988
|
+
const wasUpToDate = this.upToDate;
|
|
1989
|
+
const text = await result.value.text();
|
|
1990
|
+
if (text) parts.push(text);
|
|
1991
|
+
if (wasUpToDate) break;
|
|
1992
|
+
result = await reader.read();
|
|
1993
|
+
}
|
|
1994
|
+
} finally {
|
|
1995
|
+
reader.releaseLock();
|
|
1996
|
+
}
|
|
1997
|
+
this.#markClosed();
|
|
1998
|
+
return parts.join(``);
|
|
1999
|
+
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Internal helper to create the body stream without consumption check.
|
|
2002
|
+
* Used by both bodyStream() and textStream().
|
|
2003
|
+
*/
|
|
2004
|
+
#createBodyStreamInternal() {
|
|
2005
|
+
const { readable, writable } = new TransformStream();
|
|
2006
|
+
const reader = this.#getResponseReader();
|
|
2007
|
+
const pipeBodyStream = async () => {
|
|
2008
|
+
try {
|
|
2009
|
+
let result = await reader.read();
|
|
2010
|
+
while (!result.done) {
|
|
2011
|
+
const wasUpToDate = this.upToDate;
|
|
2012
|
+
const body = result.value.body;
|
|
2013
|
+
if (body) await body.pipeTo(writable, {
|
|
2014
|
+
preventClose: true,
|
|
2015
|
+
preventAbort: true,
|
|
2016
|
+
preventCancel: true
|
|
2017
|
+
});
|
|
2018
|
+
if (wasUpToDate && !this.#shouldContinueLive()) break;
|
|
2019
|
+
result = await reader.read();
|
|
2020
|
+
}
|
|
2021
|
+
await writable.close();
|
|
2022
|
+
this.#markClosed();
|
|
2023
|
+
} catch (err) {
|
|
2024
|
+
if (this.#abortController.signal.aborted) {
|
|
2025
|
+
try {
|
|
2026
|
+
await writable.close();
|
|
2027
|
+
} catch {}
|
|
2028
|
+
this.#markClosed();
|
|
2029
|
+
} else {
|
|
2030
|
+
try {
|
|
2031
|
+
await writable.abort(err);
|
|
2032
|
+
} catch {}
|
|
2033
|
+
this.#markError(err instanceof Error ? err : new Error(String(err)));
|
|
2034
|
+
}
|
|
2035
|
+
} finally {
|
|
2036
|
+
reader.releaseLock();
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
pipeBodyStream();
|
|
2040
|
+
return readable;
|
|
2041
|
+
}
|
|
2042
|
+
bodyStream() {
|
|
2043
|
+
this.#ensureNoConsumption(`bodyStream`);
|
|
2044
|
+
return asAsyncIterableReadableStream(this.#createBodyStreamInternal());
|
|
2045
|
+
}
|
|
2046
|
+
jsonStream() {
|
|
2047
|
+
this.#ensureNoConsumption(`jsonStream`);
|
|
2048
|
+
this.#ensureJsonMode();
|
|
2049
|
+
const reader = this.#getResponseReader();
|
|
2050
|
+
let pendingItems = [];
|
|
2051
|
+
return asAsyncIterableReadableStream(new ReadableStream({
|
|
2052
|
+
pull: async (controller) => {
|
|
2053
|
+
if (pendingItems.length > 0) {
|
|
2054
|
+
controller.enqueue(pendingItems.shift());
|
|
2055
|
+
return;
|
|
2056
|
+
}
|
|
2057
|
+
let result = await reader.read();
|
|
2058
|
+
while (!result.done) {
|
|
2059
|
+
const content = (await result.value.text()).trim() || `[]`;
|
|
2060
|
+
let parsed;
|
|
2061
|
+
try {
|
|
2062
|
+
parsed = JSON.parse(content);
|
|
2063
|
+
} catch (err) {
|
|
2064
|
+
const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
|
|
2065
|
+
throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
|
|
2066
|
+
}
|
|
2067
|
+
pendingItems = Array.isArray(parsed) ? parsed : [parsed];
|
|
2068
|
+
if (pendingItems.length > 0) {
|
|
2069
|
+
controller.enqueue(pendingItems.shift());
|
|
2070
|
+
return;
|
|
2071
|
+
}
|
|
2072
|
+
result = await reader.read();
|
|
2073
|
+
}
|
|
2074
|
+
this.#markClosed();
|
|
2075
|
+
controller.close();
|
|
2076
|
+
},
|
|
2077
|
+
cancel: () => {
|
|
2078
|
+
reader.releaseLock();
|
|
2079
|
+
this.cancel();
|
|
2080
|
+
}
|
|
2081
|
+
}));
|
|
2082
|
+
}
|
|
2083
|
+
textStream() {
|
|
2084
|
+
this.#ensureNoConsumption(`textStream`);
|
|
2085
|
+
const decoder = new TextDecoder();
|
|
2086
|
+
return asAsyncIterableReadableStream(this.#createBodyStreamInternal().pipeThrough(new TransformStream({
|
|
2087
|
+
transform(chunk, controller) {
|
|
2088
|
+
controller.enqueue(decoder.decode(chunk, { stream: true }));
|
|
2089
|
+
},
|
|
2090
|
+
flush(controller) {
|
|
2091
|
+
const remaining = decoder.decode();
|
|
2092
|
+
if (remaining) controller.enqueue(remaining);
|
|
2093
|
+
}
|
|
2094
|
+
})));
|
|
2095
|
+
}
|
|
2096
|
+
subscribeJson(subscriber) {
|
|
2097
|
+
this.#ensureNoConsumption(`subscribeJson`);
|
|
2098
|
+
this.#ensureJsonMode();
|
|
2099
|
+
const abortController = new AbortController();
|
|
2100
|
+
const reader = this.#getResponseReader();
|
|
2101
|
+
const consumeJsonSubscription = async () => {
|
|
2102
|
+
try {
|
|
2103
|
+
let result = await reader.read();
|
|
2104
|
+
while (!result.done) {
|
|
2105
|
+
if (abortController.signal.aborted) break;
|
|
2106
|
+
const response = result.value;
|
|
2107
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
2108
|
+
const content = (await response.text()).trim() || `[]`;
|
|
2109
|
+
let parsed;
|
|
2110
|
+
try {
|
|
2111
|
+
parsed = JSON.parse(content);
|
|
2112
|
+
} catch (err) {
|
|
2113
|
+
const preview = content.length > 100 ? content.slice(0, 100) + `...` : content;
|
|
2114
|
+
throw new DurableStreamError(`Failed to parse JSON response: ${err instanceof Error ? err.message : String(err)}. Data: ${preview}`, `PARSE_ERROR`);
|
|
2115
|
+
}
|
|
2116
|
+
await subscriber({
|
|
2117
|
+
items: Array.isArray(parsed) ? parsed : [parsed],
|
|
2118
|
+
offset,
|
|
2119
|
+
cursor,
|
|
2120
|
+
upToDate,
|
|
2121
|
+
streamClosed
|
|
2122
|
+
});
|
|
2123
|
+
result = await reader.read();
|
|
2124
|
+
}
|
|
2125
|
+
this.#markClosed();
|
|
2126
|
+
} catch (e) {
|
|
2127
|
+
const isAborted = abortController.signal.aborted;
|
|
2128
|
+
const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
|
|
2129
|
+
if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
|
|
2130
|
+
else this.#markClosed();
|
|
2131
|
+
} finally {
|
|
2132
|
+
reader.releaseLock();
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
consumeJsonSubscription();
|
|
2136
|
+
return () => {
|
|
2137
|
+
abortController.abort();
|
|
2138
|
+
this.cancel();
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
subscribeBytes(subscriber) {
|
|
2142
|
+
this.#ensureNoConsumption(`subscribeBytes`);
|
|
2143
|
+
const abortController = new AbortController();
|
|
2144
|
+
const reader = this.#getResponseReader();
|
|
2145
|
+
const consumeBytesSubscription = async () => {
|
|
2146
|
+
try {
|
|
2147
|
+
let result = await reader.read();
|
|
2148
|
+
while (!result.done) {
|
|
2149
|
+
if (abortController.signal.aborted) break;
|
|
2150
|
+
const response = result.value;
|
|
2151
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
2152
|
+
const buffer = await response.arrayBuffer();
|
|
2153
|
+
await subscriber({
|
|
2154
|
+
data: new Uint8Array(buffer),
|
|
2155
|
+
offset,
|
|
2156
|
+
cursor,
|
|
2157
|
+
upToDate,
|
|
2158
|
+
streamClosed
|
|
2159
|
+
});
|
|
2160
|
+
result = await reader.read();
|
|
2161
|
+
}
|
|
2162
|
+
this.#markClosed();
|
|
2163
|
+
} catch (e) {
|
|
2164
|
+
const isAborted = abortController.signal.aborted;
|
|
2165
|
+
const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
|
|
2166
|
+
if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
|
|
2167
|
+
else this.#markClosed();
|
|
2168
|
+
} finally {
|
|
2169
|
+
reader.releaseLock();
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
consumeBytesSubscription();
|
|
2173
|
+
return () => {
|
|
2174
|
+
abortController.abort();
|
|
2175
|
+
this.cancel();
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
subscribeText(subscriber) {
|
|
2179
|
+
this.#ensureNoConsumption(`subscribeText`);
|
|
2180
|
+
const abortController = new AbortController();
|
|
2181
|
+
const reader = this.#getResponseReader();
|
|
2182
|
+
const consumeTextSubscription = async () => {
|
|
2183
|
+
try {
|
|
2184
|
+
let result = await reader.read();
|
|
2185
|
+
while (!result.done) {
|
|
2186
|
+
if (abortController.signal.aborted) break;
|
|
2187
|
+
const response = result.value;
|
|
2188
|
+
const { offset, cursor, upToDate, streamClosed } = getMetadataFromResponse(response, this.offset, this.cursor, this.streamClosed);
|
|
2189
|
+
await subscriber({
|
|
2190
|
+
text: await response.text(),
|
|
2191
|
+
offset,
|
|
2192
|
+
cursor,
|
|
2193
|
+
upToDate,
|
|
2194
|
+
streamClosed
|
|
2195
|
+
});
|
|
2196
|
+
result = await reader.read();
|
|
2197
|
+
}
|
|
2198
|
+
this.#markClosed();
|
|
2199
|
+
} catch (e) {
|
|
2200
|
+
const isAborted = abortController.signal.aborted;
|
|
2201
|
+
const isBodyError = e instanceof TypeError && String(e).includes(`Body`);
|
|
2202
|
+
if (!isAborted && !isBodyError) this.#markError(e instanceof Error ? e : new Error(String(e)));
|
|
2203
|
+
else this.#markClosed();
|
|
2204
|
+
} finally {
|
|
2205
|
+
reader.releaseLock();
|
|
2206
|
+
}
|
|
2207
|
+
};
|
|
2208
|
+
consumeTextSubscription();
|
|
2209
|
+
return () => {
|
|
2210
|
+
abortController.abort();
|
|
2211
|
+
this.cancel();
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
cancel(reason) {
|
|
2215
|
+
this.#abortController.abort(reason);
|
|
2216
|
+
this.#unsubscribeFromVisibilityChanges?.();
|
|
2217
|
+
this.#markClosed();
|
|
2218
|
+
}
|
|
2219
|
+
get closed() {
|
|
2220
|
+
return this.#closed;
|
|
2221
|
+
}
|
|
2222
|
+
};
|
|
2223
|
+
/**
|
|
2224
|
+
* Extract stream metadata from Response headers.
|
|
2225
|
+
* Falls back to the provided defaults when headers are absent.
|
|
2226
|
+
*/
|
|
2227
|
+
function getMetadataFromResponse(response, fallbackOffset, fallbackCursor, fallbackStreamClosed) {
|
|
2228
|
+
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
2229
|
+
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
2230
|
+
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
2231
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2232
|
+
return {
|
|
2233
|
+
offset: offset ?? fallbackOffset,
|
|
2234
|
+
cursor: cursor ?? fallbackCursor,
|
|
2235
|
+
upToDate,
|
|
2236
|
+
streamClosed: streamClosed || fallbackStreamClosed
|
|
2237
|
+
};
|
|
2238
|
+
}
|
|
2239
|
+
/**
|
|
2240
|
+
* Decode base64 string to Uint8Array.
|
|
2241
|
+
* Per protocol: concatenate data lines, remove \n and \r, then decode.
|
|
2242
|
+
*/
|
|
2243
|
+
function decodeBase64(base64Str) {
|
|
2244
|
+
const cleaned = base64Str.replace(/[\n\r]/g, ``);
|
|
2245
|
+
if (cleaned.length === 0) return /* @__PURE__ */ new Uint8Array(0);
|
|
2246
|
+
if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
|
|
2247
|
+
try {
|
|
2248
|
+
if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
|
|
2249
|
+
else {
|
|
2250
|
+
const binaryStr = atob(cleaned);
|
|
2251
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
2252
|
+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
|
2253
|
+
return bytes;
|
|
2254
|
+
}
|
|
2255
|
+
} catch (err) {
|
|
2256
|
+
throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Create a synthetic Response from SSE data with proper headers.
|
|
2261
|
+
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
2262
|
+
*/
|
|
2263
|
+
function createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed, contentType, encoding) {
|
|
2264
|
+
return createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed, contentType, encoding);
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Create a synthetic Response from multiple SSE data parts.
|
|
2268
|
+
* For base64 mode, each part is independently encoded, so we decode each
|
|
2269
|
+
* separately and concatenate the binary results.
|
|
2270
|
+
* For text mode, parts are simply concatenated as strings.
|
|
2271
|
+
*/
|
|
2272
|
+
function createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed, contentType, encoding, isJsonMode) {
|
|
2273
|
+
const headers = {
|
|
2274
|
+
"content-type": contentType ?? `application/json`,
|
|
2275
|
+
[STREAM_OFFSET_HEADER]: String(offset)
|
|
2276
|
+
};
|
|
2277
|
+
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
|
|
2278
|
+
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
|
|
2279
|
+
if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
|
|
2280
|
+
let body;
|
|
2281
|
+
if (encoding === `base64`) {
|
|
2282
|
+
const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => decodeBase64(part));
|
|
2283
|
+
if (decodedParts.length === 0) body = /* @__PURE__ */ new ArrayBuffer(0);
|
|
2284
|
+
else if (decodedParts.length === 1) {
|
|
2285
|
+
const decoded = decodedParts[0];
|
|
2286
|
+
body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
|
|
2287
|
+
} else {
|
|
2288
|
+
const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
|
|
2289
|
+
const combined = new Uint8Array(totalLength);
|
|
2290
|
+
let offset$1 = 0;
|
|
2291
|
+
for (const part of decodedParts) {
|
|
2292
|
+
combined.set(part, offset$1);
|
|
2293
|
+
offset$1 += part.length;
|
|
2294
|
+
}
|
|
2295
|
+
body = combined.buffer;
|
|
2296
|
+
}
|
|
2297
|
+
} else if (isJsonMode) {
|
|
2298
|
+
const mergedParts = [];
|
|
2299
|
+
for (const part of dataParts) {
|
|
2300
|
+
const trimmed = part.trim();
|
|
2301
|
+
if (trimmed.length === 0) continue;
|
|
2302
|
+
if (trimmed.startsWith(`[`) && trimmed.endsWith(`]`)) {
|
|
2303
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
2304
|
+
if (inner.length > 0) mergedParts.push(inner);
|
|
2305
|
+
} else mergedParts.push(trimmed);
|
|
2306
|
+
}
|
|
2307
|
+
body = `[${mergedParts.join(`,`)}]`;
|
|
2308
|
+
} else body = dataParts.join(``);
|
|
2309
|
+
return new Response(body, {
|
|
2310
|
+
status: 200,
|
|
2311
|
+
headers
|
|
2312
|
+
});
|
|
2313
|
+
}
|
|
2314
|
+
/**
|
|
2315
|
+
* Resolve headers from HeadersRecord (supports async functions).
|
|
2316
|
+
* Unified implementation used by both stream() and DurableStream.
|
|
2317
|
+
*/
|
|
2318
|
+
async function resolveHeaders(headers) {
|
|
2319
|
+
const resolved = {};
|
|
2320
|
+
if (!headers) return resolved;
|
|
2321
|
+
for (const [key, value] of Object.entries(headers)) if (typeof value === `function`) resolved[key] = await value();
|
|
2322
|
+
else resolved[key] = value;
|
|
2323
|
+
return resolved;
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Handle error responses from the server.
|
|
2327
|
+
* Throws appropriate DurableStreamError based on status code.
|
|
2328
|
+
*/
|
|
2329
|
+
async function handleErrorResponse(response, url, context) {
|
|
2330
|
+
const status = response.status;
|
|
2331
|
+
if (status === 404) throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404);
|
|
2332
|
+
if (status === 409) {
|
|
2333
|
+
if (response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`) throw new StreamClosedError(url, response.headers.get(STREAM_OFFSET_HEADER) ?? void 0);
|
|
2334
|
+
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);
|
|
2335
|
+
}
|
|
2336
|
+
if (status === 400) throw new DurableStreamError(`Bad request (possibly content-type mismatch)`, `BAD_REQUEST`, 400);
|
|
2337
|
+
throw await DurableStreamError.fromResponse(response, url);
|
|
2338
|
+
}
|
|
2339
|
+
/**
|
|
2340
|
+
* Resolve params from ParamsRecord (supports async functions).
|
|
2341
|
+
*/
|
|
2342
|
+
async function resolveParams(params) {
|
|
2343
|
+
const resolved = {};
|
|
2344
|
+
if (!params) return resolved;
|
|
2345
|
+
for (const [key, value] of Object.entries(params)) if (value !== void 0) if (typeof value === `function`) resolved[key] = await value();
|
|
2346
|
+
else resolved[key] = value;
|
|
2347
|
+
return resolved;
|
|
2348
|
+
}
|
|
2349
|
+
const warnedOrigins = /* @__PURE__ */ new Set();
|
|
2350
|
+
/**
|
|
2351
|
+
* Safely read NODE_ENV without triggering "process is not defined" errors.
|
|
2352
|
+
* Works in both browser and Node.js environments.
|
|
2353
|
+
*/
|
|
2354
|
+
function getNodeEnvSafely() {
|
|
2355
|
+
if (typeof process === `undefined`) return void 0;
|
|
2356
|
+
return process.env?.NODE_ENV;
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Check if we're in a browser environment.
|
|
2360
|
+
*/
|
|
2361
|
+
function isBrowserEnvironment() {
|
|
2362
|
+
return typeof globalThis.window !== `undefined`;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Get window.location.href safely, returning undefined if not available.
|
|
2366
|
+
*/
|
|
2367
|
+
function getWindowLocationHref() {
|
|
2368
|
+
if (typeof globalThis.window !== `undefined` && typeof globalThis.window.location !== `undefined`) return globalThis.window.location.href;
|
|
2369
|
+
}
|
|
2370
|
+
/**
|
|
2371
|
+
* Resolve a URL string, handling relative URLs in browser environments.
|
|
2372
|
+
* Returns undefined if the URL cannot be parsed.
|
|
2373
|
+
*/
|
|
2374
|
+
function resolveUrlMaybe(urlString) {
|
|
2375
|
+
try {
|
|
2376
|
+
return new URL(urlString);
|
|
2377
|
+
} catch {
|
|
2378
|
+
const base = getWindowLocationHref();
|
|
2379
|
+
if (base) try {
|
|
2380
|
+
return new URL(urlString, base);
|
|
2381
|
+
} catch {
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
return;
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Warn if using HTTP (not HTTPS) URL in a browser environment.
|
|
2389
|
+
* HTTP typically limits browsers to ~6 concurrent connections per origin under HTTP/1.1,
|
|
2390
|
+
* which can cause slow streams and app freezes with multiple active streams.
|
|
2391
|
+
*
|
|
2392
|
+
* Features:
|
|
2393
|
+
* - Warns only once per origin to prevent log spam
|
|
2394
|
+
* - Handles relative URLs by resolving against window.location.href
|
|
2395
|
+
* - Safe to call in Node.js environments (no-op)
|
|
2396
|
+
* - Skips warning during tests (NODE_ENV=test)
|
|
2397
|
+
*/
|
|
2398
|
+
function warnIfUsingHttpInBrowser(url, warnOnHttp) {
|
|
2399
|
+
if (warnOnHttp === false) return;
|
|
2400
|
+
if (getNodeEnvSafely() === `test`) return;
|
|
2401
|
+
if (!isBrowserEnvironment() || typeof console === `undefined` || typeof console.warn !== `function`) return;
|
|
2402
|
+
const parsedUrl = resolveUrlMaybe(url instanceof URL ? url.toString() : url);
|
|
2403
|
+
if (!parsedUrl) return;
|
|
2404
|
+
if (parsedUrl.protocol === `http:`) {
|
|
2405
|
+
if (!warnedOrigins.has(parsedUrl.origin)) {
|
|
2406
|
+
warnedOrigins.add(parsedUrl.origin);
|
|
2407
|
+
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.");
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
/**
|
|
2412
|
+
* Create a streaming session to read from a durable stream.
|
|
2413
|
+
*
|
|
2414
|
+
* This is a fetch-like API:
|
|
2415
|
+
* - The promise resolves after the first network request succeeds
|
|
2416
|
+
* - It rejects for auth/404/other protocol errors
|
|
2417
|
+
* - Returns a StreamResponse for consuming the data
|
|
2418
|
+
*
|
|
2419
|
+
* @example
|
|
2420
|
+
* ```typescript
|
|
2421
|
+
* // Catch-up JSON:
|
|
2422
|
+
* const res = await stream<{ message: string }>({
|
|
2423
|
+
* url,
|
|
2424
|
+
* auth,
|
|
2425
|
+
* offset: "0",
|
|
2426
|
+
* live: false,
|
|
2427
|
+
* })
|
|
2428
|
+
* const items = await res.json()
|
|
2429
|
+
*
|
|
2430
|
+
* // Live JSON:
|
|
2431
|
+
* const live = await stream<{ message: string }>({
|
|
2432
|
+
* url,
|
|
2433
|
+
* auth,
|
|
2434
|
+
* offset: savedOffset,
|
|
2435
|
+
* live: true,
|
|
2436
|
+
* })
|
|
2437
|
+
* live.subscribeJson(async (batch) => {
|
|
2438
|
+
* for (const item of batch.items) {
|
|
2439
|
+
* handle(item)
|
|
2440
|
+
* }
|
|
2441
|
+
* })
|
|
2442
|
+
* ```
|
|
2443
|
+
*/
|
|
2444
|
+
async function stream(options) {
|
|
2445
|
+
if (!options.url) throw new DurableStreamError(`Invalid stream options: missing required url parameter`, `BAD_REQUEST`);
|
|
2446
|
+
let currentHeaders = options.headers;
|
|
2447
|
+
let currentParams = options.params;
|
|
2448
|
+
while (true) try {
|
|
2449
|
+
return await streamInternal({
|
|
2450
|
+
...options,
|
|
2451
|
+
headers: currentHeaders,
|
|
2452
|
+
params: currentParams
|
|
2453
|
+
});
|
|
2454
|
+
} catch (err) {
|
|
2455
|
+
if (options.onError) {
|
|
2456
|
+
const retryOpts = await options.onError(err instanceof Error ? err : new Error(String(err)));
|
|
2457
|
+
if (retryOpts === void 0) throw err;
|
|
2458
|
+
if (retryOpts.params) currentParams = {
|
|
2459
|
+
...currentParams,
|
|
2460
|
+
...retryOpts.params
|
|
2461
|
+
};
|
|
2462
|
+
if (retryOpts.headers) currentHeaders = {
|
|
2463
|
+
...currentHeaders,
|
|
2464
|
+
...retryOpts.headers
|
|
2465
|
+
};
|
|
2466
|
+
continue;
|
|
2467
|
+
}
|
|
2468
|
+
throw err;
|
|
2469
|
+
}
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Internal implementation of stream that doesn't handle onError retries.
|
|
2473
|
+
*/
|
|
2474
|
+
async function streamInternal(options) {
|
|
2475
|
+
const url = options.url instanceof URL ? options.url.toString() : options.url;
|
|
2476
|
+
warnIfUsingHttpInBrowser(url, options.warnOnHttp);
|
|
2477
|
+
const fetchUrl = new URL(url);
|
|
2478
|
+
const startOffset = options.offset ?? `-1`;
|
|
2479
|
+
fetchUrl.searchParams.set(OFFSET_QUERY_PARAM, startOffset);
|
|
2480
|
+
const live = options.live ?? true;
|
|
2481
|
+
const params = await resolveParams(options.params);
|
|
2482
|
+
for (const [key, value] of Object.entries(params)) fetchUrl.searchParams.set(key, value);
|
|
2483
|
+
const headers = await resolveHeaders(options.headers);
|
|
2484
|
+
const abortController = new AbortController();
|
|
2485
|
+
if (options.signal) options.signal.addEventListener(`abort`, () => abortController.abort(options.signal?.reason), { once: true });
|
|
2486
|
+
const fetchClient = createFetchWithBackoff(options.fetch ?? ((...args) => fetch(...args)), options.backoffOptions ?? BackoffDefaults);
|
|
2487
|
+
let firstResponse;
|
|
2488
|
+
try {
|
|
2489
|
+
firstResponse = await fetchClient(fetchUrl.toString(), {
|
|
2490
|
+
method: `GET`,
|
|
2491
|
+
headers,
|
|
2492
|
+
signal: abortController.signal
|
|
2493
|
+
});
|
|
2494
|
+
} catch (err) {
|
|
2495
|
+
if (err instanceof FetchBackoffAbortError) throw new DurableStreamError(`Stream request was aborted`, `UNKNOWN`);
|
|
2496
|
+
throw err;
|
|
2497
|
+
}
|
|
2498
|
+
const contentType = firstResponse.headers.get(`content-type`) ?? void 0;
|
|
2499
|
+
const initialOffset = firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset;
|
|
2500
|
+
const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
|
|
2501
|
+
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
2502
|
+
const initialStreamClosed = firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2503
|
+
const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
|
|
2504
|
+
const encoding = firstResponse.headers.get(STREAM_SSE_DATA_ENCODING_HEADER) === `base64` ? `base64` : void 0;
|
|
2505
|
+
const fetchNext = async (offset, cursor, signal, upToDate, resumingFromPause) => {
|
|
2506
|
+
const nextUrl = new URL(url);
|
|
2507
|
+
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
|
|
2508
|
+
if (upToDate && !resumingFromPause) {
|
|
2509
|
+
if (live === true || live === `long-poll`) nextUrl.searchParams.set(LIVE_QUERY_PARAM, `long-poll`);
|
|
2510
|
+
}
|
|
2511
|
+
if (cursor) nextUrl.searchParams.set(`cursor`, cursor);
|
|
2512
|
+
const nextParams = await resolveParams(options.params);
|
|
2513
|
+
for (const [key, value] of Object.entries(nextParams)) nextUrl.searchParams.set(key, value);
|
|
2514
|
+
const nextHeaders = await resolveHeaders(options.headers);
|
|
2515
|
+
const response = await fetchClient(nextUrl.toString(), {
|
|
2516
|
+
method: `GET`,
|
|
2517
|
+
headers: nextHeaders,
|
|
2518
|
+
signal
|
|
2519
|
+
});
|
|
2520
|
+
if (!response.ok) await handleErrorResponse(response, url);
|
|
2521
|
+
return response;
|
|
2522
|
+
};
|
|
2523
|
+
return new StreamResponseImpl({
|
|
2524
|
+
url,
|
|
2525
|
+
contentType,
|
|
2526
|
+
live,
|
|
2527
|
+
startOffset,
|
|
2528
|
+
isJsonMode,
|
|
2529
|
+
initialOffset,
|
|
2530
|
+
initialCursor,
|
|
2531
|
+
initialUpToDate,
|
|
2532
|
+
initialStreamClosed,
|
|
2533
|
+
firstResponse,
|
|
2534
|
+
abortController,
|
|
2535
|
+
fetchNext,
|
|
2536
|
+
startSSE: live === `sse` ? async (offset, cursor, signal) => {
|
|
2537
|
+
const sseUrl = new URL(url);
|
|
2538
|
+
sseUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
|
|
2539
|
+
sseUrl.searchParams.set(LIVE_QUERY_PARAM, `sse`);
|
|
2540
|
+
if (cursor) sseUrl.searchParams.set(`cursor`, cursor);
|
|
2541
|
+
const sseParams = await resolveParams(options.params);
|
|
2542
|
+
for (const [key, value] of Object.entries(sseParams)) sseUrl.searchParams.set(key, value);
|
|
2543
|
+
const sseHeaders = await resolveHeaders(options.headers);
|
|
2544
|
+
const response = await fetchClient(sseUrl.toString(), {
|
|
2545
|
+
method: `GET`,
|
|
2546
|
+
headers: sseHeaders,
|
|
2547
|
+
signal
|
|
2548
|
+
});
|
|
2549
|
+
if (!response.ok) await handleErrorResponse(response, url);
|
|
2550
|
+
return response;
|
|
2551
|
+
} : void 0,
|
|
2552
|
+
sseResilience: options.sseResilience,
|
|
2553
|
+
encoding
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* Error thrown when a producer's epoch is stale (zombie fencing).
|
|
2558
|
+
*/
|
|
2559
|
+
var StaleEpochError = class extends Error {
|
|
2560
|
+
/**
|
|
2561
|
+
* The current epoch on the server.
|
|
2562
|
+
*/
|
|
2563
|
+
currentEpoch;
|
|
2564
|
+
constructor(currentEpoch) {
|
|
2565
|
+
super(`Producer epoch is stale. Current server epoch: ${currentEpoch}. Call restart() or create a new producer with a higher epoch.`);
|
|
2566
|
+
this.name = `StaleEpochError`;
|
|
2567
|
+
this.currentEpoch = currentEpoch;
|
|
2568
|
+
}
|
|
2569
|
+
};
|
|
2570
|
+
/**
|
|
2571
|
+
* Error thrown when an unrecoverable sequence gap is detected.
|
|
2572
|
+
*
|
|
2573
|
+
* With maxInFlight > 1, HTTP requests can arrive out of order at the server,
|
|
2574
|
+
* causing temporary 409 responses. The client automatically handles these
|
|
2575
|
+
* by waiting for earlier sequences to complete, then retrying.
|
|
2576
|
+
*
|
|
2577
|
+
* This error is only thrown when the gap cannot be resolved (e.g., the
|
|
2578
|
+
* expected sequence is >= our sequence, indicating a true protocol violation).
|
|
2579
|
+
*/
|
|
2580
|
+
var SequenceGapError = class extends Error {
|
|
2581
|
+
expectedSeq;
|
|
2582
|
+
receivedSeq;
|
|
2583
|
+
constructor(expectedSeq, receivedSeq) {
|
|
2584
|
+
super(`Producer sequence gap: expected ${expectedSeq}, received ${receivedSeq}`);
|
|
2585
|
+
this.name = `SequenceGapError`;
|
|
2586
|
+
this.expectedSeq = expectedSeq;
|
|
2587
|
+
this.receivedSeq = receivedSeq;
|
|
2588
|
+
}
|
|
2589
|
+
};
|
|
2590
|
+
/**
|
|
2591
|
+
* Normalize content-type by extracting the media type (before any semicolon).
|
|
2592
|
+
*/
|
|
2593
|
+
function normalizeContentType$1(contentType) {
|
|
2594
|
+
if (!contentType) return ``;
|
|
2595
|
+
return contentType.split(`;`)[0].trim().toLowerCase();
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* An idempotent producer for exactly-once writes to a durable stream.
|
|
2599
|
+
*
|
|
2600
|
+
* Features:
|
|
2601
|
+
* - Fire-and-forget: append() returns immediately, batches in background
|
|
2602
|
+
* - Exactly-once: server deduplicates using (producerId, epoch, seq)
|
|
2603
|
+
* - Batching: multiple appends batched into single HTTP request
|
|
2604
|
+
* - Pipelining: up to maxInFlight concurrent batches
|
|
2605
|
+
* - Zombie fencing: stale producers rejected via epoch validation
|
|
2606
|
+
*
|
|
2607
|
+
* @example
|
|
2608
|
+
* ```typescript
|
|
2609
|
+
* const stream = new DurableStream({ url: "https://..." });
|
|
2610
|
+
* const producer = new IdempotentProducer(stream, "order-service-1", {
|
|
2611
|
+
* epoch: 0,
|
|
2612
|
+
* autoClaim: true,
|
|
2613
|
+
* });
|
|
2614
|
+
*
|
|
2615
|
+
* // Fire-and-forget writes (synchronous, returns immediately)
|
|
2616
|
+
* producer.append("message 1");
|
|
2617
|
+
* producer.append("message 2");
|
|
2618
|
+
*
|
|
2619
|
+
* // Ensure all messages are delivered before shutdown
|
|
2620
|
+
* await producer.flush();
|
|
2621
|
+
* await producer.close();
|
|
2622
|
+
* ```
|
|
2623
|
+
*/
|
|
2624
|
+
var IdempotentProducer = class {
|
|
2625
|
+
#stream;
|
|
2626
|
+
#producerId;
|
|
2627
|
+
#epoch;
|
|
2628
|
+
#nextSeq = 0;
|
|
2629
|
+
#autoClaim;
|
|
2630
|
+
#maxBatchBytes;
|
|
2631
|
+
#lingerMs;
|
|
2632
|
+
#fetchClient;
|
|
2633
|
+
#headers;
|
|
2634
|
+
#signal;
|
|
2635
|
+
#onError;
|
|
2636
|
+
#pendingBatch = [];
|
|
2637
|
+
#batchBytes = 0;
|
|
2638
|
+
#lingerTimeout = null;
|
|
2639
|
+
#queue;
|
|
2640
|
+
#maxInFlight;
|
|
2641
|
+
#deferredEnqueues = /* @__PURE__ */ new Set();
|
|
2642
|
+
#closed = false;
|
|
2643
|
+
#closeResult = null;
|
|
2644
|
+
#pendingFinalMessage;
|
|
2645
|
+
#lastSuccessfulOffset;
|
|
2646
|
+
#epochClaimed;
|
|
2647
|
+
#seqState = /* @__PURE__ */ new Map();
|
|
2648
|
+
/**
|
|
2649
|
+
* Create an idempotent producer for a stream.
|
|
2650
|
+
*
|
|
2651
|
+
* @param stream - The DurableStream to write to
|
|
2652
|
+
* @param producerId - Stable identifier for this producer (e.g., "order-service-1")
|
|
2653
|
+
* @param opts - Producer options
|
|
2654
|
+
*/
|
|
2655
|
+
constructor(stream$1, producerId, opts) {
|
|
2656
|
+
const epoch = opts?.epoch ?? 0;
|
|
2657
|
+
const maxBatchBytes = opts?.maxBatchBytes ?? 1024 * 1024;
|
|
2658
|
+
const maxInFlight = opts?.maxInFlight ?? 5;
|
|
2659
|
+
const lingerMs = opts?.lingerMs ?? 5;
|
|
2660
|
+
if (epoch < 0) throw new Error(`epoch must be >= 0`);
|
|
2661
|
+
if (maxBatchBytes <= 0) throw new Error(`maxBatchBytes must be > 0`);
|
|
2662
|
+
if (maxInFlight <= 0) throw new Error(`maxInFlight must be > 0`);
|
|
2663
|
+
if (lingerMs < 0) throw new Error(`lingerMs must be >= 0`);
|
|
2664
|
+
this.#stream = stream$1;
|
|
2665
|
+
this.#producerId = producerId;
|
|
2666
|
+
this.#epoch = epoch;
|
|
2667
|
+
this.#autoClaim = opts?.autoClaim ?? false;
|
|
2668
|
+
this.#maxBatchBytes = maxBatchBytes;
|
|
2669
|
+
this.#lingerMs = lingerMs;
|
|
2670
|
+
this.#signal = opts?.signal;
|
|
2671
|
+
this.#headers = opts?.headers;
|
|
2672
|
+
this.#onError = opts?.onError;
|
|
2673
|
+
this.#fetchClient = opts?.fetch ?? ((...args) => fetch(...args));
|
|
2674
|
+
this.#maxInFlight = maxInFlight;
|
|
2675
|
+
this.#epochClaimed = !this.#autoClaim;
|
|
2676
|
+
this.#queue = import_queue.default.promise(this.#batchWorker.bind(this), this.#maxInFlight);
|
|
2677
|
+
if (this.#signal) this.#signal.addEventListener(`abort`, () => {
|
|
2678
|
+
this.#rejectPendingBatch(new DurableStreamError(`Producer aborted`, `ALREADY_CLOSED`, void 0, void 0));
|
|
2679
|
+
}, { once: true });
|
|
2680
|
+
}
|
|
2681
|
+
/**
|
|
2682
|
+
* Append data to the stream.
|
|
2683
|
+
*
|
|
2684
|
+
* This is fire-and-forget: returns immediately after adding to the batch.
|
|
2685
|
+
* The message is batched and sent when:
|
|
2686
|
+
* - maxBatchBytes is reached
|
|
2687
|
+
* - lingerMs elapses
|
|
2688
|
+
* - flush() is called
|
|
2689
|
+
*
|
|
2690
|
+
* Errors are reported via onError callback if configured. Use flush() to
|
|
2691
|
+
* wait for all pending messages to be sent.
|
|
2692
|
+
*
|
|
2693
|
+
* For JSON streams, pass pre-serialized JSON strings.
|
|
2694
|
+
* For byte streams, pass string or Uint8Array.
|
|
2695
|
+
*
|
|
2696
|
+
* @param body - Data to append (string or Uint8Array)
|
|
2697
|
+
*
|
|
2698
|
+
* @example
|
|
2699
|
+
* ```typescript
|
|
2700
|
+
* // JSON stream
|
|
2701
|
+
* producer.append(JSON.stringify({ message: "hello" }));
|
|
2702
|
+
*
|
|
2703
|
+
* // Byte stream
|
|
2704
|
+
* producer.append("raw text data");
|
|
2705
|
+
* producer.append(new Uint8Array([1, 2, 3]));
|
|
2706
|
+
* ```
|
|
2707
|
+
*/
|
|
2708
|
+
append(body) {
|
|
2709
|
+
if (this.#closed) throw new DurableStreamError(`Producer is closed`, `ALREADY_CLOSED`, void 0, void 0);
|
|
2710
|
+
let bytes;
|
|
2711
|
+
if (typeof body === `string`) bytes = new TextEncoder().encode(body);
|
|
2712
|
+
else if (body instanceof Uint8Array) bytes = body;
|
|
2713
|
+
else throw new DurableStreamError(`append() requires string or Uint8Array. For objects, use JSON.stringify().`, `BAD_REQUEST`, 400, void 0);
|
|
2714
|
+
this.#pendingBatch.push({ body: bytes });
|
|
2715
|
+
this.#batchBytes += bytes.length;
|
|
2716
|
+
if (this.#batchBytes >= this.#maxBatchBytes) this.#enqueuePendingBatch();
|
|
2717
|
+
else if (!this.#lingerTimeout) this.#lingerTimeout = setTimeout(() => {
|
|
2718
|
+
this.#lingerTimeout = null;
|
|
2719
|
+
if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
|
|
2720
|
+
}, this.#lingerMs);
|
|
2721
|
+
}
|
|
2722
|
+
/**
|
|
2723
|
+
* Send any pending batch immediately and wait for all in-flight batches.
|
|
2724
|
+
*
|
|
2725
|
+
* Call this before shutdown to ensure all messages are delivered.
|
|
2726
|
+
*/
|
|
2727
|
+
async flush() {
|
|
2728
|
+
if (this.#lingerTimeout) {
|
|
2729
|
+
clearTimeout(this.#lingerTimeout);
|
|
2730
|
+
this.#lingerTimeout = null;
|
|
2731
|
+
}
|
|
2732
|
+
if (this.#pendingBatch.length > 0) this.#enqueuePendingBatch();
|
|
2733
|
+
do {
|
|
2734
|
+
await this.#queue.drained();
|
|
2735
|
+
await Promise.all(this.#deferredEnqueues);
|
|
2736
|
+
} while (this.#deferredEnqueues.size > 0 || this.inFlightCount > 0);
|
|
2737
|
+
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Stop the producer without closing the underlying stream.
|
|
2740
|
+
*
|
|
2741
|
+
* Use this when you want to:
|
|
2742
|
+
* - Hand off writing to another producer
|
|
2743
|
+
* - Keep the stream open for future writes
|
|
2744
|
+
* - Stop this producer but not signal EOF to readers
|
|
2745
|
+
*
|
|
2746
|
+
* Flushes any pending messages before detaching.
|
|
2747
|
+
* After calling detach(), further append() calls will throw.
|
|
2748
|
+
*/
|
|
2749
|
+
async detach() {
|
|
2750
|
+
if (this.#closed) return;
|
|
2751
|
+
this.#closed = true;
|
|
2752
|
+
try {
|
|
2753
|
+
await this.flush();
|
|
2754
|
+
} catch {}
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Flush pending messages and close the underlying stream (EOF).
|
|
2758
|
+
*
|
|
2759
|
+
* This is the typical way to end a producer session. It:
|
|
2760
|
+
* 1. Flushes all pending messages
|
|
2761
|
+
* 2. Optionally appends a final message
|
|
2762
|
+
* 3. Closes the stream (no further appends permitted)
|
|
2763
|
+
*
|
|
2764
|
+
* **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
|
|
2765
|
+
* idempotent even with a final message because it uses producer headers
|
|
2766
|
+
* for deduplication. Safe to retry on network failures.
|
|
2767
|
+
*
|
|
2768
|
+
* @param finalMessage - Optional final message to append atomically with close
|
|
2769
|
+
* @returns CloseResult with the final offset
|
|
2770
|
+
*/
|
|
2771
|
+
async close(finalMessage) {
|
|
2772
|
+
if (this.#closed) {
|
|
2773
|
+
if (this.#closeResult) return this.#closeResult;
|
|
2774
|
+
await this.flush();
|
|
2775
|
+
const result$1 = await this.#doClose(this.#pendingFinalMessage);
|
|
2776
|
+
this.#closeResult = result$1;
|
|
2777
|
+
return result$1;
|
|
2778
|
+
}
|
|
2779
|
+
this.#closed = true;
|
|
2780
|
+
this.#pendingFinalMessage = finalMessage;
|
|
2781
|
+
await this.flush();
|
|
2782
|
+
const result = await this.#doClose(finalMessage);
|
|
2783
|
+
this.#closeResult = result;
|
|
2784
|
+
return result;
|
|
2785
|
+
}
|
|
2786
|
+
/**
|
|
2787
|
+
* Actually close the stream with optional final message.
|
|
2788
|
+
* Uses producer headers for idempotency.
|
|
2789
|
+
*/
|
|
2790
|
+
async #doClose(finalMessage) {
|
|
2791
|
+
const contentType = this.#stream.contentType ?? `application/octet-stream`;
|
|
2792
|
+
const isJson = normalizeContentType$1(contentType) === `application/json`;
|
|
2793
|
+
let body;
|
|
2794
|
+
if (finalMessage !== void 0) {
|
|
2795
|
+
const bodyBytes = typeof finalMessage === `string` ? new TextEncoder().encode(finalMessage) : finalMessage;
|
|
2796
|
+
if (isJson) body = `[${new TextDecoder().decode(bodyBytes)}]`;
|
|
2797
|
+
else body = bodyBytes;
|
|
2798
|
+
}
|
|
2799
|
+
const seqForThisRequest = this.#nextSeq;
|
|
2800
|
+
const headers = await this.#buildHeaders({
|
|
2801
|
+
"content-type": contentType,
|
|
2802
|
+
[PRODUCER_ID_HEADER]: this.#producerId,
|
|
2803
|
+
[PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
|
|
2804
|
+
[PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
|
|
2805
|
+
[STREAM_CLOSED_HEADER]: `true`
|
|
2806
|
+
});
|
|
2807
|
+
const response = await this.#fetchClient(this.#stream.url, {
|
|
2808
|
+
method: `POST`,
|
|
2809
|
+
headers,
|
|
2810
|
+
body,
|
|
2811
|
+
signal: this.#signal
|
|
2812
|
+
});
|
|
2813
|
+
if (response.status === 204) {
|
|
2814
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
2815
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
2816
|
+
this.#recordSuccessfulOffset(finalOffset);
|
|
2817
|
+
return { finalOffset };
|
|
2818
|
+
}
|
|
2819
|
+
if (response.status === 200) {
|
|
2820
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
2821
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
2822
|
+
this.#recordSuccessfulOffset(finalOffset);
|
|
2823
|
+
return { finalOffset };
|
|
2824
|
+
}
|
|
2825
|
+
if (response.status === 403) {
|
|
2826
|
+
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
|
|
2827
|
+
const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : this.#epoch;
|
|
2828
|
+
if (this.#autoClaim) {
|
|
2829
|
+
const newEpoch = currentEpoch + 1;
|
|
2830
|
+
this.#epoch = newEpoch;
|
|
2831
|
+
this.#nextSeq = 0;
|
|
2832
|
+
return this.#doClose(finalMessage);
|
|
2833
|
+
}
|
|
2834
|
+
throw new StaleEpochError(currentEpoch);
|
|
2835
|
+
}
|
|
2836
|
+
throw await FetchError.fromResponse(response, this.#stream.url);
|
|
2837
|
+
}
|
|
2838
|
+
/**
|
|
2839
|
+
* Increment epoch and reset sequence.
|
|
2840
|
+
*
|
|
2841
|
+
* Call this when restarting the producer to establish a new session.
|
|
2842
|
+
* Flushes any pending messages first.
|
|
2843
|
+
*/
|
|
2844
|
+
async restart() {
|
|
2845
|
+
await this.flush();
|
|
2846
|
+
this.#epoch++;
|
|
2847
|
+
this.#nextSeq = 0;
|
|
2848
|
+
}
|
|
2849
|
+
/**
|
|
2850
|
+
* Current epoch for this producer.
|
|
2851
|
+
*/
|
|
2852
|
+
get epoch() {
|
|
2853
|
+
return this.#epoch;
|
|
2854
|
+
}
|
|
2855
|
+
/**
|
|
2856
|
+
* Next sequence number to be assigned.
|
|
2857
|
+
*/
|
|
2858
|
+
get nextSeq() {
|
|
2859
|
+
return this.#nextSeq;
|
|
2860
|
+
}
|
|
2861
|
+
/**
|
|
2862
|
+
* Number of messages in the current pending batch.
|
|
2863
|
+
*/
|
|
2864
|
+
get pendingCount() {
|
|
2865
|
+
return this.#pendingBatch.length;
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2868
|
+
* Number of batches currently in flight.
|
|
2869
|
+
*/
|
|
2870
|
+
get inFlightCount() {
|
|
2871
|
+
return this.#queue.length() + this.#queue.running();
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* The greatest non-empty stream offset returned by a successful producer
|
|
2875
|
+
* append or close request.
|
|
2876
|
+
*/
|
|
2877
|
+
get lastSuccessfulOffset() {
|
|
2878
|
+
return this.#lastSuccessfulOffset;
|
|
2879
|
+
}
|
|
2880
|
+
/**
|
|
2881
|
+
* Enqueue the current pending batch for processing.
|
|
2882
|
+
*/
|
|
2883
|
+
#enqueuePendingBatch() {
|
|
2884
|
+
if (this.#pendingBatch.length === 0) return;
|
|
2885
|
+
const batch = this.#pendingBatch;
|
|
2886
|
+
this.#pendingBatch = [];
|
|
2887
|
+
this.#batchBytes = 0;
|
|
2888
|
+
if (this.#autoClaim && !this.#epochClaimed && this.inFlightCount > 0) {
|
|
2889
|
+
const deferred = this.#queue.drained().then(() => {
|
|
2890
|
+
this.#pushBatch(batch);
|
|
2891
|
+
}).finally(() => {
|
|
2892
|
+
this.#deferredEnqueues.delete(deferred);
|
|
2893
|
+
});
|
|
2894
|
+
this.#deferredEnqueues.add(deferred);
|
|
2895
|
+
deferred.catch(() => {});
|
|
2896
|
+
} else this.#pushBatch(batch);
|
|
2897
|
+
}
|
|
2898
|
+
#pushBatch(batch) {
|
|
2899
|
+
const seq = this.#nextSeq;
|
|
2900
|
+
this.#nextSeq++;
|
|
2901
|
+
this.#queue.push({
|
|
2902
|
+
batch,
|
|
2903
|
+
seq
|
|
2904
|
+
}).catch(() => {});
|
|
2905
|
+
}
|
|
2906
|
+
/**
|
|
2907
|
+
* Batch worker - processes batches via fastq.
|
|
2908
|
+
*/
|
|
2909
|
+
async #batchWorker(task) {
|
|
2910
|
+
const { batch, seq } = task;
|
|
2911
|
+
const epoch = this.#epoch;
|
|
2912
|
+
try {
|
|
2913
|
+
const result = await this.#doSendBatch(batch, seq, epoch);
|
|
2914
|
+
this.#recordSuccessfulOffset(result.offset);
|
|
2915
|
+
if (!this.#epochClaimed) this.#epochClaimed = true;
|
|
2916
|
+
this.#signalSeqComplete(epoch, seq, void 0);
|
|
2917
|
+
} catch (error) {
|
|
2918
|
+
this.#signalSeqComplete(epoch, seq, error);
|
|
2919
|
+
if (this.#onError) this.#onError(error);
|
|
2920
|
+
throw error;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
#recordSuccessfulOffset(offset) {
|
|
2924
|
+
if (offset && (!this.#lastSuccessfulOffset || offset > this.#lastSuccessfulOffset)) this.#lastSuccessfulOffset = offset;
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* Signal that a sequence has completed (success or failure).
|
|
2928
|
+
*/
|
|
2929
|
+
#signalSeqComplete(epoch, seq, error) {
|
|
2930
|
+
let epochMap = this.#seqState.get(epoch);
|
|
2931
|
+
if (!epochMap) {
|
|
2932
|
+
epochMap = /* @__PURE__ */ new Map();
|
|
2933
|
+
this.#seqState.set(epoch, epochMap);
|
|
2934
|
+
}
|
|
2935
|
+
const state = epochMap.get(seq);
|
|
2936
|
+
if (state) {
|
|
2937
|
+
state.resolved = true;
|
|
2938
|
+
state.error = error;
|
|
2939
|
+
for (const waiter of state.waiters) waiter(error);
|
|
2940
|
+
state.waiters = [];
|
|
2941
|
+
} else epochMap.set(seq, {
|
|
2942
|
+
resolved: true,
|
|
2943
|
+
error,
|
|
2944
|
+
waiters: []
|
|
2945
|
+
});
|
|
2946
|
+
const cleanupThreshold = seq - this.#maxInFlight * 3;
|
|
2947
|
+
if (cleanupThreshold > 0) {
|
|
2948
|
+
for (const oldSeq of epochMap.keys()) if (oldSeq < cleanupThreshold) epochMap.delete(oldSeq);
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
/**
|
|
2952
|
+
* Wait for a specific sequence to complete.
|
|
2953
|
+
* Returns immediately if already completed.
|
|
2954
|
+
* Throws if the sequence failed.
|
|
2955
|
+
*/
|
|
2956
|
+
#waitForSeq(epoch, seq) {
|
|
2957
|
+
let epochMap = this.#seqState.get(epoch);
|
|
2958
|
+
if (!epochMap) {
|
|
2959
|
+
epochMap = /* @__PURE__ */ new Map();
|
|
2960
|
+
this.#seqState.set(epoch, epochMap);
|
|
2961
|
+
}
|
|
2962
|
+
const state = epochMap.get(seq);
|
|
2963
|
+
if (state?.resolved) {
|
|
2964
|
+
if (state.error) return Promise.reject(state.error);
|
|
2965
|
+
return Promise.resolve();
|
|
2966
|
+
}
|
|
2967
|
+
return new Promise((resolve, reject) => {
|
|
2968
|
+
const waiter = (err) => {
|
|
2969
|
+
if (err) reject(err);
|
|
2970
|
+
else resolve();
|
|
2971
|
+
};
|
|
2972
|
+
if (state) state.waiters.push(waiter);
|
|
2973
|
+
else epochMap.set(seq, {
|
|
2974
|
+
resolved: false,
|
|
2975
|
+
waiters: [waiter]
|
|
2976
|
+
});
|
|
2977
|
+
});
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Actually send the batch to the server.
|
|
2981
|
+
* Handles auto-claim retry on 403 (stale epoch) if autoClaim is enabled.
|
|
2982
|
+
* Does NOT implement general retry/backoff for network errors or 5xx responses.
|
|
2983
|
+
*/
|
|
2984
|
+
async #doSendBatch(batch, seq, epoch) {
|
|
2985
|
+
const contentType = this.#stream.contentType ?? `application/octet-stream`;
|
|
2986
|
+
const isJson = normalizeContentType$1(contentType) === `application/json`;
|
|
2987
|
+
let batchedBody;
|
|
2988
|
+
if (isJson) batchedBody = `[${batch.map((e) => new TextDecoder().decode(e.body)).join(`,`)}]`;
|
|
2989
|
+
else {
|
|
2990
|
+
const totalSize = batch.reduce((sum, e) => sum + e.body.length, 0);
|
|
2991
|
+
const concatenated = new Uint8Array(totalSize);
|
|
2992
|
+
let offset = 0;
|
|
2993
|
+
for (const entry of batch) {
|
|
2994
|
+
concatenated.set(entry.body, offset);
|
|
2995
|
+
offset += entry.body.length;
|
|
2996
|
+
}
|
|
2997
|
+
batchedBody = concatenated;
|
|
2998
|
+
}
|
|
2999
|
+
const url = this.#stream.url;
|
|
3000
|
+
const headers = await this.#buildHeaders({
|
|
3001
|
+
"content-type": contentType,
|
|
3002
|
+
[PRODUCER_ID_HEADER]: this.#producerId,
|
|
3003
|
+
[PRODUCER_EPOCH_HEADER]: epoch.toString(),
|
|
3004
|
+
[PRODUCER_SEQ_HEADER]: seq.toString()
|
|
3005
|
+
});
|
|
3006
|
+
const response = await this.#fetchClient(url, {
|
|
3007
|
+
method: `POST`,
|
|
3008
|
+
headers,
|
|
3009
|
+
body: batchedBody,
|
|
3010
|
+
signal: this.#signal
|
|
3011
|
+
});
|
|
3012
|
+
if (response.status === 204) return {
|
|
3013
|
+
offset: ``,
|
|
3014
|
+
duplicate: true
|
|
3015
|
+
};
|
|
3016
|
+
if (response.status === 200) return {
|
|
3017
|
+
offset: response.headers.get(STREAM_OFFSET_HEADER) ?? ``,
|
|
3018
|
+
duplicate: false
|
|
3019
|
+
};
|
|
3020
|
+
if (response.status === 403) {
|
|
3021
|
+
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
|
|
3022
|
+
const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : epoch;
|
|
3023
|
+
if (this.#autoClaim) {
|
|
3024
|
+
const newEpoch = currentEpoch + 1;
|
|
3025
|
+
this.#epoch = newEpoch;
|
|
3026
|
+
this.#nextSeq = 1;
|
|
3027
|
+
return this.#doSendBatch(batch, 0, newEpoch);
|
|
3028
|
+
}
|
|
3029
|
+
throw new StaleEpochError(currentEpoch);
|
|
3030
|
+
}
|
|
3031
|
+
if (response.status === 409) {
|
|
3032
|
+
const expectedSeqStr = response.headers.get(PRODUCER_EXPECTED_SEQ_HEADER);
|
|
3033
|
+
const expectedSeq = expectedSeqStr ? parseInt(expectedSeqStr, 10) : 0;
|
|
3034
|
+
if (expectedSeq < seq) {
|
|
3035
|
+
const waitPromises = [];
|
|
3036
|
+
for (let s = expectedSeq; s < seq; s++) waitPromises.push(this.#waitForSeq(epoch, s));
|
|
3037
|
+
await Promise.all(waitPromises);
|
|
3038
|
+
return this.#doSendBatch(batch, seq, epoch);
|
|
3039
|
+
}
|
|
3040
|
+
const receivedSeqStr = response.headers.get(PRODUCER_RECEIVED_SEQ_HEADER);
|
|
3041
|
+
throw new SequenceGapError(expectedSeq, receivedSeqStr ? parseInt(receivedSeqStr, 10) : seq);
|
|
3042
|
+
}
|
|
3043
|
+
if (response.status === 400) throw await DurableStreamError.fromResponse(response, url);
|
|
3044
|
+
throw await FetchError.fromResponse(response, url);
|
|
3045
|
+
}
|
|
3046
|
+
async #buildHeaders(protocolHeaders) {
|
|
3047
|
+
const streamHeaders = await this.#stream.resolveHeaders();
|
|
3048
|
+
const producerHeaders = await resolveHeaders(this.#headers);
|
|
3049
|
+
return {
|
|
3050
|
+
...streamHeaders,
|
|
3051
|
+
...producerHeaders,
|
|
3052
|
+
...protocolHeaders
|
|
3053
|
+
};
|
|
3054
|
+
}
|
|
3055
|
+
/**
|
|
3056
|
+
* Clear pending batch and report error.
|
|
3057
|
+
*/
|
|
3058
|
+
#rejectPendingBatch(error) {
|
|
3059
|
+
if (this.#onError && this.#pendingBatch.length > 0) this.#onError(error);
|
|
3060
|
+
this.#pendingBatch = [];
|
|
3061
|
+
this.#batchBytes = 0;
|
|
3062
|
+
if (this.#lingerTimeout) {
|
|
3063
|
+
clearTimeout(this.#lingerTimeout);
|
|
3064
|
+
this.#lingerTimeout = null;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
};
|
|
3068
|
+
/**
|
|
3069
|
+
* Normalize content-type by extracting the media type (before any semicolon).
|
|
3070
|
+
* Handles cases like "application/json; charset=utf-8".
|
|
3071
|
+
*/
|
|
3072
|
+
function normalizeContentType(contentType) {
|
|
3073
|
+
if (!contentType) return ``;
|
|
3074
|
+
return contentType.split(`;`)[0].trim().toLowerCase();
|
|
3075
|
+
}
|
|
3076
|
+
/**
|
|
3077
|
+
* Check if a value is a Promise or Promise-like (thenable).
|
|
3078
|
+
*/
|
|
3079
|
+
function isPromiseLike(value) {
|
|
3080
|
+
return value != null && typeof value.then === `function`;
|
|
3081
|
+
}
|
|
3082
|
+
/**
|
|
3083
|
+
* A handle to a remote durable stream for read/write operations.
|
|
3084
|
+
*
|
|
3085
|
+
* This is a lightweight, reusable handle - not a persistent connection.
|
|
3086
|
+
* It does not automatically start reading or listening.
|
|
3087
|
+
* Create sessions as needed via stream().
|
|
3088
|
+
*
|
|
3089
|
+
* @example
|
|
3090
|
+
* ```typescript
|
|
3091
|
+
* // Create a new stream
|
|
3092
|
+
* const stream = await DurableStream.create({
|
|
3093
|
+
* url: "https://streams.example.com/my-stream",
|
|
3094
|
+
* headers: { Authorization: "Bearer my-token" },
|
|
3095
|
+
* contentType: "application/json"
|
|
3096
|
+
* });
|
|
3097
|
+
*
|
|
3098
|
+
* // Single write
|
|
3099
|
+
* await stream.append(JSON.stringify({ message: "hello" }));
|
|
3100
|
+
*
|
|
3101
|
+
* // Read with the new API
|
|
3102
|
+
* const res = await stream.stream<{ message: string }>();
|
|
3103
|
+
* res.subscribeJson(async (batch) => {
|
|
3104
|
+
* for (const item of batch.items) {
|
|
3105
|
+
* console.log(item.message);
|
|
3106
|
+
* }
|
|
3107
|
+
* });
|
|
3108
|
+
* ```
|
|
3109
|
+
*/
|
|
3110
|
+
var DurableStream = class DurableStream {
|
|
3111
|
+
/**
|
|
3112
|
+
* The URL of the durable stream.
|
|
3113
|
+
*/
|
|
3114
|
+
url;
|
|
3115
|
+
/**
|
|
3116
|
+
* The content type of the stream (populated after connect/head/read).
|
|
3117
|
+
*/
|
|
3118
|
+
contentType;
|
|
3119
|
+
#options;
|
|
3120
|
+
#fetchClient;
|
|
3121
|
+
#baseFetchClient;
|
|
3122
|
+
#onError;
|
|
3123
|
+
#batchingEnabled;
|
|
3124
|
+
#queue;
|
|
3125
|
+
#buffer = [];
|
|
3126
|
+
/**
|
|
3127
|
+
* Create a cold handle to a stream.
|
|
3128
|
+
* No network IO is performed by the constructor.
|
|
3129
|
+
*/
|
|
3130
|
+
constructor(opts) {
|
|
3131
|
+
validateOptions(opts);
|
|
3132
|
+
const urlStr = opts.url instanceof URL ? opts.url.toString() : opts.url;
|
|
3133
|
+
this.url = urlStr;
|
|
3134
|
+
this.#options = {
|
|
3135
|
+
...opts,
|
|
3136
|
+
url: urlStr
|
|
3137
|
+
};
|
|
3138
|
+
this.#onError = opts.onError;
|
|
3139
|
+
if (opts.contentType) this.contentType = opts.contentType;
|
|
3140
|
+
this.#batchingEnabled = opts.batching !== false;
|
|
3141
|
+
if (this.#batchingEnabled) this.#queue = import_queue.default.promise(this.#batchWorker.bind(this), 1);
|
|
3142
|
+
this.#baseFetchClient = opts.fetch ?? ((...args) => fetch(...args));
|
|
3143
|
+
const backOffOpts = { ...opts.backoffOptions ?? BackoffDefaults };
|
|
3144
|
+
const fetchWithBackoffClient = createFetchWithBackoff(this.#baseFetchClient, backOffOpts);
|
|
3145
|
+
this.#fetchClient = createFetchWithConsumedBody(fetchWithBackoffClient);
|
|
3146
|
+
}
|
|
3147
|
+
/**
|
|
3148
|
+
* Create a new stream (create-only PUT) and return a handle.
|
|
3149
|
+
* Fails with DurableStreamError(code="CONFLICT_EXISTS") if it already exists.
|
|
3150
|
+
*/
|
|
3151
|
+
static async create(opts) {
|
|
3152
|
+
const stream$1 = new DurableStream(opts);
|
|
3153
|
+
await stream$1.create({
|
|
3154
|
+
contentType: opts.contentType,
|
|
3155
|
+
ttlSeconds: opts.ttlSeconds,
|
|
3156
|
+
expiresAt: opts.expiresAt,
|
|
3157
|
+
body: opts.body,
|
|
3158
|
+
closed: opts.closed
|
|
3159
|
+
});
|
|
3160
|
+
return stream$1;
|
|
3161
|
+
}
|
|
3162
|
+
/**
|
|
3163
|
+
* Validate that a stream exists and fetch metadata via HEAD.
|
|
3164
|
+
* Returns a handle with contentType populated (if sent by server).
|
|
3165
|
+
*
|
|
3166
|
+
* **Important**: This only performs a HEAD request for validation - it does
|
|
3167
|
+
* NOT open a session or start reading data. To read from the stream, call
|
|
3168
|
+
* `stream()` on the returned handle.
|
|
3169
|
+
*
|
|
3170
|
+
* @example
|
|
3171
|
+
* ```typescript
|
|
3172
|
+
* // Validate stream exists before reading
|
|
3173
|
+
* const handle = await DurableStream.connect({ url })
|
|
3174
|
+
* const res = await handle.stream() // Now actually read
|
|
3175
|
+
* ```
|
|
3176
|
+
*/
|
|
3177
|
+
static async connect(opts) {
|
|
3178
|
+
const stream$1 = new DurableStream(opts);
|
|
3179
|
+
await stream$1.head();
|
|
3180
|
+
return stream$1;
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
3183
|
+
* HEAD metadata for a stream without creating a handle.
|
|
3184
|
+
*/
|
|
3185
|
+
static async head(opts) {
|
|
3186
|
+
return new DurableStream(opts).head();
|
|
3187
|
+
}
|
|
3188
|
+
/**
|
|
3189
|
+
* Delete a stream without creating a handle.
|
|
3190
|
+
*/
|
|
3191
|
+
static async delete(opts) {
|
|
3192
|
+
return new DurableStream(opts).delete();
|
|
3193
|
+
}
|
|
3194
|
+
/**
|
|
3195
|
+
* HEAD metadata for this stream.
|
|
3196
|
+
*/
|
|
3197
|
+
async head(opts) {
|
|
3198
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
3199
|
+
const response = await this.#baseFetchClient(fetchUrl.toString(), {
|
|
3200
|
+
method: `HEAD`,
|
|
3201
|
+
headers: requestHeaders,
|
|
3202
|
+
signal: opts?.signal ?? this.#options.signal
|
|
3203
|
+
});
|
|
3204
|
+
if (!response.ok) {
|
|
3205
|
+
if (response.status === 404) return { exists: false };
|
|
3206
|
+
await handleErrorResponse(response, this.url);
|
|
3207
|
+
}
|
|
3208
|
+
const contentType = response.headers.get(`content-type`) ?? void 0;
|
|
3209
|
+
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
3210
|
+
const etag = response.headers.get(`etag`) ?? void 0;
|
|
3211
|
+
const cacheControl = response.headers.get(`cache-control`) ?? void 0;
|
|
3212
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
3213
|
+
if (contentType) this.contentType = contentType;
|
|
3214
|
+
return {
|
|
3215
|
+
exists: true,
|
|
3216
|
+
contentType,
|
|
3217
|
+
offset,
|
|
3218
|
+
etag,
|
|
3219
|
+
cacheControl,
|
|
3220
|
+
streamClosed
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* Create this stream (create-only PUT) using the URL/auth from the handle.
|
|
3225
|
+
*/
|
|
3226
|
+
async create(opts) {
|
|
3227
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
3228
|
+
const contentType = opts?.contentType ?? this.#options.contentType;
|
|
3229
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
3230
|
+
if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
|
|
3231
|
+
if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
|
|
3232
|
+
if (opts?.closed) requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
3233
|
+
const body = encodeBody(opts?.body);
|
|
3234
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
3235
|
+
method: `PUT`,
|
|
3236
|
+
headers: requestHeaders,
|
|
3237
|
+
body,
|
|
3238
|
+
signal: this.#options.signal
|
|
3239
|
+
});
|
|
3240
|
+
if (!response.ok) await handleErrorResponse(response, this.url, { operation: `create` });
|
|
3241
|
+
const responseContentType = response.headers.get(`content-type`);
|
|
3242
|
+
if (responseContentType) this.contentType = responseContentType;
|
|
3243
|
+
else if (contentType) this.contentType = contentType;
|
|
3244
|
+
return this;
|
|
3245
|
+
}
|
|
3246
|
+
/**
|
|
3247
|
+
* Delete this stream.
|
|
3248
|
+
*/
|
|
3249
|
+
async delete(opts) {
|
|
3250
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
3251
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
3252
|
+
method: `DELETE`,
|
|
3253
|
+
headers: requestHeaders,
|
|
3254
|
+
signal: opts?.signal ?? this.#options.signal
|
|
3255
|
+
});
|
|
3256
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
3257
|
+
}
|
|
3258
|
+
/**
|
|
3259
|
+
* Close the stream, optionally with a final message.
|
|
3260
|
+
*
|
|
3261
|
+
* After closing:
|
|
3262
|
+
* - No further appends are permitted (server returns 409)
|
|
3263
|
+
* - Readers can observe the closed state and treat it as EOF
|
|
3264
|
+
* - The stream's data remains fully readable
|
|
3265
|
+
*
|
|
3266
|
+
* Closing is:
|
|
3267
|
+
* - **Durable**: The closed state is persisted
|
|
3268
|
+
* - **Monotonic**: Once closed, a stream cannot be reopened
|
|
3269
|
+
*
|
|
3270
|
+
* **Idempotency:**
|
|
3271
|
+
* - `close()` without body: Idempotent — safe to call multiple times
|
|
3272
|
+
* - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
|
|
3273
|
+
* if stream is already closed (use `IdempotentProducer.close()` for
|
|
3274
|
+
* idempotent close-with-body semantics)
|
|
3275
|
+
*
|
|
3276
|
+
* @returns CloseResult with the final offset
|
|
3277
|
+
* @throws StreamClosedError if called with body on an already-closed stream
|
|
3278
|
+
*/
|
|
3279
|
+
async close(opts) {
|
|
3280
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
3281
|
+
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
|
|
3282
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
3283
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
3284
|
+
let body;
|
|
3285
|
+
if (opts?.body !== void 0) if (normalizeContentType(contentType) === `application/json`) body = `[${typeof opts.body === `string` ? opts.body : new TextDecoder().decode(opts.body)}]`;
|
|
3286
|
+
else body = typeof opts.body === `string` ? opts.body : opts.body;
|
|
3287
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
3288
|
+
method: `POST`,
|
|
3289
|
+
headers: requestHeaders,
|
|
3290
|
+
body,
|
|
3291
|
+
signal: opts?.signal ?? this.#options.signal
|
|
3292
|
+
});
|
|
3293
|
+
if (response.status === 409) {
|
|
3294
|
+
if (response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`) {
|
|
3295
|
+
const finalOffset$1 = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
3296
|
+
throw new StreamClosedError(this.url, finalOffset$1);
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
3300
|
+
return { finalOffset: response.headers.get(STREAM_OFFSET_HEADER) ?? `` };
|
|
3301
|
+
}
|
|
3302
|
+
/**
|
|
3303
|
+
* Append a single payload to the stream.
|
|
3304
|
+
*
|
|
3305
|
+
* Batching: when batching is enabled (default), append() calls that overlap
|
|
3306
|
+
* in time (e.g. fired without awaiting each one) are coalesced into a
|
|
3307
|
+
* single POST while a prior POST is in flight. If every call is awaited
|
|
3308
|
+
* before the next is issued, no batching happens — each call becomes its
|
|
3309
|
+
* own roundtrip. For tight loops driving an async iterable (e.g. LLM
|
|
3310
|
+
* token streams), prefer `appendStream()` / `writable()` which pipe the
|
|
3311
|
+
* source over a single POST, or fire `append()` calls without awaiting
|
|
3312
|
+
* each one and await the last promise (and `close()`) at the end.
|
|
3313
|
+
*
|
|
3314
|
+
* - `body` must be string or Uint8Array.
|
|
3315
|
+
* - For JSON streams, pass pre-serialized JSON strings.
|
|
3316
|
+
* - `body` may also be a Promise that resolves to string or Uint8Array.
|
|
3317
|
+
* - Strings are encoded as UTF-8.
|
|
3318
|
+
* - `seq` (if provided) is sent as stream-seq (writer coordination).
|
|
3319
|
+
*
|
|
3320
|
+
* @example
|
|
3321
|
+
* ```typescript
|
|
3322
|
+
* // JSON stream - pass pre-serialized JSON (single write)
|
|
3323
|
+
* await stream.append(JSON.stringify({ message: "hello" }));
|
|
3324
|
+
*
|
|
3325
|
+
* // Byte stream
|
|
3326
|
+
* await stream.append("raw text data");
|
|
3327
|
+
* await stream.append(new Uint8Array([1, 2, 3]));
|
|
3328
|
+
*
|
|
3329
|
+
* // Promise value - awaited before buffering
|
|
3330
|
+
* await stream.append(fetchData());
|
|
3331
|
+
*
|
|
3332
|
+
* // High-frequency writes from an async iterable - fire-and-track-last
|
|
3333
|
+
* let last: Promise<void> = Promise.resolve();
|
|
3334
|
+
* for await (const chunk of source) {
|
|
3335
|
+
* last = stream.append(JSON.stringify(chunk));
|
|
3336
|
+
* }
|
|
3337
|
+
* await last;
|
|
3338
|
+
* await stream.close();
|
|
3339
|
+
* ```
|
|
3340
|
+
*/
|
|
3341
|
+
async append(body, opts) {
|
|
3342
|
+
const resolvedBody = isPromiseLike(body) ? await body : body;
|
|
3343
|
+
if (this.#batchingEnabled && this.#queue) return this.#appendWithBatching(resolvedBody, opts);
|
|
3344
|
+
return this.#appendDirect(resolvedBody, opts);
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* Direct append without batching (used when batching is disabled).
|
|
3348
|
+
*/
|
|
3349
|
+
async #appendDirect(body, opts) {
|
|
3350
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
3351
|
+
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
|
|
3352
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
3353
|
+
if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
|
|
3354
|
+
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
3355
|
+
let encodedBody;
|
|
3356
|
+
if (isJson) encodedBody = `[${typeof body === `string` ? body : new TextDecoder().decode(body)}]`;
|
|
3357
|
+
else if (typeof body === `string`) encodedBody = body;
|
|
3358
|
+
else encodedBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
3359
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
3360
|
+
method: `POST`,
|
|
3361
|
+
headers: requestHeaders,
|
|
3362
|
+
body: encodedBody,
|
|
3363
|
+
signal: opts?.signal ?? this.#options.signal
|
|
3364
|
+
});
|
|
3365
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
3366
|
+
}
|
|
3367
|
+
/**
|
|
3368
|
+
* Append with batching - buffers messages and sends them in batches.
|
|
3369
|
+
*/
|
|
3370
|
+
async #appendWithBatching(body, opts) {
|
|
3371
|
+
return new Promise((resolve, reject) => {
|
|
3372
|
+
this.#buffer.push({
|
|
3373
|
+
data: body,
|
|
3374
|
+
seq: opts?.seq,
|
|
3375
|
+
contentType: opts?.contentType,
|
|
3376
|
+
signal: opts?.signal,
|
|
3377
|
+
resolve,
|
|
3378
|
+
reject
|
|
3379
|
+
});
|
|
3380
|
+
if (this.#queue.idle()) {
|
|
3381
|
+
const batch = this.#buffer.splice(0);
|
|
3382
|
+
this.#queue.push(batch).catch((err) => {
|
|
3383
|
+
for (const msg of batch) msg.reject(err);
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
/**
|
|
3389
|
+
* Batch worker - processes batches of messages.
|
|
3390
|
+
*/
|
|
3391
|
+
async #batchWorker(batch) {
|
|
3392
|
+
try {
|
|
3393
|
+
await this.#sendBatch(batch);
|
|
3394
|
+
for (const msg of batch) msg.resolve();
|
|
3395
|
+
if (this.#buffer.length > 0) {
|
|
3396
|
+
const nextBatch = this.#buffer.splice(0);
|
|
3397
|
+
this.#queue.push(nextBatch).catch((err) => {
|
|
3398
|
+
for (const msg of nextBatch) msg.reject(err);
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
} catch (error) {
|
|
3402
|
+
for (const msg of batch) msg.reject(error);
|
|
3403
|
+
for (const msg of this.#buffer) msg.reject(error);
|
|
3404
|
+
this.#buffer = [];
|
|
3405
|
+
throw error;
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* Send a batch of messages as a single POST request.
|
|
3410
|
+
*/
|
|
3411
|
+
async #sendBatch(batch) {
|
|
3412
|
+
if (batch.length === 0) return;
|
|
3413
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
3414
|
+
const contentType = batch[0]?.contentType ?? this.#options.contentType ?? this.contentType;
|
|
3415
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
3416
|
+
let highestSeq;
|
|
3417
|
+
for (let i = batch.length - 1; i >= 0; i--) if (batch[i].seq !== void 0) {
|
|
3418
|
+
highestSeq = batch[i].seq;
|
|
3419
|
+
break;
|
|
3420
|
+
}
|
|
3421
|
+
if (highestSeq) requestHeaders[STREAM_SEQ_HEADER] = highestSeq;
|
|
3422
|
+
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
3423
|
+
let batchedBody;
|
|
3424
|
+
if (isJson) batchedBody = `[${batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data)).join(`,`)}]`;
|
|
3425
|
+
else {
|
|
3426
|
+
const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array);
|
|
3427
|
+
const hasString = batch.some((m) => typeof m.data === `string`);
|
|
3428
|
+
if (hasUint8Array && !hasString) {
|
|
3429
|
+
const chunks = batch.map((m) => m.data);
|
|
3430
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
3431
|
+
const combined = new Uint8Array(totalLength);
|
|
3432
|
+
let offset = 0;
|
|
3433
|
+
for (const chunk of chunks) {
|
|
3434
|
+
combined.set(chunk, offset);
|
|
3435
|
+
offset += chunk.length;
|
|
3436
|
+
}
|
|
3437
|
+
batchedBody = combined;
|
|
3438
|
+
} else if (hasString && !hasUint8Array) batchedBody = batch.map((m) => m.data).join(``);
|
|
3439
|
+
else {
|
|
3440
|
+
const encoder = new TextEncoder();
|
|
3441
|
+
const chunks = batch.map((m) => typeof m.data === `string` ? encoder.encode(m.data) : m.data);
|
|
3442
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
3443
|
+
const combined = new Uint8Array(totalLength);
|
|
3444
|
+
let offset = 0;
|
|
3445
|
+
for (const chunk of chunks) {
|
|
3446
|
+
combined.set(chunk, offset);
|
|
3447
|
+
offset += chunk.length;
|
|
3448
|
+
}
|
|
3449
|
+
batchedBody = combined;
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
const signals = [];
|
|
3453
|
+
if (this.#options.signal) signals.push(this.#options.signal);
|
|
3454
|
+
for (const msg of batch) if (msg.signal) signals.push(msg.signal);
|
|
3455
|
+
const combinedSignal = signals.length > 0 ? AbortSignal.any(signals) : void 0;
|
|
3456
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
3457
|
+
method: `POST`,
|
|
3458
|
+
headers: requestHeaders,
|
|
3459
|
+
body: batchedBody,
|
|
3460
|
+
signal: combinedSignal
|
|
3461
|
+
});
|
|
3462
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
3463
|
+
}
|
|
3464
|
+
/**
|
|
3465
|
+
* Append a streaming body to the stream.
|
|
3466
|
+
*
|
|
3467
|
+
* Supports piping from any ReadableStream or async iterable:
|
|
3468
|
+
* - `source` yields Uint8Array or string chunks.
|
|
3469
|
+
* - Strings are encoded as UTF-8; no delimiters are added.
|
|
3470
|
+
* - Internally uses chunked transfer or HTTP/2 streaming.
|
|
3471
|
+
*
|
|
3472
|
+
* @example
|
|
3473
|
+
* ```typescript
|
|
3474
|
+
* // Pipe from a ReadableStream
|
|
3475
|
+
* const readable = new ReadableStream({
|
|
3476
|
+
* start(controller) {
|
|
3477
|
+
* controller.enqueue("chunk 1");
|
|
3478
|
+
* controller.enqueue("chunk 2");
|
|
3479
|
+
* controller.close();
|
|
3480
|
+
* }
|
|
3481
|
+
* });
|
|
3482
|
+
* await stream.appendStream(readable);
|
|
3483
|
+
*
|
|
3484
|
+
* // Pipe from an async generator
|
|
3485
|
+
* async function* generate() {
|
|
3486
|
+
* yield "line 1\n";
|
|
3487
|
+
* yield "line 2\n";
|
|
3488
|
+
* }
|
|
3489
|
+
* await stream.appendStream(generate());
|
|
3490
|
+
*
|
|
3491
|
+
* // Pipe from fetch response body
|
|
3492
|
+
* const response = await fetch("https://example.com/data");
|
|
3493
|
+
* await stream.appendStream(response.body!);
|
|
3494
|
+
* ```
|
|
3495
|
+
*/
|
|
3496
|
+
async appendStream(source, opts) {
|
|
3497
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
3498
|
+
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
|
|
3499
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
3500
|
+
if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
|
|
3501
|
+
const body = toReadableStream(source);
|
|
3502
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
3503
|
+
method: `POST`,
|
|
3504
|
+
headers: requestHeaders,
|
|
3505
|
+
body,
|
|
3506
|
+
duplex: `half`,
|
|
3507
|
+
signal: opts?.signal ?? this.#options.signal
|
|
3508
|
+
});
|
|
3509
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
3510
|
+
}
|
|
3511
|
+
/**
|
|
3512
|
+
* Create a writable stream that pipes data to this durable stream.
|
|
3513
|
+
*
|
|
3514
|
+
* Returns a WritableStream that can be used with `pipeTo()` or
|
|
3515
|
+
* `pipeThrough()` from any ReadableStream source.
|
|
3516
|
+
*
|
|
3517
|
+
* Uses IdempotentProducer internally for:
|
|
3518
|
+
* - Automatic batching (controlled by lingerMs, maxBatchBytes)
|
|
3519
|
+
* - Exactly-once delivery semantics
|
|
3520
|
+
* - Streaming writes (doesn't buffer entire content in memory)
|
|
3521
|
+
*
|
|
3522
|
+
* @example
|
|
3523
|
+
* ```typescript
|
|
3524
|
+
* // Pipe from fetch response
|
|
3525
|
+
* const response = await fetch("https://example.com/data");
|
|
3526
|
+
* await response.body!.pipeTo(stream.writable());
|
|
3527
|
+
*
|
|
3528
|
+
* // Pipe through a transform
|
|
3529
|
+
* const readable = someStream.pipeThrough(new TextEncoderStream());
|
|
3530
|
+
* await readable.pipeTo(stream.writable());
|
|
3531
|
+
*
|
|
3532
|
+
* // With custom producer options
|
|
3533
|
+
* await source.pipeTo(stream.writable({
|
|
3534
|
+
* producerId: "my-producer",
|
|
3535
|
+
* lingerMs: 10,
|
|
3536
|
+
* maxBatchBytes: 64 * 1024,
|
|
3537
|
+
* }));
|
|
3538
|
+
* ```
|
|
3539
|
+
*/
|
|
3540
|
+
writable(opts) {
|
|
3541
|
+
const producerId = opts?.producerId ?? `writable-${crypto.randomUUID().slice(0, 8)}`;
|
|
3542
|
+
let writeError = null;
|
|
3543
|
+
const producer = new IdempotentProducer(this, producerId, {
|
|
3544
|
+
autoClaim: true,
|
|
3545
|
+
headers: opts?.headers,
|
|
3546
|
+
lingerMs: opts?.lingerMs,
|
|
3547
|
+
maxBatchBytes: opts?.maxBatchBytes,
|
|
3548
|
+
onError: (error) => {
|
|
3549
|
+
if (!writeError) writeError = error;
|
|
3550
|
+
opts?.onError?.(error);
|
|
3551
|
+
},
|
|
3552
|
+
signal: opts?.signal ?? this.#options.signal
|
|
3553
|
+
});
|
|
3554
|
+
return new WritableStream({
|
|
3555
|
+
write(chunk) {
|
|
3556
|
+
producer.append(chunk);
|
|
3557
|
+
},
|
|
3558
|
+
async close() {
|
|
3559
|
+
await producer.close();
|
|
3560
|
+
if (writeError) throw writeError;
|
|
3561
|
+
},
|
|
3562
|
+
abort(_reason) {
|
|
3563
|
+
producer.detach().catch((err) => {
|
|
3564
|
+
opts?.onError?.(err);
|
|
3565
|
+
});
|
|
3566
|
+
}
|
|
3567
|
+
});
|
|
3568
|
+
}
|
|
3569
|
+
/**
|
|
3570
|
+
* Start a fetch-like streaming session against this handle's URL/headers/params.
|
|
3571
|
+
* The first request is made inside this method; it resolves when we have
|
|
3572
|
+
* a valid first response, or rejects on errors.
|
|
3573
|
+
*
|
|
3574
|
+
* Call-specific headers and params are merged with handle-level ones,
|
|
3575
|
+
* with call-specific values taking precedence.
|
|
3576
|
+
*
|
|
3577
|
+
* @example
|
|
3578
|
+
* ```typescript
|
|
3579
|
+
* const handle = await DurableStream.connect({
|
|
3580
|
+
* url,
|
|
3581
|
+
* headers: { Authorization: `Bearer ${token}` }
|
|
3582
|
+
* });
|
|
3583
|
+
* const res = await handle.stream<{ message: string }>();
|
|
3584
|
+
*
|
|
3585
|
+
* // Accumulate all JSON items
|
|
3586
|
+
* const items = await res.json();
|
|
3587
|
+
*
|
|
3588
|
+
* // Or stream live with ReadableStream
|
|
3589
|
+
* const reader = res.jsonStream().getReader();
|
|
3590
|
+
* let result = await reader.read();
|
|
3591
|
+
* while (!result.done) {
|
|
3592
|
+
* console.log(result.value);
|
|
3593
|
+
* result = await reader.read();
|
|
3594
|
+
* }
|
|
3595
|
+
*
|
|
3596
|
+
* // Or use subscriber for backpressure-aware consumption
|
|
3597
|
+
* res.subscribeJson(async (batch) => {
|
|
3598
|
+
* for (const item of batch.items) {
|
|
3599
|
+
* console.log(item);
|
|
3600
|
+
* }
|
|
3601
|
+
* });
|
|
3602
|
+
* ```
|
|
3603
|
+
*/
|
|
3604
|
+
async stream(options) {
|
|
3605
|
+
const mergedHeaders = {
|
|
3606
|
+
...this.#options.headers,
|
|
3607
|
+
...options?.headers
|
|
3608
|
+
};
|
|
3609
|
+
const mergedParams = {
|
|
3610
|
+
...this.#options.params,
|
|
3611
|
+
...options?.params
|
|
3612
|
+
};
|
|
3613
|
+
return stream({
|
|
3614
|
+
url: this.url,
|
|
3615
|
+
headers: mergedHeaders,
|
|
3616
|
+
params: mergedParams,
|
|
3617
|
+
signal: options?.signal ?? this.#options.signal,
|
|
3618
|
+
fetch: this.#options.fetch,
|
|
3619
|
+
backoffOptions: this.#options.backoffOptions,
|
|
3620
|
+
offset: options?.offset,
|
|
3621
|
+
live: options?.live,
|
|
3622
|
+
json: options?.json,
|
|
3623
|
+
onError: options?.onError ?? this.#onError,
|
|
3624
|
+
warnOnHttp: options?.warnOnHttp ?? this.#options.warnOnHttp
|
|
3625
|
+
});
|
|
3626
|
+
}
|
|
3627
|
+
/**
|
|
3628
|
+
* Resolve the stream's configured headers.
|
|
3629
|
+
* Used by IdempotentProducer to merge auth headers into its requests.
|
|
3630
|
+
* @internal
|
|
3631
|
+
*/
|
|
3632
|
+
async resolveHeaders() {
|
|
3633
|
+
return resolveHeaders(this.#options.headers);
|
|
3634
|
+
}
|
|
3635
|
+
/**
|
|
3636
|
+
* Build request headers and URL.
|
|
3637
|
+
*/
|
|
3638
|
+
async #buildRequest() {
|
|
3639
|
+
const requestHeaders = await resolveHeaders(this.#options.headers);
|
|
3640
|
+
const fetchUrl = new URL(this.url);
|
|
3641
|
+
const params = await resolveParams(this.#options.params);
|
|
3642
|
+
for (const [key, value] of Object.entries(params)) fetchUrl.searchParams.set(key, value);
|
|
3643
|
+
return {
|
|
3644
|
+
requestHeaders,
|
|
3645
|
+
fetchUrl
|
|
3646
|
+
};
|
|
3647
|
+
}
|
|
3648
|
+
};
|
|
3649
|
+
/**
|
|
3650
|
+
* Encode a body value to the appropriate format.
|
|
3651
|
+
* Strings are encoded as UTF-8.
|
|
3652
|
+
* Objects are JSON-serialized.
|
|
3653
|
+
*/
|
|
3654
|
+
function encodeBody(body) {
|
|
3655
|
+
if (body === void 0) return void 0;
|
|
3656
|
+
if (typeof body === `string`) return new TextEncoder().encode(body);
|
|
3657
|
+
if (body instanceof Uint8Array) return body;
|
|
3658
|
+
if (body instanceof Blob || body instanceof FormData || body instanceof ReadableStream || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return body;
|
|
3659
|
+
return new TextEncoder().encode(JSON.stringify(body));
|
|
3660
|
+
}
|
|
3661
|
+
/**
|
|
3662
|
+
* Convert an async iterable to a ReadableStream.
|
|
3663
|
+
*/
|
|
3664
|
+
function toReadableStream(source) {
|
|
3665
|
+
if (source instanceof ReadableStream) return source.pipeThrough(new TransformStream({ transform(chunk, controller) {
|
|
3666
|
+
if (typeof chunk === `string`) controller.enqueue(new TextEncoder().encode(chunk));
|
|
3667
|
+
else controller.enqueue(chunk);
|
|
3668
|
+
} }));
|
|
3669
|
+
const encoder = new TextEncoder();
|
|
3670
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
3671
|
+
return new ReadableStream({
|
|
3672
|
+
async pull(controller) {
|
|
3673
|
+
try {
|
|
3674
|
+
const { done, value } = await iterator.next();
|
|
3675
|
+
if (done) controller.close();
|
|
3676
|
+
else if (typeof value === `string`) controller.enqueue(encoder.encode(value));
|
|
3677
|
+
else controller.enqueue(value);
|
|
3678
|
+
} catch (e) {
|
|
3679
|
+
controller.error(e);
|
|
3680
|
+
}
|
|
3681
|
+
},
|
|
3682
|
+
cancel() {
|
|
3683
|
+
iterator.return?.();
|
|
3684
|
+
}
|
|
3685
|
+
});
|
|
3686
|
+
}
|
|
3687
|
+
/**
|
|
3688
|
+
* Validate stream options.
|
|
3689
|
+
*/
|
|
3690
|
+
function validateOptions(options) {
|
|
3691
|
+
if (!options.url) throw new MissingStreamUrlError();
|
|
3692
|
+
if (options.signal && !(options.signal instanceof AbortSignal)) throw new InvalidSignalError();
|
|
3693
|
+
warnIfUsingHttpInBrowser(options.url, options.warnOnHttp);
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
3696
|
+
* The streams client a consumer's `durableStreams()` binding hydrates to —
|
|
3697
|
+
* the RPC parity: RPC users don't hand-roll request encoding (`rpc()` hydrates
|
|
3698
|
+
* through `makeClient`), and streams users don't hand-roll the Durable Streams
|
|
3699
|
+
* protocol. All protocol knowledge lives here: the URL layout, the bearer
|
|
3700
|
+
* scheme, JSON-array append framing, opaque offsets, and the long-poll dance
|
|
3701
|
+
* — plus the stream lifecycle (ensure-create, the proven-safe 404 heal) that
|
|
3702
|
+
* used to live in application code. The wire client is
|
|
3703
|
+
* `@durable-streams/client` (ElectricSQL's canonical protocol client,
|
|
3704
|
+
* Apache-2.0); this wrapper narrows it to what the module contract promises
|
|
3705
|
+
* and adds the platform compensations, each annotated with the ticket it
|
|
3706
|
+
* stands in for.
|
|
3707
|
+
*
|
|
3708
|
+
* Two classes: `StreamsClient` holds the transport (base URL, bearer header,
|
|
3709
|
+
* the per-stream write handles a batched append needs) and hands out one
|
|
3710
|
+
* `StreamHandle` per stream name, memoized so its ensure-create state
|
|
3711
|
+
* survives repeat calls. `StreamHandle` holds one stream's name and
|
|
3712
|
+
* ensure-create memo, and is what a consumer actually calls `append`/`read`/
|
|
3713
|
+
* `tail` on — no call site names a stream twice.
|
|
3714
|
+
*
|
|
3715
|
+
* Exported standalone (and via the umbrella) so local dev and tests can wrap
|
|
3716
|
+
* the stand-in's URL without a deployed binding:
|
|
3717
|
+
*
|
|
3718
|
+
* const client = new StreamsClient({ url: standIn.url, apiKey: 'unused' });
|
|
3719
|
+
* await client.stream('log').append({ n: 1 });
|
|
3720
|
+
*/
|
|
3721
|
+
const JSON_CONTENT_TYPE = "application/json";
|
|
3722
|
+
/**
|
|
3723
|
+
* PRO-219: a scale-to-zero streams service can reset the first connection
|
|
3724
|
+
* while its instance boots (~3.5–8s observed), so IDEMPOTENT operations ride
|
|
3725
|
+
* it out with a bounded backoff. The wire client retries any failure except
|
|
3726
|
+
* a 4xx other than 429 — thrown network errors and 5xx statuses included —
|
|
3727
|
+
* so a real protocol error (401, 404, 409) surfaces on the first try. The
|
|
3728
|
+
* bound is ATTEMPTS, not wall-clock: each wait is jittered up to the current
|
|
3729
|
+
* delay, and a server Retry-After acts as a per-wait floor (capped upstream
|
|
3730
|
+
* at 1h). Appends never get any of this (see `StreamsClient.append`). Remove
|
|
3731
|
+
* when CI's "Cold-start canary (PRO-217)" goes clean — it exists to flag
|
|
3732
|
+
* exactly that.
|
|
3733
|
+
*/
|
|
3734
|
+
const IDEMPOTENT_BACKOFF = {
|
|
3735
|
+
...BackoffDefaults,
|
|
3736
|
+
initialDelay: 250,
|
|
3737
|
+
maxDelay: 5e3,
|
|
3738
|
+
multiplier: 2,
|
|
3739
|
+
maxRetries: 5
|
|
3740
|
+
};
|
|
3741
|
+
/** The wire client retries network errors by default — appends must not be (no idempotency key). */
|
|
3742
|
+
const NO_RETRY_BACKOFF = {
|
|
3743
|
+
...BackoffDefaults,
|
|
3744
|
+
maxRetries: 0
|
|
3745
|
+
};
|
|
3746
|
+
const DEFAULT_TAIL_TIMEOUT_MS = 2e4;
|
|
3747
|
+
function isAlreadyExists(error) {
|
|
3748
|
+
return error instanceof DurableStreamError && error.status === 409;
|
|
3749
|
+
}
|
|
3750
|
+
/**
|
|
3751
|
+
* Whether a client operation failed because the stream does not exist — the
|
|
3752
|
+
* one failure that provably applied NOTHING, so re-creating the stream and
|
|
3753
|
+
* re-running the operation is safe even for an append. Deliberately exactly
|
|
3754
|
+
* that: ambiguous failures (socket closes, 502/504) never match. Not
|
|
3755
|
+
* exported — its only consumer is `StreamHandle`'s own heal, so no app code
|
|
3756
|
+
* needs the wire client's error shape.
|
|
3757
|
+
*/
|
|
3758
|
+
function isStreamNotFound(error) {
|
|
3759
|
+
return (error instanceof FetchError || error instanceof DurableStreamError) && error.status === 404;
|
|
3760
|
+
}
|
|
3761
|
+
function streamUrl(base, name) {
|
|
3762
|
+
return `${base}/v1/stream/${encodeURIComponent(name)}`;
|
|
3763
|
+
}
|
|
3764
|
+
/**
|
|
3765
|
+
* The transport a consumer's `durableStreams()` binding hydrates to (bare
|
|
3766
|
+
* form) — holds the base URL, the bearer header, and the per-stream write
|
|
3767
|
+
* handles a batched append needs. `stream(name)` is the client's whole
|
|
3768
|
+
* public surface: a dynamic streams consumer names a stream by calling it,
|
|
3769
|
+
* never by any other method here.
|
|
3770
|
+
*/
|
|
3771
|
+
var StreamsClient = class {
|
|
3772
|
+
base;
|
|
3773
|
+
headers;
|
|
3774
|
+
writers = /* @__PURE__ */ new Map();
|
|
3775
|
+
handles = /* @__PURE__ */ new Map();
|
|
3776
|
+
constructor(config) {
|
|
3777
|
+
this.base = config.url.replace(/\/$/, "");
|
|
3778
|
+
this.headers = { authorization: `Bearer ${config.apiKey}` };
|
|
3779
|
+
}
|
|
3780
|
+
/** One handle per stream name, memoized so its ensure-create state survives repeat calls. */
|
|
3781
|
+
stream(name) {
|
|
3782
|
+
let handle = this.handles.get(name);
|
|
3783
|
+
if (handle === void 0) {
|
|
3784
|
+
handle = new StreamHandle(name, this);
|
|
3785
|
+
this.handles.set(name, handle);
|
|
3786
|
+
}
|
|
3787
|
+
return handle;
|
|
3788
|
+
}
|
|
3789
|
+
writer(name) {
|
|
3790
|
+
let handle = this.writers.get(name);
|
|
3791
|
+
if (handle === void 0) {
|
|
3792
|
+
handle = new DurableStream({
|
|
3793
|
+
url: streamUrl(this.base, name),
|
|
3794
|
+
headers: this.headers,
|
|
3795
|
+
contentType: JSON_CONTENT_TYPE,
|
|
3796
|
+
batching: false,
|
|
3797
|
+
backoffOptions: NO_RETRY_BACKOFF
|
|
3798
|
+
});
|
|
3799
|
+
this.writers.set(name, handle);
|
|
3800
|
+
}
|
|
3801
|
+
return handle;
|
|
3802
|
+
}
|
|
3803
|
+
/** Creates the stream (idempotent: an existing stream of any content type is success). Used by `StreamHandle`'s ensure-create. */
|
|
3804
|
+
async create(name) {
|
|
3805
|
+
const handle = new DurableStream({
|
|
3806
|
+
url: streamUrl(this.base, name),
|
|
3807
|
+
headers: this.headers,
|
|
3808
|
+
contentType: JSON_CONTENT_TYPE,
|
|
3809
|
+
backoffOptions: IDEMPOTENT_BACKOFF
|
|
3810
|
+
});
|
|
3811
|
+
try {
|
|
3812
|
+
await handle.create();
|
|
3813
|
+
} catch (error) {
|
|
3814
|
+
if (!isAlreadyExists(error)) throw error;
|
|
3815
|
+
}
|
|
3816
|
+
}
|
|
3817
|
+
/**
|
|
3818
|
+
* Appends one JSON event. NEVER retried beyond `StreamHandle`'s one-shot
|
|
3819
|
+
* 404 heal: the protocol has no idempotency key, so a failed request is
|
|
3820
|
+
* indistinguishable from one that applied — the caller retries, because
|
|
3821
|
+
* only it knows whether a duplicate is acceptable.
|
|
3822
|
+
*/
|
|
3823
|
+
async append(name, event) {
|
|
3824
|
+
await this.writer(name).append(JSON.stringify(event));
|
|
3825
|
+
}
|
|
3826
|
+
/** Reads the stream from `offset` (default: the beginning) to the current head. */
|
|
3827
|
+
async read(name, opts) {
|
|
3828
|
+
const res = await stream({
|
|
3829
|
+
url: streamUrl(this.base, name),
|
|
3830
|
+
headers: this.headers,
|
|
3831
|
+
offset: opts?.offset ?? "-1",
|
|
3832
|
+
live: false,
|
|
3833
|
+
json: true,
|
|
3834
|
+
backoffOptions: IDEMPOTENT_BACKOFF
|
|
3835
|
+
});
|
|
3836
|
+
return {
|
|
3837
|
+
events: await res.json(),
|
|
3838
|
+
nextOffset: res.offset
|
|
3839
|
+
};
|
|
3840
|
+
}
|
|
3841
|
+
/**
|
|
3842
|
+
* Waits for the next live delivery after `offset` (default: the current
|
|
3843
|
+
* head), via long-poll — SSE cannot traverse the Compute ingress (PRO-218).
|
|
3844
|
+
* Resolves with the delivered events, or `timedOut: true` after `timeoutMs`
|
|
3845
|
+
* (default 20s) with nothing new.
|
|
3846
|
+
*/
|
|
3847
|
+
async tail(name, opts) {
|
|
3848
|
+
const abort = new AbortController();
|
|
3849
|
+
const onCallerAbort = () => abort.abort();
|
|
3850
|
+
opts?.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
3851
|
+
const timer = setTimeout(() => abort.abort(), opts?.timeoutMs ?? DEFAULT_TAIL_TIMEOUT_MS);
|
|
3852
|
+
try {
|
|
3853
|
+
const res = await stream({
|
|
3854
|
+
url: streamUrl(this.base, name),
|
|
3855
|
+
headers: this.headers,
|
|
3856
|
+
offset: opts?.offset ?? "now",
|
|
3857
|
+
live: "long-poll",
|
|
3858
|
+
json: true,
|
|
3859
|
+
backoffOptions: IDEMPOTENT_BACKOFF,
|
|
3860
|
+
signal: abort.signal
|
|
3861
|
+
});
|
|
3862
|
+
return await new Promise((resolve, reject) => {
|
|
3863
|
+
abort.signal.addEventListener("abort", () => resolve({
|
|
3864
|
+
events: [],
|
|
3865
|
+
nextOffset: res.offset,
|
|
3866
|
+
timedOut: true
|
|
3867
|
+
}), { once: true });
|
|
3868
|
+
try {
|
|
3869
|
+
res.subscribeJson((batch) => {
|
|
3870
|
+
if (batch.items.length === 0) return;
|
|
3871
|
+
resolve({
|
|
3872
|
+
events: batch.items,
|
|
3873
|
+
nextOffset: batch.offset,
|
|
3874
|
+
timedOut: false
|
|
3875
|
+
});
|
|
3876
|
+
abort.abort();
|
|
3877
|
+
});
|
|
3878
|
+
} catch (error) {
|
|
3879
|
+
reject(error);
|
|
3880
|
+
}
|
|
3881
|
+
});
|
|
3882
|
+
} catch (error) {
|
|
3883
|
+
if (abort.signal.aborted) return {
|
|
3884
|
+
events: [],
|
|
3885
|
+
nextOffset: opts?.offset ?? "now",
|
|
3886
|
+
timedOut: true
|
|
3887
|
+
};
|
|
3888
|
+
throw error;
|
|
3889
|
+
} finally {
|
|
3890
|
+
clearTimeout(timer);
|
|
3891
|
+
opts?.signal?.removeEventListener("abort", onCallerAbort);
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
};
|
|
3895
|
+
/**
|
|
3896
|
+
* One stream's handle — the name and the ensure-create memo. Everything a
|
|
3897
|
+
* `durableStreams(contract)` handle or a `durableStreams()` client's
|
|
3898
|
+
* `stream(name)` result exposes; no call site passes a name again.
|
|
3899
|
+
*
|
|
3900
|
+
* Owns the lifecycle the app used to hand-roll: the first operation creates
|
|
3901
|
+
* the stream (memoized here; upstream create is already ensure-style, so a
|
|
3902
|
+
* racing second instance is harmless — using a stream is sufficient to
|
|
3903
|
+
* create it), and a 404 on any operation heals by dropping the memo,
|
|
3904
|
+
* re-creating, and retrying that operation once. A 404 is generated INSTEAD
|
|
3905
|
+
* OF a write at every layer, so it proves nothing was applied — retrying
|
|
3906
|
+
* once cannot duplicate an event, even an append. Ambiguous failures (socket
|
|
3907
|
+
* closes, 502/504) never match `isStreamNotFound` and surface raw.
|
|
3908
|
+
*/
|
|
3909
|
+
var StreamHandle = class {
|
|
3910
|
+
name;
|
|
3911
|
+
transport;
|
|
3912
|
+
ensured;
|
|
3913
|
+
constructor(name, transport) {
|
|
3914
|
+
this.name = name;
|
|
3915
|
+
this.transport = transport;
|
|
3916
|
+
}
|
|
3917
|
+
ensureCreate() {
|
|
3918
|
+
if (this.ensured === void 0) this.ensured = this.transport.create(this.name).catch((error) => {
|
|
3919
|
+
this.ensured = void 0;
|
|
3920
|
+
throw error;
|
|
3921
|
+
});
|
|
3922
|
+
return this.ensured;
|
|
3923
|
+
}
|
|
3924
|
+
async withHeal(op) {
|
|
3925
|
+
await this.ensureCreate();
|
|
3926
|
+
try {
|
|
3927
|
+
return await op();
|
|
3928
|
+
} catch (error) {
|
|
3929
|
+
if (!isStreamNotFound(error)) throw error;
|
|
3930
|
+
this.ensured = void 0;
|
|
3931
|
+
await this.ensureCreate();
|
|
3932
|
+
return op();
|
|
3933
|
+
}
|
|
3934
|
+
}
|
|
3935
|
+
/**
|
|
3936
|
+
* Appends one JSON event. NEVER retried beyond the one-shot 404 heal above:
|
|
3937
|
+
* the protocol has no idempotency key, so a failed request is
|
|
3938
|
+
* indistinguishable from one that applied — the caller retries, because
|
|
3939
|
+
* only it knows whether a duplicate is acceptable.
|
|
3940
|
+
*/
|
|
3941
|
+
append(event) {
|
|
3942
|
+
return this.withHeal(() => this.transport.append(this.name, event));
|
|
3943
|
+
}
|
|
3944
|
+
/** Reads the stream from `offset` (default: the beginning) to the current head. */
|
|
3945
|
+
read(opts) {
|
|
3946
|
+
return this.withHeal(() => this.transport.read(this.name, opts));
|
|
3947
|
+
}
|
|
3948
|
+
/**
|
|
3949
|
+
* Waits for the next live delivery after `offset` (default: the current
|
|
3950
|
+
* head), via long-poll. Resolves with the delivered events, or
|
|
3951
|
+
* `timedOut: true` after `timeoutMs` (default 20s) with nothing new.
|
|
3952
|
+
*/
|
|
3953
|
+
tail(opts) {
|
|
3954
|
+
return this.withHeal(() => this.transport.tail(this.name, opts));
|
|
3955
|
+
}
|
|
3956
|
+
};
|
|
3957
|
+
/** Declares an untyped stream in a `streamsContract` def map. */
|
|
3958
|
+
function streamDef() {
|
|
3959
|
+
return Object.freeze({ kind: "stream-def" });
|
|
3960
|
+
}
|
|
3961
|
+
/**
|
|
3962
|
+
* Names the streams a contract transports, each with an optional def:
|
|
3963
|
+
* `streamsContract({ jobs: streamDef(), audit: streamDef() })`. The
|
|
3964
|
+
* `durableStreams(contract)` dependency built from it hydrates to one handle
|
|
3965
|
+
* per declared name.
|
|
3966
|
+
*/
|
|
3967
|
+
function streamsContract(defs) {
|
|
3968
|
+
return Object.freeze({
|
|
3969
|
+
kind: "streams",
|
|
3970
|
+
__cmp: defs,
|
|
3971
|
+
satisfies: (required) => required.kind === "streams"
|
|
3972
|
+
});
|
|
3973
|
+
}
|
|
3974
|
+
/**
|
|
3975
|
+
* The `streams()` module's own exposed port: a general streams provider,
|
|
3976
|
+
* satisfied by kind alone — the `postgresContract` pattern. The module
|
|
3977
|
+
* cannot know its eventual consumers' stream names (different consumers of
|
|
3978
|
+
* one module each name their own), and the server genuinely serves any
|
|
3979
|
+
* stream, so what a consumer requires of its provider is only "is a streams
|
|
3980
|
+
* provider". That is exactly what this wide type says, and the empty def
|
|
3981
|
+
* map is a legitimate `StreamDefs` value — a placeholder nobody reads, like
|
|
3982
|
+
* postgres's `{ url: '' }`. Consumers keep their literal handle typing from
|
|
3983
|
+
* `durableStreams(contract)`'s generic parameter, which is independent of
|
|
3984
|
+
* the wiring-compatibility type here.
|
|
3985
|
+
*/
|
|
3986
|
+
const streamsProviderContract = Object.freeze({
|
|
3987
|
+
kind: "streams",
|
|
3988
|
+
__cmp: {},
|
|
3989
|
+
satisfies: (required) => required.kind === "streams"
|
|
3990
|
+
});
|
|
3991
|
+
const connectionParams = {
|
|
3992
|
+
url: string(),
|
|
3993
|
+
apiKey: string({ provision: streamsApiKeyNeed() })
|
|
3994
|
+
};
|
|
3995
|
+
function durableStreams(contract) {
|
|
3996
|
+
return dependency({
|
|
3997
|
+
type: "streams",
|
|
3998
|
+
connection: {
|
|
3999
|
+
params: connectionParams,
|
|
4000
|
+
hydrate: (v) => {
|
|
4001
|
+
const client = new StreamsClient(v);
|
|
4002
|
+
if (contract === void 0) return client;
|
|
4003
|
+
const handles = {};
|
|
4004
|
+
for (const name of Object.keys(contract.__cmp)) handles[name] = client.stream(name);
|
|
4005
|
+
return handles;
|
|
4006
|
+
}
|
|
407
4007
|
},
|
|
408
|
-
required:
|
|
4008
|
+
required: contract ?? streamsProviderContract
|
|
409
4009
|
});
|
|
410
4010
|
}
|
|
411
4011
|
/**
|
|
412
|
-
* The streams service node: a plain `compute` service
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
4012
|
+
* The streams service node: a plain `compute` service — the contract binding's
|
|
4013
|
+
* `url` is a producer output compute's deploy already carries, and its
|
|
4014
|
+
* `apiKey` is minted by the target's registered provisioner (ADR-0031), so
|
|
4015
|
+
* nothing is left for a bespoke lowering to extend. It declares the `store`
|
|
4016
|
+
* dependency (`s3()`, the storage module's port) and the `streams` expose; the
|
|
4017
|
+
* bearer key reaches this service through the target's reserved provider
|
|
4018
|
+
* param, not through a dependency. The deploy bootstrap runs the
|
|
4019
|
+
* default-exported bare node; the real wiring arrives through serialized
|
|
4020
|
+
* config at runtime — exactly like `storage-service.ts`.
|
|
418
4021
|
*/
|
|
419
4022
|
function streamsService() {
|
|
420
4023
|
return compute({
|
|
421
4024
|
name: "streams",
|
|
422
4025
|
deps: { store: s3() },
|
|
423
|
-
secrets: { apiKey: secret() },
|
|
424
4026
|
build: node({
|
|
425
4027
|
module: new URL("./streams-service.mjs", import.meta.url).href,
|
|
426
4028
|
entry: "./streams-entrypoint.mjs"
|
|
427
4029
|
}),
|
|
428
|
-
expose: { streams:
|
|
4030
|
+
expose: { streams: streamsProviderContract }
|
|
429
4031
|
});
|
|
430
4032
|
}
|
|
431
4033
|
streamsService();
|
|
@@ -434,17 +4036,15 @@ streamsService();
|
|
|
434
4036
|
function streams(opts) {
|
|
435
4037
|
return module(opts?.name ?? "streams", {
|
|
436
4038
|
deps: { store: s3() },
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}, ({ inputs, secrets, provision }) => {
|
|
4039
|
+
expose: { streams: streamsProviderContract }
|
|
4040
|
+
}, ({ inputs, provision }) => {
|
|
440
4041
|
return { streams: provision(streamsService(), {
|
|
441
4042
|
id: "service",
|
|
442
|
-
deps: { store: inputs.store }
|
|
443
|
-
secrets: { apiKey: secrets.apiKey }
|
|
4043
|
+
deps: { store: inputs.store }
|
|
444
4044
|
}).streams };
|
|
445
4045
|
});
|
|
446
4046
|
}
|
|
447
4047
|
//#endregion
|
|
448
|
-
export { durableStreams, streams, streamsContract, streamsService };
|
|
4048
|
+
export { StreamHandle, StreamsClient, durableStreams, streamDef, streams, streamsContract, streamsService };
|
|
449
4049
|
|
|
450
4050
|
//# sourceMappingURL=index.mjs.map
|