@durable-streams/server 0.1.3 → 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/dist/index.cjs CHANGED
@@ -1075,10 +1075,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
1075
1075
  /**
1076
1076
  * Encode data for SSE format.
1077
1077
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
1078
- * 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.
1079
1081
  */
1080
1082
  function encodeSSEData(payload) {
1081
- const lines = payload.split(`\n`);
1083
+ const lines = payload.split(/\r\n|\r|\n/);
1082
1084
  return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
1083
1085
  }
1084
1086
  /**
@@ -1117,8 +1119,8 @@ var DurableStreamTestServer = class {
1117
1119
  _url = null;
1118
1120
  activeSSEResponses = new Set();
1119
1121
  isShuttingDown = false;
1120
- /** Injected errors for testing retry/resilience */
1121
- injectedErrors = new Map();
1122
+ /** Injected faults for testing retry/resilience */
1123
+ injectedFaults = new Map();
1122
1124
  constructor(options = {}) {
1123
1125
  if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
1124
1126
  else this.store = new StreamStore();
@@ -1203,30 +1205,71 @@ var DurableStreamTestServer = class {
1203
1205
  /**
1204
1206
  * Inject an error to be returned on the next N requests to a path.
1205
1207
  * Used for testing retry/resilience behavior.
1208
+ * @deprecated Use injectFault for full fault injection capabilities
1206
1209
  */
1207
1210
  injectError(path, status, count = 1, retryAfter) {
1208
- this.injectedErrors.set(path, {
1211
+ this.injectedFaults.set(path, {
1209
1212
  status,
1210
1213
  count,
1211
1214
  retryAfter
1212
1215
  });
1213
1216
  }
1214
1217
  /**
1215
- * 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.
1216
1220
  */
1217
- clearInjectedErrors() {
1218
- 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();
1232
+ }
1233
+ /**
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.
1236
+ */
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
+ }
1219
1254
  }
1220
1255
  /**
1221
- * Check if there's an injected error for this path and consume it.
1222
- * Returns the error config if one should be returned, null otherwise.
1256
+ * Apply body modifications from stored fault (truncation, corruption).
1257
+ * Returns modified body, or original if no modifications needed.
1223
1258
  */
1224
- consumeInjectedError(path) {
1225
- const error = this.injectedErrors.get(path);
1226
- if (!error) return null;
1227
- error.count--;
1228
- if (error.count <= 0) this.injectedErrors.delete(path);
1229
- return error;
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;
1230
1273
  }
1231
1274
  async handleRequest(req, res) {
1232
1275
  const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
@@ -1236,6 +1279,8 @@ var DurableStreamTestServer = class {
1236
1279
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
1237
1280
  res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
1238
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`);
1239
1284
  if (method === `OPTIONS`) {
1240
1285
  res.writeHead(204);
1241
1286
  res.end();
@@ -1245,13 +1290,21 @@ var DurableStreamTestServer = class {
1245
1290
  await this.handleTestInjectError(method, req, res);
1246
1291
  return;
1247
1292
  }
1248
- const injectedError = this.consumeInjectedError(path);
1249
- if (injectedError) {
1250
- const headers = { "content-type": `text/plain` };
1251
- if (injectedError.retryAfter !== void 0) headers[`retry-after`] = injectedError.retryAfter.toString();
1252
- res.writeHead(injectedError.status, headers);
1253
- res.end(`Injected error for testing`);
1254
- 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;
1255
1308
  }
1256
1309
  try {
1257
1310
  switch (method) {
@@ -1366,7 +1419,10 @@ var DurableStreamTestServer = class {
1366
1419
  res.end();
1367
1420
  return;
1368
1421
  }
1369
- const headers = { [STREAM_OFFSET_HEADER]: stream.currentOffset };
1422
+ const headers = {
1423
+ [STREAM_OFFSET_HEADER]: stream.currentOffset,
1424
+ "cache-control": `no-store`
1425
+ };
1370
1426
  if (stream.contentType) headers[`content-type`] = stream.contentType;
1371
1427
  headers[`etag`] = `"${Buffer.from(path).toString(`base64`)}:-1:${stream.currentOffset}"`;
1372
1428
  res.writeHead(200, headers);
@@ -1457,6 +1513,7 @@ var DurableStreamTestServer = class {
1457
1513
  headers[`vary`] = `accept-encoding`;
1458
1514
  }
1459
1515
  }
1516
+ finalData = this.applyFaultBodyModification(res, finalData);
1460
1517
  res.writeHead(200, headers);
1461
1518
  res.end(Buffer.from(finalData));
1462
1519
  }
@@ -1469,7 +1526,9 @@ var DurableStreamTestServer = class {
1469
1526
  "content-type": `text/event-stream`,
1470
1527
  "cache-control": `no-cache`,
1471
1528
  connection: `keep-alive`,
1472
- "access-control-allow-origin": `*`
1529
+ "access-control-allow-origin": `*`,
1530
+ "x-content-type-options": `nosniff`,
1531
+ "cross-origin-resource-policy": `cross-origin`
1473
1532
  });
1474
1533
  let currentOffset = initialOffset;
1475
1534
  let isConnected = true;
@@ -1540,7 +1599,7 @@ var DurableStreamTestServer = class {
1540
1599
  seq,
1541
1600
  contentType
1542
1601
  }));
1543
- res.writeHead(200, { [STREAM_OFFSET_HEADER]: message.offset });
1602
+ res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
1544
1603
  res.end();
1545
1604
  }
1546
1605
  /**
@@ -1571,12 +1630,29 @@ var DurableStreamTestServer = class {
1571
1630
  const body = await this.readBody(req);
1572
1631
  try {
1573
1632
  const config = JSON.parse(new TextDecoder().decode(body));
1574
- if (!config.path || !config.status) {
1633
+ if (!config.path) {
1634
+ res.writeHead(400, { "content-type": `text/plain` });
1635
+ res.end(`Missing required field: path`);
1636
+ return;
1637
+ }
1638
+ const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
1639
+ if (!hasFaultType) {
1575
1640
  res.writeHead(400, { "content-type": `text/plain` });
1576
- res.end(`Missing required fields: path, status`);
1641
+ res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
1577
1642
  return;
1578
1643
  }
1579
- this.injectError(config.path, config.status, config.count ?? 1, config.retryAfter);
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
+ });
1580
1656
  res.writeHead(200, { "content-type": `application/json` });
1581
1657
  res.end(JSON.stringify({ ok: true }));
1582
1658
  } catch {
@@ -1584,7 +1660,7 @@ var DurableStreamTestServer = class {
1584
1660
  res.end(`Invalid JSON body`);
1585
1661
  }
1586
1662
  } else if (method === `DELETE`) {
1587
- this.clearInjectedErrors();
1663
+ this.clearInjectedFaults();
1588
1664
  res.writeHead(200, { "content-type": `application/json` });
1589
1665
  res.end(JSON.stringify({ ok: true }));
1590
1666
  } else {
package/dist/index.d.cts CHANGED
@@ -332,6 +332,36 @@ declare class FileBackedStreamStore {
332
332
 
333
333
  //#endregion
334
334
  //#region src/server.d.ts
335
+ /**
336
+ * HTTP server for testing durable streams.
337
+ * Supports both in-memory and file-backed storage modes.
338
+ */
339
+ /**
340
+ * Configuration for injected faults (for testing retry/resilience).
341
+ * Supports various fault types beyond simple HTTP errors.
342
+ */
343
+ interface InjectedFault {
344
+ /** HTTP status code to return (if set, returns error response) */
345
+ status?: number;
346
+ /** Number of times to trigger this fault (decremented on each use) */
347
+ count: number;
348
+ /** Optional Retry-After header value (seconds) */
349
+ retryAfter?: number;
350
+ /** Delay in milliseconds before responding */
351
+ delayMs?: number;
352
+ /** Drop the connection after sending headers (simulates network failure) */
353
+ dropConnection?: boolean;
354
+ /** Truncate response body to this many bytes */
355
+ truncateBodyBytes?: number;
356
+ /** Probability of triggering fault (0-1, default 1.0 = always) */
357
+ probability?: number;
358
+ /** Only match specific HTTP method (GET, POST, PUT, DELETE) */
359
+ method?: string;
360
+ /** Corrupt the response body by flipping random bits */
361
+ corruptBody?: boolean;
362
+ /** Add jitter to delay (random 0-jitterMs added to delayMs) */
363
+ jitterMs?: number;
364
+ }
335
365
  declare class DurableStreamTestServer {
336
366
  readonly store: StreamStore | FileBackedStreamStore;
337
367
  private server;
@@ -339,8 +369,8 @@ declare class DurableStreamTestServer {
339
369
  private _url;
340
370
  private activeSSEResponses;
341
371
  private isShuttingDown;
342
- /** Injected errors for testing retry/resilience */
343
- private injectedErrors;
372
+ /** Injected faults for testing retry/resilience */
373
+ private injectedFaults;
344
374
  constructor(options?: TestServerOptions);
345
375
  /**
346
376
  * Start the server.
@@ -361,17 +391,34 @@ declare class DurableStreamTestServer {
361
391
  /**
362
392
  * Inject an error to be returned on the next N requests to a path.
363
393
  * Used for testing retry/resilience behavior.
394
+ * @deprecated Use injectFault for full fault injection capabilities
364
395
  */
365
396
  injectError(path: string, status: number, count?: number, retryAfter?: number): void;
366
397
  /**
367
- * Clear all injected errors.
398
+ * Inject a fault to be triggered on the next N requests to a path.
399
+ * Supports various fault types: delays, connection drops, body corruption, etc.
400
+ */
401
+ injectFault(path: string, fault: Omit<InjectedFault, `count`> & {
402
+ count?: number;
403
+ }): void;
404
+ /**
405
+ * Clear all injected faults.
368
406
  */
369
- clearInjectedErrors(): void;
407
+ clearInjectedFaults(): void;
370
408
  /**
371
- * Check if there's an injected error for this path and consume it.
372
- * Returns the error config if one should be returned, null otherwise.
409
+ * Check if there's an injected fault for this path/method and consume it.
410
+ * Returns the fault config if one should be triggered, null otherwise.
373
411
  */
374
- private consumeInjectedError;
412
+ private consumeInjectedFault;
413
+ /**
414
+ * Apply delay from fault config (including jitter).
415
+ */
416
+ private applyFaultDelay;
417
+ /**
418
+ * Apply body modifications from stored fault (truncation, corruption).
419
+ * Returns modified body, or original if no modifications needed.
420
+ */
421
+ private applyFaultBodyModification;
375
422
  private handleRequest;
376
423
  /**
377
424
  * Handle PUT - create stream
@@ -404,9 +451,7 @@ declare class DurableStreamTestServer {
404
451
  */
405
452
  private handleTestInjectError;
406
453
  private readBody;
407
- }
408
-
409
- //#endregion
454
+ } //#endregion
410
455
  //#region src/path-encoding.d.ts
411
456
  /**
412
457
  * Encode a stream path to a filesystem-safe directory name using base64url encoding.
package/dist/index.d.ts CHANGED
@@ -332,6 +332,36 @@ declare class FileBackedStreamStore {
332
332
 
333
333
  //#endregion
334
334
  //#region src/server.d.ts
335
+ /**
336
+ * HTTP server for testing durable streams.
337
+ * Supports both in-memory and file-backed storage modes.
338
+ */
339
+ /**
340
+ * Configuration for injected faults (for testing retry/resilience).
341
+ * Supports various fault types beyond simple HTTP errors.
342
+ */
343
+ interface InjectedFault {
344
+ /** HTTP status code to return (if set, returns error response) */
345
+ status?: number;
346
+ /** Number of times to trigger this fault (decremented on each use) */
347
+ count: number;
348
+ /** Optional Retry-After header value (seconds) */
349
+ retryAfter?: number;
350
+ /** Delay in milliseconds before responding */
351
+ delayMs?: number;
352
+ /** Drop the connection after sending headers (simulates network failure) */
353
+ dropConnection?: boolean;
354
+ /** Truncate response body to this many bytes */
355
+ truncateBodyBytes?: number;
356
+ /** Probability of triggering fault (0-1, default 1.0 = always) */
357
+ probability?: number;
358
+ /** Only match specific HTTP method (GET, POST, PUT, DELETE) */
359
+ method?: string;
360
+ /** Corrupt the response body by flipping random bits */
361
+ corruptBody?: boolean;
362
+ /** Add jitter to delay (random 0-jitterMs added to delayMs) */
363
+ jitterMs?: number;
364
+ }
335
365
  declare class DurableStreamTestServer {
336
366
  readonly store: StreamStore | FileBackedStreamStore;
337
367
  private server;
@@ -339,8 +369,8 @@ declare class DurableStreamTestServer {
339
369
  private _url;
340
370
  private activeSSEResponses;
341
371
  private isShuttingDown;
342
- /** Injected errors for testing retry/resilience */
343
- private injectedErrors;
372
+ /** Injected faults for testing retry/resilience */
373
+ private injectedFaults;
344
374
  constructor(options?: TestServerOptions);
345
375
  /**
346
376
  * Start the server.
@@ -361,17 +391,34 @@ declare class DurableStreamTestServer {
361
391
  /**
362
392
  * Inject an error to be returned on the next N requests to a path.
363
393
  * Used for testing retry/resilience behavior.
394
+ * @deprecated Use injectFault for full fault injection capabilities
364
395
  */
365
396
  injectError(path: string, status: number, count?: number, retryAfter?: number): void;
366
397
  /**
367
- * Clear all injected errors.
398
+ * Inject a fault to be triggered on the next N requests to a path.
399
+ * Supports various fault types: delays, connection drops, body corruption, etc.
400
+ */
401
+ injectFault(path: string, fault: Omit<InjectedFault, `count`> & {
402
+ count?: number;
403
+ }): void;
404
+ /**
405
+ * Clear all injected faults.
368
406
  */
369
- clearInjectedErrors(): void;
407
+ clearInjectedFaults(): void;
370
408
  /**
371
- * Check if there's an injected error for this path and consume it.
372
- * Returns the error config if one should be returned, null otherwise.
409
+ * Check if there's an injected fault for this path/method and consume it.
410
+ * Returns the fault config if one should be triggered, null otherwise.
373
411
  */
374
- private consumeInjectedError;
412
+ private consumeInjectedFault;
413
+ /**
414
+ * Apply delay from fault config (including jitter).
415
+ */
416
+ private applyFaultDelay;
417
+ /**
418
+ * Apply body modifications from stored fault (truncation, corruption).
419
+ * Returns modified body, or original if no modifications needed.
420
+ */
421
+ private applyFaultBodyModification;
375
422
  private handleRequest;
376
423
  /**
377
424
  * Handle PUT - create stream
@@ -404,9 +451,7 @@ declare class DurableStreamTestServer {
404
451
  */
405
452
  private handleTestInjectError;
406
453
  private readBody;
407
- }
408
-
409
- //#endregion
454
+ } //#endregion
410
455
  //#region src/path-encoding.d.ts
411
456
  /**
412
457
  * Encode a stream path to a filesystem-safe directory name using base64url encoding.
package/dist/index.js CHANGED
@@ -1052,10 +1052,12 @@ const CURSOR_QUERY_PARAM = `cursor`;
1052
1052
  /**
1053
1053
  * Encode data for SSE format.
1054
1054
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
1055
- * Newlines in the payload become separate data: lines.
1055
+ * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
1056
+ * This prevents CRLF injection attacks where malicious payloads could inject
1057
+ * fake SSE events using CR-only line terminators.
1056
1058
  */
1057
1059
  function encodeSSEData(payload) {
1058
- const lines = payload.split(`\n`);
1060
+ const lines = payload.split(/\r\n|\r|\n/);
1059
1061
  return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
1060
1062
  }
1061
1063
  /**
@@ -1094,8 +1096,8 @@ var DurableStreamTestServer = class {
1094
1096
  _url = null;
1095
1097
  activeSSEResponses = new Set();
1096
1098
  isShuttingDown = false;
1097
- /** Injected errors for testing retry/resilience */
1098
- injectedErrors = new Map();
1099
+ /** Injected faults for testing retry/resilience */
1100
+ injectedFaults = new Map();
1099
1101
  constructor(options = {}) {
1100
1102
  if (options.dataDir) this.store = new FileBackedStreamStore({ dataDir: options.dataDir });
1101
1103
  else this.store = new StreamStore();
@@ -1180,30 +1182,71 @@ var DurableStreamTestServer = class {
1180
1182
  /**
1181
1183
  * Inject an error to be returned on the next N requests to a path.
1182
1184
  * Used for testing retry/resilience behavior.
1185
+ * @deprecated Use injectFault for full fault injection capabilities
1183
1186
  */
1184
1187
  injectError(path$2, status, count = 1, retryAfter) {
1185
- this.injectedErrors.set(path$2, {
1188
+ this.injectedFaults.set(path$2, {
1186
1189
  status,
1187
1190
  count,
1188
1191
  retryAfter
1189
1192
  });
1190
1193
  }
1191
1194
  /**
1192
- * Clear all injected errors.
1195
+ * Inject a fault to be triggered on the next N requests to a path.
1196
+ * Supports various fault types: delays, connection drops, body corruption, etc.
1193
1197
  */
1194
- clearInjectedErrors() {
1195
- this.injectedErrors.clear();
1198
+ injectFault(path$2, fault) {
1199
+ this.injectedFaults.set(path$2, {
1200
+ count: 1,
1201
+ ...fault
1202
+ });
1203
+ }
1204
+ /**
1205
+ * Clear all injected faults.
1206
+ */
1207
+ clearInjectedFaults() {
1208
+ this.injectedFaults.clear();
1209
+ }
1210
+ /**
1211
+ * Check if there's an injected fault for this path/method and consume it.
1212
+ * Returns the fault config if one should be triggered, null otherwise.
1213
+ */
1214
+ consumeInjectedFault(path$2, method) {
1215
+ const fault = this.injectedFaults.get(path$2);
1216
+ if (!fault) return null;
1217
+ if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) return null;
1218
+ if (fault.probability !== void 0 && Math.random() > fault.probability) return null;
1219
+ fault.count--;
1220
+ if (fault.count <= 0) this.injectedFaults.delete(path$2);
1221
+ return fault;
1222
+ }
1223
+ /**
1224
+ * Apply delay from fault config (including jitter).
1225
+ */
1226
+ async applyFaultDelay(fault) {
1227
+ if (fault.delayMs !== void 0 && fault.delayMs > 0) {
1228
+ const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0;
1229
+ await new Promise((resolve) => setTimeout(resolve, fault.delayMs + jitter));
1230
+ }
1196
1231
  }
1197
1232
  /**
1198
- * Check if there's an injected error for this path and consume it.
1199
- * Returns the error config if one should be returned, null otherwise.
1233
+ * Apply body modifications from stored fault (truncation, corruption).
1234
+ * Returns modified body, or original if no modifications needed.
1200
1235
  */
1201
- consumeInjectedError(path$2) {
1202
- const error = this.injectedErrors.get(path$2);
1203
- if (!error) return null;
1204
- error.count--;
1205
- if (error.count <= 0) this.injectedErrors.delete(path$2);
1206
- return error;
1236
+ applyFaultBodyModification(res, body) {
1237
+ const fault = res._injectedFault;
1238
+ if (!fault) return body;
1239
+ let modified = body;
1240
+ if (fault.truncateBodyBytes !== void 0 && modified.length > fault.truncateBodyBytes) modified = modified.slice(0, fault.truncateBodyBytes);
1241
+ if (fault.corruptBody && modified.length > 0) {
1242
+ modified = new Uint8Array(modified);
1243
+ const numCorrupt = Math.max(1, Math.floor(modified.length * .03));
1244
+ for (let i = 0; i < numCorrupt; i++) {
1245
+ const pos = Math.floor(Math.random() * modified.length);
1246
+ modified[pos] = modified[pos] ^ 1 << Math.floor(Math.random() * 8);
1247
+ }
1248
+ }
1249
+ return modified;
1207
1250
  }
1208
1251
  async handleRequest(req, res) {
1209
1252
  const url = new URL(req.url ?? `/`, `http://${req.headers.host}`);
@@ -1213,6 +1256,8 @@ var DurableStreamTestServer = class {
1213
1256
  res.setHeader(`access-control-allow-methods`, `GET, POST, PUT, DELETE, HEAD, OPTIONS`);
1214
1257
  res.setHeader(`access-control-allow-headers`, `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`);
1215
1258
  res.setHeader(`access-control-expose-headers`, `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`);
1259
+ res.setHeader(`x-content-type-options`, `nosniff`);
1260
+ res.setHeader(`cross-origin-resource-policy`, `cross-origin`);
1216
1261
  if (method === `OPTIONS`) {
1217
1262
  res.writeHead(204);
1218
1263
  res.end();
@@ -1222,13 +1267,21 @@ var DurableStreamTestServer = class {
1222
1267
  await this.handleTestInjectError(method, req, res);
1223
1268
  return;
1224
1269
  }
1225
- const injectedError = this.consumeInjectedError(path$2);
1226
- if (injectedError) {
1227
- const headers = { "content-type": `text/plain` };
1228
- if (injectedError.retryAfter !== void 0) headers[`retry-after`] = injectedError.retryAfter.toString();
1229
- res.writeHead(injectedError.status, headers);
1230
- res.end(`Injected error for testing`);
1231
- return;
1270
+ const fault = this.consumeInjectedFault(path$2, method ?? `GET`);
1271
+ if (fault) {
1272
+ await this.applyFaultDelay(fault);
1273
+ if (fault.dropConnection) {
1274
+ res.socket?.destroy();
1275
+ return;
1276
+ }
1277
+ if (fault.status !== void 0) {
1278
+ const headers = { "content-type": `text/plain` };
1279
+ if (fault.retryAfter !== void 0) headers[`retry-after`] = fault.retryAfter.toString();
1280
+ res.writeHead(fault.status, headers);
1281
+ res.end(`Injected error for testing`);
1282
+ return;
1283
+ }
1284
+ if (fault.truncateBodyBytes !== void 0 || fault.corruptBody) res._injectedFault = fault;
1232
1285
  }
1233
1286
  try {
1234
1287
  switch (method) {
@@ -1343,7 +1396,10 @@ var DurableStreamTestServer = class {
1343
1396
  res.end();
1344
1397
  return;
1345
1398
  }
1346
- const headers = { [STREAM_OFFSET_HEADER]: stream.currentOffset };
1399
+ const headers = {
1400
+ [STREAM_OFFSET_HEADER]: stream.currentOffset,
1401
+ "cache-control": `no-store`
1402
+ };
1347
1403
  if (stream.contentType) headers[`content-type`] = stream.contentType;
1348
1404
  headers[`etag`] = `"${Buffer.from(path$2).toString(`base64`)}:-1:${stream.currentOffset}"`;
1349
1405
  res.writeHead(200, headers);
@@ -1434,6 +1490,7 @@ var DurableStreamTestServer = class {
1434
1490
  headers[`vary`] = `accept-encoding`;
1435
1491
  }
1436
1492
  }
1493
+ finalData = this.applyFaultBodyModification(res, finalData);
1437
1494
  res.writeHead(200, headers);
1438
1495
  res.end(Buffer.from(finalData));
1439
1496
  }
@@ -1446,7 +1503,9 @@ var DurableStreamTestServer = class {
1446
1503
  "content-type": `text/event-stream`,
1447
1504
  "cache-control": `no-cache`,
1448
1505
  connection: `keep-alive`,
1449
- "access-control-allow-origin": `*`
1506
+ "access-control-allow-origin": `*`,
1507
+ "x-content-type-options": `nosniff`,
1508
+ "cross-origin-resource-policy": `cross-origin`
1450
1509
  });
1451
1510
  let currentOffset = initialOffset;
1452
1511
  let isConnected = true;
@@ -1517,7 +1576,7 @@ var DurableStreamTestServer = class {
1517
1576
  seq,
1518
1577
  contentType
1519
1578
  }));
1520
- res.writeHead(200, { [STREAM_OFFSET_HEADER]: message.offset });
1579
+ res.writeHead(204, { [STREAM_OFFSET_HEADER]: message.offset });
1521
1580
  res.end();
1522
1581
  }
1523
1582
  /**
@@ -1548,12 +1607,29 @@ var DurableStreamTestServer = class {
1548
1607
  const body = await this.readBody(req);
1549
1608
  try {
1550
1609
  const config = JSON.parse(new TextDecoder().decode(body));
1551
- if (!config.path || !config.status) {
1610
+ if (!config.path) {
1611
+ res.writeHead(400, { "content-type": `text/plain` });
1612
+ res.end(`Missing required field: path`);
1613
+ return;
1614
+ }
1615
+ const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
1616
+ if (!hasFaultType) {
1552
1617
  res.writeHead(400, { "content-type": `text/plain` });
1553
- res.end(`Missing required fields: path, status`);
1618
+ res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
1554
1619
  return;
1555
1620
  }
1556
- this.injectError(config.path, config.status, config.count ?? 1, config.retryAfter);
1621
+ this.injectFault(config.path, {
1622
+ status: config.status,
1623
+ count: config.count ?? 1,
1624
+ retryAfter: config.retryAfter,
1625
+ delayMs: config.delayMs,
1626
+ dropConnection: config.dropConnection,
1627
+ truncateBodyBytes: config.truncateBodyBytes,
1628
+ probability: config.probability,
1629
+ method: config.method,
1630
+ corruptBody: config.corruptBody,
1631
+ jitterMs: config.jitterMs
1632
+ });
1557
1633
  res.writeHead(200, { "content-type": `application/json` });
1558
1634
  res.end(JSON.stringify({ ok: true }));
1559
1635
  } catch {
@@ -1561,7 +1637,7 @@ var DurableStreamTestServer = class {
1561
1637
  res.end(`Invalid JSON body`);
1562
1638
  }
1563
1639
  } else if (method === `DELETE`) {
1564
- this.clearInjectedErrors();
1640
+ this.clearInjectedFaults();
1565
1641
  res.writeHead(200, { "content-type": `application/json` });
1566
1642
  res.end(JSON.stringify({ ok: true }));
1567
1643
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Node.js reference server implementation for Durable Streams",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
@@ -39,15 +39,15 @@
39
39
  "dependencies": {
40
40
  "@neophi/sieve-cache": "^1.0.0",
41
41
  "lmdb": "^3.3.0",
42
- "@durable-streams/client": "0.1.2",
43
- "@durable-streams/state": "0.1.2"
42
+ "@durable-streams/client": "0.1.3",
43
+ "@durable-streams/state": "0.1.3"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.0.0",
47
47
  "tsdown": "^0.9.0",
48
48
  "typescript": "^5.0.0",
49
- "vitest": "^3.2.4",
50
- "@durable-streams/server-conformance-tests": "0.1.3"
49
+ "vitest": "^4.0.0",
50
+ "@durable-streams/server-conformance-tests": "0.1.6"
51
51
  },
52
52
  "files": [
53
53
  "dist",
package/src/server.ts CHANGED
@@ -32,10 +32,14 @@ const CURSOR_QUERY_PARAM = `cursor`
32
32
  /**
33
33
  * Encode data for SSE format.
34
34
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
35
- * Newlines in the payload become separate data: lines.
35
+ * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
36
+ * This prevents CRLF injection attacks where malicious payloads could inject
37
+ * fake SSE events using CR-only line terminators.
36
38
  */
37
39
  function encodeSSEData(payload: string): string {
38
- const lines = payload.split(`\n`)
40
+ // Split on all SSE-valid line terminators: CRLF, CR, or LF
41
+ // Order matters: \r\n must be matched before \r alone
42
+ const lines = payload.split(/\r\n|\r|\n/)
39
43
  return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`
40
44
  }
41
45
 
@@ -92,15 +96,30 @@ function compressData(
92
96
  * Supports both in-memory and file-backed storage modes.
93
97
  */
94
98
  /**
95
- * Configuration for injected errors (for testing retry/resilience).
99
+ * Configuration for injected faults (for testing retry/resilience).
100
+ * Supports various fault types beyond simple HTTP errors.
96
101
  */
97
- interface InjectedError {
98
- /** HTTP status code to return */
99
- status: number
100
- /** Number of times to return this error (decremented on each use) */
102
+ interface InjectedFault {
103
+ /** HTTP status code to return (if set, returns error response) */
104
+ status?: number
105
+ /** Number of times to trigger this fault (decremented on each use) */
101
106
  count: number
102
107
  /** Optional Retry-After header value (seconds) */
103
108
  retryAfter?: number
109
+ /** Delay in milliseconds before responding */
110
+ delayMs?: number
111
+ /** Drop the connection after sending headers (simulates network failure) */
112
+ dropConnection?: boolean
113
+ /** Truncate response body to this many bytes */
114
+ truncateBodyBytes?: number
115
+ /** Probability of triggering fault (0-1, default 1.0 = always) */
116
+ probability?: number
117
+ /** Only match specific HTTP method (GET, POST, PUT, DELETE) */
118
+ method?: string
119
+ /** Corrupt the response body by flipping random bits */
120
+ corruptBody?: boolean
121
+ /** Add jitter to delay (random 0-jitterMs added to delayMs) */
122
+ jitterMs?: number
104
123
  }
105
124
 
106
125
  export class DurableStreamTestServer {
@@ -126,8 +145,8 @@ export class DurableStreamTestServer {
126
145
  private _url: string | null = null
127
146
  private activeSSEResponses = new Set<ServerResponse>()
128
147
  private isShuttingDown = false
129
- /** Injected errors for testing retry/resilience */
130
- private injectedErrors = new Map<string, InjectedError>()
148
+ /** Injected faults for testing retry/resilience */
149
+ private injectedFaults = new Map<string, InjectedFault>()
131
150
 
132
151
  constructor(options: TestServerOptions = {}) {
133
152
  // Choose store based on dataDir option
@@ -253,6 +272,7 @@ export class DurableStreamTestServer {
253
272
  /**
254
273
  * Inject an error to be returned on the next N requests to a path.
255
274
  * Used for testing retry/resilience behavior.
275
+ * @deprecated Use injectFault for full fault injection capabilities
256
276
  */
257
277
  injectError(
258
278
  path: string,
@@ -260,30 +280,102 @@ export class DurableStreamTestServer {
260
280
  count: number = 1,
261
281
  retryAfter?: number
262
282
  ): void {
263
- this.injectedErrors.set(path, { status, count, retryAfter })
283
+ this.injectedFaults.set(path, { status, count, retryAfter })
264
284
  }
265
285
 
266
286
  /**
267
- * Clear all injected errors.
287
+ * Inject a fault to be triggered on the next N requests to a path.
288
+ * Supports various fault types: delays, connection drops, body corruption, etc.
268
289
  */
269
- clearInjectedErrors(): void {
270
- this.injectedErrors.clear()
290
+ injectFault(
291
+ path: string,
292
+ fault: Omit<InjectedFault, `count`> & { count?: number }
293
+ ): void {
294
+ this.injectedFaults.set(path, { count: 1, ...fault })
295
+ }
296
+
297
+ /**
298
+ * Clear all injected faults.
299
+ */
300
+ clearInjectedFaults(): void {
301
+ this.injectedFaults.clear()
302
+ }
303
+
304
+ /**
305
+ * Check if there's an injected fault for this path/method and consume it.
306
+ * Returns the fault config if one should be triggered, null otherwise.
307
+ */
308
+ private consumeInjectedFault(
309
+ path: string,
310
+ method: string
311
+ ): InjectedFault | null {
312
+ const fault = this.injectedFaults.get(path)
313
+ if (!fault) return null
314
+
315
+ // Check method filter
316
+ if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) {
317
+ return null
318
+ }
319
+
320
+ // Check probability
321
+ if (fault.probability !== undefined && Math.random() > fault.probability) {
322
+ return null
323
+ }
324
+
325
+ fault.count--
326
+ if (fault.count <= 0) {
327
+ this.injectedFaults.delete(path)
328
+ }
329
+
330
+ return fault
331
+ }
332
+
333
+ /**
334
+ * Apply delay from fault config (including jitter).
335
+ */
336
+ private async applyFaultDelay(fault: InjectedFault): Promise<void> {
337
+ if (fault.delayMs !== undefined && fault.delayMs > 0) {
338
+ const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0
339
+ await new Promise((resolve) =>
340
+ setTimeout(resolve, fault.delayMs! + jitter)
341
+ )
342
+ }
271
343
  }
272
344
 
273
345
  /**
274
- * Check if there's an injected error for this path and consume it.
275
- * Returns the error config if one should be returned, null otherwise.
346
+ * Apply body modifications from stored fault (truncation, corruption).
347
+ * Returns modified body, or original if no modifications needed.
276
348
  */
277
- private consumeInjectedError(path: string): InjectedError | null {
278
- const error = this.injectedErrors.get(path)
279
- if (!error) return null
349
+ private applyFaultBodyModification(
350
+ res: ServerResponse,
351
+ body: Uint8Array
352
+ ): Uint8Array {
353
+ const fault = (res as ServerResponse & { _injectedFault?: InjectedFault })
354
+ ._injectedFault
355
+ if (!fault) return body
356
+
357
+ let modified = body
280
358
 
281
- error.count--
282
- if (error.count <= 0) {
283
- this.injectedErrors.delete(path)
359
+ // Truncate body if configured
360
+ if (
361
+ fault.truncateBodyBytes !== undefined &&
362
+ modified.length > fault.truncateBodyBytes
363
+ ) {
364
+ modified = modified.slice(0, fault.truncateBodyBytes)
365
+ }
366
+
367
+ // Corrupt body if configured (flip random bits)
368
+ if (fault.corruptBody && modified.length > 0) {
369
+ modified = new Uint8Array(modified) // Make a copy to avoid mutating original
370
+ // Flip 1-5% of bytes
371
+ const numCorrupt = Math.max(1, Math.floor(modified.length * 0.03))
372
+ for (let i = 0; i < numCorrupt; i++) {
373
+ const pos = Math.floor(Math.random() * modified.length)
374
+ modified[pos] = modified[pos]! ^ (1 << Math.floor(Math.random() * 8))
375
+ }
284
376
  }
285
377
 
286
- return error
378
+ return modified
287
379
  }
288
380
 
289
381
  // ============================================================================
@@ -313,6 +405,10 @@ export class DurableStreamTestServer {
313
405
  `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`
314
406
  )
315
407
 
408
+ // Browser security headers (Protocol Section 10.7)
409
+ res.setHeader(`x-content-type-options`, `nosniff`)
410
+ res.setHeader(`cross-origin-resource-policy`, `cross-origin`)
411
+
316
412
  // Handle CORS preflight
317
413
  if (method === `OPTIONS`) {
318
414
  res.writeHead(204)
@@ -326,18 +422,37 @@ export class DurableStreamTestServer {
326
422
  return
327
423
  }
328
424
 
329
- // Check for injected errors (for testing retry/resilience)
330
- const injectedError = this.consumeInjectedError(path)
331
- if (injectedError) {
332
- const headers: Record<string, string> = {
333
- "content-type": `text/plain`,
425
+ // Check for injected faults (for testing retry/resilience)
426
+ const fault = this.consumeInjectedFault(path, method ?? `GET`)
427
+ if (fault) {
428
+ // Apply delay if configured
429
+ await this.applyFaultDelay(fault)
430
+
431
+ // Drop connection if configured (simulates network failure)
432
+ if (fault.dropConnection) {
433
+ res.socket?.destroy()
434
+ return
435
+ }
436
+
437
+ // If status is set, return an error response
438
+ if (fault.status !== undefined) {
439
+ const headers: Record<string, string> = {
440
+ "content-type": `text/plain`,
441
+ }
442
+ if (fault.retryAfter !== undefined) {
443
+ headers[`retry-after`] = fault.retryAfter.toString()
444
+ }
445
+ res.writeHead(fault.status, headers)
446
+ res.end(`Injected error for testing`)
447
+ return
334
448
  }
335
- if (injectedError.retryAfter !== undefined) {
336
- headers[`retry-after`] = injectedError.retryAfter.toString()
449
+
450
+ // Store fault for response modification (truncation, corruption)
451
+ if (fault.truncateBodyBytes !== undefined || fault.corruptBody) {
452
+ ;(
453
+ res as ServerResponse & { _injectedFault?: InjectedFault }
454
+ )._injectedFault = fault
337
455
  }
338
- res.writeHead(injectedError.status, headers)
339
- res.end(`Injected error for testing`)
340
- return
341
456
  }
342
457
 
343
458
  try {
@@ -511,6 +626,8 @@ export class DurableStreamTestServer {
511
626
 
512
627
  const headers: Record<string, string> = {
513
628
  [STREAM_OFFSET_HEADER]: stream.currentOffset,
629
+ // HEAD responses should not be cached to avoid stale tail offsets (Protocol Section 5.4)
630
+ "cache-control": `no-store`,
514
631
  }
515
632
 
516
633
  if (stream.contentType) {
@@ -680,6 +797,9 @@ export class DurableStreamTestServer {
680
797
  }
681
798
  }
682
799
 
800
+ // Apply fault body modifications (truncation, corruption) if configured
801
+ finalData = this.applyFaultBodyModification(res, finalData)
802
+
683
803
  res.writeHead(200, headers)
684
804
  res.end(Buffer.from(finalData))
685
805
  }
@@ -697,12 +817,14 @@ export class DurableStreamTestServer {
697
817
  // Track this SSE connection
698
818
  this.activeSSEResponses.add(res)
699
819
 
700
- // Set SSE headers
820
+ // Set SSE headers (explicitly including security headers for clarity)
701
821
  res.writeHead(200, {
702
822
  "content-type": `text/event-stream`,
703
823
  "cache-control": `no-cache`,
704
824
  connection: `keep-alive`,
705
825
  "access-control-allow-origin": `*`,
826
+ "x-content-type-options": `nosniff`,
827
+ "cross-origin-resource-policy": `cross-origin`,
706
828
  })
707
829
 
708
830
  let currentOffset = initialOffset
@@ -841,7 +963,7 @@ export class DurableStreamTestServer {
841
963
  this.store.append(path, body, { seq, contentType })
842
964
  )
843
965
 
844
- res.writeHead(200, {
966
+ res.writeHead(204, {
845
967
  [STREAM_OFFSET_HEADER]: message!.offset,
846
968
  })
847
969
  res.end()
@@ -889,23 +1011,53 @@ export class DurableStreamTestServer {
889
1011
  try {
890
1012
  const config = JSON.parse(new TextDecoder().decode(body)) as {
891
1013
  path: string
892
- status: number
1014
+ // Legacy fields (still supported)
1015
+ status?: number
893
1016
  count?: number
894
1017
  retryAfter?: number
1018
+ // New fault injection fields
1019
+ delayMs?: number
1020
+ dropConnection?: boolean
1021
+ truncateBodyBytes?: number
1022
+ probability?: number
1023
+ method?: string
1024
+ corruptBody?: boolean
1025
+ jitterMs?: number
895
1026
  }
896
1027
 
897
- if (!config.path || !config.status) {
1028
+ if (!config.path) {
898
1029
  res.writeHead(400, { "content-type": `text/plain` })
899
- res.end(`Missing required fields: path, status`)
1030
+ res.end(`Missing required field: path`)
900
1031
  return
901
1032
  }
902
1033
 
903
- this.injectError(
904
- config.path,
905
- config.status,
906
- config.count ?? 1,
907
- config.retryAfter
908
- )
1034
+ // Must have at least one fault type specified
1035
+ const hasFaultType =
1036
+ config.status !== undefined ||
1037
+ config.delayMs !== undefined ||
1038
+ config.dropConnection ||
1039
+ config.truncateBodyBytes !== undefined ||
1040
+ config.corruptBody
1041
+ if (!hasFaultType) {
1042
+ res.writeHead(400, { "content-type": `text/plain` })
1043
+ res.end(
1044
+ `Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`
1045
+ )
1046
+ return
1047
+ }
1048
+
1049
+ this.injectFault(config.path, {
1050
+ status: config.status,
1051
+ count: config.count ?? 1,
1052
+ retryAfter: config.retryAfter,
1053
+ delayMs: config.delayMs,
1054
+ dropConnection: config.dropConnection,
1055
+ truncateBodyBytes: config.truncateBodyBytes,
1056
+ probability: config.probability,
1057
+ method: config.method,
1058
+ corruptBody: config.corruptBody,
1059
+ jitterMs: config.jitterMs,
1060
+ })
909
1061
 
910
1062
  res.writeHead(200, { "content-type": `application/json` })
911
1063
  res.end(JSON.stringify({ ok: true }))
@@ -914,7 +1066,7 @@ export class DurableStreamTestServer {
914
1066
  res.end(`Invalid JSON body`)
915
1067
  }
916
1068
  } else if (method === `DELETE`) {
917
- this.clearInjectedErrors()
1069
+ this.clearInjectedFaults()
918
1070
  res.writeHead(200, { "content-type": `application/json` })
919
1071
  res.end(JSON.stringify({ ok: true }))
920
1072
  } else {