@electric-ax/agents-server 0.3.0
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/LICENSE +177 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/entrypoint.js +7319 -0
- package/dist/index.cjs +7090 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4263 -0
- package/dist/index.js +7053 -0
- package/drizzle/0000_baseline.sql +97 -0
- package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
- package/drizzle/0002_tag_outbox_hardening.sql +14 -0
- package/drizzle/0003_entity_manifest_sources.sql +11 -0
- package/drizzle/0004_tenant_scoping.sql +139 -0
- package/drizzle/0005_pull_wake_control_plane.sql +156 -0
- package/drizzle/meta/0000_snapshot.json +593 -0
- package/drizzle/meta/_journal.json +48 -0
- package/package.json +89 -0
- package/src/authenticated-user-format.ts +17 -0
- package/src/claim-write-token-store.ts +74 -0
- package/src/db/index.ts +53 -0
- package/src/db/schema.ts +490 -0
- package/src/dev-asserted-auth.ts +46 -0
- package/src/dispatch-policy-schema.ts +52 -0
- package/src/electric-agents/adapter-types.ts +70 -0
- package/src/electric-agents/default-entity-schemas.ts +1 -0
- package/src/electric-agents/schema-validator.ts +143 -0
- package/src/electric-agents-http.ts +46 -0
- package/src/electric-agents-types.ts +335 -0
- package/src/entity-bridge-manager.ts +694 -0
- package/src/entity-manager.ts +2601 -0
- package/src/entity-projector.ts +765 -0
- package/src/entity-registry.ts +1162 -0
- package/src/entrypoint-lib.ts +295 -0
- package/src/entrypoint.ts +11 -0
- package/src/host.ts +323 -0
- package/src/index.ts +49 -0
- package/src/manifest-side-effects.ts +183 -0
- package/src/routing/agent-ui-router.ts +81 -0
- package/src/routing/context.ts +35 -0
- package/src/routing/cron-router.ts +45 -0
- package/src/routing/dispatch-policy.ts +248 -0
- package/src/routing/durable-streams-router.ts +407 -0
- package/src/routing/durable-streams-routing-adapter.ts +96 -0
- package/src/routing/electric-proxy-router.ts +61 -0
- package/src/routing/entities-router.ts +484 -0
- package/src/routing/entity-types-router.ts +229 -0
- package/src/routing/global-router.ts +33 -0
- package/src/routing/hooks.ts +123 -0
- package/src/routing/internal-router.ts +741 -0
- package/src/routing/oss-server-router.ts +56 -0
- package/src/routing/runners-router.ts +416 -0
- package/src/routing/schema.ts +141 -0
- package/src/routing/stream-append.ts +196 -0
- package/src/routing/tenant-stream-paths.ts +26 -0
- package/src/runtime-registry.ts +49 -0
- package/src/runtime.ts +537 -0
- package/src/scheduler.ts +788 -0
- package/src/schema-validation.ts +15 -0
- package/src/server.ts +374 -0
- package/src/standalone-runtime.ts +188 -0
- package/src/stream-client.ts +842 -0
- package/src/tag-stream-outbox-drainer.ts +188 -0
- package/src/tenant.ts +25 -0
- package/src/tracing.ts +57 -0
- package/src/utils/electric-url.ts +15 -0
- package/src/utils/log.ts +95 -0
- package/src/utils/server-utils.ts +245 -0
- package/src/utils/webhook-url.ts +33 -0
- package/src/wake-registry.ts +946 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root catch-all for Durable Streams traffic.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { appendPathToUrl } from '@electric-ax/agents-runtime'
|
|
6
|
+
import { Type, type Static } from '@sinclair/typebox'
|
|
7
|
+
import { and, eq } from 'drizzle-orm'
|
|
8
|
+
import { Router } from 'itty-router'
|
|
9
|
+
import { readRequestBody, responseHeaders } from '../electric-agents-http.js'
|
|
10
|
+
import { subscriptionWebhooks } from '../db/schema.js'
|
|
11
|
+
import {
|
|
12
|
+
createStreamAppendRouteRequest,
|
|
13
|
+
electricAgentsStreamAppendRouter,
|
|
14
|
+
} from './stream-append.js'
|
|
15
|
+
import { validateBody } from './schema.js'
|
|
16
|
+
import { rewriteLoopbackWebhookUrl } from '../utils/webhook-url.js'
|
|
17
|
+
import { forwardFetchRequest } from '../utils/server-utils.js'
|
|
18
|
+
import { resolveDurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
|
|
19
|
+
import type { IRequest, RouterType } from 'itty-router'
|
|
20
|
+
import type { TenantContext } from './context.js'
|
|
21
|
+
import type { DurableStreamsRoutingAdapter } from './durable-streams-routing-adapter.js'
|
|
22
|
+
|
|
23
|
+
const subscriptionProxyBodySchema = Type.Object(
|
|
24
|
+
{
|
|
25
|
+
webhook: Type.Optional(
|
|
26
|
+
Type.Object(
|
|
27
|
+
{
|
|
28
|
+
url: Type.String(),
|
|
29
|
+
},
|
|
30
|
+
{ additionalProperties: true }
|
|
31
|
+
)
|
|
32
|
+
),
|
|
33
|
+
},
|
|
34
|
+
{ additionalProperties: true }
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
type SubscriptionProxyBody = Static<typeof subscriptionProxyBodySchema>
|
|
38
|
+
|
|
39
|
+
export type DurableStreamsRoutes = RouterType<
|
|
40
|
+
IRequest,
|
|
41
|
+
[TenantContext],
|
|
42
|
+
Response | undefined
|
|
43
|
+
>
|
|
44
|
+
|
|
45
|
+
export const durableStreamsRouter: DurableStreamsRoutes = Router<
|
|
46
|
+
IRequest,
|
|
47
|
+
[TenantContext],
|
|
48
|
+
Response | undefined
|
|
49
|
+
>()
|
|
50
|
+
|
|
51
|
+
durableStreamsRouter.all(`/v1/stream-meta/subscriptions/*`, subscriptionProxy)
|
|
52
|
+
durableStreamsRouter.post(`*`, streamAppend)
|
|
53
|
+
durableStreamsRouter.all(`*`, proxyPassThrough)
|
|
54
|
+
|
|
55
|
+
function bodyFromBytes(body: Uint8Array): ArrayBuffer {
|
|
56
|
+
return body.buffer.slice(
|
|
57
|
+
body.byteOffset,
|
|
58
|
+
body.byteOffset + body.byteLength
|
|
59
|
+
) as ArrayBuffer
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function responseFromUpstream(response: Response, body?: Uint8Array): Response {
|
|
63
|
+
return new Response(body ? bodyFromBytes(body) : response.body, {
|
|
64
|
+
status: response.status,
|
|
65
|
+
statusText: response.statusText,
|
|
66
|
+
headers: responseHeaders(response),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function forwardToDurableStreams(
|
|
71
|
+
ctx: TenantContext,
|
|
72
|
+
request: IRequest,
|
|
73
|
+
body?: Uint8Array,
|
|
74
|
+
route: `stream` | `stream-meta` = `stream`,
|
|
75
|
+
urlOverride?: string
|
|
76
|
+
): Promise<Response> {
|
|
77
|
+
const headers = new Headers(request.headers)
|
|
78
|
+
headers.delete(`host`)
|
|
79
|
+
|
|
80
|
+
let requestBody = body
|
|
81
|
+
if (
|
|
82
|
+
requestBody === undefined &&
|
|
83
|
+
![`GET`, `HEAD`].includes(request.method.toUpperCase())
|
|
84
|
+
) {
|
|
85
|
+
requestBody = await readRequestBody(request as Request)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return await forwardFetchRequest({
|
|
89
|
+
request: {
|
|
90
|
+
method: request.method.toUpperCase(),
|
|
91
|
+
url: urlOverride ?? request.url,
|
|
92
|
+
headers,
|
|
93
|
+
},
|
|
94
|
+
body: requestBody,
|
|
95
|
+
durableStreamsUrl: ctx.durableStreamsUrl,
|
|
96
|
+
durableStreamsBearer: ctx.durableStreamsBearer,
|
|
97
|
+
durableStreamsBearerMode: usesSubscriptionScopedBearer(
|
|
98
|
+
urlOverride ?? request.url
|
|
99
|
+
)
|
|
100
|
+
? `if-missing`
|
|
101
|
+
: `overwrite`,
|
|
102
|
+
durableStreamsRouting: ctx.durableStreamsRouting,
|
|
103
|
+
serviceId: ctx.service,
|
|
104
|
+
dispatcher: ctx.durableStreamsDispatcher,
|
|
105
|
+
route,
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function subscriptionIdFromPath(pathname: string): string | null {
|
|
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
|
+
}
|
|
126
|
+
|
|
127
|
+
function rewriteSubscriptionBodyForBackend(
|
|
128
|
+
payload: Record<string, unknown>,
|
|
129
|
+
service: string,
|
|
130
|
+
routingAdapter: DurableStreamsRoutingAdapter
|
|
131
|
+
): void {
|
|
132
|
+
if (typeof payload.pattern === `string`) {
|
|
133
|
+
payload.pattern = routingAdapter.toBackendStreamPath(
|
|
134
|
+
service,
|
|
135
|
+
payload.pattern
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
if (Array.isArray(payload.streams)) {
|
|
139
|
+
payload.streams = payload.streams.map((stream) =>
|
|
140
|
+
typeof stream === `string`
|
|
141
|
+
? routingAdapter.toBackendStreamPath(service, stream)
|
|
142
|
+
: stream
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
if (typeof payload.wake_stream === `string`) {
|
|
146
|
+
payload.wake_stream = routingAdapter.toBackendStreamPath(
|
|
147
|
+
service,
|
|
148
|
+
payload.wake_stream
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(payload.acks)) {
|
|
152
|
+
payload.acks = payload.acks.map((ack) => {
|
|
153
|
+
if (!ack || typeof ack !== `object`) return ack
|
|
154
|
+
const next = { ...(ack as Record<string, unknown>) }
|
|
155
|
+
if (typeof next.stream === `string`) {
|
|
156
|
+
next.stream = routingAdapter.toBackendStreamPath(service, next.stream)
|
|
157
|
+
}
|
|
158
|
+
if (typeof next.path === `string`) {
|
|
159
|
+
next.path = routingAdapter.toBackendStreamPath(service, next.path)
|
|
160
|
+
}
|
|
161
|
+
return next
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function rewriteSubscriptionResponseForClient(
|
|
167
|
+
bytes: Uint8Array,
|
|
168
|
+
response: Response,
|
|
169
|
+
service: string,
|
|
170
|
+
routingAdapter: DurableStreamsRoutingAdapter
|
|
171
|
+
): Uint8Array {
|
|
172
|
+
if (!response.headers.get(`content-type`)?.includes(`application/json`)) {
|
|
173
|
+
return bytes
|
|
174
|
+
}
|
|
175
|
+
const payload = decodeJson(bytes)
|
|
176
|
+
if (!payload) return bytes
|
|
177
|
+
|
|
178
|
+
if (typeof payload.pattern === `string`) {
|
|
179
|
+
payload.pattern = routingAdapter.toRuntimeStreamPath(
|
|
180
|
+
service,
|
|
181
|
+
payload.pattern
|
|
182
|
+
)
|
|
183
|
+
}
|
|
184
|
+
if (Array.isArray(payload.streams)) {
|
|
185
|
+
payload.streams = payload.streams.map((stream) => {
|
|
186
|
+
if (typeof stream === `string`) {
|
|
187
|
+
return routingAdapter.toRuntimeStreamPath(service, stream)
|
|
188
|
+
}
|
|
189
|
+
if (
|
|
190
|
+
stream &&
|
|
191
|
+
typeof stream === `object` &&
|
|
192
|
+
typeof (stream as Record<string, unknown>).path === `string`
|
|
193
|
+
) {
|
|
194
|
+
return {
|
|
195
|
+
...(stream as Record<string, unknown>),
|
|
196
|
+
path: routingAdapter.toRuntimeStreamPath(
|
|
197
|
+
service,
|
|
198
|
+
(stream as Record<string, string>).path
|
|
199
|
+
),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return stream
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
if (typeof payload.wake_stream === `string`) {
|
|
206
|
+
payload.wake_stream = routingAdapter.toRuntimeStreamPath(
|
|
207
|
+
service,
|
|
208
|
+
payload.wake_stream
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
if (typeof payload.stream === `string`) {
|
|
212
|
+
payload.stream = routingAdapter.toRuntimeStreamPath(service, payload.stream)
|
|
213
|
+
}
|
|
214
|
+
if (Array.isArray(payload.acks)) {
|
|
215
|
+
payload.acks = payload.acks.map((ack) => {
|
|
216
|
+
if (!ack || typeof ack !== `object`) return ack
|
|
217
|
+
const next = { ...(ack as Record<string, unknown>) }
|
|
218
|
+
if (typeof next.stream === `string`) {
|
|
219
|
+
next.stream = routingAdapter.toRuntimeStreamPath(service, next.stream)
|
|
220
|
+
}
|
|
221
|
+
if (typeof next.path === `string`) {
|
|
222
|
+
next.path = routingAdapter.toRuntimeStreamPath(service, next.path)
|
|
223
|
+
}
|
|
224
|
+
return next
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return new TextEncoder().encode(JSON.stringify(payload))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function decodeJson(bytes: Uint8Array): Record<string, unknown> | null {
|
|
232
|
+
try {
|
|
233
|
+
const parsed = JSON.parse(new TextDecoder().decode(bytes)) as unknown
|
|
234
|
+
return parsed && typeof parsed === `object` && !Array.isArray(parsed)
|
|
235
|
+
? (parsed as Record<string, unknown>)
|
|
236
|
+
: null
|
|
237
|
+
} catch {
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function rewriteSubscriptionStreamPathInUrl(
|
|
243
|
+
requestUrl: URL,
|
|
244
|
+
service: string,
|
|
245
|
+
routingAdapter: DurableStreamsRoutingAdapter
|
|
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()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function subscriptionProxy(
|
|
262
|
+
request: IRequest,
|
|
263
|
+
ctx: TenantContext
|
|
264
|
+
): Promise<Response | undefined> {
|
|
265
|
+
const url = new URL(request.url)
|
|
266
|
+
const subscriptionId = subscriptionIdFromPath(url.pathname)
|
|
267
|
+
if (!subscriptionId) return undefined
|
|
268
|
+
|
|
269
|
+
const routingAdapter = resolveDurableStreamsRoutingAdapter(
|
|
270
|
+
ctx.durableStreamsRouting
|
|
271
|
+
)
|
|
272
|
+
let requestBody: Uint8Array | undefined
|
|
273
|
+
let targetWebhookUrl: string | null = null
|
|
274
|
+
let requestUrl = request.url
|
|
275
|
+
|
|
276
|
+
if ([`PUT`, `POST`].includes(request.method.toUpperCase())) {
|
|
277
|
+
requestBody = await readRequestBody(request as Request)
|
|
278
|
+
if (requestBody.length > 0) {
|
|
279
|
+
const validation = validateBody(subscriptionProxyBodySchema, requestBody)
|
|
280
|
+
if (!validation.ok) return validation.response
|
|
281
|
+
const payload = validation.value as SubscriptionProxyBody
|
|
282
|
+
if (payload.webhook?.url !== undefined) {
|
|
283
|
+
targetWebhookUrl =
|
|
284
|
+
rewriteLoopbackWebhookUrl(payload.webhook.url) ?? null
|
|
285
|
+
payload.webhook.url = appendPathToUrl(
|
|
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))
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (
|
|
300
|
+
request.method.toUpperCase() === `DELETE` &&
|
|
301
|
+
/\/streams\/.+$/.test(url.pathname)
|
|
302
|
+
) {
|
|
303
|
+
requestUrl = rewriteSubscriptionStreamPathInUrl(
|
|
304
|
+
url,
|
|
305
|
+
ctx.service,
|
|
306
|
+
routingAdapter
|
|
307
|
+
)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const upstream = await forwardToDurableStreams(
|
|
311
|
+
ctx,
|
|
312
|
+
request,
|
|
313
|
+
requestBody,
|
|
314
|
+
`stream-meta`,
|
|
315
|
+
requestUrl
|
|
316
|
+
)
|
|
317
|
+
let responseBytes: Uint8Array = upstream.body
|
|
318
|
+
? new Uint8Array(await upstream.arrayBuffer())
|
|
319
|
+
: new Uint8Array()
|
|
320
|
+
responseBytes = rewriteSubscriptionResponseForClient(
|
|
321
|
+
responseBytes,
|
|
322
|
+
upstream,
|
|
323
|
+
ctx.service,
|
|
324
|
+
routingAdapter
|
|
325
|
+
)
|
|
326
|
+
const response = responseFromUpstream(upstream, responseBytes)
|
|
327
|
+
|
|
328
|
+
if (!upstream.ok) return response
|
|
329
|
+
|
|
330
|
+
if (
|
|
331
|
+
request.method.toUpperCase() === `DELETE` &&
|
|
332
|
+
isSubscriptionBasePath(url.pathname)
|
|
333
|
+
) {
|
|
334
|
+
await ctx.pgDb
|
|
335
|
+
.delete(subscriptionWebhooks)
|
|
336
|
+
.where(
|
|
337
|
+
and(
|
|
338
|
+
eq(subscriptionWebhooks.tenantId, ctx.service),
|
|
339
|
+
eq(subscriptionWebhooks.subscriptionId, subscriptionId)
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
} else if (targetWebhookUrl) {
|
|
343
|
+
await ctx.pgDb
|
|
344
|
+
.insert(subscriptionWebhooks)
|
|
345
|
+
.values({
|
|
346
|
+
tenantId: ctx.service,
|
|
347
|
+
subscriptionId,
|
|
348
|
+
webhookUrl: targetWebhookUrl,
|
|
349
|
+
})
|
|
350
|
+
.onConflictDoUpdate({
|
|
351
|
+
target: [
|
|
352
|
+
subscriptionWebhooks.tenantId,
|
|
353
|
+
subscriptionWebhooks.subscriptionId,
|
|
354
|
+
],
|
|
355
|
+
set: { webhookUrl: targetWebhookUrl },
|
|
356
|
+
})
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return response
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function streamAppend(
|
|
363
|
+
request: IRequest,
|
|
364
|
+
ctx: TenantContext
|
|
365
|
+
): Promise<Response | undefined> {
|
|
366
|
+
return await electricAgentsStreamAppendRouter.fetch(
|
|
367
|
+
createStreamAppendRouteRequest(request as Request),
|
|
368
|
+
ctx.runtime,
|
|
369
|
+
(req, body) =>
|
|
370
|
+
forwardFetchRequest({
|
|
371
|
+
request: {
|
|
372
|
+
method: req.method,
|
|
373
|
+
url: req.url,
|
|
374
|
+
headers: req.headers,
|
|
375
|
+
},
|
|
376
|
+
body,
|
|
377
|
+
durableStreamsUrl: ctx.durableStreamsUrl,
|
|
378
|
+
durableStreamsBearer: ctx.durableStreamsBearer,
|
|
379
|
+
durableStreamsBearerMode: `overwrite`,
|
|
380
|
+
durableStreamsRouting: ctx.durableStreamsRouting,
|
|
381
|
+
serviceId: ctx.service,
|
|
382
|
+
dispatcher: ctx.durableStreamsDispatcher,
|
|
383
|
+
})
|
|
384
|
+
)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function proxyPassThrough(
|
|
388
|
+
request: IRequest,
|
|
389
|
+
ctx: TenantContext
|
|
390
|
+
): Promise<Response> {
|
|
391
|
+
const upstream = await forwardToDurableStreams(ctx, request)
|
|
392
|
+
const streamPath = new URL(request.url).pathname
|
|
393
|
+
const method = request.method.toUpperCase()
|
|
394
|
+
const isControlPath = streamPath.startsWith(`/v1/stream-meta/`)
|
|
395
|
+
const endTrackedRead =
|
|
396
|
+
method === `GET` && !isControlPath
|
|
397
|
+
? await ctx.entityBridgeManager.beginClientRead(streamPath)
|
|
398
|
+
: null
|
|
399
|
+
try {
|
|
400
|
+
if (method === `HEAD` && !isControlPath) {
|
|
401
|
+
await ctx.entityBridgeManager.touchByStreamPath(streamPath)
|
|
402
|
+
}
|
|
403
|
+
return responseFromUpstream(upstream)
|
|
404
|
+
} finally {
|
|
405
|
+
await endTrackedRead?.()
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import {
|
|
2
|
+
prefixTenantStreamPath,
|
|
3
|
+
stripTenantStreamPrefix,
|
|
4
|
+
} from './tenant-stream-paths.js'
|
|
5
|
+
|
|
6
|
+
export interface DurableStreamsRoutingInput {
|
|
7
|
+
durableStreamsUrl: string
|
|
8
|
+
serviceId: string
|
|
9
|
+
requestUrl: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DurableStreamsRoutingAdapter {
|
|
13
|
+
streamUrl(input: DurableStreamsRoutingInput): URL
|
|
14
|
+
streamMetaUrl(input: DurableStreamsRoutingInput): URL
|
|
15
|
+
toBackendStreamPath(serviceId: string, streamPath: string): string
|
|
16
|
+
toRuntimeStreamPath(serviceId: string, streamPath: string): string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function appendSearch(target: URL, source: URL): URL {
|
|
20
|
+
target.search = source.search
|
|
21
|
+
return target
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function removeServiceQuery(target: URL): URL {
|
|
25
|
+
target.searchParams.delete(`service`)
|
|
26
|
+
return target
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function logicalStreamPathFromRequest(
|
|
30
|
+
requestUrl: string,
|
|
31
|
+
serviceId: string
|
|
32
|
+
): { incomingUrl: URL; streamPath: string } {
|
|
33
|
+
const incomingUrl = new URL(requestUrl, `http://localhost`)
|
|
34
|
+
const segments = incomingUrl.pathname.split(`/`).filter(Boolean)
|
|
35
|
+
if (segments[0] === `v1` && segments[1] === `stream`) {
|
|
36
|
+
return {
|
|
37
|
+
incomingUrl,
|
|
38
|
+
streamPath: segments.length > 2 ? `/${segments.slice(3).join(`/`)}` : `/`,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
incomingUrl,
|
|
44
|
+
streamPath: incomingUrl.pathname || `/${serviceId}`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function backendStreamUrl(
|
|
49
|
+
input: DurableStreamsRoutingInput,
|
|
50
|
+
backendStreamPath: string
|
|
51
|
+
): URL {
|
|
52
|
+
const path = backendStreamPath.replace(/^\/+/, ``)
|
|
53
|
+
const target = new URL(`/v1/stream/${path}`, input.durableStreamsUrl)
|
|
54
|
+
return target
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function streamMetaUrlWithoutService(input: DurableStreamsRoutingInput): URL {
|
|
58
|
+
const incomingUrl = new URL(input.requestUrl, `http://localhost`)
|
|
59
|
+
return removeServiceQuery(
|
|
60
|
+
appendSearch(
|
|
61
|
+
new URL(incomingUrl.pathname, input.durableStreamsUrl),
|
|
62
|
+
incomingUrl
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const pathPrefixedSingleTenantDurableStreamsRoutingAdapter: DurableStreamsRoutingAdapter =
|
|
68
|
+
{
|
|
69
|
+
streamUrl(input) {
|
|
70
|
+
const { incomingUrl, streamPath } = logicalStreamPathFromRequest(
|
|
71
|
+
input.requestUrl,
|
|
72
|
+
input.serviceId
|
|
73
|
+
)
|
|
74
|
+
const target = backendStreamUrl(
|
|
75
|
+
input,
|
|
76
|
+
prefixTenantStreamPath(streamPath, input.serviceId)
|
|
77
|
+
)
|
|
78
|
+
return removeServiceQuery(appendSearch(target, incomingUrl))
|
|
79
|
+
},
|
|
80
|
+
|
|
81
|
+
streamMetaUrl: streamMetaUrlWithoutService,
|
|
82
|
+
|
|
83
|
+
toBackendStreamPath(serviceId, streamPath) {
|
|
84
|
+
return prefixTenantStreamPath(streamPath, serviceId)
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
toRuntimeStreamPath(serviceId, streamPath) {
|
|
88
|
+
return stripTenantStreamPrefix(streamPath, serviceId)
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resolveDurableStreamsRoutingAdapter(
|
|
93
|
+
adapter?: DurableStreamsRoutingAdapter
|
|
94
|
+
): DurableStreamsRoutingAdapter {
|
|
95
|
+
return adapter ?? pathPrefixedSingleTenantDurableStreamsRoutingAdapter
|
|
96
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxies GET requests under /_electric/electric/* to the configured Electric
|
|
3
|
+
* SQL HTTP API.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Router } from 'itty-router'
|
|
7
|
+
import { apiError, responseHeaders } from '../electric-agents-http.js'
|
|
8
|
+
import { buildElectricProxyTarget } from '../utils/server-utils.js'
|
|
9
|
+
import type { IRequest, RouterType } from 'itty-router'
|
|
10
|
+
import type { TenantContext } from './context.js'
|
|
11
|
+
|
|
12
|
+
export type ElectricProxyRoutes = RouterType<
|
|
13
|
+
IRequest,
|
|
14
|
+
[TenantContext],
|
|
15
|
+
Response | undefined
|
|
16
|
+
>
|
|
17
|
+
|
|
18
|
+
export const electricProxyRouter: ElectricProxyRoutes = Router<
|
|
19
|
+
IRequest,
|
|
20
|
+
[TenantContext],
|
|
21
|
+
Response | undefined
|
|
22
|
+
>({
|
|
23
|
+
base: `/_electric/electric`,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
electricProxyRouter.get(`/*`, proxyElectric)
|
|
27
|
+
|
|
28
|
+
async function proxyElectric(
|
|
29
|
+
request: IRequest,
|
|
30
|
+
ctx: TenantContext
|
|
31
|
+
): Promise<Response> {
|
|
32
|
+
if (!ctx.electricUrl) {
|
|
33
|
+
return apiError(500, `ELECTRIC_PROXY_FAILED`, `Electric URL not configured`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const target = buildElectricProxyTarget({
|
|
37
|
+
incomingUrl: new URL(request.url),
|
|
38
|
+
electricUrl: ctx.electricUrl,
|
|
39
|
+
electricSecret: ctx.electricSecret,
|
|
40
|
+
tenantId: ctx.service,
|
|
41
|
+
})
|
|
42
|
+
const headers = new Headers(request.headers)
|
|
43
|
+
headers.delete(`host`)
|
|
44
|
+
|
|
45
|
+
let upstream: Response
|
|
46
|
+
try {
|
|
47
|
+
upstream = await fetch(target, { method: request.method, headers })
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return apiError(
|
|
50
|
+
502,
|
|
51
|
+
`ELECTRIC_PROXY_FAILED`,
|
|
52
|
+
err instanceof Error ? err.message : String(err)
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return new Response(upstream.body, {
|
|
57
|
+
status: upstream.status,
|
|
58
|
+
statusText: upstream.statusText,
|
|
59
|
+
headers: responseHeaders(upstream),
|
|
60
|
+
})
|
|
61
|
+
}
|