@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/dist/index.cjs +1344 -266
- package/dist/index.d.cts +258 -2
- package/dist/index.d.ts +258 -2
- package/dist/index.js +1391 -318
- package/package.json +4 -4
- package/src/crypto.ts +217 -0
- package/src/file-store.ts +239 -144
- package/src/glob.ts +70 -0
- package/src/index.ts +14 -0
- package/src/log.ts +56 -0
- package/src/server.ts +96 -40
- package/src/store.ts +66 -10
- 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/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
|
-
|
|
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
|
-
//
|
|
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:
|
|
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":
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|