@durable-streams/server 0.3.1 → 0.3.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/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"
package/src/log.ts ADDED
@@ -0,0 +1,56 @@
1
+ const streamsLogFile = process.env.STREAMS_LOG_FILE
2
+
3
+ async function appendLogLine(line: string): Promise<void> {
4
+ if (!streamsLogFile) return
5
+ const fs = await import(`node:fs/promises`)
6
+ const path = await import(`node:path`)
7
+ await fs.mkdir(path.dirname(streamsLogFile), { recursive: true })
8
+ await fs.appendFile(streamsLogFile, `${line}\n`)
9
+ }
10
+
11
+ function serializeArg(arg: unknown): string {
12
+ if (arg instanceof Error) {
13
+ return arg.stack ?? arg.message
14
+ }
15
+ if (typeof arg === `string`) {
16
+ return arg
17
+ }
18
+ try {
19
+ return JSON.stringify(arg)
20
+ } catch {
21
+ return String(arg)
22
+ }
23
+ }
24
+
25
+ function write(level: `info` | `warn` | `error`, args: Array<unknown>): void {
26
+ const line = args.map(serializeArg).join(` `)
27
+ const formatted = `[${level}] ${line}`
28
+
29
+ if (level === `error`) {
30
+ console.error(formatted)
31
+ } else if (level === `warn`) {
32
+ console.warn(formatted)
33
+ } else {
34
+ console.info(formatted)
35
+ }
36
+
37
+ void appendLogLine(formatted).catch(() => undefined)
38
+ }
39
+
40
+ export const serverLog = {
41
+ info(...args: Array<unknown>): void {
42
+ write(`info`, args)
43
+ },
44
+
45
+ warn(...args: Array<unknown>): void {
46
+ write(`warn`, args)
47
+ },
48
+
49
+ error(...args: Array<unknown>): void {
50
+ write(`error`, args)
51
+ },
52
+
53
+ event(obj: Record<string, unknown>, msg: string): void {
54
+ write(`info`, [msg, obj])
55
+ },
56
+ }
package/src/server.ts CHANGED
@@ -4,47 +4,45 @@
4
4
 
5
5
  import { createServer } from "node:http"
6
6
  import { deflateSync, gzipSync } from "node:zlib"
7
+ import {
8
+ CURSOR_QUERY_PARAM,
9
+ LIVE_QUERY_PARAM,
10
+ OFFSET_QUERY_PARAM,
11
+ PRODUCER_EPOCH_HEADER,
12
+ PRODUCER_EXPECTED_SEQ_HEADER,
13
+ PRODUCER_ID_HEADER,
14
+ PRODUCER_RECEIVED_SEQ_HEADER,
15
+ PRODUCER_SEQ_HEADER,
16
+ SSE_CLOSED_FIELD,
17
+ SSE_CURSOR_FIELD,
18
+ SSE_OFFSET_FIELD,
19
+ STREAM_CLOSED_HEADER,
20
+ STREAM_CURSOR_HEADER,
21
+ STREAM_EXPIRES_AT_HEADER,
22
+ STREAM_OFFSET_HEADER,
23
+ STREAM_SEQ_HEADER,
24
+ STREAM_TTL_HEADER,
25
+ STREAM_UP_TO_DATE_HEADER,
26
+ } from "@durable-streams/client"
7
27
  import { StreamStore } from "./store"
8
28
  import { FileBackedStreamStore } from "./file-store"
9
29
  import { generateResponseCursor } from "./cursor"
30
+ import { SubscriptionManager } from "./subscription-manager"
31
+ import { SubscriptionRoutes } from "./subscription-routes"
32
+ import { serverLog } from "./log"
10
33
  import type { CursorOptions } from "./cursor"
11
34
  import type { IncomingMessage, Server, ServerResponse } from "node:http"
12
35
  import type { StreamLifecycleEvent, TestServerOptions } from "./types"
13
36
 
