@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/dist/index.cjs +518 -47
- package/dist/index.d.cts +191 -15
- package/dist/index.d.ts +191 -15
- package/dist/index.js +518 -47
- package/package.json +5 -5
- package/src/file-store.ts +238 -10
- package/src/server.ts +398 -61
- package/src/store.ts +272 -7
- package/src/types.ts +46 -0
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
|
-
*
|
|
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
|
-
|
|
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
|
|
106
|
+
* Configuration for injected faults (for testing retry/resilience).
|
|
107
|
+
* Supports various fault types beyond simple HTTP errors.
|
|
96
108
|
*/
|
|
97
|
-
interface
|
|
98
|
-
/** HTTP status code to return */
|
|
99
|
-
status
|
|
100
|
-
/** Number of times to
|
|
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
|
|
130
|
-
private
|
|
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.
|
|
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
|
-
*
|
|
341
|
+
* Apply delay from fault config (including jitter).
|
|
268
342
|
*/
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
*
|
|
275
|
-
* Returns
|
|
353
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
354
|
+
* Returns modified body, or original if no modifications needed.
|
|
276
355
|
*/
|
|
277
|
-
private
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
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
|
|
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
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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]:
|
|
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
|
-
//
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
845
|
-
|
|
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
|
-
|
|
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
|
|
1213
|
+
if (!config.path) {
|
|
898
1214
|
res.writeHead(400, { "content-type": `text/plain` })
|
|
899
|
-
res.end(`Missing required
|
|
1215
|
+
res.end(`Missing required field: path`)
|
|
900
1216
|
return
|
|
901
1217
|
}
|
|
902
1218
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
config.status
|
|
906
|
-
config.
|
|
907
|
-
config.
|
|
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.
|
|
1254
|
+
this.clearInjectedFaults()
|
|
918
1255
|
res.writeHead(200, { "content-type": `application/json` })
|
|
919
1256
|
res.end(JSON.stringify({ ok: true }))
|
|
920
1257
|
} else {
|