@durable-streams/server 0.1.7 → 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 CHANGED
@@ -132,7 +132,8 @@ var StreamStore = class {
132
132
  const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
133
133
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
134
134
  const expiresMatches = options.expiresAt === existing.expiresAt;
135
- if (contentTypeMatches && ttlMatches && expiresMatches) return existing;
135
+ const closedMatches = (options.closed ?? false) === (existing.closed ?? false);
136
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches) return existing;
136
137
  else throw new Error(`Stream already exists with different configuration: ${path}`);
137
138
  }
138
139
  const stream = {
@@ -142,7 +143,8 @@ var StreamStore = class {
142
143
  currentOffset: `0000000000000000_0000000000000000`,
143
144
  ttlSeconds: options.ttlSeconds,
144
145
  expiresAt: options.expiresAt,
145
- createdAt: Date.now()
146
+ createdAt: Date.now(),
147
+ closed: options.closed ?? false
146
148
  };
147
149
  if (options.initialData && options.initialData.length > 0) this.appendToStream(stream, options.initialData, true);
148
150
  this.streams.set(path, stream);
@@ -279,6 +281,20 @@ var StreamStore = class {
279
281
  append(path, data, options = {}) {
280
282
  const stream = this.getIfNotExpired(path);
281
283
  if (!stream) throw new Error(`Stream not found: ${path}`);
284
+ if (stream.closed) {
285
+ if (options.producerId && stream.closedBy && stream.closedBy.producerId === options.producerId && stream.closedBy.epoch === options.producerEpoch && stream.closedBy.seq === options.producerSeq) return {
286
+ message: null,
287
+ streamClosed: true,
288
+ producerResult: {
289
+ status: `duplicate`,
290
+ lastSeq: options.producerSeq
291
+ }
292
+ };
293
+ return {
294
+ message: null,
295
+ streamClosed: true
296
+ };
297
+ }
282
298
  if (options.contentType && stream.contentType) {
283
299
  const providedType = normalizeContentType(options.contentType);
284
300
  const streamType = normalizeContentType(stream.contentType);
@@ -298,10 +314,20 @@ var StreamStore = class {
298
314
  const message = this.appendToStream(stream, data);
299
315
  if (producerResult) this.commitProducerState(stream, producerResult);
300
316
  if (options.seq !== void 0) stream.lastSeq = options.seq;
317
+ if (options.close) {
318
+ stream.closed = true;
319
+ if (options.producerId !== void 0) stream.closedBy = {
320
+ producerId: options.producerId,
321
+ epoch: options.producerEpoch,
322
+ seq: options.producerSeq
323
+ };
324
+ this.notifyLongPollsClosed(path);
325
+ }
301
326
  this.notifyLongPolls(path);
302
- if (producerResult) return {
327
+ if (producerResult || options.close) return {
303
328
  message,
304
- producerResult
329
+ producerResult,
330
+ streamClosed: options.close
305
331
  };
306
332
  return message;
307
333
  }
@@ -325,6 +351,69 @@ var StreamStore = class {
325
351
  }
326
352
  }
327
353
  /**
354
+ * Close a stream without appending data.
355
+ * @returns The final offset, or null if stream doesn't exist
356
+ */
357
+ closeStream(path) {
358
+ const stream = this.getIfNotExpired(path);
359
+ if (!stream) return null;
360
+ const alreadyClosed = stream.closed ?? false;
361
+ stream.closed = true;
362
+ this.notifyLongPollsClosed(path);
363
+ return {
364
+ finalOffset: stream.currentOffset,
365
+ alreadyClosed
366
+ };
367
+ }
368
+ /**
369
+ * Close a stream with producer headers for idempotent close-only operations.
370
+ * Participates in producer sequencing for deduplication.
371
+ * @returns The final offset and producer result, or null if stream doesn't exist
372
+ */
373
+ async closeStreamWithProducer(path, options) {
374
+ const releaseLock = await this.acquireProducerLock(path, options.producerId);
375
+ try {
376
+ const stream = this.getIfNotExpired(path);
377
+ if (!stream) return null;
378
+ if (stream.closed) {
379
+ if (stream.closedBy && stream.closedBy.producerId === options.producerId && stream.closedBy.epoch === options.producerEpoch && stream.closedBy.seq === options.producerSeq) return {
380
+ finalOffset: stream.currentOffset,
381
+ alreadyClosed: true,
382
+ producerResult: {
383
+ status: `duplicate`,
384
+ lastSeq: options.producerSeq
385
+ }
386
+ };
387
+ return {
388
+ finalOffset: stream.currentOffset,
389
+ alreadyClosed: true,
390
+ producerResult: { status: `stream_closed` }
391
+ };
392
+ }
393
+ const producerResult = this.validateProducer(stream, options.producerId, options.producerEpoch, options.producerSeq);
394
+ if (producerResult.status !== `accepted`) return {
395
+ finalOffset: stream.currentOffset,
396
+ alreadyClosed: stream.closed ?? false,
397
+ producerResult
398
+ };
399
+ this.commitProducerState(stream, producerResult);
400
+ stream.closed = true;
401
+ stream.closedBy = {
402
+ producerId: options.producerId,
403
+ epoch: options.producerEpoch,
404
+ seq: options.producerSeq
405
+ };
406
+ this.notifyLongPollsClosed(path);
407
+ return {
408
+ finalOffset: stream.currentOffset,
409
+ alreadyClosed: false,
410
+ producerResult
411
+ };
412
+ } finally {
413
+ releaseLock();
414
+ }
415
+ }
416
+ /**
328
417
  * Get the current epoch for a producer on a stream.
329
418
  * Returns undefined if the producer doesn't exist or stream not found.
330
419
  */
@@ -384,12 +473,20 @@ var StreamStore = class {
384
473
  messages,
385
474
  timedOut: false
386
475
  };
476
+ if (stream.closed && offset === stream.currentOffset) return {
477
+ messages: [],
478
+ timedOut: false,
479
+ streamClosed: true
480
+ };
387
481
  return new Promise((resolve) => {
388
482
  const timeoutId = setTimeout(() => {
389
483
  this.removePendingLongPoll(pending);
484
+ const currentStream = this.getIfNotExpired(path);
485
+ const streamClosed = currentStream?.closed ?? false;
390
486
  resolve({
391
487
  messages: [],
392
- timedOut: true
488
+ timedOut: true,
489
+ streamClosed
393
490
  });
394
491
  }, timeoutMs);
395
492
  const pending = {
@@ -398,9 +495,12 @@ var StreamStore = class {
398
495
  resolve: (msgs) => {
399
496
  clearTimeout(timeoutId);
400
497
  this.removePendingLongPoll(pending);
498
+ const currentStream = this.getIfNotExpired(path);
499
+ const streamClosed = currentStream?.closed && msgs.length === 0 ? true : void 0;
401
500
  resolve({
402
501
  messages: msgs,
403
- timedOut: false
502
+ timedOut: false,
503
+ streamClosed
404
504
  });
405
505
  },
406
506
  timeoutId
@@ -473,6 +573,14 @@ var StreamStore = class {
473
573
  if (messages.length > 0) pending.resolve(messages);
474
574
  }
475
575
  }
576
+ /**
577
+ * Notify pending long-polls that a stream has been closed.
578
+ * They should wake up immediately and return Stream-Closed: true.
579
+ */
580
+ notifyLongPollsClosed(path) {
581
+ const toNotify = this.pendingLongPolls.filter((p) => p.path === path);
582
+ for (const pending of toNotify) pending.resolve([]);
583
+ }
476
584
  cancelLongPollsForStream(path) {
477
585
  const toCancel = this.pendingLongPolls.filter((p) => p.path === path);
478
586
  for (const pending of toCancel) {
@@ -794,7 +902,9 @@ var FileBackedStreamStore = class {
794
902
  ttlSeconds: meta.ttlSeconds,
795
903
  expiresAt: meta.expiresAt,
796
904
  createdAt: meta.createdAt,
797
- producers
905
+ producers,
906
+ closed: meta.closed,
907
+ closedBy: meta.closedBy
798
908
  };
799
909
  }
800
910
  /**
@@ -933,7 +1043,8 @@ var FileBackedStreamStore = class {
933
1043
  const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
934
1044
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
935
1045
  const expiresMatches = options.expiresAt === existing.expiresAt;
936
- if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
1046
+ const closedMatches = (options.closed ?? false) === (existing.closed ?? false);
1047
+ if (contentTypeMatches && ttlMatches && expiresMatches && closedMatches) return this.streamMetaToStream(existing);
937
1048
  else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
938
1049
  }
939
1050
  const key = `stream:${streamPath}`;
@@ -947,7 +1058,8 @@ var FileBackedStreamStore = class {
947
1058
  createdAt: Date.now(),
948
1059
  segmentCount: 1,
949
1060
  totalBytes: 0,
950
- directoryName: generateUniqueDirectoryName(streamPath)
1061
+ directoryName: generateUniqueDirectoryName(streamPath),
1062
+ closed: false
951
1063
  };
952
1064
  const streamDir = node_path.join(this.dataDir, `streams`, streamMeta.directoryName);
953
1065
  try {
@@ -959,15 +1071,17 @@ var FileBackedStreamStore = class {
959
1071
  throw err;
960
1072
  }
961
1073
  this.db.putSync(key, streamMeta);
962
- if (options.initialData && options.initialData.length > 0) {
963
- await this.append(streamPath, options.initialData, {
964
- contentType: options.contentType,
965
- isInitialCreate: true
966
- });
967
- const updated = this.db.get(key);
968
- return this.streamMetaToStream(updated);
1074
+ if (options.initialData && options.initialData.length > 0) await this.append(streamPath, options.initialData, {
1075
+ contentType: options.contentType,
1076
+ isInitialCreate: true
1077
+ });
1078
+ if (options.closed) {
1079
+ const updatedMeta = this.db.get(key);
1080
+ updatedMeta.closed = true;
1081
+ this.db.putSync(key, updatedMeta);
969
1082
  }
970
- return this.streamMetaToStream(streamMeta);
1083
+ const updated = this.db.get(key);
1084
+ return this.streamMetaToStream(updated);
971
1085
  }
972
1086
  get(streamPath) {
973
1087
  const meta = this.getMetaIfNotExpired(streamPath);
@@ -994,6 +1108,20 @@ var FileBackedStreamStore = class {
994
1108
  async append(streamPath, data, options = {}) {
995
1109
  const streamMeta = this.getMetaIfNotExpired(streamPath);
996
1110
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
1111
+ if (streamMeta.closed) {
1112
+ if (options.producerId && streamMeta.closedBy && streamMeta.closedBy.producerId === options.producerId && streamMeta.closedBy.epoch === options.producerEpoch && streamMeta.closedBy.seq === options.producerSeq) return {
1113
+ message: null,
1114
+ streamClosed: true,
1115
+ producerResult: {
1116
+ status: `duplicate`,
1117
+ lastSeq: options.producerSeq
1118
+ }
1119
+ };
1120
+ return {
1121
+ message: null,
1122
+ streamClosed: true
1123
+ };
1124
+ }
997
1125
  if (options.contentType && streamMeta.contentType) {
998
1126
  const providedType = normalizeContentType(options.contentType);
999
1127
  const streamType = normalizeContentType(streamMeta.contentType);
@@ -1044,19 +1172,29 @@ var FileBackedStreamStore = class {
1044
1172
  await this.fileHandlePool.fsyncFile(segmentPath);
1045
1173
  const updatedProducers = { ...streamMeta.producers };
1046
1174
  if (producerResult && producerResult.status === `accepted`) updatedProducers[producerResult.producerId] = producerResult.proposedState;
1175
+ let closedBy = void 0;
1176
+ if (options.close && options.producerId) closedBy = {
1177
+ producerId: options.producerId,
1178
+ epoch: options.producerEpoch,
1179
+ seq: options.producerSeq
1180
+ };
1047
1181
  const updatedMeta = {
1048
1182
  ...streamMeta,
1049
1183
  currentOffset: newOffset,
1050
1184
  lastSeq: options.seq ?? streamMeta.lastSeq,
1051
1185
  totalBytes: streamMeta.totalBytes + processedData.length + 5,
1052
- producers: updatedProducers
1186
+ producers: updatedProducers,
1187
+ closed: options.close ? true : streamMeta.closed,
1188
+ closedBy: closedBy ?? streamMeta.closedBy
1053
1189
  };
1054
1190
  const key = `stream:${streamPath}`;
1055
1191
  this.db.putSync(key, updatedMeta);
1056
1192
  this.notifyLongPolls(streamPath);
1057
- if (producerResult) return {
1193
+ if (options.close) this.notifyLongPollsClosed(streamPath);
1194
+ if (producerResult || options.close) return {
1058
1195
  message,
1059
- producerResult
1196
+ producerResult,
1197
+ streamClosed: options.close
1060
1198
  };
1061
1199
  return message;
1062
1200
  }
@@ -1079,6 +1217,81 @@ var FileBackedStreamStore = class {
1079
1217
  releaseLock();
1080
1218
  }
1081
1219
  }
1220
+ /**
1221
+ * Close a stream without appending data.
1222
+ * @returns The final offset, or null if stream doesn't exist
1223
+ */
1224
+ closeStream(streamPath) {
1225
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
1226
+ if (!streamMeta) return null;
1227
+ const alreadyClosed = streamMeta.closed ?? false;
1228
+ const key = `stream:${streamPath}`;
1229
+ const updatedMeta = {
1230
+ ...streamMeta,
1231
+ closed: true
1232
+ };
1233
+ this.db.putSync(key, updatedMeta);
1234
+ this.notifyLongPollsClosed(streamPath);
1235
+ return {
1236
+ finalOffset: streamMeta.currentOffset,
1237
+ alreadyClosed
1238
+ };
1239
+ }
1240
+ /**
1241
+ * Close a stream with producer headers for idempotent close-only operations.
1242
+ * Participates in producer sequencing for deduplication.
1243
+ * @returns The final offset and producer result, or null if stream doesn't exist
1244
+ */
1245
+ async closeStreamWithProducer(streamPath, options) {
1246
+ const releaseLock = await this.acquireProducerLock(streamPath, options.producerId);
1247
+ try {
1248
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
1249
+ if (!streamMeta) return null;
1250
+ if (streamMeta.closed) {
1251
+ if (streamMeta.closedBy && streamMeta.closedBy.producerId === options.producerId && streamMeta.closedBy.epoch === options.producerEpoch && streamMeta.closedBy.seq === options.producerSeq) return {
1252
+ finalOffset: streamMeta.currentOffset,
1253
+ alreadyClosed: true,
1254
+ producerResult: {
1255
+ status: `duplicate`,
1256
+ lastSeq: options.producerSeq
1257
+ }
1258
+ };
1259
+ return {
1260
+ finalOffset: streamMeta.currentOffset,
1261
+ alreadyClosed: true,
1262
+ producerResult: { status: `stream_closed` }
1263
+ };
1264
+ }
1265
+ const producerResult = this.validateProducer(streamMeta, options.producerId, options.producerEpoch, options.producerSeq);
1266
+ if (producerResult.status !== `accepted`) return {
1267
+ finalOffset: streamMeta.currentOffset,
1268
+ alreadyClosed: streamMeta.closed ?? false,
1269
+ producerResult
1270
+ };
1271
+ const key = `stream:${streamPath}`;
1272
+ const updatedProducers = { ...streamMeta.producers };
1273
+ updatedProducers[producerResult.producerId] = producerResult.proposedState;
1274
+ const updatedMeta = {
1275
+ ...streamMeta,
1276
+ closed: true,
1277
+ closedBy: {
1278
+ producerId: options.producerId,
1279
+ epoch: options.producerEpoch,
1280
+ seq: options.producerSeq
1281
+ },
1282
+ producers: updatedProducers
1283
+ };
1284
+ this.db.putSync(key, updatedMeta);
1285
+ this.notifyLongPollsClosed(streamPath);
1286
+ return {
1287
+ finalOffset: streamMeta.currentOffset,
1288
+ alreadyClosed: false,
1289
+ producerResult
1290
+ };
1291
+ } finally {
1292
+ releaseLock();
1293
+ }
1294
+ }
1082
1295
  read(streamPath, offset) {
1083
1296
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1084
1297
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
@@ -1134,17 +1347,30 @@ var FileBackedStreamStore = class {
1134
1347
  async waitForMessages(streamPath, offset, timeoutMs) {
1135
1348
  const streamMeta = this.getMetaIfNotExpired(streamPath);
1136
1349
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
1350
+ if (streamMeta.closed && offset === streamMeta.currentOffset) return {
1351
+ messages: [],
1352
+ timedOut: false,
1353
+ streamClosed: true
1354
+ };
1137
1355
  const { messages } = this.read(streamPath, offset);
1138
1356
  if (messages.length > 0) return {
1139
1357
  messages,
1140
- timedOut: false
1358
+ timedOut: false,
1359
+ streamClosed: streamMeta.closed
1360
+ };
1361
+ if (streamMeta.closed) return {
1362
+ messages: [],
1363
+ timedOut: false,
1364
+ streamClosed: true
1141
1365
  };
1142
1366
  return new Promise((resolve) => {
1143
1367
  const timeoutId = setTimeout(() => {
1144
1368
  this.removePendingLongPoll(pending);
1369
+ const currentMeta = this.getMetaIfNotExpired(streamPath);
1145
1370
  resolve({
1146
1371
  messages: [],
1147
- timedOut: true
1372
+ timedOut: true,
1373
+ streamClosed: currentMeta?.closed
1148
1374
  });
1149
1375
  }, timeoutMs);
1150
1376
  const pending = {
@@ -1153,9 +1379,11 @@ var FileBackedStreamStore = class {
1153
1379
  resolve: (msgs) => {
1154
1380
  clearTimeout(timeoutId);
1155
1381
  this.removePendingLongPoll(pending);
1382
+ const currentMeta = this.getMetaIfNotExpired(streamPath);
1156
1383
  resolve({
1157
1384
  messages: msgs,
1158
- timedOut: false
1385
+ timedOut: false,
1386
+ streamClosed: currentMeta?.closed
1159
1387
  });
1160
1388
  },
1161
1389
  timeoutId
@@ -1228,6 +1456,14 @@ var FileBackedStreamStore = class {
1228
1456
  if (messages.length > 0) pending.resolve(messages);
1229
1457
  }
1230
1458
  }
1459
+ /**
1460
+ * Notify pending long-polls that a stream has been closed.
1461
+ * They should wake up immediately and return Stream-Closed: true.
1462
+ */
1463
+ notifyLongPollsClosed(streamPath) {
1464
+ const toNotify = this.pendingLongPolls.filter((p) => p.path === streamPath);
1465
+ for (const pending of toNotify) pending.resolve([]);
1466
+ }
1231
1467
  cancelLongPollsForStream(streamPath) {
1232
1468
  const toCancel = this.pendingLongPolls.filter((p) => p.path === streamPath);
1233
1469
  for (const pending of toCancel) {
@@ -1352,6 +1588,7 @@ const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`;
1352
1588
  const STREAM_SEQ_HEADER = `Stream-Seq`;
1353
1589
  const STREAM_TTL_HEADER = `Stream-TTL`;
1354
1590
  const STREAM_EXPIRES_AT_HEADER = `Stream-Expires-At`;
1591
+ const STREAM_SSE_DATA_ENCODING_HEADER = `Stream-SSE-Data-Encoding`;
1355
1592
  const PRODUCER_ID_HEADER = `Producer-Id`;
1356
1593
  const PRODUCER_EPOCH_HEADER = `Producer-Epoch`;
1357
1594
  const PRODUCER_SEQ_HEADER = `Producer-Seq`;
@@ -1360,6 +1597,8 @@ const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`;
1360
1597
  const SSE_OFFSET_FIELD = `streamNextOffset`;
1361
1598
  const SSE_CURSOR_FIELD = `streamCursor`;
1362
1599
  const SSE_UP_TO_DATE_FIELD = `upToDate`;
1600
+ const SSE_CLOSED_FIELD = `streamClosed`;
1601
+ const STREAM_CLOSED_HEADER = `Stream-Closed`;
1363
1602
  const OFFSET_QUERY_PARAM = `offset`;
1364
1603
  const LIVE_QUERY_PARAM = `live`;
1365
1604
  const CURSOR_QUERY_PARAM = `cursor`;
@@ -1574,8 +1813,8 @@ var DurableStreamTestServer = class {
1574
1813
  const method = req.method?.toUpperCase();
1575
1814
  res.setHeader(`access-control-allow-origin`, `*`);
1576
1815
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
1577
- res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Producer-Id, Producer-Epoch, Producer-Seq`);
1578
- 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`);
1816
+ res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Stream-Closed, Producer-Id, Producer-Epoch, Producer-Seq`);
1817
+ 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`);
1579
1818
  res.setHeader(`x-content-type-options`, `nosniff`);
1580
1819
  res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
1581
1820
  if (method === `OPTIONS`) {
@@ -1655,6 +1894,8 @@ var DurableStreamTestServer = class {
1655
1894
  if (!contentType || contentType.trim() === `` || !/^[\w-]+\/[\w-]+/.test(contentType)) contentType = `application/octet-stream`;
1656
1895
  const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()];
1657
1896
  const expiresAtHeader = req.headers[STREAM_EXPIRES_AT_HEADER.toLowerCase()];
1897
+ const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
1898
+ const createClosed = closedHeader === `true`;
1658
1899
  if (ttlHeader && expiresAtHeader) {
1659
1900
  res.writeHead(400, { "content-type": `text/plain` });
1660
1901
  res.end(`Cannot specify both Stream-TTL and Stream-Expires-At`);
@@ -1689,7 +1930,8 @@ var DurableStreamTestServer = class {
1689
1930
  contentType,
1690
1931
  ttlSeconds,
1691
1932
  expiresAt: expiresAtHeader,
1692
- initialData: body.length > 0 ? body : void 0
1933
+ initialData: body.length > 0 ? body : void 0,
1934
+ closed: createClosed
1693
1935
  }));
1694
1936
  const stream = this.store.get(path);
1695
1937
  if (isNew && this.options.onStreamCreated) await Promise.resolve(this.options.onStreamCreated({
@@ -1703,6 +1945,7 @@ var DurableStreamTestServer = class {
1703
1945
  [STREAM_OFFSET_HEADER]: stream.currentOffset
1704
1946
  };
1705
1947
  if (isNew) headers[`location`] = `${this._url}${path}`;
1948
+ if (stream.closed) headers[STREAM_CLOSED_HEADER] = `true`;
1706
1949
  res.writeHead(isNew ? 201 : 200, headers);
1707
1950
  res.end();
1708
1951
  }
@@ -1721,7 +1964,9 @@ var DurableStreamTestServer = class {
1721
1964
  "cache-control": `no-store`
1722
1965
  };
1723
1966
  if (stream.contentType) headers[`content-type`] = stream.contentType;
1724
- headers[`etag`] = `"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}"`;
1967
+ if (stream.closed) headers[STREAM_CLOSED_HEADER] = `true`;
1968
+ const closedSuffix = stream.closed ? `:c` : ``;
1969
+ headers[`etag`] = `"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}${closedSuffix}"`;
1725
1970
  res.writeHead(200, headers);
1726
1971
  res.end();
1727
1972
  }
@@ -1762,9 +2007,15 @@ var DurableStreamTestServer = class {
1762
2007
  res.end(`${live === `sse` ? `SSE` : `Long-poll`} requires offset parameter`);
1763
2008
  return;
1764
2009
  }
2010
+ let useBase64 = false;
2011
+ if (live === `sse`) {
2012
+ const ct = stream.contentType?.toLowerCase().split(`;`)[0]?.trim() ?? ``;
2013
+ const isTextCompatible = ct.startsWith(`text/`) || ct === `application/json`;
2014
+ useBase64 = !isTextCompatible;
2015
+ }
1765
2016
  if (live === `sse`) {
1766
2017
  const sseOffset = offset === `now` ? stream.currentOffset : offset;
1767
- await this.handleSSE(path, stream, sseOffset, cursor, res);
2018
+ await this.handleSSE(path, stream, sseOffset, cursor, useBase64, res);
1768
2019
  return;
1769
2020
  }
1770
2021
  const effectiveOffset = offset === `now` ? stream.currentOffset : offset;
@@ -1775,6 +2026,7 @@ var DurableStreamTestServer = class {
1775
2026
  [`cache-control`]: `no-store`
1776
2027
  };
1777
2028
  if (stream.contentType) headers$1[`content-type`] = stream.contentType;
2029
+ if (stream.closed) headers$1[STREAM_CLOSED_HEADER] = `true`;
1778
2030
  const isJsonMode = stream.contentType?.includes(`application/json`);
1779
2031
  const responseBody = isJsonMode ? `[]` : ``;
1780
2032
  res.writeHead(200, headers$1);
@@ -1784,17 +2036,40 @@ var DurableStreamTestServer = class {
1784
2036
  let { messages, upToDate } = this.store.read(path, effectiveOffset);
1785
2037
  const clientIsCaughtUp = effectiveOffset && effectiveOffset === stream.currentOffset || offset === `now`;
1786
2038
  if (live === `long-poll` && clientIsCaughtUp && messages.length === 0) {
2039
+ if (stream.closed) {
2040
+ res.writeHead(204, {
2041
+ [STREAM_OFFSET_HEADER]: stream.currentOffset,
2042
+ [STREAM_UP_TO_DATE_HEADER]: `true`,
2043
+ [STREAM_CLOSED_HEADER]: `true`
2044
+ });
2045
+ res.end();
2046
+ return;
2047
+ }
1787
2048
  const result = await this.store.waitForMessages(path, effectiveOffset ?? stream.currentOffset, this.options.longPollTimeout);
1788
- if (result.timedOut) {
2049
+ if (result.streamClosed) {
1789
2050
  const responseCursor = generateResponseCursor(cursor, this.options.cursorOptions);
1790
2051
  res.writeHead(204, {
1791
2052
  [STREAM_OFFSET_HEADER]: effectiveOffset ?? stream.currentOffset,
1792
2053
  [STREAM_UP_TO_DATE_HEADER]: `true`,
1793
- [STREAM_CURSOR_HEADER]: responseCursor
2054
+ [STREAM_CURSOR_HEADER]: responseCursor,
2055
+ [STREAM_CLOSED_HEADER]: `true`
1794
2056
  });
1795
2057
  res.end();
1796
2058
  return;
1797
2059
  }
2060
+ if (result.timedOut) {
2061
+ const responseCursor = generateResponseCursor(cursor, this.options.cursorOptions);
2062
+ const currentStream$1 = this.store.get(path);
2063
+ const timeoutHeaders = {
2064
+ [STREAM_OFFSET_HEADER]: effectiveOffset ?? stream.currentOffset,
2065
+ [STREAM_UP_TO_DATE_HEADER]: `true`,
2066
+ [STREAM_CURSOR_HEADER]: responseCursor
2067
+ };
2068
+ if (currentStream$1?.closed) timeoutHeaders[STREAM_CLOSED_HEADER] = `true`;
2069
+ res.writeHead(204, timeoutHeaders);
2070
+ res.end();
2071
+ return;
2072
+ }
1798
2073
  messages = result.messages;
1799
2074
  upToDate = true;
1800
2075
  }
@@ -1805,8 +2080,12 @@ var DurableStreamTestServer = class {
1805
2080
  headers[STREAM_OFFSET_HEADER] = responseOffset;
1806
2081
  if (live === `long-poll`) headers[STREAM_CURSOR_HEADER] = generateResponseCursor(cursor, this.options.cursorOptions);
1807
2082
  if (upToDate) headers[STREAM_UP_TO_DATE_HEADER] = `true`;
2083
+ const currentStream = this.store.get(path);
2084
+ const clientAtTail = responseOffset === currentStream?.currentOffset;
2085
+ if (currentStream?.closed && clientAtTail && upToDate) headers[STREAM_CLOSED_HEADER] = `true`;
1808
2086
  const startOffset = offset ?? `-1`;
1809
- const etag = `"${Buffer.from(path).toString(`base64`)}:${startOffset}:${responseOffset}"`;
2087
+ const closedSuffix = currentStream?.closed && clientAtTail && upToDate ? `:c` : ``;
2088
+ const etag = `"${Buffer.from(path).toString(`base64`)}:${startOffset}:${responseOffset}${closedSuffix}"`;
1810
2089
  headers[`etag`] = etag;
1811
2090
  const ifNoneMatch = req.headers[`if-none-match`];
1812
2091
  if (ifNoneMatch && ifNoneMatch === etag) {
@@ -1818,10 +2097,10 @@ var DurableStreamTestServer = class {
1818
2097
  let finalData = responseData;
1819
2098
  if (this.options.compression && responseData.length >= COMPRESSION_THRESHOLD) {
1820
2099
  const acceptEncoding = req.headers[`accept-encoding`];
1821
- const encoding = getCompressionEncoding(acceptEncoding);
1822
- if (encoding) {
1823
- finalData = compressData(responseData, encoding);
1824
- headers[`content-encoding`] = encoding;
2100
+ const compressionEncoding = getCompressionEncoding(acceptEncoding);
2101
+ if (compressionEncoding) {
2102
+ finalData = compressData(responseData, compressionEncoding);
2103
+ headers[`content-encoding`] = compressionEncoding;
1825
2104
  headers[`vary`] = `accept-encoding`;
1826
2105
  }
1827
2106
  }
@@ -1832,16 +2111,18 @@ var DurableStreamTestServer = class {
1832
2111
  /**
1833
2112
  * Handle SSE (Server-Sent Events) mode
1834
2113
  */
1835
- async handleSSE(path, stream, initialOffset, cursor, res) {
2114
+ async handleSSE(path, stream, initialOffset, cursor, useBase64, res) {
1836
2115
  this.activeSSEResponses.add(res);
1837
- res.writeHead(200, {
2116
+ const sseHeaders = {
1838
2117
  "content-type": `text/event-stream`,
1839
2118
  "cache-control": `no-cache`,
1840
2119
  connection: `keep-alive`,
1841
2120
  "access-control-allow-origin": `*`,
1842
2121
  "x-content-type-options": `nosniff`,
1843
2122
  "cross-origin-resource-policy": `cross-origin`
1844
- });
2123
+ };
2124
+ if (useBase64) sseHeaders[STREAM_SSE_DATA_ENCODING_HEADER] = `base64`;
2125
+ res.writeHead(200, sseHeaders);
1845
2126
  const fault = res._injectedFault;
1846
2127
  if (fault?.injectSseEvent) {
1847
2128
  res.write(`event: ${fault.injectSseEvent.eventType}\n`);
@@ -1859,7 +2140,8 @@ var DurableStreamTestServer = class {
1859
2140
  const { messages, upToDate } = this.store.read(path, currentOffset);
1860
2141
  for (const message of messages) {
1861
2142
  let dataPayload;
1862
- if (isJsonStream) {
2143
+ if (useBase64) dataPayload = Buffer.from(message.data).toString(`base64`);
2144
+ else if (isJsonStream) {
1863
2145
  const jsonBytes = this.store.formatResponse(path, [message]);
1864
2146
  dataPayload = decoder.decode(jsonBytes);
1865
2147
  } else dataPayload = decoder.decode(message.data);
@@ -1867,28 +2149,60 @@ var DurableStreamTestServer = class {
1867
2149
  res.write(encodeSSEData(dataPayload));
1868
2150
  currentOffset = message.offset;
1869
2151
  }
1870
- const controlOffset = messages[messages.length - 1]?.offset ?? stream.currentOffset;
2152
+ const currentStream = this.store.get(path);
2153
+ const controlOffset = messages[messages.length - 1]?.offset ?? currentStream.currentOffset;
2154
+ const streamIsClosed = currentStream?.closed ?? false;
2155
+ const clientAtTail = controlOffset === currentStream.currentOffset;
1871
2156
  const responseCursor = generateResponseCursor(cursor, this.options.cursorOptions);
1872
- const controlData = {
1873
- [SSE_OFFSET_FIELD]: controlOffset,
1874
- [SSE_CURSOR_FIELD]: responseCursor
1875
- };
1876
- if (upToDate) controlData[SSE_UP_TO_DATE_FIELD] = true;
2157
+ const controlData = { [SSE_OFFSET_FIELD]: controlOffset };
2158
+ if (streamIsClosed && clientAtTail) controlData[SSE_CLOSED_FIELD] = true;
2159
+ else {
2160
+ controlData[SSE_CURSOR_FIELD] = responseCursor;
2161
+ if (upToDate) controlData[SSE_UP_TO_DATE_FIELD] = true;
2162
+ }
1877
2163
  res.write(`event: control\n`);
1878
2164
  res.write(encodeSSEData(JSON.stringify(controlData)));
2165
+ if (streamIsClosed && clientAtTail) break;
1879
2166
  currentOffset = controlOffset;
1880
2167
  if (upToDate) {
2168
+ if (currentStream?.closed) {
2169
+ const finalControlData = {
2170
+ [SSE_OFFSET_FIELD]: currentOffset,
2171
+ [SSE_CLOSED_FIELD]: true
2172
+ };
2173
+ res.write(`event: control\n`);
2174
+ res.write(encodeSSEData(JSON.stringify(finalControlData)));
2175
+ break;
2176
+ }
1881
2177
  const result = await this.store.waitForMessages(path, currentOffset, this.options.longPollTimeout);
1882
2178
  if (this.isShuttingDown || !isConnected) break;
2179
+ if (result.streamClosed) {
2180
+ const finalControlData = {
2181
+ [SSE_OFFSET_FIELD]: currentOffset,
2182
+ [SSE_CLOSED_FIELD]: true
2183
+ };
2184
+ res.write(`event: control\n`);
2185
+ res.write(encodeSSEData(JSON.stringify(finalControlData)));
2186
+ break;
2187
+ }
1883
2188
  if (result.timedOut) {
1884
2189
  const keepAliveCursor = generateResponseCursor(cursor, this.options.cursorOptions);
2190
+ const streamAfterWait = this.store.get(path);
2191
+ if (streamAfterWait?.closed) {
2192
+ const closedControlData = {
2193
+ [SSE_OFFSET_FIELD]: currentOffset,
2194
+ [SSE_CLOSED_FIELD]: true
2195
+ };
2196
+ res.write(`event: control\n`);
2197
+ res.write(encodeSSEData(JSON.stringify(closedControlData)));
2198
+ break;
2199
+ }
1885
2200
  const keepAliveData = {
1886
2201
  [SSE_OFFSET_FIELD]: currentOffset,
1887
2202
  [SSE_CURSOR_FIELD]: keepAliveCursor,
1888
2203
  [SSE_UP_TO_DATE_FIELD]: true
1889
2204
  };
1890
- res.write(`event: control\n`);
1891
- res.write(encodeSSEData(JSON.stringify(keepAliveData)));
2205
+ res.write(`event: control\n` + encodeSSEData(JSON.stringify(keepAliveData)));
1892
2206
  }
1893
2207
  }
1894
2208
  }
@@ -1901,20 +2215,11 @@ var DurableStreamTestServer = class {
1901
2215
  async handleAppend(path, req, res) {
1902
2216
  const contentType = req.headers[`content-type`];
1903
2217
  const seq = req.headers[STREAM_SEQ_HEADER.toLowerCase()];
2218
+ const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()];
2219
+ const closeStream = closedHeader === `true`;
1904
2220
  const producerId = req.headers[PRODUCER_ID_HEADER.toLowerCase()];
1905
2221
  const producerEpochStr = req.headers[PRODUCER_EPOCH_HEADER.toLowerCase()];
1906
2222
  const producerSeqStr = req.headers[PRODUCER_SEQ_HEADER.toLowerCase()];
1907
- const body = await this.readBody(req);
1908
- if (body.length === 0) {
1909
- res.writeHead(400, { "content-type": `text/plain` });
1910
- res.end(`Empty body`);
1911
- return;
1912
- }
1913
- if (!contentType) {
1914
- res.writeHead(400, { "content-type": `text/plain` });
1915
- res.end(`Content-Type header is required`);
1916
- return;
1917
- }
1918
2223
  const hasProducerHeaders = producerId !== void 0 || producerEpochStr !== void 0 || producerSeqStr !== void 0;
1919
2224
  const hasAllProducerHeaders = producerId !== void 0 && producerEpochStr !== void 0 && producerSeqStr !== void 0;
1920
2225
  if (hasProducerHeaders && !hasAllProducerHeaders) {
@@ -1954,34 +2259,148 @@ var DurableStreamTestServer = class {
1954
2259
  return;
1955
2260
  }
1956
2261
  }
2262
+ const body = await this.readBody(req);
2263
+ if (body.length === 0 && closeStream) {
2264
+ if (hasAllProducerHeaders) {
2265
+ const closeResult$1 = await this.store.closeStreamWithProducer(path, {
2266
+ producerId,
2267
+ producerEpoch,
2268
+ producerSeq
2269
+ });
2270
+ if (!closeResult$1) {
2271
+ res.writeHead(404, { "content-type": `text/plain` });
2272
+ res.end(`Stream not found`);
2273
+ return;
2274
+ }
2275
+ if (closeResult$1.producerResult?.status === `duplicate`) {
2276
+ res.writeHead(204, {
2277
+ [STREAM_OFFSET_HEADER]: closeResult$1.finalOffset,
2278
+ [STREAM_CLOSED_HEADER]: `true`,
2279
+ [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
2280
+ [PRODUCER_SEQ_HEADER]: closeResult$1.producerResult.lastSeq.toString()
2281
+ });
2282
+ res.end();
2283
+ return;
2284
+ }
2285
+ if (closeResult$1.producerResult?.status === `stale_epoch`) {
2286
+ res.writeHead(403, {
2287
+ "content-type": `text/plain`,
2288
+ [PRODUCER_EPOCH_HEADER]: closeResult$1.producerResult.currentEpoch.toString()
2289
+ });
2290
+ res.end(`Stale producer epoch`);
2291
+ return;
2292
+ }
2293
+ if (closeResult$1.producerResult?.status === `invalid_epoch_seq`) {
2294
+ res.writeHead(400, { "content-type": `text/plain` });
2295
+ res.end(`New epoch must start with sequence 0`);
2296
+ return;
2297
+ }
2298
+ if (closeResult$1.producerResult?.status === `sequence_gap`) {
2299
+ res.writeHead(409, {
2300
+ "content-type": `text/plain`,
2301
+ [PRODUCER_EXPECTED_SEQ_HEADER]: closeResult$1.producerResult.expectedSeq.toString(),
2302
+ [PRODUCER_RECEIVED_SEQ_HEADER]: closeResult$1.producerResult.receivedSeq.toString()
2303
+ });
2304
+ res.end(`Producer sequence gap`);
2305
+ return;
2306
+ }
2307
+ if (closeResult$1.producerResult?.status === `stream_closed`) {
2308
+ const stream = this.store.get(path);
2309
+ res.writeHead(409, {
2310
+ "content-type": `text/plain`,
2311
+ [STREAM_CLOSED_HEADER]: `true`,
2312
+ [STREAM_OFFSET_HEADER]: stream?.currentOffset ?? ``
2313
+ });
2314
+ res.end(`Stream is closed`);
2315
+ return;
2316
+ }
2317
+ res.writeHead(204, {
2318
+ [STREAM_OFFSET_HEADER]: closeResult$1.finalOffset,
2319
+ [STREAM_CLOSED_HEADER]: `true`,
2320
+ [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
2321
+ [PRODUCER_SEQ_HEADER]: producerSeq.toString()
2322
+ });
2323
+ res.end();
2324
+ return;
2325
+ }
2326
+ const closeResult = this.store.closeStream(path);
2327
+ if (!closeResult) {
2328
+ res.writeHead(404, { "content-type": `text/plain` });
2329
+ res.end(`Stream not found`);
2330
+ return;
2331
+ }
2332
+ res.writeHead(204, {
2333
+ [STREAM_OFFSET_HEADER]: closeResult.finalOffset,
2334
+ [STREAM_CLOSED_HEADER]: `true`
2335
+ });
2336
+ res.end();
2337
+ return;
2338
+ }
2339
+ if (body.length === 0) {
2340
+ res.writeHead(400, { "content-type": `text/plain` });
2341
+ res.end(`Empty body`);
2342
+ return;
2343
+ }
2344
+ if (!contentType) {
2345
+ res.writeHead(400, { "content-type": `text/plain` });
2346
+ res.end(`Content-Type header is required`);
2347
+ return;
2348
+ }
1957
2349
  const appendOptions = {
1958
2350
  seq,
1959
2351
  contentType,
1960
2352
  producerId,
1961
2353
  producerEpoch,
1962
- producerSeq
2354
+ producerSeq,
2355
+ close: closeStream
1963
2356
  };
1964
2357
  let result;
1965
2358
  if (producerId !== void 0) result = await this.store.appendWithProducer(path, body, appendOptions);
1966
2359
  else result = await Promise.resolve(this.store.append(path, body, appendOptions));
1967
- if (result && typeof result === `object` && `producerResult` in result) {
1968
- const { message: message$1, producerResult } = result;
2360
+ if (result && typeof result === `object` && `message` in result) {
2361
+ const { message: message$1, producerResult, streamClosed } = result;
2362
+ if (streamClosed && !message$1) {
2363
+ if (producerResult?.status === `duplicate`) {
2364
+ const stream = this.store.get(path);
2365
+ res.writeHead(204, {
2366
+ [STREAM_OFFSET_HEADER]: stream?.currentOffset ?? ``,
2367
+ [STREAM_CLOSED_HEADER]: `true`,
2368
+ [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
2369
+ [PRODUCER_SEQ_HEADER]: producerResult.lastSeq.toString()
2370
+ });
2371
+ res.end();
2372
+ return;
2373
+ }
2374
+ const closedStream = this.store.get(path);
2375
+ res.writeHead(409, {
2376
+ "content-type": `text/plain`,
2377
+ [STREAM_CLOSED_HEADER]: `true`,
2378
+ [STREAM_OFFSET_HEADER]: closedStream?.currentOffset ?? ``
2379
+ });
2380
+ res.end(`Stream is closed`);
2381
+ return;
2382
+ }
1969
2383
  if (!producerResult || producerResult.status === `accepted`) {
1970
- const responseHeaders = { [STREAM_OFFSET_HEADER]: message$1.offset };
1971
- if (producerEpoch !== void 0) responseHeaders[PRODUCER_EPOCH_HEADER] = producerEpoch.toString();
1972
- if (producerSeq !== void 0) responseHeaders[PRODUCER_SEQ_HEADER] = producerSeq.toString();
1973
- res.writeHead(200, responseHeaders);
2384
+ const responseHeaders$1 = { [STREAM_OFFSET_HEADER]: message$1.offset };
2385
+ if (producerEpoch !== void 0) responseHeaders$1[PRODUCER_EPOCH_HEADER] = producerEpoch.toString();
2386
+ if (producerSeq !== void 0) responseHeaders$1[PRODUCER_SEQ_HEADER] = producerSeq.toString();
2387
+ if (streamClosed) responseHeaders$1[STREAM_CLOSED_HEADER] = `true`;
2388
+ const statusCode = producerId !== void 0 ? 200 : 204;
2389
+ res.writeHead(statusCode, responseHeaders$1);
1974
2390
  res.end();
1975
2391
  return;
1976
2392
  }
1977
2393
  switch (producerResult.status) {
1978
- case `duplicate`:
1979
- res.writeHead(204, {
2394
+ case `duplicate`: {
2395
+ const dupHeaders = {
1980
2396
  [PRODUCER_EPOCH_HEADER]: producerEpoch.toString(),
1981
2397
  [PRODUCER_SEQ_HEADER]: producerResult.lastSeq.toString()
1982
- });
2398
+ };
2399
+ if (streamClosed) dupHeaders[STREAM_CLOSED_HEADER] = `true`;
2400
+ res.writeHead(204, dupHeaders);
1983
2401
  res.end();
1984
2402
  return;
2403
+ }
1985
2404
  case `stale_epoch`: {
1986
2405
  res.writeHead(403, {
1987
2406
  "content-type": `text/plain`,
@@ -2005,7 +2424,9 @@ var DurableStreamTestServer = class {
2005
2424
  }
2006
2425
  }
2007
2426
  const message = result;
2008
- res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
2427
+ const responseHeaders = { [STREAM_OFFSET_HEADER]: message.offset };
2428
+ if (closeStream) responseHeaders[STREAM_CLOSED_HEADER] = `true`;
2429
+ res.writeHead(204, responseHeaders);
2009
2430
  res.end();
2010
2431
  }
2011
2432
  /**