14
- // Protocol headers (aligned with PROTOCOL.md)
15
- const STREAM_OFFSET_HEADER = `Stream-Next-Offset`
16
- const STREAM_CURSOR_HEADER = `Stream-Cursor`
17
- const STREAM_UP_TO_DATE_HEADER = `Stream-Up-To-Date`
18
- const STREAM_SEQ_HEADER = `Stream-Seq`
19
- const STREAM_TTL_HEADER = `Stream-TTL`
20
- const STREAM_EXPIRES_AT_HEADER = `Stream-Expires-At`
21
37
  const STREAM_SSE_DATA_ENCODING_HEADER = `Stream-SSE-Data-Encoding`
22
38
 
23
- // Idempotent producer headers
24
- const PRODUCER_ID_HEADER = `Producer-Id`
25
- const PRODUCER_EPOCH_HEADER = `Producer-Epoch`
26
- const PRODUCER_SEQ_HEADER = `Producer-Seq`
27
- const PRODUCER_EXPECTED_SEQ_HEADER = `Producer-Expected-Seq`
28
- const PRODUCER_RECEIVED_SEQ_HEADER = `Producer-Received-Seq`
29
-
30
39
  // SSE control event fields (Protocol Section 5.7)
31
- const SSE_OFFSET_FIELD = `streamNextOffset`
32
- const SSE_CURSOR_FIELD = `streamCursor`
33
40
  const SSE_UP_TO_DATE_FIELD = `upToDate`
34
- const SSE_CLOSED_FIELD = `streamClosed`
35
-
36
- // Stream closure header
37
- const STREAM_CLOSED_HEADER = `Stream-Closed`
38
41
 
39
42
  // Fork headers (request headers only — not set on responses)
40
43
  const STREAM_FORKED_FROM_HEADER = `Stream-Forked-From`
41
44
  const STREAM_FORK_OFFSET_HEADER = `Stream-Fork-Offset`
42
45
 
43
- // Query params
44
- const OFFSET_QUERY_PARAM = `offset`
45
- const LIVE_QUERY_PARAM = `live`
46
- const CURSOR_QUERY_PARAM = `cursor`
47
-
48
46
  /**
49
47
  * Encode data for SSE format.
50
48
  * Per SSE spec, each line in the payload needs its own "data:" prefix.
@@ -161,6 +159,7 @@ export class DurableStreamTestServer {
161
159
  | `compression`
162
160
  | `cursorIntervalSeconds`
163
161
  | `cursorEpoch`
162
+ | `webhooks`
164
163
  >
165
164
  > & {
166
165
  dataDir?: string
@@ -168,12 +167,15 @@ export class DurableStreamTestServer {
168
167
  onStreamDeleted?: (event: StreamLifecycleEvent) => void | Promise<void>
169
168
  compression: boolean
170
169
  cursorOptions: CursorOptions
170
+ webhooks: boolean
171
171
  }
172
172
  private _url: string | null = null
173
173
  private activeSSEResponses = new Set<ServerResponse>()
174
174
  private isShuttingDown = false
175
175
  /** Injected faults for testing retry/resilience */
176
176
  private injectedFaults = new Map<string, InjectedFault>()
177
+ private subscriptionManager: SubscriptionManager | null = null
178
+ private subscriptionRoutes: SubscriptionRoutes | null = null
177
179
 
178
180
  constructor(options: TestServerOptions = {}) {
179
181
  // Choose store based on dataDir option
@@ -197,6 +199,7 @@ export class DurableStreamTestServer {
197
199
  intervalSeconds: options.cursorIntervalSeconds,
198
200
  epoch: options.cursorEpoch,
199
201
  },
202
+ webhooks: options.webhooks ?? false,
200
203
  }
201
204
  }
202
205
 
