@durable-streams/server 0.1.3 → 0.1.5

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
@@ -19,6 +19,13 @@ const STREAM_SEQ_HEADER = `Stream-Seq`
19
19
  const STREAM_TTL_HEADER = `Stream-TTL`
20
20
  const STREAM_EXPIRES_AT_HEADER = `Stream-Expires-At`
21
21
 
22
+ // Idempotent producer headers
23
+ const PRODUCER_ID_HEADER = `Producer-Id`
24
+ const PRODUCER_EPOCH_HEADER = `Producer-Epoch`
25
+ const PRODUCER_SEQ_HEADER = `Producer-Seq`
26
+ const PRODUCER_EXPECTED_SEQ_HEADER = `Producer-Expected-Seq`
27
+ const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`
28
+
22
29
  // SSE control event fields (Protocol Section 5.7)
23
30
  const SSE_OFFSET_FIELD = `streamNextOffset`
24
31
  const SSE_CURSOR_FIELD = `streamCursor`
@@ -32,10 +39,14 @@ const CURSOR_QUERY_PARAM = `cursor`
32
39
  /**
33
40
  * Encode data for SSE format.
34
41
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
35
- * Newlines in the payload become separate data: lines.
42
+ * Line terminators in the payload (CR, LF, or CRLF) become separate data: lines.
43
+ * This prevents CRLF injection attacks where malicious payloads could inject
44
+ * fake SSE events using CR-only line terminators.
36
45
  */
37
46
  function encodeSSEData(payload: string): string {
38
- const lines = payload.split(`\n`)
47
+ // Split on all SSE-valid line terminators: CRLF, CR, or LF
48
+ // Order matters: \r\n must be matched before \r alone
49
+ const lines = payload.split(/\r\n|\r|\n/)
39
50
  return lines.map((line) => `data: ${line}`).join(`\n`) + `\n\n`
40
51
  }
41
52
 
@@ -92,15 +103,30 @@ function compressData(
92
103
  * Supports both in-memory and file-backed storage modes.
93
104
  */
94
105
  /**
95
- * Configuration for injected errors (for testing retry/resilience).
106
+ * Configuration for injected faults (for testing retry/resilience).
107
+ * Supports various fault types beyond simple HTTP errors.
96
108
  */
97
- interface InjectedError {
98
- /** HTTP status code to return */
99
- status: number
100
- /** Number of times to return this error (decremented on each use) */
109
+ interface InjectedFault {
110
+ /** HTTP status code to return (if set, returns error response) */
111
+ status?: number
112
+ /** Number of times to trigger this fault (decremented on each use) */
101
113
  count: number
102
114
  /** Optional Retry-After header value (seconds) */
103
115
  retryAfter?: number
116
+ /** Delay in milliseconds before responding */
117
+ delayMs?: number
118
+ /** Drop the connection after sending headers (simulates network failure) */
119
+ dropConnection?: boolean
120
+ /** Truncate response body to this many bytes */
121
+ truncateBodyBytes?: number
122
+ /** Probability of triggering fault (0-1, default 1.0 = always) */
123
+ probability?: number
124
+ /** Only match specific HTTP method (GET, POST, PUT, DELETE) */
125
+ method?: string
126
+ /** Corrupt the response body by flipping random bits */
127
+ corruptBody?: boolean
128
+ /** Add jitter to delay (random 0-jitterMs added to delayMs) */
129
+ jitterMs?: number
104
130
  }
105
131
 
106
132
  export class DurableStreamTestServer {
@@ -126,8 +152,8 @@ export class DurableStreamTestServer {
126
152
  private _url: string | null = null
127
153
  private activeSSEResponses = new Set<ServerResponse>()
128
154
  private isShuttingDown = false
129
- /** Injected errors for testing retry/resilience */
130
- private injectedErrors = new Map<string, InjectedError>()
155
+ /** Injected faults for testing retry/resilience */
156
+ private injectedFaults = new Map<string, InjectedFault>()
131
157
 
132
158
  constructor(options: TestServerOptions = {}) {
133
159
  // Choose store based on dataDir option
@@ -253,6 +279,7 @@ export class DurableStreamTestServer {
253
279
  /**
254
280
  * Inject an error to be returned on the next N requests to a path.
255
281
  * Used for testing retry/resilience behavior.
282
+ * @deprecated Use injectFault for full fault injection capabilities
256
283
  */
257
284
  injectError(
258
285
  path: string,
@@ -260,30 +287,102 @@ export class DurableStreamTestServer {
260
287
  count: number = 1,
261
288
  retryAfter?: number
262
289
  ): void {
263
- this.injectedErrors.set(path, { status, count, retryAfter })
290
+ this.injectedFaults.set(path, { status, count, retryAfter })
291
+ }
292
+
293
+ /**
294
+ * Inject a fault to be triggered on the next N requests to a path.
295
+ * Supports various fault types: delays, connection drops, body corruption, etc.
296
+ */
297
+ injectFault(
298
+ path: string,
299
+ fault: Omit<InjectedFault, `count`> & { count?: number }
300
+ ): void {
301
+ this.injectedFaults.set(path, { count: 1, ...fault })
302
+ }
303
+
304
+ /**
305
+ * Clear all injected faults.
306
+ */
307
+ clearInjectedFaults(): void {
308
+ this.injectedFaults.clear()
309
+ }
310
+
311
+ /**
312
+ * Check if there's an injected fault for this path/method and consume it.
313
+ * Returns the fault config if one should be triggered, null otherwise.
314
+ */
315
+ private consumeInjectedFault(
316
+ path: string,
317
+ method: string
318
+ ): InjectedFault | null {
319
+ const fault = this.injectedFaults.get(path)
320
+ if (!fault) return null
321
+
322
+ // Check method filter
323
+ if (fault.method && fault.method.toUpperCase() !== method.toUpperCase()) {
324
+ return null
325
+ }
326
+
327
+ // Check probability
328
+ if (fault.probability !== undefined && Math.random() > fault.probability) {
329
+ return null
330
+ }
331
+
332
+ fault.count--
333
+ if (fault.count <= 0) {
334
+ this.injectedFaults.delete(path)
335
+ }
336
+
337
+ return fault
264
338
  }
265
339
 
266
340
  /**
267
- * Clear all injected errors.
341
+ * Apply delay from fault config (including jitter).
268
342
  */
269
- clearInjectedErrors(): void {
270
- this.injectedErrors.clear()
343
+ private async applyFaultDelay(fault: InjectedFault): Promise<void> {
344
+ if (fault.delayMs !== undefined && fault.delayMs > 0) {
345
+ const jitter = fault.jitterMs ? Math.random() * fault.jitterMs : 0
346
+ await new Promise((resolve) =>
347
+ setTimeout(resolve, fault.delayMs! + jitter)
348
+ )
349
+ }
271
350
  }
272
351
 
273
352
  /**
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.
353
+ * Apply body modifications from stored fault (truncation, corruption).
354
+ * Returns modified body, or original if no modifications needed.
276
355
  */
277
- private consumeInjectedError(path: string): InjectedError | null {
278
- const error = this.injectedErrors.get(path)
279
- if (!error) return null
356
+ private applyFaultBodyModification(
357
+ res: ServerResponse,
358
+ body: Uint8Array
359
+ ): Uint8Array {
360
+ const fault = (res as ServerResponse & { _injectedFault?: InjectedFault })
361
+ ._injectedFault
362
+ if (!fault) return body
363
+
364
+ let modified = body
280
365
 
281
- error.count--
282
- if (error.count <= 0) {
283
- this.injectedErrors.delete(path)
366
+ // Truncate body if configured
367
+ if (
368
+ fault.truncateBodyBytes !== undefined &&
369
+ modified.length > fault.truncateBodyBytes
370
+ ) {
371
+ modified = modified.slice(0, fault.truncateBodyBytes)
372
+ }
373
+
374
+ // Corrupt body if configured (flip random bits)
375
+ if (fault.corruptBody && modified.length > 0) {
376
+ 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))
379
+ for (let i = 0; i < numCorrupt; i++) {
380
+ const pos = Math.floor(Math.random() * modified.length)
381
+ modified[pos] = modified[pos]! ^ (1 << Math.floor(Math.random() * 8))
382
+ }
284
383
  }
285
384
 
286
- return error
385
+ return modified
287
386
  }
288
387
 
289
388
  // ============================================================================
@@ -306,13 +405,17 @@ export class DurableStreamTestServer {
306
405
  )
307
406
  res.setHeader(
308
407
  `access-control-allow-headers`,
309
- `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At`
408
+ `content-type, authorization, Stream-Seq, Stream-TTL, Stream-Expires-At, Producer-Id, Producer-Epoch, Producer-Seq`
310
409
  )
311
410
  res.setHeader(
312
411
  `access-control-expose-headers`,
313
- `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, etag, content-type, content-encoding, vary`
412
+ `Stream-Next-Offset, Stream-Cursor, Stream-Up-To-Date, Producer-Epoch, Producer-Seq, Producer-Expected-Seq, Producer-Received-Seq, etag, content-type, content-encoding, vary`
314
413
  )
315
414
 
415
+ // Browser security headers (Protocol Section 10.7)
416
+ res.setHeader(`x-content-type-options`, `nosniff`)
417
+ res.setHeader(`cross-origin-resource-policy`, `cross-origin`)
418
+
316
419
  // Handle CORS preflight
317
420
  if (method === `OPTIONS`) {
318
421
  res.writeHead(204)
@@ -326,18 +429,37 @@ export class DurableStreamTestServer {
326
429
  return
327
430
  }
328
431
 
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`,
432
+ // Check for injected faults (for testing retry/resilience)
433
+ const fault = this.consumeInjectedFault(path, method ?? `GET`)
434
+ if (fault) {
435
+ // Apply delay if configured
436
+ await this.applyFaultDelay(fault)
437
+
438
+ // Drop connection if configured (simulates network failure)
439
+ if (fault.dropConnection) {
440
+ res.socket?.destroy()
441
+ return
334
442
  }
335
- if (injectedError.retryAfter !== undefined) {
336
- headers[`retry-after`] = injectedError.retryAfter.toString()
443
+
444
+ // If status is set, return an error response
445
+ if (fault.status !== undefined) {
446
+ const headers: Record<string, string> = {
447
+ "content-type": `text/plain`,
448
+ }
449
+ if (fault.retryAfter !== undefined) {
450
+ headers[`retry-after`] = fault.retryAfter.toString()
451
+ }
452
+ res.writeHead(fault.status, headers)
453
+ res.end(`Injected error for testing`)
454
+ return
455
+ }
456
+
457
+ // Store fault for response modification (truncation, corruption)
458
+ if (fault.truncateBodyBytes !== undefined || fault.corruptBody) {
459
+ ;(
460
+ res as ServerResponse & { _injectedFault?: InjectedFault }
461
+ )._injectedFault = fault
337
462
  }
338
- res.writeHead(injectedError.status, headers)
339
- res.end(`Injected error for testing`)
340
- return
341
463
  }
342
464
 
343
465
  try {
@@ -511,6 +633,8 @@ export class DurableStreamTestServer {
511
633
 
512
634
  const headers: Record<string, string> = {
513
635
  [STREAM_OFFSET_HEADER]: stream.currentOffset,
636
+ // HEAD responses should not be cached to avoid stale tail offsets (Protocol Section 5.4)
637
+ "cache-control": `no-store`,
514
638
  }
515
639
 
516
640
  if (stream.contentType) {
@@ -562,9 +686,9 @@ export class DurableStreamTestServer {
562
686
  return
563
687
  }
564
688
 
565
- // Validate offset format: must be "-1" or match our offset format (digits_digits)
689
+ // Validate offset format: must be "-1", "now", or match our offset format (digits_digits)
566
690
  // This prevents path traversal, injection attacks, and invalid characters
567
- const validOffsetPattern = /^(-1|\d+_\d+)$/
691
+ const validOffsetPattern = /^(-1|now|\d+_\d+)$/
568
692
  if (!validOffsetPattern.test(offset)) {
569
693
  res.writeHead(400, { "content-type": `text/plain` })
570
694
  res.end(`Invalid offset format`)
@@ -583,23 +707,57 @@ export class DurableStreamTestServer {
583
707
 
584
708
  // Handle SSE mode
585
709
  if (live === `sse`) {
586
- await this.handleSSE(path, stream, offset!, cursor, res)
710
+ // For SSE with offset=now, convert to actual tail offset
711
+ const sseOffset = offset === `now` ? stream.currentOffset : offset!
712
+ await this.handleSSE(path, stream, sseOffset, cursor, res)
713
+ return
714
+ }
715
+
716
+ // For offset=now, convert to actual tail offset
717
+ // This allows long-poll to immediately start waiting for new data
718
+ const effectiveOffset = offset === `now` ? stream.currentOffset : offset
719
+
720
+ // Handle catch-up mode offset=now: return empty response with tail offset
721
+ // For long-poll mode, we fall through to wait for new data instead
722
+ if (offset === `now` && live !== `long-poll`) {
723
+ const headers: Record<string, string> = {
724
+ [STREAM_OFFSET_HEADER]: stream.currentOffset,
725
+ [STREAM_UP_TO_DATE_HEADER]: `true`,
726
+ // Prevent caching - tail offset changes with each append
727
+ [`cache-control`]: `no-store`,
728
+ }
729
+
730
+ if (stream.contentType) {
731
+ headers[`content-type`] = stream.contentType
732
+ }
733
+
734
+ // No ETag for offset=now responses - Cache-Control: no-store makes ETag unnecessary
735
+ // and some CDNs may behave unexpectedly with both headers
736
+
737
+ // For JSON mode, return empty array; otherwise empty body
738
+ const isJsonMode = stream.contentType?.includes(`application/json`)
739
+ const responseBody = isJsonMode ? `[]` : ``
740
+
741
+ res.writeHead(200, headers)
742
+ res.end(responseBody)
587
743
  return
588
744
  }
589
745
 
590
746
  // Read current messages
591
- let { messages, upToDate } = this.store.read(path, offset)
747
+ let { messages, upToDate } = this.store.read(path, effectiveOffset)
592
748
 
593
749
  // Only wait in long-poll if:
594
750
  // 1. long-poll mode is enabled
595
- // 2. Client provided an offset (not first request)
751
+ // 2. Client provided an offset (not first request) OR used offset=now
596
752
  // 3. Client's offset matches current offset (already caught up)
597
753
  // 4. No new messages
598
- const clientIsCaughtUp = offset && offset === stream.currentOffset
754
+ const clientIsCaughtUp =
755
+ (effectiveOffset && effectiveOffset === stream.currentOffset) ||
756
+ offset === `now`
599
757
  if (live === `long-poll` && clientIsCaughtUp && messages.length === 0) {
600
758
  const result = await this.store.waitForMessages(
601
759
  path,
602
- offset,
760
+ effectiveOffset ?? stream.currentOffset,
603
761
  this.options.longPollTimeout
604
762
  )
605
763
 
@@ -611,7 +769,7 @@ export class DurableStreamTestServer {
611
769
  this.options.cursorOptions
612
770
  )
613
771
  res.writeHead(204, {
614
- [STREAM_OFFSET_HEADER]: offset,
772
+ [STREAM_OFFSET_HEADER]: effectiveOffset ?? stream.currentOffset,
615
773
  [STREAM_UP_TO_DATE_HEADER]: `true`,
616
774
  [STREAM_CURSOR_HEADER]: responseCursor,
617
775
  })
@@ -680,6 +838,9 @@ export class DurableStreamTestServer {
680
838
  }
681
839
  }
682
840
 
841
+ // Apply fault body modifications (truncation, corruption) if configured
842
+ finalData = this.applyFaultBodyModification(res, finalData)
843
+
683
844
  res.writeHead(200, headers)
684
845
  res.end(Buffer.from(finalData))
685
846
  }
@@ -697,12 +858,14 @@ export class DurableStreamTestServer {
697
858
  // Track this SSE connection
698
859
  this.activeSSEResponses.add(res)
699
860
 
700
- // Set SSE headers
861
+ // Set SSE headers (explicitly including security headers for clarity)
701
862
  res.writeHead(200, {
702
863
  "content-type": `text/event-stream`,
703
864
  "cache-control": `no-cache`,
704
865
  connection: `keep-alive`,
705
866
  "access-control-allow-origin": `*`,
867
+ "x-content-type-options": `nosniff`,
868
+ "cross-origin-resource-policy": `cross-origin`,
706
869
  })
707
870
 
708
871
  let currentOffset = initialOffset
@@ -819,6 +982,17 @@ export class DurableStreamTestServer {
819
982
  | string
820
983
  | undefined
821
984
 
985
+ // Extract producer headers
986
+ const producerId = req.headers[PRODUCER_ID_HEADER.toLowerCase()] as
987
+ | string
988
+ | undefined
989
+ const producerEpochStr = req.headers[
990
+ PRODUCER_EPOCH_HEADER.toLowerCase()
991
+ ] as string | undefined
992
+ const producerSeqStr = req.headers[PRODUCER_SEQ_HEADER.toLowerCase()] as
993
+ | string
994
+ | undefined
995
+
822
996
  const body = await this.readBody(req)
823
997
 
824
998
  if (body.length === 0) {
@@ -834,15 +1008,148 @@ export class DurableStreamTestServer {
834
1008
  return
835
1009
  }
836
1010
 
837
- // Support both sync (StreamStore) and async (FileBackedStreamStore) append
838
- // Note: append returns null only for empty arrays with isInitialCreate=true,
839
- // which doesn't apply to POST requests (those throw on empty arrays)
840
- const message = await Promise.resolve(
841
- this.store.append(path, body, { seq, contentType })
842
- )
1011
+ // Validate producer headers - all three must be present together or none
1012
+ // Also reject empty producer ID
1013
+ const hasProducerHeaders =
1014
+ producerId !== undefined ||
1015
+ producerEpochStr !== undefined ||
1016
+ producerSeqStr !== undefined
1017
+ const hasAllProducerHeaders =
1018
+ producerId !== undefined &&
1019
+ producerEpochStr !== undefined &&
1020
+ producerSeqStr !== undefined
1021
+
1022
+ if (hasProducerHeaders && !hasAllProducerHeaders) {
1023
+ res.writeHead(400, { "content-type": `text/plain` })
1024
+ res.end(
1025
+ `All producer headers (Producer-Id, Producer-Epoch, Producer-Seq) must be provided together`
1026
+ )
1027
+ return
1028
+ }
843
1029
 
844
- res.writeHead(200, {
845
- [STREAM_OFFSET_HEADER]: message!.offset,
1030
+ if (hasAllProducerHeaders && producerId === ``) {
1031
+ res.writeHead(400, { "content-type": `text/plain` })
1032
+ res.end(`Invalid Producer-Id: must not be empty`)
1033
+ return
1034
+ }
1035
+
1036
+ // Parse and validate producer epoch and seq as integers
1037
+ // Use strict digit-only validation to reject values like "1abc" or "1e3"
1038
+ const STRICT_INTEGER_REGEX = /^\d+$/
1039
+ let producerEpoch: number | undefined
1040
+ let producerSeq: number | undefined
1041
+ if (hasAllProducerHeaders) {
1042
+ if (!STRICT_INTEGER_REGEX.test(producerEpochStr)) {
1043
+ res.writeHead(400, { "content-type": `text/plain` })
1044
+ res.end(`Invalid Producer-Epoch: must be a non-negative integer`)
1045
+ return
1046
+ }
1047
+ producerEpoch = Number(producerEpochStr)
1048
+ if (!Number.isSafeInteger(producerEpoch)) {
1049
+ res.writeHead(400, { "content-type": `text/plain` })
1050
+ res.end(`Invalid Producer-Epoch: must be a non-negative integer`)
1051
+ return
1052
+ }
1053
+
1054
+ if (!STRICT_INTEGER_REGEX.test(producerSeqStr)) {
1055
+ res.writeHead(400, { "content-type": `text/plain` })
1056
+ res.end(`Invalid Producer-Seq: must be a non-negative integer`)
1057
+ return
1058
+ }
1059
+ producerSeq = Number(producerSeqStr)
1060
+ if (!Number.isSafeInteger(producerSeq)) {
1061
+ res.writeHead(400, { "content-type": `text/plain` })
1062
+ res.end(`Invalid Producer-Seq: must be a non-negative integer`)
1063
+ return
1064
+ }
1065
+ }
1066
+
1067
+ // Build append options
1068
+ const appendOptions = {
1069
+ seq,
1070
+ contentType,
1071
+ producerId,
1072
+ producerEpoch,
1073
+ producerSeq,
1074
+ }
1075
+
1076
+ // Use appendWithProducer for serialized producer operations
1077
+ let result
1078
+ if (producerId !== undefined) {
1079
+ result = await this.store.appendWithProducer(path, body, appendOptions)
1080
+ } else {
1081
+ result = await Promise.resolve(
1082
+ this.store.append(path, body, appendOptions)
1083
+ )
1084
+ }
1085
+
1086
+ // Handle AppendResult with producer validation
1087
+ if (result && typeof result === `object` && `producerResult` in result) {
1088
+ const { message, producerResult } = result
1089
+
1090
+ if (!producerResult || producerResult.status === `accepted`) {
1091
+ // Success - return offset
1092
+ const responseHeaders: Record<string, string> = {
1093
+ [STREAM_OFFSET_HEADER]: message!.offset,
1094
+ }
1095
+ // Echo back the producer epoch and seq (highest accepted)
1096
+ if (producerEpoch !== undefined) {
1097
+ responseHeaders[PRODUCER_EPOCH_HEADER] = producerEpoch.toString()
1098
+ }
1099
+ if (producerSeq !== undefined) {
1100
+ responseHeaders[PRODUCER_SEQ_HEADER] = producerSeq.toString()
1101
+ }
1102
+ res.writeHead(200, responseHeaders)
1103
+ res.end()
1104
+ return
1105
+ }
1106
+
1107
+ // Handle producer validation failures
1108
+ switch (producerResult.status) {
1109
+ case `duplicate`:
1110
+ // 204 No Content for duplicates (idempotent success)
1111
+ // Return Producer-Seq as highest accepted (per PROTOCOL.md)
1112
+ res.writeHead(204, {
1113
+ [PRODUCER_EPOCH_HEADER]: producerEpoch!.toString(),
1114
+ [PRODUCER_SEQ_HEADER]: producerResult.lastSeq.toString(),
1115
+ })
1116
+ res.end()
1117
+ return
1118
+
1119
+ case `stale_epoch`: {
1120
+ // 403 Forbidden for stale epochs (zombie fencing)
1121
+ res.writeHead(403, {
1122
+ "content-type": `text/plain`,
1123
+ [PRODUCER_EPOCH_HEADER]: producerResult.currentEpoch.toString(),
1124
+ })
1125
+ res.end(`Stale producer epoch`)
1126
+ return
1127
+ }
1128
+
1129
+ case `invalid_epoch_seq`:
1130
+ // 400 Bad Request for epoch increase with seq != 0
1131
+ res.writeHead(400, { "content-type": `text/plain` })
1132
+ res.end(`New epoch must start with sequence 0`)
1133
+ return
1134
+
1135
+ case `sequence_gap`:
1136
+ // 409 Conflict for sequence gaps
1137
+ res.writeHead(409, {
1138
+ "content-type": `text/plain`,
1139
+ [PRODUCER_EXPECTED_SEQ_HEADER]:
1140
+ producerResult.expectedSeq.toString(),
1141
+ [PRODUCER_RECEIVED_SEQ_HEADER]:
1142
+ producerResult.receivedSeq.toString(),
1143
+ })
1144
+ res.end(`Producer sequence gap`)
1145
+ return
1146
+ }
1147
+ }
1148
+
1149
+ // Standard append (no producer) - result is StreamMessage
1150
+ const message = result as { offset: string }
1151
+ res.writeHead(204, {
1152
+ [STREAM_OFFSET_HEADER]: message.offset,
846
1153
  })
847
1154
  res.end()
848
1155
  }
@@ -889,23 +1196,53 @@ export class DurableStreamTestServer {
889
1196
  try {
890
1197
  const config = JSON.parse(new TextDecoder().decode(body)) as {
891
1198
  path: string
892
- status: number
1199
+ // Legacy fields (still supported)
1200
+ status?: number
893
1201
  count?: number
894
1202
  retryAfter?: number
1203
+ // New fault injection fields
1204
+ delayMs?: number
1205
+ dropConnection?: boolean
1206
+ truncateBodyBytes?: number
1207
+ probability?: number
1208
+ method?: string
1209
+ corruptBody?: boolean
1210
+ jitterMs?: number
895
1211
  }
896
1212
 
897
- if (!config.path || !config.status) {
1213
+ if (!config.path) {
898
1214
  res.writeHead(400, { "content-type": `text/plain` })
899
- res.end(`Missing required fields: path, status`)
1215
+ res.end(`Missing required field: path`)
900
1216
  return
901
1217
  }
902
1218
 
903
- this.injectError(
904
- config.path,
905
- config.status,
906
- config.count ?? 1,
907
- config.retryAfter
908
- )
1219
+ // Must have at least one fault type specified
1220
+ const hasFaultType =
1221
+ config.status !== undefined ||
1222
+ config.delayMs !== undefined ||
1223
+ config.dropConnection ||
1224
+ config.truncateBodyBytes !== undefined ||
1225
+ config.corruptBody
1226
+ if (!hasFaultType) {
1227
+ res.writeHead(400, { "content-type": `text/plain` })
1228
+ res.end(
1229
+ `Must specify at least one fault type: status, delayMs, dropConnection, truncateBodyBytes, or corruptBody`
1230
+ )
1231
+ return
1232
+ }
1233
+
1234
+ this.injectFault(config.path, {
1235
+ status: config.status,
1236
+ count: config.count ?? 1,
1237
+ retryAfter: config.retryAfter,
1238
+ delayMs: config.delayMs,
1239
+ dropConnection: config.dropConnection,
1240
+ truncateBodyBytes: config.truncateBodyBytes,
1241
+ probability: config.probability,
1242
+ method: config.method,
1243
+ corruptBody: config.corruptBody,
1244
+ jitterMs: config.jitterMs,
1245
+ })
909
1246
 
910
1247
  res.writeHead(200, { "content-type": `application/json` })
911
1248
  res.end(JSON.stringify({ ok: true }))
@@ -914,7 +1251,7 @@ export class DurableStreamTestServer {
914
1251
  res.end(`Invalid JSON body`)
915
1252
  }
916
1253
  } else if (method === `DELETE`) {
917
- this.clearInjectedErrors()
1254
+ this.clearInjectedFaults()
918
1255
  res.writeHead(200, { "content-type": `application/json` })
919
1256
  res.end(JSON.stringify({ ok: true }))
920
1257
  } else {