@electric-ax/agents-server 0.4.2 → 0.4.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/dist/entrypoint.js +529 -248
- package/dist/index.cjs +1603 -1332
- package/dist/index.d.cts +274 -162
- package/dist/index.d.ts +274 -162
- package/dist/index.js +1601 -1332
- package/drizzle/0007_runner_diagnostics_and_principal.sql +22 -0
- package/drizzle/0008_runner_runtime_diagnostics.sql +50 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +6 -6
- package/src/db/schema.ts +33 -10
- package/src/electric-agents-types.ts +49 -3
- package/src/entity-registry.ts +136 -26
- package/src/host.ts +4 -5
- package/src/index.ts +5 -1
- package/src/principal.ts +23 -11
- package/src/routing/context.ts +1 -0
- package/src/routing/dispatch-policy.ts +123 -20
- package/src/routing/durable-streams-router.ts +286 -116
- package/src/routing/durable-streams-routing-adapter.ts +31 -64
- package/src/routing/electric-proxy-router.ts +1 -0
- package/src/routing/entities-router.ts +5 -5
- package/src/routing/hooks.ts +8 -1
- package/src/routing/internal-router.ts +6 -2
- package/src/routing/runners-router.ts +257 -19
- package/src/runtime.ts +4 -5
- package/src/server.ts +21 -15
- package/src/standalone-runtime.ts +4 -5
- package/src/stream-client.ts +18 -69
- package/src/utils/server-utils.ts +27 -8
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
ErrCodeUnauthorized,
|
|
9
9
|
} from '../electric-agents-types.js'
|
|
10
10
|
import { runnerWakeStream } from '../entity-registry.js'
|
|
11
|
+
import { DurableStreamsSubscriptionError } from '../stream-client.js'
|
|
11
12
|
import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
|
|
12
13
|
import { serverLog } from '../utils/log.js'
|
|
13
14
|
import type {
|
|
@@ -16,6 +17,9 @@ import type {
|
|
|
16
17
|
ElectricAgentsEntity,
|
|
17
18
|
} from '../electric-agents-types.js'
|
|
18
19
|
import type { TenantContext } from './context.js'
|
|
20
|
+
import type { SubscriptionCreateInput } from '../stream-client.js'
|
|
21
|
+
|
|
22
|
+
const linkedDispatchSubscriptions = new WeakMap<object, Set<string>>()
|
|
19
23
|
|
|
20
24
|
export function subscriptionIdForDispatchTarget(
|
|
21
25
|
target: DispatchTarget
|
|
@@ -31,7 +35,7 @@ function subscriptionIdForEntityDispatchTarget(
|
|
|
31
35
|
entityUrl: string
|
|
32
36
|
): string {
|
|
33
37
|
const base = subscriptionIdForDispatchTarget(target)
|
|
34
|
-
if (!target.subscription_id) return base
|
|
38
|
+
if (!target.subscription_id && target.type !== `runner`) return base
|
|
35
39
|
const digest = createHash(`sha256`).update(entityUrl).digest(`hex`)
|
|
36
40
|
return `${base}:${digest.slice(0, 16)}`
|
|
37
41
|
}
|
|
@@ -109,12 +113,92 @@ function sameDispatchDestination(
|
|
|
109
113
|
return false
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
function subscriptionHasStream(
|
|
117
|
+
ctx: TenantContext,
|
|
118
|
+
existing: { streams?: Array<string | { path?: string }> },
|
|
119
|
+
streamPath: string
|
|
120
|
+
): boolean {
|
|
121
|
+
const normalizedStream = streamPath.replace(/^\/+/, ``)
|
|
122
|
+
const backendStream = `${ctx.service}/${normalizedStream}`
|
|
123
|
+
return (
|
|
124
|
+
existing.streams?.some((stream) => {
|
|
125
|
+
const path = typeof stream === `string` ? stream : stream.path
|
|
126
|
+
if (!path) return false
|
|
127
|
+
const normalized = path.replace(/^\/+/, ``)
|
|
128
|
+
return normalized === normalizedStream || normalized === backendStream
|
|
129
|
+
}) ?? false
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function dispatchLinkCacheKey(
|
|
134
|
+
ctx: TenantContext,
|
|
135
|
+
subscriptionId: string,
|
|
136
|
+
streamPath: string
|
|
137
|
+
): string {
|
|
138
|
+
return `${ctx.service}:${subscriptionId}:${streamPath}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getDispatchLinkCache(ctx: TenantContext): Set<string> {
|
|
142
|
+
let cache = linkedDispatchSubscriptions.get(ctx.streamClient)
|
|
143
|
+
if (!cache) {
|
|
144
|
+
cache = new Set()
|
|
145
|
+
linkedDispatchSubscriptions.set(ctx.streamClient, cache)
|
|
146
|
+
}
|
|
147
|
+
return cache
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isSubscriptionAlreadyExistsError(err: unknown): boolean {
|
|
151
|
+
if (!(err instanceof DurableStreamsSubscriptionError)) return false
|
|
152
|
+
if (err.status === 409) return true
|
|
153
|
+
return (
|
|
154
|
+
err.code === `SUBSCRIPTION_ALREADY_EXISTS` ||
|
|
155
|
+
err.code === `ALREADY_EXISTS` ||
|
|
156
|
+
/already exists/i.test(err.errorMessage ?? err.body ?? err.message)
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function ensureSubscriptionIncludesStream(
|
|
161
|
+
ctx: TenantContext,
|
|
162
|
+
subscriptionId: string,
|
|
163
|
+
streamPath: string,
|
|
164
|
+
input: SubscriptionCreateInput,
|
|
165
|
+
existing: { streams?: Array<string | { path?: string }> } | null
|
|
166
|
+
): Promise<void> {
|
|
167
|
+
if (!existing) {
|
|
168
|
+
try {
|
|
169
|
+
await ctx.streamClient.putSubscription(subscriptionId, input)
|
|
170
|
+
return
|
|
171
|
+
} catch (err) {
|
|
172
|
+
if (!isSubscriptionAlreadyExistsError(err)) throw err
|
|
173
|
+
existing = await ctx.streamClient.getSubscription(subscriptionId)
|
|
174
|
+
if (!existing) {
|
|
175
|
+
serverLog.warn(
|
|
176
|
+
`[dispatch-policy] subscription create raced with existing subscription but it could not be read`,
|
|
177
|
+
{ subscriptionId, stream: streamPath }
|
|
178
|
+
)
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!subscriptionHasStream(ctx, existing, streamPath)) {
|
|
185
|
+
await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
112
189
|
export async function assertDispatchPolicyAllowed(
|
|
113
190
|
ctx: TenantContext,
|
|
114
191
|
policy: DispatchPolicy | undefined
|
|
115
192
|
): Promise<void> {
|
|
116
193
|
const target = policy?.targets[0]
|
|
117
194
|
if (!target || target.type !== `runner`) return
|
|
195
|
+
if (!ctx.principal) {
|
|
196
|
+
throw new ElectricAgentsError(
|
|
197
|
+
ErrCodeUnauthorized,
|
|
198
|
+
`Runner dispatch requires an authenticated owner`,
|
|
199
|
+
401
|
|
200
|
+
)
|
|
201
|
+
}
|
|
118
202
|
|
|
119
203
|
const runner = await ctx.entityManager.registry.getRunner(target.runnerId)
|
|
120
204
|
if (!runner) {
|
|
@@ -124,7 +208,7 @@ export async function assertDispatchPolicyAllowed(
|
|
|
124
208
|
404
|
|
125
209
|
)
|
|
126
210
|
}
|
|
127
|
-
if (
|
|
211
|
+
if (runner.owner_principal !== ctx.principal.url) {
|
|
128
212
|
throw new ElectricAgentsError(
|
|
129
213
|
ErrCodeUnauthorized,
|
|
130
214
|
`Runner dispatch requires the authenticated owner`,
|
|
@@ -143,7 +227,19 @@ export async function linkEntityDispatchSubscription(
|
|
|
143
227
|
)
|
|
144
228
|
const target = dispatchPolicy?.targets[0]
|
|
145
229
|
if (!target) return
|
|
146
|
-
|
|
230
|
+
const subscriptionId = subscriptionIdForEntityDispatchTarget(
|
|
231
|
+
target,
|
|
232
|
+
entity.url
|
|
233
|
+
)
|
|
234
|
+
const cacheKey = dispatchLinkCacheKey(
|
|
235
|
+
ctx,
|
|
236
|
+
subscriptionId,
|
|
237
|
+
entity.streams.main
|
|
238
|
+
)
|
|
239
|
+
const cache = getDispatchLinkCache(ctx)
|
|
240
|
+
if (cache.has(cacheKey)) return
|
|
241
|
+
await linkStreamToTargetSubscription(ctx, target, entity, subscriptionId)
|
|
242
|
+
cache.add(cacheKey)
|
|
147
243
|
}
|
|
148
244
|
|
|
149
245
|
export async function unlinkEntityDispatchSubscription(
|
|
@@ -160,6 +256,9 @@ export async function unlinkEntityDispatchSubscription(
|
|
|
160
256
|
target,
|
|
161
257
|
entity.url
|
|
162
258
|
)
|
|
259
|
+
getDispatchLinkCache(ctx).delete(
|
|
260
|
+
dispatchLinkCacheKey(ctx, subscriptionId, entity.streams.main)
|
|
261
|
+
)
|
|
163
262
|
await ctx.streamClient
|
|
164
263
|
.removeSubscriptionStream(subscriptionId, entity.streams.main)
|
|
165
264
|
.catch((err) => {
|
|
@@ -174,13 +273,13 @@ export async function unlinkEntityDispatchSubscription(
|
|
|
174
273
|
async function linkStreamToTargetSubscription(
|
|
175
274
|
ctx: TenantContext,
|
|
176
275
|
target: DispatchTarget,
|
|
177
|
-
entity: ElectricAgentsEntity
|
|
276
|
+
entity: ElectricAgentsEntity,
|
|
277
|
+
subscriptionId: string
|
|
178
278
|
): Promise<void> {
|
|
179
279
|
const streamPath = entity.streams.main
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
)
|
|
280
|
+
await ctx.streamClient.ensure(streamPath, {
|
|
281
|
+
contentType: `application/json`,
|
|
282
|
+
})
|
|
184
283
|
const existing = await ctx.streamClient.getSubscription(subscriptionId)
|
|
185
284
|
|
|
186
285
|
if (target.type === `runner`) {
|
|
@@ -196,16 +295,18 @@ async function linkStreamToTargetSubscription(
|
|
|
196
295
|
await ctx.streamClient.ensure(wakeStream, {
|
|
197
296
|
contentType: `application/json`,
|
|
198
297
|
})
|
|
199
|
-
|
|
200
|
-
|
|
298
|
+
await ensureSubscriptionIncludesStream(
|
|
299
|
+
ctx,
|
|
300
|
+
subscriptionId,
|
|
301
|
+
streamPath,
|
|
302
|
+
{
|
|
201
303
|
type: `pull-wake`,
|
|
202
304
|
streams: [streamPath],
|
|
203
305
|
wake_stream: wakeStream,
|
|
204
306
|
description: `Electric Agents runner ${target.runnerId}`,
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
await ctx.streamClient.addSubscriptionStreams(subscriptionId, [streamPath])
|
|
307
|
+
},
|
|
308
|
+
existing
|
|
309
|
+
)
|
|
209
310
|
return
|
|
210
311
|
}
|
|
211
312
|
|
|
@@ -221,16 +322,18 @@ async function linkStreamToTargetSubscription(
|
|
|
221
322
|
ctx.publicUrl,
|
|
222
323
|
`/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
|
|
223
324
|
)
|
|
224
|
-
|
|
225
|
-
|
|
325
|
+
await ensureSubscriptionIncludesStream(
|
|
326
|
+
ctx,
|
|
327
|
+
subscriptionId,
|
|
328
|
+
streamPath,
|
|
329
|
+
{
|
|
226
330
|
type: `webhook`,
|
|
227
331
|
streams: [streamPath],
|
|
228
332
|
webhook: { url: forwardUrl },
|
|
229
333
|
description: `Electric Agents webhook ${subscriptionId}`,
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
334
|
+
},
|
|
335
|
+
existing
|
|
336
|
+
)
|
|
234
337
|
await ctx.pgDb
|
|
235
338
|
.insert(subscriptionWebhooks)
|
|
236
339
|
.values({
|
|
@@ -36,6 +36,13 @@ const subscriptionProxyBodySchema = Type.Object(
|
|
|
36
36
|
|
|
37
37
|
type SubscriptionProxyBody = Static<typeof subscriptionProxyBodySchema>
|
|
38
38
|
|
|
39
|
+
const subscriptionControlActions = [
|
|
40
|
+
`callback`,
|
|
41
|
+
`claim`,
|
|
42
|
+
`ack`,
|
|
43
|
+
`release`,
|
|
44
|
+
] as const
|
|
45
|
+
|
|
39
46
|
export type DurableStreamsRoutes = RouterType<
|
|
40
47
|
IRequest,
|
|
41
48
|
[TenantContext],
|
|
@@ -48,7 +55,34 @@ export const durableStreamsRouter: DurableStreamsRoutes = Router<
|
|
|
48
55
|
Response | undefined
|
|
49
56
|
>()
|
|
50
57
|
|
|
51
|
-
durableStreamsRouter.
|
|
58
|
+
durableStreamsRouter.put(
|
|
59
|
+
`/__ds/subscriptions/:subscriptionId`,
|
|
60
|
+
putSubscriptionBase
|
|
61
|
+
)
|
|
62
|
+
durableStreamsRouter.get(
|
|
63
|
+
`/__ds/subscriptions/:subscriptionId`,
|
|
64
|
+
getSubscriptionBase
|
|
65
|
+
)
|
|
66
|
+
durableStreamsRouter.delete(
|
|
67
|
+
`/__ds/subscriptions/:subscriptionId`,
|
|
68
|
+
deleteSubscriptionBase
|
|
69
|
+
)
|
|
70
|
+
durableStreamsRouter.post(
|
|
71
|
+
`/__ds/subscriptions/:subscriptionId/streams`,
|
|
72
|
+
postSubscriptionStreams
|
|
73
|
+
)
|
|
74
|
+
durableStreamsRouter.delete(
|
|
75
|
+
`/__ds/subscriptions/:subscriptionId/streams/:streamPath+`,
|
|
76
|
+
deleteSubscriptionStream
|
|
77
|
+
)
|
|
78
|
+
for (const action of subscriptionControlActions) {
|
|
79
|
+
durableStreamsRouter.post(
|
|
80
|
+
`/__ds/subscriptions/:subscriptionId/${action}`,
|
|
81
|
+
subscriptionAction(action)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
durableStreamsRouter.all(`/__ds`, controlPassThrough)
|
|
85
|
+
durableStreamsRouter.all(`/__ds/*`, controlPassThrough)
|
|
52
86
|
durableStreamsRouter.post(`*`, streamAppend)
|
|
53
87
|
durableStreamsRouter.all(`*`, proxyPassThrough)
|
|
54
88
|
|
|
@@ -71,8 +105,9 @@ async function forwardToDurableStreams(
|
|
|
71
105
|
ctx: TenantContext,
|
|
72
106
|
request: IRequest,
|
|
73
107
|
body?: Uint8Array,
|
|
74
|
-
route: `stream` | `
|
|
75
|
-
urlOverride?: string
|
|
108
|
+
route: `stream` | `control` = `stream`,
|
|
109
|
+
urlOverride?: string,
|
|
110
|
+
durableStreamsBearerMode: `overwrite` | `if-missing` = `overwrite`
|
|
76
111
|
): Promise<Response> {
|
|
77
112
|
const headers = new Headers(request.headers)
|
|
78
113
|
headers.delete(`host`)
|
|
@@ -94,11 +129,7 @@ async function forwardToDurableStreams(
|
|
|
94
129
|
body: requestBody,
|
|
95
130
|
durableStreamsUrl: ctx.durableStreamsUrl,
|
|
96
131
|
durableStreamsBearer: ctx.durableStreamsBearer,
|
|
97
|
-
durableStreamsBearerMode
|
|
98
|
-
urlOverride ?? request.url
|
|
99
|
-
)
|
|
100
|
-
? `if-missing`
|
|
101
|
-
: `overwrite`,
|
|
132
|
+
durableStreamsBearerMode,
|
|
102
133
|
durableStreamsRouting: ctx.durableStreamsRouting,
|
|
103
134
|
serviceId: ctx.service,
|
|
104
135
|
dispatcher: ctx.durableStreamsDispatcher,
|
|
@@ -106,23 +137,7 @@ async function forwardToDurableStreams(
|
|
|
106
137
|
})
|
|
107
138
|
}
|
|
108
139
|
|
|
109
|
-
|
|
110
|
-
const match = /^\/v1\/stream-meta\/subscriptions\/([^/]+)(?:\/.*)?$/.exec(
|
|
111
|
-
pathname
|
|
112
|
-
)
|
|
113
|
-
return match ? decodeURIComponent(match[1]!) : null
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function isSubscriptionBasePath(pathname: string): boolean {
|
|
117
|
-
return /^\/v1\/stream-meta\/subscriptions\/[^/]+\/?$/.test(pathname)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function usesSubscriptionScopedBearer(requestUrl: string): boolean {
|
|
121
|
-
const pathname = new URL(requestUrl, `http://localhost`).pathname
|
|
122
|
-
return /^\/v1\/stream-meta\/subscriptions\/[^/]+\/(?:ack|release|callback)\/?$/.test(
|
|
123
|
-
pathname
|
|
124
|
-
)
|
|
125
|
-
}
|
|
140
|
+
type SubscriptionControlAction = (typeof subscriptionControlActions)[number]
|
|
126
141
|
|
|
127
142
|
function rewriteSubscriptionBodyForBackend(
|
|
128
143
|
payload: Record<string, unknown>,
|
|
@@ -239,80 +254,82 @@ function decodeJson(bytes: Uint8Array): Record<string, unknown> | null {
|
|
|
239
254
|
}
|
|
240
255
|
}
|
|
241
256
|
|
|
242
|
-
function
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
): string {
|
|
247
|
-
const match =
|
|
248
|
-
/^(\/v1\/stream-meta\/subscriptions\/[^/]+\/streams\/)(.+)$/.exec(
|
|
249
|
-
requestUrl.pathname
|
|
250
|
-
)
|
|
251
|
-
if (!match) return requestUrl.toString()
|
|
252
|
-
|
|
253
|
-
const [, prefix, encodedPath] = match
|
|
254
|
-
const streamPath = decodeURIComponent(encodedPath!)
|
|
255
|
-
requestUrl.pathname = `${prefix}${encodeURIComponent(
|
|
256
|
-
routingAdapter.toBackendStreamPath(service, streamPath)
|
|
257
|
-
)}`
|
|
258
|
-
return requestUrl.toString()
|
|
257
|
+
function routeParam(request: IRequest, name: string): string {
|
|
258
|
+
const value = request.params[name]
|
|
259
|
+
const raw = Array.isArray(value) ? value[0] : value
|
|
260
|
+
return decodeURIComponent(raw ?? ``)
|
|
259
261
|
}
|
|
260
262
|
|
|
261
|
-
|
|
262
|
-
request: IRequest,
|
|
263
|
+
function subscriptionRoutingAdapter(
|
|
263
264
|
ctx: TenantContext
|
|
264
|
-
):
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const routingAdapter = resolveDurableStreamsRoutingAdapter(
|
|
270
|
-
ctx.durableStreamsRouting
|
|
265
|
+
): DurableStreamsRoutingAdapter {
|
|
266
|
+
return resolveDurableStreamsRoutingAdapter(
|
|
267
|
+
ctx.durableStreamsRouting,
|
|
268
|
+
ctx.durableStreamsUrl
|
|
271
269
|
)
|
|
272
|
-
|
|
273
|
-
let targetWebhookUrl: string | null = null
|
|
274
|
-
let requestUrl = request.url
|
|
270
|
+
}
|
|
275
271
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
ctx.publicUrl,
|
|
287
|
-
`/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
|
|
288
|
-
)
|
|
289
|
-
}
|
|
290
|
-
rewriteSubscriptionBodyForBackend(
|
|
291
|
-
payload as Record<string, unknown>,
|
|
292
|
-
ctx.service,
|
|
293
|
-
routingAdapter
|
|
294
|
-
)
|
|
295
|
-
requestBody = new TextEncoder().encode(JSON.stringify(payload))
|
|
272
|
+
async function rewriteSubscriptionRequestBody(
|
|
273
|
+
request: IRequest,
|
|
274
|
+
ctx: TenantContext,
|
|
275
|
+
subscriptionId: string,
|
|
276
|
+
routingAdapter: DurableStreamsRoutingAdapter
|
|
277
|
+
): Promise<
|
|
278
|
+
| {
|
|
279
|
+
ok: true
|
|
280
|
+
body: Uint8Array
|
|
281
|
+
targetWebhookUrl: string | null
|
|
296
282
|
}
|
|
283
|
+
| { ok: false; response: Response }
|
|
284
|
+
> {
|
|
285
|
+
const body = await readRequestBody(request as Request)
|
|
286
|
+
if (body.length === 0) {
|
|
287
|
+
return { ok: true, body, targetWebhookUrl: null }
|
|
297
288
|
}
|
|
298
289
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
290
|
+
const validation = validateBody(subscriptionProxyBodySchema, body)
|
|
291
|
+
if (!validation.ok) return { ok: false, response: validation.response }
|
|
292
|
+
|
|
293
|
+
const payload = validation.value as SubscriptionProxyBody
|
|
294
|
+
let targetWebhookUrl: string | null = null
|
|
295
|
+
if (payload.webhook?.url !== undefined) {
|
|
296
|
+
targetWebhookUrl = rewriteLoopbackWebhookUrl(payload.webhook.url) ?? null
|
|
297
|
+
payload.webhook.url = appendPathToUrl(
|
|
298
|
+
ctx.publicUrl,
|
|
299
|
+
`/_electric/webhook-forward/${encodeURIComponent(subscriptionId)}`
|
|
307
300
|
)
|
|
308
301
|
}
|
|
309
302
|
|
|
303
|
+
rewriteSubscriptionBodyForBackend(
|
|
304
|
+
payload as Record<string, unknown>,
|
|
305
|
+
ctx.service,
|
|
306
|
+
routingAdapter
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
body: new TextEncoder().encode(JSON.stringify(payload)),
|
|
312
|
+
targetWebhookUrl,
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function forwardSubscriptionRequest(
|
|
317
|
+
request: IRequest,
|
|
318
|
+
ctx: TenantContext,
|
|
319
|
+
routingAdapter: DurableStreamsRoutingAdapter,
|
|
320
|
+
opts: {
|
|
321
|
+
body?: Uint8Array
|
|
322
|
+
requestUrl?: string
|
|
323
|
+
bearerMode?: `overwrite` | `if-missing`
|
|
324
|
+
} = {}
|
|
325
|
+
): Promise<{ upstream: Response; response: Response }> {
|
|
310
326
|
const upstream = await forwardToDurableStreams(
|
|
311
327
|
ctx,
|
|
312
328
|
request,
|
|
313
|
-
|
|
314
|
-
`
|
|
315
|
-
requestUrl
|
|
329
|
+
opts.body,
|
|
330
|
+
`control`,
|
|
331
|
+
opts.requestUrl,
|
|
332
|
+
opts.bearerMode ?? `overwrite`
|
|
316
333
|
)
|
|
317
334
|
let responseBytes: Uint8Array = upstream.body
|
|
318
335
|
? new Uint8Array(await upstream.arrayBuffer())
|
|
@@ -323,42 +340,196 @@ async function subscriptionProxy(
|
|
|
323
340
|
ctx.service,
|
|
324
341
|
routingAdapter
|
|
325
342
|
)
|
|
326
|
-
|
|
343
|
+
return {
|
|
344
|
+
upstream,
|
|
345
|
+
response: responseFromUpstream(upstream, responseBytes),
|
|
346
|
+
}
|
|
347
|
+
}
|
|
327
348
|
|
|
328
|
-
|
|
349
|
+
async function upsertSubscriptionWebhook(
|
|
350
|
+
ctx: TenantContext,
|
|
351
|
+
subscriptionId: string,
|
|
352
|
+
targetWebhookUrl: string
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
await ctx.pgDb
|
|
355
|
+
.insert(subscriptionWebhooks)
|
|
356
|
+
.values({
|
|
357
|
+
tenantId: ctx.service,
|
|
358
|
+
subscriptionId,
|
|
359
|
+
webhookUrl: targetWebhookUrl,
|
|
360
|
+
})
|
|
361
|
+
.onConflictDoUpdate({
|
|
362
|
+
target: [
|
|
363
|
+
subscriptionWebhooks.tenantId,
|
|
364
|
+
subscriptionWebhooks.subscriptionId,
|
|
365
|
+
],
|
|
366
|
+
set: { webhookUrl: targetWebhookUrl },
|
|
367
|
+
})
|
|
368
|
+
}
|
|
329
369
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
)
|
|
370
|
+
async function deleteSubscriptionWebhook(
|
|
371
|
+
ctx: TenantContext,
|
|
372
|
+
subscriptionId: string
|
|
373
|
+
): Promise<void> {
|
|
374
|
+
await ctx.pgDb
|
|
375
|
+
.delete(subscriptionWebhooks)
|
|
376
|
+
.where(
|
|
377
|
+
and(
|
|
378
|
+
eq(subscriptionWebhooks.tenantId, ctx.service),
|
|
379
|
+
eq(subscriptionWebhooks.subscriptionId, subscriptionId)
|
|
341
380
|
)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function rewriteSubscriptionStreamPathInUrl(
|
|
385
|
+
requestUrl: URL,
|
|
386
|
+
service: string,
|
|
387
|
+
routingAdapter: DurableStreamsRoutingAdapter,
|
|
388
|
+
streamPath: string
|
|
389
|
+
): string {
|
|
390
|
+
const prefix = requestUrl.pathname.slice(
|
|
391
|
+
0,
|
|
392
|
+
requestUrl.pathname.indexOf(`/streams/`) + `/streams/`.length
|
|
393
|
+
)
|
|
394
|
+
requestUrl.pathname = `${prefix}${encodeURIComponent(
|
|
395
|
+
routingAdapter.toBackendStreamPath(service, streamPath)
|
|
396
|
+
)}`
|
|
397
|
+
return requestUrl.toString()
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function putSubscriptionBase(
|
|
401
|
+
request: IRequest,
|
|
402
|
+
ctx: TenantContext
|
|
403
|
+
): Promise<Response> {
|
|
404
|
+
const subscriptionId = routeParam(request, `subscriptionId`)
|
|
405
|
+
const routingAdapter = subscriptionRoutingAdapter(ctx)
|
|
406
|
+
const rewrite = await rewriteSubscriptionRequestBody(
|
|
407
|
+
request,
|
|
408
|
+
ctx,
|
|
409
|
+
subscriptionId,
|
|
410
|
+
routingAdapter
|
|
411
|
+
)
|
|
412
|
+
if (!rewrite.ok) return rewrite.response
|
|
413
|
+
|
|
414
|
+
const { upstream, response } = await forwardSubscriptionRequest(
|
|
415
|
+
request,
|
|
416
|
+
ctx,
|
|
417
|
+
routingAdapter,
|
|
418
|
+
{ body: rewrite.body }
|
|
419
|
+
)
|
|
420
|
+
if (upstream.ok && rewrite.targetWebhookUrl) {
|
|
421
|
+
await upsertSubscriptionWebhook(
|
|
422
|
+
ctx,
|
|
423
|
+
subscriptionId,
|
|
424
|
+
rewrite.targetWebhookUrl
|
|
425
|
+
)
|
|
357
426
|
}
|
|
427
|
+
return response
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function getSubscriptionBase(
|
|
431
|
+
request: IRequest,
|
|
432
|
+
ctx: TenantContext
|
|
433
|
+
): Promise<Response> {
|
|
434
|
+
const routingAdapter = subscriptionRoutingAdapter(ctx)
|
|
435
|
+
return (await forwardSubscriptionRequest(request, ctx, routingAdapter))
|
|
436
|
+
.response
|
|
437
|
+
}
|
|
358
438
|
|
|
439
|
+
async function deleteSubscriptionBase(
|
|
440
|
+
request: IRequest,
|
|
441
|
+
ctx: TenantContext
|
|
442
|
+
): Promise<Response> {
|
|
443
|
+
const subscriptionId = routeParam(request, `subscriptionId`)
|
|
444
|
+
const routingAdapter = subscriptionRoutingAdapter(ctx)
|
|
445
|
+
const { upstream, response } = await forwardSubscriptionRequest(
|
|
446
|
+
request,
|
|
447
|
+
ctx,
|
|
448
|
+
routingAdapter
|
|
449
|
+
)
|
|
450
|
+
if (upstream.ok) {
|
|
451
|
+
await deleteSubscriptionWebhook(ctx, subscriptionId)
|
|
452
|
+
}
|
|
359
453
|
return response
|
|
360
454
|
}
|
|
361
455
|
|
|
456
|
+
async function postSubscriptionStreams(
|
|
457
|
+
request: IRequest,
|
|
458
|
+
ctx: TenantContext
|
|
459
|
+
): Promise<Response> {
|
|
460
|
+
const subscriptionId = routeParam(request, `subscriptionId`)
|
|
461
|
+
const routingAdapter = subscriptionRoutingAdapter(ctx)
|
|
462
|
+
const rewrite = await rewriteSubscriptionRequestBody(
|
|
463
|
+
request,
|
|
464
|
+
ctx,
|
|
465
|
+
subscriptionId,
|
|
466
|
+
routingAdapter
|
|
467
|
+
)
|
|
468
|
+
if (!rewrite.ok) return rewrite.response
|
|
469
|
+
|
|
470
|
+
return (
|
|
471
|
+
await forwardSubscriptionRequest(request, ctx, routingAdapter, {
|
|
472
|
+
body: rewrite.body,
|
|
473
|
+
})
|
|
474
|
+
).response
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function deleteSubscriptionStream(
|
|
478
|
+
request: IRequest,
|
|
479
|
+
ctx: TenantContext
|
|
480
|
+
): Promise<Response> {
|
|
481
|
+
const routingAdapter = subscriptionRoutingAdapter(ctx)
|
|
482
|
+
const requestUrl = rewriteSubscriptionStreamPathInUrl(
|
|
483
|
+
new URL(request.url),
|
|
484
|
+
ctx.service,
|
|
485
|
+
routingAdapter,
|
|
486
|
+
routeParam(request, `streamPath`)
|
|
487
|
+
)
|
|
488
|
+
return (
|
|
489
|
+
await forwardSubscriptionRequest(request, ctx, routingAdapter, {
|
|
490
|
+
requestUrl,
|
|
491
|
+
})
|
|
492
|
+
).response
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function subscriptionAction(action: SubscriptionControlAction) {
|
|
496
|
+
return async (request: IRequest, ctx: TenantContext): Promise<Response> => {
|
|
497
|
+
const subscriptionId = routeParam(request, `subscriptionId`)
|
|
498
|
+
const routingAdapter = subscriptionRoutingAdapter(ctx)
|
|
499
|
+
const rewrite = await rewriteSubscriptionRequestBody(
|
|
500
|
+
request,
|
|
501
|
+
ctx,
|
|
502
|
+
subscriptionId,
|
|
503
|
+
routingAdapter
|
|
504
|
+
)
|
|
505
|
+
if (!rewrite.ok) return rewrite.response
|
|
506
|
+
|
|
507
|
+
const bearerMode =
|
|
508
|
+
action === `ack` || action === `release` || action === `callback`
|
|
509
|
+
? `if-missing`
|
|
510
|
+
: `overwrite`
|
|
511
|
+
return (
|
|
512
|
+
await forwardSubscriptionRequest(request, ctx, routingAdapter, {
|
|
513
|
+
body: rewrite.body,
|
|
514
|
+
bearerMode,
|
|
515
|
+
})
|
|
516
|
+
).response
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function controlPassThrough(
|
|
521
|
+
request: IRequest,
|
|
522
|
+
ctx: TenantContext
|
|
523
|
+
): Promise<Response> {
|
|
524
|
+
const upstream = await forwardToDurableStreams(
|
|
525
|
+
ctx,
|
|
526
|
+
request,
|
|
527
|
+
undefined,
|
|
528
|
+
`control`
|
|
529
|
+
)
|
|
530
|
+
return responseFromUpstream(upstream)
|
|
531
|
+
}
|
|
532
|
+
|
|
362
533
|
async function streamAppend(
|
|
363
534
|
request: IRequest,
|
|
364
535
|
ctx: TenantContext
|
|
@@ -391,13 +562,12 @@ async function proxyPassThrough(
|
|
|
391
562
|
const upstream = await forwardToDurableStreams(ctx, request)
|
|
392
563
|
const streamPath = new URL(request.url).pathname
|
|
393
564
|
const method = request.method.toUpperCase()
|
|
394
|
-
const isControlPath = streamPath.startsWith(`/v1/stream-meta/`)
|
|
395
565
|
const endTrackedRead =
|
|
396
|
-
method === `GET`
|
|
566
|
+
method === `GET`
|
|
397
567
|
? await ctx.entityBridgeManager.beginClientRead(streamPath)
|
|
398
568
|
: null
|
|
399
569
|
try {
|
|
400
|
-
if (method === `HEAD`
|
|
570
|
+
if (method === `HEAD`) {
|
|
401
571
|
await ctx.entityBridgeManager.touchByStreamPath(streamPath)
|
|
402
572
|
}
|
|
403
573
|
return responseFromUpstream(upstream)
|