@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/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 {
package/src/store.ts CHANGED
@@ -83,6 +83,49 @@ export class StreamStore {
83
83
  private streams = new Map<string, Stream>()
84
84
  private pendingLongPolls: Array<PendingLongPoll> = []
85
85
 
86
+ /**
87
+ * Check if a stream is expired based on TTL or Expires-At.
88
+ */
89
+ private isExpired(stream: Stream): boolean {
90
+ const now = Date.now()
91
+
92
+ // Check absolute expiry time
93
+ if (stream.expiresAt) {
94
+ const expiryTime = new Date(stream.expiresAt).getTime()
95
+ // Treat invalid dates (NaN) as expired (fail closed)
96
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) {
97
+ return true
98
+ }
99
+ }
100
+
101
+ // Check TTL (relative to creation time)
102
+ if (stream.ttlSeconds !== undefined) {
103
+ const expiryTime = stream.createdAt + stream.ttlSeconds * 1000
104
+ if (now >= expiryTime) {
105
+ return true
106
+ }
107
+ }
108
+
109
+ return false
110
+ }
111
+
112
+ /**
113
+ * Get a stream, deleting it if expired.
114
+ * Returns undefined if stream doesn't exist or is expired.
115
+ */
116
+ private getIfNotExpired(path: string): Stream | undefined {
117
+ const stream = this.streams.get(path)
118
+ if (!stream) {
119
+ return undefined
120
+ }
121
+ if (this.isExpired(stream)) {
122
+ // Delete expired stream
123
+ this.delete(path)
124
+ return undefined
125
+ }
126
+ return stream
127
+ }
128
+
86
129
  /**
87
130
  * Create a new stream.
88
131
  * @throws Error if stream already exists with different config
@@ -97,7 +140,8 @@ export class StreamStore {
97
140
  initialData?: Uint8Array
98
141
  } = {}
99
142
  ): Stream {
100
- const existing = this.streams.get(path)
143
+ // Use getIfNotExpired to treat expired streams as non-existent
144
+ const existing = this.getIfNotExpired(path)
101
145
  if (existing) {
102
146
  // Check if config matches (idempotent create)
103
147
  const contentTypeMatches =
@@ -140,16 +184,17 @@ export class StreamStore {
140
184
 
141
185
  /**
142
186
  * Get a stream by path.
187
+ * Returns undefined if stream doesn't exist or is expired.
143
188
  */
144
189
  get(path: string): Stream | undefined {
145
- return this.streams.get(path)
190
+ return this.getIfNotExpired(path)
146
191
  }
147
192
 
148
193
  /**
149
- * Check if a stream exists.
194
+ * Check if a stream exists (and is not expired).
150
195
  */
151
196
  has(path: string): boolean {
152
- return this.streams.has(path)
197
+ return this.getIfNotExpired(path) !== undefined
153
198
  }
154
199
 
155
200
  /**
@@ -163,7 +208,7 @@ export class StreamStore {
163
208
 
164
209
  /**
165
210
  * Append data to a stream.
166
- * @throws Error if stream doesn't exist
211
+ * @throws Error if stream doesn't exist or is expired
167
212
  * @throws Error if seq is lower than lastSeq
168
213
  * @throws Error if JSON mode and array is empty
169
214
  */
@@ -172,7 +217,7 @@ export class StreamStore {
172
217
  data: Uint8Array,
173
218
  options: { seq?: string; contentType?: string } = {}
174
219
  ): StreamMessage {
175
- const stream = this.streams.get(path)
220
+ const stream = this.getIfNotExpired(path)
176
221
  if (!stream) {
177
222
  throw new Error(`Stream not found: ${path}`)
178
223
  }
@@ -210,12 +255,13 @@ export class StreamStore {
210
255
 
211
256
  /**
212
257
  * Read messages from a stream starting at the given offset.
258
+ * @throws Error if stream doesn't exist or is expired
213
259
  */
214
260
  read(
215
261
  path: string,
216
262
  offset?: string
217
263
  ): { messages: Array<StreamMessage>; upToDate: boolean } {
218
- const stream = this.streams.get(path)
264
+ const stream = this.getIfNotExpired(path)
219
265
  if (!stream) {
220
266
  throw new Error(`Stream not found: ${path}`)
221
267
  }
@@ -247,9 +293,10 @@ export class StreamStore {
247
293
  /**
248
294
  * Format messages for response.
249
295
  * For JSON mode, wraps concatenated data in array brackets.
296
+ * @throws Error if stream doesn't exist or is expired
250
297
  */
251
298
  formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array {
252
- const stream = this.streams.get(path)
299
+ const stream = this.getIfNotExpired(path)
253
300
  if (!stream) {
254
301
  throw new Error(`Stream not found: ${path}`)
255
302
  }
@@ -273,13 +320,14 @@ export class StreamStore {
273
320
 
274
321
  /**
275
322
  * Wait for new messages (long-poll).
323
+ * @throws Error if stream doesn't exist or is expired
276
324
  */
277
325
  async waitForMessages(
278
326
  path: string,
279
327
  offset: string,
280
328
  timeoutMs: number
281
329
  ): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {
282
- const stream = this.streams.get(path)
330
+ const stream = this.getIfNotExpired(path)
283
331
  if (!stream) {
284
332
  throw new Error(`Stream not found: ${path}`)
285
333
  }
@@ -315,9 +363,10 @@ export class StreamStore {
315
363
 
316
364
  /**
317
365
  * Get the current offset for a stream.
366
+ * Returns undefined if stream doesn't exist or is expired.
318
367
  */
319
368
  getCurrentOffset(path: string): string | undefined {
320
- return this.streams.get(path)?.currentOffset
369
+ return this.getIfNotExpired(path)?.currentOffset
321
370
  }
322
371
 
323
372
  /**