@durable-streams/server 0.1.6 → 0.2.0

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
@@ -1369,10 +1369,14 @@ const CURSOR_QUERY_PARAM = `cursor`;
1369
1369
  * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
1370
1370
  * This prevents CRLF injection attacks where malicious payloads could inject
1371
1371
  * fake SSE events using CR-only line terminators.
1372
+ *
1373
+ * Note: We don't add a space after "data:" because clients strip exactly one
1374
+ * leading space per the SSE spec. Adding one would cause data starting with
1375
+ * spaces to lose an extra space character.
1372
1376
  */
1373
1377
  function encodeSSEData(payload) {
1374
1378
  const lines = payload.split(/\r\n|\r|\n/);
1375
- return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
1379
+ return lines.map((line) => `data:${line}`).join(`\n`) + `\n\n`;
1376
1380
  }
1377
1381
  /**
1378
1382
  * Minimum response size to consider for compression.
@@ -1554,10 +1558,12 @@ var DurableStreamTestServer = class {
1554
1558
  if (fault.truncateBodyBytes !== void 0 && modified.length > fault.truncateBodyBytes) modified = modified.slice(0, fault.truncateBodyBytes);
1555
1559
  if (fault.corruptBody && modified.length > 0) {
1556
1560
  modified = new Uint8Array(modified);
1557
- const numCorrupt = Math.max(1, Math.floor(modified.length * .03));
1561
+ modified[0] = 88;
1562
+ if (modified.length > 1) modified[1] = 89;
1563
+ const numCorrupt = Math.max(1, Math.floor(modified.length * .1));
1558
1564
  for (let i = 0; i < numCorrupt; i++) {
1559
1565
  const pos = Math.floor(Math.random() * modified.length);
1560
- modified[pos] = modified[pos] ^ 1 << Math.floor(Math.random() * 8);
1566
+ modified[pos] = 90;
1561
1567
  }
1562
1568
  }
1563
1569
  return modified;
@@ -1595,7 +1601,7 @@ var DurableStreamTestServer = class {
1595
1601
  res.end(`Injected error for testing`);
1596
1602
  return;
1597
1603
  }
1598
- if (fault.truncateBodyBytes !== void 0 || fault.corruptBody) res._injectedFault = fault;
1604
+ if (fault.truncateBodyBytes !== void 0 || fault.corruptBody || fault.injectSseEvent) res._injectedFault = fault;
1599
1605
  }
1600
1606
  try {
1601
1607
  switch (method) {
@@ -1836,6 +1842,11 @@ var DurableStreamTestServer = class {
1836
1842
  "x-content-type-options": `nosniff`,
1837
1843
  "cross-origin-resource-policy": `cross-origin`
1838
1844
  });
1845
+ const fault = res._injectedFault;
1846
+ if (fault?.injectSseEvent) {
1847
+ res.write(`event: ${fault.injectSseEvent.eventType}\n`);
1848
+ res.write(`data: ${fault.injectSseEvent.data}\n\n`);
1849
+ }
1839
1850
  let currentOffset = initialOffset;
1840
1851
  let isConnected = true;
1841
1852
  const decoder = new TextDecoder();
@@ -2030,10 +2041,10 @@ var DurableStreamTestServer = class {
2030
2041
  res.end(`Missing required field: path`);
2031
2042
  return;
2032
2043
  }
2033
- const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
2044
+ const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody || config.injectSseEvent !== void 0;
2034
2045
  if (!hasFaultType) {
2035
2046
  res.writeHead(400, { "content-type": `text/plain` });
2036
- res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
2047
+ res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, corruptBody, or injectSseEvent`);
2037
2048
  return;
2038
2049
  }
2039
2050
  this.injectFault(config.path, {
@@ -2046,7 +2057,8 @@ var DurableStreamTestServer = class {
2046
2057
  probability: config.probability,
2047
2058
  method: config.method,
2048
2059
  corruptBody: config.corruptBody,
2049
- jitterMs: config.jitterMs
2060
+ jitterMs: config.jitterMs,
2061
+ injectSseEvent: config.injectSseEvent
2050
2062
  });
2051
2063
  res.writeHead(200, { "content-type": `application/json` });
2052
2064
  res.end(JSON.stringify({ ok: true }));
@@ -2128,13 +2140,13 @@ function createRegistryHooks(store, serverUrl) {
2128
2140
  createdAt: event.timestamp
2129
2141
  }
2130
2142
  });
2131
- await registryStream.append(changeEvent);
2143
+ await registryStream.append(JSON.stringify(changeEvent));
2132
2144
  },
2133
2145
  onStreamDeleted: async (event) => {
2134
2146
  await ensureRegistryExists();
2135
2147
  const streamName = extractStreamName(event.path);
2136
2148
  const changeEvent = registryStateSchema.streams.delete({ key: streamName });
2137
- await registryStream.append(changeEvent);
2149
+ await registryStream.append(JSON.stringify(changeEvent));
2138
2150
  }
2139
2151
  };
2140
2152
  }
package/dist/index.d.cts CHANGED
@@ -490,6 +490,13 @@ interface InjectedFault {
490
490
  corruptBody?: boolean;
491
491
  /** Add jitter to delay (random 0-jitterMs added to delayMs) */
