@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
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import { isIP } from "node:net"
|
|
3
|
+
import {
|
|
4
|
+
generateCallbackToken,
|
|
5
|
+
generateWakeId,
|
|
6
|
+
getWebhookJwks,
|
|
7
|
+
getWebhookSigningKeyId,
|
|
8
|
+
signWebhookPayload,
|
|
9
|
+
validateCallbackToken,
|
|
10
|
+
} from "./crypto"
|
|
11
|
+
import { globMatch } from "./glob"
|
|
12
|
+
import { serverLog } from "./log"
|
|
13
|
+
import type {
|
|
14
|
+
SubscriptionCallbackRequest,
|
|
15
|
+
SubscriptionCreateInput,
|
|
16
|
+
SubscriptionError,
|
|
17
|
+
SubscriptionRecord,
|
|
18
|
+
SubscriptionStreamInfo,
|
|
19
|
+
SubscriptionStreamLink,
|
|
20
|
+
} from "./subscription-types"
|
|
21
|
+
|
|
22
|
+
const DEFAULT_LEASE_TTL_MS = 30_000
|
|
23
|
+
const MIN_LEASE_TTL_MS = 1_000
|
|
24
|
+
const MAX_LEASE_TTL_MS: number = 10 * 60_000
|
|
25
|
+
const ZERO_OFFSET = `0000000000000000_0000000000000000`
|
|
26
|
+
const BEFORE_FIRST_OFFSET = `-1`
|
|
27
|
+
const MAX_RETRY_DELAY_MS = 60_000
|
|
28
|
+
|
|
29
|
+
interface StreamLike {
|
|
30
|
+
currentOffset: string
|
|
31
|
+
softDeleted?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SubscriptionStreamStore {
|
|
35
|
+
has: (path: string) => boolean
|
|
36
|
+
get: (path: string) => StreamLike | undefined
|
|
37
|
+
list: () => Array<string>
|
|
38
|
+
append: (path: string, data: Uint8Array) => unknown
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function compareOffsets(a: string, b: string): number {
|
|
42
|
+
if (a < b) return -1
|
|
43
|
+
if (a > b) return 1
|
|
44
|
+
return 0
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeRelativePath(path: string): string {
|
|
48
|
+
return path.replace(/^\/+/, ``).replace(/\/+$/, ``)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function toAbsoluteStreamPath(streamPath: string): string {
|
|
52
|
+
return `/v1/stream/${normalizeRelativePath(streamPath)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function toStreamRelativePath(absolutePath: string): string | null {
|
|
56
|
+
const streamRoot = `/v1/stream/`
|
|
57
|
+
if (!absolutePath.startsWith(streamRoot)) return null
|
|
58
|
+
|
|
59
|
+
const path = absolutePath.slice(streamRoot.length)
|
|
60
|
+
if (path === `__ds` || path.startsWith(`__ds/`)) return null
|
|
61
|
+
return path.length > 0 ? path : null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function stableConfigHash(input: SubscriptionCreateInput): string {
|
|
65
|
+
const canonical = {
|
|
66
|
+
type: input.type,
|
|
67
|
+
pattern: input.pattern,
|
|
68
|
+
streams: [...new Set(input.streams)].sort(),
|
|
69
|
+
webhook: input.webhook ? { url: input.webhook.url } : undefined,
|
|
70
|
+
wake_stream: input.wake_stream,
|
|
71
|
+
lease_ttl_ms: input.lease_ttl_ms,
|
|
72
|
+
description: input.description,
|
|
73
|
+
}
|
|
74
|
+
return createHash(`sha256`).update(JSON.stringify(canonical)).digest(`hex`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isPrivateOrLinkLocalIpv4(host: string): boolean {
|
|
78
|
+
const parts = host.split(`.`).map((part) => Number(part))
|
|
79
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
|
|
80
|
+
return false
|
|
81
|
+
}
|
|
82
|
+
const [a, b] = parts as [number, number, number, number]
|
|
83
|
+
return (
|
|
84
|
+
a === 10 ||
|
|
85
|
+
a === 127 ||
|
|
86
|
+
a === 0 ||
|
|
87
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
88
|
+
(a === 192 && b === 168) ||
|
|
89
|
+
(a === 169 && b === 254)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isLocalDevHost(host: string): boolean {
|
|
94
|
+
return host === `localhost` || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function validateWebhookUrl(
|
|
98
|
+
rawUrl: string
|
|
99
|
+
): { ok: true } | { ok: false; message: string } {
|
|
100
|
+
let url: URL
|
|
101
|
+
try {
|
|
102
|
+
url = new URL(rawUrl)
|
|
103
|
+
} catch {
|
|
104
|
+
return { ok: false, message: `webhook.url must be a valid URL` }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const host = url.hostname.toLowerCase()
|
|
108
|
+
if (url.protocol === `http:`) {
|
|
109
|
+
if (isLocalDevHost(host)) return { ok: true }
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
message: `http webhook URLs are only allowed for localhost or 127.0.0.x`,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (url.protocol !== `https:`) {
|
|
117
|
+
return { ok: false, message: `webhook.url must use https` }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (host === `localhost`) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
message: `localhost webhook URLs must use http for dev`,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (isIP(host) === 4 && isPrivateOrLinkLocalIpv4(host)) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
message: `webhook.url must not target private or link-local hosts`,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isIP(host) === 6) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
message: `IPv6 webhook hosts are not accepted by the reference server`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { ok: true }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export class SubscriptionManager {
|
|
145
|
+
private readonly subscriptions = new Map<string, SubscriptionRecord>()
|
|
146
|
+
private readonly streamStore: SubscriptionStreamStore
|
|
147
|
+
private readonly callbackBaseUrl: string
|
|
148
|
+
private readonly webhooksEnabled: boolean
|
|
149
|
+
private isShuttingDown = false
|
|
150
|
+
|
|
151
|
+
constructor(opts: {
|
|
152
|
+
callbackBaseUrl: string
|
|
153
|
+
streamStore: SubscriptionStreamStore
|
|
154
|
+
webhooksEnabled?: boolean
|
|
155
|
+
}) {
|
|
156
|
+
this.callbackBaseUrl = opts.callbackBaseUrl
|
|
157
|
+
this.streamStore = opts.streamStore
|
|
158
|
+
this.webhooksEnabled = opts.webhooksEnabled ?? true
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
createOrConfirm(
|
|
162
|
+
id: string,
|
|
163
|
+
input: SubscriptionCreateInput
|
|
164
|
+
):
|
|
165
|
+
| { subscription: SubscriptionRecord; created: boolean }
|
|
166
|
+
| { error: SubscriptionError } {
|
|
167
|
+
const configHash = stableConfigHash(input)
|
|
168
|
+
const existing = this.subscriptions.get(id)
|
|
169
|
+
if (existing) {
|
|
170
|
+
if (existing.config_hash !== configHash) {
|
|
171
|
+
return {
|
|
172
|
+
error: {
|
|
173
|
+
code: `SUBSCRIPTION_ALREADY_EXISTS`,
|
|
174
|
+
message: `Subscription already exists with different configuration`,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { subscription: existing, created: false }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (input.type === `webhook`) {
|
|
182
|
+
if (!this.webhooksEnabled) {
|
|
183
|
+
return {
|
|
184
|
+
error: {
|
|
185
|
+
code: `INVALID_REQUEST`,
|
|
186
|
+
message: `webhook subscriptions are not enabled on this server`,
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!input.webhook) {
|
|
191
|
+
return {
|
|
192
|
+
error: {
|
|
193
|
+
code: `INVALID_REQUEST`,
|
|
194
|
+
message: `webhook subscriptions require webhook.url`,
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const validation = validateWebhookUrl(input.webhook.url)
|
|
199
|
+
if (!validation.ok) {
|
|
200
|
+
return {
|
|
201
|
+
error: { code: `WEBHOOK_URL_REJECTED`, message: validation.message },
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (input.type === `pull-wake` && !input.wake_stream) {
|
|
207
|
+
return {
|
|
208
|
+
error: {
|
|
209
|
+
code: `INVALID_REQUEST`,
|
|
210
|
+
message: `pull-wake subscriptions require wake_stream`,
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const subscription: SubscriptionRecord = {
|
|
216
|
+
id,
|
|
217
|
+
type: input.type,
|
|
218
|
+
pattern: input.pattern,
|
|
219
|
+
webhook: input.webhook ? { url: input.webhook.url } : undefined,
|
|
220
|
+
wake_stream: input.wake_stream,
|
|
221
|
+
lease_ttl_ms: input.lease_ttl_ms,
|
|
222
|
+
description: input.description,
|
|
223
|
+
created_at: new Date().toISOString(),
|
|
224
|
+
status: `active`,
|
|
225
|
+
config_hash: configHash,
|
|
226
|
+
streams: new Map(),
|
|
227
|
+
generation: 0,
|
|
228
|
+
wake_id: null,
|
|
229
|
+
wake_snapshot: new Map(),
|
|
230
|
+
token: null,
|
|
231
|
+
holder: null,
|
|
232
|
+
lease_timer: null,
|
|
233
|
+
retry_count: 0,
|
|
234
|
+
retry_timer: null,
|
|
235
|
+
next_attempt_at: null,
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (const stream of input.streams) {
|
|
239
|
+
this.linkStream(
|
|
240
|
+
subscription,
|
|
241
|
+
stream,
|
|
242
|
+
`explicit`,
|
|
243
|
+
this.getTailOffset(stream)
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (input.pattern) {
|
|
248
|
+
for (const stream of this.listStreams()) {
|
|
249
|
+
if (globMatch(input.pattern, stream)) {
|
|
250
|
+
this.linkStream(
|
|
251
|
+
subscription,
|
|
252
|
+
stream,
|
|
253
|
+
`glob`,
|
|
254
|
+
this.getTailOffset(stream)
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
this.subscriptions.set(id, subscription)
|
|
261
|
+
return { subscription, created: true }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
get(id: string): SubscriptionRecord | undefined {
|
|
265
|
+
return this.subscriptions.get(id)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
delete(id: string): boolean {
|
|
269
|
+
const subscription = this.subscriptions.get(id)
|
|
270
|
+
if (!subscription) return false
|
|
271
|
+
this.clearLease(subscription)
|
|
272
|
+
if (subscription.retry_timer) clearTimeout(subscription.retry_timer)
|
|
273
|
+
this.subscriptions.delete(id)
|
|
274
|
+
return true
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
addExplicitStreams(id: string, streams: Array<string>): boolean {
|
|
278
|
+
const subscription = this.get(id)
|
|
279
|
+
if (!subscription) return false
|
|
280
|
+
for (const stream of streams) {
|
|
281
|
+
this.linkStream(
|
|
282
|
+
subscription,
|
|
283
|
+
stream,
|
|
284
|
+
`explicit`,
|
|
285
|
+
this.getTailOffset(stream)
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
return true
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
removeExplicitStream(id: string, streamPath: string): boolean {
|
|
292
|
+
const subscription = this.get(id)
|
|
293
|
+
if (!subscription) return false
|
|
294
|
+
const normalized = normalizeRelativePath(streamPath)
|
|
295
|
+
const link = subscription.streams.get(normalized)
|
|
296
|
+
if (!link) return true
|
|
297
|
+
link.link_types.delete(`explicit`)
|
|
298
|
+
if (link.link_types.size === 0) {
|
|
299
|
+
subscription.streams.delete(normalized)
|
|
300
|
+
}
|
|
301
|
+
return true
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async onStreamAppend(absolutePath: string): Promise<void> {
|
|
305
|
+
if (this.isShuttingDown) return
|
|
306
|
+
for (const subscription of this.subscriptions.values()) {
|
|
307
|
+
const relative = toStreamRelativePath(absolutePath)
|
|
308
|
+
if (!relative) continue
|
|
309
|
+
if (subscription.pattern && globMatch(subscription.pattern, relative)) {
|
|
310
|
+
const existing = subscription.streams.get(relative)
|
|
311
|
+
this.linkStream(
|
|
312
|
+
subscription,
|
|
313
|
+
relative,
|
|
314
|
+
`glob`,
|
|
315
|
+
existing?.acked_offset ?? BEFORE_FIRST_OFFSET
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
if (subscription.streams.has(relative)) {
|
|
319
|
+
await this.maybeWake(subscription, relative)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
onStreamDeleted(absolutePath: string): void {
|
|
325
|
+
for (const subscription of this.subscriptions.values()) {
|
|
326
|
+
const relative = toStreamRelativePath(absolutePath)
|
|
327
|
+
if (relative) subscription.streams.delete(relative)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async handleWebhookCallback(
|
|
332
|
+
id: string,
|
|
333
|
+
token: string,
|
|
334
|
+
request: SubscriptionCallbackRequest
|
|
335
|
+
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
336
|
+
const subscription = this.get(id)
|
|
337
|
+
if (!subscription) {
|
|
338
|
+
return this.errorResponse(
|
|
339
|
+
404,
|
|
340
|
+
`SUBSCRIPTION_NOT_FOUND`,
|
|
341
|
+
`Subscription not found`
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
const fenced = this.validateWakeToken(subscription, token, request)
|
|
345
|
+
if (fenced) return fenced
|
|
346
|
+
|
|
347
|
+
const ackError = this.applyAcks(subscription, request)
|
|
348
|
+
if (ackError) return ackError
|
|
349
|
+
|
|
350
|
+
this.extendLease(subscription)
|
|
351
|
+
let nextWake = false
|
|
352
|
+
if (request.done === true) {
|
|
353
|
+
this.clearLease(subscription)
|
|
354
|
+
subscription.token = null
|
|
355
|
+
subscription.holder = null
|
|
356
|
+
subscription.wake_id = null
|
|
357
|
+
subscription.wake_snapshot.clear()
|
|
358
|
+
nextWake = await this.triggerNextWakeIfPending(subscription)
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { status: 200, body: { ok: true, next_wake: nextWake } }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async claim(
|
|
365
|
+
id: string,
|
|
366
|
+
worker: string
|
|
367
|
+
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
368
|
+
const subscription = this.get(id)
|
|
369
|
+
if (!subscription) {
|
|
370
|
+
return this.errorResponse(
|
|
371
|
+
404,
|
|
372
|
+
`SUBSCRIPTION_NOT_FOUND`,
|
|
373
|
+
`Subscription not found`
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
if (subscription.type !== `pull-wake`) {
|
|
377
|
+
return this.errorResponse(
|
|
378
|
+
400,
|
|
379
|
+
`INVALID_REQUEST`,
|
|
380
|
+
`Subscription is not pull-wake`
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
if (subscription.holder) {
|
|
384
|
+
return {
|
|
385
|
+
status: 409,
|
|
386
|
+
body: {
|
|
387
|
+
error: {
|
|
388
|
+
code: `ALREADY_CLAIMED`,
|
|
389
|
+
current_holder: subscription.holder,
|
|
390
|
+
generation: subscription.generation,
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (!this.hasPendingWork(subscription)) {
|
|
396
|
+
return this.errorResponse(
|
|
397
|
+
409,
|
|
398
|
+
`NO_PENDING_WORK`,
|
|
399
|
+
`Subscription has no pending work`
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
if (!subscription.wake_id) {
|
|
403
|
+
await this.createWake(subscription, this.firstPendingStream(subscription))
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
subscription.holder = worker
|
|
407
|
+
subscription.token = generateCallbackToken(
|
|
408
|
+
this.tokenSubject(subscription),
|
|
409
|
+
subscription.generation
|
|
410
|
+
)
|
|
411
|
+
this.extendLease(subscription)
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
status: 200,
|
|
415
|
+
body: {
|
|
416
|
+
wake_id: subscription.wake_id,
|
|
417
|
+
generation: subscription.generation,
|
|
418
|
+
token: subscription.token,
|
|
419
|
+
streams: this.streamInfos(subscription),
|
|
420
|
+
lease_ttl_ms: subscription.lease_ttl_ms,
|
|
421
|
+
},
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async ack(
|
|
426
|
+
id: string,
|
|
427
|
+
token: string,
|
|
428
|
+
request: SubscriptionCallbackRequest
|
|
429
|
+
): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
430
|
+
const subscription = this.get(id)
|
|
431
|
+
if (!subscription) {
|
|
432
|
+
return this.errorResponse(
|
|
433
|
+
404,
|
|
434
|
+
`SUBSCRIPTION_NOT_FOUND`,
|
|
435
|
+
`Subscription not found`
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
if (subscription.type !== `pull-wake`) {
|
|
439
|
+
return this.errorResponse(
|
|
440
|
+
400,
|
|
441
|
+
`INVALID_REQUEST`,
|
|
442
|
+
`Subscription is not pull-wake`
|
|
443
|
+
)
|
|
444
|
+
}
|
|
445
|
+
const fenced = this.validateWakeToken(subscription, token, request)
|
|
446
|
+
if (fenced) return fenced
|
|
447
|
+
|
|
448
|
+
const ackError = this.applyAcks(subscription, request)
|
|
449
|
+
if (ackError) return ackError
|
|
450
|
+
|
|
451
|
+
this.extendLease(subscription)
|
|
452
|
+
let nextWake = false
|
|
453
|
+
if (request.done === true) {
|
|
454
|
+
this.clearLease(subscription)
|
|
455
|
+
subscription.token = null
|
|
456
|
+
subscription.holder = null
|
|
457
|
+
subscription.wake_id = null
|
|
458
|
+
subscription.wake_snapshot.clear()
|
|
459
|
+
nextWake = await this.triggerNextWakeIfPending(subscription)
|
|
460
|
+
}
|
|
461
|
+
return { status: 200, body: { ok: true, next_wake: nextWake } }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async release(
|
|
465
|
+
id: string,
|
|
466
|
+
token: string,
|
|
467
|
+
request: SubscriptionCallbackRequest
|
|
468
|
+
): Promise<{ status: number; body?: Record<string, unknown> }> {
|
|
469
|
+
const subscription = this.get(id)
|
|
470
|
+
if (!subscription) {
|
|
471
|
+
return this.errorResponse(
|
|
472
|
+
404,
|
|
473
|
+
`SUBSCRIPTION_NOT_FOUND`,
|
|
474
|
+
`Subscription not found`
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
if (subscription.type !== `pull-wake`) {
|
|
478
|
+
return this.errorResponse(
|
|
479
|
+
400,
|
|
480
|
+
`INVALID_REQUEST`,
|
|
481
|
+
`Subscription is not pull-wake`
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
const fenced = this.validateWakeToken(subscription, token, request)
|
|
485
|
+
if (fenced) return fenced
|
|
486
|
+
|
|
487
|
+
this.clearLease(subscription)
|
|
488
|
+
subscription.token = null
|
|
489
|
+
subscription.holder = null
|
|
490
|
+
subscription.wake_id = null
|
|
491
|
+
subscription.wake_snapshot.clear()
|
|
492
|
+
await this.triggerNextWakeIfPending(subscription)
|
|
493
|
+
return { status: 204 }
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
serialize(subscription: SubscriptionRecord): Record<string, unknown> {
|
|
497
|
+
return {
|
|
498
|
+
id: subscription.id,
|
|
499
|
+
subscription_id: subscription.id,
|
|
500
|
+
type: subscription.type,
|
|
501
|
+
pattern: subscription.pattern,
|
|
502
|
+
streams: this.streamInfos(subscription).map((stream) => ({
|
|
503
|
+
path: stream.path,
|
|
504
|
+
link_type: stream.link_type,
|
|
505
|
+
acked_offset: stream.acked_offset,
|
|
506
|
+
})),
|
|
507
|
+
webhook: subscription.webhook
|
|
508
|
+
? {
|
|
509
|
+
url: subscription.webhook.url,
|
|
510
|
+
signing: this.webhookSigningMetadata(),
|
|
511
|
+
}
|
|
512
|
+
: undefined,
|
|
513
|
+
wake_stream: subscription.wake_stream,
|
|
514
|
+
lease_ttl_ms: subscription.lease_ttl_ms,
|
|
515
|
+
created_at: subscription.created_at,
|
|
516
|
+
status: subscription.status,
|
|
517
|
+
description: subscription.description,
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
getWebhookJwks(): ReturnType<typeof getWebhookJwks> {
|
|
522
|
+
return getWebhookJwks()
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
shutdown(): void {
|
|
526
|
+
this.isShuttingDown = true
|
|
527
|
+
for (const subscription of this.subscriptions.values()) {
|
|
528
|
+
this.clearLease(subscription)
|
|
529
|
+
if (subscription.retry_timer) clearTimeout(subscription.retry_timer)
|
|
530
|
+
}
|
|
531
|
+
this.subscriptions.clear()
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private async maybeWake(
|
|
535
|
+
subscription: SubscriptionRecord,
|
|
536
|
+
triggeredBy: string
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
if (subscription.wake_id || subscription.holder) return
|
|
539
|
+
if (!this.hasPendingWork(subscription)) return
|
|
540
|
+
await this.createWake(subscription, triggeredBy)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private async createWake(
|
|
544
|
+
subscription: SubscriptionRecord,
|
|
545
|
+
triggeredBy: string
|
|
546
|
+
): Promise<void> {
|
|
547
|
+
subscription.generation++
|
|
548
|
+
subscription.wake_id = generateWakeId()
|
|
549
|
+
subscription.wake_snapshot = new Map(
|
|
550
|
+
this.streamInfos(subscription).map((stream) => [
|
|
551
|
+
stream.path,
|
|
552
|
+
stream.tail_offset,
|
|
553
|
+
])
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
if (subscription.type === `webhook`) {
|
|
557
|
+
subscription.token = generateCallbackToken(
|
|
558
|
+
this.tokenSubject(subscription),
|
|
559
|
+
subscription.generation
|
|
560
|
+
)
|
|
561
|
+
this.extendLease(subscription)
|
|
562
|
+
void this.deliverWebhook(subscription, [triggeredBy])
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
await this.writePullWakeEvent(subscription, triggeredBy)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private async deliverWebhook(
|
|
570
|
+
subscription: SubscriptionRecord,
|
|
571
|
+
triggeredBy: Array<string>
|
|
572
|
+
): Promise<void> {
|
|
573
|
+
if (!subscription.webhook || !subscription.wake_id || !subscription.token)
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
const body = JSON.stringify({
|
|
577
|
+
subscription_id: subscription.id,
|
|
578
|
+
wake_id: subscription.wake_id,
|
|
579
|
+
generation: subscription.generation,
|
|
580
|
+
streams: this.streamInfos(subscription),
|
|
581
|
+
callback_url: this.subscriptionActionUrl(subscription, `callback`),
|
|
582
|
+
callback_token: subscription.token,
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
const headers = {
|
|
586
|
+
"content-type": `application/json`,
|
|
587
|
+
"webhook-signature": signWebhookPayload(body),
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
const response = await fetch(subscription.webhook.url, {
|
|
592
|
+
method: `POST`,
|
|
593
|
+
headers,
|
|
594
|
+
body,
|
|
595
|
+
})
|
|
596
|
+
if (!response.ok) {
|
|
597
|
+
this.scheduleWebhookRetry(subscription, triggeredBy)
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
subscription.status = `active`
|
|
602
|
+
subscription.retry_count = 0
|
|
603
|
+
subscription.next_attempt_at = null
|
|
604
|
+
|
|
605
|
+
let parsed: { done?: boolean } | null = null
|
|
606
|
+
try {
|
|
607
|
+
parsed = (await response.json()) as { done?: boolean }
|
|
608
|
+
} catch {
|
|
609
|
+
parsed = null
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (parsed?.done === true) {
|
|
613
|
+
this.autoAckWakeSnapshot(subscription)
|
|
614
|
+
this.clearLease(subscription)
|
|
615
|
+
subscription.token = null
|
|
616
|
+
subscription.holder = null
|
|
617
|
+
subscription.wake_id = null
|
|
618
|
+
subscription.wake_snapshot.clear()
|
|
619
|
+
await this.triggerNextWakeIfPending(subscription)
|
|
620
|
+
}
|
|
621
|
+
} catch (err) {
|
|
622
|
+
serverLog.warn(`[subscriptions] webhook delivery failed:`, err)
|
|
623
|
+
this.scheduleWebhookRetry(subscription, triggeredBy)
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private scheduleWebhookRetry(
|
|
628
|
+
subscription: SubscriptionRecord,
|
|
629
|
+
triggeredBy: Array<string>
|
|
630
|
+
): void {
|
|
631
|
+
if (this.isShuttingDown) return
|
|
632
|
+
subscription.retry_count++
|
|
633
|
+
const baseDelay = Math.min(
|
|
634
|
+
1000 * Math.pow(2, Math.max(0, subscription.retry_count - 1)),
|
|
635
|
+
MAX_RETRY_DELAY_MS
|
|
636
|
+
)
|
|
637
|
+
const jitter = baseDelay * 0.2 * (Math.random() * 2 - 1)
|
|
638
|
+
const delay = Math.max(0, Math.round(baseDelay + jitter))
|
|
639
|
+
subscription.status = `failed`
|
|
640
|
+
subscription.next_attempt_at = Date.now() + delay
|
|
641
|
+
if (subscription.retry_timer) clearTimeout(subscription.retry_timer)
|
|
642
|
+
subscription.retry_timer = setTimeout(() => {
|
|
643
|
+
subscription.retry_timer = null
|
|
644
|
+
void this.deliverWebhook(subscription, triggeredBy)
|
|
645
|
+
}, delay)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private async writePullWakeEvent(
|
|
649
|
+
subscription: SubscriptionRecord,
|
|
650
|
+
streamPath: string
|
|
651
|
+
): Promise<void> {
|
|
652
|
+
if (!subscription.wake_stream) return
|
|
653
|
+
const wakeStream = toAbsoluteStreamPath(subscription.wake_stream)
|
|
654
|
+
if (!this.streamStore.has(wakeStream)) {
|
|
655
|
+
serverLog.warn(
|
|
656
|
+
`[subscriptions] wake stream does not exist: ${wakeStream}`
|
|
657
|
+
)
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
const event = {
|
|
661
|
+
type: `wake`,
|
|
662
|
+
subscription_id: subscription.id,
|
|
663
|
+
stream: streamPath,
|
|
664
|
+
generation: subscription.generation,
|
|
665
|
+
ts: Date.now(),
|
|
666
|
+
}
|
|
667
|
+
await Promise.resolve(
|
|
668
|
+
this.streamStore.append(
|
|
669
|
+
wakeStream,
|
|
670
|
+
new TextEncoder().encode(JSON.stringify(event))
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
private autoAckWakeSnapshot(subscription: SubscriptionRecord): void {
|
|
676
|
+
for (const [stream, tail] of subscription.wake_snapshot) {
|
|
677
|
+
const link = subscription.streams.get(stream)
|
|
678
|
+
if (link) link.acked_offset = tail
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private applyAcks(
|
|
683
|
+
subscription: SubscriptionRecord,
|
|
684
|
+
request: SubscriptionCallbackRequest
|
|
685
|
+
): { status: number; body: Record<string, unknown> } | null {
|
|
686
|
+
if (!request.acks) return null
|
|
687
|
+
for (const ack of request.acks) {
|
|
688
|
+
const stream = normalizeRelativePath(ack.stream ?? ack.path ?? ``)
|
|
689
|
+
const link = subscription.streams.get(stream)
|
|
690
|
+
if (!stream || !link) {
|
|
691
|
+
return this.errorResponse(
|
|
692
|
+
409,
|
|
693
|
+
`INVALID_OFFSET`,
|
|
694
|
+
`Ack references an unknown subscription stream`
|
|
695
|
+
)
|
|
696
|
+
}
|
|
697
|
+
if (ack.offset === BEFORE_FIRST_OFFSET) {
|
|
698
|
+
return this.errorResponse(
|
|
699
|
+
409,
|
|
700
|
+
`INVALID_OFFSET`,
|
|
701
|
+
`Ack offset must not be -1`
|
|
702
|
+
)
|
|
703
|
+
}
|
|
704
|
+
if (compareOffsets(ack.offset, link.acked_offset) < 0) {
|
|
705
|
+
return this.errorResponse(
|
|
706
|
+
409,
|
|
707
|
+
`INVALID_OFFSET`,
|
|
708
|
+
`Ack offset regresses the committed cursor`
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
if (compareOffsets(ack.offset, this.getTailOffset(stream)) > 0) {
|
|
712
|
+
return this.errorResponse(
|
|
713
|
+
409,
|
|
714
|
+
`INVALID_OFFSET`,
|
|
715
|
+
`Ack offset is beyond stream tail`
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
for (const ack of request.acks) {
|
|
720
|
+
const stream = normalizeRelativePath(ack.stream ?? ack.path ?? ``)
|
|
721
|
+
subscription.streams.get(stream)!.acked_offset = ack.offset
|
|
722
|
+
}
|
|
723
|
+
return null
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
private validateWakeToken(
|
|
727
|
+
subscription: SubscriptionRecord,
|
|
728
|
+
token: string,
|
|
729
|
+
request: SubscriptionCallbackRequest
|
|
730
|
+
): { status: number; body: Record<string, unknown> } | null {
|
|
731
|
+
const tokenResult = validateCallbackToken(
|
|
732
|
+
token,
|
|
733
|
+
this.tokenSubject(subscription)
|
|
734
|
+
)
|
|
735
|
+
if (!tokenResult.valid) {
|
|
736
|
+
return this.errorResponse(
|
|
737
|
+
401,
|
|
738
|
+
tokenResult.code,
|
|
739
|
+
tokenResult.code === `TOKEN_EXPIRED` ? `Token expired` : `Token invalid`
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
if (
|
|
743
|
+
tokenResult.epoch !== subscription.generation ||
|
|
744
|
+
request.generation !== subscription.generation ||
|
|
745
|
+
request.wake_id !== subscription.wake_id
|
|
746
|
+
) {
|
|
747
|
+
return this.errorResponse(409, `FENCED`, `Wake generation is stale`)
|
|
748
|
+
}
|
|
749
|
+
return null
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private async triggerNextWakeIfPending(
|
|
753
|
+
subscription: SubscriptionRecord
|
|
754
|
+
): Promise<boolean> {
|
|
755
|
+
if (!this.hasPendingWork(subscription)) return false
|
|
756
|
+
await this.createWake(subscription, this.firstPendingStream(subscription))
|
|
757
|
+
return true
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private hasPendingWork(subscription: SubscriptionRecord): boolean {
|
|
761
|
+
return this.streamInfos(subscription).some((stream) => stream.has_pending)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
private firstPendingStream(subscription: SubscriptionRecord): string {
|
|
765
|
+
return (
|
|
766
|
+
this.streamInfos(subscription).find((stream) => stream.has_pending)
|
|
767
|
+
?.path ?? ``
|
|
768
|
+
)
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private streamInfos(
|
|
772
|
+
subscription: SubscriptionRecord
|
|
773
|
+
): Array<SubscriptionStreamInfo> {
|
|
774
|
+
return Array.from(subscription.streams.values()).map((link) => {
|
|
775
|
+
const tail = this.getTailOffset(link.path)
|
|
776
|
+
return {
|
|
777
|
+
path: link.path,
|
|
778
|
+
link_type: link.link_types.has(`explicit`) ? `explicit` : `glob`,
|
|
779
|
+
acked_offset: link.acked_offset,
|
|
780
|
+
tail_offset: tail,
|
|
781
|
+
has_pending: compareOffsets(tail, link.acked_offset) > 0,
|
|
782
|
+
}
|
|
783
|
+
})
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
private linkStream(
|
|
787
|
+
subscription: SubscriptionRecord,
|
|
788
|
+
streamPath: string,
|
|
789
|
+
linkType: `glob` | `explicit`,
|
|
790
|
+
ackedOffset: string
|
|
791
|
+
): SubscriptionStreamLink {
|
|
792
|
+
const normalized = normalizeRelativePath(streamPath)
|
|
793
|
+
const existing = subscription.streams.get(normalized)
|
|
794
|
+
if (existing) {
|
|
795
|
+
existing.link_types.add(linkType)
|
|
796
|
+
return existing
|
|
797
|
+
}
|
|
798
|
+
const link: SubscriptionStreamLink = {
|
|
799
|
+
path: normalized,
|
|
800
|
+
link_types: new Set([linkType]),
|
|
801
|
+
acked_offset: ackedOffset,
|
|
802
|
+
}
|
|
803
|
+
subscription.streams.set(normalized, link)
|
|
804
|
+
return link
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
private listStreams(): Array<string> {
|
|
808
|
+
return this.streamStore
|
|
809
|
+
.list()
|
|
810
|
+
.map((path) => toStreamRelativePath(path))
|
|
811
|
+
.filter((path): path is string => path !== null)
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private getTailOffset(streamPath: string): string {
|
|
815
|
+
return (
|
|
816
|
+
this.streamStore.get(toAbsoluteStreamPath(streamPath))?.currentOffset ??
|
|
817
|
+
ZERO_OFFSET
|
|
818
|
+
)
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private subscriptionActionUrl(
|
|
822
|
+
subscription: SubscriptionRecord,
|
|
823
|
+
action: string
|
|
824
|
+
): string {
|
|
825
|
+
const url = new URL(
|
|
826
|
+
`/v1/stream/__ds/subscriptions/${encodeURIComponent(subscription.id)}/${action}`,
|
|
827
|
+
this.callbackBaseUrl
|
|
828
|
+
)
|
|
829
|
+
return url.toString()
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
private webhookJwksUrl(): string {
|
|
833
|
+
const url = new URL(`/v1/stream/__ds/jwks.json`, this.callbackBaseUrl)
|
|
834
|
+
return url.toString()
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private webhookSigningMetadata(): Record<string, string> {
|
|
838
|
+
return {
|
|
839
|
+
alg: `ed25519`,
|
|
840
|
+
kid: getWebhookSigningKeyId(),
|
|
841
|
+
jwks_url: this.webhookJwksUrl(),
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private extendLease(subscription: SubscriptionRecord): void {
|
|
846
|
+
this.clearLease(subscription)
|
|
847
|
+
subscription.lease_timer = setTimeout(() => {
|
|
848
|
+
subscription.lease_timer = null
|
|
849
|
+
subscription.holder = null
|
|
850
|
+
subscription.token = null
|
|
851
|
+
subscription.wake_id = null
|
|
852
|
+
subscription.wake_snapshot.clear()
|
|
853
|
+
void this.triggerNextWakeIfPending(subscription)
|
|
854
|
+
}, subscription.lease_ttl_ms)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
private clearLease(subscription: SubscriptionRecord): void {
|
|
858
|
+
if (subscription.lease_timer) {
|
|
859
|
+
clearTimeout(subscription.lease_timer)
|
|
860
|
+
subscription.lease_timer = null
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
private tokenSubject(subscription: SubscriptionRecord): string {
|
|
865
|
+
return `subscription:${subscription.id}`
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private errorResponse(
|
|
869
|
+
status: number,
|
|
870
|
+
code: SubscriptionError[`code`],
|
|
871
|
+
message: string
|
|
872
|
+
): { status: number; body: Record<string, unknown> } {
|
|
873
|
+
return { status, body: { error: { code, message } } }
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
export {
|
|
878
|
+
DEFAULT_LEASE_TTL_MS,
|
|
879
|
+
MIN_LEASE_TTL_MS,
|
|
880
|
+
MAX_LEASE_TTL_MS,
|
|
881
|
+
normalizeRelativePath,
|
|
882
|
+
}
|