@durable-streams/server 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,167 @@
1
+ # @durable-streams/server
2
+
3
+ Node.js reference server implementation for the Durable Streams protocol.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @durable-streams/server
9
+ ```
10
+
11
+ ## Overview
12
+
13
+ This package provides a reference implementation of the Durable Streams protocol for Node.js. It supports both in-memory and file-backed storage modes, making it suitable for development, testing, and production workloads.
14
+
15
+ For a standalone binary option, see the [Caddy-based server](https://github.com/durable-streams/durable-streams/releases).
16
+
17
+ ## Quick Start
18
+
19
+ ```typescript
20
+ import { DurableStreamTestServer } from "@durable-streams/server"
21
+
22
+ const server = new DurableStreamTestServer({
23
+ port: 4437,
24
+ host: "127.0.0.1",
25
+ })
26
+
27
+ await server.start()
28
+ console.log("Server running on http://127.0.0.1:4437")
29
+ ```
30
+
31
+ ## Storage Modes
32
+
33
+ ### In-Memory (Default)
34
+
35
+ Fast, ephemeral storage for development and testing:
36
+
37
+ ```typescript
38
+ import { DurableStreamTestServer, StreamStore } from "@durable-streams/server"
39
+
40
+ const store = new StreamStore()
41
+ const server = new DurableStreamTestServer({
42
+ port: 4437,
43
+ store,
44
+ })
45
+ ```
46
+
47
+ ### File-Backed
48
+
49
+ Persistent storage with streams stored as log files and LMDB for metadata:
50
+
51
+ ```typescript
52
+ import {
53
+ DurableStreamTestServer,
54
+ FileBackedStreamStore,
55
+ } from "@durable-streams/server"
56
+
57
+ const store = new FileBackedStreamStore({
58
+ path: "./data/streams",
59
+ })
60
+ const server = new DurableStreamTestServer({
61
+ port: 4437,
62
+ store,
63
+ })
64
+ ```
65
+
66
+ ## Registry Hooks
67
+
68
+ Track stream lifecycle events (creation, deletion):
69
+
70
+ ```typescript
71
+ import {
72
+ DurableStreamTestServer,
73
+ createRegistryHooks,
74
+ } from "@durable-streams/server"
75
+
76
+ const server = new DurableStreamTestServer({
77
+ port: 4437,
78
+ hooks: createRegistryHooks({
79
+ registryPath: "__registry__",
80
+ }),
81
+ })
82
+ ```
83
+
84
+ The registry maintains a system stream that tracks all stream creates and deletes, useful for building admin UIs or monitoring.
85
+
86
+ ## API
87
+
88
+ ### DurableStreamTestServer
89
+
90
+ ```typescript
91
+ interface TestServerOptions {
92
+ port?: number
93
+ host?: string
94
+ store?: StreamStore | FileBackedStreamStore
95
+ hooks?: StreamLifecycleHook[]
96
+ cors?: boolean
97
+ cursorOptions?: CursorOptions
98
+ }
99
+
100
+ class DurableStreamTestServer {
101
+ constructor(options?: TestServerOptions)
102
+ start(): Promise<void>
103
+ stop(): Promise<void>
104
+ readonly port: number
105
+ readonly baseUrl: string
106
+ }
107
+ ```
108
+
109
+ ### StreamStore
110
+
111
+ In-memory stream storage:
112
+
113
+ ```typescript
114
+ class StreamStore {
115
+ create(path: string, contentType: string, options?: CreateOptions): Stream
116
+ get(path: string): Stream | undefined
117
+ delete(path: string): boolean
118
+ append(path: string, data: Uint8Array, seq?: string): void
119
+ read(path: string, offset: string): ReadResult
120
+ }
121
+ ```
122
+
123
+ ### FileBackedStreamStore
124
+
125
+ File-backed persistent storage (log files for streams, LMDB for metadata) with the same interface as `StreamStore`.
126
+
127
+ ## Exports
128
+
129
+ ```typescript
130
+ export { DurableStreamTestServer } from "./server"
131
+ export { StreamStore } from "./store"
132
+ export { FileBackedStreamStore } from "./file-store"
133
+ export { encodeStreamPath, decodeStreamPath } from "./path-encoding"
134
+ export { createRegistryHooks } from "./registry-hook"
135
+ export {
136
+ calculateCursor,
137
+ handleCursorCollision,
138
+ generateResponseCursor,
139
+ DEFAULT_CURSOR_EPOCH,
140
+ DEFAULT_CURSOR_INTERVAL_SECONDS,
141
+ type CursorOptions,
142
+ } from "./cursor"
143
+ export type {
144
+ Stream,
145
+ StreamMessage,
146
+ TestServerOptions,
147
+ PendingLongPoll,
148
+ StreamLifecycleEvent,
149
+ StreamLifecycleHook,
150
+ } from "./types"
151
+ ```
152
+
153
+ ## Testing Your Implementation
154
+
155
+ Use the conformance test suite to validate protocol compliance:
156
+
157
+ ```typescript
158
+ import { runConformanceTests } from "@durable-streams/server-conformance-tests"
159
+
160
+ runConformanceTests({
161
+ baseUrl: "http://localhost:4437",
162
+ })
163
+ ```
164
+
165
+ ## License
166
+
167
+ Apache-2.0
package/dist/index.cjs CHANGED
@@ -88,12 +88,40 @@ var StreamStore = class {
88
88
  streams = new Map();
89
89
  pendingLongPolls = [];
90
90
  /**
91
+ * Check if a stream is expired based on TTL or Expires-At.
92
+ */
93
+ isExpired(stream) {
94
+ const now = Date.now();
95
+ if (stream.expiresAt) {
96
+ const expiryTime = new Date(stream.expiresAt).getTime();
97
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
98
+ }
99
+ if (stream.ttlSeconds !== void 0) {
100
+ const expiryTime = stream.createdAt + stream.ttlSeconds * 1e3;
101
+ if (now >= expiryTime) return true;
102
+ }
103
+ return false;
104
+ }
105
+ /**
106
+ * Get a stream, deleting it if expired.
107
+ * Returns undefined if stream doesn't exist or is expired.
108
+ */
109
+ getIfNotExpired(path) {
110
+ const stream = this.streams.get(path);
111
+ if (!stream) return void 0;
112
+ if (this.isExpired(stream)) {
113
+ this.delete(path);
114
+ return void 0;
115
+ }
116
+ return stream;
117
+ }
118
+ /**
91
119
  * Create a new stream.
92
120
  * @throws Error if stream already exists with different config
93
121
  * @returns existing stream if config matches (idempotent)
94
122
  */
95
123
  create(path, options = {}) {
96
- const existing = this.streams.get(path);
124
+ const existing = this.getIfNotExpired(path);
97
125
  if (existing) {
98
126
  const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
99
127
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
@@ -116,15 +144,16 @@ var StreamStore = class {
116
144
  }
117
145
  /**
118
146
  * Get a stream by path.
147
+ * Returns undefined if stream doesn't exist or is expired.
119
148
  */
120
149
  get(path) {
121
- return this.streams.get(path);
150
+ return this.getIfNotExpired(path);
122
151
  }
123
152
  /**
124
- * Check if a stream exists.
153
+ * Check if a stream exists (and is not expired).
125
154
  */
126
155
  has(path) {
127
- return this.streams.has(path);
156
+ return this.getIfNotExpired(path) !== void 0;
128
157
  }
129
158
  /**
130
159
  * Delete a stream.
@@ -135,12 +164,12 @@ var StreamStore = class {
135
164
  }
136
165
  /**
137
166
  * Append data to a stream.
138
- * @throws Error if stream doesn't exist
167
+ * @throws Error if stream doesn't exist or is expired
139
168
  * @throws Error if seq is lower than lastSeq
140
169
  * @throws Error if JSON mode and array is empty
141
170
  */
142
171
  append(path, data, options = {}) {
143
- const stream = this.streams.get(path);
172
+ const stream = this.getIfNotExpired(path);
144
173
  if (!stream) throw new Error(`Stream not found: ${path}`);
145
174
  if (options.contentType && stream.contentType) {
146
175
  const providedType = normalizeContentType(options.contentType);
@@ -157,9 +186,10 @@ var StreamStore = class {
157
186
  }
158
187
  /**
159
188
  * Read messages from a stream starting at the given offset.
189
+ * @throws Error if stream doesn't exist or is expired
160
190
  */
161
191
  read(path, offset) {
162
- const stream = this.streams.get(path);
192
+ const stream = this.getIfNotExpired(path);
163
193
  if (!stream) throw new Error(`Stream not found: ${path}`);
164
194
  if (!offset || offset === `-1`) return {
165
195
  messages: [...stream.messages],
@@ -178,9 +208,10 @@ var StreamStore = class {
178
208
  /**
179
209
  * Format messages for response.
180
210
  * For JSON mode, wraps concatenated data in array brackets.
211
+ * @throws Error if stream doesn't exist or is expired
181
212
  */
182
213
  formatResponse(path, messages) {
183
- const stream = this.streams.get(path);
214
+ const stream = this.getIfNotExpired(path);
184
215
  if (!stream) throw new Error(`Stream not found: ${path}`);
185
216
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
186
217
  const concatenated = new Uint8Array(totalSize);
@@ -194,9 +225,10 @@ var StreamStore = class {
194
225
  }
195
226
  /**
196
227
  * Wait for new messages (long-poll).
228
+ * @throws Error if stream doesn't exist or is expired
197
229
  */
198
230
  async waitForMessages(path, offset, timeoutMs) {
199
- const stream = this.streams.get(path);
231
+ const stream = this.getIfNotExpired(path);
200
232
  if (!stream) throw new Error(`Stream not found: ${path}`);
201
233
  const { messages } = this.read(path, offset);
202
234
  if (messages.length > 0) return {
@@ -229,9 +261,10 @@ var StreamStore = class {
229
261
  }
230
262
  /**
231
263
  * Get the current offset for a stream.
264
+ * Returns undefined if stream doesn't exist or is expired.
232
265
  */
233
266
  getCurrentOffset(path) {
234
- return this.streams.get(path)?.currentOffset;
267
+ return this.getIfNotExpired(path)?.currentOffset;
235
268
  }
236
269
  /**
237
270
  * Clear all streams.
@@ -605,6 +638,35 @@ var FileBackedStreamStore = class {
605
638
  };
606
639
  }
607
640
  /**
641
+ * Check if a stream is expired based on TTL or Expires-At.
642
+ */
643
+ isExpired(meta) {
644
+ const now = Date.now();
645
+ if (meta.expiresAt) {
646
+ const expiryTime = new Date(meta.expiresAt).getTime();
647
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
648
+ }
649
+ if (meta.ttlSeconds !== void 0) {
650
+ const expiryTime = meta.createdAt + meta.ttlSeconds * 1e3;
651
+ if (now >= expiryTime) return true;
652
+ }
653
+ return false;
654
+ }
655
+ /**
656
+ * Get stream metadata, deleting it if expired.
657
+ * Returns undefined if stream doesn't exist or is expired.
658
+ */
659
+ getMetaIfNotExpired(streamPath) {
660
+ const key = `stream:${streamPath}`;
661
+ const meta = this.db.get(key);
662
+ if (!meta) return void 0;
663
+ if (this.isExpired(meta)) {
664
+ this.delete(streamPath);
665
+ return void 0;
666
+ }
667
+ return meta;
668
+ }
669
+ /**
608
670
  * Close the store, closing all file handles and database.
609
671
  * All data is already fsynced on each append, so no final flush needed.
610
672
  */
@@ -613,8 +675,7 @@ var FileBackedStreamStore = class {
613
675
  await this.db.close();
614
676
  }
615
677
  async create(streamPath, options = {}) {
616
- const key = `stream:${streamPath}`;
617
- const existing = this.db.get(key);
678
+ const existing = this.getMetaIfNotExpired(streamPath);
618
679
  if (existing) {
619
680
  const normalizeMimeType = (ct) => (ct ?? `application/octet-stream`).toLowerCase();
620
681
  const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
@@ -623,6 +684,7 @@ var FileBackedStreamStore = class {
623
684
  if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
624
685
  else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
625
686
  }
687
+ const key = `stream:${streamPath}`;
626
688
  const streamMeta = {
627
689
  path: streamPath,
628
690
  contentType: options.contentType,
@@ -656,13 +718,11 @@ var FileBackedStreamStore = class {
656
718
  return this.streamMetaToStream(streamMeta);
657
719
  }
658
720
  get(streamPath) {
659
- const key = `stream:${streamPath}`;
660
- const meta = this.db.get(key);
721
+ const meta = this.getMetaIfNotExpired(streamPath);
661
722
  return meta ? this.streamMetaToStream(meta) : void 0;
662
723
  }
663
724
  has(streamPath) {
664
- const key = `stream:${streamPath}`;
665
- return this.db.get(key) !== void 0;
725
+ return this.getMetaIfNotExpired(streamPath) !== void 0;
666
726
  }
667
727
  delete(streamPath) {
668
728
  const key = `stream:${streamPath}`;
@@ -680,8 +740,7 @@ var FileBackedStreamStore = class {
680
740
  return true;
681
741
  }
682
742
  async append(streamPath, data, options = {}) {
683
- const key = `stream:${streamPath}`;
684
- const streamMeta = this.db.get(key);
743
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
685
744
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
686
745
  if (options.contentType && streamMeta.contentType) {
687
746
  const providedType = normalizeContentType(options.contentType);
@@ -729,13 +788,13 @@ var FileBackedStreamStore = class {
729
788
  lastSeq: options.seq ?? streamMeta.lastSeq,
730
789
  totalBytes: streamMeta.totalBytes + processedData.length + 5
731
790
  };
791
+ const key = `stream:${streamPath}`;
732
792
  this.db.putSync(key, updatedMeta);
733
793
  this.notifyLongPolls(streamPath);
734
794
  return message;
735
795
  }
736
796
  read(streamPath, offset) {
737
- const key = `stream:${streamPath}`;
738
- const streamMeta = this.db.get(key);
797
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
739
798
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
740
799
  const startOffset = offset ?? `0000000000000000_0000000000000000`;
741
800
  const startParts = startOffset.split(`_`).map(Number);
@@ -787,8 +846,7 @@ var FileBackedStreamStore = class {
787
846
  };
788
847
  }
789
848
  async waitForMessages(streamPath, offset, timeoutMs) {
790
- const key = `stream:${streamPath}`;
791
- const streamMeta = this.db.get(key);
849
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
792
850
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
793
851
  const { messages } = this.read(streamPath, offset);
794
852
  if (messages.length > 0) return {
@@ -822,10 +880,10 @@ var FileBackedStreamStore = class {
822
880
  /**
823
881
  * Format messages for response.
824
882
  * For JSON mode, wraps concatenated data in array brackets.
883
+ * @throws Error if stream doesn't exist or is expired
825
884
  */
826
885
  formatResponse(streamPath, messages) {
827
- const key = `stream:${streamPath}`;
828
- const streamMeta = this.db.get(key);
886
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
829
887
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
830
888
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
831
889
  const concatenated = new Uint8Array(totalSize);
@@ -838,8 +896,7 @@ var FileBackedStreamStore = class {
838
896
  return concatenated;
839
897
  }
840
898
  getCurrentOffset(streamPath) {
841
- const key = `stream:${streamPath}`;
842
- const streamMeta = this.db.get(key);
899
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
843
900
  return streamMeta?.currentOffset;
844
901
  }
845
902
  clear() {
@@ -1018,10 +1075,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
1018
1075
  /**
1019
1076
  * Encode data for SSE format.
1020
1077
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
1021
- * Newlines in the payload become separate data: lines.
1078
+ * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
1079
+ * This prevents CRLF injection attacks where malicious payloads could inject
1080
+ * fake SSE events using CR-only line terminators.
1022
1081
  */
1023
1082
  function encodeSSEData(payload) {
1024
- const lines = payload.split(`\n`);
1083
+ const lines = payload.split(/\r\n|\r|\n/);
1025
1084
  return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
1026
1085
  }
1027
1086
  /**
@@ -1060,8 +1119,8 @@ var DurableStreamTestServer = class {
1060
1119
  _url = null;
1061
1120
  activeSSEResponses = new Set();
1062
1121
  isShuttingDown = false;
1063
- /** Injected errors for testing retry/resilience */
1064
- injectedErrors = new Map();
1122
+ /** Injected faults for testing retry/resilience */
1123
+ injectedFaults = new Map();
1065
1124
  constructor(options = {}) {
1066
1125
  if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
1067
1126
  else this.store = new StreamStore();
@@ -1146,30 +1205,71 @@ var DurableStreamTestServer = class {
1146
1205
  /**
1147
1206
  * Inject an error to be returned on the next N requests to a path.
1148
1207
  * Used for testing retry/resilience behavior.
1208
+ * @deprecated Use injectFault for full fault injection capabilities
1149
1209
  */
1150
1210
  injectError(path, status, count = 1, retryAfter) {
1151
- this.injectedErrors.set(path, {
1211
+ this.injectedFaults.set(path, {
1152
1212
  status,
1153
1213
  count,
1154
1214
  retryAfter
1155
1215
  });
1156
1216
  }
1157
1217
  /**
1158
- * Clear all injected errors.
1218
+ * Inject a fault to be triggered on the next N requests to a path.
1219
+ * Supports various fault types: delays, connection drops, body corruption, etc.
1159
1220
  */
1160
- clearInjectedErrors() {
1161
- this.injectedErrors.clear();
1221
+ injectFault(path, fault) {
1222
+ this.injectedFaults.set(path, {
1223
+ count: 1,
1224
+ ...fault
1225
+ });
1226
+ }
1227
+ /**
1228
+ * Clear all injected faults.
1229
+ */
1230
+ clearInjectedFaults() {
1231
+ this.injectedFaults.clear();
1162
1232
  }
1163
1233
  /**
1164
- * Check if there's an injected error for this path and consume it.
1165
- * Returns the error config if one should be returned, null otherwise.
1234
+ * Check if there's an injected fault for this path/method and consume it.
1235
+ * Returns the fault config if one should be triggered, null otherwise.
1166
1236
  */
1167
- consumeInjectedError(path) {
1168
- const error = this.injectedErrors.get(path);
1169
- if (!error) return null;
1170
- error.count--;
1171
- if (error.count <= 0) this.injectedErrors.delete(path);
1172
- return error;
1237
+ consumeInjectedFault(path, method) {
1238
+ const fault = this.injectedFaults.get(path);
1239
+ if (!fault) return null;
1240
+ if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) return null;
1241
+ if (fault.probability !== void 0 && Math.random() > fault.probability) return null;
1242
+ fault.count--;
1243
+ if (fault.count <= 0) this.injectedFaults.delete(path);
1244
+ return fault;
1245
+ }
1246
+ /**
1247
+ * Apply delay from fault config (including jitter).
1248
+ */
1249
+ async applyFaultDelay(fault) {
1250
+ if (fault.delayMs !== void 0 && fault.delayMs > 0) {
1251
+ const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0;
1252
+ await new Promise((resolve) => setTimeout(resolve, fault.delayMs + jitter));
1253
+ }
1254
+ }
1255
+ /**
1256
+ * Apply body modifications from stored fault (truncation, corruption).
1257
+ * Returns modified body, or original if no modifications needed.
1258
+ */
1259
+ applyFaultBodyModification(res, body) {
1260
+ const fault = res._injectedFault;
1261
+ if (!fault) return body;
1262
+ let modified = body;
1263
+ if (fault.truncateBodyBytes !== void 0 && modified.length > fault.truncateBodyBytes) modified = modified.slice(0, fault.truncateBodyBytes);
1264
+ if (fault.corruptBody && modified.length > 0) {
1265
+ modified = new Uint8Array(modified);
1266
+ const numCorrupt = Math.max(1, Math.floor(modified.length * .03));
1267
+ for (let i = 0; i < numCorrupt; i++) {
1268
+ const pos = Math.floor(Math.random() * modified.length);
1269
+ modified[pos] = modified[pos] ^ 1 << Math.floor(Math.random() * 8);
1270
+ }
1271
+ }
1272
+ return modified;
1173
1273
  }
1174
1274
  async handleRequest(req, res) {
1175
1275
  const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
@@ -1179,6 +1279,8 @@ var DurableStreamTestServer = class {
1179
1279
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
1180
1280
  res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
1181
1281
  res.setHeader(`access-control-expose-headers`, `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`);
1282
+ res.setHeader(`x-content-type-options`, `nosniff`);
1283
+ res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
1182
1284
  if (method === `OPTIONS`) {
1183
1285
  res.writeHead(204);
1184
1286
  res.end();
@@ -1188,13 +1290,21 @@ var DurableStreamTestServer = class {
1188
1290
  await this.handleTestInjectError(method, req, res);
1189
1291
  return;
1190
1292
  }
1191
- const injectedError = this.consumeInjectedError(path);
1192
- if (injectedError) {
1193
- const headers = { "content-type": `text/plain` };
1194
- if (injectedError.retryAfter !== void 0) headers[`retry-after`] = injectedError.retryAfter.toString();
1195
- res.writeHead(injectedError.status, headers);
1196
- res.end(`Injected error for testing`);
1197
- return;
1293
+ const fault = this.consumeInjectedFault(path, method ?? `GET`);
1294
+ if (fault) {
1295
+ await this.applyFaultDelay(fault);
1296
+ if (fault.dropConnection) {
1297
+ res.socket?.destroy();
1298
+ return;
1299
+ }
1300
+ if (fault.status !== void 0) {
1301
+ const headers = { "content-type": `text/plain` };
1302
+ if (fault.retryAfter !== void 0) headers[`retry-after`] = fault.retryAfter.toString();
1303
+ res.writeHead(fault.status, headers);
1304
+ res.end(`Injected error for testing`);
1305
+ return;
1306
+ }
1307
+ if (fault.truncateBodyBytes !== void 0 || fault.corruptBody) res._injectedFault = fault;
1198
1308
  }
1199
1309
  try {
1200
1310
  switch (method) {
@@ -1309,7 +1419,10 @@ var DurableStreamTestServer = class {
1309
1419
  res.end();
1310
1420
  return;
1311
1421
  }
1312
- const headers = { [STREAM_OFFSET_HEADER]: stream.currentOffset };
1422
+ const headers = {
1423
+ [STREAM_OFFSET_HEADER]: stream.currentOffset,
1424
+ "cache-control": `no-store`
1425
+ };
1313
1426
  if (stream.contentType) headers[`content-type`] = stream.contentType;
1314
1427
  headers[`etag`] = `"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}"`;
1315
1428
  res.writeHead(200, headers);
@@ -1400,6 +1513,7 @@ var DurableStreamTestServer = class {
1400
1513
  headers[`vary`] = `accept-encoding`;
1401
1514
  }
1402
1515
  }
1516
+ finalData = this.applyFaultBodyModification(res, finalData);
1403
1517
  res.writeHead(200, headers);
1404
1518
  res.end(Buffer.from(finalData));
1405
1519
  }
@@ -1412,7 +1526,9 @@ var DurableStreamTestServer = class {
1412
1526
  "content-type": `text/event-stream`,
1413
1527
  "cache-control": `no-cache`,
1414
1528
  connection: `keep-alive`,
1415
- "access-control-allow-origin": `*`
1529
+ "access-control-allow-origin": `*`,
1530
+ "x-content-type-options": `nosniff`,
1531
+ "cross-origin-resource-policy": `cross-origin`
1416
1532
  });
1417
1533
  let currentOffset = initialOffset;
1418
1534
  let isConnected = true;
@@ -1483,7 +1599,7 @@ var DurableStreamTestServer = class {
1483
1599
  seq,
1484
1600
  contentType
1485
1601
  }));
1486
- res.writeHead(200, { [STREAM_OFFSET_HEADER]: message.offset });
1602
+ res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
1487
1603
  res.end();
1488
1604
  }
1489
1605
  /**
@@ -1514,12 +1630,29 @@ var DurableStreamTestServer = class {
1514
1630
  const body = await this.readBody(req);
1515
1631
  try {
1516
1632
  const config = JSON.parse(new TextDecoder().decode(body));
1517
- if (!config.path || !config.status) {
1633
+ if (!config.path) {
1518
1634
  res.writeHead(400, { "content-type": `text/plain` });
1519
- res.end(`Missing required fields: path, status`);
1635
+ res.end(`Missing required field: path`);
1520
1636
  return;
1521
1637
  }
1522
- this.injectError(config.path, config.status, config.count ?? 1, config.retryAfter);
1638
+ const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
1639
+ if (!hasFaultType) {
1640
+ res.writeHead(400, { "content-type": `text/plain` });
1641
+ res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
1642
+ return;
1643
+ }
1644
+ this.injectFault(config.path, {
1645
+ status: config.status,
1646
+ count: config.count ?? 1,
1647
+ retryAfter: config.retryAfter,
1648
+ delayMs: config.delayMs,
1649
+ dropConnection: config.dropConnection,
1650
+ truncateBodyBytes: config.truncateBodyBytes,
1651
+ probability: config.probability,
1652
+ method: config.method,
1653
+ corruptBody: config.corruptBody,
1654
+ jitterMs: config.jitterMs
1655
+ });
1523
1656
  res.writeHead(200, { "content-type": `application/json` });
1524
1657
  res.end(JSON.stringify({ ok: true }));
1525
1658
  } catch {
@@ -1527,7 +1660,7 @@ var DurableStreamTestServer = class {
1527
1660
  res.end(`Invalid JSON body`);
1528
1661
  }
1529
1662
  } else if (method === `DELETE`) {
1530
- this.clearInjectedErrors();
1663
+ this.clearInjectedFaults();
1531
1664
  res.writeHead(200, { "content-type": `application/json` });
1532
1665
  res.end(JSON.stringify({ ok: true }));
1533
1666
  } else {