492
492
  jitterMs?: number;
493
+ /** Inject an SSE event with custom type and data (for testing SSE parsing) */
494
+ injectSseEvent?: {
495
+ /** Event type (e.g., "unknown", "control", "data") */
496
+ eventType: string;
497
+ /** Event data (will be sent as-is) */
498
+ data: string;
499
+ };
493
500
  }
494
501
  declare class DurableStreamTestServer {
495
502
  readonly store: StreamStore | FileBackedStreamStore;
package/dist/index.d.ts CHANGED
@@ -490,6 +490,13 @@ interface InjectedFault {
490
490
  corruptBody?: boolean;
491
491
  /** Add jitter to delay (random 0-jitterMs added to delayMs) */
492
492
  jitterMs?: number;
493
+ /** Inject an SSE event with custom type and data (for testing SSE parsing) */
494
+ injectSseEvent?: {
495
+ /** Event type (e.g., "unknown", "control", "data") */
496
+ eventType: string;
497
+ /** Event data (will be sent as-is) */
498
+ data: string;
499
+ };
493
500
  }
494
501
  declare class DurableStreamTestServer {
495
502
  readonly store: StreamStore | FileBackedStreamStore;
package/dist/index.js CHANGED
@@ -1346,10 +1346,14 @@ const CURSOR_QUERY_PARAM = `cursor`;
1346
1346
  * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
1347
1347
  * This prevents CRLF injection attacks where malicious payloads could inject
1348
1348
  * fake SSE events using CR-only line terminators.
1349
+ *
1350
+ * Note: We don't add a space after "data:" because clients strip exactly one
1351
+ * leading space per the SSE spec. Adding one would cause data starting with
1352
+ * spaces to lose an extra space character.
1349
1353
  */
1350
1354
  function encodeSSEData(payload) {
1351
1355
  const lines = payload.split(/\r\n|\r|\n/);
1352
- return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`;
1356
+ return lines.map((line) => `data:${line}`).join(`\n`) + `\n\n`;
1353
1357
  }
1354
1358
  /**
1355
1359
  * Minimum response size to consider for compression.
@@ -1531,10 +1535,12 @@ var DurableStreamTestServer = class {
1531
1535
  if (fault.truncateBodyBytes !== void 0 && modified.length > fault.truncateBodyBytes) modified = modified.slice(0, fault.truncateBodyBytes);
1532
1536
  if (fault.corruptBody && modified.length > 0) {
1533
1537
  modified = new Uint8Array(modified);
1534
- const numCorrupt = Math.max(1, Math.floor(modified.length * .03));
1538
+ modified[0] = 88;
1539
+ if (modified.length > 1) modified[1] = 89;
1540
+ const numCorrupt = Math.max(1, Math.floor(modified.length * .1));
1535
1541
  for (let i = 0; i < numCorrupt; i++) {
1536
1542
  const pos = Math.floor(Math.random() * modified.length);
1537
- modified[pos] = modified[pos] ^ 1 << Math.floor(Math.random() * 8);
1543
+ modified[pos] = 90;
1538
1544
  }
1539
1545
  }
1540
1546
  return modified;
@@ -1572,7 +1578,7 @@ var DurableStreamTestServer = class {
1572
1578
  res.end(`Injected error for testing`);
1573
1579
  return;
1574
1580
  }
1575
- if (fault.truncateBodyBytes !== void 0 || fault.corruptBody) res._injectedFault = fault;
1581
+ if (fault.truncateBodyBytes !== void 0 || fault.corruptBody || fault.injectSseEvent) res._injectedFault = fault;
1576
1582
  }
1577
1583
  try {
1578
1584
  switch (method) {
@@ -1813,6 +1819,11 @@ var DurableStreamTestServer = class {
1813
1819
  "x-content-type-options": `nosniff`,
1814
1820
  "cross-origin-resource-policy": `cross-origin`
1815
1821
  });
1822
+ const fault = res._injectedFault;
1823
+ if (fault?.injectSseEvent) {
1824
+ res.write(`event: ${fault.injectSseEvent.eventType}\n`);
1825
+ res.write(`data: ${fault.injectSseEvent.data}\n\n`);
1826
+ }
1816
1827
  let currentOffset = initialOffset;
1817
1828
  let isConnected = true;
1818
1829
  const decoder = new TextDecoder();
@@ -2007,10 +2018,10 @@ var DurableStreamTestServer = class {
2007
2018
  res.end(`Missing required field: path`);
2008
2019
  return;
2009
2020
  }
2010
- const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody;
2021
+ const hasFaultType = config.status !== void 0 || config.delayMs !== void 0 || config.dropConnection || config.truncateBodyBytes !== void 0 || config.corruptBody || config.injectSseEvent !== void 0;
2011
2022
  if (!hasFaultType) {
2012
2023
  res.writeHead(400, { "content-type": `text/plain` });
2013
- res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`);
2024
+ res.end(`Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, corruptBody, or injectSseEvent`);
2014
2025
  return;
2015
2026
  }
2016
2027
  this.injectFault(config.path, {
@@ -2023,7 +2034,8 @@ var DurableStreamTestServer = class {
2023
2034
  probability: config.probability,
2024
2035
  method: config.method,
2025
2036
  corruptBody: config.corruptBody,
2026
- jitterMs: config.jitterMs
2037
+ jitterMs: config.jitterMs,
2038
+ injectSseEvent: config.injectSseEvent
2027
2039
  });
2028
2040
  res.writeHead(200, { "content-type": `application/json` });
2029
2041
  res.end(JSON.stringify({ ok: true }));
@@ -2105,13 +2117,13 @@ function createRegistryHooks(store, serverUrl) {
2105
2117
  createdAt: event.timestamp
2106
2118
  }
2107
2119
  });
