@durable-streams/server 0.1.1 → 0.1.2

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.
@@ -0,0 +1,494 @@
1
+ //#region src/types.d.ts
2
+ /**
3
+ * Types for the in-memory durable streams test server.
4
+ */
5
+ /**
6
+ * A single message in a stream.
7
+ */
8
+ interface StreamMessage {
9
+ /**
10
+ * The raw bytes of the message.
11
+ */
12
+ data: Uint8Array;
13
+ /**
14
+ * The offset after this message.
15
+ * Format: "<read-seq>_<byte-offset>"
16
+ */
17
+ offset: string;
18
+ /**
19
+ * Timestamp when the message was appended.
20
+ */
21
+ timestamp: number;
22
+ }
23
+ /**
24
+ * Stream metadata and data.
25
+ */
26
+ interface Stream {
27
+ /**
28
+ * The stream URL path (key).
29
+ */
30
+ path: string;
31
+ /**
32
+ * Content type of the stream.
33
+ */
34
+ contentType?: string;
35
+ /**
36
+ * Messages in the stream.
37
+ */
38
+ messages: Array<StreamMessage>;
39
+ /**
40
+ * Current offset (next offset to write to).
41
+ */
42
+ currentOffset: string;
43
+ /**
44
+ * Last sequence number for writer coordination.
45
+ */
46
+ lastSeq?: string;
47
+ /**
48
+ * TTL in seconds.
49
+ */
50
+ ttlSeconds?: number;
51
+ /**
52
+ * Absolute expiry time (ISO 8601).
53
+ */
54
+ expiresAt?: string;
55
+ /**
56
+ * Timestamp when the stream was created.
57
+ */
58
+ createdAt: number;
59
+ }
60
+ /**
61
+ * Event data for stream lifecycle hooks.
62
+ */
63
+ interface StreamLifecycleEvent {
64
+ /**
65
+ * Type of event.
66
+ */
67
+ type: `created` | `deleted`;
68
+ /**
69
+ * Stream path.
70
+ */
71
+ path: string;
72
+ /**
73
+ * Content type (only for 'created' events).
74
+ */
75
+ contentType?: string;
76
+ /**
77
+ * Timestamp of the event.
78
+ */
79
+ timestamp: number;
80
+ }
81
+ /**
82
+ * Hook function called when a stream is created or deleted.
83
+ */
84
+ type StreamLifecycleHook = (event: StreamLifecycleEvent) => void | Promise<void>;
85
+ /**
86
+ * Options for creating the test server.
87
+ */
88
+ interface TestServerOptions {
89
+ /**
90
+ * Port to listen on. Default: 0 (auto-assign).
91
+ */
92
+ port?: number;
93
+ /**
94
+ * Host to bind to. Default: "127.0.0.1".
95
+ */
96
+ host?: string;
97
+ /**
98
+ * Default long-poll timeout in milliseconds.
99
+ * Default: 30000 (30 seconds).
100
+ */
101
+ longPollTimeout?: number;
102
+ /**
103
+ * Data directory for file-backed storage.
104
+ * If provided, enables file-backed mode using LMDB and append-only logs.
105
+ * If omitted, uses in-memory storage.
106
+ */
107
+ dataDir?: string;
108
+ /**
109
+ * Hook called when a stream is created.
110
+ */
111
+ onStreamCreated?: StreamLifecycleHook;
112
+ /**
113
+ * Hook called when a stream is deleted.
114
+ */
115
+ onStreamDeleted?: StreamLifecycleHook;
116
+ /**
117
+ * Enable gzip/deflate compression for responses.
118
+ * Default: true.
119
+ */
120
+ compression?: boolean;
121
+ /**
122
+ * Interval in seconds for cursor calculation.
123
+ * Used for CDN cache collapsing to prevent infinite cache loops.
124
+ * Default: 20 seconds.
125
+ */
126
+ cursorIntervalSeconds?: number;
127
+ /**
128
+ * Epoch timestamp for cursor interval calculation.
129
+ * Default: October 9, 2024 00:00:00 UTC.
130
+ */
131
+ cursorEpoch?: Date;
132
+ }
133
+ /**
134
+ * Pending long-poll request.
135
+ */
136
+ interface PendingLongPoll {
137
+ /**
138
+ * Stream path.
139
+ */
140
+ path: string;
141
+ /**
142
+ * Offset to wait for.
143
+ */
144
+ offset: string;
145
+ /**
146
+ * Resolve function.
147
+ */
148
+ resolve: (messages: Array<StreamMessage>) => void;
149
+ /**
150
+ * Timeout ID.
151
+ */
152
+ timeoutId: ReturnType<typeof setTimeout>;
153
+ } //#endregion
154
+ //#region src/store.d.ts
155
+ /**
156
+ * In-memory store for durable streams.
157
+ */
158
+ declare class StreamStore {
159
+ private streams;
160
+ private pendingLongPolls;
161
+ /**
162
+ * Create a new stream.
163
+ * @throws Error if stream already exists with different config
164
+ * @returns existing stream if config matches (idempotent)
165
+ */
166
+ create(path: string, options?: {
167
+ contentType?: string;
168
+ ttlSeconds?: number;
169
+ expiresAt?: string;
170
+ initialData?: Uint8Array;
171
+ }): Stream;
172
+ /**
173
+ * Get a stream by path.
174
+ */
175
+ get(path: string): Stream | undefined;
176
+ /**
177
+ * Check if a stream exists.
178
+ */
179
+ has(path: string): boolean;
180
+ /**
181
+ * Delete a stream.
182
+ */
183
+ delete(path: string): boolean;
184
+ /**
185
+ * Append data to a stream.
186
+ * @throws Error if stream doesn't exist
187
+ * @throws Error if seq is lower than lastSeq
188
+ * @throws Error if JSON mode and array is empty
189
+ */
190
+ append(path: string, data: Uint8Array, options?: {
191
+ seq?: string;
192
+ contentType?: string;
193
+ }): StreamMessage;
194
+ /**
195
+ * Read messages from a stream starting at the given offset.
196
+ */
197
+ read(path: string, offset?: string): {
198
+ messages: Array<StreamMessage>;
199
+ upToDate: boolean;
200
+ };
201
+ /**
202
+ * Format messages for response.
203
+ * For JSON mode, wraps concatenated data in array brackets.
204
+ */
205
+ formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array;
206
+ /**
207
+ * Wait for new messages (long-poll).
208
+ */
209
+ waitForMessages(path: string, offset: string, timeoutMs: number): Promise<{
210
+ messages: Array<StreamMessage>;
211
+ timedOut: boolean;
212
+ }>;
213
+ /**
214
+ * Get the current offset for a stream.
215
+ */
216
+ getCurrentOffset(path: string): string | undefined;
217
+ /**
218
+ * Clear all streams.
219
+ */
220
+ clear(): void;
221
+ /**
222
+ * Cancel all pending long-polls (used during shutdown).
223
+ */
224
+ cancelAllWaits(): void;
225
+ /**
226
+ * Get all stream paths.
227
+ */
228
+ list(): Array<string>;
229
+ private appendToStream;
230
+ private findOffsetIndex;
231
+ private notifyLongPolls;
232
+ private cancelLongPollsForStream;
233
+ private removePendingLongPoll;
234
+ } //#endregion
235
+ //#region src/file-store.d.ts
236
+ interface FileBackedStreamStoreOptions {
237
+ dataDir: string;
238
+ maxFileHandles?: number;
239
+ }
240
+ /**
241
+ * File-backed implementation of StreamStore.
242
+ * Maintains the same interface as the in-memory StreamStore for drop-in compatibility.
243
+ */
244
+ declare class FileBackedStreamStore {
245
+ private db;
246
+ private fileManager;
247
+ private fileHandlePool;
248
+ private pendingLongPolls;
249
+ private dataDir;
250
+ constructor(options: FileBackedStreamStoreOptions);
251
+ /**
252
+ * Recover streams from disk on startup.
253
+ * Validates that LMDB metadata matches actual file contents and reconciles any mismatches.
254
+ */
255
+ private recover;
256
+ /**
257
+ * Scan a segment file to compute the true last offset.
258
+ * Handles partial/truncated messages at the end.
259
+ */
260
+ private scanFileForTrueOffset;
261
+ /**
262
+ * Convert LMDB metadata to Stream object.
263
+ */
264
+ private streamMetaToStream;
265
+ /**
266
+ * Close the store, closing all file handles and database.
267
+ * All data is already fsynced on each append, so no final flush needed.
268
+ */
269
+ close(): Promise<void>;
270
+ create(streamPath: string, options?: {
271
+ contentType?: string;
272
+ ttlSeconds?: number;
273
+ expiresAt?: string;
274
+ initialData?: Uint8Array;
275
+ }): Promise<Stream>;
276
+ get(streamPath: string): Stream | undefined;
277
+ has(streamPath: string): boolean;
278
+ delete(streamPath: string): boolean;
279
+ append(streamPath: string, data: Uint8Array, options?: {
280
+ seq?: string;
281
+ contentType?: string;
282
+ isInitialCreate?: boolean;
283
+ }): Promise<StreamMessage | null>;
284
+ read(streamPath: string, offset?: string): {
285
+ messages: Array<StreamMessage>;
286
+ upToDate: boolean;
287
+ };
288
+ waitForMessages(streamPath: string, offset: string, timeoutMs: number): Promise<{
289
+ messages: Array<StreamMessage>;
290
+ timedOut: boolean;
291
+ }>;
292
+ /**
293
+ * Format messages for response.
294
+ * For JSON mode, wraps concatenated data in array brackets.
295
+ */
296
+ formatResponse(streamPath: string, messages: Array<StreamMessage>): Uint8Array;
297
+ getCurrentOffset(streamPath: string): string | undefined;
298
+ clear(): void;
299
+ /**
300
+ * Cancel all pending long-polls (used during shutdown).
301
+ */
302
+ cancelAllWaits(): void;
303
+ list(): Array<string>;
304
+ private notifyLongPolls;
305
+ private cancelLongPollsForStream;
306
+ private removePendingLongPoll;
307
+ }
308
+
309
+ //#endregion
310
+ //#region src/server.d.ts
311
+ declare class DurableStreamTestServer {
312
+ readonly store: StreamStore | FileBackedStreamStore;
313
+ private server;
314
+ private options;
315
+ private _url;
316
+ private activeSSEResponses;
317
+ private isShuttingDown;
318
+ /** Injected errors for testing retry/resilience */
319
+ private injectedErrors;
320
+ constructor(options?: TestServerOptions);
321
+ /**
322
+ * Start the server.
323
+ */
324
+ start(): Promise<string>;
325
+ /**
326
+ * Stop the server.
327
+ */
328
+ stop(): Promise<void>;
329
+ /**
330
+ * Get the server URL.
331
+ */
332
+ get url(): string;
333
+ /**
334
+ * Clear all streams.
335
+ */
336
+ clear(): void;
337
+ /**
338
+ * Inject an error to be returned on the next N requests to a path.
339
+ * Used for testing retry/resilience behavior.
340
+ */
341
+ injectError(path: string, status: number, count?: number, retryAfter?: number): void;
342
+ /**
343
+ * Clear all injected errors.
344
+ */
345
+ clearInjectedErrors(): void;
346
+ /**
347
+ * Check if there's an injected error for this path and consume it.
348
+ * Returns the error config if one should be returned, null otherwise.
349
+ */
350
+ private consumeInjectedError;
351
+ private handleRequest;
352
+ /**
353
+ * Handle PUT - create stream
354
+ */
355
+ private handleCreate;
356
+ /**
357
+ * Handle HEAD - get metadata
358
+ */
359
+ private handleHead;
360
+ /**
361
+ * Handle GET - read data
362
+ */
363
+ private handleRead;
364
+ /**
365
+ * Handle SSE (Server-Sent Events) mode
366
+ */
367
+ private handleSSE;
368
+ /**
369
+ * Handle POST - append data
370
+ */
371
+ private handleAppend;
372
+ /**
373
+ * Handle DELETE - delete stream
374
+ */
375
+ private handleDelete;
376
+ /**
377
+ * Handle test control endpoints for error injection.
378
+ * POST /_test/inject-error - inject an error
379
+ * DELETE /_test/inject-error - clear all injected errors
380
+ */
381
+ private handleTestInjectError;
382
+ private readBody;
383
+ }
384
+
385
+ //#endregion
386
+ //#region src/path-encoding.d.ts
387
+ /**
388
+ * Encode a stream path to a filesystem-safe directory name using base64url encoding.
389
+ * Long paths (>200 chars) are hashed to keep directory names manageable.
390
+ *
391
+ * @example
392
+ * encodeStreamPath("/stream/users:created") → "L3N0cmVhbS91c2VyczpjcmVhdGVk"
393
+ */
394
+ declare function encodeStreamPath(path: string): string;
395
+ /**
396
+ * Decode a filesystem-safe directory name back to the original stream path.
397
+ *
398
+ * @example
399
+ * decodeStreamPath("L3N0cmVhbS91c2VyczpjcmVhdGVk") → "/stream/users:created"
400
+ */
401
+ declare function decodeStreamPath(encoded: string): string;
402
+
403
+ //#endregion
404
+ //#region src/registry-hook.d.ts
405
+ /**
406
+ * Creates lifecycle hooks that write to a __registry__ stream.
407
+ * Any client can read this stream to discover all streams and their lifecycle events.
408
+ */
409
+ declare function createRegistryHooks(store: StreamStore | FileBackedStreamStore, serverUrl: string): {
410
+ onStreamCreated: StreamLifecycleHook;
411
+ onStreamDeleted: StreamLifecycleHook;
412
+ };
413
+
414
+ //#endregion
415
+ //#region src/cursor.d.ts
416
+ /**
417
+ * Stream cursor calculation for CDN cache collapsing.
418
+ *
419
+ * This module implements interval-based cursor generation to prevent
420
+ * infinite CDN cache loops while enabling request collapsing.
421
+ *
422
+ * The mechanism works by:
423
+ * 1. Dividing time into fixed intervals (default 20 seconds)
424
+ * 2. Computing interval number from an epoch (October 9, 2024)
425
+ * 3. Returning cursor values that change at interval boundaries
426
+ * 4. Ensuring monotonic cursor progression (never going backwards)
427
+ */
428
+ /**
429
+ * Default epoch for cursor calculation: October 9, 2024 00:00:00 UTC.
430
+ * This is the reference point from which intervals are counted.
431
+ * Using a past date ensures cursors are always positive.
432
+ */
433
+ declare const DEFAULT_CURSOR_EPOCH: Date;
434
+ /**
435
+ * Default interval duration in seconds.
436
+ */
437
+ declare const DEFAULT_CURSOR_INTERVAL_SECONDS = 20;
438
+ /**
439
+ * Configuration options for cursor calculation.
440
+ */
441
+ interface CursorOptions {
442
+ /**
443
+ * Interval duration in seconds.
444
+ * Default: 20 seconds.
445
+ */
446
+ intervalSeconds?: number;
447
+ /**
448
+ * Epoch timestamp for interval calculation.
449
+ * Default: October 9, 2024 00:00:00 UTC.
450
+ */
451
+ epoch?: Date;
452
+ }
453
+ /**
454
+ * Calculate the current cursor value based on time intervals.
455
+ *
456
+ * @param options - Configuration for cursor calculation
457
+ * @returns The current cursor value as a string
458
+ */
459
+ declare function calculateCursor(options?: CursorOptions): string;
460
+ /**
461
+ * Generate a cursor for a response, ensuring monotonic progression.
462
+ *
463
+ * This function ensures the returned cursor is always greater than or equal
464
+ * to the current time interval, and strictly greater than any client-provided
465
+ * cursor. This prevents cache loops where a client could cycle between
466
+ * cursor values.
467
+ *
468
+ * Algorithm:
469
+ * - If no client cursor: return current interval
470
+ * - If client cursor < current interval: return current interval
471
+ * - If client cursor >= current interval: return client cursor + jitter
472
+ *
473
+ * This guarantees monotonic cursor progression and prevents A→B→A cycles.
474
+ *
475
+ * @param clientCursor - The cursor provided by the client (if any)
476
+ * @param options - Configuration for cursor calculation
477
+ * @returns The cursor value to include in the response
478
+ */
479
+ declare function generateResponseCursor(clientCursor: string | undefined, options?: CursorOptions): string;
480
+ /**
481
+ * Handle cursor collision by adding random jitter.
482
+ *
483
+ * @deprecated Use generateResponseCursor instead, which handles all cases
484
+ * including monotonicity guarantees.
485
+ *
486
+ * @param currentCursor - The newly calculated cursor value
487
+ * @param previousCursor - The cursor provided by the client (if any)
488
+ * @param options - Configuration for cursor calculation
489
+ * @returns The cursor value to return, with jitter applied if there's a collision
490
+ */
491
+ declare function handleCursorCollision(currentCursor: string, previousCursor: string | undefined, options?: CursorOptions): string;
492
+
493
+ //#endregion
494
+ export { CursorOptions, DEFAULT_CURSOR_EPOCH, DEFAULT_CURSOR_INTERVAL_SECONDS, DurableStreamTestServer, FileBackedStreamStore, PendingLongPoll, Stream, StreamLifecycleEvent, StreamLifecycleHook, StreamMessage, StreamStore, TestServerOptions, calculateCursor, createRegistryHooks, decodeStreamPath, encodeStreamPath, generateResponseCursor, handleCursorCollision };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Node.js reference server implementation for Durable Streams",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
@@ -39,15 +39,15 @@
39
39
  "dependencies": {
40
40
  "@neophi/sieve-cache": "^1.0.0",
41
41
  "lmdb": "^3.3.0",
42
- "@durable-streams/client": "0.1.1",
43
- "@durable-streams/state": "0.1.1"
42
+ "@durable-streams/client": "0.1.2",
43
+ "@durable-streams/state": "0.1.2"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/node": "^22.0.0",
47
47
  "tsdown": "^0.9.0",
48
48
  "typescript": "^5.0.0",
49
49
  "vitest": "^3.2.4",
50
- "@durable-streams/server-conformance-tests": "0.1.1"
50
+ "@durable-streams/server-conformance-tests": "0.1.2"
51
51
  },
52
52
  "files": [
53
53
  "dist",