@durable-streams/server 0.3.2 → 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/dist/index.cjs +1297 -260
- package/dist/index.d.cts +236 -2
- package/dist/index.d.ts +236 -2
- package/dist/index.js +1344 -312
- package/package.json +3 -3
- package/src/crypto.ts +217 -0
- package/src/file-store.ts +187 -144
- package/src/glob.ts +70 -0
- package/src/index.ts +14 -0
- package/src/log.ts +56 -0
- package/src/server.ts +75 -26
- package/src/store.ts +59 -7
- package/src/subscription-manager.ts +882 -0
- package/src/subscription-routes.ts +504 -0
- package/src/subscription-types.ts +80 -0
- package/src/types.ts +8 -0
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
|
-
|
|
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
|
-
|
|
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 = {
|