@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/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`:
@@ -699,6 +727,10 @@ export class DurableStreamTestServer {
699
727
  )
700
728
  }
701
729
 
730
+ if (isNew && body.length > 0) {
731
+ await this.notifyStreamAppend(path)
732
+ }
733
+
702
734
  // Return 201 for new streams, 200 for idempotent creates
703
735
  const headers: Record<string, string> = {
704
736
  "content-type": resolvedContentType,
@@ -1515,6 +1547,8 @@ export class DurableStreamTestServer {
1515
1547
  const statusCode = producerId !== undefined ? 200 : 204
1516
1548
  res.writeHead(statusCode, responseHeaders)
1517
1549
  res.end()
1550
+
1551
+ await this.notifyStreamAppend(path)
1518
1552
  return
1519
1553
  }
1520
1554
 
@@ -1577,6 +1611,17 @@ export class DurableStreamTestServer {
1577
1611
  }
1578
1612
  res.writeHead(204, responseHeaders)
1579
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
+ }
1580
1625
  }
1581
1626
 
1582
1627
  /**
@@ -1609,6 +1654,10 @@ export class DurableStreamTestServer {
1609
1654
  )
1610
1655
  }
1611
1656
 
1657
+ if (this.subscriptionManager) {
1658
+ this.subscriptionManager.onStreamDeleted(path)
1659
+ }
1660
+
1612
1661
  res.writeHead(204)
1613
1662
  res.end()
1614
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
  */
@@ -1056,6 +1109,10 @@ export class StreamStore {
1056
1109
  throw new Error(`Stream not found: ${path}`)
1057
1110
  }
1058
1111
 
1112
+ if (normalizeContentType(stream.contentType) === `application/json`) {
1113
+ return formatJsonMessages(messages)
1114
+ }
1115
+
1059
1116
  // Concatenate all message data
1060
1117
  const totalSize = messages.reduce((sum, m) => sum + m.data.length, 0)
1061
1118
  const concatenated = new Uint8Array(totalSize)
@@ -1065,11 +1122,6 @@ export class StreamStore {
1065
1122
  offset += msg.data.length
1066
1123
  }
1067
1124
 
1068
- // For JSON mode, wrap in array brackets
1069
- if (normalizeContentType(stream.contentType) === `application/json`) {
1070
- return formatJsonResponse(concatenated)
1071
- }
1072
-
1073
1125
  return concatenated
1074
1126
  }
1075
1127
 
@@ -1204,8 +1256,8 @@ export class StreamStore {
1204
1256
  const readSeq = parts[0]!
1205
1257
  const byteOffset = parts[1]!
1206
1258
 
1207
- // Calculate new offset with zero-padding for lexicographic sorting
1208
- 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
1209
1261
  const newOffset = `${String(readSeq).padStart(16, `0`)}_${String(newByteOffset).padStart(16, `0`)}`
1210
1262
 
1211
1263
  const message: StreamMessage = {