@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.js CHANGED
@@ -109,7 +109,8 @@ var StreamStore = class {
109
109
  const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
110
110
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
111
111
  const expiresMatches = options.expiresAt === existing.expiresAt;
112
- if (contentTypeMatches && ttlMatches && expiresMatches) return existing;
112
+ const closedMatches = (options.closed ?? false) === (existing.closed ?? false);
113
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches) return existing;
113
114
  else throw new Error(`Stream already exists with different configuration: ${path$2}`);
114
115
  }
115
116
  const stream = {
@@ -119,7 +120,8 @@ var StreamStore = class {
119
120
  currentOffset: `0000000000000000_0000000000000000`,
120
121
  ttlSeconds: options.ttlSeconds,
121
122
  expiresAt: options.expiresAt,
122
- createdAt: Date.now()
123
+ createdAt: Date.now(),
124
+ closed: options.closed ?? false
123
125
  };
124
126
  if (options.initialData && options.initialData.length > 0) this.appendToStream(stream, options.initialData, true);
125
127
  this.streams.set(path$2, stream);
@@ -256,6 +258,20 @@ var StreamStore = class {
256
258
  append(path$2, data, options = {}) {
257
259
  const stream = this.getIfNotExpired(path$2);
258
260
  if (!stream) throw new Error(`Stream not found: ${path$2}`);
261
+ if (stream.closed) {
262
+ if (options.producerId && stream.closedBy && stream.closedBy.producerId === options.producerId && stream.closedBy.epoch === options.producerEpoch && stream.closedBy.seq === options.producerSeq) return {
263
+ message: null,
264
+ streamClosed: true,
265
+ producerResult: {
266
+ status: `duplicate`,
267
+ lastSeq: options.producerSeq
268
+ }
269
+ };
270
+ return {
271
+ message: null,
272
+ streamClosed: true
273
+ };
274
+ }
259
275
  if (options.contentType && stream.contentType) {
260
276
  const providedType = normalizeContentType(options.contentType);
261
277
  const streamType = normalizeContentType(stream.contentType);
@@ -275,10 +291,20 @@ var StreamStore = class {
275
291
  const message = this.appendToStream(stream, data);
276
292
  if (producerResult) this.commitProducerState(stream, producerResult);
277
293
  if (options.seq !== void 0) stream.lastSeq = options.seq;
294
+ if (options.close) {
295
+ stream.closed = true;
296
+ if (options.producerId !== void 0) stream.closedBy = {
297
+ producerId: options.producerId,
298
+ epoch: options.producerEpoch,
299
+ seq: options.producerSeq
300
+ };
301
+ this.notifyLongPollsClosed(path$2);
302
+ }
278
303
  this.notifyLongPolls(path$2);
279
- if (producerResult) return {
304
+ if (producerResult || options.close) return {
280
305
  message,
281
- producerResult
306
+ producerResult,
307
+ streamClosed: options.close
282
308
  };
283
309
  return message;
284
310
  }
@@ -302,6 +328,69 @@ var StreamStore = class {
302
328
  }
303
329
  }
304
330
  /**
331
+ * Close a stream without appending data.
332
+ * @returns The final offset, or null if stream doesn't exist
333
+ */
334
+ closeStream(path$2) {
335
+ const stream = this.getIfNotExpired(path$2);
336
+ if (!stream) return null;
337
+ const alreadyClosed = stream.closed ?? false;
338
+ stream.closed = true;
339
+ this.notifyLongPollsClosed(path$2);
340
+ return {
341
+ finalOffset: stream.currentOffset,
342
+ alreadyClosed
343
+ };
344
+ }
345
+ /**
346
+ * Close a stream with producer headers for idempotent close-only operations.
347
+ * Participates in producer sequencing for deduplication.
348
+ * @returns The final offset and producer result, or null if stream doesn't exist
349
+ */
350
+ async closeStreamWithProducer(path$2, options) {
351
+ const releaseLock = await this.acquireProducerLock(path$2, options.producerId);
352
+ try {
353
+ const stream = this.getIfNotExpired(path$2);
354
+ if (!stream) return null;
355
+ if (stream.closed) {
356
+ if (stream.closedBy && stream.closedBy.producerId === options.producerId && stream.closedBy.epoch === options.producerEpoch && stream.closedBy.seq === options.producerSeq) return {
357
+ finalOffset: stream.currentOffset,
358
+ alreadyClosed: true,
359
+ producerResult: {
360
+ status: `duplicate`,
361
+ lastSeq: options.producerSeq
362
+ }
363
+ };
364
+ return {
365
+ finalOffset: stream.currentOffset,
366
+ alreadyClosed: true,
367
+ producerResult: { status: `stream_closed` }
368
+ };
369
+ }
370
+ const producerResult = this.validateProducer(stream, options.producerId, options.producerEpoch, options.producerSeq);
371
+ if (producerResult.status !== `accepted`) return {
372
+ finalOffset: stream.currentOffset,
373
+ alreadyClosed: stream.closed ?? false,
374
+ producerResult
375
+ };
376
+ this.commitProducerState(stream, producerResult);
377
+ stream.closed = true;
378
+ stream.closedBy = {
379
+ producerId: options.producerId,
380
+ epoch: options.producerEpoch,
381
+ seq: options.producerSeq
382
+ };
383
+ this.notifyLongPollsClosed(path$2);
384
+ return {
385
+ finalOffset: stream.currentOffset,
386
+ alreadyClosed: false,
387
+ producerResult
388
+ };
389
+ } finally {
390
+ releaseLock();
391
+ }
392
+ }
393
+ /**
305
394
  * Get the current epoch for a producer on a stream.
306
395
  * Returns undefined if the producer doesn't exist or stream not found.
307
396
  */
@@ -361,12 +450,20 @@ var StreamStore = class {
361
450
  messages,
362
451
  timedOut: false
363
452
  };
453
+ if (stream.closed && offset === stream.currentOffset) return {
454
+ messages: [],
455
+ timedOut: false,
456
+ streamClosed: true
457
+ };
364
458
  return new Promise((resolve) => {
365
459
  const timeoutId = setTimeout(() => {
366
460
  this.removePendingLongPoll(pending);
461
+ const currentStream = this.getIfNotExpired(path$2);
462
+ const streamClosed = currentStream?.closed ?? false;
367
463
  resolve({
368
464
  messages: [],
369
- timedOut: true
465
+ timedOut: true,
466
+ streamClosed
370
467
  });
371
468
  }, timeoutMs);
372
469
  const pending = {
@@ -375,9 +472,12 @@ var StreamStore = class {
375
472
  resolve: (msgs) => {
376
473
  clearTimeout(timeoutId);
377
474
  this.removePendingLongPoll(pending);
475
+ const currentStream = this.getIfNotExpired(path$2);
476
+ const streamClosed = currentStream?.closed && msgs.length === 0 ? true : void 0;
378
477
  resolve({
379
478
  messages: msgs,
380
- timedOut: false
479
+ timedOut: false,
480
+ streamClosed
381
481
  });
382
482
  },
383
483
  timeoutId
@@ -450,6 +550,14 @@ var StreamStore = class {
450
550
  if (messages.length > 0) pending.resolve(messages);
451
551
  }
452
552
  }
553
+ /**
554
+ * Notify pending long-polls that a stream has been closed.
555
+ * They should wake up immediately and return Stream-Closed: true.
556
+ */
557
+ notifyLongPollsClosed(path$2) {
558
+ const toNotify = this.pendingLongPolls.filter((p) => p.path === path$2);
559
+ for (const pending of toNotify) pending.resolve([]);
560
+ }
453
561
  cancelLongPollsForStream(path$2) {
454
562
  const toCancel = this.pendingLongPolls.filter((p) => p.path === path$2);
455
563
  for (const pending of toCancel) {
@@ -771,7 +879,9 @@ var FileBackedStreamStore = class {
771
879
  ttlSeconds: meta.ttlSeconds,
772
880
  expiresAt: meta.expiresAt,
773
881
  createdAt: meta.createdAt,
774
- producers
882
+ producers,
883
+ closed: meta.closed,
884
+ closedBy: meta.closedBy
775
885
  };
776
886
  }
777
887
  /**
@@ -910,7 +1020,8 @@ var FileBackedStreamStore = class {
910
1020
  const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
911
1021
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
912
1022
  const expiresMatches = options.expiresAt === existing.expiresAt;
913
- if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
1023
+ const closedMatches = (options.closed ?? false) === (existing.closed ?? false);
1024
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches) return this.streamMetaToStream(existing);
914
1025
  else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
915
1026
  }
916
1027
  const key = `stream:${streamPath}`;
@@ -924,7 +1035,8 @@ var FileBackedStreamStore = class {
924
1035
  createdAt: Date.now(),
925
1036
  segmentCount: 1,
926
1037
  totalBytes: 0,
927
- directoryName: generateUniqueDirectoryName(streamPath)
1038
+ directoryName: generateUniqueDirectoryName(streamPath),
1039
+ closed: false
928
1040
  };
929
1041
  const streamDir = path.join(this.dataDir, `streams`, streamMeta.directoryName);
930
1042
  try {
@@ -936,15 +1048,17 @@ var FileBackedStreamStore = class {
936
1048
  throw err;
937
1049
  }
938
1050
  this.db.putSync(key, streamMeta);
939
- if (options.initialData && options.initialData.length > 0) {
940
- await this.append(streamPath, options.initialData, {
941
- contentType: options.contentType,
942
- isInitialCreate: true
943
- });
944
- const updated = this.db.get(key);
945
- return this.streamMetaToStream(updated);
1051
+ if (options.initialData && options.initialData.length > 0) await this.append(streamPath, options.initialData, {
1052
+ contentType: options.contentType,
1053
+ isInitialCreate: true
1054
+ });
1055
+ if (options.closed) {
1056
+ const updatedMeta = this.db.get(key);
1057
+ updatedMeta.closed = true;
1058
+ this.db.putSync(key, updatedMeta);
946
1059
  }
947
- return this.streamMetaToStream(streamMeta);
1060
+ const updated = this.db.get(key);
1061
+ return this.streamMetaToStream(updated);
948
1062
  }
949
1063
  get(streamPath) {
950
1064
  const meta = this.getMetaIfNotExpired(streamPath);
@@ -971,6 +1085,20 @@ var FileBackedStreamStore = class {
971
1085
  async append(streamPath, data, options = {}) {
972
1086
  const streamMeta = this.getMetaIfNotExpired(streamPath);
973
1087
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
1088
+ if (streamMeta.closed) {
1089
+ if (options.producerId && streamMeta.closedBy && streamMeta.closedBy.producerId === options.producerId && streamMeta.closedBy.epoch === options.producerEpoch && streamMeta.closedBy.seq === options.producerSeq) return {
1090
+ message: null,
1091
+ streamClosed: true,
1092
+ producerResult: {
1093
+ status: `duplicate`,
1094
+ lastSeq: options.producerSeq
1095
+ }
1096
+ };
1097
+ return {
1098
+ message: null,
1099
+ streamClosed: true
1100
+ };
1101
+ }
974
1102
  if (options.contentType && streamMeta.contentType) {
975
1103
  const providedType = normalizeContentType(options.contentType);
976
1104
  const streamType = normalizeContentType(streamMeta.contentType);
@@ -1021,19 +1149,29 @@ var FileBackedStreamStore = class {
1021
1149
  await this.fileHandlePool.fsyncFile(segmentPath);
1022
1150
  const updatedProducers = { ...streamMeta.producers };
1023
1151
  if (producerResult && producerResult.status === `accepted`) updatedProducers[producerResult.producerId] = producerResult.proposedState;
1152
+ let closedBy = void 0;
1153
+ if (options.close && options.producerId) closedBy = {
1154
+ producerId: options.producerId,
1155
+ epoch: options.producerEpoch,
1156
+ seq: options.producerSeq
1157
+ };
1024
1158
  const updatedMeta = {
1025
1159
  ...streamMeta,
1026
1160
  currentOffset: newOffset,
1027
1161
  lastSeq: options.seq ?? streamMeta.lastSeq,
1028
1162
  totalBytes: streamMeta.totalBytes + processedData.length + 5,
1029
- producers: updatedProducers
1163
+ producers: updatedProducers,
1164
+ closed: options.close ? true : streamMeta.closed,
1165
+ closedBy: closedBy ?? streamMeta.closedBy
1030
1166
  };
1031
1167
  const key = `stream:${streamPath}`;
1032
1168
  this.db.putSync(key, updatedMeta);
1033
1169
  this.notifyLongPolls(streamPath);
1034
- if (producerResult) return {
1170
+ if (options.close) this.notifyLongPollsClosed(streamPath);
1171
+ if (producerResult || options.close) return {
1035
1172
  message,
1036
- producerResult
1173
+ producerResult,
1174
+ streamClosed: options.close
1037
1175
  };
1038
1176
  return message;
1039
1177
  }
@@ -1056,6 +1194,81 @@ var FileBackedStreamStore = class {
1056
1194
  releaseLock();
1057
1195
  }
1058
1196
  }
1197
+ /**
1198
+ * Close a stream without appending data.
1199
+ * @returns The final offset, or null if stream doesn't exist
1200
+ */
1201
+ closeStream(streamPath) {
1202
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
1203
+ if (!streamMeta) return null;
1204
+ const alreadyClosed = streamMeta.closed ?? false;
1205
+ const key = `stream:${streamPath}`;
1206
+ const updatedMeta = {
1207
+ ...streamMeta,
1208
+ closed: true
1209
+ };
1210
+ this.db.putSync(key, updatedMeta);
1211
+ this.notifyLongPollsClosed(streamPath);
1212
+ return {
1213
+ finalOffset: streamMeta.currentOffset,
1214
+ alreadyClosed
1215
+ };
1216
+ }
1217
+ /**
1218
+ * Close a stream with producer headers for idempotent close-only operations.
1219
+ * Participates in producer sequencing for deduplication.
1220
+ * @returns The final offset and producer result, or null if stream doesn't exist
1221
+ */
1222
+ async closeStreamWithProducer(streamPath, options) {
1223
+ const releaseLock = await this.acquireProducerLock(streamPath, options.producerId);
1224
+ try {
1225
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
1226
+ if (!streamMeta) return null;
1227
+ if (streamMeta.closed) {
1228
+ if (streamMeta.closedBy && streamMeta.closedBy.producerId === options.producerId && streamMeta.closedBy.epoch === options.producerEpoch && streamMeta.closedBy.seq === options.producerSeq) return {
1229
+ finalOffset: streamMeta.currentOffset,
1230
+ alreadyClosed: true,
1231
+ producerResult: {
1232
+ status: `duplicate`,
1233
+ lastSeq: options.producerSeq
1234
+ }
1235
+ };
1236
+ return {
1237
+ finalOffset: streamMeta.currentOffset,
1238
+ alreadyClosed: true,
1239
+ producerResult: { status: `stream_closed` }
1240
+ };
1241
+ }
1242
+ const producerResult = this.validateProducer(streamMeta, options.producerId, options.producerEpoch, options.producerSeq);
1243
+ if (producerResult.status !== `accepted`) return {
1244
+ finalOffset: streamMeta.currentOffset,
1245
+ alreadyClosed: streamMeta.closed ?? false,
1246
+ producerResult
1247
+ };
1248
+ const key = `stream:${streamPath}`;
1249
+ const updatedProducers = { ...streamMeta.producers };
1250
+ updatedProducers[producerResult.producerId] = producerResult.proposedState;
1251
+ const updatedMeta = {
1252
+ ...streamMeta,
1253
+ closed: true,
1254
+ closedBy: {
1255
+ producerId: options.producerId,
1256
+ epoch: options.producerEpoch,
1257
+ seq: options.producerSeq
1258
+ },
1259
+ producers: updatedProducers
1260
+ };
1261
+ this.db.putSync(key, updatedMeta);
1262
+ this.notifyLongPollsClosed(streamPath);
1263
+ return {
1264
+ finalOffset: streamMeta.currentOffset,
1265
+ alreadyClosed: false,
1266
+ producerResult
1267
+ };
1268
+ } finally {
1269
+ releaseLock();
1270
+ }
1271
+ }
1059
1272
  read(streamPath, offset) {
1060
1273
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1061
1274
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
@@ -1111,17 +1324,30 @@ var FileBackedStreamStore = class {
1111
1324
  async waitForMessages(streamPath, offset, timeoutMs) {
1112
1325
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1113
1326
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
1327
+ if (streamMeta.closed && offset === streamMeta.currentOffset) return {
1328
+ messages: [],
1329
+ timedOut: false,
1330
+ streamClosed: true
1331
+ };
1114
1332
  const { messages } = this.read(streamPath, offset);
1115
1333
  if (messages.length > 0) return {
1116
1334
  messages,
1117
- timedOut: false
1335
+ timedOut: false,
1336
+ streamClosed: streamMeta.closed
1337
+ };
1338
+ if (streamMeta.closed) return {
1339
+ messages: [],
1340
+ timedOut: false,
1341
+ streamClosed: true
1118
1342
  };
1119
1343
  return new Promise((resolve) => {
1120
1344
  const timeoutId = setTimeout(() => {
1121
1345
  this.removePendingLongPoll(pending);
1346
+ const currentMeta = this.getMetaIfNotExpired(streamPath);
1122
1347
  resolve({
1123
1348
  messages: [],
1124
- timedOut: true
1349
+ timedOut: true,
1350
+ streamClosed: currentMeta?.closed
1125
1351
  });
1126
1352
  }, timeoutMs);
1127
1353
  const pending = {
@@ -1130,9 +1356,11 @@ var FileBackedStreamStore = class {
1130
1356
  resolve: (msgs) => {
1131
1357
  clearTimeout(timeoutId);
1132
1358
  this.removePendingLongPoll(pending);
1359
+ const currentMeta = this.getMetaIfNotExpired(streamPath);
1133
1360
  resolve({
1134
1361
  messages: msgs,
1135
- timedOut: false
1362
+ timedOut: false,
1363
+ streamClosed: currentMeta?.closed
1136
1364
  });
1137
1365
  },
1138
1366
  timeoutId
@@ -1205,6 +1433,14 @@ var FileBackedStreamStore = class {
1205
1433
  if (messages.length > 0) pending.resolve(messages);
1206
1434
  }
1207
1435
  }
1436
+ /**
1437
+ * Notify pending long-polls that a stream has been closed.
1438
+ * They should wake up immediately and return Stream-Closed: true.
1439
+ */
1440
+ notifyLongPollsClosed(streamPath) {
1441
+ const toNotify = this.pendingLongPolls.filter((p) => p.path === streamPath);
1442
+ for (const pending of toNotify) pending.resolve([]);
1443
+ }
1208
1444
  cancelLongPollsForStream(streamPath) {
1209
1445
  const toCancel = this.pendingLongPolls.filter((p) => p.path === streamPath);
1210
1446
  for (const pending of toCancel) {
@@ -1329,6 +1565,7 @@ const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`;
1329
1565
  const STREAM_SEQ_HEADER = `Stream-Seq`;
1330
1566
  const STREAM_TTL_HEADER = `Stream-TTL`;
1331
1567
  const STREAM_EXPIRES_AT_HEADER = `Stream-Expires-At`;
1568
+ const STREAM_SSE_DATA_ENCODING_HEADER = `Stream-SSE-Data-Encoding`;
1332
1569
  const PRODUCER_ID_HEADER = `Producer-Id`;
1333
1570
  const PRODUCER_EPOCH_HEADER = `Producer-Epoch`;
1334
1571
  const PRODUCER_SEQ_HEADER = `Producer-Seq`;
@@ -1337,6 +1574,8 @@ const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`;
1337
1574
  const SSE_OFFSET_FIELD = `streamNextOffset`;
1338
1575
  const SSE_CURSOR_FIELD = `streamCursor`;
1339
1576
  const SSE_UP_TO_DATE_FIELD = `upToDate`;
1577
+ const SSE_CLOSED_FIELD = `streamClosed`;
1578
+ const STREAM_CLOSED_HEADER = `Stream-Closed`;
1340
1579
  const OFFSET_QUERY_PARAM = `offset`;
1341
1580
  const LIVE_QUERY_PARAM = `live`;
1342
1581
  const CURSOR_QUERY_PARAM = `cursor`;
@@ -1551,8 +1790,8 @@ var DurableStreamTestServer = class {
1551
1790
  const method = req.method?.toUpperCase();
1552
1791
  res.setHeader(`access-control-allow-origin`, `*`);
1553
1792
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
1554
- res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Producer-Id, Producer-Epoch, Producer-Seq`);
1555
- res.setHeader(`access-control-expose-headers`, `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`);
1793
+ res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq`);
1794
+ res.setHeader(`access-control-expose-headers`, `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`);
1556
1795
  res.setHeader(`x-content-type-options`, `nosniff`);
1557
1796
  res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
1558
1797
  if (method === `OPTIONS`) {
@@ -1632,6 +1871,8 @@ var DurableStreamTestServer = class {
1632
1871
  if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = `application/octet-stream`;
1633
1872
  const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()];
1634
1873
  const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER.toLowerCase()];
1874
+ const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
1875
+ const createClosed = closedHeader === `true`;
1635
1876
  if (ttlHeader && expiresAtHeader) {
1636
1877
  res.writeHead(400, { "content-type": `text/plain` });
1637
1878
  res.end(`Cannot specify both Stream-TTL and Stream-Expires-At`);
@@ -1666,7 +1907,8 @@ var DurableStreamTestServer = class {
1666
1907
  contentType,
1667
1908
  ttlSeconds,
1668
1909
  expiresAt: expiresAtHeader,
1669
- initialData: body.length > 0 ? body : void 0
1910
+ initialData: body.length > 0 ? body : void 0,
1911
+ closed: createClosed
1670
1912
  }));
1671
1913
  const stream = this.store.get(path$2);
1672
1914
  if (isNew && this.options.onStreamCreated) await Promise.resolve(this.options.onStreamCreated({
@@ -1680,6 +1922,7 @@ var DurableStreamTestServer = class {
1680
1922
  [STREAM_OFFSET_HEADER]: stream.currentOffset
1681
1923
  };
1682
1924
  if (isNew) headers[`location`] = `${this._url}${path$2}`;
1925
+ if (stream.closed) headers[STREAM_CLOSED_HEADER] = `true`;
1683
1926
  res.writeHead(isNew ? 201 : 200, headers);
1684
1927
  res.end();
1685
1928
  }
@@ -1698,7 +1941,9 @@ var DurableStreamTestServer = class {
1698
1941
  "cache-control": `no-store`
1699
1942
  };
1700
1943
  if (stream.contentType) headers[`content-type`] = stream.contentType;
1701
- headers[`etag`] = `"${Buffer.from(path$2).toString(`base64`)}:-1:${stream.currentOffset}"`;
1944
+ if (stream.closed) headers[STREAM_CLOSED_HEADER] = `true`;
1945
+ const closedSuffix = stream.closed ? `:c` : ``;
1946
+ headers[`etag`] = `"${Buffer.from(path$2).toString(`base64`)}:-1:${stream.currentOffset}${closedSuffix}"`;
1702
1947
  res.writeHead(200, headers);
1703
1948
  res.end();
1704
1949
  }
@@ -1739,9 +1984,15 @@ var DurableStreamTestServer = class {
1739
1984
  res.end(`${live === `sse` ? `SSE` : `Long-poll`} requires offset parameter`);
1740
1985
  return;
1741
1986
  }
1987
+ let useBase64 = false;
1988
+ if (live === `sse`) {
1989
+ const ct = stream.contentType?.toLowerCase().split(`;`)[0]?.trim() ?? ``;
1990
+ const isTextCompatible = ct.startsWith(`text/`) || ct === `application/json`;
1991
+ useBase64 = !isTextCompatible;
1992
+ }
1742
1993
  if (live === `sse`) {
1743
1994
  const sseOffset = offset === `now` ? stream.currentOffset : offset;
1744
- await this.handleSSE(path$2, stream, sseOffset, cursor, res);
1995
+ await this.handleSSE(path$2, stream, sseOffset, cursor, useBase64, res);
1745
1996
  return;
1746
1997
  }
1747
1998
  const effectiveOffset = offset === `now` ? stream.currentOffset : offset;
@@ -1752,6 +2003,7 @@ var DurableStreamTestServer = class {
1752
2003
  [`cache-control`]: `no-store`
1753
2004
  };
1754
2005
  if (stream.contentType) headers$1[`content-type`] = stream.contentType;
2006
+ if (stream.closed) headers$1[STREAM_CLOSED_HEADER] = `true`;
1755
2007
  const isJsonMode = stream.contentType?.includes(`application/json`);
1756
2008
  const responseBody = isJsonMode ? `[]` : ``;
1757
2009
  res.writeHead(200, headers$1);
@@ -1761,17 +2013,40 @@ var DurableStreamTestServer = class {
1761
2013
  let { messages, upToDate } = this.store.read(path$2, effectiveOffset);
1762
2014
  const clientIsCaughtUp = effectiveOffset && effectiveOffset === stream.currentOffset || offset === `now`;
1763
2015
  if (live === `long-poll` && clientIsCaughtUp && messages.length === 0) {
2016
+ if (stream.closed) {
2017
+ res.writeHead(204, {
2018
+ [STREAM_OFFSET_HEADER]: stream.currentOffset,
2019
+ [STREAM_UP_TO_DATE_HEADER]: `true`,
2020
+ [STREAM_CLOSED_HEADER]: `true`
2021
+ });
2022
+ res.end();
2023
+ return;
2024
+ }
1764
2025
  const result = await this.store.waitForMessages(path$2, effectiveOffset ?? stream.currentOffset, this.options.longPollTimeout);
1765
- if (result.timedOut) {
2026
+ if (result.streamClosed) {
1766
2027
  const responseCursor = generateResponseCursor(cursor, this.options.cursorOptions);
1767
2028
  res.writeHead(204, {
1768
2029
  [STREAM_OFFSET_HEADER]: effectiveOffset ?? stream.currentOffset,
1769
2030
  [STREAM_UP_TO_DATE_HEADER]: `true`,
1770
- [STREAM_CURSOR_HEADER]: responseCursor
2031
+ [STREAM_CURSOR_HEADER]: responseCursor,
2032
+ [STREAM_CLOSED_HEADER]: `true`
1771
2033
  });
1772
2034
  res.end();
1773
2035
  return;
1774
2036
  }
2037
+ if (result.timedOut) {
2038
+ const responseCursor = generateResponseCursor(cursor, this.options.cursorOptions);
2039
+ const currentStream$1 = this.store.get(path$2);
2040
+ const timeoutHeaders = {
2041
+ [STREAM_OFFSET_HEADER]: effectiveOffset ?? stream.currentOffset,
2042
+ [STREAM_UP_TO_DATE_HEADER]: `true`,
2043
+ [STREAM_CURSOR_HEADER]: responseCursor
2044
+ };
2045
+ if (currentStream$1?.closed) timeoutHeaders[STREAM_CLOSED_HEADER] = `true`;
2046
+ res.writeHead(204, timeoutHeaders);
2047
+ res.end();
2048
+ return;
2049
+ }
1775
2050
  messages = result.messages;
1776
2051
  upToDate = true;
1777
2052
  }
@@ -1782,8 +2057,12 @@ var DurableStreamTestServer = class {
1782
2057
  headers[STREAM_OFFSET_HEADER] = responseOffset;
1783
2058
  if (live === `long-poll`) headers[STREAM_CURSOR_HEADER] = generateResponseCursor(cursor, this.options.cursorOptions);
1784
2059
  if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
2060
+ const currentStream = this.store.get(path$2);
2061
+ const clientAtTail = responseOffset === currentStream?.currentOffset;
2062
+ if (currentStream?.closed && clientAtTail && upToDate) headers[STREAM_CLOSED_HEADER] = `true`;
1785
2063
  const startOffset = offset ?? `-1`;
1786
- const etag = `"${Buffer.from(path$2).toString(`base64`)}:${startOffset}:${responseOffset}"`;
2064
+ const closedSuffix = currentStream?.closed && clientAtTail && upToDate ? `:c` : ``;
2065
+ const etag = `"${Buffer.from(path$2).toString(`base64`)}:${startOffset}:${responseOffset}${closedSuffix}"`;
1787
2066
  headers[`etag`] = etag;
1788
2067
  const ifNoneMatch = req.headers[`if-none-match`];
1789
2068
  if (ifNoneMatch && ifNoneMatch === etag) {
@@ -1795,10 +2074,10 @@ var DurableStreamTestServer = class {
1795
2074
  let finalData = responseData;
1796
2075
  if (this.options.compression && responseData.length >= COMPRESSION_THRESHOLD) {
1797
2076
  const acceptEncoding = req.headers[`accept-encoding`];
1798
- const encoding = getCompressionEncoding(acceptEncoding);
1799
- if (encoding) {
1800
- finalData = compressData(responseData, encoding);
1801
- headers[`content-encoding`] = encoding;
2077
+ const compressionEncoding = getCompressionEncoding(acceptEncoding);
2078
+ if (compressionEncoding) {
2079
+ finalData = compressData(responseData, compressionEncoding);
2080
+ headers[`content-encoding`] = compressionEncoding;
1802
2081
  headers[`vary`] = `accept-encoding`;
1803
2082
  }
1804
2083
  }
@@ -1809,16 +2088,18 @@ var DurableStreamTestServer = class {
1809
2088
  /**
1810
2089
  * Handle SSE (Server-Sent Events) mode
1811
2090
  */
1812
- async handleSSE(path$2, stream, initialOffset, cursor, res) {
2091
+ async handleSSE(path$2, stream, initialOffset, cursor, useBase64, res) {
1813
2092
  this.activeSSEResponses.add(res);
1814
- res.writeHead(200, {
2093
+ const sseHeaders = {
1815
2094
  "content-type": `text/event-stream`,
1816
2095
  "cache-control": `no-cache`,
1817
2096
  connection: `keep-alive`,
1818
2097
  "access-control-allow-origin": `*`,
1819
2098
  "x-content-type-options": `nosniff`,
1820
2099
  "cross-origin-resource-policy": `cross-origin`
1821
- });
2100
+ };
2101
+ if (useBase64) sseHeaders[STREAM_SSE_DATA_ENCODING_HEADER] = `base64`;
2102
+ res.writeHead(200, sseHeaders);
1822
2103
  const fault = res._injectedFault;
1823
2104
  if (fault?.injectSseEvent) {
1824
2105
  res.write(`event: ${fault.injectSseEvent.eventType}\n`);
@@ -1836,7 +2117,8 @@ var DurableStreamTestServer = class {
1836
2117
  const { messages, upToDate } = this.store.read(path$2, currentOffset);
1837
2118
  for (const message of messages) {
1838
2119
  let dataPayload;
1839
- if (isJsonStream) {
2120
+ if (useBase64) dataPayload = Buffer.from(message.data).toString(`base64`);
2121
+ else if (isJsonStream) {
1840
2122
  const jsonBytes = this.store.formatResponse(path$2, [message]);
1841
2123
  dataPayload = decoder.decode(jsonBytes);
1842
2124
  } else dataPayload = decoder.decode(message.data);
@@ -1844,28 +2126,60 @@ var DurableStreamTestServer = class {
1844
2126
  res.write(encodeSSEData(dataPayload));
1845
2127
  currentOffset = message.offset;
1846
2128
  }
1847
- const controlOffset = messages[messages.length - 1]?.offset ?? stream.currentOffset;
2129
+ const currentStream = this.store.get(path$2);
2130
+ const controlOffset = messages[messages.length - 1]?.offset ?? currentStream.currentOffset;
2131
+ const streamIsClosed = currentStream?.closed ?? false;
2132
+ const clientAtTail = controlOffset === currentStream.currentOffset;
1848
2133
  const responseCursor = generateResponseCursor(cursor, this.options.cursorOptions);
1849
- const controlData = {
1850
- [SSE_OFFSET_FIELD]: controlOffset,
1851
- [SSE_CURSOR_FIELD]: responseCursor
1852
- };
1853
- if (upToDate) controlData[SSE_UP_TO_DATE_FIELD] = true;
2134
+ const controlData = { [SSE_OFFSET_FIELD]: controlOffset };
2135
+ if (streamIsClosed && clientAtTail) controlData[SSE_CLOSED_FIELD] = true;
2136
+ else {
2137
+ controlData[SSE_CURSOR_FIELD] = responseCursor;
2138
+ if (upToDate) controlData[SSE_UP_TO_DATE_FIELD] = true;
2139
+ }
1854
2140
  res.write(`event: control\n`);
1855
2141
  res.write(encodeSSEData(JSON.stringify(controlData)));
2142
+ if (streamIsClosed && clientAtTail) break;
1856
2143
  currentOffset = controlOffset;
1857
2144
  if (upToDate) {
2145
+ if (currentStream?.closed) {
2146
+ const finalControlData = {
2147
+ [SSE_OFFSET_FIELD]: currentOffset,
2148
+ [SSE_CLOSED_FIELD]: true
2149
+ };
2150
+ res.write(`event: control\n`);
2151
+ res.write(encodeSSEData(JSON.stringify(finalControlData)));
2152
+ break;
2153
+ }
1858
2154
  const result = await this.store.waitForMessages(path$2, currentOffset, this.options.longPollTimeout);
1859
2155
  if (this.isShuttingDown || !isConnected) break;
2156
+ if (result.streamClosed) {
2157
+ const finalControlData = {
2158
+ [SSE_OFFSET_FIELD]: currentOffset,
2159
+ [SSE_CLOSED_FIELD]: true
2160
+ };
2161
+ res.write(`event: control\n`);
2162
+ res.write(encodeSSEData(JSON.stringify(finalControlData)));
2163
+ break;
2164
+ }
1860
2165
  if (result.timedOut) {
1861
2166
  const keepAliveCursor = generateResponseCursor(cursor, this.options.cursorOptions);
2167
+ const streamAfterWait = this.store.get(path$2);
2168
+ if (streamAfterWait?.closed) {
2169
+ const closedControlData = {
2170
+ [SSE_OFFSET_FIELD]: currentOffset,
2171
+ [SSE_CLOSED_FIELD]: true
2172
+ };
2173
+ res.write(`event: control\n`);
2174
+ res.write(encodeSSEData(JSON.stringify(closedControlData)));
2175
+ break;
2176
+ }
1862
2177
  const keepAliveData = {
1863
2178
  [SSE_OFFSET_FIELD]: currentOffset,
1864
2179
  [SSE_CURSOR_FIELD]: keepAliveCursor,
1865
2180
  [SSE_UP_TO_DATE_FIELD]: true
1866
2181
  };
1867
- res.write(`event: control\n`);
1868
- res.write(encodeSSEData(JSON.stringify(keepAliveData)));
2182
+ res.write(`event: control\n` + encodeSSEData(JSON.stringify(keepAliveData)));
1869
2183
  }
1870
2184
  }
1871
2185
  }
@@ -1878,20 +2192,11 @@ var DurableStreamTestServer = class {
1878
2192
  async handleAppend(path$2, req, res) {
1879
2193
  const contentType = req.headers[`content-type`];
1880
2194
  const seq = req.headers[STREAM_SEQ_HEADER.toLowerCase()];
2195
+ const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
2196
+ const closeStream = closedHeader === `true`;
1881
2197
  const producerId = req.headers[PRODUCER_ID_HEADER.toLowerCase()];
1882
2198
  const producerEpochStr = req.headers[PRODUCER_EPOCH_HEADER.toLowerCase()];
1883
2199
  const producerSeqStr = req.headers[PRODUCER_SEQ_HEADER.toLowerCase()];
1884
- const body = await this.readBody(req);
1885
- if (body.length === 0) {
1886
- res.writeHead(400, { "content-type": `text/plain` });
1887
- res.end(`Empty body`);
1888
- return;
1889
- }
1890
- if (!contentType) {
1891
- res.writeHead(400, { "content-type": `text/plain` });
1892
- res.end(`Content-Type header is required`);
1893
- return;
1894
- }
1895
2200
  const hasProducerHeaders = producerId !== void 0 || producerEpochStr !== void 0 || producerSeqStr !== void 0;
1896
2201
  const hasAllProducerHeaders = producerId !== void 0 && producerEpochStr !== void 0 && producerSeqStr !== void 0;
1897
2202
  if (hasProducerHeaders && !hasAllProducerHeaders) {
@@ -1931,34 +2236,148 @@ var DurableStreamTestServer = class {
1931
2236
  return;
1932
2237
  }
1933
2238
  }
2239
+ const body = await this.readBody(req);
2240
+ if (body.length === 0 && closeStream) {
2241
+ if (hasAllProducerHeaders) {
2242
+ const closeResult$1 = await this.store.closeStreamWithProducer(path$2, {
2243
+ producerId,
2244
+ producerEpoch,
2245
+ producerSeq
2246
+ });
2247
+ if (!closeResult$1) {
2248
+ res.writeHead(404, { "content-type": `text/plain` });
2249
+ res.end(`Stream not found`);
2250
+ return;
2251
+ }
2252
+ if (closeResult$1.producerResult?.status === `duplicate`) {
2253
+ res.writeHead(204, {
2254
+ [STREAM_OFFSET_HEADER]: closeResult$1.finalOffset,
2255
+ [STREAM_CLOSED_HEADER]: `true`,
2256
+ [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
2257
+ [PRODUCER_SEQ_HEADER]: closeResult$1.producerResult.lastSeq.toString()
2258
+ });
2259
+ res.end();
2260
+ return;
2261
+ }
2262
+ if (closeResult$1.producerResult?.status === `stale_epoch`) {
2263
+ res.writeHead(403, {
2264
+ "content-type": `text/plain`,
2265
+ [PRODUCER_EPOCH_HEADER]: closeResult$1.producerResult.currentEpoch.toString()
2266
+ });
2267
+ res.end(`Stale producer epoch`);
2268
+ return;
2269
+ }
2270
+ if (closeResult$1.producerResult?.status === `invalid_epoch_seq`) {
2271
+ res.writeHead(400, { "content-type": `text/plain` });
2272
+ res.end(`New epoch must start with sequence 0`);
2273
+ return;
2274
+ }
2275
+ if (closeResult$1.producerResult?.status === `sequence_gap`) {
2276
+ res.writeHead(409, {
2277
+ "content-type": `text/plain`,
2278
+ [PRODUCER_EXPECTED_SEQ_HEADER]: closeResult$1.producerResult.expectedSeq.toString(),
2279
+ [PRODUCER_RECEIVED_SEQ_HEADER]: closeResult$1.producerResult.receivedSeq.toString()
2280
+ });
2281
+ res.end(`Producer sequence gap`);
2282
+ return;
2283
+ }
2284
+ if (closeResult$1.producerResult?.status === `stream_closed`) {
2285
+ const stream = this.store.get(path$2);
2286
+ res.writeHead(409, {
2287
+ "content-type": `text/plain`,
2288
+ [STREAM_CLOSED_HEADER]: `true`,
2289
+ [STREAM_OFFSET_HEADER]: stream?.currentOffset ?? ``
2290
+ });
2291
+ res.end(`Stream is closed`);
2292
+ return;
2293
+ }
2294
+ res.writeHead(204, {
2295
+ [STREAM_OFFSET_HEADER]: closeResult$1.finalOffset,
2296
+ [STREAM_CLOSED_HEADER]: `true`,
2297
+ [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
2298
+ [PRODUCER_SEQ_HEADER]: producerSeq.toString()
2299
+ });
2300
+ res.end();
2301
+ return;
2302
+ }
2303
+ const closeResult = this.store.closeStream(path$2);
2304
+ if (!closeResult) {
2305
+ res.writeHead(404, { "content-type": `text/plain` });
2306
+ res.end(`Stream not found`);
2307
+ return;
2308
+ }
2309
+ res.writeHead(204, {
2310
+ [STREAM_OFFSET_HEADER]: closeResult.finalOffset,
2311
+ [STREAM_CLOSED_HEADER]: `true`
2312
+ });
2313
+ res.end();
2314
+ return;
2315
+ }
2316
+ if (body.length === 0) {
2317
+ res.writeHead(400, { "content-type": `text/plain` });
2318
+ res.end(`Empty body`);
2319
+ return;
2320
+ }
2321
+ if (!contentType) {
2322
+ res.writeHead(400, { "content-type": `text/plain` });
2323
+ res.end(`Content-Type header is required`);
2324
+ return;
2325
+ }
1934
2326
  const appendOptions = {
1935
2327
  seq,
1936
2328
  contentType,
1937
2329
  producerId,
1938
2330
  producerEpoch,
1939
- producerSeq
2331
+ producerSeq,
2332
+ close: closeStream
1940
2333
  };
1941
2334
  let result;
1942
2335
  if (producerId !== void 0) result = await this.store.appendWithProducer(path$2, body, appendOptions);
1943
2336
  else result = await Promise.resolve(this.store.append(path$2, body, appendOptions));
1944
- if (result && typeof result === `object` && `producerResult` in result) {
1945
- const { message: message$1, producerResult } = result;
2337
+ if (result && typeof result === `object` && `message` in result) {
2338
+ const { message: message$1, producerResult, streamClosed } = result;
2339
+ if (streamClosed && !message$1) {
2340
+ if (producerResult?.status === `duplicate`) {
2341
+ const stream = this.store.get(path$2);
2342
+ res.writeHead(204, {
2343
+ [STREAM_OFFSET_HEADER]: stream?.currentOffset ?? ``,
2344
+ [STREAM_CLOSED_HEADER]: `true`,
2345
+ [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
2346
+ [PRODUCER_SEQ_HEADER]: producerResult.lastSeq.toString()
2347
+ });
2348
+ res.end();
2349
+ return;
2350
+ }
2351
+ const closedStream = this.store.get(path$2);
2352
+ res.writeHead(409, {
2353
+ "content-type": `text/plain`,
2354
+ [STREAM_CLOSED_HEADER]: `true`,
2355
+ [STREAM_OFFSET_HEADER]: closedStream?.currentOffset ?? ``
2356
+ });
2357
+ res.end(`Stream is closed`);
2358
+ return;
2359
+ }
1946
2360
  if (!producerResult || producerResult.status === `accepted`) {
1947
- const responseHeaders = { [STREAM_OFFSET_HEADER]: message$1.offset };
1948
- if (producerEpoch !== void 0) responseHeaders[PRODUCER_EPOCH_HEADER] = producerEpoch.toString();
1949
- if (producerSeq !== void 0) responseHeaders[PRODUCER_SEQ_HEADER] = producerSeq.toString();
1950
- res.writeHead(200, responseHeaders);
2361
+ const responseHeaders$1 = { [STREAM_OFFSET_HEADER]: message$1.offset };
2362
+ if (producerEpoch !== void 0) responseHeaders$1[PRODUCER_EPOCH_HEADER] = producerEpoch.toString();
2363
+ if (producerSeq !== void 0) responseHeaders$1[PRODUCER_SEQ_HEADER] = producerSeq.toString();
2364
+ if (streamClosed) responseHeaders$1[STREAM_CLOSED_HEADER] = `true`;
2365
+ const statusCode = producerId !== void 0 ? 200 : 204;
2366
+ res.writeHead(statusCode, responseHeaders$1);
1951
2367
  res.end();
1952
2368
  return;
1953
2369
  }
1954
2370
  switch (producerResult.status) {
1955
- case `duplicate`:
1956
- res.writeHead(204, {
2371
+ case `duplicate`: {
2372
+ const dupHeaders = {
1957
2373
  [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
1958
2374
  [PRODUCER_SEQ_HEADER]: producerResult.lastSeq.toString()
1959
- });
2375
+ };
2376
+ if (streamClosed) dupHeaders[STREAM_CLOSED_HEADER] = `true`;
2377
+ res.writeHead(204, dupHeaders);
1960
2378
  res.end();
1961
2379
  return;
2380
+ }
1962
2381
  case `stale_epoch`: {
1963
2382
  res.writeHead(403, {
1964
2383
  "content-type": `text/plain`,
@@ -1982,7 +2401,9 @@ var DurableStreamTestServer = class {
1982
2401
  }
1983
2402
  }
1984
2403
  const message = result;
1985
- res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
2404
+ const responseHeaders = { [STREAM_OFFSET_HEADER]: message.offset };
2405
+ if (closeStream) responseHeaders[STREAM_CLOSED_HEADER] = `true`;
2406
+ res.writeHead(204, responseHeaders);
1986
2407
  res.end();
1987
2408
  }
1988
2409
  /**