2108
- await registryStream.append(changeEvent);
2120
+ await registryStream.append(JSON.stringify(changeEvent));
2109
2121
  },
2110
2122
  onStreamDeleted: async (event) => {
2111
2123
  await ensureRegistryExists();
2112
2124
  const streamName = extractStreamName(event.path);
2113
2125
  const changeEvent = registryStateSchema.streams.delete({ key: streamName });
2114
- await registryStream.append(changeEvent);
2126
+ await registryStream.append(JSON.stringify(changeEvent));
2115
2127
  }
2116
2128
  };
2117
2129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
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.5",
43
- "@durable-streams/state": "0.1.5"
42
+ "@durable-streams/client": "0.2.0",
43
+ "@durable-streams/state": "0.2.0"
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
49
  "vitest": "^4.0.0",
50
- "@durable-streams/server-conformance-tests": "0.1.8"
50
+ "@durable-streams/server-conformance-tests": "0.2.0"
51
51
  },
52
52
  "files": [
53
53
  "dist",
@@ -100,7 +100,7 @@ export function createRegistryHooks(
100
100
  },
101
101
  })
102
102
 
103
- await registryStream.append(changeEvent)
103
+ await registryStream.append(JSON.stringify(changeEvent))
104
104
  },
105
105
 
106
106
  onStreamDeleted: async (event) => {
@@ -112,7 +112,7 @@ export function createRegistryHooks(
112
112
  key: streamName,
113
113
  })
114
114
 
115
- await registryStream.append(changeEvent)
115
+ await registryStream.append(JSON.stringify(changeEvent))
116
116
  },
117
117
  }
118
118
  }
package/src/server.ts CHANGED
@@ -42,12 +42,16 @@ const CURSOR_QUERY_PARAM = `cursor`
42
42
  * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
43
43
  * This prevents CRLF injection attacks where malicious payloads could inject
44
44
  * fake SSE events using CR-only line terminators.
45
+ *
46
+ * Note: We don't add a space after "data:" because clients strip exactly one
47
+ * leading space per the SSE spec. Adding one would cause data starting with
48
+ * spaces to lose an extra space character.
45
49
  */
