@durable-streams/server 0.1.2 → 0.1.3

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 ADDED
@@ -0,0 +1,167 @@
1
+ # @durable-streams/server
2
+
3
+ Node.js reference server implementation for the Durable Streams protocol.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @durable-streams/server
9
+ ```
10
+
11
+ ## Overview
12
+
13
+ This package provides a reference implementation of the Durable Streams protocol for Node.js. It supports both in-memory and file-backed storage modes, making it suitable for development, testing, and production workloads.
14
+
15
+ For a standalone binary option, see the [Caddy-based server](https://github.com/durable-streams/durable-streams/releases).
16
+
17
+ ## Quick Start
18
+
19
+ ```typescript
20
+ import { DurableStreamTestServer } from "@durable-streams/server"
21
+
22
+ const server = new DurableStreamTestServer({
23
+ port: 4437,
24
+ host: "127.0.0.1",
25
+ })
26
+
27
+ await server.start()
28
+ console.log("Server running on http://127.0.0.1:4437")
29
+ ```
30
+
31
+ ## Storage Modes
32
+
33
+ ### In-Memory (Default)
34
+
35
+ Fast, ephemeral storage for development and testing:
36
+
37
+ ```typescript
38
+ import { DurableStreamTestServer, StreamStore } from "@durable-streams/server"
39
+
40
+ const store = new StreamStore()
41
+ const server = new DurableStreamTestServer({
42
+ port: 4437,
43
+ store,
44
+ })
45
+ ```
46
+
47
+ ### File-Backed
48
+
49
+ Persistent storage with streams stored as log files and LMDB for metadata:
50
+
51
+ ```typescript
52
+ import {
53
+ DurableStreamTestServer,
54
+ FileBackedStreamStore,
55
+ } from "@durable-streams/server"
56
+
57
+ const store = new FileBackedStreamStore({
58
+ path: "./data/streams",
59
+ })
60
+ const server = new DurableStreamTestServer({
61
+ port: 4437,
62
+ store,
63
+ })
64
+ ```
65
+
66
+ ## Registry Hooks
67
+
68
+ Track stream lifecycle events (creation, deletion):
69
+
70
+ ```typescript
71
+ import {
72
+ DurableStreamTestServer,
73
+ createRegistryHooks,
74
+ } from "@durable-streams/server"
75
+
76
+ const server = new DurableStreamTestServer({
77
+ port: 4437,
78
+ hooks: createRegistryHooks({
79
+ registryPath: "__registry__",
80
+ }),
81
+ })
82
+ ```
83
+
84
+ The registry maintains a system stream that tracks all stream creates and deletes, useful for building admin UIs or monitoring.
85
+
86
+ ## API
87
+
88
+ ### DurableStreamTestServer
89
+
90
+ ```typescript
91
+ interface TestServerOptions {
92
+ port?: number
93
+ host?: string
94
+ store?: StreamStore | FileBackedStreamStore
95
+ hooks?: StreamLifecycleHook[]
96
+ cors?: boolean
97
+ cursorOptions?: CursorOptions
98
+ }
99
+
100
+ class DurableStreamTestServer {
101
+ constructor(options?: TestServerOptions)
102
+ start(): Promise<void>
103
+ stop(): Promise<void>
104
+ readonly port: number
105
+ readonly baseUrl: string
106
+ }
107
+ ```
108
+
109
+ ### StreamStore
110
+
111
+ In-memory stream storage:
112
+
113
+ ```typescript
114
+ class StreamStore {
115
+ create(path: string, contentType: string, options?: CreateOptions): Stream
116
+ get(path: string): Stream | undefined
117
+ delete(path: string): boolean
118
+ append(path: string, data: Uint8Array, seq?: string): void
119
+ read(path: string, offset: string): ReadResult
120
+ }
121
+ ```
122
+
123
+ ### FileBackedStreamStore
124
+
125
+ File-backed persistent storage (log files for streams, LMDB for metadata) with the same interface as `StreamStore`.
126
+
127
+ ## Exports
128
+
129
+ ```typescript
130
+ export { DurableStreamTestServer } from "./server"
131
+ export { StreamStore } from "./store"
132
+ export { FileBackedStreamStore } from "./file-store"
133
+ export { encodeStreamPath, decodeStreamPath } from "./path-encoding"
134
+ export { createRegistryHooks } from "./registry-hook"
135
+ export {
136
+ calculateCursor,
137
+ handleCursorCollision,
138
+ generateResponseCursor,
139
+ DEFAULT_CURSOR_EPOCH,
140
+ DEFAULT_CURSOR_INTERVAL_SECONDS,
141
+ type CursorOptions,
142
+ } from "./cursor"
143
+ export type {
144
+ Stream,
145
+ StreamMessage,
146
+ TestServerOptions,
147
+ PendingLongPoll,
148
+ StreamLifecycleEvent,
149
+ StreamLifecycleHook,
150
+ } from "./types"
151
+ ```
152
+
153
+ ## Testing Your Implementation
154
+
155
+ Use the conformance test suite to validate protocol compliance:
156
+
157
+ ```typescript
158
+ import { runConformanceTests } from "@durable-streams/server-conformance-tests"
159
+
160
+ runConformanceTests({
161
+ baseUrl: "http://localhost:4437",
162
+ })
163
+ ```
164
+
165
+ ## License
166
+
167
+ Apache-2.0
package/dist/index.cjs CHANGED
@@ -88,12 +88,40 @@ var StreamStore = class {
88
88
  streams = new Map();
89
89
  pendingLongPolls = [];
90
90
  /**
91
+ * Check if a stream is expired based on TTL or Expires-At.
92
+ */
93
+ isExpired(stream) {
94
+ const now = Date.now();
95
+ if (stream.expiresAt) {
96
+ const expiryTime = new Date(stream.expiresAt).getTime();
97
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
98
+ }
99
+ if (stream.ttlSeconds !== void 0) {
100
+ const expiryTime = stream.createdAt + stream.ttlSeconds * 1e3;
101
+ if (now >= expiryTime) return true;
102
+ }
103
+ return false;
104
+ }
105
+ /**
106
+ * Get a stream, deleting it if expired.
107
+ * Returns undefined if stream doesn't exist or is expired.
108
+ */
109
+ getIfNotExpired(path) {
110
+ const stream = this.streams.get(path);
111
+ if (!stream) return void 0;
112
+ if (this.isExpired(stream)) {
113
+ this.delete(path);
114
+ return void 0;
115
+ }
116
+ return stream;
117
+ }
118
+ /**
91
119
  * Create a new stream.
92
120
  * @throws Error if stream already exists with different config
93
121
  * @returns existing stream if config matches (idempotent)
94
122
  */
95
123
  create(path, options = {}) {
96
- const existing = this.streams.get(path);
124
+ const existing = this.getIfNotExpired(path);
97
125
  if (existing) {
98
126
  const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
99
127
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
@@ -116,15 +144,16 @@ var StreamStore = class {
116
144
  }
117
145
  /**
118
146
  * Get a stream by path.
147
+ * Returns undefined if stream doesn't exist or is expired.
119
148
  */
120
149
  get(path) {
121
- return this.streams.get(path);
150
+ return this.getIfNotExpired(path);
122
151
  }
123
152
  /**
124
- * Check if a stream exists.
153
+ * Check if a stream exists (and is not expired).
125
154
  */
126
155
  has(path) {
127
- return this.streams.has(path);
156
+ return this.getIfNotExpired(path) !== void 0;
128
157
  }
129
158
  /**
130
159
  * Delete a stream.
@@ -135,12 +164,12 @@ var StreamStore = class {
135
164
  }
136
165
  /**
137
166
  * Append data to a stream.
138
- * @throws Error if stream doesn't exist
167
+ * @throws Error if stream doesn't exist or is expired
139
168
  * @throws Error if seq is lower than lastSeq
140
169
  * @throws Error if JSON mode and array is empty
141
170
  */
142
171
  append(path, data, options = {}) {
143
- const stream = this.streams.get(path);
172
+ const stream = this.getIfNotExpired(path);
144
173
  if (!stream) throw new Error(`Stream not found: ${path}`);
145
174
  if (options.contentType && stream.contentType) {
146
175
  const providedType = normalizeContentType(options.contentType);
@@ -157,9 +186,10 @@ var StreamStore = class {
157
186
  }
158
187
  /**
159
188
  * Read messages from a stream starting at the given offset.
189
+ * @throws Error if stream doesn't exist or is expired
160
190
  */
161
191
  read(path, offset) {
162
- const stream = this.streams.get(path);
192
+ const stream = this.getIfNotExpired(path);
163
193
  if (!stream) throw new Error(`Stream not found: ${path}`);
164
194
  if (!offset || offset === `-1`) return {
165
195
  messages: [...stream.messages],
@@ -178,9 +208,10 @@ var StreamStore = class {
178
208
  /**
179
209
  * Format messages for response.
180
210
  * For JSON mode, wraps concatenated data in array brackets.
211
+ * @throws Error if stream doesn't exist or is expired
181
212
  */
182
213
  formatResponse(path, messages) {
183
- const stream = this.streams.get(path);
214
+ const stream = this.getIfNotExpired(path);
184
215
  if (!stream) throw new Error(`Stream not found: ${path}`);
185
216
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
186
217
  const concatenated = new Uint8Array(totalSize);
@@ -194,9 +225,10 @@ var StreamStore = class {
194
225
  }
195
226
  /**
196
227
  * Wait for new messages (long-poll).
228
+ * @throws Error if stream doesn't exist or is expired
197
229
  */
198
230
  async waitForMessages(path, offset, timeoutMs) {
199
- const stream = this.streams.get(path);
231
+ const stream = this.getIfNotExpired(path);
200
232
  if (!stream) throw new Error(`Stream not found: ${path}`);
201
233
  const { messages } = this.read(path, offset);
202
234
  if (messages.length > 0) return {
@@ -229,9 +261,10 @@ var StreamStore = class {
229
261
  }
230
262
  /**
231
263
  * Get the current offset for a stream.
264
+ * Returns undefined if stream doesn't exist or is expired.
232
265
  */
233
266
  getCurrentOffset(path) {
234
- return this.streams.get(path)?.currentOffset;
267
+ return this.getIfNotExpired(path)?.currentOffset;
235
268
  }
236
269
  /**
237
270
  * Clear all streams.
@@ -605,6 +638,35 @@ var FileBackedStreamStore = class {
605
638
  };
606
639
  }
607
640
  /**
641
+ * Check if a stream is expired based on TTL or Expires-At.
642
+ */
643
+ isExpired(meta) {
644
+ const now = Date.now();
645
+ if (meta.expiresAt) {
646
+ const expiryTime = new Date(meta.expiresAt).getTime();
647
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
648
+ }
649
+ if (meta.ttlSeconds !== void 0) {
650
+ const expiryTime = meta.createdAt + meta.ttlSeconds * 1e3;
651
+ if (now >= expiryTime) return true;
652
+ }
653
+ return false;
654
+ }
655
+ /**
656
+ * Get stream metadata, deleting it if expired.
657
+ * Returns undefined if stream doesn't exist or is expired.
658
+ */
659
+ getMetaIfNotExpired(streamPath) {
660
+ const key = `stream:${streamPath}`;
661
+ const meta = this.db.get(key);
662
+ if (!meta) return void 0;
663
+ if (this.isExpired(meta)) {
664
+ this.delete(streamPath);
665
+ return void 0;
666
+ }
667
+ return meta;
668
+ }
669
+ /**
608
670
  * Close the store, closing all file handles and database.
609
671
  * All data is already fsynced on each append, so no final flush needed.
610
672
  */
@@ -613,8 +675,7 @@ var FileBackedStreamStore = class {
613
675
  await this.db.close();
614
676
  }
615
677
  async create(streamPath, options = {}) {
616
- const key = `stream:${streamPath}`;
617
- const existing = this.db.get(key);
678
+ const existing = this.getMetaIfNotExpired(streamPath);
618
679
  if (existing) {
619
680
  const normalizeMimeType = (ct) => (ct ?? `application/octet-stream`).toLowerCase();
620
681
  const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
@@ -623,6 +684,7 @@ var FileBackedStreamStore = class {
623
684
  if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
624
685
  else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
625
686
  }
687
+ const key = `stream:${streamPath}`;
626
688
  const streamMeta = {
627
689
  path: streamPath,
628
690
  contentType: options.contentType,
@@ -656,13 +718,11 @@ var FileBackedStreamStore = class {
656
718
  return this.streamMetaToStream(streamMeta);
657
719
  }
658
720
  get(streamPath) {
659
- const key = `stream:${streamPath}`;
660
- const meta = this.db.get(key);
721
+ const meta = this.getMetaIfNotExpired(streamPath);
661
722
  return meta ? this.streamMetaToStream(meta) : void 0;
662
723
  }
663
724
  has(streamPath) {
664
- const key = `stream:${streamPath}`;
665
- return this.db.get(key) !== void 0;
725
+ return this.getMetaIfNotExpired(streamPath) !== void 0;
666
726
  }
667
727
  delete(streamPath) {
668
728
  const key = `stream:${streamPath}`;
@@ -680,8 +740,7 @@ var FileBackedStreamStore = class {
680
740
  return true;
681
741
  }
682
742
  async append(streamPath, data, options = {}) {
683
- const key = `stream:${streamPath}`;
684
- const streamMeta = this.db.get(key);
743
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
685
744
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
686
745
  if (options.contentType && streamMeta.contentType) {
687
746
  const providedType = normalizeContentType(options.contentType);
@@ -729,13 +788,13 @@ var FileBackedStreamStore = class {
729
788
  lastSeq: options.seq ?? streamMeta.lastSeq,
730
789
  totalBytes: streamMeta.totalBytes + processedData.length + 5
731
790
  };
791
+ const key = `stream:${streamPath}`;
732
792
  this.db.putSync(key, updatedMeta);
733
793
  this.notifyLongPolls(streamPath);
734
794
  return message;
735
795
  }
736
796
  read(streamPath, offset) {
737
- const key = `stream:${streamPath}`;
738
- const streamMeta = this.db.get(key);
797
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
739
798
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
740
799
  const startOffset = offset ?? `0000000000000000_0000000000000000`;
741
800
  const startParts = startOffset.split(`_`).map(Number);
@@ -787,8 +846,7 @@ var FileBackedStreamStore = class {
787
846
  };
788
847
  }
789
848
  async waitForMessages(streamPath, offset, timeoutMs) {
790
- const key = `stream:${streamPath}`;
791
- const streamMeta = this.db.get(key);
849
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
792
850
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
793
851
  const { messages } = this.read(streamPath, offset);
794
852
  if (messages.length > 0) return {
@@ -822,10 +880,10 @@ var FileBackedStreamStore = class {
822
880
  /**
823
881
  * Format messages for response.
824
882
  * For JSON mode, wraps concatenated data in array brackets.
883
+ * @throws Error if stream doesn't exist or is expired
825
884
  */
826
885
  formatResponse(streamPath, messages) {
827
- const key = `stream:${streamPath}`;
828
- const streamMeta = this.db.get(key);
886
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
829
887
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
830
888
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
831
889
  const concatenated = new Uint8Array(totalSize);
@@ -838,8 +896,7 @@ var FileBackedStreamStore = class {
838
896
  return concatenated;
839
897
  }
840
898
  getCurrentOffset(streamPath) {
841
- const key = `stream:${streamPath}`;
842
- const streamMeta = this.db.get(key);
899
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
843
900
  return streamMeta?.currentOffset;
844
901
  }
845
902
  clear() {
package/dist/index.d.cts CHANGED
@@ -159,6 +159,15 @@ declare class StreamStore {
159
159
  private streams;
160
160
  private pendingLongPolls;
161
161
  /**
162
+ * Check if a stream is expired based on TTL or Expires-At.
163
+ */
164
+ private isExpired;
165
+ /**
166
+ * Get a stream, deleting it if expired.
167
+ * Returns undefined if stream doesn't exist or is expired.
168
+ */
169
+ private getIfNotExpired;
170
+ /**
162
171
  * Create a new stream.
163
172
  * @throws Error if stream already exists with different config
164
173
  * @returns existing stream if config matches (idempotent)
@@ -171,10 +180,11 @@ declare class StreamStore {
171
180
  }): Stream;
172
181
  /**
173
182
  * Get a stream by path.
183
+ * Returns undefined if stream doesn't exist or is expired.
174
184
  */
175
185
  get(path: string): Stream | undefined;
176
186
  /**
177
- * Check if a stream exists.
187
+ * Check if a stream exists (and is not expired).
178
188
  */
179
189
  has(path: string): boolean;
180
190
  /**
@@ -183,7 +193,7 @@ declare class StreamStore {
183
193
  delete(path: string): boolean;
184
194
  /**
185
195
  * Append data to a stream.
186
- * @throws Error if stream doesn't exist
196
+ * @throws Error if stream doesn't exist or is expired
187
197
  * @throws Error if seq is lower than lastSeq
188
198
  * @throws Error if JSON mode and array is empty
189
199
  */
@@ -193,6 +203,7 @@ declare class StreamStore {
193
203
  }): StreamMessage;
194
204
  /**
195
205
  * Read messages from a stream starting at the given offset.
206
+ * @throws Error if stream doesn't exist or is expired
196
207
  */
197
208
  read(path: string, offset?: string): {
198
209
  messages: Array<StreamMessage>;
@@ -201,10 +212,12 @@ declare class StreamStore {
201
212
  /**
202
213
  * Format messages for response.
203
214
  * For JSON mode, wraps concatenated data in array brackets.
215
+ * @throws Error if stream doesn't exist or is expired
204
216
  */
205
217
  formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array;
206
218
  /**
207
219
  * Wait for new messages (long-poll).
220
+ * @throws Error if stream doesn't exist or is expired
208
221
  */
209
222
  waitForMessages(path: string, offset: string, timeoutMs: number): Promise<{
210
223
  messages: Array<StreamMessage>;
@@ -212,6 +225,7 @@ declare class StreamStore {
212
225
  }>;
213
226
  /**
214
227
  * Get the current offset for a stream.
228
+ * Returns undefined if stream doesn't exist or is expired.
215
229
  */
216
230
  getCurrentOffset(path: string): string | undefined;
217
231
  /**
@@ -263,6 +277,15 @@ declare class FileBackedStreamStore {
263
277
  */
264
278
  private streamMetaToStream;
265
279
  /**
280
+ * Check if a stream is expired based on TTL or Expires-At.
281
+ */
282
+ private isExpired;
283
+ /**
284
+ * Get stream metadata, deleting it if expired.
285
+ * Returns undefined if stream doesn't exist or is expired.
286
+ */
287
+ private getMetaIfNotExpired;
288
+ /**
266
289
  * Close the store, closing all file handles and database.
267
290
  * All data is already fsynced on each append, so no final flush needed.
268
291
  */
@@ -292,6 +315,7 @@ declare class FileBackedStreamStore {
292
315
  /**
293
316
  * Format messages for response.
294
317
  * For JSON mode, wraps concatenated data in array brackets.
318
+ * @throws Error if stream doesn't exist or is expired
295
319
  */
296
320
  formatResponse(streamPath: string, messages: Array<StreamMessage>): Uint8Array;
297
321
  getCurrentOffset(streamPath: string): string | undefined;
package/dist/index.d.ts CHANGED
@@ -159,6 +159,15 @@ declare class StreamStore {
159
159
  private streams;
160
160
  private pendingLongPolls;
161
161
  /**
162
+ * Check if a stream is expired based on TTL or Expires-At.
163
+ */
164
+ private isExpired;
165
+ /**
166
+ * Get a stream, deleting it if expired.
167
+ * Returns undefined if stream doesn't exist or is expired.
168
+ */
169
+ private getIfNotExpired;
170
+ /**
162
171
  * Create a new stream.
163
172
  * @throws Error if stream already exists with different config
164
173
  * @returns existing stream if config matches (idempotent)
@@ -171,10 +180,11 @@ declare class StreamStore {
171
180
  }): Stream;
172
181
  /**
173
182
  * Get a stream by path.
183
+ * Returns undefined if stream doesn't exist or is expired.
174
184
  */
175
185
  get(path: string): Stream | undefined;
176
186
  /**
177
- * Check if a stream exists.
187
+ * Check if a stream exists (and is not expired).
178
188
  */
179
189
  has(path: string): boolean;
180
190
  /**
@@ -183,7 +193,7 @@ declare class StreamStore {
183
193
  delete(path: string): boolean;
184
194
  /**
185
195
  * Append data to a stream.
186
- * @throws Error if stream doesn't exist
196
+ * @throws Error if stream doesn't exist or is expired
187
197
  * @throws Error if seq is lower than lastSeq
188
198
  * @throws Error if JSON mode and array is empty
189
199
  */
@@ -193,6 +203,7 @@ declare class StreamStore {
193
203
  }): StreamMessage;
194
204
  /**
195
205
  * Read messages from a stream starting at the given offset.
206
+ * @throws Error if stream doesn't exist or is expired
196
207
  */
197
208
  read(path: string, offset?: string): {
198
209
  messages: Array<StreamMessage>;
@@ -201,10 +212,12 @@ declare class StreamStore {
201
212
  /**
202
213
  * Format messages for response.
203
214
  * For JSON mode, wraps concatenated data in array brackets.
215
+ * @throws Error if stream doesn't exist or is expired
204
216
  */
205
217
  formatResponse(path: string, messages: Array<StreamMessage>): Uint8Array;
206
218
  /**
207
219
  * Wait for new messages (long-poll).
220
+ * @throws Error if stream doesn't exist or is expired
208
221
  */
209
222
  waitForMessages(path: string, offset: string, timeoutMs: number): Promise<{
210
223
  messages: Array<StreamMessage>;
@@ -212,6 +225,7 @@ declare class StreamStore {
212
225
  }>;
213
226
  /**
214
227
  * Get the current offset for a stream.
228
+ * Returns undefined if stream doesn't exist or is expired.
215
229
  */
216
230
  getCurrentOffset(path: string): string | undefined;
217
231
  /**
@@ -263,6 +277,15 @@ declare class FileBackedStreamStore {
263
277
  */
264
278
  private streamMetaToStream;
265
279
  /**
280
+ * Check if a stream is expired based on TTL or Expires-At.
281
+ */
282
+ private isExpired;
283
+ /**
284
+ * Get stream metadata, deleting it if expired.
285
+ * Returns undefined if stream doesn't exist or is expired.
286
+ */
287
+ private getMetaIfNotExpired;
288
+ /**
266
289
  * Close the store, closing all file handles and database.
267
290
  * All data is already fsynced on each append, so no final flush needed.
268
291
  */
@@ -292,6 +315,7 @@ declare class FileBackedStreamStore {
292
315
  /**
293
316
  * Format messages for response.
294
317
  * For JSON mode, wraps concatenated data in array brackets.
318
+ * @throws Error if stream doesn't exist or is expired
295
319
  */
296
320
  formatResponse(streamPath: string, messages: Array<StreamMessage>): Uint8Array;
297
321
  getCurrentOffset(streamPath: string): string | undefined;
package/dist/index.js CHANGED
@@ -65,12 +65,40 @@ var StreamStore = class {
65
65
  streams = new Map();
66
66
  pendingLongPolls = [];
67
67
  /**
68
+ * Check if a stream is expired based on TTL or Expires-At.
69
+ */
70
+ isExpired(stream) {
71
+ const now = Date.now();
72
+ if (stream.expiresAt) {
73
+ const expiryTime = new Date(stream.expiresAt).getTime();
74
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
75
+ }
76
+ if (stream.ttlSeconds !== void 0) {
77
+ const expiryTime = stream.createdAt + stream.ttlSeconds * 1e3;
78
+ if (now >= expiryTime) return true;
79
+ }
80
+ return false;
81
+ }
82
+ /**
83
+ * Get a stream, deleting it if expired.
84
+ * Returns undefined if stream doesn't exist or is expired.
85
+ */
86
+ getIfNotExpired(path$2) {
87
+ const stream = this.streams.get(path$2);
88
+ if (!stream) return void 0;
89
+ if (this.isExpired(stream)) {
90
+ this.delete(path$2);
91
+ return void 0;
92
+ }
93
+ return stream;
94
+ }
95
+ /**
68
96
  * Create a new stream.
69
97
  * @throws Error if stream already exists with different config
70
98
  * @returns existing stream if config matches (idempotent)
71
99
  */
72
100
  create(path$2, options = {}) {
73
- const existing = this.streams.get(path$2);
101
+ const existing = this.getIfNotExpired(path$2);
74
102
  if (existing) {
75
103
  const contentTypeMatches = (normalizeContentType(options.contentType) || `application/octet-stream`) === (normalizeContentType(existing.contentType) || `application/octet-stream`);
76
104
  const ttlMatches = options.ttlSeconds === existing.ttlSeconds;
@@ -93,15 +121,16 @@ var StreamStore = class {
93
121
  }
94
122
  /**
95
123
  * Get a stream by path.
124
+ * Returns undefined if stream doesn't exist or is expired.
96
125
  */
97
126
  get(path$2) {
98
- return this.streams.get(path$2);
127
+ return this.getIfNotExpired(path$2);
99
128
  }
100
129
  /**
101
- * Check if a stream exists.
130
+ * Check if a stream exists (and is not expired).
102
131
  */
103
132
  has(path$2) {
104
- return this.streams.has(path$2);
133
+ return this.getIfNotExpired(path$2) !== void 0;
105
134
  }
106
135
  /**
107
136
  * Delete a stream.
@@ -112,12 +141,12 @@ var StreamStore = class {
112
141
  }
113
142
  /**
114
143
  * Append data to a stream.
115
- * @throws Error if stream doesn't exist
144
+ * @throws Error if stream doesn't exist or is expired
116
145
  * @throws Error if seq is lower than lastSeq
117
146
  * @throws Error if JSON mode and array is empty
118
147
  */
119
148
  append(path$2, data, options = {}) {
120
- const stream = this.streams.get(path$2);
149
+ const stream = this.getIfNotExpired(path$2);
121
150
  if (!stream) throw new Error(`Stream not found: ${path$2}`);
122
151
  if (options.contentType && stream.contentType) {
123
152
  const providedType = normalizeContentType(options.contentType);
@@ -134,9 +163,10 @@ var StreamStore = class {
134
163
  }
135
164
  /**
136
165
  * Read messages from a stream starting at the given offset.
166
+ * @throws Error if stream doesn't exist or is expired
137
167
  */
138
168
  read(path$2, offset) {
139
- const stream = this.streams.get(path$2);
169
+ const stream = this.getIfNotExpired(path$2);
140
170
  if (!stream) throw new Error(`Stream not found: ${path$2}`);
141
171
  if (!offset || offset === `-1`) return {
142
172
  messages: [...stream.messages],
@@ -155,9 +185,10 @@ var StreamStore = class {
155
185
  /**
156
186
  * Format messages for response.
157
187
  * For JSON mode, wraps concatenated data in array brackets.
188
+ * @throws Error if stream doesn't exist or is expired
158
189
  */
159
190
  formatResponse(path$2, messages) {
160
- const stream = this.streams.get(path$2);
191
+ const stream = this.getIfNotExpired(path$2);
161
192
  if (!stream) throw new Error(`Stream not found: ${path$2}`);
162
193
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
163
194
  const concatenated = new Uint8Array(totalSize);
@@ -171,9 +202,10 @@ var StreamStore = class {
171
202
  }
172
203
  /**
173
204
  * Wait for new messages (long-poll).
205
+ * @throws Error if stream doesn't exist or is expired
174
206
  */
175
207
  async waitForMessages(path$2, offset, timeoutMs) {
176
- const stream = this.streams.get(path$2);
208
+ const stream = this.getIfNotExpired(path$2);
177
209
  if (!stream) throw new Error(`Stream not found: ${path$2}`);
178
210
  const { messages } = this.read(path$2, offset);
179
211
  if (messages.length > 0) return {
@@ -206,9 +238,10 @@ var StreamStore = class {
206
238
  }
207
239
  /**
208
240
  * Get the current offset for a stream.
241
+ * Returns undefined if stream doesn't exist or is expired.
209
242
  */
210
243
  getCurrentOffset(path$2) {
211
- return this.streams.get(path$2)?.currentOffset;
244
+ return this.getIfNotExpired(path$2)?.currentOffset;
212
245
  }
213
246
  /**
214
247
  * Clear all streams.
@@ -582,6 +615,35 @@ var FileBackedStreamStore = class {
582
615
  };
583
616
  }
584
617
  /**
618
+ * Check if a stream is expired based on TTL or Expires-At.
619
+ */
620
+ isExpired(meta) {
621
+ const now = Date.now();
622
+ if (meta.expiresAt) {
623
+ const expiryTime = new Date(meta.expiresAt).getTime();
624
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) return true;
625
+ }
626
+ if (meta.ttlSeconds !== void 0) {
627
+ const expiryTime = meta.createdAt + meta.ttlSeconds * 1e3;
628
+ if (now >= expiryTime) return true;
629
+ }
630
+ return false;
631
+ }
632
+ /**
633
+ * Get stream metadata, deleting it if expired.
634
+ * Returns undefined if stream doesn't exist or is expired.
635
+ */
636
+ getMetaIfNotExpired(streamPath) {
637
+ const key = `stream:${streamPath}`;
638
+ const meta = this.db.get(key);
639
+ if (!meta) return void 0;
640
+ if (this.isExpired(meta)) {
641
+ this.delete(streamPath);
642
+ return void 0;
643
+ }
644
+ return meta;
645
+ }
646
+ /**
585
647
  * Close the store, closing all file handles and database.
586
648
  * All data is already fsynced on each append, so no final flush needed.
587
649
  */
@@ -590,8 +652,7 @@ var FileBackedStreamStore = class {
590
652
  await this.db.close();
591
653
  }
592
654
  async create(streamPath, options = {}) {
593
- const key = `stream:${streamPath}`;
594
- const existing = this.db.get(key);
655
+ const existing = this.getMetaIfNotExpired(streamPath);
595
656
  if (existing) {
596
657
  const normalizeMimeType = (ct) => (ct ?? `application/octet-stream`).toLowerCase();
597
658
  const contentTypeMatches = normalizeMimeType(options.contentType) === normalizeMimeType(existing.contentType);
@@ -600,6 +661,7 @@ var FileBackedStreamStore = class {
600
661
  if (contentTypeMatches && ttlMatches && expiresMatches) return this.streamMetaToStream(existing);
601
662
  else throw new Error(`Stream already exists with different configuration: ${streamPath}`);
602
663
  }
664
+ const key = `stream:${streamPath}`;
603
665
  const streamMeta = {
604
666
  path: streamPath,
605
667
  contentType: options.contentType,
@@ -633,13 +695,11 @@ var FileBackedStreamStore = class {
633
695
  return this.streamMetaToStream(streamMeta);
634
696
  }
635
697
  get(streamPath) {
636
- const key = `stream:${streamPath}`;
637
- const meta = this.db.get(key);
698
+ const meta = this.getMetaIfNotExpired(streamPath);
638
699
  return meta ? this.streamMetaToStream(meta) : void 0;
639
700
  }
640
701
  has(streamPath) {
641
- const key = `stream:${streamPath}`;
642
- return this.db.get(key) !== void 0;
702
+ return this.getMetaIfNotExpired(streamPath) !== void 0;
643
703
  }
644
704
  delete(streamPath) {
645
705
  const key = `stream:${streamPath}`;
@@ -657,8 +717,7 @@ var FileBackedStreamStore = class {
657
717
  return true;
658
718
  }
659
719
  async append(streamPath, data, options = {}) {
660
- const key = `stream:${streamPath}`;
661
- const streamMeta = this.db.get(key);
720
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
662
721
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
663
722
  if (options.contentType && streamMeta.contentType) {
664
723
  const providedType = normalizeContentType(options.contentType);
@@ -706,13 +765,13 @@ var FileBackedStreamStore = class {
706
765
  lastSeq: options.seq ?? streamMeta.lastSeq,
707
766
  totalBytes: streamMeta.totalBytes + processedData.length + 5
708
767
  };
768
+ const key = `stream:${streamPath}`;
709
769
  this.db.putSync(key, updatedMeta);
710
770
  this.notifyLongPolls(streamPath);
711
771
  return message;
712
772
  }
713
773
  read(streamPath, offset) {
714
- const key = `stream:${streamPath}`;
715
- const streamMeta = this.db.get(key);
774
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
716
775
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
717
776
  const startOffset = offset ?? `0000000000000000_0000000000000000`;
718
777
  const startParts = startOffset.split(`_`).map(Number);
@@ -764,8 +823,7 @@ var FileBackedStreamStore = class {
764
823
  };
765
824
  }
766
825
  async waitForMessages(streamPath, offset, timeoutMs) {
767
- const key = `stream:${streamPath}`;
768
- const streamMeta = this.db.get(key);
826
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
769
827
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
770
828
  const { messages } = this.read(streamPath, offset);
771
829
  if (messages.length > 0) return {
@@ -799,10 +857,10 @@ var FileBackedStreamStore = class {
799
857
  /**
800
858
  * Format messages for response.
801
859
  * For JSON mode, wraps concatenated data in array brackets.
860
+ * @throws Error if stream doesn't exist or is expired
802
861
  */
803
862
  formatResponse(streamPath, messages) {
804
- const key = `stream:${streamPath}`;
805
- const streamMeta = this.db.get(key);
863
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
806
864
  if (!streamMeta) throw new Error(`Stream not found: ${streamPath}`);
807
865
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0);
808
866
  const concatenated = new Uint8Array(totalSize);
@@ -815,8 +873,7 @@ var FileBackedStreamStore = class {
815
873
  return concatenated;
816
874
  }
817
875
  getCurrentOffset(streamPath) {
818
- const key = `stream:${streamPath}`;
819
- const streamMeta = this.db.get(key);
876
+ const streamMeta = this.getMetaIfNotExpired(streamPath);
820
877
  return streamMeta?.currentOffset;
821
878
  }
822
879
  clear() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@durable-streams/server",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Node.js reference server implementation for Durable Streams",
5
5
  "author": "Durable Stream contributors",
6
6
  "license": "Apache-2.0",
@@ -47,7 +47,7 @@
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.2"
50
+ "@durable-streams/server-conformance-tests": "0.1.3"
51
51
  },
52
52
  "files": [
53
53
  "dist",
package/src/file-store.ts CHANGED
@@ -332,6 +332,50 @@ export class FileBackedStreamStore {
332
332
  }
333
333
  }
334
334
 
335
+ /**
336
+ * Check if a stream is expired based on TTL or Expires-At.
337
+ */
338
+ private isExpired(meta: StreamMetadata): boolean {
339
+ const now = Date.now()
340
+
341
+ // Check absolute expiry time
342
+ if (meta.expiresAt) {
343
+ const expiryTime = new Date(meta.expiresAt).getTime()
344
+ // Treat invalid dates (NaN) as expired (fail closed)
345
+ if (!Number.isFinite(expiryTime) || now >= expiryTime) {
346
+ return true
347
+ }
348
+ }
349
+
350
+ // Check TTL (relative to creation time)
351
+ if (meta.ttlSeconds !== undefined) {
352
+ const expiryTime = meta.createdAt + meta.ttlSeconds * 1000
353
+ if (now >= expiryTime) {
354
+ return true
355
+ }
356
+ }
357
+
358
+ return false
359
+ }
360
+
361
+ /**
362
+ * Get stream metadata, deleting it if expired.
363
+ * Returns undefined if stream doesn't exist or is expired.
364
+ */
365
+ private getMetaIfNotExpired(streamPath: string): StreamMetadata | undefined {
366
+ const key = `stream:${streamPath}`
367
+ const meta = this.db.get(key) as StreamMetadata | undefined
368
+ if (!meta) {
369
+ return undefined
370
+ }
371
+ if (this.isExpired(meta)) {
372
+ // Delete expired stream
373
+ this.delete(streamPath)
374
+ return undefined
375
+ }
376
+ return meta
377
+ }
378
+
335
379
  /**
336
380
  * Close the store, closing all file handles and database.
337
381
  * All data is already fsynced on each append, so no final flush needed.
@@ -354,8 +398,8 @@ export class FileBackedStreamStore {
354
398
  initialData?: Uint8Array
355
399
  } = {}
356
400
  ): Promise<Stream> {
357
- const key = `stream:${streamPath}`
358
- const existing = this.db.get(key) as StreamMetadata | undefined
401
+ // Use getMetaIfNotExpired to treat expired streams as non-existent
402
+ const existing = this.getMetaIfNotExpired(streamPath)
359
403
 
360
404
  if (existing) {
361
405
  // Check if config matches (idempotent create)
@@ -379,6 +423,9 @@ export class FileBackedStreamStore {
379
423
  }
380
424
  }
381
425
 
426
+ // Define key for LMDB operations
427
+ const key = `stream:${streamPath}`
428
+
382
429
  // Initialize metadata
383
430
  const streamMeta: StreamMetadata = {
384
431
  path: streamPath,
@@ -430,14 +477,12 @@ export class FileBackedStreamStore {
430
477
  }
431
478
 
432
479
  get(streamPath: string): Stream | undefined {
433
- const key = `stream:${streamPath}`
434
- const meta = this.db.get(key) as StreamMetadata | undefined
480
+ const meta = this.getMetaIfNotExpired(streamPath)
435
481
  return meta ? this.streamMetaToStream(meta) : undefined
436
482
  }
437
483
 
438
484
  has(streamPath: string): boolean {
439
- const key = `stream:${streamPath}`
440
- return this.db.get(key) !== undefined
485
+ return this.getMetaIfNotExpired(streamPath) !== undefined
441
486
  }
442
487
 
443
488
  delete(streamPath: string): boolean {
@@ -489,8 +534,7 @@ export class FileBackedStreamStore {
489
534
  isInitialCreate?: boolean
490
535
  } = {}
491
536
  ): Promise<StreamMessage | null> {
492
- const key = `stream:${streamPath}`
493
- const streamMeta = this.db.get(key) as StreamMetadata | undefined
537
+ const streamMeta = this.getMetaIfNotExpired(streamPath)
494
538
 
495
539
  if (!streamMeta) {
496
540
  throw new Error(`Stream not found: ${streamPath}`)
@@ -583,6 +627,7 @@ export class FileBackedStreamStore {
583
627
  lastSeq: options.seq ?? streamMeta.lastSeq,
584
628
  totalBytes: streamMeta.totalBytes + processedData.length + 5, // +4 for length, +1 for newline
585
629
  }
630
+ const key = `stream:${streamPath}`
586
631
  this.db.putSync(key, updatedMeta)
587
632
 
588
633
  // 5. Notify long-polls (data is now readable from disk)
@@ -596,8 +641,7 @@ export class FileBackedStreamStore {
596
641
  streamPath: string,
597
642
  offset?: string
598
643
  ): { messages: Array<StreamMessage>; upToDate: boolean } {
599
- const key = `stream:${streamPath}`
600
- const streamMeta = this.db.get(key) as StreamMetadata | undefined
644
+ const streamMeta = this.getMetaIfNotExpired(streamPath)
601
645
 
602
646
  if (!streamMeta) {
603
647
  throw new Error(`Stream not found: ${streamPath}`)
@@ -690,8 +734,7 @@ export class FileBackedStreamStore {
690
734
  offset: string,
691
735
  timeoutMs: number
692
736
  ): Promise<{ messages: Array<StreamMessage>; timedOut: boolean }> {
693
- const key = `stream:${streamPath}`
694
- const streamMeta = this.db.get(key) as StreamMetadata | undefined
737
+ const streamMeta = this.getMetaIfNotExpired(streamPath)
695
738
 
696
739
  if (!streamMeta) {
697
740
  throw new Error(`Stream not found: ${streamPath}`)
@@ -729,13 +772,13 @@ export class FileBackedStreamStore {
729
772
  /**
730
773
  * Format messages for response.
731
774
  * For JSON mode, wraps concatenated data in array brackets.
775
+ * @throws Error if stream doesn't exist or is expired
732
776
  */
733
777
  formatResponse(
734
778
  streamPath: string,
735
779
  messages: Array<StreamMessage>
736
780
  ): Uint8Array {
737
- const key = `stream:${streamPath}`
738
- const streamMeta = this.db.get(key) as StreamMetadata | undefined
781
+ const streamMeta = this.getMetaIfNotExpired(streamPath)
739
782
 
740
783
  if (!streamMeta) {
741
784
  throw new Error(`Stream not found: ${streamPath}`)
@@ -759,8 +802,7 @@ export class FileBackedStreamStore {
759
802
  }
760
803
 
761
804
  getCurrentOffset(streamPath: string): string | undefined {
762
- const key = `stream:${streamPath}`
763
- const streamMeta = this.db.get(key) as StreamMetadata | undefined
805
+ const streamMeta = this.getMetaIfNotExpired(streamPath)
764
806
  return streamMeta?.currentOffset
765
807
  }
766
808
 
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
- const existing = this.streams.get(path)
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.streams.get(path)
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.streams.has(path)
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.streams.get(path)
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.streams.get(path)
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.streams.get(path)
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.streams.get(path)
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.streams.get(path)?.currentOffset
369
+ return this.getIfNotExpired(path)?.currentOffset
321
370
  }
322
371
 
323
372
  /**