@durable-streams/server 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/dist/index.cjs +490 -69
- package/dist/index.d.cts +76 -1
- package/dist/index.d.ts +76 -1
- package/dist/index.js +490 -69
- package/package.json +4 -4
- package/src/file-store.ts +259 -11
- package/src/server.ts +357 -54
- package/src/store.ts +201 -7
- package/src/types.ts +17 -0
package/src/server.ts
CHANGED
|
@@ -18,6 +18,7 @@ const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`
|
|
|
18
18
|
const STREAM_SEQ_HEADER = `Stream-Seq`
|
|
19
19
|
const STREAM_TTL_HEADER = `Stream-TTL`
|
|
20
20
|
const STREAM_EXPIRES_AT_HEADER = `Stream-Expires-At`
|
|
21
|
+
const STREAM_SSE_DATA_ENCODING_HEADER = `Stream-SSE-Data-Encoding`
|
|
21
22
|
|
|
22
23
|
// Idempotent producer headers
|
|
23
24
|
const PRODUCER_ID_HEADER = `Producer-Id`
|
|
@@ -30,6 +31,10 @@ const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`
|
|
|
30
31
|
const SSE_OFFSET_FIELD = `streamNextOffset`
|
|
31
32
|
const SSE_CURSOR_FIELD = `streamCursor`
|
|
32
33
|
const SSE_UP_TO_DATE_FIELD = `upToDate`
|
|
34
|
+
const SSE_CLOSED_FIELD = `streamClosed`
|
|
35
|
+
|
|
36
|
+
// Stream closure header
|
|
37
|
+
const STREAM_CLOSED_HEADER = `Stream-Closed`
|
|
33
38
|
|
|
34
39
|
// Query params
|
|
35
40
|
const OFFSET_QUERY_PARAM = `offset`
|
|
@@ -422,11 +427,11 @@ export class DurableStreamTestServer {
|
|
|
422
427
|
)
|
|
423
428
|
res.setHeader(
|
|
424
429
|
`access-control-allow-headers`,
|
|
425
|
-
`content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Producer-Id, Producer-Epoch, Producer-Seq`
|
|
430
|
+
`content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq`
|
|
426
431
|
)
|
|
427
432
|
res.setHeader(
|
|
428
433
|
`access-control-expose-headers`,
|
|
429
|
-
`Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, Producer-Epoch, Producer-Seq, Producer-Expected-Seq, Producer-Received-Seq, etag, content-type, content-encoding, vary`
|
|
434
|
+
`Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, Stream-Closed, Producer-Epoch, Producer-Seq, Producer-Expected-Seq, Producer-Received-Seq, etag, content-type, content-encoding, vary`
|
|
430
435
|
)
|
|
431
436
|
|
|
432
437
|
// Browser security headers (Protocol Section 10.7)
|
|
@@ -561,6 +566,10 @@ export class DurableStreamTestServer {
|
|
|
561
566
|
STREAM_EXPIRES_AT_HEADER.toLowerCase()
|
|
562
567
|
] as string | undefined
|
|
563
568
|
|
|
569
|
+
// Parse Stream-Closed header
|
|
570
|
+
const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()]
|
|
571
|
+
const createClosed = closedHeader === `true`
|
|
572
|
+
|
|
564
573
|
// Validate TTL and Expires-At headers
|
|
565
574
|
if (ttlHeader && expiresAtHeader) {
|
|
566
575
|
res.writeHead(400, { "content-type": `text/plain` })
|
|
@@ -609,6 +618,7 @@ export class DurableStreamTestServer {
|
|
|
609
618
|
ttlSeconds,
|
|
610
619
|
expiresAt: expiresAtHeader,
|
|
611
620
|
initialData: body.length > 0 ? body : undefined,
|
|
621
|
+
closed: createClosed,
|
|
612
622
|
})
|
|
613
623
|
)
|
|
614
624
|
|
|
@@ -637,6 +647,11 @@ export class DurableStreamTestServer {
|
|
|
637
647
|
headers[`location`] = `${this._url}${path}`
|
|
638
648
|
}
|
|
639
649
|
|
|
650
|
+
// Include Stream-Closed header if created closed
|
|
651
|
+
if (stream.closed) {
|
|
652
|
+
headers[STREAM_CLOSED_HEADER] = `true`
|
|
653
|
+
}
|
|
654
|
+
|
|
640
655
|
res.writeHead(isNew ? 201 : 200, headers)
|
|
641
656
|
res.end()
|
|
642
657
|
}
|
|
@@ -662,9 +677,16 @@ export class DurableStreamTestServer {
|
|
|
662
677
|
headers[`content-type`] = stream.contentType
|
|
663
678
|
}
|
|
664
679
|
|
|
665
|
-
//
|
|
680
|
+
// Include Stream-Closed if stream is closed
|
|
681
|
+
if (stream.closed) {
|
|
682
|
+
headers[STREAM_CLOSED_HEADER] = `true`
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Generate ETag: {path}:-1:{offset}[:c] (includes closure status)
|
|
686
|
+
// The :c suffix ensures ETag changes when a stream is closed, even without new data
|
|
687
|
+
const closedSuffix = stream.closed ? `:c` : ``
|
|
666
688
|
headers[`etag`] =
|
|
667
|
-
`"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}"`
|
|
689
|
+
`"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}${closedSuffix}"`
|
|
668
690
|
|
|
669
691
|
res.writeHead(200, headers)
|
|
670
692
|
res.end()
|
|
@@ -726,11 +748,20 @@ export class DurableStreamTestServer {
|
|
|
726
748
|
return
|
|
727
749
|
}
|
|
728
750
|
|
|
751
|
+
// Determine if this is a binary stream that needs base64 encoding in SSE mode
|
|
752
|
+
let useBase64 = false
|
|
753
|
+
if (live === `sse`) {
|
|
754
|
+
const ct = stream.contentType?.toLowerCase().split(`;`)[0]?.trim() ?? ``
|
|
755
|
+
const isTextCompatible =
|
|
756
|
+
ct.startsWith(`text/`) || ct === `application/json`
|
|
757
|
+
useBase64 = !isTextCompatible
|
|
758
|
+
}
|
|
759
|
+
|
|
729
760
|
// Handle SSE mode
|
|
730
761
|
if (live === `sse`) {
|
|
731
762
|
// For SSE with offset=now, convert to actual tail offset
|
|
732
763
|
const sseOffset = offset === `now` ? stream.currentOffset : offset!
|
|
733
|
-
await this.handleSSE(path, stream, sseOffset, cursor, res)
|
|
764
|
+
await this.handleSSE(path, stream, sseOffset, cursor, useBase64, res)
|
|
734
765
|
return
|
|
735
766
|
}
|
|
736
767
|
|
|
@@ -752,6 +783,11 @@ export class DurableStreamTestServer {
|
|
|
752
783
|
headers[`content-type`] = stream.contentType
|
|
753
784
|
}
|
|
754
785
|
|
|
786
|
+
// Include Stream-Closed if stream is closed (client at tail, upToDate)
|
|
787
|
+
if (stream.closed) {
|
|
788
|
+
headers[STREAM_CLOSED_HEADER] = `true`
|
|
789
|
+
}
|
|
790
|
+
|
|
755
791
|
// No ETag for offset=now responses - Cache-Control: no-store makes ETag unnecessary
|
|
756
792
|
// and some CDNs may behave unexpectedly with both headers
|
|
757
793
|
|
|
@@ -776,12 +812,39 @@ export class DurableStreamTestServer {
|
|
|
776
812
|
(effectiveOffset && effectiveOffset === stream.currentOffset) ||
|
|
777
813
|
offset === `now`
|
|
778
814
|
if (live === `long-poll` && clientIsCaughtUp && messages.length === 0) {
|
|
815
|
+
// If stream is closed and client is at tail, return immediately (don't wait)
|
|
816
|
+
if (stream.closed) {
|
|
817
|
+
res.writeHead(204, {
|
|
818
|
+
[STREAM_OFFSET_HEADER]: stream.currentOffset,
|
|
819
|
+
[STREAM_UP_TO_DATE_HEADER]: `true`,
|
|
820
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
821
|
+
})
|
|
822
|
+
res.end()
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
|
|
779
826
|
const result = await this.store.waitForMessages(
|
|
780
827
|
path,
|
|
781
828
|
effectiveOffset ?? stream.currentOffset,
|
|
782
829
|
this.options.longPollTimeout
|
|
783
830
|
)
|
|
784
831
|
|
|
832
|
+
// If stream was closed during wait, return immediately with Stream-Closed
|
|
833
|
+
if (result.streamClosed) {
|
|
834
|
+
const responseCursor = generateResponseCursor(
|
|
835
|
+
cursor,
|
|
836
|
+
this.options.cursorOptions
|
|
837
|
+
)
|
|
838
|
+
res.writeHead(204, {
|
|
839
|
+
[STREAM_OFFSET_HEADER]: effectiveOffset ?? stream.currentOffset,
|
|
840
|
+
[STREAM_UP_TO_DATE_HEADER]: `true`,
|
|
841
|
+
[STREAM_CURSOR_HEADER]: responseCursor,
|
|
842
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
843
|
+
})
|
|
844
|
+
res.end()
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
|
|
785
848
|
if (result.timedOut) {
|
|
786
849
|
// Return 204 No Content on timeout (per Protocol Section 5.6)
|
|
787
850
|
// Generate cursor for CDN cache collapsing (Protocol Section 8.1)
|
|
@@ -789,11 +852,17 @@ export class DurableStreamTestServer {
|
|
|
789
852
|
cursor,
|
|
790
853
|
this.options.cursorOptions
|
|
791
854
|
)
|
|
792
|
-
|
|
855
|
+
// Check if stream was closed during the wait
|
|
856
|
+
const currentStream = this.store.get(path)
|
|
857
|
+
const timeoutHeaders: Record<string, string> = {
|
|
793
858
|
[STREAM_OFFSET_HEADER]: effectiveOffset ?? stream.currentOffset,
|
|
794
859
|
[STREAM_UP_TO_DATE_HEADER]: `true`,
|
|
795
860
|
[STREAM_CURSOR_HEADER]: responseCursor,
|
|
796
|
-
}
|
|
861
|
+
}
|
|
862
|
+
if (currentStream?.closed) {
|
|
863
|
+
timeoutHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
864
|
+
}
|
|
865
|
+
res.writeHead(204, timeoutHeaders)
|
|
797
866
|
res.end()
|
|
798
867
|
return
|
|
799
868
|
}
|
|
@@ -827,9 +896,20 @@ export class DurableStreamTestServer {
|
|
|
827
896
|
headers[STREAM_UP_TO_DATE_HEADER] = `true`
|
|
828
897
|
}
|
|
829
898
|
|
|
830
|
-
//
|
|
899
|
+
// Include Stream-Closed when stream is closed AND client is at tail AND upToDate
|
|
900
|
+
// Re-fetch stream to get current state (may have been closed during request)
|
|
901
|
+
const currentStream = this.store.get(path)
|
|
902
|
+
const clientAtTail = responseOffset === currentStream?.currentOffset
|
|
903
|
+
if (currentStream?.closed && clientAtTail && upToDate) {
|
|
904
|
+
headers[STREAM_CLOSED_HEADER] = `true`
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Generate ETag: based on path, start offset, end offset, and closure status
|
|
908
|
+
// The :c suffix ensures ETag changes when a stream is closed, even without new data
|
|
831
909
|
const startOffset = offset ?? `-1`
|
|
832
|
-
const
|
|
910
|
+
const closedSuffix =
|
|
911
|
+
currentStream?.closed && clientAtTail && upToDate ? `:c` : ``
|
|
912
|
+
const etag = `"${Buffer.from(path).toString(`base64`)}:${startOffset}:${responseOffset}${closedSuffix}"`
|
|
833
913
|
headers[`etag`] = etag
|
|
834
914
|
|
|
835
915
|
// Check If-None-Match for conditional GET (Protocol Section 8.1)
|
|
@@ -850,10 +930,10 @@ export class DurableStreamTestServer {
|
|
|
850
930
|
responseData.length >= COMPRESSION_THRESHOLD
|
|
851
931
|
) {
|
|
852
932
|
const acceptEncoding = req.headers[`accept-encoding`]
|
|
853
|
-
const
|
|
854
|
-
if (
|
|
855
|
-
finalData = compressData(responseData,
|
|
856
|
-
headers[`content-encoding`] =
|
|
933
|
+
const compressionEncoding = getCompressionEncoding(acceptEncoding)
|
|
934
|
+
if (compressionEncoding) {
|
|
935
|
+
finalData = compressData(responseData, compressionEncoding)
|
|
936
|
+
headers[`content-encoding`] = compressionEncoding
|
|
857
937
|
// Add Vary header to indicate response varies by Accept-Encoding
|
|
858
938
|
headers[`vary`] = `accept-encoding`
|
|
859
939
|
}
|
|
@@ -874,20 +954,28 @@ export class DurableStreamTestServer {
|
|
|
874
954
|
stream: ReturnType<StreamStore[`get`]>,
|
|
875
955
|
initialOffset: string,
|
|
876
956
|
cursor: string | undefined,
|
|
957
|
+
useBase64: boolean,
|
|
877
958
|
res: ServerResponse
|
|
878
959
|
): Promise<void> {
|
|
879
960
|
// Track this SSE connection
|
|
880
961
|
this.activeSSEResponses.add(res)
|
|
881
962
|
|
|
882
963
|
// Set SSE headers (explicitly including security headers for clarity)
|
|
883
|
-
|
|
964
|
+
const sseHeaders: Record<string, string> = {
|
|
884
965
|
"content-type": `text/event-stream`,
|
|
885
966
|
"cache-control": `no-cache`,
|
|
886
967
|
connection: `keep-alive`,
|
|
887
968
|
"access-control-allow-origin": `*`,
|
|
888
969
|
"x-content-type-options": `nosniff`,
|
|
889
970
|
"cross-origin-resource-policy": `cross-origin`,
|
|
890
|
-
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Add encoding header when base64 encoding is used for binary streams
|
|
974
|
+
if (useBase64) {
|
|
975
|
+
sseHeaders[STREAM_SSE_DATA_ENCODING_HEADER] = `base64`
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
res.writeHead(200, sseHeaders)
|
|
891
979
|
|
|
892
980
|
// Check for injected SSE event (for testing SSE parsing)
|
|
893
981
|
const fault = (res as ServerResponse & { _injectedFault?: InjectedFault })
|
|
@@ -920,9 +1008,12 @@ export class DurableStreamTestServer {
|
|
|
920
1008
|
|
|
921
1009
|
// Send data events for each message
|
|
922
1010
|
for (const message of messages) {
|
|
923
|
-
// Format data based on content type
|
|
1011
|
+
// Format data based on content type and encoding
|
|
924
1012
|
let dataPayload: string
|
|
925
|
-
if (
|
|
1013
|
+
if (useBase64) {
|
|
1014
|
+
// Base64 encode binary data (Protocol Section 5.7)
|
|
1015
|
+
dataPayload = Buffer.from(message.data).toString(`base64`)
|
|
1016
|
+
} else if (isJsonStream) {
|
|
926
1017
|
// Use formatResponse to get properly formatted JSON (strips trailing commas)
|
|
927
1018
|
const jsonBytes = this.store.formatResponse(path, [message])
|
|
928
1019
|
dataPayload = decoder.decode(jsonBytes)
|
|
@@ -939,8 +1030,14 @@ export class DurableStreamTestServer {
|
|
|
939
1030
|
}
|
|
940
1031
|
|
|
941
1032
|
// Compute offset the same way as HTTP GET: last message's offset, or stream's current offset
|
|
1033
|
+
// Re-fetch stream to get current state (may have been closed)
|
|
1034
|
+
const currentStream = this.store.get(path)
|
|
942
1035
|
const controlOffset =
|
|
943
|
-
messages[messages.length - 1]?.offset ??
|
|
1036
|
+
messages[messages.length - 1]?.offset ?? currentStream!.currentOffset
|
|
1037
|
+
|
|
1038
|
+
// Check if stream is closed and client is at tail
|
|
1039
|
+
const streamIsClosed = currentStream?.closed ?? false
|
|
1040
|
+
const clientAtTail = controlOffset === currentStream!.currentOffset
|
|
944
1041
|
|
|
945
1042
|
// Send control event with current offset/cursor (Protocol Section 5.7)
|
|
946
1043
|
// Generate cursor for CDN cache collapsing (Protocol Section 8.1)
|
|
@@ -950,22 +1047,47 @@ export class DurableStreamTestServer {
|
|
|
950
1047
|
)
|
|
951
1048
|
const controlData: Record<string, string | boolean> = {
|
|
952
1049
|
[SSE_OFFSET_FIELD]: controlOffset,
|
|
953
|
-
[SSE_CURSOR_FIELD]: responseCursor,
|
|
954
1050
|
}
|
|
955
1051
|
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1052
|
+
if (streamIsClosed && clientAtTail) {
|
|
1053
|
+
// Final control event - stream is closed
|
|
1054
|
+
// streamCursor is omitted when streamClosed is true per protocol
|
|
1055
|
+
// upToDate is implied by streamClosed per protocol
|
|
1056
|
+
controlData[SSE_CLOSED_FIELD] = true
|
|
1057
|
+
} else {
|
|
1058
|
+
// Normal control event - include cursor
|
|
1059
|
+
controlData[SSE_CURSOR_FIELD] = responseCursor
|
|
1060
|
+
// Include upToDate flag when client has caught up to head
|
|
1061
|
+
if (upToDate) {
|
|
1062
|
+
controlData[SSE_UP_TO_DATE_FIELD] = true
|
|
1063
|
+
}
|
|
959
1064
|
}
|
|
960
1065
|
|
|
961
1066
|
res.write(`event: control\n`)
|
|
962
1067
|
res.write(encodeSSEData(JSON.stringify(controlData)))
|
|
963
1068
|
|
|
1069
|
+
// Close SSE connection after sending streamClosed
|
|
1070
|
+
if (streamIsClosed && clientAtTail) {
|
|
1071
|
+
break // Exit loop, connection will be closed
|
|
1072
|
+
}
|
|
1073
|
+
|
|
964
1074
|
// Update currentOffset for next iteration (use controlOffset for consistency)
|
|
965
1075
|
currentOffset = controlOffset
|
|
966
1076
|
|
|
967
1077
|
// If caught up, wait for new messages
|
|
968
1078
|
if (upToDate) {
|
|
1079
|
+
// Check if stream was closed during processing (before wait)
|
|
1080
|
+
if (currentStream?.closed) {
|
|
1081
|
+
// Send final control event and exit
|
|
1082
|
+
const finalControlData: Record<string, string | boolean> = {
|
|
1083
|
+
[SSE_OFFSET_FIELD]: currentOffset,
|
|
1084
|
+
[SSE_CLOSED_FIELD]: true,
|
|
1085
|
+
}
|
|
1086
|
+
res.write(`event: control\n`)
|
|
1087
|
+
res.write(encodeSSEData(JSON.stringify(finalControlData)))
|
|
1088
|
+
break
|
|
1089
|
+
}
|
|
1090
|
+
|
|
969
1091
|
const result = await this.store.waitForMessages(
|
|
970
1092
|
path,
|
|
971
1093
|
currentOffset,
|
|
@@ -976,6 +1098,17 @@ export class DurableStreamTestServer {
|
|
|
976
1098
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
977
1099
|
if (this.isShuttingDown || !isConnected) break
|
|
978
1100
|
|
|
1101
|
+
// Check if stream was closed during wait
|
|
1102
|
+
if (result.streamClosed) {
|
|
1103
|
+
const finalControlData: Record<string, string | boolean> = {
|
|
1104
|
+
[SSE_OFFSET_FIELD]: currentOffset,
|
|
1105
|
+
[SSE_CLOSED_FIELD]: true,
|
|
1106
|
+
}
|
|
1107
|
+
res.write(`event: control\n`)
|
|
1108
|
+
res.write(encodeSSEData(JSON.stringify(finalControlData)))
|
|
1109
|
+
break
|
|
1110
|
+
}
|
|
1111
|
+
|
|
979
1112
|
if (result.timedOut) {
|
|
980
1113
|
// Send keep-alive control event on timeout (Protocol Section 5.7)
|
|
981
1114
|
// Generate cursor for CDN cache collapsing (Protocol Section 8.1)
|
|
@@ -983,13 +1116,28 @@ export class DurableStreamTestServer {
|
|
|
983
1116
|
cursor,
|
|
984
1117
|
this.options.cursorOptions
|
|
985
1118
|
)
|
|
1119
|
+
|
|
1120
|
+
// Check if stream was closed during the wait
|
|
1121
|
+
const streamAfterWait = this.store.get(path)
|
|
1122
|
+
if (streamAfterWait?.closed) {
|
|
1123
|
+
const closedControlData: Record<string, string | boolean> = {
|
|
1124
|
+
[SSE_OFFSET_FIELD]: currentOffset,
|
|
1125
|
+
[SSE_CLOSED_FIELD]: true,
|
|
1126
|
+
}
|
|
1127
|
+
res.write(`event: control\n`)
|
|
1128
|
+
res.write(encodeSSEData(JSON.stringify(closedControlData)))
|
|
1129
|
+
break
|
|
1130
|
+
}
|
|
1131
|
+
|
|
986
1132
|
const keepAliveData: Record<string, string | boolean> = {
|
|
987
1133
|
[SSE_OFFSET_FIELD]: currentOffset,
|
|
988
1134
|
[SSE_CURSOR_FIELD]: keepAliveCursor,
|
|
989
1135
|
[SSE_UP_TO_DATE_FIELD]: true, // Still caught up after timeout
|
|
990
1136
|
}
|
|
991
|
-
|
|
992
|
-
res.write(
|
|
1137
|
+
// Single write for keep-alive control event
|
|
1138
|
+
res.write(
|
|
1139
|
+
`event: control\n` + encodeSSEData(JSON.stringify(keepAliveData))
|
|
1140
|
+
)
|
|
993
1141
|
}
|
|
994
1142
|
// Loop will continue and read new messages
|
|
995
1143
|
}
|
|
@@ -1012,6 +1160,10 @@ export class DurableStreamTestServer {
|
|
|
1012
1160
|
| string
|
|
1013
1161
|
| undefined
|
|
1014
1162
|
|
|
1163
|
+
// Parse Stream-Closed header
|
|
1164
|
+
const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()]
|
|
1165
|
+
const closeStream = closedHeader === `true`
|
|
1166
|
+
|
|
1015
1167
|
// Extract producer headers
|
|
1016
1168
|
const producerId = req.headers[PRODUCER_ID_HEADER.toLowerCase()] as
|
|
1017
1169
|
| string
|
|
@@ -1023,23 +1175,8 @@ export class DurableStreamTestServer {
|
|
|
1023
1175
|
| string
|
|
1024
1176
|
| undefined
|
|
1025
1177
|
|
|
1026
|
-
const body = await this.readBody(req)
|
|
1027
|
-
|
|
1028
|
-
if (body.length === 0) {
|
|
1029
|
-
res.writeHead(400, { "content-type": `text/plain` })
|
|
1030
|
-
res.end(`Empty body`)
|
|
1031
|
-
return
|
|
1032
|
-
}
|
|
1033
|
-
|
|
1034
|
-
// Content-Type is required per protocol
|
|
1035
|
-
if (!contentType) {
|
|
1036
|
-
res.writeHead(400, { "content-type": `text/plain` })
|
|
1037
|
-
res.end(`Content-Type header is required`)
|
|
1038
|
-
return
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
1178
|
// Validate producer headers - all three must be present together or none
|
|
1042
|
-
// Also reject empty producer ID
|
|
1179
|
+
// Also reject empty producer ID (do this before reading body)
|
|
1043
1180
|
const hasProducerHeaders =
|
|
1044
1181
|
producerId !== undefined ||
|
|
1045
1182
|
producerEpochStr !== undefined ||
|
|
@@ -1094,13 +1231,126 @@ export class DurableStreamTestServer {
|
|
|
1094
1231
|
}
|
|
1095
1232
|
}
|
|
1096
1233
|
|
|
1097
|
-
|
|
1234
|
+
const body = await this.readBody(req)
|
|
1235
|
+
|
|
1236
|
+
// Handle close-only request (empty body with Stream-Closed: true)
|
|
1237
|
+
// Note: Content-Type validation is skipped for close-only requests per protocol Section 5.2
|
|
1238
|
+
if (body.length === 0 && closeStream) {
|
|
1239
|
+
// Close-only with producer headers participates in producer sequencing
|
|
1240
|
+
if (hasAllProducerHeaders) {
|
|
1241
|
+
const closeResult = await this.store.closeStreamWithProducer(path, {
|
|
1242
|
+
producerId: producerId,
|
|
1243
|
+
producerEpoch: producerEpoch!,
|
|
1244
|
+
producerSeq: producerSeq!,
|
|
1245
|
+
})
|
|
1246
|
+
|
|
1247
|
+
if (!closeResult) {
|
|
1248
|
+
res.writeHead(404, { "content-type": `text/plain` })
|
|
1249
|
+
res.end(`Stream not found`)
|
|
1250
|
+
return
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// Handle producer validation results
|
|
1254
|
+
if (closeResult.producerResult?.status === `duplicate`) {
|
|
1255
|
+
res.writeHead(204, {
|
|
1256
|
+
[STREAM_OFFSET_HEADER]: closeResult.finalOffset,
|
|
1257
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
1258
|
+
[PRODUCER_EPOCH_HEADER]: producerEpoch!.toString(),
|
|
1259
|
+
[PRODUCER_SEQ_HEADER]:
|
|
1260
|
+
closeResult.producerResult.lastSeq.toString(),
|
|
1261
|
+
})
|
|
1262
|
+
res.end()
|
|
1263
|
+
return
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (closeResult.producerResult?.status === `stale_epoch`) {
|
|
1267
|
+
res.writeHead(403, {
|
|
1268
|
+
"content-type": `text/plain`,
|
|
1269
|
+
[PRODUCER_EPOCH_HEADER]:
|
|
1270
|
+
closeResult.producerResult.currentEpoch.toString(),
|
|
1271
|
+
})
|
|
1272
|
+
res.end(`Stale producer epoch`)
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (closeResult.producerResult?.status === `invalid_epoch_seq`) {
|
|
1277
|
+
res.writeHead(400, { "content-type": `text/plain` })
|
|
1278
|
+
res.end(`New epoch must start with sequence 0`)
|
|
1279
|
+
return
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
if (closeResult.producerResult?.status === `sequence_gap`) {
|
|
1283
|
+
res.writeHead(409, {
|
|
1284
|
+
"content-type": `text/plain`,
|
|
1285
|
+
[PRODUCER_EXPECTED_SEQ_HEADER]:
|
|
1286
|
+
closeResult.producerResult.expectedSeq.toString(),
|
|
1287
|
+
[PRODUCER_RECEIVED_SEQ_HEADER]:
|
|
1288
|
+
closeResult.producerResult.receivedSeq.toString(),
|
|
1289
|
+
})
|
|
1290
|
+
res.end(`Producer sequence gap`)
|
|
1291
|
+
return
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
// Stream already closed by a different producer - conflict
|
|
1295
|
+
if (closeResult.producerResult?.status === `stream_closed`) {
|
|
1296
|
+
const stream = this.store.get(path)
|
|
1297
|
+
res.writeHead(409, {
|
|
1298
|
+
"content-type": `text/plain`,
|
|
1299
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
1300
|
+
[STREAM_OFFSET_HEADER]: stream?.currentOffset ?? ``,
|
|
1301
|
+
})
|
|
1302
|
+
res.end(`Stream is closed`)
|
|
1303
|
+
return
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
res.writeHead(204, {
|
|
1307
|
+
[STREAM_OFFSET_HEADER]: closeResult.finalOffset,
|
|
1308
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
1309
|
+
[PRODUCER_EPOCH_HEADER]: producerEpoch!.toString(),
|
|
1310
|
+
[PRODUCER_SEQ_HEADER]: producerSeq!.toString(),
|
|
1311
|
+
})
|
|
1312
|
+
res.end()
|
|
1313
|
+
return
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// Close-only without producer headers (simple idempotent close)
|
|
1317
|
+
const closeResult = this.store.closeStream(path)
|
|
1318
|
+
if (!closeResult) {
|
|
1319
|
+
res.writeHead(404, { "content-type": `text/plain` })
|
|
1320
|
+
res.end(`Stream not found`)
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
res.writeHead(204, {
|
|
1325
|
+
[STREAM_OFFSET_HEADER]: closeResult.finalOffset,
|
|
1326
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
1327
|
+
})
|
|
1328
|
+
res.end()
|
|
1329
|
+
return
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// Empty body without Stream-Closed is an error
|
|
1333
|
+
if (body.length === 0) {
|
|
1334
|
+
res.writeHead(400, { "content-type": `text/plain` })
|
|
1335
|
+
res.end(`Empty body`)
|
|
1336
|
+
return
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
// Content-Type is required per protocol (for requests with body)
|
|
1340
|
+
if (!contentType) {
|
|
1341
|
+
res.writeHead(400, { "content-type": `text/plain` })
|
|
1342
|
+
res.end(`Content-Type header is required`)
|
|
1343
|
+
return
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
// Build append options (include close flag for append-and-close)
|
|
1098
1347
|
const appendOptions = {
|
|
1099
1348
|
seq,
|
|
1100
1349
|
contentType,
|
|
1101
1350
|
producerId,
|
|
1102
1351
|
producerEpoch,
|
|
1103
1352
|
producerSeq,
|
|
1353
|
+
close: closeStream,
|
|
1104
1354
|
}
|
|
1105
1355
|
|
|
1106
1356
|
// Use appendWithProducer for serialized producer operations
|
|
@@ -1113,9 +1363,45 @@ export class DurableStreamTestServer {
|
|
|
1113
1363
|
)
|
|
1114
1364
|
}
|
|
1115
1365
|
|
|
1116
|
-
// Handle AppendResult with producer validation
|
|
1117
|
-
if (result && typeof result === `object` && `
|
|
1118
|
-
const { message, producerResult } = result
|
|
1366
|
+
// Handle AppendResult with producer validation or streamClosed
|
|
1367
|
+
if (result && typeof result === `object` && `message` in result) {
|
|
1368
|
+
const { message, producerResult, streamClosed } = result as {
|
|
1369
|
+
message: { offset: string } | null
|
|
1370
|
+
producerResult?: {
|
|
1371
|
+
status: string
|
|
1372
|
+
lastSeq?: number
|
|
1373
|
+
currentEpoch?: number
|
|
1374
|
+
expectedSeq?: number
|
|
1375
|
+
receivedSeq?: number
|
|
1376
|
+
}
|
|
1377
|
+
streamClosed?: boolean
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Handle append to closed stream
|
|
1381
|
+
if (streamClosed && !message) {
|
|
1382
|
+
// Check if this is an idempotent producer duplicate (matching closing tuple)
|
|
1383
|
+
if (producerResult?.status === `duplicate`) {
|
|
1384
|
+
const stream = this.store.get(path)
|
|
1385
|
+
res.writeHead(204, {
|
|
1386
|
+
[STREAM_OFFSET_HEADER]: stream?.currentOffset ?? ``,
|
|
1387
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
1388
|
+
[PRODUCER_EPOCH_HEADER]: producerEpoch!.toString(),
|
|
1389
|
+
[PRODUCER_SEQ_HEADER]: producerResult.lastSeq!.toString(),
|
|
1390
|
+
})
|
|
1391
|
+
res.end()
|
|
1392
|
+
return
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Not a duplicate - stream was closed by different request, return 409
|
|
1396
|
+
const closedStream = this.store.get(path)
|
|
1397
|
+
res.writeHead(409, {
|
|
1398
|
+
"content-type": `text/plain`,
|
|
1399
|
+
[STREAM_CLOSED_HEADER]: `true`,
|
|
1400
|
+
[STREAM_OFFSET_HEADER]: closedStream?.currentOffset ?? ``,
|
|
1401
|
+
})
|
|
1402
|
+
res.end(`Stream is closed`)
|
|
1403
|
+
return
|
|
1404
|
+
}
|
|
1119
1405
|
|
|
1120
1406
|
if (!producerResult || producerResult.status === `accepted`) {
|
|
1121
1407
|
// Success - return offset
|
|
@@ -1129,28 +1415,40 @@ export class DurableStreamTestServer {
|
|
|
1129
1415
|
if (producerSeq !== undefined) {
|
|
1130
1416
|
responseHeaders[PRODUCER_SEQ_HEADER] = producerSeq.toString()
|
|
1131
1417
|
}
|
|
1132
|
-
|
|
1418
|
+
// Include Stream-Closed if stream was closed with this append
|
|
1419
|
+
if (streamClosed) {
|
|
1420
|
+
responseHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
1421
|
+
}
|
|
1422
|
+
// Use 200 for producer appends (with headers), 204 for non-producer appends
|
|
1423
|
+
const statusCode = producerId !== undefined ? 200 : 204
|
|
1424
|
+
res.writeHead(statusCode, responseHeaders)
|
|
1133
1425
|
res.end()
|
|
1134
1426
|
return
|
|
1135
1427
|
}
|
|
1136
1428
|
|
|
1137
1429
|
// Handle producer validation failures
|
|
1138
1430
|
switch (producerResult.status) {
|
|
1139
|
-
case `duplicate`:
|
|
1431
|
+
case `duplicate`: {
|
|
1140
1432
|
// 204 No Content for duplicates (idempotent success)
|
|
1141
1433
|
// Return Producer-Seq as highest accepted (per PROTOCOL.md)
|
|
1142
|
-
|
|
1434
|
+
const dupHeaders: Record<string, string> = {
|
|
1143
1435
|
[PRODUCER_EPOCH_HEADER]: producerEpoch!.toString(),
|
|
1144
|
-
[PRODUCER_SEQ_HEADER]: producerResult.lastSeq
|
|
1145
|
-
}
|
|
1436
|
+
[PRODUCER_SEQ_HEADER]: producerResult.lastSeq!.toString(),
|
|
1437
|
+
}
|
|
1438
|
+
// Include Stream-Closed if the stream is now closed
|
|
1439
|
+
if (streamClosed) {
|
|
1440
|
+
dupHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
1441
|
+
}
|
|
1442
|
+
res.writeHead(204, dupHeaders)
|
|
1146
1443
|
res.end()
|
|
1147
1444
|
return
|
|
1445
|
+
}
|
|
1148
1446
|
|
|
1149
1447
|
case `stale_epoch`: {
|
|
1150
1448
|
// 403 Forbidden for stale epochs (zombie fencing)
|
|
1151
1449
|
res.writeHead(403, {
|
|
1152
1450
|
"content-type": `text/plain`,
|
|
1153
|
-
[PRODUCER_EPOCH_HEADER]: producerResult.currentEpoch
|
|
1451
|
+
[PRODUCER_EPOCH_HEADER]: producerResult.currentEpoch!.toString(),
|
|
1154
1452
|
})
|
|
1155
1453
|
res.end(`Stale producer epoch`)
|
|
1156
1454
|
return
|
|
@@ -1167,9 +1465,9 @@ export class DurableStreamTestServer {
|
|
|
1167
1465
|
res.writeHead(409, {
|
|
1168
1466
|
"content-type": `text/plain`,
|
|
1169
1467
|
[PRODUCER_EXPECTED_SEQ_HEADER]:
|
|
1170
|
-
producerResult.expectedSeq
|
|
1468
|
+
producerResult.expectedSeq!.toString(),
|
|
1171
1469
|
[PRODUCER_RECEIVED_SEQ_HEADER]:
|
|
1172
|
-
producerResult.receivedSeq
|
|
1470
|
+
producerResult.receivedSeq!.toString(),
|
|
1173
1471
|
})
|
|
1174
1472
|
res.end(`Producer sequence gap`)
|
|
1175
1473
|
return
|
|
@@ -1178,9 +1476,14 @@ export class DurableStreamTestServer {
|
|
|
1178
1476
|
|
|
1179
1477
|
// Standard append (no producer) - result is StreamMessage
|
|
1180
1478
|
const message = result as { offset: string }
|
|
1181
|
-
|
|
1479
|
+
const responseHeaders: Record<string, string> = {
|
|
1182
1480
|
[STREAM_OFFSET_HEADER]: message.offset,
|
|
1183
|
-
}
|
|
1481
|
+
}
|
|
1482
|
+
// Include Stream-Closed if stream was closed with this append
|
|
1483
|
+
if (closeStream) {
|
|
1484
|
+
responseHeaders[STREAM_CLOSED_HEADER] = `true`
|
|
1485
|
+
}
|
|
1486
|
+
res.writeHead(204, responseHeaders)
|
|
1184
1487
|
res.end()
|
|
1185
1488
|
}
|
|
1186
1489
|
|