@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/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
+ }