@durable-streams/client 0.2.0 → 0.2.1
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/README.md +201 -8
- package/dist/index.cjs +331 -38
- package/dist/index.d.cts +139 -9
- package/dist/index.d.ts +139 -9
- package/dist/index.js +329 -39
- package/package.json +2 -2
- package/src/constants.ts +19 -2
- package/src/error.ts +20 -0
- package/src/idempotent-producer.ts +144 -5
- package/src/index.ts +7 -0
- package/src/response.ts +176 -17
- package/src/sse.ts +10 -1
- package/src/stream-api.ts +13 -0
- package/src/stream.ts +147 -26
- package/src/types.ts +73 -0
- package/src/utils.ts +10 -1
package/dist/index.cjs
CHANGED
|
@@ -46,6 +46,11 @@ const STREAM_CURSOR_HEADER = `Stream-Cursor`;
|
|
|
46
46
|
*/
|
|
47
47
|
const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`;
|
|
48
48
|
/**
|
|
49
|
+
* Response/request header indicating stream is closed (EOF).
|
|
50
|
+
* When present with value "true", the stream is permanently closed.
|
|
51
|
+
*/
|
|
52
|
+
const STREAM_CLOSED_HEADER = `Stream-Closed`;
|
|
53
|
+
/**
|
|
49
54
|
* Request header for writer coordination sequence.
|
|
50
55
|
* Monotonic, lexicographic. If lower than last appended seq -> 409 Conflict.
|
|
51
56
|
*/
|
|
@@ -94,8 +99,17 @@ const LIVE_QUERY_PARAM = `live`;
|
|
|
94
99
|
*/
|
|
95
100
|
const CURSOR_QUERY_PARAM = `cursor`;
|
|
96
101
|
/**
|
|
97
|
-
*
|
|
98
|
-
|
|
102
|
+
* Response header indicating SSE data encoding (e.g., base64 for binary streams).
|
|
103
|
+
*/
|
|
104
|
+
const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`;
|
|
105
|
+
/**
|
|
106
|
+
* SSE control event field for stream closed state.
|
|
107
|
+
* Note: Different from HTTP header name (camelCase vs Header-Case).
|
|
108
|
+
*/
|
|
109
|
+
const SSE_CLOSED_FIELD = `streamClosed`;
|
|
110
|
+
/**
|
|
111
|
+
* Content types that are natively compatible with SSE (UTF-8 text).
|
|
112
|
+
* Binary content types are also supported via automatic base64 encoding.
|
|
99
113
|
*/
|
|
100
114
|
const SSE_COMPATIBLE_CONTENT_TYPES = [`text/`, `application/json`];
|
|
101
115
|
/**
|
|
@@ -225,6 +239,23 @@ var MissingStreamUrlError = class extends Error {
|
|
|
225
239
|
}
|
|
226
240
|
};
|
|
227
241
|
/**
|
|
242
|
+
* Error thrown when attempting to append to a closed stream.
|
|
243
|
+
*/
|
|
244
|
+
var StreamClosedError = class extends DurableStreamError {
|
|
245
|
+
code = `STREAM_CLOSED`;
|
|
246
|
+
status = 409;
|
|
247
|
+
streamClosed = true;
|
|
248
|
+
/**
|
|
249
|
+
* The final offset of the stream, if available from the response.
|
|
250
|
+
*/
|
|
251
|
+
finalOffset;
|
|
252
|
+
constructor(url, finalOffset) {
|
|
253
|
+
super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url);
|
|
254
|
+
this.name = `StreamClosedError`;
|
|
255
|
+
this.finalOffset = finalOffset;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
/**
|
|
228
259
|
* Error thrown when signal option is invalid.
|
|
229
260
|
*/
|
|
230
261
|
var InvalidSignalError = class extends Error {
|
|
@@ -504,7 +535,8 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
504
535
|
type: `control`,
|
|
505
536
|
streamNextOffset: control.streamNextOffset,
|
|
506
537
|
streamCursor: control.streamCursor,
|
|
507
|
-
upToDate: control.upToDate
|
|
538
|
+
upToDate: control.upToDate,
|
|
539
|
+
streamClosed: control.streamClosed
|
|
508
540
|
};
|
|
509
541
|
} catch (err) {
|
|
510
542
|
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
|
|
@@ -512,8 +544,10 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
512
544
|
}
|
|
513
545
|
}
|
|
514
546
|
currentEvent = { data: [] };
|
|
515
|
-
} else if (line.startsWith(`event:`))
|
|
516
|
-
|
|
547
|
+
} else if (line.startsWith(`event:`)) {
|
|
548
|
+
const eventType = line.slice(6);
|
|
549
|
+
currentEvent.type = eventType.startsWith(` `) ? eventType.slice(1) : eventType;
|
|
550
|
+
} else if (line.startsWith(`data:`)) {
|
|
517
551
|
const content = line.slice(5);
|
|
518
552
|
currentEvent.data.push(content.startsWith(` `) ? content.slice(1) : content);
|
|
519
553
|
}
|
|
@@ -532,7 +566,8 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
532
566
|
type: `control`,
|
|
533
567
|
streamNextOffset: control.streamNextOffset,
|
|
534
568
|
streamCursor: control.streamCursor,
|
|
535
|
-
upToDate: control.upToDate
|
|
569
|
+
upToDate: control.upToDate,
|
|
570
|
+
streamClosed: control.streamClosed
|
|
536
571
|
};
|
|
537
572
|
} catch (err) {
|
|
538
573
|
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
|
|
@@ -566,6 +601,7 @@ var StreamResponseImpl = class {
|
|
|
566
601
|
#offset;
|
|
567
602
|
#cursor;
|
|
568
603
|
#upToDate;
|
|
604
|
+
#streamClosed;
|
|
569
605
|
#isJsonMode;
|
|
570
606
|
#abortController;
|
|
571
607
|
#fetchNext;
|
|
@@ -585,6 +621,7 @@ var StreamResponseImpl = class {
|
|
|
585
621
|
#lastSSEConnectionStartTime;
|
|
586
622
|
#consecutiveShortSSEConnections = 0;
|
|
587
623
|
#sseFallbackToLongPoll = false;
|
|
624
|
+
#encoding;
|
|
588
625
|
#responseStream;
|
|
589
626
|
constructor(config) {
|
|
590
627
|
this.url = config.url;
|
|
@@ -594,6 +631,7 @@ var StreamResponseImpl = class {
|
|
|
594
631
|
this.#offset = config.initialOffset;
|
|
595
632
|
this.#cursor = config.initialCursor;
|
|
596
633
|
this.#upToDate = config.initialUpToDate;
|
|
634
|
+
this.#streamClosed = config.initialStreamClosed;
|
|
597
635
|
this.#headers = config.firstResponse.headers;
|
|
598
636
|
this.#status = config.firstResponse.status;
|
|
599
637
|
this.#statusText = config.firstResponse.statusText;
|
|
@@ -610,6 +648,7 @@ var StreamResponseImpl = class {
|
|
|
610
648
|
backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5e3,
|
|
611
649
|
logWarnings: config.sseResilience?.logWarnings ?? true
|
|
612
650
|
};
|
|
651
|
+
this.#encoding = config.encoding;
|
|
613
652
|
this.#closed = new Promise((resolve, reject) => {
|
|
614
653
|
this.#closedResolve = resolve;
|
|
615
654
|
this.#closedReject = reject;
|
|
@@ -693,6 +732,9 @@ var StreamResponseImpl = class {
|
|
|
693
732
|
get upToDate() {
|
|
694
733
|
return this.#upToDate;
|
|
695
734
|
}
|
|
735
|
+
get streamClosed() {
|
|
736
|
+
return this.#streamClosed;
|
|
737
|
+
}
|
|
696
738
|
#ensureJsonMode() {
|
|
697
739
|
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`);
|
|
698
740
|
}
|
|
@@ -714,11 +756,12 @@ var StreamResponseImpl = class {
|
|
|
714
756
|
}
|
|
715
757
|
/**
|
|
716
758
|
* Determine if we should continue with live updates based on live mode
|
|
717
|
-
* and whether we've received upToDate.
|
|
759
|
+
* and whether we've received upToDate or streamClosed.
|
|
718
760
|
*/
|
|
719
761
|
#shouldContinueLive() {
|
|
720
762
|
if (this.#stopAfterUpToDate && this.upToDate) return false;
|
|
721
763
|
if (this.live === false) return false;
|
|
764
|
+
if (this.#streamClosed) return false;
|
|
722
765
|
return true;
|
|
723
766
|
}
|
|
724
767
|
/**
|
|
@@ -730,6 +773,8 @@ var StreamResponseImpl = class {
|
|
|
730
773
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
731
774
|
if (cursor) this.#cursor = cursor;
|
|
732
775
|
this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
776
|
+
const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER);
|
|
777
|
+
if (streamClosedHeader?.toLowerCase() === `true`) this.#streamClosed = true;
|
|
733
778
|
this.#headers = response.headers;
|
|
734
779
|
this.#status = response.status;
|
|
735
780
|
this.#statusText = response.statusText;
|
|
@@ -737,7 +782,7 @@ var StreamResponseImpl = class {
|
|
|
737
782
|
}
|
|
738
783
|
/**
|
|
739
784
|
* Extract stream metadata from Response headers.
|
|
740
|
-
* Used by subscriber APIs to get the correct offset/cursor/upToDate for each
|
|
785
|
+
* Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
|
|
741
786
|
* specific Response, rather than reading from `this` which may be stale due to
|
|
742
787
|
* ReadableStream prefetching or timing issues.
|
|
743
788
|
*/
|
|
@@ -745,24 +790,74 @@ var StreamResponseImpl = class {
|
|
|
745
790
|
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
746
791
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
747
792
|
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
793
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
748
794
|
return {
|
|
749
795
|
offset: offset ?? this.offset,
|
|
750
796
|
cursor: cursor ?? this.cursor,
|
|
751
|
-
upToDate
|
|
797
|
+
upToDate,
|
|
798
|
+
streamClosed: streamClosed || this.streamClosed
|
|
752
799
|
};
|
|
753
800
|
}
|
|
754
801
|
/**
|
|
802
|
+
* Decode base64 string to Uint8Array.
|
|
803
|
+
* Per protocol: concatenate data lines, remove \n and \r, then decode.
|
|
804
|
+
*/
|
|
805
|
+
#decodeBase64(base64Str) {
|
|
806
|
+
const cleaned = base64Str.replace(/[\n\r]/g, ``);
|
|
807
|
+
if (cleaned.length === 0) return new Uint8Array(0);
|
|
808
|
+
if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
|
|
809
|
+
try {
|
|
810
|
+
if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
|
|
811
|
+
else {
|
|
812
|
+
const binaryStr = atob(cleaned);
|
|
813
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
814
|
+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
|
815
|
+
return bytes;
|
|
816
|
+
}
|
|
817
|
+
} catch (err) {
|
|
818
|
+
throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
755
822
|
* Create a synthetic Response from SSE data with proper headers.
|
|
756
|
-
* Includes offset/cursor/upToDate in headers so subscribers can read them.
|
|
823
|
+
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
824
|
+
*/
|
|
825
|
+
#createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed) {
|
|
826
|
+
return this.#createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Create a synthetic Response from multiple SSE data parts.
|
|
830
|
+
* For base64 mode, each part is independently encoded, so we decode each
|
|
831
|
+
* separately and concatenate the binary results.
|
|
832
|
+
* For text mode, parts are simply concatenated as strings.
|
|
757
833
|
*/
|
|
758
|
-
#
|
|
834
|
+
#createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed) {
|
|
759
835
|
const headers = {
|
|
760
836
|
"content-type": this.contentType ?? `application/json`,
|
|
761
837
|
[STREAM_OFFSET_HEADER]: String(offset)
|
|
762
838
|
};
|
|
763
839
|
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
|
|
764
840
|
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
|
|
765
|
-
|
|
841
|
+
if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
|
|
842
|
+
let body;
|
|
843
|
+
if (this.#encoding === `base64`) {
|
|
844
|
+
const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => this.#decodeBase64(part));
|
|
845
|
+
if (decodedParts.length === 0) body = new ArrayBuffer(0);
|
|
846
|
+
else if (decodedParts.length === 1) {
|
|
847
|
+
const decoded = decodedParts[0];
|
|
848
|
+
body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
|
|
849
|
+
} else {
|
|
850
|
+
const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
|
|
851
|
+
const combined = new Uint8Array(totalLength);
|
|
852
|
+
let offset$1 = 0;
|
|
853
|
+
for (const part of decodedParts) {
|
|
854
|
+
combined.set(part, offset$1);
|
|
855
|
+
offset$1 += part.length;
|
|
856
|
+
}
|
|
857
|
+
body = combined.buffer;
|
|
858
|
+
}
|
|
859
|
+
} else body = dataParts.join(``);
|
|
860
|
+
return new Response(body, {
|
|
766
861
|
status: 200,
|
|
767
862
|
headers
|
|
768
863
|
});
|
|
@@ -774,6 +869,10 @@ var StreamResponseImpl = class {
|
|
|
774
869
|
this.#offset = controlEvent.streamNextOffset;
|
|
775
870
|
if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
|
|
776
871
|
if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
|
|
872
|
+
if (controlEvent.streamClosed) {
|
|
873
|
+
this.#streamClosed = true;
|
|
874
|
+
this.#upToDate = true;
|
|
875
|
+
}
|
|
777
876
|
}
|
|
778
877
|
/**
|
|
779
878
|
* Mark the start of an SSE connection for duration tracking.
|
|
@@ -846,19 +945,29 @@ var StreamResponseImpl = class {
|
|
|
846
945
|
}
|
|
847
946
|
if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
|
|
848
947
|
this.#updateStateFromSSEControl(event);
|
|
948
|
+
if (event.upToDate) {
|
|
949
|
+
const response = this.#createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false);
|
|
950
|
+
return {
|
|
951
|
+
type: `response`,
|
|
952
|
+
response
|
|
953
|
+
};
|
|
954
|
+
}
|
|
849
955
|
return { type: `continue` };
|
|
850
956
|
}
|
|
851
957
|
/**
|
|
852
958
|
* Process an SSE data event by waiting for its corresponding control event.
|
|
853
959
|
* In SSE protocol, control events come AFTER data events.
|
|
854
960
|
* Multiple data events may arrive before a single control event - we buffer them.
|
|
961
|
+
*
|
|
962
|
+
* For base64 mode, each data event is independently base64 encoded, so we
|
|
963
|
+
* collect them as an array and decode each separately.
|
|
855
964
|
*/
|
|
856
965
|
async #processSSEDataEvent(pendingData, sseEventIterator) {
|
|
857
|
-
|
|
966
|
+
const bufferedDataParts = [pendingData];
|
|
858
967
|
while (true) {
|
|
859
968
|
const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
|
|
860
969
|
if (controlDone) {
|
|
861
|
-
const response = this.#
|
|
970
|
+
const response = this.#createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed);
|
|
862
971
|
try {
|
|
863
972
|
const newIterator = await this.#trySSEReconnect();
|
|
864
973
|
return {
|
|
@@ -875,13 +984,13 @@ var StreamResponseImpl = class {
|
|
|
875
984
|
}
|
|
876
985
|
if (controlEvent.type === `control`) {
|
|
877
986
|
this.#updateStateFromSSEControl(controlEvent);
|
|
878
|
-
const response = this.#
|
|
987
|
+
const response = this.#createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false);
|
|
879
988
|
return {
|
|
880
989
|
type: `response`,
|
|
881
990
|
response
|
|
882
991
|
};
|
|
883
992
|
}
|
|
884
|
-
|
|
993
|
+
bufferedDataParts.push(controlEvent.data);
|
|
885
994
|
}
|
|
886
995
|
}
|
|
887
996
|
/**
|
|
@@ -1184,7 +1293,7 @@ var StreamResponseImpl = class {
|
|
|
1184
1293
|
while (!result.done) {
|
|
1185
1294
|
if (abortController.signal.aborted) break;
|
|
1186
1295
|
const response = result.value;
|
|
1187
|
-
const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
|
|
1296
|
+
const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
|
|
1188
1297
|
const text = await response.text();
|
|
1189
1298
|
const content = text.trim() || `[]`;
|
|
1190
1299
|
let parsed;
|
|
@@ -1199,7 +1308,8 @@ var StreamResponseImpl = class {
|
|
|
1199
1308
|
items,
|
|
1200
1309
|
offset,
|
|
1201
1310
|
cursor,
|
|
1202
|
-
upToDate
|
|
1311
|
+
upToDate,
|
|
1312
|
+
streamClosed
|
|
1203
1313
|
});
|
|
1204
1314
|
result = await reader.read();
|
|
1205
1315
|
}
|
|
@@ -1229,13 +1339,14 @@ var StreamResponseImpl = class {
|
|
|
1229
1339
|
while (!result.done) {
|
|
1230
1340
|
if (abortController.signal.aborted) break;
|
|
1231
1341
|
const response = result.value;
|
|
1232
|
-
const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
|
|
1342
|
+
const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
|
|
1233
1343
|
const buffer = await response.arrayBuffer();
|
|
1234
1344
|
await subscriber({
|
|
1235
1345
|
data: new Uint8Array(buffer),
|
|
1236
1346
|
offset,
|
|
1237
1347
|
cursor,
|
|
1238
|
-
upToDate
|
|
1348
|
+
upToDate,
|
|
1349
|
+
streamClosed
|
|
1239
1350
|
});
|
|
1240
1351
|
result = await reader.read();
|
|
1241
1352
|
}
|
|
@@ -1265,13 +1376,14 @@ var StreamResponseImpl = class {
|
|
|
1265
1376
|
while (!result.done) {
|
|
1266
1377
|
if (abortController.signal.aborted) break;
|
|
1267
1378
|
const response = result.value;
|
|
1268
|
-
const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
|
|
1379
|
+
const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
|
|
1269
1380
|
const text = await response.text();
|
|
1270
1381
|
await subscriber({
|
|
1271
1382
|
text,
|
|
1272
1383
|
offset,
|
|
1273
1384
|
cursor,
|
|
1274
|
-
upToDate
|
|
1385
|
+
upToDate,
|
|
1386
|
+
streamClosed
|
|
1275
1387
|
});
|
|
1276
1388
|
result = await reader.read();
|
|
1277
1389
|
}
|
|
@@ -1322,6 +1434,11 @@ async function handleErrorResponse(response, url, context) {
|
|
|
1322
1434
|
const status = response.status;
|
|
1323
1435
|
if (status === 404) throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404);
|
|
1324
1436
|
if (status === 409) {
|
|
1437
|
+
const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER);
|
|
1438
|
+
if (streamClosedHeader?.toLowerCase() === `true`) {
|
|
1439
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
1440
|
+
throw new StreamClosedError(url, finalOffset);
|
|
1441
|
+
}
|
|
1325
1442
|
const message = context?.operation === `create` ? `Stream already exists: ${url}` : `Sequence conflict: seq is lower than last appended`;
|
|
1326
1443
|
const code = context?.operation === `create` ? `CONFLICT_EXISTS` : `CONFLICT_SEQ`;
|
|
1327
1444
|
throw new DurableStreamError(message, code, 409);
|
|
@@ -1508,7 +1625,10 @@ async function streamInternal(options) {
|
|
|
1508
1625
|
const initialOffset = firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset;
|
|
1509
1626
|
const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
|
|
1510
1627
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
1628
|
+
const initialStreamClosed = firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
1511
1629
|
const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
|
|
1630
|
+
const sseDataEncoding = firstResponse.headers.get(STREAM_SSE_DATA_ENCODING_HEADER);
|
|
1631
|
+
const encoding = sseDataEncoding === `base64` ? `base64` : void 0;
|
|
1512
1632
|
const fetchNext = async (offset, cursor, signal, resumingFromPause) => {
|
|
1513
1633
|
const nextUrl = new URL(url);
|
|
1514
1634
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
|
|
@@ -1553,11 +1673,13 @@ async function streamInternal(options) {
|
|
|
1553
1673
|
initialOffset,
|
|
1554
1674
|
initialCursor,
|
|
1555
1675
|
initialUpToDate,
|
|
1676
|
+
initialStreamClosed,
|
|
1556
1677
|
firstResponse,
|
|
1557
1678
|
abortController,
|
|
1558
1679
|
fetchNext,
|
|
1559
1680
|
startSSE,
|
|
1560
|
-
sseResilience: options.sseResilience
|
|
1681
|
+
sseResilience: options.sseResilience,
|
|
1682
|
+
encoding
|
|
1561
1683
|
});
|
|
1562
1684
|
}
|
|
1563
1685
|
|
|
@@ -1648,6 +1770,8 @@ var IdempotentProducer = class {
|
|
|
1648
1770
|
#queue;
|
|
1649
1771
|
#maxInFlight;
|
|
1650
1772
|
#closed = false;
|
|
1773
|
+
#closeResult = null;
|
|
1774
|
+
#pendingFinalMessage;
|
|
1651
1775
|
#epochClaimed;
|
|
1652
1776
|
#seqState = new Map();
|
|
1653
1777
|
/**
|
|
@@ -1737,11 +1861,17 @@ var IdempotentProducer = class {
|
|
|
1737
1861
|
await this.#queue.drained();
|
|
1738
1862
|
}
|
|
1739
1863
|
/**
|
|
1740
|
-
*
|
|
1864
|
+
* Stop the producer without closing the underlying stream.
|
|
1865
|
+
*
|
|
1866
|
+
* Use this when you want to:
|
|
1867
|
+
* - Hand off writing to another producer
|
|
1868
|
+
* - Keep the stream open for future writes
|
|
1869
|
+
* - Stop this producer but not signal EOF to readers
|
|
1741
1870
|
*
|
|
1742
|
-
*
|
|
1871
|
+
* Flushes any pending messages before detaching.
|
|
1872
|
+
* After calling detach(), further append() calls will throw.
|
|
1743
1873
|
*/
|
|
1744
|
-
async
|
|
1874
|
+
async detach() {
|
|
1745
1875
|
if (this.#closed) return;
|
|
1746
1876
|
this.#closed = true;
|
|
1747
1877
|
try {
|
|
@@ -1749,6 +1879,89 @@ var IdempotentProducer = class {
|
|
|
1749
1879
|
} catch {}
|
|
1750
1880
|
}
|
|
1751
1881
|
/**
|
|
1882
|
+
* Flush pending messages and close the underlying stream (EOF).
|
|
1883
|
+
*
|
|
1884
|
+
* This is the typical way to end a producer session. It:
|
|
1885
|
+
* 1. Flushes all pending messages
|
|
1886
|
+
* 2. Optionally appends a final message
|
|
1887
|
+
* 3. Closes the stream (no further appends permitted)
|
|
1888
|
+
*
|
|
1889
|
+
* **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
|
|
1890
|
+
* idempotent even with a final message because it uses producer headers
|
|
1891
|
+
* for deduplication. Safe to retry on network failures.
|
|
1892
|
+
*
|
|
1893
|
+
* @param finalMessage - Optional final message to append atomically with close
|
|
1894
|
+
* @returns CloseResult with the final offset
|
|
1895
|
+
*/
|
|
1896
|
+
async close(finalMessage) {
|
|
1897
|
+
if (this.#closed) {
|
|
1898
|
+
if (this.#closeResult) return this.#closeResult;
|
|
1899
|
+
await this.flush();
|
|
1900
|
+
const result$1 = await this.#doClose(this.#pendingFinalMessage);
|
|
1901
|
+
this.#closeResult = result$1;
|
|
1902
|
+
return result$1;
|
|
1903
|
+
}
|
|
1904
|
+
this.#closed = true;
|
|
1905
|
+
this.#pendingFinalMessage = finalMessage;
|
|
1906
|
+
await this.flush();
|
|
1907
|
+
const result = await this.#doClose(finalMessage);
|
|
1908
|
+
this.#closeResult = result;
|
|
1909
|
+
return result;
|
|
1910
|
+
}
|
|
1911
|
+
/**
|
|
1912
|
+
* Actually close the stream with optional final message.
|
|
1913
|
+
* Uses producer headers for idempotency.
|
|
1914
|
+
*/
|
|
1915
|
+
async #doClose(finalMessage) {
|
|
1916
|
+
const contentType = this.#stream.contentType ?? `application/octet-stream`;
|
|
1917
|
+
const isJson = normalizeContentType$1(contentType) === `application/json`;
|
|
1918
|
+
let body;
|
|
1919
|
+
if (finalMessage !== void 0) {
|
|
1920
|
+
const bodyBytes = typeof finalMessage === `string` ? new TextEncoder().encode(finalMessage) : finalMessage;
|
|
1921
|
+
if (isJson) {
|
|
1922
|
+
const jsonStr = new TextDecoder().decode(bodyBytes);
|
|
1923
|
+
body = `[${jsonStr}]`;
|
|
1924
|
+
} else body = bodyBytes;
|
|
1925
|
+
}
|
|
1926
|
+
const seqForThisRequest = this.#nextSeq;
|
|
1927
|
+
const headers = {
|
|
1928
|
+
"content-type": contentType,
|
|
1929
|
+
[PRODUCER_ID_HEADER]: this.#producerId,
|
|
1930
|
+
[PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
|
|
1931
|
+
[PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
|
|
1932
|
+
[STREAM_CLOSED_HEADER]: `true`
|
|
1933
|
+
};
|
|
1934
|
+
const response = await this.#fetchClient(this.#stream.url, {
|
|
1935
|
+
method: `POST`,
|
|
1936
|
+
headers,
|
|
1937
|
+
body,
|
|
1938
|
+
signal: this.#signal
|
|
1939
|
+
});
|
|
1940
|
+
if (response.status === 204) {
|
|
1941
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
1942
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
1943
|
+
return { finalOffset };
|
|
1944
|
+
}
|
|
1945
|
+
if (response.status === 200) {
|
|
1946
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
1947
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
1948
|
+
return { finalOffset };
|
|
1949
|
+
}
|
|
1950
|
+
if (response.status === 403) {
|
|
1951
|
+
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
|
|
1952
|
+
const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : this.#epoch;
|
|
1953
|
+
if (this.#autoClaim) {
|
|
1954
|
+
const newEpoch = currentEpoch + 1;
|
|
1955
|
+
this.#epoch = newEpoch;
|
|
1956
|
+
this.#nextSeq = 0;
|
|
1957
|
+
return this.#doClose(finalMessage);
|
|
1958
|
+
}
|
|
1959
|
+
throw new StaleEpochError(currentEpoch);
|
|
1960
|
+
}
|
|
1961
|
+
const error = await FetchError.fromResponse(response, this.#stream.url);
|
|
1962
|
+
throw error;
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1752
1965
|
* Increment epoch and reset sequence.
|
|
1753
1966
|
*
|
|
1754
1967
|
* Call this when restarting the producer to establish a new session.
|
|
@@ -2054,7 +2267,8 @@ var DurableStream = class DurableStream {
|
|
|
2054
2267
|
contentType: opts.contentType,
|
|
2055
2268
|
ttlSeconds: opts.ttlSeconds,
|
|
2056
2269
|
expiresAt: opts.expiresAt,
|
|
2057
|
-
body: opts.body
|
|
2270
|
+
body: opts.body,
|
|
2271
|
+
closed: opts.closed
|
|
2058
2272
|
});
|
|
2059
2273
|
return stream$1;
|
|
2060
2274
|
}
|
|
@@ -2107,13 +2321,15 @@ var DurableStream = class DurableStream {
|
|
|
2107
2321
|
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
2108
2322
|
const etag = response.headers.get(`etag`) ?? void 0;
|
|
2109
2323
|
const cacheControl = response.headers.get(`cache-control`) ?? void 0;
|
|
2324
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2110
2325
|
if (contentType) this.contentType = contentType;
|
|
2111
2326
|
return {
|
|
2112
2327
|
exists: true,
|
|
2113
2328
|
contentType,
|
|
2114
2329
|
offset,
|
|
2115
2330
|
etag,
|
|
2116
|
-
cacheControl
|
|
2331
|
+
cacheControl,
|
|
2332
|
+
streamClosed
|
|
2117
2333
|
};
|
|
2118
2334
|
}
|
|
2119
2335
|
/**
|
|
@@ -2125,6 +2341,7 @@ var DurableStream = class DurableStream {
|
|
|
2125
2341
|
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2126
2342
|
if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
|
|
2127
2343
|
if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
|
|
2344
|
+
if (opts?.closed) requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
2128
2345
|
const body = encodeBody(opts?.body);
|
|
2129
2346
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2130
2347
|
method: `PUT`,
|
|
@@ -2151,6 +2368,57 @@ var DurableStream = class DurableStream {
|
|
|
2151
2368
|
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
2152
2369
|
}
|
|
2153
2370
|
/**
|
|
2371
|
+
* Close the stream, optionally with a final message.
|
|
2372
|
+
*
|
|
2373
|
+
* After closing:
|
|
2374
|
+
* - No further appends are permitted (server returns 409)
|
|
2375
|
+
* - Readers can observe the closed state and treat it as EOF
|
|
2376
|
+
* - The stream's data remains fully readable
|
|
2377
|
+
*
|
|
2378
|
+
* Closing is:
|
|
2379
|
+
* - **Durable**: The closed state is persisted
|
|
2380
|
+
* - **Monotonic**: Once closed, a stream cannot be reopened
|
|
2381
|
+
*
|
|
2382
|
+
* **Idempotency:**
|
|
2383
|
+
* - `close()` without body: Idempotent — safe to call multiple times
|
|
2384
|
+
* - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
|
|
2385
|
+
* if stream is already closed (use `IdempotentProducer.close()` for
|
|
2386
|
+
* idempotent close-with-body semantics)
|
|
2387
|
+
*
|
|
2388
|
+
* @returns CloseResult with the final offset
|
|
2389
|
+
* @throws StreamClosedError if called with body on an already-closed stream
|
|
2390
|
+
*/
|
|
2391
|
+
async close(opts) {
|
|
2392
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
2393
|
+
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
|
|
2394
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2395
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
2396
|
+
let body;
|
|
2397
|
+
if (opts?.body !== void 0) {
|
|
2398
|
+
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
2399
|
+
if (isJson) {
|
|
2400
|
+
const bodyStr = typeof opts.body === `string` ? opts.body : new TextDecoder().decode(opts.body);
|
|
2401
|
+
body = `[${bodyStr}]`;
|
|
2402
|
+
} else body = typeof opts.body === `string` ? opts.body : opts.body;
|
|
2403
|
+
}
|
|
2404
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2405
|
+
method: `POST`,
|
|
2406
|
+
headers: requestHeaders,
|
|
2407
|
+
body,
|
|
2408
|
+
signal: opts?.signal ?? this.#options.signal
|
|
2409
|
+
});
|
|
2410
|
+
if (response.status === 409) {
|
|
2411
|
+
const isClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2412
|
+
if (isClosed) {
|
|
2413
|
+
const finalOffset$1 = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
2414
|
+
throw new StreamClosedError(this.url, finalOffset$1);
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
2418
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
2419
|
+
return { finalOffset };
|
|
2420
|
+
}
|
|
2421
|
+
/**
|
|
2154
2422
|
* Append a single payload to the stream.
|
|
2155
2423
|
*
|
|
2156
2424
|
* When batching is enabled (default), multiple append() calls made while
|
|
@@ -2190,8 +2458,12 @@ var DurableStream = class DurableStream {
|
|
|
2190
2458
|
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2191
2459
|
if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
|
|
2192
2460
|
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
2193
|
-
|
|
2194
|
-
|
|
2461
|
+
let encodedBody;
|
|
2462
|
+
if (isJson) {
|
|
2463
|
+
const bodyStr = typeof body === `string` ? body : new TextDecoder().decode(body);
|
|
2464
|
+
encodedBody = `[${bodyStr}]`;
|
|
2465
|
+
} else if (typeof body === `string`) encodedBody = body;
|
|
2466
|
+
else encodedBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
2195
2467
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2196
2468
|
method: `POST`,
|
|
2197
2469
|
headers: requestHeaders,
|
|
@@ -2261,8 +2533,31 @@ var DurableStream = class DurableStream {
|
|
|
2261
2533
|
const jsonStrings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
|
|
2262
2534
|
batchedBody = `[${jsonStrings.join(`,`)}]`;
|
|
2263
2535
|
} else {
|
|
2264
|
-
const
|
|
2265
|
-
|
|
2536
|
+
const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array);
|
|
2537
|
+
const hasString = batch.some((m) => typeof m.data === `string`);
|
|
2538
|
+
if (hasUint8Array && !hasString) {
|
|
2539
|
+
const chunks = batch.map((m) => m.data);
|
|
2540
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
2541
|
+
const combined = new Uint8Array(totalLength);
|
|
2542
|
+
let offset = 0;
|
|
2543
|
+
for (const chunk of chunks) {
|
|
2544
|
+
combined.set(chunk, offset);
|
|
2545
|
+
offset += chunk.length;
|
|
2546
|
+
}
|
|
2547
|
+
batchedBody = combined;
|
|
2548
|
+
} else if (hasString && !hasUint8Array) batchedBody = batch.map((m) => m.data).join(``);
|
|
2549
|
+
else {
|
|
2550
|
+
const encoder = new TextEncoder();
|
|
2551
|
+
const chunks = batch.map((m) => typeof m.data === `string` ? encoder.encode(m.data) : m.data);
|
|
2552
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
2553
|
+
const combined = new Uint8Array(totalLength);
|
|
2554
|
+
let offset = 0;
|
|
2555
|
+
for (const chunk of chunks) {
|
|
2556
|
+
combined.set(chunk, offset);
|
|
2557
|
+
offset += chunk.length;
|
|
2558
|
+
}
|
|
2559
|
+
batchedBody = combined;
|
|
2560
|
+
}
|
|
2266
2561
|
}
|
|
2267
2562
|
const signals = [];
|
|
2268
2563
|
if (this.#options.signal) signals.push(this.#options.signal);
|
|
@@ -2370,12 +2665,11 @@ var DurableStream = class DurableStream {
|
|
|
2370
2665
|
producer.append(chunk);
|
|
2371
2666
|
},
|
|
2372
2667
|
async close() {
|
|
2373
|
-
await producer.flush();
|
|
2374
2668
|
await producer.close();
|
|
2375
2669
|
if (writeError) throw writeError;
|
|
2376
2670
|
},
|
|
2377
2671
|
abort(_reason) {
|
|
2378
|
-
producer.
|
|
2672
|
+
producer.detach().catch((err) => {
|
|
2379
2673
|
opts?.onError?.(err);
|
|
2380
2674
|
});
|
|
2381
2675
|
}
|
|
@@ -2417,10 +2711,6 @@ var DurableStream = class DurableStream {
|
|
|
2417
2711
|
* ```
|
|
2418
2712
|
*/
|
|
2419
2713
|
async stream(options) {
|
|
2420
|
-
if (options?.live === `sse` && this.contentType) {
|
|
2421
|
-
const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) => this.contentType.startsWith(prefix));
|
|
2422
|
-
if (!isSSECompatible) throw new DurableStreamError(`SSE is not supported for content-type: ${this.contentType}`, `SSE_NOT_SUPPORTED`, 400);
|
|
2423
|
-
}
|
|
2424
2714
|
const mergedHeaders = {
|
|
2425
2715
|
...this.#options.headers,
|
|
2426
2716
|
...options?.headers
|
|
@@ -2522,7 +2812,9 @@ exports.PRODUCER_EXPECTED_SEQ_HEADER = PRODUCER_EXPECTED_SEQ_HEADER
|
|
|
2522
2812
|
exports.PRODUCER_ID_HEADER = PRODUCER_ID_HEADER
|
|
2523
2813
|
exports.PRODUCER_RECEIVED_SEQ_HEADER = PRODUCER_RECEIVED_SEQ_HEADER
|
|
2524
2814
|
exports.PRODUCER_SEQ_HEADER = PRODUCER_SEQ_HEADER
|
|
2815
|
+
exports.SSE_CLOSED_FIELD = SSE_CLOSED_FIELD
|
|
2525
2816
|
exports.SSE_COMPATIBLE_CONTENT_TYPES = SSE_COMPATIBLE_CONTENT_TYPES
|
|
2817
|
+
exports.STREAM_CLOSED_HEADER = STREAM_CLOSED_HEADER
|
|
2526
2818
|
exports.STREAM_CURSOR_HEADER = STREAM_CURSOR_HEADER
|
|
2527
2819
|
exports.STREAM_EXPIRES_AT_HEADER = STREAM_EXPIRES_AT_HEADER
|
|
2528
2820
|
exports.STREAM_OFFSET_HEADER = STREAM_OFFSET_HEADER
|
|
@@ -2531,6 +2823,7 @@ exports.STREAM_TTL_HEADER = STREAM_TTL_HEADER
|
|
|
2531
2823
|
exports.STREAM_UP_TO_DATE_HEADER = STREAM_UP_TO_DATE_HEADER
|
|
2532
2824
|
exports.SequenceGapError = SequenceGapError
|
|
2533
2825
|
exports.StaleEpochError = StaleEpochError
|
|
2826
|
+
exports.StreamClosedError = StreamClosedError
|
|
2534
2827
|
exports._resetHttpWarningForTesting = _resetHttpWarningForTesting
|
|
2535
2828
|
exports.asAsyncIterableReadableStream = asAsyncIterableReadableStream
|
|
2536
2829
|
exports.createFetchWithBackoff = createFetchWithBackoff
|