@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.
Files changed (68) hide show
  1. package/LICENSE +177 -0
  2. package/dist/chunk-Cl8Af3a2.js +11 -0
  3. package/dist/entrypoint.js +7319 -0
  4. package/dist/index.cjs +7090 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4263 -0
  7. package/dist/index.js +7053 -0
  8. package/drizzle/0000_baseline.sql +97 -0
  9. package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
  10. package/drizzle/0002_tag_outbox_hardening.sql +14 -0
  11. package/drizzle/0003_entity_manifest_sources.sql +11 -0
  12. package/drizzle/0004_tenant_scoping.sql +139 -0
  13. package/drizzle/0005_pull_wake_control_plane.sql +156 -0
  14. package/drizzle/meta/0000_snapshot.json +593 -0
  15. package/drizzle/meta/_journal.json +48 -0
  16. package/package.json +89 -0
  17. package/src/authenticated-user-format.ts +17 -0
  18. package/src/claim-write-token-store.ts +74 -0
  19. package/src/db/index.ts +53 -0
  20. package/src/db/schema.ts +490 -0
  21. package/src/dev-asserted-auth.ts +46 -0
  22. package/src/dispatch-policy-schema.ts +52 -0
  23. package/src/electric-agents/adapter-types.ts +70 -0
  24. package/src/electric-agents/default-entity-schemas.ts +1 -0
  25. package/src/electric-agents/schema-validator.ts +143 -0
  26. package/src/electric-agents-http.ts +46 -0
  27. package/src/electric-agents-types.ts +335 -0
  28. package/src/entity-bridge-manager.ts +694 -0
  29. package/src/entity-manager.ts +2601 -0
  30. package/src/entity-projector.ts +765 -0
  31. package/src/entity-registry.ts +1162 -0
  32. package/src/entrypoint-lib.ts +295 -0
  33. package/src/entrypoint.ts +11 -0
  34. package/src/host.ts +323 -0
  35. package/src/index.ts +49 -0
  36. package/src/manifest-side-effects.ts +183 -0
  37. package/src/routing/agent-ui-router.ts +81 -0
  38. package/src/routing/context.ts +35 -0
  39. package/src/routing/cron-router.ts +45 -0
  40. package/src/routing/dispatch-policy.ts +248 -0
  41. package/src/routing/durable-streams-router.ts +407 -0
  42. package/src/routing/durable-streams-routing-adapter.ts +96 -0
  43. package/src/routing/electric-proxy-router.ts +61 -0
  44. package/src/routing/entities-router.ts +484 -0
  45. package/src/routing/entity-types-router.ts +229 -0
  46. package/src/routing/global-router.ts +33 -0
  47. package/src/routing/hooks.ts +123 -0
  48. package/src/routing/internal-router.ts +741 -0
  49. package/src/routing/oss-server-router.ts +56 -0
  50. package/src/routing/runners-router.ts +416 -0
  51. package/src/routing/schema.ts +141 -0
  52. package/src/routing/stream-append.ts +196 -0
  53. package/src/routing/tenant-stream-paths.ts +26 -0
  54. package/src/runtime-registry.ts +49 -0
  55. package/src/runtime.ts +537 -0
  56. package/src/scheduler.ts +788 -0
  57. package/src/schema-validation.ts +15 -0
  58. package/src/server.ts +374 -0
  59. package/src/standalone-runtime.ts +188 -0
  60. package/src/stream-client.ts +842 -0
  61. package/src/tag-stream-outbox-drainer.ts +188 -0
  62. package/src/tenant.ts +25 -0
  63. package/src/tracing.ts +57 -0
  64. package/src/utils/electric-url.ts +15 -0
  65. package/src/utils/log.ts +95 -0
  66. package/src/utils/server-utils.ts +245 -0
  67. package/src/utils/webhook-url.ts +33 -0
  68. package/src/wake-registry.ts +946 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * OSS server-only wrapper routes.
