@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/README.md +167 -0
- package/dist/index.cjs +189 -56
- package/dist/index.d.cts +81 -12
- package/dist/index.d.ts +81 -12
- package/dist/index.js +189 -56
- package/package.json +5 -5
- package/src/file-store.ts +58 -16
- package/src/server.ts +196 -44
- package/src/store.ts +59 -10
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
|
-
*
|
|
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
|
-
|
|
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
|
|
99
|
+
* Configuration for injected faults (for testing retry/resilience).
|
|
100
|
+
* Supports various fault types beyond simple HTTP errors.
|
|
96
101
|
*/
|
|
97
|
-
interface
|
|
98
|
-
/** HTTP status code to return */
|
|
99
|
-
status
|
|
100
|
-
/** Number of times to
|
|
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
|
|
130
|
-
private
|
|
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.
|
|
283
|
+
this.injectedFaults.set(path, { status, count, retryAfter })
|
|
264
284
|
}
|
|
265
285
|
|
|
266
286
|
/**
|
|
267
|
-
*
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
*
|
|
275
|
-
* Returns
|
|
346
|
+
* Apply body modifications from stored fault (truncation, corruption).
|
|
347
|
+
* Returns modified body, or original if no modifications needed.
|
|
276
348
|
*/
|
|
277
|
-
private
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
if (
|
|
283
|
-
|
|
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
|
|
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
|
|
330
|
-
const
|
|
331
|
-
if (
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1028
|
+
if (!config.path) {
|
|
898
1029
|
res.writeHead(400, { "content-type": `text/plain` })
|
|
899
|
-
res.end(`Missing required
|
|
1030
|
+
res.end(`Missing required field: path`)
|
|
900
1031
|
return
|
|
901
1032
|
}
|
|
902
1033
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
config.status
|
|
906
|
-
config.
|
|
907
|
-
config.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
369
|
+
return this.getIfNotExpired(path)?.currentOffset
|
|
321
370
|
}
|
|
322
371
|
|
|
323
372
|
/**
|