@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.
@@ -0,0 +1,504 @@
1
+ import {
2
+ DEFAULT_LEASE_TTL_MS,
3
+ MAX_LEASE_TTL_MS,
4
+ MIN_LEASE_TTL_MS,
5
+ normalizeRelativePath,
6
+ } from "./subscription-manager"
7
+ import type { IncomingMessage, ServerResponse } from "node:http"
8
+ import type { SubscriptionManager } from "./subscription-manager"
9
+ import type {
10
+ SubscriptionCallbackRequest,
11
+ SubscriptionCreateInput,
12
+ SubscriptionErrorCode,
13
+ SubscriptionType,
14
+ } from "./subscription-types"
15
+
16
+ const RESERVED_CONTROL_PREFIX = `/v1/stream/__ds`
17
+ const SUBSCRIPTION_PREFIX = `${RESERVED_CONTROL_PREFIX}/subscriptions/`
18
+ const JWKS_PATH = `${RESERVED_CONTROL_PREFIX}/jwks.json`
19
+
20
+ interface ParsedRoute {
21
+ subscriptionId: string
22
+ action:
23
+ | `base`
24
+ | `streams`
25
+ | `stream`
26
+ | `callback`
27
+ | `claim`
28
+ | `ack`
29
+ | `release`
30
+ streamPath?: string
31
+ }
32
+
33
+ const ERROR_STATUS: Record<SubscriptionErrorCode, number> = {
34
+ INVALID_REQUEST: 400,
35
+ SUBSCRIPTION_NOT_FOUND: 404,
36
+ SUBSCRIPTION_ALREADY_EXISTS: 409,
37
+ WEBHOOK_URL_REJECTED: 400,
38
+ TOKEN_INVALID: 401,
39
+ TOKEN_EXPIRED: 401,
40
+ FENCED: 409,
41
+ ALREADY_CLAIMED: 409,
42
+ NO_PENDING_WORK: 409,
43
+ INVALID_OFFSET: 409,
44
+ }
45
+
46
+ export class SubscriptionRoutes {
47
+ private readonly manager: SubscriptionManager
48
+
49
+ constructor(manager: SubscriptionManager) {
50
+ this.manager = manager
51
+ }
52
+
53
+ async handleRequest(
54
+ method: string,
55
+ path: string,
56
+ req: IncomingMessage,
57
+ res: ServerResponse
58
+ ): Promise<boolean> {
59
+ if (path === JWKS_PATH) {
60
+ this.handleJwks(method, res)
61
+ return true
62
+ }
63
+
64
+ const route = this.parseRoute(path)
65
+ if (!route) {
66
+ if (
67
+ path === RESERVED_CONTROL_PREFIX ||
68
+ path.startsWith(`${RESERVED_CONTROL_PREFIX}/`)
69
+ ) {
70
+ this.writeError(
71
+ res,
72
+ 404,
73
+ `SUBSCRIPTION_NOT_FOUND`,
74
+ `Durable Streams control route not found`
75
+ )
76
+ return true
77
+ }
78
+ return false
79
+ }
80
+
81
+ try {
82
+ switch (route.action) {
83
+ case `base`:
84
+ await this.handleBase(route, method, req, res)
85
+ return true
86
+ case `streams`:
87
+ await this.handleStreams(route, method, req, res)
88
+ return true
89
+ case `stream`:
90
+ this.handleStream(route, method, res)
91
+ return true
92
+ case `callback`:
93
+ await this.handleCallback(route, req, res)
94
+ return true
95
+ case `claim`:
96
+ await this.handleClaim(route, req, res)
97
+ return true
98
+ case `ack`:
99
+ await this.handleAck(route, req, res)
100
+ return true
101
+ case `release`:
102
+ await this.handleRelease(route, req, res)
103
+ return true
104
+ }
105
+ } catch (err) {
106
+ if (err instanceof SyntaxError) {
107
+ this.writeError(res, 400, `INVALID_REQUEST`, `Invalid JSON body`)
108
+ return true
109
+ }
110
+ throw err
111
+ }
112
+ }
113
+
114
+ private async handleBase(
115
+ route: ParsedRoute,
116
+ method: string,
117
+ req: IncomingMessage,
118
+ res: ServerResponse
119
+ ): Promise<void> {
120
+ if (method === `PUT`) {
121
+ const parsed = await this.readJson(req)
122
+ const input = this.parseCreateInput(parsed)
123
+ if (`error` in input) {
124
+ this.writeError(res, 400, `INVALID_REQUEST`, input.error)
125
+ return
126
+ }
127
+
128
+ const result = this.manager.createOrConfirm(
129
+ route.subscriptionId,
130
+ input.value
131
+ )
132
+ if (`error` in result) {
133
+ this.writeError(
134
+ res,
135
+ ERROR_STATUS[result.error.code],
136
+ result.error.code,
137
+ result.error.message
138
+ )
139
+ return
140
+ }
141
+
142
+ this.writeJson(
143
+ res,
144
+ result.created ? 201 : 200,
145
+ this.manager.serialize(result.subscription)
146
+ )
147
+ return
148
+ }
149
+
150
+ if (method === `GET`) {
151
+ const subscription = this.manager.get(route.subscriptionId)
152
+ if (!subscription) {
153
+ this.writeError(
154
+ res,
155
+ 404,
156
+ `SUBSCRIPTION_NOT_FOUND`,
157
+ `Subscription not found`
158
+ )
159
+ return
160
+ }
161
+ this.writeJson(res, 200, this.manager.serialize(subscription))
162
+ return
163
+ }
164
+
165
+ if (method === `DELETE`) {
166
+ this.manager.delete(route.subscriptionId)
167
+ res.writeHead(204)
168
+ res.end()
169
+ return
170
+ }
171
+
172
+ this.methodNotAllowed(res)
173
+ }
174
+
175
+ private handleJwks(method: string, res: ServerResponse): void {
176
+ if (method !== `GET`) {
177
+ this.methodNotAllowed(res)
178
+ return
179
+ }
180
+ res.writeHead(200, {
181
+ "content-type": `application/jwk-set+json`,
182
+ "cache-control": `public, max-age=300`,
183
+ })
184
+ res.end(JSON.stringify(this.manager.getWebhookJwks()))
185
+ }
186
+
187
+ private async handleStreams(
188
+ route: ParsedRoute,
189
+ method: string,
190
+ req: IncomingMessage,
191
+ res: ServerResponse
192
+ ): Promise<void> {
193
+ if (method !== `POST`) {
194
+ this.methodNotAllowed(res)
195
+ return
196
+ }
197
+ const parsed = await this.readJson(req)
198
+ const streams = (parsed as { streams?: unknown }).streams
199
+ if (
200
+ !Array.isArray(streams) ||
201
+ streams.some(
202
+ (stream) => typeof stream !== `string` || stream.length === 0
203
+ )
204
+ ) {
205
+ this.writeError(
206
+ res,
207
+ 400,
208
+ `INVALID_REQUEST`,
209
+ `streams must be a non-empty string array`
210
+ )
211
+ return
212
+ }
213
+ const ok = this.manager.addExplicitStreams(
214
+ route.subscriptionId,
215
+ streams.map(normalizeRelativePath)
216
+ )
217
+ if (!ok) {
218
+ this.writeError(
219
+ res,
220
+ 404,
221
+ `SUBSCRIPTION_NOT_FOUND`,
222
+ `Subscription not found`
223
+ )
224
+ return
225
+ }
226
+ res.writeHead(204)
227
+ res.end()
228
+ }
229
+
230
+ private handleStream(
231
+ route: ParsedRoute,
232
+ method: string,
233
+ res: ServerResponse
234
+ ): void {
235
+ if (method !== `DELETE`) {
236
+ this.methodNotAllowed(res)
237
+ return
238
+ }
239
+ const ok = this.manager.removeExplicitStream(
240
+ route.subscriptionId,
241
+ route.streamPath ?? ``
242
+ )
243
+ if (!ok) {
244
+ this.writeError(
245
+ res,
246
+ 404,
247
+ `SUBSCRIPTION_NOT_FOUND`,
248
+ `Subscription not found`
249
+ )
250
+ return
251
+ }
252
+ res.writeHead(204)
253
+ res.end()
254
+ }
255
+
256
+ private async handleCallback(
257
+ route: ParsedRoute,
258
+ req: IncomingMessage,
259
+ res: ServerResponse
260
+ ): Promise<void> {
261
+ const token = this.readBearerToken(req)
262
+ if (!token) {
263
+ this.writeError(
264
+ res,
265
+ 401,
266
+ `TOKEN_INVALID`,
267
+ `Missing or malformed Authorization header`
268
+ )
269
+ return
270
+ }
271
+ const body = (await this.readJson(req)) as SubscriptionCallbackRequest
272
+ const result = await this.manager.handleWebhookCallback(
273
+ route.subscriptionId,
274
+ token,
275
+ body
276
+ )
277
+ this.writeManagerResult(res, result)
278
+ }
279
+
280
+ private async handleClaim(
281
+ route: ParsedRoute,
282
+ req: IncomingMessage,
283
+ res: ServerResponse
284
+ ): Promise<void> {
285
+ const parsed = await this.readJson(req)
286
+ const worker = (parsed as { worker?: unknown }).worker
287
+ if (typeof worker !== `string` || worker.length === 0) {
288
+ this.writeError(
289
+ res,
290
+ 400,
291
+ `INVALID_REQUEST`,
292
+ `worker must be a non-empty string`
293
+ )
294
+ return
295
+ }
296
+ const result = await this.manager.claim(route.subscriptionId, worker)
297
+ this.writeManagerResult(res, result)
298
+ }
299
+
300
+ private async handleAck(
301
+ route: ParsedRoute,
302
+ req: IncomingMessage,
303
+ res: ServerResponse
304
+ ): Promise<void> {
305
+ const token = this.readBearerToken(req)
306
+ if (!token) {
307
+ this.writeError(
308
+ res,
309
+ 401,
310
+ `TOKEN_INVALID`,
311
+ `Missing or malformed Authorization header`
312
+ )
313
+ return
314
+ }
315
+ const body = (await this.readJson(req)) as SubscriptionCallbackRequest
316
+ const result = await this.manager.ack(route.subscriptionId, token, body)
317
+ this.writeManagerResult(res, result)
318
+ }
319
+
320
+ private async handleRelease(
321
+ route: ParsedRoute,
322
+ req: IncomingMessage,
323
+ res: ServerResponse
324
+ ): Promise<void> {
325
+ const token = this.readBearerToken(req)
326
+ if (!token) {
327
+ this.writeError(
328
+ res,
329
+ 401,
330
+ `TOKEN_INVALID`,
331
+ `Missing or malformed Authorization header`
332
+ )
333
+ return
334
+ }
335
+ const body = (await this.readJson(req)) as SubscriptionCallbackRequest
336
+ const result = await this.manager.release(route.subscriptionId, token, body)
337
+ this.writeManagerResult(res, result)
338
+ }
339
+
340
+ private parseCreateInput(
341
+ value: unknown
342
+ ): { value: SubscriptionCreateInput } | { error: string } {
343
+ if (!value || typeof value !== `object`) {
344
+ return { error: `Request body must be a JSON object` }
345
+ }
346
+ const payload = value as Record<string, unknown>
347
+ if (payload.type !== `webhook` && payload.type !== `pull-wake`) {
348
+ return { error: `type must be "webhook" or "pull-wake"` }
349
+ }
350
+ const type = payload.type as SubscriptionType
351
+ const pattern =
352
+ typeof payload.pattern === `string` && payload.pattern.length > 0
353
+ ? normalizeRelativePath(payload.pattern)
354
+ : undefined
355
+ const streams =
356
+ Array.isArray(payload.streams) && payload.streams.length > 0
357
+ ? payload.streams.map((stream) =>
358
+ typeof stream === `string` ? normalizeRelativePath(stream) : null
359
+ )
360
+ : []
361
+ if (streams.some((stream) => stream === null)) {
362
+ return { error: `streams must contain only strings` }
363
+ }
364
+ if (!pattern && streams.length === 0) {
365
+ return { error: `At least one of pattern or streams is required` }
366
+ }
367
+
368
+ const leaseTtl =
369
+ payload.lease_ttl_ms === undefined
370
+ ? DEFAULT_LEASE_TTL_MS
371
+ : payload.lease_ttl_ms
372
+ if (
373
+ typeof leaseTtl !== `number` ||
374
+ !Number.isInteger(leaseTtl) ||
375
+ leaseTtl < MIN_LEASE_TTL_MS ||
376
+ leaseTtl > MAX_LEASE_TTL_MS
377
+ ) {
378
+ return { error: `lease_ttl_ms must be an integer from 1000 to 600000` }
379
+ }
380
+
381
+ let webhook: { url: string } | undefined
382
+ if (type === `webhook`) {
383
+ const rawWebhook = payload.webhook
384
+ if (!rawWebhook || typeof rawWebhook !== `object`) {
385
+ return { error: `webhook subscriptions require webhook.url` }
386
+ }
387
+ const url = (rawWebhook as { url?: unknown }).url
388
+ if (typeof url !== `string` || url.length === 0) {
389
+ return { error: `webhook subscriptions require webhook.url` }
390
+ }
391
+ webhook = { url }
392
+ }
393
+
394
+ const wakeStream =
395
+ typeof payload.wake_stream === `string` && payload.wake_stream.length > 0
396
+ ? normalizeRelativePath(payload.wake_stream)
397
+ : undefined
398
+ if (type === `pull-wake` && !wakeStream) {
399
+ return { error: `pull-wake subscriptions require wake_stream` }
400
+ }
401
+
402
+ return {
403
+ value: {
404
+ type,
405
+ pattern,
406
+ streams: streams as Array<string>,
407
+ webhook,
408
+ wake_stream: wakeStream,
409
+ lease_ttl_ms: leaseTtl,
410
+ description:
411
+ typeof payload.description === `string`
412
+ ? payload.description
413
+ : undefined,
414
+ },
415
+ }
416
+ }
417
+
418
+ private parseRoute(path: string): ParsedRoute | null {
419
+ if (!path.startsWith(SUBSCRIPTION_PREFIX)) return null
420
+
421
+ const rest = path.slice(SUBSCRIPTION_PREFIX.length)
422
+ const parts = rest.split(`/`)
423
+ const subscriptionId = parts[0] ? decodeURIComponent(parts[0]) : ``
424
+ if (!subscriptionId) return null
425
+
426
+ const tail = parts.slice(1)
427
+ if (tail.length === 0) {
428
+ return { subscriptionId, action: `base` }
429
+ }
430
+ if (tail[0] === `streams` && tail.length === 1) {
431
+ return { subscriptionId, action: `streams` }
432
+ }
433
+ if (tail[0] === `streams` && tail.length > 1) {
434
+ return {
435
+ subscriptionId,
436
+ action: `stream`,
437
+ streamPath: normalizeRelativePath(
438
+ decodeURIComponent(tail.slice(1).join(`/`))
439
+ ),
440
+ }
441
+ }
442
+ if (
443
+ tail.length === 1 &&
444
+ [`callback`, `claim`, `ack`, `release`].includes(tail[0]!)
445
+ ) {
446
+ return {
447
+ subscriptionId,
448
+ action: tail[0] as ParsedRoute[`action`],
449
+ }
450
+ }
451
+
452
+ return null
453
+ }
454
+
455
+ private readBearerToken(req: IncomingMessage): string | null {
456
+ const authHeader = req.headers.authorization
457
+ if (!authHeader || !authHeader.startsWith(`Bearer `)) return null
458
+ return authHeader.slice(`Bearer `.length)
459
+ }
460
+
461
+ private async readJson(req: IncomingMessage): Promise<unknown> {
462
+ const chunks: Array<Buffer> = []
463
+ for await (const chunk of req) {
464
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
465
+ }
466
+ const raw = Buffer.concat(chunks).toString(`utf8`)
467
+ return raw.length > 0 ? JSON.parse(raw) : {}
468
+ }
469
+
470
+ private writeManagerResult(
471
+ res: ServerResponse,
472
+ result: { status: number; body?: Record<string, unknown> }
473
+ ): void {
474
+ if (result.status === 204) {
475
+ res.writeHead(204)
476
+ res.end()
477
+ return
478
+ }
479
+ this.writeJson(res, result.status, result.body ?? {})
480
+ }
481
+
482
+ private writeJson(
483
+ res: ServerResponse,
484
+ status: number,
485
+ body: Record<string, unknown>
486
+ ): void {
487
+ res.writeHead(status, { "content-type": `application/json` })
488
+ res.end(JSON.stringify(body))
489
+ }
490
+
491
+ private writeError(
492
+ res: ServerResponse,
493
+ status: number,
494
+ code: SubscriptionErrorCode,
495
+ message: string
496
+ ): void {
497
+ this.writeJson(res, status, { error: { code, message } })
498
+ }
499
+
500
+ private methodNotAllowed(res: ServerResponse): void {
501
+ res.writeHead(405, { "content-type": `text/plain` })
502
+ res.end(`Method not allowed`)
503
+ }
504
+ }
@@ -0,0 +1,80 @@
1
+ export type SubscriptionType = `webhook` | `pull-wake`
2
+ export type SubscriptionStatus = `active` | `failed`
3
+ export type SubscriptionLinkType = `glob` | `explicit`
4
+
5
+ export interface SubscriptionStreamLink {
6
+ path: string
7
+ link_types: Set<SubscriptionLinkType>
8
+ acked_offset: string
9
+ }
10
+
11
+ export interface SubscriptionWebhookConfig {
12
+ url: string
13
+ }
14
+
15
+ export interface SubscriptionRecord {
16
+ id: string
17
+ type: SubscriptionType
18
+ pattern?: string
19
+ webhook?: SubscriptionWebhookConfig
20
+ wake_stream?: string
21
+ lease_ttl_ms: number
22
+ description?: string
23
+ created_at: string
24
+ status: SubscriptionStatus
25
+ config_hash: string
26
+ streams: Map<string, SubscriptionStreamLink>
27
+ generation: number
28
+ wake_id: string | null
29
+ wake_snapshot: Map<string, string>
30
+ token: string | null
31
+ holder: string | null
32
+ lease_timer: ReturnType<typeof setTimeout> | null
33
+ retry_count: number
34
+ retry_timer: ReturnType<typeof setTimeout> | null
35
+ next_attempt_at: number | null
36
+ }
37
+
38
+ export interface SubscriptionStreamInfo {
39
+ path: string
40
+ link_type: SubscriptionLinkType
41
+ acked_offset: string
42
+ tail_offset: string
43
+ has_pending: boolean
44
+ }
45
+
46
+ export interface SubscriptionCreateInput {
47
+ type: SubscriptionType
48
+ pattern?: string
49
+ streams: Array<string>
50
+ webhook?: { url: string }
51
+ wake_stream?: string
52
+ lease_ttl_ms: number
53
+ description?: string
54
+ }
55
+
56
+ export interface SubscriptionCallbackRequest {
57
+ wake_id?: string
58
+ generation?: number
59
+ acks?: Array<{ stream?: string; path?: string; offset: string }>
60
+ done?: boolean
61
+ }
62
+
63
+ export type SubscriptionErrorCode =
64
+ | `INVALID_REQUEST`
65
+ | `SUBSCRIPTION_NOT_FOUND`
66
+ | `SUBSCRIPTION_ALREADY_EXISTS`
67
+ | `WEBHOOK_URL_REJECTED`
68
+ | `TOKEN_INVALID`
69
+ | `TOKEN_EXPIRED`
70
+ | `FENCED`
71
+ | `ALREADY_CLAIMED`
72
+ | `NO_PENDING_WORK`
73
+ | `INVALID_OFFSET`
74
+
75
+ export interface SubscriptionError {
76
+ code: SubscriptionErrorCode
77
+ message: string
78
+ current_holder?: string
79
+ generation?: number
80
+ }
package/src/types.ts CHANGED
@@ -206,6 +206,14 @@ export interface TestServerOptions {
206
206
  * Default: October 9, 2024 00:00:00 UTC.
207
207
  */
208
208
  cursorEpoch?: Date
209
+
210
+ /**
211
+ * Enable webhook subscriptions.
212
+ * Pull-wake subscription routes are always mounted, but type=webhook creates
213
+ * are rejected unless this is true.
214
+ * Default: false.
215
+ */
216
+ webhooks?: boolean
209
217
  }
210
218
 
211
219
  /**