3
+ *
4
+ * The exported global router stays library-safe. The standalone OSS server adds
5
+ * its dashboard and optional mock-agent handler here before falling through to
6
+ * the global router.
7
+ */
8
+
9
+ import { AutoRouter, status } from 'itty-router'
10
+ import { agentUiRouter } from './agent-ui-router.js'
11
+ import { globalRouter } from './global-router.js'
12
+ import { applyCors, errorMapper, preflightCors } from './hooks.js'
13
+ import type { RuntimeHandler } from '@electric-ax/agents-runtime'
14
+ import type { AutoRouterType, IRequest } from 'itty-router'
15
+ import type { TenantContext } from './context.js'
16
+
17
+ export interface OssServerContext extends TenantContext {
18
+ mockAgent?: { runtime: RuntimeHandler }
19
+ }
20
+
21
+ export type OssServerRoutes = AutoRouterType<
22
+ IRequest,
23
+ [OssServerContext],
24
+ Response
25
+ >
26
+
27
+ export const ossServerRouter: OssServerRoutes = AutoRouter<
28
+ IRequest,
29
+ [OssServerContext],
30
+ Response
31
+ >({
32
+ before: [preflightCors],
33
+ catch: errorMapper,
34
+ finally: [applyCors],
35
+ })
36
+
37
+ ossServerRouter.get(`/`, redirectToAgentUi)
38
+ ossServerRouter.head(`/`, redirectToAgentUi)
39
+ ossServerRouter.all(`/__agent_ui/*`, agentUiRouter.fetch)
40
+ ossServerRouter.post(`/_electric/mock-agent-handler`, mockAgentHandler)
41
+ ossServerRouter.all(`*`, (request, ctx) => globalRouter.fetch(request, ctx))
42
+
43
+ function redirectToAgentUi(): Response {
44
+ return new Response(null, {
45
+ status: 302,
46
+ headers: { location: `/__agent_ui/` },
47
+ })
48
+ }
49
+
50
+ async function mockAgentHandler(
51
+ request: IRequest,
52
+ ctx: OssServerContext
53
+ ): Promise<Response> {
54
+ if (!ctx.mockAgent) return status(404)
55
+ return await ctx.mockAgent.runtime.handleWebhookRequest(request as Request)
56
+ }
@@ -0,0 +1,416 @@
1
+ import { appendPathToUrl } from '@electric-ax/agents-runtime'
2
+ import { Type, type Static } from '@sinclair/typebox'
3
+ import { Router, json, status } from 'itty-router'
4
+ import { consumerCallbacks } from '../db/schema.js'
5
+ import { apiError } from '../electric-agents-http.js'
6
+ import { ElectricAgentsError } from '../entity-manager.js'
7
+ import {
8
+ ErrCodeInvalidRequest,
9
+ ErrCodeNotFound,
10
+ ErrCodeNotRunning,
11
+ ErrCodeUnauthorized,
12
+ } from '../electric-agents-types.js'
13
+ import { routeBody, withSchema } from './schema.js'
14
+ import { subscriptionIdForDispatchTarget } from './dispatch-policy.js'
15
+ import { withLeadingSlash } from './tenant-stream-paths.js'
16
+ import type { JsonRouteRequest } from './schema.js'
17
+ import type { RouterType } from 'itty-router'
18
+ import type { TenantContext } from './context.js'
19
+ import {
20
+ DurableStreamsSubscriptionError,
21
+ type SubscriptionClaimResponse,
22
+ } from '../stream-client.js'
23
+
24
+ interface RunnersRouteRequest extends JsonRouteRequest {}
25
+
26
+ type RunnersRouteArgs = [TenantContext]
27
+ type RunnersRouteResult = Response | undefined
28
+
29
+ export type RunnersRoutes = RouterType<
30
+ RunnersRouteRequest,
31
+ RunnersRouteArgs,
32
+ RunnersRouteResult
33
+ >
34
+
35
+ const registerRunnerBodySchema = Type.Object({
36
+ id: Type.String(),
37
+ owner_user_id: Type.Optional(Type.String()),
38
+ label: Type.String(),
39
+ kind: Type.Optional(
40
+ Type.Union([
41
+ Type.Literal(`local`),
42
+ Type.Literal(`cloud-worker`),
43
+ Type.Literal(`sandbox`),
44
+ Type.Literal(`ci`),
45
+ Type.Literal(`server`),
46
+ ])
47
+ ),
48
+ admin_status: Type.Optional(
49
+ Type.Union([Type.Literal(`enabled`), Type.Literal(`disabled`)])
50
+ ),
51
+ wake_stream: Type.Optional(Type.String()),
52
+ })
53
+
54
+ const heartbeatBodySchema = Type.Object({
55
+ lease_ms: Type.Optional(Type.Number()),
56
+ wake_stream_offset: Type.Optional(Type.String()),
57
+ wakeStreamOffset: Type.Optional(Type.String()),
58
+ liveness_lease_expires_at: Type.Optional(Type.String()),
59
+ })
60
+
61
+ const claimBodySchema = Type.Object(
62
+ {
63
+ subscription_id: Type.Optional(Type.String()),
64
+ stream: Type.Optional(Type.String()),
65
+ generation: Type.Optional(Type.Number()),
66
+ ts: Type.Optional(Type.Union([Type.String(), Type.Number()])),
67
+ },
68
+ { additionalProperties: true }
69
+ )
70
+
71
+ type RegisterRunnerBody = Static<typeof registerRunnerBodySchema>
72
+ type HeartbeatBody = Static<typeof heartbeatBodySchema>
73
+ type ClaimBody = Static<typeof claimBodySchema>
74
+
75
+ export const runnersRouter: RunnersRoutes = Router<
76
+ RunnersRouteRequest,
77
+ RunnersRouteArgs,
78
+ RunnersRouteResult
79
+ >({
80
+ base: `/_electric/runners`,
81
+ })
82
+
83
+ runnersRouter.post(`/`, withSchema(registerRunnerBodySchema), registerRunner)
84
+ runnersRouter.get(`/`, listRunners)
85
+ runnersRouter.get(`/:id`, getRunner)
86
+ runnersRouter.post(`/:id/heartbeat`, withSchema(heartbeatBodySchema), heartbeat)
87
+ runnersRouter.post(`/:id/enable`, setEnabled)
88
+ runnersRouter.post(`/:id/disable`, setDisabled)
89
+ runnersRouter.post(`/:id/claim`, withSchema(claimBodySchema), claimWake)
90
+
91
+ function routeParam(request: RunnersRouteRequest, name: string): string {
92
+ const value = request.params[name]
93
+ return decodeURIComponent(Array.isArray(value) ? value[0]! : value)
94
+ }
95
+
96
+ function firstQueryValue(
97
+ value: string | Array<string> | undefined
98
+ ): string | undefined {
99
+ return Array.isArray(value) ? value[0] : value
100
+ }
101
+
102
+ async function registerRunner(
103
+ request: RunnersRouteRequest,
104
+ ctx: TenantContext
105
+ ): Promise<Response> {
106
+ const parsed = routeBody<RegisterRunnerBody>(request)
107
+ const ownerUserId = parsed.owner_user_id ?? ctx.authenticatedUser?.userId
108
+ if (!ownerUserId) {
109
+ throw new ElectricAgentsError(
110
+ ErrCodeInvalidRequest,
111
+ `owner_user_id is required when no authenticated user is present`,
112
+ 400
113
+ )
114
+ }
115
+ if (ctx.authenticatedUser && ownerUserId !== ctx.authenticatedUser.userId) {
116
+ throw new ElectricAgentsError(
117
+ ErrCodeUnauthorized,
118
+ `owner_user_id must match the authenticated user`,
119
+ 403
120
+ )
121
+ }
122
+
123
+ const runner = await ctx.entityManager.registry.createRunner({
124
+ id: parsed.id,
125
+ ownerUserId,
126
+ label: parsed.label,
127
+ kind: parsed.kind,
128
+ adminStatus: parsed.admin_status,
129
+ wakeStream: parsed.wake_stream,
130
+ })
131
+ await ctx.streamClient.ensure(runner.wake_stream, {
132
+ contentType: `application/json`,
133
+ })
134
+ return json(runner, { status: 201 })
135
+ }
136
+
137
+ async function listRunners(
138
+ request: RunnersRouteRequest,
139
+ ctx: TenantContext
140
+ ): Promise<Response> {
141
+ const requestedOwner = firstQueryValue(request.query.owner_user_id)
142
+ if (
143
+ ctx.authenticatedUser &&
144
+ requestedOwner &&
145
+ requestedOwner !== ctx.authenticatedUser.userId
146
+ ) {
147
+ throw new ElectricAgentsError(
148
+ ErrCodeUnauthorized,
149
+ `owner_user_id must match the authenticated user`,
150
+ 403
151
+ )
152
+ }
153
+ const runners = await ctx.entityManager.registry.listRunners({
154
+ ownerUserId: ctx.authenticatedUser?.userId ?? requestedOwner,
155
+ })
156
+ return json(runners)
157
+ }
158
+
159
+ async function getRunner(
160
+ request: RunnersRouteRequest,
161
+ ctx: TenantContext
162
+ ): Promise<Response> {
163
+ const runner = await requireRunner(ctx, routeParam(request, `id`))
164
+ assertRunnerOwnerIfAuthenticated(ctx, runner.owner_user_id)
165
+ return json(runner)
166
+ }
167
+
168
+ async function heartbeat(
169
+ request: RunnersRouteRequest,
170
+ ctx: TenantContext
171
+ ): Promise<Response> {
172
+ const runnerId = routeParam(request, `id`)
173
+ const existing = await requireRunner(ctx, runnerId)
174
+ assertRunnerOwnerIfAuthenticated(ctx, existing.owner_user_id)
175
+ const parsed = routeBody<HeartbeatBody>(request)
176
+ const runner = await ctx.entityManager.registry.heartbeatRunner({
177
+ runnerId,
178
+ leaseMs: parsed.lease_ms,
179
+ wakeStreamOffset: parsed.wake_stream_offset ?? parsed.wakeStreamOffset,
180
+ livenessLeaseExpiresAt: parsed.liveness_lease_expires_at
181
+ ? new Date(parsed.liveness_lease_expires_at)
182
+ : undefined,
183
+ })
184
+ if (!runner) {
185
+ throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404)
186
+ }
187
+ return json(runner)
188
+ }
189
+
190
+ async function setEnabled(
191
+ request: RunnersRouteRequest,
192
+ ctx: TenantContext
193
+ ): Promise<Response> {
194
+ return await setRunnerStatus(request, ctx, `enabled`)
195
+ }
196
+
197
+ async function setDisabled(
198
+ request: RunnersRouteRequest,
199
+ ctx: TenantContext
200
+ ): Promise<Response> {
201
+ return await setRunnerStatus(request, ctx, `disabled`)
202
+ }
203
+
204
+ async function setRunnerStatus(
205
+ request: RunnersRouteRequest,
206
+ ctx: TenantContext,
207
+ adminStatus: `enabled` | `disabled`
208
+ ): Promise<Response> {
209
+ const runnerId = routeParam(request, `id`)
210
+ const existing = await requireRunner(ctx, runnerId)
211
+ assertRunnerOwnerIfAuthenticated(ctx, existing.owner_user_id)
212
+ const runner = await ctx.entityManager.registry.setRunnerAdminStatus(
213
+ runnerId,
214
+ adminStatus
215
+ )
216
+ if (!runner) {
217
+ throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404)
218
+ }
219
+ return json(runner)
220
+ }
221
+
222
+ async function claimWake(
223
+ request: RunnersRouteRequest,
224
+ ctx: TenantContext
225
+ ): Promise<Response> {
226
+ const runnerId = routeParam(request, `id`)
227
+ if (!ctx.authenticatedUser) {
228
+ throw new ElectricAgentsError(
229
+ ErrCodeUnauthorized,
230
+ `Authentication is required to claim runner work`,
231
+ 401
232
+ )
233
+ }
234
+ const runner = await requireRunner(ctx, runnerId)
235
+ if (runner.owner_user_id !== ctx.authenticatedUser.userId) {
236
+ throw new ElectricAgentsError(
237
+ ErrCodeUnauthorized,
238
+ `Runner claim requires the authenticated owner`,
239
+ 403
240
+ )
241
+ }
242
+ if (runner.admin_status !== `enabled`) {
243
+ throw new ElectricAgentsError(ErrCodeNotRunning, `Runner is disabled`, 409)
244
+ }
245
+
246
+ const parsed = routeBody<ClaimBody>(request)
247
+ const expectedSubscriptionId = subscriptionIdForDispatchTarget({
248
+ type: `runner`,
249
+ runnerId,
250
+ })
251
+ const subscriptionId = parsed.subscription_id ?? expectedSubscriptionId
252
+ if (
253
+ subscriptionId !== expectedSubscriptionId &&
254
+ !subscriptionId.startsWith(`${expectedSubscriptionId}:`)
255
+ ) {
256
+ throw new ElectricAgentsError(
257
+ ErrCodeInvalidRequest,
258
+ `Wake event subscription_id does not match runner`,
259
+ 400
260
+ )
261
+ }
262
+
263
+ const claim = await ctx.streamClient
264
+ .claimSubscription(subscriptionId, runnerId)
265
+ .catch((err) => {
266
+ if (isExpectedClaimConflict(err)) {
267
+ return err
268
+ }
269
+ throw err
270
+ })
271
+ if (claim instanceof DurableStreamsSubscriptionError) {
272
+ return apiError(
273
+ claim.status,
274
+ claim.code ?? `SUBSCRIPTION_CLAIM_FAILED`,
275
+ claim.errorMessage ?? claim.body
276
+ )
277
+ }
278
+ if (!claim) return status(204)
279
+
280
+ const notification = await notificationFromClaim(ctx, {
281
+ runnerId,
282
+ runnerWakeStream: runner.wake_stream,
283
+ subscriptionId,
284
+ claim,
285
+ })
286
+ return json(notification)
287
+ }
288
+
289
+ function isExpectedClaimConflict(
290
+ err: unknown
291
+ ): err is DurableStreamsSubscriptionError {
292
+ return (
293
+ err instanceof DurableStreamsSubscriptionError &&
294
+ err.status === 409 &&
295
+ (err.code === `NO_PENDING_WORK` || err.code === `ALREADY_CLAIMED`)
296
+ )
297
+ }
298
+
299
+ async function requireRunner(ctx: TenantContext, runnerId: string) {
300
+ const runner = await ctx.entityManager.registry.getRunner(runnerId)
301
+ if (!runner) {
302
+ throw new ElectricAgentsError(ErrCodeNotFound, `Runner not found`, 404)
303
+ }
304
+ return runner
305
+ }
306
+
307
+ function assertRunnerOwnerIfAuthenticated(
308
+ ctx: TenantContext,
309
+ ownerUserId: string
310
+ ): void {
311
+ if (!ctx.authenticatedUser) return
312
+ if (ownerUserId === ctx.authenticatedUser.userId) return
313
+ throw new ElectricAgentsError(
314
+ ErrCodeUnauthorized,
315
+ `Runner access requires the authenticated owner`,
316
+ 403
317
+ )
318
+ }
319
+
320
+ async function notificationFromClaim(
321
+ ctx: TenantContext,
322
+ input: {
323
+ runnerId: string
324
+ runnerWakeStream: string
325
+ subscriptionId: string
326
+ claim: SubscriptionClaimResponse
327
+ }
328
+ ): Promise<Record<string, unknown>> {
329
+ const primary =
330
+ input.claim.streams.find((stream) => stream.has_pending === true) ??
331
+ input.claim.streams[0]
332
+ if (!primary?.path) {
333
+ throw new ElectricAgentsError(
334
+ ErrCodeInvalidRequest,
335
+ `Claim response did not include a stream`,
336
+ 502
337
+ )
338
+ }
339
+
340
+ const primaryStream = withLeadingSlash(primary.path)
341
+ const entity =
342
+ await ctx.entityManager.registry.getEntityByStream(primaryStream)
343
+ if (!entity) {
344
+ throw new ElectricAgentsError(
345
+ ErrCodeNotFound,
346
+ `Claim stream is not attached to an entity`,
347
+ 404
348
+ )
349
+ }
350
+ if (entity.status === `stopped`) {
351
+ await ctx.streamClient.releaseSubscription(
352
+ input.subscriptionId,
353
+ input.claim.token,
354
+ {
355
+ wake_id: input.claim.wake_id,
356
+ generation: input.claim.generation,
357
+ }
358
+ )
359
+ return { done: true }
360
+ }
361
+
362
+ await ctx.pgDb
363
+ .insert(consumerCallbacks)
364
+ .values({
365
+ tenantId: ctx.service,
366
+ consumerId: input.claim.wake_id,
367
+ callbackUrl: `ds-subscription:${input.subscriptionId}`,
368
+ primaryStream,
369
+ })
370
+ .onConflictDoUpdate({
371
+ target: [consumerCallbacks.tenantId, consumerCallbacks.consumerId],
372
+ set: {
373
+ callbackUrl: `ds-subscription:${input.subscriptionId}`,
374
+ primaryStream,
375
+ },
376
+ })
377
+
378
+ await ctx.entityManager.registry.materializeActiveClaim({
379
+ consumerId: input.claim.wake_id,
380
+ epoch: input.claim.generation,
381
+ wakeId: input.claim.wake_id,
382
+ entityUrl: entity.url,
383
+ streamPath: primaryStream,
384
+ runnerId: input.runnerId,
385
+ leaseExpiresAt: input.claim.lease_ttl_ms
386
+ ? new Date(Date.now() + input.claim.lease_ttl_ms)
387
+ : undefined,
388
+ })
389
+ await ctx.entityManager.registry.updateStatus(entity.url, `running`)
390
+
391
+ const streams = input.claim.streams.map((stream) => ({
392
+ path: withLeadingSlash(stream.path),
393
+ offset: stream.tail_offset ?? ``,
394
+ }))
395
+ return {
396
+ consumerId: input.claim.wake_id,
397
+ epoch: input.claim.generation,
398
+ wakeId: input.claim.wake_id,
399
+ streamPath: primaryStream,
400
+ streams,
401
+ callback: appendPathToUrl(
402
+ ctx.publicUrl,
403
+ `/_electric/callback-forward/${encodeURIComponent(input.claim.wake_id)}`
404
+ ),
405
+ claimToken: input.claim.token,
406
+ triggerEvent: `message_received`,
407
+ entity: {
408
+ type: entity.type,
409
+ status: entity.status,
410
+ url: entity.url,
411
+ streams: entity.streams,
412
+ tags: entity.tags,
413
+ spawnArgs: entity.spawn_args,
414
+ },
415
+ }
416
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Shared JSON body schema middleware for itty-router handlers.
3
+ */
4
+
5
+ import { apiError } from '../electric-agents-http'
6
+ import { ErrCodeInvalidRequest } from '../electric-agents-types'
7
+ import { schemaValidator } from '../schema-validation.js'
8
+ import type { TSchema as TypeBoxSchema } from '@sinclair/typebox'
9
+ import type { IRequest, RequestHandler } from 'itty-router'
10
+
11
+ export interface JsonRouteRequest extends IRequest {
12
+ content?: unknown
13
+ }
14
+
15
+ export function routeBody<T>(request: JsonRouteRequest): T {
16
+ return request.content as T
17
+ }
18
+
19
+ export interface WithSchemaOptions {
20
+ lenient?: boolean
21
+ }
22
+
23
+ export function withSchema<TSchema extends TypeBoxSchema>(
24
+ schema: TSchema,
25
+ options: WithSchemaOptions = {}
26
+ ): RequestHandler<JsonRouteRequest, Array<unknown>> {
27
+ return async (request) => {
28
+ const contentType = request.headers.get(`content-type`)?.toLowerCase() ?? ``
29
+ const isJson = contentType.includes(`application/json`)
30
+ if (options.lenient && !isJson) {
31
+ return undefined
32
+ }
33
+
34
+ const bodyStr = await request.text()
35
+ let parsed: unknown
36
+
37
+ if (bodyStr.trim()) {
38
+ try {
39
+ parsed = JSON.parse(bodyStr)
40
+ } catch {
41
+ return apiError(400, ErrCodeInvalidRequest, `Invalid JSON body`)
42
+ }
43
+ } else {
44
+ parsed = {}
45
+ }
46
+
47
+ const validate = schemaValidator(schema)
48
+ if (!validate(parsed)) {
49
+ return apiError(
50
+ 400,
51
+ ErrCodeInvalidRequest,
52
+ `Request body does not match API schema`,
53
+ (validate.errors ?? []).map((err) => ({
54
+ path: err.instancePath || `/`,
55
+ message: err.message ?? `validation error`,
56
+ }))
57
+ )
58
+ }
59
+
60
+ request.content = parsed
61
+ return undefined
62
+ }
63
+ }
64
+
65
+ export function validateBody<TSchema extends TypeBoxSchema>(
66
+ schema: TSchema,
67
+ body: Uint8Array
68
+ ): { ok: true; value: unknown } | { ok: false; response: Response } {
69
+ const parsed = parseJsonBodyBytes(body)
70
+ if (!parsed.ok) return parsed
71
+
72
+ const validation = validateParsedBody(schema, parsed.value)
73
+ if (!validation.ok) return validation
74
+ return { ok: true, value: parsed.value }
75
+ }
76
+
77
+ export function validateOptionalJsonBody<TSchema extends TypeBoxSchema>(
78
+ schema: TSchema,
79
+ body: Uint8Array,
80
+ contentType?: string | null
81
+ ):
82
+ | { ok: true; value: unknown | undefined }
83
+ | { ok: false; response: Response } {
84
+ const bodyText = new TextDecoder().decode(body)
85
+ const trimmed = bodyText.trim()
86
+ if (!trimmed) return { ok: true, value: undefined }
87
+
88
+ let parsed: unknown
89
+ try {
90
+ parsed = JSON.parse(bodyText)
91
+ } catch {
92
+ if (contentType?.toLowerCase().includes(`application/json`)) {
93
+ return {
94
+ ok: false,
95
+ response: apiError(400, ErrCodeInvalidRequest, `Invalid JSON body`),
96
+ }
97
+ }
98
+ return { ok: true, value: undefined }
99
+ }
100
+
101
+ const validation = validateParsedBody(schema, parsed)
102
+ if (!validation.ok) return validation
103
+ return { ok: true, value: parsed }
104
+ }
105
+
106
+ function parseJsonBodyBytes(
107
+ body: Uint8Array
108
+ ): { ok: true; value: unknown } | { ok: false; response: Response } {
109
+ if (body.length === 0) return { ok: true, value: {} }
110
+ try {
111
+ return {
112
+ ok: true,
113
+ value: JSON.parse(new TextDecoder().decode(body)) as unknown,
114
+ }
115
+ } catch {
116
+ return {
117
+ ok: false,
118
+ response: apiError(400, ErrCodeInvalidRequest, `Invalid JSON body`),
119
+ }
120
+ }
121
+ }
122
+
123
+ function validateParsedBody<TSchema extends TypeBoxSchema>(
124
+ schema: TSchema,
125
+ parsed: unknown
126
+ ): { ok: true } | { ok: false; response: Response } {
127
+ const validate = schemaValidator(schema)
128
+ if (validate(parsed)) return { ok: true }
129
+ return {
130
+ ok: false,
131
+ response: apiError(
132
+ 400,
133
+ ErrCodeInvalidRequest,
134
+ `Request body does not match API schema`,
135
+ (validate.errors ?? []).map((err) => ({
136
+ path: err.instancePath || `/`,
137
+ message: err.message ?? `validation error`,
138
+ }))
139
+ ),
140
+ }
141
+ }