@durable-streams/server 0.1.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.d.ts +494 -0
- package/dist/index.js +1592 -0
- package/dist/index.js.map +1 -0
- package/package.json +41 -0
- package/src/cursor.ts +156 -0
- package/src/file-manager.ts +89 -0
- package/src/file-store.ts +863 -0
- package/src/index.ts +27 -0
- package/src/path-encoding.ts +61 -0
- package/src/registry-hook.ts +118 -0
- package/src/server.ts +946 -0
- package/src/store.ts +433 -0
- package/src/types.ts +183 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory stream storage.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { PendingLongPoll, Stream, StreamMessage } from "./types"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize content-type by extracting the media type (before any semicolon).
|
|
9
|
+
* Handles cases like "application/json; charset=utf-8".
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeContentType(contentType: string | undefined): string {
|
|
12
|
+
if (!contentType) return ``
|
|
13
|
+
return contentType.split(`;`)[0]!.trim().toLowerCase()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Process JSON data for append in JSON mode.
|
|
18
|
+
* - Validates JSON
|
|
19
|
+
* - Extracts array elements if data is an array
|
|
20
|
+
* - Always appends trailing comma for easy concatenation
|
|
21
|
+
* @param isInitialCreate - If true, empty arrays are allowed (creates empty stream)
|
|
22
|
+
* @throws Error if JSON is invalid or array is empty (for non-create operations)
|
|
23
|
+
*/
|
|
24
|
+
export function processJsonAppend(
|
|
25
|
+
data: Uint8Array,
|
|
26
|
+
isInitialCreate = false
|
|
27
|
+
): Uint8Array {
|
|
28
|
+
const text = new TextDecoder().decode(data)
|
|
29
|
+
|
|
30
|
+
// Validate JSON
|
|
31
|
+
let parsed: unknown
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(text)
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`Invalid JSON`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If it's an array, extract elements and join with commas
|
|
39
|
+
let result: string
|
|
40
|
+
if (Array.isArray(parsed)) {
|
|
41
|
+
if (parsed.length === 0) {
|
|
42
|
+
// Empty arrays are valid for PUT (creates empty stream)
|
|
43
|
+
// but invalid for POST (no-op append, likely a bug)
|
|
44
|
+
if (isInitialCreate) {
|
|
45
|
+
return new Uint8Array(0) // Return empty data for empty stream
|
|
46
|
+
}
|
|
47
|
+
throw new Error(`Empty arrays are not allowed`)
|
|
48
|
+
}
|
|
49
|
+
const elements = parsed.map((item) => JSON.stringify(item))
|
|
50
|
+
result = elements.join(`,`) + `,`
|
|
51
|
+
} else {
|
|
52
|
+
// Single value - re-serialize to normalize whitespace (single-line JSON)
|
|
53
|
+
result = JSON.stringify(parsed) + `,`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new TextEncoder().encode(result)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format JSON mode response by wrapping in array brackets.
|
|
61
|
+
* Strips trailing comma before wrapping.
|
|
62
|
+
*/
|
|
63
|
+
export function formatJsonResponse(data: Uint8Array): Uint8Array {
|
|
64
|
+
if (data.length === 0) {
|
|
65
|
+
return new TextEncoder().encode(`[]`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let text = new TextDecoder().decode(data)
|
|
69
|
+
// Strip trailing comma if present
|
|
70
|
+
text = text.trimEnd()
|
|
71
|
+
if (text.endsWith(`,`)) {
|
|
72
|
+
text = text.slice(0, -1)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const wrapped = `[${text}]`
|
|
76
|
+
return new TextEncoder().encode(wrapped)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* In-memory store for durable streams.
|
|
81
|
+
*/
|
|
82
|
+
export class StreamStore {
|
|
83
|
+
private streams = new Map<string, Stream>()
|
|
84
|
+
private pendingLongPolls: Array<PendingLongPoll> = []
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Create a new stream.
|
|
88
|
+
* @throws Error if stream already exists with different config
|
|
89
|
+
* @returns existing stream if config matches (idempotent)
|
|
90
|
+
*/
|
|
91
|
+
create(
|
|
92
|
+
path: string,
|
|
93
|
+
options: {
|
|
94
|
+
contentType?: string
|
|
95
|
+
ttlSeconds?: number
|
|
96
|
+
expiresAt?: string
|
|
97
|
+
initialData?: Uint8Array
|
|
98
|
+
} = {}
|
|
99
|
+
): Stream {
|
|
100
|
+
const existing = this.streams.get(path)
|
|
101
|
+
if (existing) {
|
|
102
|
+
// Check if config matches (idempotent create)
|
|
103
|
+
const contentTypeMatches =
|
|
104
|
+
(normalizeContentType(options.contentType) ||
|
|
105
|
+
`application/octet-stream`) ===
|
|
106
|
+
(normalizeContentType(existing.contentType) ||
|
|
107
|
+
`application/octet-stream`)
|
|
108
|
+
const ttlMatches = options.ttlSeconds === existing.ttlSeconds
|
|
109
|
+
const expiresMatches = options.expiresAt === existing.expiresAt
|
|
110
|
+
|
|
111
|
+
if (contentTypeMatches && ttlMatches && expiresMatches) {
|
|
112
|
+
// Idempotent success - return existing stream
|
|
113
|
+
return existing
|
|
114
|
+
} else {
|
|
115
|
+
// Config mismatch - conflict
|
|
116
|
+
throw new Error(
|
|
117
|
+
`Stream already exists with different configuration: ${path}`
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stream: Stream = {
|
|
123
|
+
path,
|
|
124
|
+
contentType: options.contentType,
|
|
125
|
+
messages: [],
|
|
126
|
+
currentOffset: `0000000000000000_0000000000000000`,
|
|
127
|
+
ttlSeconds: options.ttlSeconds,
|
|
128
|
+
expiresAt: options.expiresAt,
|
|
129
|
+
createdAt: Date.now(),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// If initial data is provided, append it
|
|
133
|
+
if (options.initialData && options.initialData.length > 0) {
|
|
134
|
+
this.appendToStream(stream, options.initialData, true) // isInitialCreate = true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
this.streams.set(path, stream)
|
|
138
|
+
return stream
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get a stream by path.
|
|
143
|
+
*/
|
|
144
|
+
get(path: string): Stream | undefined {
|
|
145
|
+
return this.streams.get(path)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if a stream exists.
|
|
150
|
+
*/
|
|
151
|
+
has(path: string): boolean {
|
|
152
|
+
return this.streams.has(path)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Delete a stream.
|
|
157
|
+
*/
|
|
158
|
+
delete(path: string): boolean {
|
|
159
|
+
// Cancel any pending long-polls for this stream
|
|
160
|
+
this.cancelLongPollsForStream(path)
|
|
161
|
+
return this.streams.delete(path)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Append data to a stream.
|
|
166
|
+
* @throws Error if stream doesn't exist
|
|
167
|
+
* @throws Error if seq is lower than lastSeq
|
|
168
|
+
* @throws Error if JSON mode and array is empty
|
|
169
|
+
*/
|
|
170
|
+
append(
|
|
171
|
+
path: string,
|
|
172
|
+
data: Uint8Array,
|
|
173
|
+
options: { seq?: string; contentType?: string } = {}
|
|
174
|
+
): StreamMessage {
|
|
175
|
+
const stream = this.streams.get(path)
|
|
176
|
+
if (!stream) {
|
|
177
|
+
throw new Error(`Stream not found: ${path}`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check content type match using normalization (handles charset parameters)
|
|
181
|
+
if (options.contentType && stream.contentType) {
|
|
182
|
+
const providedType = normalizeContentType(options.contentType)
|
|
183
|
+
const streamType = normalizeContentType(stream.contentType)
|
|
184
|
+
if (providedType !== streamType) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Content-type mismatch: expected ${stream.contentType}, got ${options.contentType}`
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check sequence for writer coordination
|
|
192
|
+
if (options.seq !== undefined) {
|
|
193
|
+
if (stream.lastSeq !== undefined && options.seq <= stream.lastSeq) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`Sequence conflict: ${options.seq} <= ${stream.lastSeq}`
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
stream.lastSeq = options.seq
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// appendToStream returns null only for empty arrays in create mode,
|
|
202
|
+
// but public append() never sets isInitialCreate, so empty arrays throw before this
|
|
203
|
+
const message = this.appendToStream(stream, data)!
|
|
204
|
+
|
|
205
|
+
// Notify any pending long-polls
|
|
206
|
+
this.notifyLongPolls(path)
|
|
207
|
+
|
|
208
|
+
return message
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Read messages from a stream starting at the given offset.
|
|
213
|
+
*/
|
|
214
|
+
read(
|
|
215
|
+
path: string,
|
|
216
|
+
offset?: string
|
|
217
|
+
): { messages: Array<StreamMessage>; upToDate: boolean } {
|
|
218
|
+
const stream = this.streams.get(path)
|
|
219
|
+
if (!stream) {
|
|
220
|
+
throw new Error(`Stream not found: ${path}`)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// No offset or -1 means start from beginning
|
|
224
|
+
if (!offset || offset === `-1`) {
|
|
225
|
+
return {
|
|
226
|
+
messages: [...stream.messages],
|
|
227
|
+
upToDate: true,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Find messages after the given offset
|
|
232
|
+
const offsetIndex = this.findOffsetIndex(stream, offset)
|
|
233
|
+
if (offsetIndex === -1) {
|
|
234
|
+
// Offset is at or past the end
|
|
235
|
+
return {
|
|
236
|
+
messages: [],
|
|
237
|
+
upToDate: true,
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
messages: stream.messages.slice(offsetIndex),
|
|
243
|
+
upToDate: true,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Format messages for response.
|
|
249
|
+
* For JSON mode, wraps concatenated data in array brackets.
|
|
250
|
+
*/
|
|
251
|
+
formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array {
|
|
252
|
+
const stream = this.streams.get(path)
|
|
253
|
+
if (!stream) {
|
|
254
|
+
throw new Error(`Stream not found: ${path}`)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Concatenate all message data
|
|
258
|
+
const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)
|
|
259
|
+
const concatenated = new Uint8Array(totalSize)
|
|
260
|
+
let offset = 0
|
|
261
|
+
for (const msg of messages) {
|
|
262
|
+
concatenated.set(msg.data, offset)
|
|
263
|
+
offset += msg.data.length
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// For JSON mode, wrap in array brackets
|
|
267
|
+
if (normalizeContentType(stream.contentType) === `application/json`) {
|
|
268
|
+
return formatJsonResponse(concatenated)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return concatenated
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Wait for new messages (long-poll).
|
|
276
|
+
*/
|
|
277
|
+
async waitForMessages(
|
|
278
|
+
path: string,
|
|
279
|
+
offset: string,
|
|
280
|
+
timeoutMs: number
|
|
281
|
+
): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {
|
|
282
|
+
const stream = this.streams.get(path)
|
|
283
|
+
if (!stream) {
|
|
284
|
+
throw new Error(`Stream not found: ${path}`)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Check if there are already new messages
|
|
288
|
+
const { messages } = this.read(path, offset)
|
|
289
|
+
if (messages.length > 0) {
|
|
290
|
+
return { messages, timedOut: false }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Wait for new messages
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const timeoutId = setTimeout(() => {
|
|
296
|
+
// Remove from pending
|
|
297
|
+
this.removePendingLongPoll(pending)
|
|
298
|
+
resolve({ messages: [], timedOut: true })
|
|
299
|
+
}, timeoutMs)
|
|
300
|
+
|
|
301
|
+
const pending: PendingLongPoll = {
|
|
302
|
+
path,
|
|
303
|
+
offset,
|
|
304
|
+
resolve: (msgs) => {
|
|
305
|
+
clearTimeout(timeoutId)
|
|
306
|
+
this.removePendingLongPoll(pending)
|
|
307
|
+
resolve({ messages: msgs, timedOut: false })
|
|
308
|
+
},
|
|
309
|
+
timeoutId,
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
this.pendingLongPolls.push(pending)
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Get the current offset for a stream.
|
|
318
|
+
*/
|
|
319
|
+
getCurrentOffset(path: string): string | undefined {
|
|
320
|
+
return this.streams.get(path)?.currentOffset
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Clear all streams.
|
|
325
|
+
*/
|
|
326
|
+
clear(): void {
|
|
327
|
+
// Cancel all pending long-polls and resolve them with timeout
|
|
328
|
+
for (const pending of this.pendingLongPolls) {
|
|
329
|
+
clearTimeout(pending.timeoutId)
|
|
330
|
+
// Resolve with empty result to unblock waiting handlers
|
|
331
|
+
pending.resolve([])
|
|
332
|
+
}
|
|
333
|
+
this.pendingLongPolls = []
|
|
334
|
+
this.streams.clear()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Cancel all pending long-polls (used during shutdown).
|
|
339
|
+
*/
|
|
340
|
+
cancelAllWaits(): void {
|
|
341
|
+
for (const pending of this.pendingLongPolls) {
|
|
342
|
+
clearTimeout(pending.timeoutId)
|
|
343
|
+
// Resolve with empty result to unblock waiting handlers
|
|
344
|
+
pending.resolve([])
|
|
345
|
+
}
|
|
346
|
+
this.pendingLongPolls = []
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Get all stream paths.
|
|
351
|
+
*/
|
|
352
|
+
list(): Array<string> {
|
|
353
|
+
return Array.from(this.streams.keys())
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ============================================================================
|
|
357
|
+
// Private helpers
|
|
358
|
+
// ============================================================================
|
|
359
|
+
|
|
360
|
+
private appendToStream(
|
|
361
|
+
stream: Stream,
|
|
362
|
+
data: Uint8Array,
|
|
363
|
+
isInitialCreate = false
|
|
364
|
+
): StreamMessage | null {
|
|
365
|
+
// Process JSON mode data (throws on invalid JSON or empty arrays for appends)
|
|
366
|
+
let processedData = data
|
|
367
|
+
if (normalizeContentType(stream.contentType) === `application/json`) {
|
|
368
|
+
processedData = processJsonAppend(data, isInitialCreate)
|
|
369
|
+
// If empty array in create mode, return null (empty stream created successfully)
|
|
370
|
+
if (processedData.length === 0) {
|
|
371
|
+
return null
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Parse current offset
|
|
376
|
+
const parts = stream.currentOffset.split(`_`).map(Number)
|
|
377
|
+
const readSeq = parts[0]!
|
|
378
|
+
const byteOffset = parts[1]!
|
|
379
|
+
|
|
380
|
+
// Calculate new offset with zero-padding for lexicographic sorting
|
|
381
|
+
const newByteOffset = byteOffset + processedData.length
|
|
382
|
+
const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
|
|
383
|
+
|
|
384
|
+
const message: StreamMessage = {
|
|
385
|
+
data: processedData,
|
|
386
|
+
offset: newOffset,
|
|
387
|
+
timestamp: Date.now(),
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
stream.messages.push(message)
|
|
391
|
+
stream.currentOffset = newOffset
|
|
392
|
+
|
|
393
|
+
return message
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
private findOffsetIndex(stream: Stream, offset: string): number {
|
|
397
|
+
// Find the first message with an offset greater than the given offset
|
|
398
|
+
// Use lexicographic comparison as required by protocol
|
|
399
|
+
for (let i = 0; i < stream.messages.length; i++) {
|
|
400
|
+
if (stream.messages[i]!.offset > offset) {
|
|
401
|
+
return i
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return -1 // No messages after the offset
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private notifyLongPolls(path: string): void {
|
|
408
|
+
const toNotify = this.pendingLongPolls.filter((p) => p.path === path)
|
|
409
|
+
|
|
410
|
+
for (const pending of toNotify) {
|
|
411
|
+
const { messages } = this.read(path, pending.offset)
|
|
412
|
+
if (messages.length > 0) {
|
|
413
|
+
pending.resolve(messages)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private cancelLongPollsForStream(path: string): void {
|
|
419
|
+
const toCancel = this.pendingLongPolls.filter((p) => p.path === path)
|
|
420
|
+
for (const pending of toCancel) {
|
|
421
|
+
clearTimeout(pending.timeoutId)
|
|
422
|
+
pending.resolve([])
|
|
423
|
+
}
|
|
424
|
+
this.pendingLongPolls = this.pendingLongPolls.filter((p) => p.path !== path)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private removePendingLongPoll(pending: PendingLongPoll): void {
|
|
428
|
+
const index = this.pendingLongPolls.indexOf(pending)
|
|
429
|
+
if (index !== -1) {
|
|
430
|
+
this.pendingLongPolls.splice(index, 1)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the in-memory durable streams test server.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A single message in a stream.
|
|
7
|
+
*/
|
|
8
|
+
export interface StreamMessage {
|
|
9
|
+
/**
|
|
10
|
+
* The raw bytes of the message.
|
|
11
|
+
*/
|
|
12
|
+
data: Uint8Array
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The offset after this message.
|
|
16
|
+
* Format: "<read-seq>_<byte-offset>"
|
|
17
|
+
*/
|
|
18
|
+
offset: string
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Timestamp when the message was appended.
|
|
22
|
+
*/
|
|
23
|
+
timestamp: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stream metadata and data.
|
|
28
|
+
*/
|
|
29
|
+
export interface Stream {
|
|
30
|
+
/**
|
|
31
|
+
* The stream URL path (key).
|
|
32
|
+
*/
|
|
33
|
+
path: string
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Content type of the stream.
|
|
37
|
+
*/
|
|
38
|
+
contentType?: string
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Messages in the stream.
|
|
42
|
+
*/
|
|
43
|
+
messages: Array<StreamMessage>
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Current offset (next offset to write to).
|
|
47
|
+
*/
|
|
48
|
+
currentOffset: string
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Last sequence number for writer coordination.
|
|
52
|
+
*/
|
|
53
|
+
lastSeq?: string
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* TTL in seconds.
|
|
57
|
+
*/
|
|
58
|
+
ttlSeconds?: number
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Absolute expiry time (ISO 8601).
|
|
62
|
+
*/
|
|
63
|
+
expiresAt?: string
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Timestamp when the stream was created.
|
|
67
|
+
*/
|
|
68
|
+
createdAt: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Event data for stream lifecycle hooks.
|
|
73
|
+
*/
|
|
74
|
+
export interface StreamLifecycleEvent {
|
|
75
|
+
/**
|
|
76
|
+
* Type of event.
|
|
77
|
+
*/
|
|
78
|
+
type: `created` | `deleted`
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Stream path.
|
|
82
|
+
*/
|
|
83
|
+
path: string
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Content type (only for 'created' events).
|
|
87
|
+
*/
|
|
88
|
+
contentType?: string
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Timestamp of the event.
|
|
92
|
+
*/
|
|
93
|
+
timestamp: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Hook function called when a stream is created or deleted.
|
|
98
|
+
*/
|
|
99
|
+
export type StreamLifecycleHook = (
|
|
100
|
+
event: StreamLifecycleEvent
|
|
101
|
+
) => void | Promise<void>
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Options for creating the test server.
|
|
105
|
+
*/
|
|
106
|
+
export interface TestServerOptions {
|
|
107
|
+
/**
|
|
108
|
+
* Port to listen on. Default: 0 (auto-assign).
|
|
109
|
+
*/
|
|
110
|
+
port?: number
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Host to bind to. Default: "127.0.0.1".
|
|
114
|
+
*/
|
|
115
|
+
host?: string
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Default long-poll timeout in milliseconds.
|
|
119
|
+
* Default: 30000 (30 seconds).
|
|
120
|
+
*/
|
|
121
|
+
longPollTimeout?: number
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Data directory for file-backed storage.
|
|
125
|
+
* If provided, enables file-backed mode using LMDB and append-only logs.
|
|
126
|
+
* If omitted, uses in-memory storage.
|
|
127
|
+
*/
|
|
128
|
+
dataDir?: string
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Hook called when a stream is created.
|
|
132
|
+
*/
|
|
133
|
+
onStreamCreated?: StreamLifecycleHook
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Hook called when a stream is deleted.
|
|
137
|
+
*/
|
|
138
|
+
onStreamDeleted?: StreamLifecycleHook
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Enable gzip/deflate compression for responses.
|
|
142
|
+
* Default: true.
|
|
143
|
+
*/
|
|
144
|
+
compression?: boolean
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Interval in seconds for cursor calculation.
|
|
148
|
+
* Used for CDN cache collapsing to prevent infinite cache loops.
|
|
149
|
+
* Default: 20 seconds.
|
|
150
|
+
*/
|
|
151
|
+
cursorIntervalSeconds?: number
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Epoch timestamp for cursor interval calculation.
|
|
155
|
+
* Default: October 9, 2024 00:00:00 UTC.
|
|
156
|
+
*/
|
|
157
|
+
cursorEpoch?: Date
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Pending long-poll request.
|
|
162
|
+
*/
|
|
163
|
+
export interface PendingLongPoll {
|
|
164
|
+
/**
|
|
165
|
+
* Stream path.
|
|
166
|
+
*/
|
|
167
|
+
path: string
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Offset to wait for.
|
|
171
|
+
*/
|
|
172
|
+
offset: string
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve function.
|
|
176
|
+
*/
|
|
177
|
+
resolve: (messages: Array<StreamMessage>) => void
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Timeout ID.
|
|
181
|
+
*/
|
|
182
|
+
timeoutId: ReturnType<typeof setTimeout>
|
|
183
|
+
}
|