@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/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
- // Generate ETag: {path}:-1:{offset} (consistent with GET format)
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
- res.writeHead(204, {
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
- // Generate ETag: based on path, start offset, and end offset
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 etag = `"${Buffer.from(path).toString(`base64`)}:${startOffset}:${responseOffset}"`
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 encoding = getCompressionEncoding(acceptEncoding)
854
- if (encoding) {
855
- finalData = compressData(responseData, encoding)
856
- headers[`content-encoding`] = 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
- res.writeHead(200, {
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 (isJsonStream) {
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 ?? stream!.currentOffset
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
- // Include upToDate flag when client has caught up to head
957
- if (upToDate) {
958
- controlData[SSE_UP_TO_DATE_FIELD] = true
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
- res.write(`event: control\n`)
992
- res.write(encodeSSEData(JSON.stringify(keepAliveData)))
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
- // Build append options
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` && `producerResult` in result) {
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
- res.writeHead(200, responseHeaders)
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
- res.writeHead(204, {
1434
+ const dupHeaders: Record<string, string> = {
1143
1435
  [PRODUCER_EPOCH_HEADER]: producerEpoch!.toString(),
1144
- [PRODUCER_SEQ_HEADER]: producerResult.lastSeq.toString(),
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.toString(),
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.toString(),
1468
+ producerResult.expectedSeq!.toString(),
1171
1469
  [PRODUCER_RECEIVED_SEQ_HEADER]:
1172
- producerResult.receivedSeq.toString(),
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
- res.writeHead(204, {
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