@durable-streams/server 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/file-store.ts CHANGED
@@ -8,10 +8,10 @@ import * as path from "node:path"
8
8
  import { randomBytes } from "node:crypto"
9
9
  import { open as openLMDB } from "lmdb"
10
10
  import { SieveCache } from "@neophi/sieve-cache"
11
- import { StreamFileManager } from "./file-manager"
11
+ import { serverLog } from "./log"
12
12
  import { encodeStreamPath } from "./path-encoding"
13
13
  import {
14
- formatJsonResponse,
14
+ formatJsonMessages,
15
15
  normalizeContentType,
16
16
  processJsonAppend,
17
17
  } from "./store"
@@ -54,7 +54,7 @@ interface StreamMetadata {
54
54
  segmentCount: number
55
55
  totalBytes: number
56
56
  /**
57
- * Unique directory name for this stream instance.
57
+ * Unique file stem for this stream instance.
58
58
  * Format: {encoded_path}~{timestamp}~{random_hex}
59
59
  * This allows safe async deletion and immediate reuse of stream paths.
60
60
  */
@@ -104,6 +104,15 @@ interface StreamMetadata {
104
104
  */
105
105
  interface PooledHandle {
106
106
  stream: fs.WriteStream
107
+ /**
108
+ * Coalesced fsync leader. When set and `scheduled` is true, new fsync callers
109
+ * piggyback on this batch; when `scheduled` is false the batch has already
110
+ * entered its syscall and new callers must start a new batch.
111
+ */
112
+ syncLeader: {
113
+ promise: Promise<void>
114
+ scheduled: boolean
115
+ } | null
107
116
  }
108
117
 
109
118
  class FileHandlePool {
@@ -114,7 +123,7 @@ class FileHandlePool {
114
123
  evictHook: (_key: string, handle: PooledHandle) => {
115
124
  // Close the handle when evicted (sync version - fire and forget)
116
125
  this.closeHandle(handle).catch((err: Error) => {
117
- console.error(`[FileHandlePool] Error closing evicted handle:`, err)
126
+ serverLog.error(`[FileHandlePool] Error closing evicted handle:`, err)
118
127
  })
119
128
  },
120
129
  })
@@ -125,49 +134,80 @@ class FileHandlePool {
125
134
 
126
135
  if (!handle) {
127
136
  const stream = fs.createWriteStream(filePath, { flags: `a` })
128
- handle = { stream }
137
+ handle = { stream, syncLeader: null }
129
138
  this.cache.set(filePath, handle)
130
139
  }
131
140
 
132
141
  return handle.stream
133
142
  }
134
143
 
144
+ /**
145
+ * Open a write stream eagerly so the first write does not pay the lazy
146
+ * `open()` stall. Resolves once the underlying fd is ready.
147
+ */
148
+ async openWriteStream(filePath: string): Promise<fs.WriteStream> {
149
+ const stream = this.getWriteStream(filePath)
150
+ const fd = (stream as unknown as { fd: number | null }).fd
151
+ if (typeof fd === `number`) return stream
152
+ await new Promise<void>((resolve, reject) => {
153
+ stream.once(`open`, () => resolve())
154
+ stream.once(`error`, (err) => reject(err))
155
+ })
156
+ return stream
157
+ }
158
+
135
159
  /**
136
160
  * Flush a specific file to disk immediately.
137
- * This is called after each append to ensure durability.
161
+ * Concurrent callers on the same fd share one in-flight fdatasync: the
162
+ * first caller issues the syscall, later arrivals during that window wait
163
+ * for it to finish and then issue a fresh syscall (because their writes
164
+ * may have landed after the in-flight syscall started). This preserves
165
+ * durability without adding scheduling latency.
138
166
  */
139
- async fsyncFile(filePath: string): Promise<void> {
167
+ fsyncFile(filePath: string): Promise<void> {
140
168
  const handle = this.cache.get(filePath)
141
- if (!handle) return
142
-
143
- return new Promise<void>((resolve, reject) => {
144
- // Use fdatasync (faster than fsync, skips metadata)
145
- // Cast to any to access fd property (exists at runtime but not in types)
146
- const fd = (handle.stream as any).fd
147
-
148
- // If fd is null, stream hasn't been opened yet - wait for open event
149
- if (typeof fd !== `number`) {
150
- const onOpen = (openedFd: number): void => {
151
- handle.stream.off(`error`, onError)
152
- fs.fdatasync(openedFd, (err) => {
153
- if (err) reject(err)
154
- else resolve()
155
- })
156
- }
157
- const onError = (err: Error): void => {
158
- handle.stream.off(`open`, onOpen)
159
- reject(err)
160
- }
161
- handle.stream.once(`open`, onOpen)
162
- handle.stream.once(`error`, onError)
163
- return
164
- }
169
+ if (!handle) {
170
+ return Promise.reject(
171
+ new Error(
172
+ `[FileHandlePool] Cannot fsync: handle not found for ${filePath}`
173
+ )
174
+ )
175
+ }
176
+
177
+ const existing = handle.syncLeader
178
+ if (existing && existing.scheduled) {
179
+ return existing.promise
180
+ }
181
+
182
+ let resolveFn!: () => void
183
+ let rejectFn!: (err: Error) => void
184
+ const promise = new Promise<void>((res, rej) => {
185
+ resolveFn = res
186
+ rejectFn = rej
187
+ })
188
+ const leader = { promise, scheduled: true }
189
+ handle.syncLeader = leader
165
190
 
191
+ const runSyscall = (fd: number): void => {
192
+ leader.scheduled = false
166
193
  fs.fdatasync(fd, (err) => {
167
- if (err) reject(err)
168
- else resolve()
194
+ if (handle.syncLeader === leader) handle.syncLeader = null
195
+ if (err) rejectFn(err)
196
+ else resolveFn()
169
197
  })
170
- })
198
+ }
199
+
200
+ const fd = (handle.stream as unknown as { fd: number | null }).fd
201
+ if (typeof fd === `number`) {
202
+ runSyscall(fd)
203
+ } else {
204
+ handle.stream.once(`open`, (openedFd: number) => runSyscall(openedFd))
205
+ handle.stream.once(`error`, (err: Error) => {
206
+ if (handle.syncLeader === leader) handle.syncLeader = null
207
+ rejectFn(err)
208
+ })
209
+ }
210
+ return promise
171
211
  }
172
212
 
173
213
  async closeAll(): Promise<void> {
@@ -212,18 +252,21 @@ export interface FileBackedStreamStoreOptions {
212
252
  */
213
253
  function generateUniqueDirectoryName(streamPath: string): string {
214
254
  const encoded = encodeStreamPath(streamPath)
215
- const timestamp = Date.now().toString(36) // Base36 for shorter strings
216
- const random = randomBytes(4).toString(`hex`) // 8 chars hex
255
+ const timestamp = Date.now().toString(36)
256
+ const random = randomBytes(4).toString(`hex`)
217
257
  return `${encoded}~${timestamp}~${random}`
218
258
  }
219
259
 
260
+ function segmentFile(dataDir: string, dirName: string): string {
261
+ return path.join(dataDir, `streams`, `${dirName}.log`)
262
+ }
263
+
220
264
  /**
221
265
  * File-backed implementation of StreamStore.
222
266
  * Maintains the same interface as the in-memory StreamStore for drop-in compatibility.
223
267
  */
224
268
  export class FileBackedStreamStore {
225
269
  private db: Database
226
- private fileManager: StreamFileManager
227
270
  private fileHandlePool: FileHandlePool
228
271
  private pendingLongPolls: Array<PendingLongPoll> = []
229
272
  private dataDir: string
@@ -247,10 +290,13 @@ export class FileBackedStreamStore {
247
290
  this.db = openLMDB({
248
291
  path: path.join(this.dataDir, `metadata.lmdb`),
249
292
  compression: true,
293
+ noMemInit: true,
294
+ cache: true,
295
+ sharedStructuresKey: Symbol.for(`structures`),
250
296
  })
251
297
 
252
- // Initialize file manager
253
- this.fileManager = new StreamFileManager(path.join(this.dataDir, `streams`))
298
+ // Pre-create the streams directory
299
+ fs.mkdirSync(path.join(this.dataDir, `streams`), { recursive: true })
254
300
 
255
301
  // Initialize file handle pool with SIEVE cache
256
302
  const maxFileHandles = options.maxFileHandles ?? 100
@@ -265,7 +311,7 @@ export class FileBackedStreamStore {
265
311
  * Validates that LMDB metadata matches actual file contents and reconciles any mismatches.
266
312
  */
267
313
  private recover(): void {
268
- console.log(`[FileBackedStreamStore] Starting recovery...`)
314
+ serverLog.info(`[FileBackedStreamStore] Starting recovery...`)
269
315
 
270
316
  let recovered = 0
271
317
  let reconciled = 0
@@ -288,17 +334,11 @@ export class FileBackedStreamStore {
288
334
  const streamMeta = value as StreamMetadata
289
335
  const streamPath = key.replace(`stream:`, ``)
290
336
 
291
- // Get segment file path
292
- const segmentPath = path.join(
293
- this.dataDir,
294
- `streams`,
295
- streamMeta.directoryName,
296
- `segment_00000.log`
297
- )
337
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
298
338
 
299
339
  // Check if file exists
300
340
  if (!fs.existsSync(segmentPath)) {
301
- console.warn(
341
+ serverLog.warn(
302
342
  `[FileBackedStreamStore] Recovery: Stream file missing for ${streamPath}, removing from LMDB`
303
343
  )
304
344
  this.db.removeSync(key)
@@ -323,7 +363,7 @@ export class FileBackedStreamStore {
323
363
 
324
364
  // Check if offset matches
325
365
  if (trueOffset !== streamMeta.currentOffset) {
326
- console.warn(
366
+ serverLog.warn(
327
367
  `[FileBackedStreamStore] Recovery: Offset mismatch for ${streamPath}: ` +
328
368
  `LMDB says ${streamMeta.currentOffset}, file says ${trueOffset}. Reconciling to file.`
329
369
  )
@@ -339,12 +379,12 @@ export class FileBackedStreamStore {
339
379
 
340
380
  recovered++
341
381
  } catch (err) {
342
- console.error(`[FileBackedStreamStore] Error recovering stream:`, err)
382
+ serverLog.error(`[FileBackedStreamStore] Error recovering stream:`, err)
343
383
  errors++
344
384
  }
345
385
  }
346
386
 
347
- console.log(
387
+ serverLog.info(
348
388
  `[FileBackedStreamStore] Recovery complete: ${recovered} streams, ` +
349
389
  `${reconciled} reconciled, ${errors} errors`
350
390
  )
@@ -358,39 +398,21 @@ export class FileBackedStreamStore {
358
398
  try {
359
399
  const fileContent = fs.readFileSync(segmentPath)
360
400
  let filePos = 0
361
- let currentDataOffset = 0
362
401
 
363
402
  while (filePos < fileContent.length) {
364
- // Read message length (4 bytes)
365
- if (filePos + 4 > fileContent.length) {
366
- // Truncated length header - stop here
367
- break
368
- }
403
+ if (filePos + 4 > fileContent.length) break
369
404
 
370
405
  const messageLength = fileContent.readUInt32BE(filePos)
371
- filePos += 4
372
-
373
- // Check if we have the full message
374
- if (filePos + messageLength > fileContent.length) {
375
- // Truncated message data - stop here
376
- break
377
- }
378
-
379
- filePos += messageLength
406
+ const frameEnd = filePos + 4 + messageLength + 1
380
407
 
381
- // Skip newline
382
- if (filePos < fileContent.length) {
383
- filePos += 1
384
- }
408
+ if (frameEnd > fileContent.length) break
385
409
 
386
- // Update offset with this complete message
387
- currentDataOffset += messageLength
410
+ filePos = frameEnd
388
411
  }
389
412
 
390
- // Return offset in format "readSeq_byteOffset" with zero-padding
391
- return `0000000000000000_${String(currentDataOffset).padStart(16, `0`)}`
413
+ return `0000000000000000_${String(filePos).padStart(16, `0`)}`
392
414
  } catch (err) {
393
- console.error(
415
+ serverLog.error(
394
416
  `[FileBackedStreamStore] Error scanning file ${segmentPath}:`,
395
417
  err
396
418
  )
@@ -829,6 +851,8 @@ export class FileBackedStreamStore {
829
851
  // Define key for LMDB operations
830
852
  const key = `stream:${streamPath}`
831
853
 
854
+ const t0 = performance.now()
855
+
832
856
  // Initialize metadata
833
857
  // Note: We set closed to false initially, then set it true after appending initial data
834
858
  // This prevents the closed check from rejecting the initial append
@@ -850,17 +874,11 @@ export class FileBackedStreamStore {
850
874
  refCount: 0,
851
875
  }
852
876
 
853
- // Create stream directory and empty segment file immediately
854
- // This ensures the stream is fully initialized and can be recovered
855
- const streamDir = path.join(
856
- this.dataDir,
857
- `streams`,
858
- streamMeta.directoryName
859
- )
877
+ const tAfterMeta = performance.now()
878
+
879
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
860
880
  try {
861
- fs.mkdirSync(streamDir, { recursive: true })
862
- const segmentPath = path.join(streamDir, `segment_00000.log`)
863
- fs.writeFileSync(segmentPath, ``)
881
+ await this.db.put(key, streamMeta)
864
882
  } catch (err) {
865
883
  // Rollback source refcount on failure
866
884
  if (isFork && sourceMeta) {
@@ -874,15 +892,24 @@ export class FileBackedStreamStore {
874
892
  this.db.putSync(sourceKey, updatedSource)
875
893
  }
876
894
  }
877
- console.error(
878
- `[FileBackedStreamStore] Error creating stream directory:`,
895
+ serverLog.error(
896
+ `[FileBackedStreamStore] Error creating stream (LMDB put):`,
879
897
  err
880
898
  )
881
899
  throw err
882
900
  }
883
-
884
- // Save to LMDB
885
- this.db.putSync(key, streamMeta)
901
+ const tAfterLmdb = performance.now()
902
+ try {
903
+ await this.fileHandlePool.openWriteStream(segmentPath)
904
+ } catch (err) {
905
+ this.db.removeSync(key)
906
+ serverLog.error(
907
+ `[FileBackedStreamStore] Error creating stream (file open):`,
908
+ err
909
+ )
910
+ throw err
911
+ }
912
+ const tAfterOpen = performance.now()
886
913
 
887
914
  // Append initial data if provided
888
915
  if (options.initialData && options.initialData.length > 0) {
@@ -909,16 +936,33 @@ export class FileBackedStreamStore {
909
936
  throw err
910
937
  }
911
938
  }
939
+ const tAfterAppend = performance.now()
912
940
 
913
941
  // Now set closed flag if requested (after initial append succeeded)
914
942
  if (options.closed) {
915
943
  const updatedMeta = this.db.get(key) as StreamMetadata
916
944
  updatedMeta.closed = true
917
- this.db.putSync(key, updatedMeta)
945
+ await this.db.put(key, updatedMeta)
918
946
  }
919
947
 
920
948
  // Re-fetch updated metadata
921
949
  const updated = this.db.get(key) as StreamMetadata
950
+ const totalMs = performance.now() - t0
951
+ if (totalMs > 50) {
952
+ serverLog.event(
953
+ {
954
+ event: `store.create`,
955
+ path: streamPath,
956
+ totalMs: +totalMs.toFixed(2),
957
+ metaMs: +(tAfterMeta - t0).toFixed(2),
958
+ lmdbMs: +(tAfterLmdb - tAfterMeta).toFixed(2),
959
+ openMs: +(tAfterOpen - tAfterLmdb).toFixed(2),
960
+ appendMs: +(tAfterAppend - tAfterOpen).toFixed(2),
961
+ initBytes: options.initialData?.length ?? 0,
962
+ },
963
+ `store.create slow`
964
+ )
965
+ }
922
966
  return this.streamMetaToStream(updated)
923
967
  }
924
968
 
@@ -975,26 +1019,18 @@ export class FileBackedStreamStore {
975
1019
  // Cancel any pending long-polls for this stream
976
1020
  this.cancelLongPollsForStream(streamPath)
977
1021
 
978
- // Close any open file handle for this stream's segment file
979
- const segmentPath = path.join(
980
- this.dataDir,
981
- `streams`,
982
- streamMeta.directoryName,
983
- `segment_00000.log`
984
- )
985
- this.fileHandlePool.closeFileHandle(segmentPath).catch((err: Error) => {
986
- console.error(`[FileBackedStreamStore] Error closing file handle:`, err)
987
- })
1022
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
988
1023
 
989
1024
  // Delete from LMDB
990
1025
  this.db.removeSync(key)
991
1026
 
992
- // Delete files using unique directory name (async, but don't wait)
993
- this.fileManager
994
- .deleteDirectoryByName(streamMeta.directoryName)
1027
+ // Close handle then delete file (chained to avoid EBUSY on Windows)
1028
+ this.fileHandlePool
1029
+ .closeFileHandle(segmentPath)
1030
+ .then(() => fs.promises.unlink(segmentPath))
995
1031
  .catch((err: Error) => {
996
- console.error(
997
- `[FileBackedStreamStore] Error deleting stream directory:`,
1032
+ serverLog.error(
1033
+ `[FileBackedStreamStore] Error cleaning up stream file:`,
998
1034
  err
999
1035
  )
1000
1036
  })
@@ -1144,17 +1180,13 @@ export class FileBackedStreamStore {
1144
1180
  const readSeq = parts[0]!
1145
1181
  const byteOffset = parts[1]!
1146
1182
 
1147
- // Calculate new offset with zero-padding for lexicographic sorting (only data bytes, not framing)
1148
- const newByteOffset = byteOffset + processedData.length
1183
+ const FRAME_OVERHEAD = 5 // 4-byte length prefix + 1-byte newline
1184
+ const newByteOffset = byteOffset + FRAME_OVERHEAD + processedData.length
1149
1185
  const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
1150
1186
 
1151
- // Get segment file path (directory was created in create())
1152
- const streamDir = path.join(
1153
- this.dataDir,
1154
- `streams`,
1155
- streamMeta.directoryName
1156
- )
1157
- const segmentPath = path.join(streamDir, `segment_00000.log`)
1187
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
1188
+
1189
+ const tAppendStart = performance.now()
1158
1190
 
1159
1191
  // Get write stream from pool
1160
1192
  const stream = this.fileHandlePool.getWriteStream(segmentPath)
@@ -1176,6 +1208,8 @@ export class FileBackedStreamStore {
1176
1208
  })
1177
1209
  })
1178
1210
 
1211
+ const tAfterWrite = performance.now()
1212
+
1179
1213
  // 2. Create message object for return value
1180
1214
  const message: StreamMessage = {
1181
1215
  data: processedData,
@@ -1186,6 +1220,8 @@ export class FileBackedStreamStore {
1186
1220
  // 3. Flush to disk (blocks here until durable)
1187
1221
  await this.fileHandlePool.fsyncFile(segmentPath)
1188
1222
 
1223
+ const tAfterFsync = performance.now()
1224
+
1189
1225
  // 4. Update LMDB metadata atomically (only after flush, so metadata reflects durability)
1190
1226
  // This includes both the offset update and producer state update
1191
1227
  // Producer state is committed HERE (not in validateProducer) for atomicity
@@ -1214,7 +1250,25 @@ export class FileBackedStreamStore {
1214
1250
  closedBy: closedBy ?? streamMeta.closedBy,
1215
1251
  }
1216
1252
  const key = `stream:${streamPath}`
1217
- this.db.putSync(key, updatedMeta)
1253
+ await this.db.put(key, updatedMeta)
1254
+
1255
+ const tAfterLmdb = performance.now()
1256
+ const appendTotal = tAfterLmdb - tAppendStart
1257
+ if (appendTotal > 50) {
1258
+ serverLog.event(
1259
+ {
1260
+ event: `store.append`,
1261
+ path: streamPath,
1262
+ totalMs: +appendTotal.toFixed(2),
1263
+ writeMs: +(tAfterWrite - tAppendStart).toFixed(2),
1264
+ fsyncMs: +(tAfterFsync - tAfterWrite).toFixed(2),
1265
+ lmdbMs: +(tAfterLmdb - tAfterFsync).toFixed(2),
1266
+ bytes: processedData.length,
1267
+ isInitial: options.isInitialCreate ?? false,
1268
+ },
1269
+ `store.append slow`
1270
+ )
1271
+ }
1218
1272
 
1219
1273
  // 5. Notify long-polls (data is now readable from disk)
1220
1274
  this.notifyLongPolls(streamPath)
@@ -1390,7 +1444,7 @@ export class FileBackedStreamStore {
1390
1444
  },
1391
1445
  producers: updatedProducers,
1392
1446
  }
1393
- this.db.putSync(key, updatedMeta)
1447
+ await this.db.put(key, updatedMeta)
1394
1448
 
1395
1449
  // Notify any pending long-polls
1396
1450
  this.notifyLongPollsClosed(streamPath)
@@ -1449,8 +1503,10 @@ export class FileBackedStreamStore {
1449
1503
  // Skip newline
1450
1504
  filePos += 1
1451
1505
 
1452
- // Calculate this message's logical offset (end position)
1453
- physicalDataOffset += messageLength
1506
+ // Calculate this message's logical offset (end position).
1507
+ // Frames in our file layout are 4-byte length + data + 1-byte newline,
1508
+ // and stream offsets advance by the full frame size — see append().
1509
+ physicalDataOffset += messageLength + 5
1454
1510
  const logicalOffset = baseByteOffset + physicalDataOffset
1455
1511
 
1456
1512
  // Stop if we've exceeded the cap
@@ -1468,7 +1524,10 @@ export class FileBackedStreamStore {
1468
1524
  }
1469
1525
  }
1470
1526
  } catch (err) {
1471
- console.error(`[FileBackedStreamStore] Error reading segment file:`, err)
1527
+ serverLog.error(
1528
+ `[FileBackedStreamStore] Error reading segment file:`,
1529
+ err
1530
+ )
1472
1531
  }
1473
1532
 
1474
1533
  return messages
@@ -1512,12 +1571,7 @@ export class FileBackedStreamStore {
1512
1571
  // Read source's own segment file
1513
1572
  // For a fork source, its own data starts at physical byte 0 in its segment file,
1514
1573
  // but the logical offsets need to account for its own forkOffset base
1515
- const segmentPath = path.join(
1516
- this.dataDir,
1517
- `streams`,
1518
- sourceMeta.directoryName,
1519
- `segment_00000.log`
1520
- )
1574
+ const segmentPath = segmentFile(this.dataDir, sourceMeta.directoryName)
1521
1575
 
1522
1576
  // The base offset for this source's own data is its forkOffset (if it's a fork) or 0
1523
1577
  const sourceBaseByte = sourceMeta.forkOffset
@@ -1578,12 +1632,7 @@ export class FileBackedStreamStore {
1578
1632
 
1579
1633
  // Read fork's own segment file with offset translation
1580
1634
  // Physical bytes in file start at 0, but logical offsets start at forkOffset
1581
- const segmentPath = path.join(
1582
- this.dataDir,
1583
- `streams`,
1584
- streamMeta.directoryName,
1585
- `segment_00000.log`
1586
- )
1635
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
1587
1636
  const ownMessages = this.readMessagesFromSegmentFile(
1588
1637
  segmentPath,
1589
1638
  startByte,
@@ -1592,12 +1641,7 @@ export class FileBackedStreamStore {
1592
1641
  messages.push(...ownMessages)
1593
1642
  } else {
1594
1643
  // Non-forked stream: read from segment file directly
1595
- const segmentPath = path.join(
1596
- this.dataDir,
1597
- `streams`,
1598
- streamMeta.directoryName,
1599
- `segment_00000.log`
1600
- )
1644
+ const segmentPath = segmentFile(this.dataDir, streamMeta.directoryName)
1601
1645
  const ownMessages = this.readMessagesFromSegmentFile(
1602
1646
  segmentPath,
1603
1647
  startByte,
@@ -1701,6 +1745,10 @@ export class FileBackedStreamStore {
1701
1745
  throw new Error(`Stream not found: ${streamPath}`)
1702
1746
  }
1703
1747
 
1748
+ if (normalizeContentType(streamMeta.contentType) === `application/json`) {
1749
+ return formatJsonMessages(messages)
1750
+ }
1751
+
1704
1752
  // Concatenate all message data
1705
1753
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)
1706
1754
  const concatenated = new Uint8Array(totalSize)
@@ -1710,11 +1758,6 @@ export class FileBackedStreamStore {
1710
1758
  offset += msg.data.length
1711
1759
  }
1712
1760
 
1713
- // For JSON mode, wrap in array brackets
1714
- if (normalizeContentType(streamMeta.contentType) === `application/json`) {
1715
- return formatJsonResponse(concatenated)
1716
- }
1717
-
1718
1761
  return concatenated
1719
1762
  }
1720
1763
 
@@ -1747,7 +1790,7 @@ export class FileBackedStreamStore {
1747
1790
 
1748
1791
  // Clear file handle pool
1749
1792
  this.fileHandlePool.closeAll().catch((err: Error) => {
1750
- console.error(`[FileBackedStreamStore] Error closing handles:`, err)
1793
+ serverLog.error(`[FileBackedStreamStore] Error closing handles:`, err)
1751
1794
  })
1752
1795
 
1753
1796
  // Note: Files are not deleted in clear() with unique directory names
package/src/glob.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Glob pattern matching for webhook subscription patterns.
3
+ *
4
+ * Supports:
5
+ * - `*` matches exactly one path segment
6
+ * - `**` matches zero or more path segments (recursive)
7
+ * - Literal segments match exactly
8
+ */
9
+
10
+ /**
11
+ * Match a stream path against a glob pattern.
12
+ */
13
+ export function globMatch(pattern: string, path: string): boolean {
14
+ const patternParts = splitPath(pattern)
15
+ const pathParts = splitPath(path)
16
+ return matchParts(patternParts, 0, pathParts, 0)
17
+ }
18
+
19
+ function splitPath(p: string): Array<string> {
20
+ // Normalize: remove leading/trailing slashes, split on /
21
+ return p
22
+ .replace(/^\/+/, ``)
23
+ .replace(/\/+$/, ``)
24
+ .split(`/`)
25
+ .filter((s) => s.length > 0)
26
+ }
27
+
28
+ function matchParts(
29
+ pattern: Array<string>,
30
+ pi: number,
31
+ path: Array<string>,
32
+ si: number
33
+ ): boolean {
34
+ while (pi < pattern.length && si < path.length) {
35
+ const seg = pattern[pi]!
36
+
37
+ if (seg === `**`) {
38
+ // ** matches zero or more segments
39
+ // Try matching rest of pattern against every possible suffix of path
40
+ for (let i = si; i <= path.length; i++) {
41
+ if (matchParts(pattern, pi + 1, path, i)) {
42
+ return true
43
+ }
44
+ }
45
+ return false
46
+ }
47
+
48
+ if (seg === `*`) {
49
+ // * matches exactly one segment
50
+ pi++
51
+ si++
52
+ continue
53
+ }
54
+
55
+ // Literal match (also handle %2A as *)
56
+ const decodedSeg = seg.replace(/%2[Aa]/g, `*`)
57
+ if (decodedSeg !== path[si]) {
58
+ return false
59
+ }
60
+ pi++
61
+ si++
62
+ }
63
+
64
+ // Handle trailing ** which matches zero segments
65
+ while (pi < pattern.length && pattern[pi] === `**`) {
66
+ pi++
67
+ }
68
+
69
+ return pi === pattern.length && si === path.length
70
+ }
package/src/index.ts CHANGED
@@ -25,3 +25,17 @@ export type {
25
25
  StreamLifecycleEvent,
26
26
  StreamLifecycleHook,
27
27
  } from "./types"
28
+ export { SubscriptionManager, validateWebhookUrl } from "./subscription-manager"
29
+ export { SubscriptionRoutes } from "./subscription-routes"
30
+ export type {
31
+ SubscriptionCallbackRequest,
32
+ SubscriptionCreateInput,
33
+ SubscriptionError,
34
+ SubscriptionErrorCode,
35
+ SubscriptionRecord,
36
+ SubscriptionStatus,
37
+ SubscriptionStreamInfo,
38
+ SubscriptionStreamLink,
39
+ SubscriptionType,
40
+ } from "./subscription-types"
41
+ export { globMatch } from "./glob"