@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.js
CHANGED
|
@@ -22,6 +22,11 @@ const STREAM_CURSOR_HEADER = `Stream-Cursor`;
|
|
|
22
22
|
*/
|
|
23
23
|
const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`;
|
|
24
24
|
/**
|
|
25
|
+
* Response/request header indicating stream is closed (EOF).
|
|
26
|
+
* When present with value "true", the stream is permanently closed.
|
|
27
|
+
*/
|
|
28
|
+
const STREAM_CLOSED_HEADER = `Stream-Closed`;
|
|
29
|
+
/**
|
|
25
30
|
* Request header for writer coordination sequence.
|
|
26
31
|
* Monotonic, lexicographic. If lower than last appended seq -> 409 Conflict.
|
|
27
32
|
*/
|
|
@@ -70,8 +75,17 @@ const LIVE_QUERY_PARAM = `live`;
|
|
|
70
75
|
*/
|
|
71
76
|
const CURSOR_QUERY_PARAM = `cursor`;
|
|
72
77
|
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
78
|
+
* Response header indicating SSE data encoding (e.g., base64 for binary streams).
|
|
79
|
+
*/
|
|
80
|
+
const STREAM_SSE_DATA_ENCODING_HEADER = `stream-sse-data-encoding`;
|
|
81
|
+
/**
|
|
82
|
+
* SSE control event field for stream closed state.
|
|
83
|
+
* Note: Different from HTTP header name (camelCase vs Header-Case).
|
|
84
|
+
*/
|
|
85
|
+
const SSE_CLOSED_FIELD = `streamClosed`;
|
|
86
|
+
/**
|
|
87
|
+
* Content types that are natively compatible with SSE (UTF-8 text).
|
|
88
|
+
* Binary content types are also supported via automatic base64 encoding.
|
|
75
89
|
*/
|
|
76
90
|
const SSE_COMPATIBLE_CONTENT_TYPES = [`text/`, `application/json`];
|
|
77
91
|
/**
|
|
@@ -201,6 +215,23 @@ var MissingStreamUrlError = class extends Error {
|
|
|
201
215
|
}
|
|
202
216
|
};
|
|
203
217
|
/**
|
|
218
|
+
* Error thrown when attempting to append to a closed stream.
|
|
219
|
+
*/
|
|
220
|
+
var StreamClosedError = class extends DurableStreamError {
|
|
221
|
+
code = `STREAM_CLOSED`;
|
|
222
|
+
status = 409;
|
|
223
|
+
streamClosed = true;
|
|
224
|
+
/**
|
|
225
|
+
* The final offset of the stream, if available from the response.
|
|
226
|
+
*/
|
|
227
|
+
finalOffset;
|
|
228
|
+
constructor(url, finalOffset) {
|
|
229
|
+
super(`Cannot append to closed stream`, `STREAM_CLOSED`, 409, url);
|
|
230
|
+
this.name = `StreamClosedError`;
|
|
231
|
+
this.finalOffset = finalOffset;
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
204
235
|
* Error thrown when signal option is invalid.
|
|
205
236
|
*/
|
|
206
237
|
var InvalidSignalError = class extends Error {
|
|
@@ -480,7 +511,8 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
480
511
|
type: `control`,
|
|
481
512
|
streamNextOffset: control.streamNextOffset,
|
|
482
513
|
streamCursor: control.streamCursor,
|
|
483
|
-
upToDate: control.upToDate
|
|
514
|
+
upToDate: control.upToDate,
|
|
515
|
+
streamClosed: control.streamClosed
|
|
484
516
|
};
|
|
485
517
|
} catch (err) {
|
|
486
518
|
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
|
|
@@ -488,8 +520,10 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
488
520
|
}
|
|
489
521
|
}
|
|
490
522
|
currentEvent = { data: [] };
|
|
491
|
-
} else if (line.startsWith(`event:`))
|
|
492
|
-
|
|
523
|
+
} else if (line.startsWith(`event:`)) {
|
|
524
|
+
const eventType = line.slice(6);
|
|
525
|
+
currentEvent.type = eventType.startsWith(` `) ? eventType.slice(1) : eventType;
|
|
526
|
+
} else if (line.startsWith(`data:`)) {
|
|
493
527
|
const content = line.slice(5);
|
|
494
528
|
currentEvent.data.push(content.startsWith(` `) ? content.slice(1) : content);
|
|
495
529
|
}
|
|
@@ -508,7 +542,8 @@ async function* parseSSEStream(stream$1, signal) {
|
|
|
508
542
|
type: `control`,
|
|
509
543
|
streamNextOffset: control.streamNextOffset,
|
|
510
544
|
streamCursor: control.streamCursor,
|
|
511
|
-
upToDate: control.upToDate
|
|
545
|
+
upToDate: control.upToDate,
|
|
546
|
+
streamClosed: control.streamClosed
|
|
512
547
|
};
|
|
513
548
|
} catch (err) {
|
|
514
549
|
const preview = dataStr.length > 100 ? dataStr.slice(0, 100) + `...` : dataStr;
|
|
@@ -542,6 +577,7 @@ var StreamResponseImpl = class {
|
|
|
542
577
|
#offset;
|
|
543
578
|
#cursor;
|
|
544
579
|
#upToDate;
|
|
580
|
+
#streamClosed;
|
|
545
581
|
#isJsonMode;
|
|
546
582
|
#abortController;
|
|
547
583
|
#fetchNext;
|
|
@@ -561,6 +597,7 @@ var StreamResponseImpl = class {
|
|
|
561
597
|
#lastSSEConnectionStartTime;
|
|
562
598
|
#consecutiveShortSSEConnections = 0;
|
|
563
599
|
#sseFallbackToLongPoll = false;
|
|
600
|
+
#encoding;
|
|
564
601
|
#responseStream;
|
|
565
602
|
constructor(config) {
|
|
566
603
|
this.url = config.url;
|
|
@@ -570,6 +607,7 @@ var StreamResponseImpl = class {
|
|
|
570
607
|
this.#offset = config.initialOffset;
|
|
571
608
|
this.#cursor = config.initialCursor;
|
|
572
609
|
this.#upToDate = config.initialUpToDate;
|
|
610
|
+
this.#streamClosed = config.initialStreamClosed;
|
|
573
611
|
this.#headers = config.firstResponse.headers;
|
|
574
612
|
this.#status = config.firstResponse.status;
|
|
575
613
|
this.#statusText = config.firstResponse.statusText;
|
|
@@ -586,6 +624,7 @@ var StreamResponseImpl = class {
|
|
|
586
624
|
backoffMaxDelay: config.sseResilience?.backoffMaxDelay ?? 5e3,
|
|
587
625
|
logWarnings: config.sseResilience?.logWarnings ?? true
|
|
588
626
|
};
|
|
627
|
+
this.#encoding = config.encoding;
|
|
589
628
|
this.#closed = new Promise((resolve, reject) => {
|
|
590
629
|
this.#closedResolve = resolve;
|
|
591
630
|
this.#closedReject = reject;
|
|
@@ -669,6 +708,9 @@ var StreamResponseImpl = class {
|
|
|
669
708
|
get upToDate() {
|
|
670
709
|
return this.#upToDate;
|
|
671
710
|
}
|
|
711
|
+
get streamClosed() {
|
|
712
|
+
return this.#streamClosed;
|
|
713
|
+
}
|
|
672
714
|
#ensureJsonMode() {
|
|
673
715
|
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`);
|
|
674
716
|
}
|
|
@@ -690,11 +732,12 @@ var StreamResponseImpl = class {
|
|
|
690
732
|
}
|
|
691
733
|
/**
|
|
692
734
|
* Determine if we should continue with live updates based on live mode
|
|
693
|
-
* and whether we've received upToDate.
|
|
735
|
+
* and whether we've received upToDate or streamClosed.
|
|
694
736
|
*/
|
|
695
737
|
#shouldContinueLive() {
|
|
696
738
|
if (this.#stopAfterUpToDate && this.upToDate) return false;
|
|
697
739
|
if (this.live === false) return false;
|
|
740
|
+
if (this.#streamClosed) return false;
|
|
698
741
|
return true;
|
|
699
742
|
}
|
|
700
743
|
/**
|
|
@@ -706,6 +749,8 @@ var StreamResponseImpl = class {
|
|
|
706
749
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
707
750
|
if (cursor) this.#cursor = cursor;
|
|
708
751
|
this.#upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
752
|
+
const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER);
|
|
753
|
+
if (streamClosedHeader?.toLowerCase() === `true`) this.#streamClosed = true;
|
|
709
754
|
this.#headers = response.headers;
|
|
710
755
|
this.#status = response.status;
|
|
711
756
|
this.#statusText = response.statusText;
|
|
@@ -713,7 +758,7 @@ var StreamResponseImpl = class {
|
|
|
713
758
|
}
|
|
714
759
|
/**
|
|
715
760
|
* Extract stream metadata from Response headers.
|
|
716
|
-
* Used by subscriber APIs to get the correct offset/cursor/upToDate for each
|
|
761
|
+
* Used by subscriber APIs to get the correct offset/cursor/upToDate/streamClosed for each
|
|
717
762
|
* specific Response, rather than reading from `this` which may be stale due to
|
|
718
763
|
* ReadableStream prefetching or timing issues.
|
|
719
764
|
*/
|
|
@@ -721,24 +766,74 @@ var StreamResponseImpl = class {
|
|
|
721
766
|
const offset = response.headers.get(STREAM_OFFSET_HEADER);
|
|
722
767
|
const cursor = response.headers.get(STREAM_CURSOR_HEADER);
|
|
723
768
|
const upToDate = response.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
769
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
724
770
|
return {
|
|
725
771
|
offset: offset ?? this.offset,
|
|
726
772
|
cursor: cursor ?? this.cursor,
|
|
727
|
-
upToDate
|
|
773
|
+
upToDate,
|
|
774
|
+
streamClosed: streamClosed || this.streamClosed
|
|
728
775
|
};
|
|
729
776
|
}
|
|
730
777
|
/**
|
|
778
|
+
* Decode base64 string to Uint8Array.
|
|
779
|
+
* Per protocol: concatenate data lines, remove \n and \r, then decode.
|
|
780
|
+
*/
|
|
781
|
+
#decodeBase64(base64Str) {
|
|
782
|
+
const cleaned = base64Str.replace(/[\n\r]/g, ``);
|
|
783
|
+
if (cleaned.length === 0) return new Uint8Array(0);
|
|
784
|
+
if (cleaned.length % 4 !== 0) throw new DurableStreamError(`Invalid base64 data: length ${cleaned.length} is not a multiple of 4`, `PARSE_ERROR`);
|
|
785
|
+
try {
|
|
786
|
+
if (typeof Buffer !== `undefined`) return new Uint8Array(Buffer.from(cleaned, `base64`));
|
|
787
|
+
else {
|
|
788
|
+
const binaryStr = atob(cleaned);
|
|
789
|
+
const bytes = new Uint8Array(binaryStr.length);
|
|
790
|
+
for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
|
|
791
|
+
return bytes;
|
|
792
|
+
}
|
|
793
|
+
} catch (err) {
|
|
794
|
+
throw new DurableStreamError(`Failed to decode base64 data: ${err instanceof Error ? err.message : String(err)}`, `PARSE_ERROR`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
731
798
|
* Create a synthetic Response from SSE data with proper headers.
|
|
732
|
-
* Includes offset/cursor/upToDate in headers so subscribers can read them.
|
|
799
|
+
* Includes offset/cursor/upToDate/streamClosed in headers so subscribers can read them.
|
|
800
|
+
*/
|
|
801
|
+
#createSSESyntheticResponse(data, offset, cursor, upToDate, streamClosed) {
|
|
802
|
+
return this.#createSSESyntheticResponseFromParts([data], offset, cursor, upToDate, streamClosed);
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* Create a synthetic Response from multiple SSE data parts.
|
|
806
|
+
* For base64 mode, each part is independently encoded, so we decode each
|
|
807
|
+
* separately and concatenate the binary results.
|
|
808
|
+
* For text mode, parts are simply concatenated as strings.
|
|
733
809
|
*/
|
|
734
|
-
#
|
|
810
|
+
#createSSESyntheticResponseFromParts(dataParts, offset, cursor, upToDate, streamClosed) {
|
|
735
811
|
const headers = {
|
|
736
812
|
"content-type": this.contentType ?? `application/json`,
|
|
737
813
|
[STREAM_OFFSET_HEADER]: String(offset)
|
|
738
814
|
};
|
|
739
815
|
if (cursor) headers[STREAM_CURSOR_HEADER] = cursor;
|
|
740
816
|
if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
|
|
741
|
-
|
|
817
|
+
if (streamClosed) headers[STREAM_CLOSED_HEADER] = `true`;
|
|
818
|
+
let body;
|
|
819
|
+
if (this.#encoding === `base64`) {
|
|
820
|
+
const decodedParts = dataParts.filter((part) => part.length > 0).map((part) => this.#decodeBase64(part));
|
|
821
|
+
if (decodedParts.length === 0) body = new ArrayBuffer(0);
|
|
822
|
+
else if (decodedParts.length === 1) {
|
|
823
|
+
const decoded = decodedParts[0];
|
|
824
|
+
body = decoded.buffer.slice(decoded.byteOffset, decoded.byteOffset + decoded.byteLength);
|
|
825
|
+
} else {
|
|
826
|
+
const totalLength = decodedParts.reduce((sum, part) => sum + part.length, 0);
|
|
827
|
+
const combined = new Uint8Array(totalLength);
|
|
828
|
+
let offset$1 = 0;
|
|
829
|
+
for (const part of decodedParts) {
|
|
830
|
+
combined.set(part, offset$1);
|
|
831
|
+
offset$1 += part.length;
|
|
832
|
+
}
|
|
833
|
+
body = combined.buffer;
|
|
834
|
+
}
|
|
835
|
+
} else body = dataParts.join(``);
|
|
836
|
+
return new Response(body, {
|
|
742
837
|
status: 200,
|
|
743
838
|
headers
|
|
744
839
|
});
|
|
@@ -750,6 +845,10 @@ var StreamResponseImpl = class {
|
|
|
750
845
|
this.#offset = controlEvent.streamNextOffset;
|
|
751
846
|
if (controlEvent.streamCursor) this.#cursor = controlEvent.streamCursor;
|
|
752
847
|
if (controlEvent.upToDate !== void 0) this.#upToDate = controlEvent.upToDate;
|
|
848
|
+
if (controlEvent.streamClosed) {
|
|
849
|
+
this.#streamClosed = true;
|
|
850
|
+
this.#upToDate = true;
|
|
851
|
+
}
|
|
753
852
|
}
|
|
754
853
|
/**
|
|
755
854
|
* Mark the start of an SSE connection for duration tracking.
|
|
@@ -822,19 +921,29 @@ var StreamResponseImpl = class {
|
|
|
822
921
|
}
|
|
823
922
|
if (event.type === `data`) return this.#processSSEDataEvent(event.data, sseEventIterator);
|
|
824
923
|
this.#updateStateFromSSEControl(event);
|
|
924
|
+
if (event.upToDate) {
|
|
925
|
+
const response = this.#createSSESyntheticResponse(``, event.streamNextOffset, event.streamCursor, true, event.streamClosed ?? false);
|
|
926
|
+
return {
|
|
927
|
+
type: `response`,
|
|
928
|
+
response
|
|
929
|
+
};
|
|
930
|
+
}
|
|
825
931
|
return { type: `continue` };
|
|
826
932
|
}
|
|
827
933
|
/**
|
|
828
934
|
* Process an SSE data event by waiting for its corresponding control event.
|
|
829
935
|
* In SSE protocol, control events come AFTER data events.
|
|
830
936
|
* Multiple data events may arrive before a single control event - we buffer them.
|
|
937
|
+
*
|
|
938
|
+
* For base64 mode, each data event is independently base64 encoded, so we
|
|
939
|
+
* collect them as an array and decode each separately.
|
|
831
940
|
*/
|
|
832
941
|
async #processSSEDataEvent(pendingData, sseEventIterator) {
|
|
833
|
-
|
|
942
|
+
const bufferedDataParts = [pendingData];
|
|
834
943
|
while (true) {
|
|
835
944
|
const { done: controlDone, value: controlEvent } = await sseEventIterator.next();
|
|
836
945
|
if (controlDone) {
|
|
837
|
-
const response = this.#
|
|
946
|
+
const response = this.#createSSESyntheticResponseFromParts(bufferedDataParts, this.offset, this.cursor, this.upToDate, this.streamClosed);
|
|
838
947
|
try {
|
|
839
948
|
const newIterator = await this.#trySSEReconnect();
|
|
840
949
|
return {
|
|
@@ -851,13 +960,13 @@ var StreamResponseImpl = class {
|
|
|
851
960
|
}
|
|
852
961
|
if (controlEvent.type === `control`) {
|
|
853
962
|
this.#updateStateFromSSEControl(controlEvent);
|
|
854
|
-
const response = this.#
|
|
963
|
+
const response = this.#createSSESyntheticResponseFromParts(bufferedDataParts, controlEvent.streamNextOffset, controlEvent.streamCursor, controlEvent.upToDate ?? false, controlEvent.streamClosed ?? false);
|
|
855
964
|
return {
|
|
856
965
|
type: `response`,
|
|
857
966
|
response
|
|
858
967
|
};
|
|
859
968
|
}
|
|
860
|
-
|
|
969
|
+
bufferedDataParts.push(controlEvent.data);
|
|
861
970
|
}
|
|
862
971
|
}
|
|
863
972
|
/**
|
|
@@ -1160,7 +1269,7 @@ var StreamResponseImpl = class {
|
|
|
1160
1269
|
while (!result.done) {
|
|
1161
1270
|
if (abortController.signal.aborted) break;
|
|
1162
1271
|
const response = result.value;
|
|
1163
|
-
const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
|
|
1272
|
+
const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
|
|
1164
1273
|
const text = await response.text();
|
|
1165
1274
|
const content = text.trim() || `[]`;
|
|
1166
1275
|
let parsed;
|
|
@@ -1175,7 +1284,8 @@ var StreamResponseImpl = class {
|
|
|
1175
1284
|
items,
|
|
1176
1285
|
offset,
|
|
1177
1286
|
cursor,
|
|
1178
|
-
upToDate
|
|
1287
|
+
upToDate,
|
|
1288
|
+
streamClosed
|
|
1179
1289
|
});
|
|
1180
1290
|
result = await reader.read();
|
|
1181
1291
|
}
|
|
@@ -1205,13 +1315,14 @@ var StreamResponseImpl = class {
|
|
|
1205
1315
|
while (!result.done) {
|
|
1206
1316
|
if (abortController.signal.aborted) break;
|
|
1207
1317
|
const response = result.value;
|
|
1208
|
-
const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
|
|
1318
|
+
const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
|
|
1209
1319
|
const buffer = await response.arrayBuffer();
|
|
1210
1320
|
await subscriber({
|
|
1211
1321
|
data: new Uint8Array(buffer),
|
|
1212
1322
|
offset,
|
|
1213
1323
|
cursor,
|
|
1214
|
-
upToDate
|
|
1324
|
+
upToDate,
|
|
1325
|
+
streamClosed
|
|
1215
1326
|
});
|
|
1216
1327
|
result = await reader.read();
|
|
1217
1328
|
}
|
|
@@ -1241,13 +1352,14 @@ var StreamResponseImpl = class {
|
|
|
1241
1352
|
while (!result.done) {
|
|
1242
1353
|
if (abortController.signal.aborted) break;
|
|
1243
1354
|
const response = result.value;
|
|
1244
|
-
const { offset, cursor, upToDate } = this.#getMetadataFromResponse(response);
|
|
1355
|
+
const { offset, cursor, upToDate, streamClosed } = this.#getMetadataFromResponse(response);
|
|
1245
1356
|
const text = await response.text();
|
|
1246
1357
|
await subscriber({
|
|
1247
1358
|
text,
|
|
1248
1359
|
offset,
|
|
1249
1360
|
cursor,
|
|
1250
|
-
upToDate
|
|
1361
|
+
upToDate,
|
|
1362
|
+
streamClosed
|
|
1251
1363
|
});
|
|
1252
1364
|
result = await reader.read();
|
|
1253
1365
|
}
|
|
@@ -1298,6 +1410,11 @@ async function handleErrorResponse(response, url, context) {
|
|
|
1298
1410
|
const status = response.status;
|
|
1299
1411
|
if (status === 404) throw new DurableStreamError(`Stream not found: ${url}`, `NOT_FOUND`, 404);
|
|
1300
1412
|
if (status === 409) {
|
|
1413
|
+
const streamClosedHeader = response.headers.get(STREAM_CLOSED_HEADER);
|
|
1414
|
+
if (streamClosedHeader?.toLowerCase() === `true`) {
|
|
1415
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
1416
|
+
throw new StreamClosedError(url, finalOffset);
|
|
1417
|
+
}
|
|
1301
1418
|
const message = context?.operation === `create` ? `Stream already exists: ${url}` : `Sequence conflict: seq is lower than last appended`;
|
|
1302
1419
|
const code = context?.operation === `create` ? `CONFLICT_EXISTS` : `CONFLICT_SEQ`;
|
|
1303
1420
|
throw new DurableStreamError(message, code, 409);
|
|
@@ -1484,7 +1601,10 @@ async function streamInternal(options) {
|
|
|
1484
1601
|
const initialOffset = firstResponse.headers.get(STREAM_OFFSET_HEADER) ?? startOffset;
|
|
1485
1602
|
const initialCursor = firstResponse.headers.get(STREAM_CURSOR_HEADER) ?? void 0;
|
|
1486
1603
|
const initialUpToDate = firstResponse.headers.has(STREAM_UP_TO_DATE_HEADER);
|
|
1604
|
+
const initialStreamClosed = firstResponse.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
1487
1605
|
const isJsonMode = options.json === true || (contentType?.includes(`application/json`) ?? false);
|
|
1606
|
+
const sseDataEncoding = firstResponse.headers.get(STREAM_SSE_DATA_ENCODING_HEADER);
|
|
1607
|
+
const encoding = sseDataEncoding === `base64` ? `base64` : void 0;
|
|
1488
1608
|
const fetchNext = async (offset, cursor, signal, resumingFromPause) => {
|
|
1489
1609
|
const nextUrl = new URL(url);
|
|
1490
1610
|
nextUrl.searchParams.set(OFFSET_QUERY_PARAM, offset);
|
|
@@ -1529,11 +1649,13 @@ async function streamInternal(options) {
|
|
|
1529
1649
|
initialOffset,
|
|
1530
1650
|
initialCursor,
|
|
1531
1651
|
initialUpToDate,
|
|
1652
|
+
initialStreamClosed,
|
|
1532
1653
|
firstResponse,
|
|
1533
1654
|
abortController,
|
|
1534
1655
|
fetchNext,
|
|
1535
1656
|
startSSE,
|
|
1536
|
-
sseResilience: options.sseResilience
|
|
1657
|
+
sseResilience: options.sseResilience,
|
|
1658
|
+
encoding
|
|
1537
1659
|
});
|
|
1538
1660
|
}
|
|
1539
1661
|
|
|
@@ -1624,6 +1746,8 @@ var IdempotentProducer = class {
|
|
|
1624
1746
|
#queue;
|
|
1625
1747
|
#maxInFlight;
|
|
1626
1748
|
#closed = false;
|
|
1749
|
+
#closeResult = null;
|
|
1750
|
+
#pendingFinalMessage;
|
|
1627
1751
|
#epochClaimed;
|
|
1628
1752
|
#seqState = new Map();
|
|
1629
1753
|
/**
|
|
@@ -1713,11 +1837,17 @@ var IdempotentProducer = class {
|
|
|
1713
1837
|
await this.#queue.drained();
|
|
1714
1838
|
}
|
|
1715
1839
|
/**
|
|
1716
|
-
*
|
|
1840
|
+
* Stop the producer without closing the underlying stream.
|
|
1841
|
+
*
|
|
1842
|
+
* Use this when you want to:
|
|
1843
|
+
* - Hand off writing to another producer
|
|
1844
|
+
* - Keep the stream open for future writes
|
|
1845
|
+
* - Stop this producer but not signal EOF to readers
|
|
1717
1846
|
*
|
|
1718
|
-
*
|
|
1847
|
+
* Flushes any pending messages before detaching.
|
|
1848
|
+
* After calling detach(), further append() calls will throw.
|
|
1719
1849
|
*/
|
|
1720
|
-
async
|
|
1850
|
+
async detach() {
|
|
1721
1851
|
if (this.#closed) return;
|
|
1722
1852
|
this.#closed = true;
|
|
1723
1853
|
try {
|
|
@@ -1725,6 +1855,89 @@ var IdempotentProducer = class {
|
|
|
1725
1855
|
} catch {}
|
|
1726
1856
|
}
|
|
1727
1857
|
/**
|
|
1858
|
+
* Flush pending messages and close the underlying stream (EOF).
|
|
1859
|
+
*
|
|
1860
|
+
* This is the typical way to end a producer session. It:
|
|
1861
|
+
* 1. Flushes all pending messages
|
|
1862
|
+
* 2. Optionally appends a final message
|
|
1863
|
+
* 3. Closes the stream (no further appends permitted)
|
|
1864
|
+
*
|
|
1865
|
+
* **Idempotent**: Unlike `DurableStream.close({ body })`, this method is
|
|
1866
|
+
* idempotent even with a final message because it uses producer headers
|
|
1867
|
+
* for deduplication. Safe to retry on network failures.
|
|
1868
|
+
*
|
|
1869
|
+
* @param finalMessage - Optional final message to append atomically with close
|
|
1870
|
+
* @returns CloseResult with the final offset
|
|
1871
|
+
*/
|
|
1872
|
+
async close(finalMessage) {
|
|
1873
|
+
if (this.#closed) {
|
|
1874
|
+
if (this.#closeResult) return this.#closeResult;
|
|
1875
|
+
await this.flush();
|
|
1876
|
+
const result$1 = await this.#doClose(this.#pendingFinalMessage);
|
|
1877
|
+
this.#closeResult = result$1;
|
|
1878
|
+
return result$1;
|
|
1879
|
+
}
|
|
1880
|
+
this.#closed = true;
|
|
1881
|
+
this.#pendingFinalMessage = finalMessage;
|
|
1882
|
+
await this.flush();
|
|
1883
|
+
const result = await this.#doClose(finalMessage);
|
|
1884
|
+
this.#closeResult = result;
|
|
1885
|
+
return result;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Actually close the stream with optional final message.
|
|
1889
|
+
* Uses producer headers for idempotency.
|
|
1890
|
+
*/
|
|
1891
|
+
async #doClose(finalMessage) {
|
|
1892
|
+
const contentType = this.#stream.contentType ?? `application/octet-stream`;
|
|
1893
|
+
const isJson = normalizeContentType$1(contentType) === `application/json`;
|
|
1894
|
+
let body;
|
|
1895
|
+
if (finalMessage !== void 0) {
|
|
1896
|
+
const bodyBytes = typeof finalMessage === `string` ? new TextEncoder().encode(finalMessage) : finalMessage;
|
|
1897
|
+
if (isJson) {
|
|
1898
|
+
const jsonStr = new TextDecoder().decode(bodyBytes);
|
|
1899
|
+
body = `[${jsonStr}]`;
|
|
1900
|
+
} else body = bodyBytes;
|
|
1901
|
+
}
|
|
1902
|
+
const seqForThisRequest = this.#nextSeq;
|
|
1903
|
+
const headers = {
|
|
1904
|
+
"content-type": contentType,
|
|
1905
|
+
[PRODUCER_ID_HEADER]: this.#producerId,
|
|
1906
|
+
[PRODUCER_EPOCH_HEADER]: this.#epoch.toString(),
|
|
1907
|
+
[PRODUCER_SEQ_HEADER]: seqForThisRequest.toString(),
|
|
1908
|
+
[STREAM_CLOSED_HEADER]: `true`
|
|
1909
|
+
};
|
|
1910
|
+
const response = await this.#fetchClient(this.#stream.url, {
|
|
1911
|
+
method: `POST`,
|
|
1912
|
+
headers,
|
|
1913
|
+
body,
|
|
1914
|
+
signal: this.#signal
|
|
1915
|
+
});
|
|
1916
|
+
if (response.status === 204) {
|
|
1917
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
1918
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
1919
|
+
return { finalOffset };
|
|
1920
|
+
}
|
|
1921
|
+
if (response.status === 200) {
|
|
1922
|
+
this.#nextSeq = seqForThisRequest + 1;
|
|
1923
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
1924
|
+
return { finalOffset };
|
|
1925
|
+
}
|
|
1926
|
+
if (response.status === 403) {
|
|
1927
|
+
const currentEpochStr = response.headers.get(PRODUCER_EPOCH_HEADER);
|
|
1928
|
+
const currentEpoch = currentEpochStr ? parseInt(currentEpochStr, 10) : this.#epoch;
|
|
1929
|
+
if (this.#autoClaim) {
|
|
1930
|
+
const newEpoch = currentEpoch + 1;
|
|
1931
|
+
this.#epoch = newEpoch;
|
|
1932
|
+
this.#nextSeq = 0;
|
|
1933
|
+
return this.#doClose(finalMessage);
|
|
1934
|
+
}
|
|
1935
|
+
throw new StaleEpochError(currentEpoch);
|
|
1936
|
+
}
|
|
1937
|
+
const error = await FetchError.fromResponse(response, this.#stream.url);
|
|
1938
|
+
throw error;
|
|
1939
|
+
}
|
|
1940
|
+
/**
|
|
1728
1941
|
* Increment epoch and reset sequence.
|
|
1729
1942
|
*
|
|
1730
1943
|
* Call this when restarting the producer to establish a new session.
|
|
@@ -2030,7 +2243,8 @@ var DurableStream = class DurableStream {
|
|
|
2030
2243
|
contentType: opts.contentType,
|
|
2031
2244
|
ttlSeconds: opts.ttlSeconds,
|
|
2032
2245
|
expiresAt: opts.expiresAt,
|
|
2033
|
-
body: opts.body
|
|
2246
|
+
body: opts.body,
|
|
2247
|
+
closed: opts.closed
|
|
2034
2248
|
});
|
|
2035
2249
|
return stream$1;
|
|
2036
2250
|
}
|
|
@@ -2083,13 +2297,15 @@ var DurableStream = class DurableStream {
|
|
|
2083
2297
|
const offset = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
2084
2298
|
const etag = response.headers.get(`etag`) ?? void 0;
|
|
2085
2299
|
const cacheControl = response.headers.get(`cache-control`) ?? void 0;
|
|
2300
|
+
const streamClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2086
2301
|
if (contentType) this.contentType = contentType;
|
|
2087
2302
|
return {
|
|
2088
2303
|
exists: true,
|
|
2089
2304
|
contentType,
|
|
2090
2305
|
offset,
|
|
2091
2306
|
etag,
|
|
2092
|
-
cacheControl
|
|
2307
|
+
cacheControl,
|
|
2308
|
+
streamClosed
|
|
2093
2309
|
};
|
|
2094
2310
|
}
|
|
2095
2311
|
/**
|
|
@@ -2101,6 +2317,7 @@ var DurableStream = class DurableStream {
|
|
|
2101
2317
|
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2102
2318
|
if (opts?.ttlSeconds !== void 0) requestHeaders[STREAM_TTL_HEADER] = String(opts.ttlSeconds);
|
|
2103
2319
|
if (opts?.expiresAt) requestHeaders[STREAM_EXPIRES_AT_HEADER] = opts.expiresAt;
|
|
2320
|
+
if (opts?.closed) requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
2104
2321
|
const body = encodeBody(opts?.body);
|
|
2105
2322
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2106
2323
|
method: `PUT`,
|
|
@@ -2127,6 +2344,57 @@ var DurableStream = class DurableStream {
|
|
|
2127
2344
|
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
2128
2345
|
}
|
|
2129
2346
|
/**
|
|
2347
|
+
* Close the stream, optionally with a final message.
|
|
2348
|
+
*
|
|
2349
|
+
* After closing:
|
|
2350
|
+
* - No further appends are permitted (server returns 409)
|
|
2351
|
+
* - Readers can observe the closed state and treat it as EOF
|
|
2352
|
+
* - The stream's data remains fully readable
|
|
2353
|
+
*
|
|
2354
|
+
* Closing is:
|
|
2355
|
+
* - **Durable**: The closed state is persisted
|
|
2356
|
+
* - **Monotonic**: Once closed, a stream cannot be reopened
|
|
2357
|
+
*
|
|
2358
|
+
* **Idempotency:**
|
|
2359
|
+
* - `close()` without body: Idempotent — safe to call multiple times
|
|
2360
|
+
* - `close({ body })` with body: NOT idempotent — throws `StreamClosedError`
|
|
2361
|
+
* if stream is already closed (use `IdempotentProducer.close()` for
|
|
2362
|
+
* idempotent close-with-body semantics)
|
|
2363
|
+
*
|
|
2364
|
+
* @returns CloseResult with the final offset
|
|
2365
|
+
* @throws StreamClosedError if called with body on an already-closed stream
|
|
2366
|
+
*/
|
|
2367
|
+
async close(opts) {
|
|
2368
|
+
const { requestHeaders, fetchUrl } = await this.#buildRequest();
|
|
2369
|
+
const contentType = opts?.contentType ?? this.#options.contentType ?? this.contentType;
|
|
2370
|
+
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2371
|
+
requestHeaders[STREAM_CLOSED_HEADER] = `true`;
|
|
2372
|
+
let body;
|
|
2373
|
+
if (opts?.body !== void 0) {
|
|
2374
|
+
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
2375
|
+
if (isJson) {
|
|
2376
|
+
const bodyStr = typeof opts.body === `string` ? opts.body : new TextDecoder().decode(opts.body);
|
|
2377
|
+
body = `[${bodyStr}]`;
|
|
2378
|
+
} else body = typeof opts.body === `string` ? opts.body : opts.body;
|
|
2379
|
+
}
|
|
2380
|
+
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2381
|
+
method: `POST`,
|
|
2382
|
+
headers: requestHeaders,
|
|
2383
|
+
body,
|
|
2384
|
+
signal: opts?.signal ?? this.#options.signal
|
|
2385
|
+
});
|
|
2386
|
+
if (response.status === 409) {
|
|
2387
|
+
const isClosed = response.headers.get(STREAM_CLOSED_HEADER)?.toLowerCase() === `true`;
|
|
2388
|
+
if (isClosed) {
|
|
2389
|
+
const finalOffset$1 = response.headers.get(STREAM_OFFSET_HEADER) ?? void 0;
|
|
2390
|
+
throw new StreamClosedError(this.url, finalOffset$1);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
if (!response.ok) await handleErrorResponse(response, this.url);
|
|
2394
|
+
const finalOffset = response.headers.get(STREAM_OFFSET_HEADER) ?? ``;
|
|
2395
|
+
return { finalOffset };
|
|
2396
|
+
}
|
|
2397
|
+
/**
|
|
2130
2398
|
* Append a single payload to the stream.
|
|
2131
2399
|
*
|
|
2132
2400
|
* When batching is enabled (default), multiple append() calls made while
|
|
@@ -2166,8 +2434,12 @@ var DurableStream = class DurableStream {
|
|
|
2166
2434
|
if (contentType) requestHeaders[`content-type`] = contentType;
|
|
2167
2435
|
if (opts?.seq) requestHeaders[STREAM_SEQ_HEADER] = opts.seq;
|
|
2168
2436
|
const isJson = normalizeContentType(contentType) === `application/json`;
|
|
2169
|
-
|
|
2170
|
-
|
|
2437
|
+
let encodedBody;
|
|
2438
|
+
if (isJson) {
|
|
2439
|
+
const bodyStr = typeof body === `string` ? body : new TextDecoder().decode(body);
|
|
2440
|
+
encodedBody = `[${bodyStr}]`;
|
|
2441
|
+
} else if (typeof body === `string`) encodedBody = body;
|
|
2442
|
+
else encodedBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
|
|
2171
2443
|
const response = await this.#fetchClient(fetchUrl.toString(), {
|
|
2172
2444
|
method: `POST`,
|
|
2173
2445
|
headers: requestHeaders,
|
|
@@ -2237,8 +2509,31 @@ var DurableStream = class DurableStream {
|
|
|
2237
2509
|
const jsonStrings = batch.map((m) => typeof m.data === `string` ? m.data : new TextDecoder().decode(m.data));
|
|
2238
2510
|
batchedBody = `[${jsonStrings.join(`,`)}]`;
|
|
2239
2511
|
} else {
|
|
2240
|
-
const
|
|
2241
|
-
|
|
2512
|
+
const hasUint8Array = batch.some((m) => m.data instanceof Uint8Array);
|
|
2513
|
+
const hasString = batch.some((m) => typeof m.data === `string`);
|
|
2514
|
+
if (hasUint8Array && !hasString) {
|
|
2515
|
+
const chunks = batch.map((m) => m.data);
|
|
2516
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
2517
|
+
const combined = new Uint8Array(totalLength);
|
|
2518
|
+
let offset = 0;
|
|
2519
|
+
for (const chunk of chunks) {
|
|
2520
|
+
combined.set(chunk, offset);
|
|
2521
|
+
offset += chunk.length;
|
|
2522
|
+
}
|
|
2523
|
+
batchedBody = combined;
|
|
2524
|
+
} else if (hasString && !hasUint8Array) batchedBody = batch.map((m) => m.data).join(``);
|
|
2525
|
+
else {
|
|
2526
|
+
const encoder = new TextEncoder();
|
|
2527
|
+
const chunks = batch.map((m) => typeof m.data === `string` ? encoder.encode(m.data) : m.data);
|
|
2528
|
+
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
2529
|
+
const combined = new Uint8Array(totalLength);
|
|
2530
|
+
let offset = 0;
|
|
2531
|
+
for (const chunk of chunks) {
|
|
2532
|
+
combined.set(chunk, offset);
|
|
2533
|
+
offset += chunk.length;
|
|
2534
|
+
}
|
|
2535
|
+
batchedBody = combined;
|
|
2536
|
+
}
|
|
2242
2537
|
}
|
|
2243
2538
|
const signals = [];
|
|
2244
2539
|
if (this.#options.signal) signals.push(this.#options.signal);
|
|
@@ -2346,12 +2641,11 @@ var DurableStream = class DurableStream {
|
|
|
2346
2641
|
producer.append(chunk);
|
|
2347
2642
|
},
|
|
2348
2643
|
async close() {
|
|
2349
|
-
await producer.flush();
|
|
2350
2644
|
await producer.close();
|
|
2351
2645
|
if (writeError) throw writeError;
|
|
2352
2646
|
},
|
|
2353
2647
|
abort(_reason) {
|
|
2354
|
-
producer.
|
|
2648
|
+
producer.detach().catch((err) => {
|
|
2355
2649
|
opts?.onError?.(err);
|
|
2356
2650
|
});
|
|
2357
2651
|
}
|
|
@@ -2393,10 +2687,6 @@ var DurableStream = class DurableStream {
|
|
|
2393
2687
|
* ```
|
|
2394
2688
|
*/
|
|
2395
2689
|
async stream(options) {
|
|
2396
|
-
if (options?.live === `sse` && this.contentType) {
|
|
2397
|
-
const isSSECompatible = SSE_COMPATIBLE_CONTENT_TYPES.some((prefix) => this.contentType.startsWith(prefix));
|
|
2398
|
-
if (!isSSECompatible) throw new DurableStreamError(`SSE is not supported for content-type: ${this.contentType}`, `SSE_NOT_SUPPORTED`, 400);
|
|
2399
|
-
}
|
|
2400
2690
|
const mergedHeaders = {
|
|
2401
2691
|
...this.#options.headers,
|
|
2402
2692
|
...options?.headers
|
|
@@ -2481,4 +2771,4 @@ function validateOptions(options) {
|
|
|
2481
2771
|
}
|
|
2482
2772
|
|
|
2483
2773
|
//#endregion
|
|
2484
|
-
export { BackoffDefaults, CURSOR_QUERY_PARAM, DURABLE_STREAM_PROTOCOL_QUERY_PARAMS, DurableStream, DurableStreamError, FetchBackoffAbortError, FetchError, IdempotentProducer, InvalidSignalError, LIVE_QUERY_PARAM, MissingStreamUrlError, OFFSET_QUERY_PARAM, PRODUCER_EPOCH_HEADER, PRODUCER_EXPECTED_SEQ_HEADER, PRODUCER_ID_HEADER, PRODUCER_RECEIVED_SEQ_HEADER, PRODUCER_SEQ_HEADER, SSE_COMPATIBLE_CONTENT_TYPES, STREAM_CURSOR_HEADER, STREAM_EXPIRES_AT_HEADER, STREAM_OFFSET_HEADER, STREAM_SEQ_HEADER, STREAM_TTL_HEADER, STREAM_UP_TO_DATE_HEADER, SequenceGapError, StaleEpochError, _resetHttpWarningForTesting, asAsyncIterableReadableStream, createFetchWithBackoff, createFetchWithConsumedBody, stream, warnIfUsingHttpInBrowser };
|
|
2774
|
+
export { BackoffDefaults, CURSOR_QUERY_PARAM, DURABLE_STREAM_PROTOCOL_QUERY_PARAMS, DurableStream, DurableStreamError, FetchBackoffAbortError, FetchError, IdempotentProducer, InvalidSignalError, LIVE_QUERY_PARAM, MissingStreamUrlError, OFFSET_QUERY_PARAM, PRODUCER_EPOCH_HEADER, PRODUCER_EXPECTED_SEQ_HEADER, PRODUCER_ID_HEADER, PRODUCER_RECEIVED_SEQ_HEADER, PRODUCER_SEQ_HEADER, SSE_CLOSED_FIELD, SSE_COMPATIBLE_CONTENT_TYPES, STREAM_CLOSED_HEADER, STREAM_CURSOR_HEADER, STREAM_EXPIRES_AT_HEADER, STREAM_OFFSET_HEADER, STREAM_SEQ_HEADER, STREAM_TTL_HEADER, STREAM_UP_TO_DATE_HEADER, SequenceGapError, StaleEpochError, StreamClosedError, _resetHttpWarningForTesting, asAsyncIterableReadableStream, createFetchWithBackoff, createFetchWithConsumedBody, stream, warnIfUsingHttpInBrowser };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@durable-streams/client",
|
|
3
3
|
"description": "TypeScript client for the Durable Streams protocol",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.1",
|
|
5
5
|
"author": "Durable Stream contributors",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"fast-check": "^4.4.0",
|
|
49
49
|
"tsdown": "^0.9.0",
|
|
50
|
-
"@durable-streams/server": "0.1
|
|
50
|
+
"@durable-streams/server": "0.2.1"
|
|
51
51
|
},
|
|
52
52
|
"engines": {
|
|
53
53
|
"node": ">=18.0.0"
|