46
50
  function encodeSSEData(payload: string): string {
47
51
  // Split on all SSE-valid line terminators: CRLF, CR, or LF
48
52
  // Order matters: \r\n must be matched before \r alone
49
53
  const lines = payload.split(/\r\n|\r|\n/)
50
- return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`
54
+ return lines.map((line) => `data:${line}`).join(`\n`) + `\n\n`
51
55
  }
52
56
 
53
57
  /**
@@ -127,6 +131,13 @@ interface InjectedFault {
127
131
  corruptBody?: boolean
128
132
  /** Add jitter to delay (random 0-jitterMs added to delayMs) */
129
133
  jitterMs?: number
134
+ /** Inject an SSE event with custom type and data (for testing SSE parsing) */
135
+ injectSseEvent?: {
136
+ /** Event type (e.g., "unknown", "control", "data") */
137
+ eventType: string
138
+ /** Event data (will be sent as-is) */
139
+ data: string
140
+ }
130
141
  }
131
142
 
132
143
  export class DurableStreamTestServer {
@@ -371,14 +382,20 @@ export class DurableStreamTestServer {
371
382
  modified = modified.slice(0, fault.truncateBodyBytes)
372
383
  }
373
384
 
374
- // Corrupt body if configured (flip random bits)
385
+ // Corrupt body if configured - deterministically break JSON structure
375
386
  if (fault.corruptBody && modified.length > 0) {
376
387
  modified = new Uint8Array(modified) // Make a copy to avoid mutating original
377
- // Flip 1-5% of bytes
378
- const numCorrupt = Math.max(1, Math.floor(modified.length * 0.03))
388
+ // Always corrupt the first byte (breaks JSON structure - the opening [ or {)
389
+ // and add some random corruption for good measure
390
+ modified[0] = 0x58 // 'X' - makes JSON syntactically invalid
391
+ if (modified.length > 1) {
392
+ modified[1] = 0x59 // 'Y'
393
+ }
394
+ // Also corrupt some bytes in the middle to catch edge cases
395
+ const numCorrupt = Math.max(1, Math.floor(modified.length * 0.1))
379
396
  for (let i = 0; i < numCorrupt; i++) {
380
397
  const pos = Math.floor(Math.random() * modified.length)
381
- modified[pos] = modified[pos]! ^ (1 << Math.floor(Math.random() * 8))
398
+ modified[pos] = 0x5a // 'Z' - valid UTF-8 but breaks JSON structure
382
399
  }
383
400
  }
384
401
 
@@ -454,8 +471,12 @@ export class DurableStreamTestServer {
454
471
  return
455
472
  }
456
473
 
457
- // Store fault for response modification (truncation, corruption)
458
- if (fault.truncateBodyBytes !== undefined || fault.corruptBody) {
474
+ // Store fault for response modification (truncation, corruption, SSE injection)
475
+ if (
476
+ fault.truncateBodyBytes !== undefined ||
477
+ fault.corruptBody ||
478
+ fault.injectSseEvent
479
+ ) {
459
480
  ;(
460
481
  res as ServerResponse & { _injectedFault?: InjectedFault }
461
482
  )._injectedFault = fault
@@ -868,6 +889,15 @@ export class DurableStreamTestServer {
868
889
  "cross-origin-resource-policy": `cross-origin`,
869
890
  })
870
891
 
892
+ // Check for injected SSE event (for testing SSE parsing)
893
+ const fault = (res as ServerResponse & { _injectedFault?: InjectedFault })
894
+ ._injectedFault
895
+ if (fault?.injectSseEvent) {
896
+ // Send the injected SSE event before normal stream
897
+ res.write(`event: ${fault.injectSseEvent.eventType}\n`)
898
+ res.write(`data: ${fault.injectSseEvent.data}\n\n`)
899
+ }
900
+
871
901
  let currentOffset = initialOffset
872
902
  let isConnected = true
873
903
  const decoder = new TextDecoder()
@@ -1208,6 +1238,11 @@ export class DurableStreamTestServer {
1208
1238
  method?: string
1209
1239
  corruptBody?: boolean
1210
1240
  jitterMs?: number
1241
+ // SSE event injection (for testing SSE parsing)
1242
+ injectSseEvent?: {
1243
+ eventType: string
1244
+ data: string
1245
+ }
1211
1246
  }
1212
1247
 
1213
1248
  if (!config.path) {
@@ -1222,11 +1257,12 @@ export class DurableStreamTestServer {
1222
1257
  config.delayMs !== undefined ||
1223
1258
  config.dropConnection ||
1224
1259
  config.truncateBodyBytes !== undefined ||
1225
- config.corruptBody
1260
+ config.corruptBody ||
1261
+ config.injectSseEvent !== undefined
1226
1262
  if (!hasFaultType) {
1227
1263
  res.writeHead(400, { "content-type": `text/plain` })
1228
1264
  res.end(
1229
- `Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`
1265
+ `Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, corruptBody, or injectSseEvent`
1230
1266
  )
1231
1267
  return
1232
1268
  }
@@ -1242,6 +1278,7 @@ export class DurableStreamTestServer {
1242
1278
  method: config.method,
1243
1279
  corruptBody: config.corruptBody,
1244
1280
  jitterMs: config.jitterMs,
1281
+ injectSseEvent: config.injectSseEvent,
1245
1282
  })
1246
1283
 
1247
1284
  res.writeHead(200, { "content-type": `application/json` })