@@ -211,7 +214,7 @@ export class DurableStreamTestServer {
211
214
  return new Promise((resolve, reject) => {
212
215
  this.server = createServer((req, res) => {
213
216
  this.handleRequest(req, res).catch((err) => {
214
- console.error(`Request error:`, err)
217
+ serverLog.error(`Request error:`, err)
215
218
  if (!res.headersSent) {
216
219
  res.writeHead(500, { "content-type": `text/plain` })
217
220
  res.end(`Internal server error`)
@@ -228,6 +231,15 @@ export class DurableStreamTestServer {
228
231
  } else if (addr) {
229
232
  this._url = `http://${this.options.host}:${addr.port}`
230
233
  }
234
+
235
+ this.subscriptionManager = new SubscriptionManager({
236
+ callbackBaseUrl: this._url!,
237
+ streamStore: this.store,
238
+ webhooksEnabled: this.options.webhooks,
239
+ })
240
+ this.subscriptionRoutes = new SubscriptionRoutes(
241
+ this.subscriptionManager
242
+ )
231
243
  resolve(this._url!)
232
244
  })
233
245
  })
@@ -244,6 +256,12 @@ export class DurableStreamTestServer {
244
256
  // Mark as shutting down to stop SSE handlers
245
257
  this.isShuttingDown = true
246
258
 
259
+ if (this.subscriptionManager) {
260
+ this.subscriptionManager.shutdown()
261
+ this.subscriptionManager = null
262
+ this.subscriptionRoutes = null
263
+ }
264
+
247
265
  // Cancel all pending long-polls and SSE waits to unblock connection handlers
248
266
  if (`cancelAllWaits` in this.store) {
249
267
  ;(this.store as { cancelAllWaits: () => void }).cancelAllWaits()
@@ -492,6 +510,16 @@ export class DurableStreamTestServer {
492
510
  }
493
511
  }
494
512
 
513
+ if (this.subscriptionRoutes && method) {
514
+ const handled = await this.subscriptionRoutes.handleRequest(
515
+ method,
516
+ path,
517
+ req,
518
+ res
519
+ )
520
+ if (handled) return
521
+ }
522
+
495
523
  try {
496
524
  switch (method) {
497
525
  case `PUT`:
@@ -562,13 +590,24 @@ export class DurableStreamTestServer {
562
590
  ): Promise<void> {
563
591
  let contentType = req.headers[`content-type`]
564
592
 
565
- // Sanitize content-type: if empty or invalid, use default
593
+ // Parse fork headers (must come before content-type sanitization so
594
+ // forks can fall through to the store's content-type inheritance)
595
+ const forkedFromHeader = req.headers[
596
+ STREAM_FORKED_FROM_HEADER.toLowerCase()
597
+ ] as string | undefined
598
+ const forkOffsetHeader = req.headers[
599
+ STREAM_FORK_OFFSET_HEADER.toLowerCase()
600
+ ] as string | undefined
601
+
602
+ // Sanitize content-type: if empty or invalid, use default — but only
603
+ // for non-fork creates. For forks, an omitted Content-Type means "inherit
604
+ // from source", which is resolved by the store.
566
605
  if (
567
606
  !contentType ||
568
607
  contentType.trim() === `` ||
569
608
  !/^[\w-]+\/[\w-]+/.test(contentType)
570
609
  ) {
571
- contentType = `application/octet-stream`
610
+ contentType = forkedFromHeader ? undefined : `application/octet-stream`
572
611
  }
573
612
 
574
613
  const ttlHeader = req.headers[STREAM_TTL_HEADER.toLowerCase()] as
@@ -582,14 +621,6 @@ export class DurableStreamTestServer {
582
621
  const closedHeader = req.headers[STREAM_CLOSED_HEADER.toLowerCase()]
583
622
  const createClosed = closedHeader === `true`
584
623
 
585
- // Parse fork headers
586
- const forkedFromHeader = req.headers[
587
- STREAM_FORKED_FROM_HEADER.toLowerCase()
588
- ] as string | undefined
589
- const forkOffsetHeader = req.headers[
590
- STREAM_FORK_OFFSET_HEADER.toLowerCase()
591
- ] as string | undefined
592
-
593
624
  // Validate TTL and Expires-At headers
594
625
  if (ttlHeader && expiresAtHeader) {
595
626
  res.writeHead(400, { "content-type": `text/plain` })
@@ -681,6 +712,8 @@ export class DurableStreamTestServer {
681
712
  }
682
713
 
683
714
  const stream = this.store.get(path)!
715
+ const resolvedContentType =
716
+ stream.contentType ?? contentType ?? `application/octet-stream`
684
717
 
685
718
  // Call lifecycle hook for new streams
686
719
  if (isNew && this.options.onStreamCreated) {
@@ -688,15 +721,19 @@ export class DurableStreamTestServer {
688
721
  this.options.onStreamCreated({
689
722
  type: `created`,
690
723
  path,
691
- contentType: stream.contentType ?? contentType,
724
+ contentType: resolvedContentType,
692
725
  timestamp: Date.now(),
693
726
  })
694
727
  )
695
728
  }
696
729
 
730
+ if (isNew && body.length > 0) {
731
+ await this.notifyStreamAppend(path)
732
+ }
733
+
697
734
  // Return 201 for new streams, 200 for idempotent creates
698
735
  const headers: Record<string, string> = {
699
- "content-type": stream.contentType ?? contentType,
736
+ "content-type": resolvedContentType,
700
737
  [STREAM_OFFSET_HEADER]: stream.currentOffset,
701
738
  }
702
739
 
@@ -1182,8 +1219,10 @@ export class DurableStreamTestServer {
1182
1219
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
1183
1220
  if (this.isShuttingDown || !isConnected) break
1184
1221
 
1185
- // Check if stream was closed during wait
1186
- if (result.streamClosed) {
1222
+ // Check if stream was closed during wait. If the close also appended
1223
+ // final data, let the next loop iteration deliver those messages
1224
+ // before emitting the streamClosed control event.
1225
+ if (result.streamClosed && result.messages.length === 0) {
1187
1226
  const finalControlData: Record<string, string | boolean> = {
1188
1227
  [SSE_OFFSET_FIELD]: currentOffset,
1189
1228
  [SSE_CLOSED_FIELD]: true,
@@ -1508,6 +1547,8 @@ export class DurableStreamTestServer {
1508
1547
  const statusCode = producerId !== undefined ? 200 : 204
1509
1548
  res.writeHead(statusCode, responseHeaders)
1510
1549
  res.end()
1550
+
1551
+ await this.notifyStreamAppend(path)
1511
1552
  return
1512
1553
  }
1513
1554
 
@@ -1570,6 +1611,17 @@ export class DurableStreamTestServer {
1570
1611
  }
1571
1612
  res.writeHead(204, responseHeaders)
1572
1613
  res.end()
1614
+
1615
+ await this.notifyStreamAppend(path)
1616
+ }
1617
+
1618
+ private async notifyStreamAppend(path: string): Promise<void> {
1619
+ if (!this.subscriptionManager) return
1620
+ try {
1621
+ await this.subscriptionManager.onStreamAppend(path)
1622
+ } catch (err) {
1623
+ serverLog.error(`[server] subscription append hook failed:`, err)
1624
+ }
1573
1625
  }
1574
1626
 
1575
1627
  /**
@@ -1602,6 +1654,10 @@ export class DurableStreamTestServer {
1602
1654
  )
1603
1655
  }
1604
1656
 
1657
+ if (this.subscriptionManager) {
1658
+ this.subscriptionManager.onStreamDeleted(path)
1659
+ }
1660
+
1605
1661
  res.writeHead(204)
1606
1662
  res.end()
1607
1663
  }
package/src/store.ts CHANGED
@@ -86,6 +86,59 @@ export function formatJsonResponse(data: Uint8Array): Uint8Array {
86
86
  return new TextEncoder().encode(wrapped)
87
87
  }
88
88
 
89
+ function decodeStoredJsonMessage(data: Uint8Array): string {
90
+ let text = new TextDecoder().decode(data).trimEnd()
91
+ if (text.endsWith(`,`)) {
92
+ text = text.slice(0, -1)
93
+ }
94
+ return text
95
+ }
96
+
97
+ function enrichJsonValueWithOffset(parsed: unknown, offset: string): string {
98
+ if (!parsed || typeof parsed !== `object` || Array.isArray(parsed)) {
99
+ return JSON.stringify(parsed)
100
+ }
101
+
102
+ const candidate = parsed as {
103
+ headers?: Record<string, unknown>
104
+ }
105
+ const headers = candidate.headers
106
+
107
+ if (!headers || typeof headers !== `object`) {
108
+ return JSON.stringify(parsed)
109
+ }
110
+
111
+ const isStateChange = typeof headers.operation === `string`
112
+ const isStateControl = typeof headers.control === `string`
113
+ if (!isStateChange && !isStateControl) {
114
+ return JSON.stringify(parsed)
115
+ }
116
+
117
+ return JSON.stringify({
118
+ ...candidate,
119
+ headers: {
120
+ ...headers,
121
+ offset,
122
+ },
123
+ })
124
+ }
125
+
126
+ export function formatJsonMessages(messages: Array<StreamMessage>): Uint8Array {
127
+ if (messages.length === 0) {
128
+ return new TextEncoder().encode(`[]`)
129
+ }
130
+
131
+ const items = messages.flatMap((message) => {
132
+ const rawFragment = decodeStoredJsonMessage(message.data)
133
+ const parsed = JSON.parse(`[${rawFragment}]`) as Array<unknown>
134
+ return parsed.map((value) =>
135
+ enrichJsonValueWithOffset(value, message.offset)
136
+ )
137
+ })
138
+
139
+ return new TextEncoder().encode(`[${items.join(`,`)}]`)
140
+ }
141
+
89
142
  /**
90
143
  * In-memory store for durable streams.
91
144
  */
@@ -723,13 +776,17 @@ export class StreamStore {
723
776
  seq: options.producerSeq!,
724
777
  }
725
778
  }
726
- // Notify pending long-polls that stream is closed
727
- this.notifyLongPollsClosed(path)
728
779
  }
729
780
 
730
- // Notify any pending long-polls of new messages
781
+ // Notify pending long-polls of new messages before empty close signals.
782
+ // Append-and-close must deliver the final message with streamClosed
783
+ // metadata instead of waking readers with an empty close event first.
731
784
  this.notifyLongPolls(path)
732
785
 
786
+ if (options.close) {
787
+ this.notifyLongPollsClosed(path)
788
+ }
789
+
733
790
  // Return AppendResult if producer headers were used or stream was closed
734
791
  if (producerResult || options.close) {
735
792
  return {
@@ -1052,6 +1109,10 @@ export class StreamStore {
1052
1109
  throw new Error(`Stream not found: ${path}`)
1053
1110
  }
1054
1111
 
1112
+ if (normalizeContentType(stream.contentType) === `application/json`) {
1113
+ return formatJsonMessages(messages)
1114
+ }
1115
+
1055
1116
  // Concatenate all message data
1056
1117
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)
1057
1118
  const concatenated = new Uint8Array(totalSize)
@@ -1061,11 +1122,6 @@ export class StreamStore {
1061
1122
  offset += msg.data.length
1062
1123
  }
1063
1124
 
1064
- // For JSON mode, wrap in array brackets
1065
- if (normalizeContentType(stream.contentType) === `application/json`) {
1066
- return formatJsonResponse(concatenated)
1067
- }
1068
-
1069
1125
  return concatenated
1070
1126
  }
1071
1127
 
@@ -1200,8 +1256,8 @@ export class StreamStore {
1200
1256
  const readSeq = parts[0]!
1201
1257
  const byteOffset = parts[1]!
1202
1258
 
1203
- // Calculate new offset with zero-padding for lexicographic sorting
1204
- const newByteOffset = byteOffset + processedData.length
1259
+ const FRAME_OVERHEAD = 5 // 4-byte length prefix + 1-byte newline
1260
+ const newByteOffset = byteOffset + FRAME_OVERHEAD + processedData.length
1205
1261
  const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
1206
1262
 
1207
1263
  const message: StreamMessage = {