invoke.ts133 lines · main
| 1 | import { brivenError, NotFoundError } from '@briven/shared'; |
| 2 | |
| 3 | import { env } from '../env.js'; |
| 4 | import { log } from '../lib/logger.js'; |
| 5 | import { getCurrentDeployment, stripFunctionExt } from './deployments.js'; |
| 6 | import { getPlainEnvForProject } from './project-env.js'; |
| 7 | |
| 8 | export interface InvokeInput { |
| 9 | projectId: string; |
| 10 | functionName: string; |
| 11 | args: unknown; |
| 12 | requestId: string; |
| 13 | auth: { |
| 14 | userId: string; |
| 15 | tokenType: 'session' | 'api_key'; |
| 16 | } | null; |
| 17 | } |
| 18 | |
| 19 | export type InvokeResult = |
| 20 | | { |
| 21 | ok: true; |
| 22 | value: unknown; |
| 23 | durationMs: number; |
| 24 | deploymentId: string; |
| 25 | touchedTables: readonly string[]; |
| 26 | } |
| 27 | | { |
| 28 | ok: false; |
| 29 | code: string; |
| 30 | message: string; |
| 31 | durationMs: number; |
| 32 | deploymentId: string; |
| 33 | touchedTables?: readonly string[]; |
| 34 | }; |
| 35 | |
| 36 | /** |
| 37 | * Forward an invoke to apps/runtime. The control plane is the only caller |
| 38 | * that can reach the runtime — the runtime itself requires the shared |
| 39 | * secret, and we never expose the runtime URL publicly. |
| 40 | */ |
| 41 | export async function invoke(input: InvokeInput): Promise<InvokeResult> { |
| 42 | const deployment = await getCurrentDeployment(input.projectId); |
| 43 | if (!deployment) throw new NotFoundError('deployment', input.projectId); |
| 44 | |
| 45 | // New deployments register bare names. Older ones may have stored the name |
| 46 | // with its source extension (e.g. `listNotes.ts`), so normalise BOTH sides |
| 47 | // before comparing — this lets an already-deployed `listNotes.ts` resolve |
| 48 | // when invoked as `listNotes` without forcing an immediate redeploy. |
| 49 | const functionNames = (deployment.functionNames as string[] | null) ?? []; |
| 50 | const requestedName = stripFunctionExt(input.functionName); |
| 51 | if (!functionNames.some((n) => stripFunctionExt(n) === requestedName)) { |
| 52 | throw new NotFoundError('function', input.functionName); |
| 53 | } |
| 54 | |
| 55 | const headers: Record<string, string> = { |
| 56 | 'content-type': 'application/json', |
| 57 | accept: 'application/json', |
| 58 | }; |
| 59 | if (env.BRIVEN_RUNTIME_SHARED_SECRET) { |
| 60 | headers['authorization'] = `Bearer ${env.BRIVEN_RUNTIME_SHARED_SECRET}`; |
| 61 | } |
| 62 | |
| 63 | // Pull per-project env vars. Encrypted at rest, decrypted only here and |
| 64 | // shipped to the runtime over the swarm overlay. The runtime never caches |
| 65 | // them and `ctx.env` is the only surface exposed to user code. |
| 66 | const projectEnv = await getPlainEnvForProject(input.projectId).catch(() => ({})); |
| 67 | |
| 68 | let res: Response; |
| 69 | try { |
| 70 | res = await fetch(`${env.BRIVEN_RUNTIME_URL}/invoke`, { |
| 71 | method: 'POST', |
| 72 | headers, |
| 73 | body: JSON.stringify({ |
| 74 | projectId: input.projectId, |
| 75 | functionName: requestedName, |
| 76 | deploymentId: deployment.id, |
| 77 | requestId: input.requestId, |
| 78 | args: input.args, |
| 79 | auth: input.auth, |
| 80 | env: projectEnv, |
| 81 | }), |
| 82 | }); |
| 83 | } catch (err) { |
| 84 | log.error('runtime_unreachable', { |
| 85 | projectId: input.projectId, |
| 86 | message: err instanceof Error ? err.message : String(err), |
| 87 | }); |
| 88 | throw new brivenError('runtime_unreachable', 'function runtime is unreachable', { |
| 89 | status: 502, |
| 90 | }); |
| 91 | } |
| 92 | |
| 93 | if (!res.ok) { |
| 94 | const body = await res.text().catch(() => ''); |
| 95 | log.error('runtime_error', { |
| 96 | projectId: input.projectId, |
| 97 | status: res.status, |
| 98 | body: body.slice(0, 500), |
| 99 | }); |
| 100 | throw new brivenError('runtime_error', 'function runtime returned an error', { |
| 101 | status: 502, |
| 102 | }); |
| 103 | } |
| 104 | |
| 105 | const payload = (await res.json()) as { |
| 106 | ok: boolean; |
| 107 | value?: unknown; |
| 108 | code?: string; |
| 109 | message?: string; |
| 110 | durationMs?: number; |
| 111 | touchedTables?: string[]; |
| 112 | }; |
| 113 | |
| 114 | const durationMs = typeof payload.durationMs === 'number' ? payload.durationMs : 0; |
| 115 | const touchedTables = payload.touchedTables ?? []; |
| 116 | if (payload.ok) { |
| 117 | return { |
| 118 | ok: true, |
| 119 | value: payload.value, |
| 120 | durationMs, |
| 121 | deploymentId: deployment.id, |
| 122 | touchedTables, |
| 123 | }; |
| 124 | } |
| 125 | return { |
| 126 | ok: false, |
| 127 | code: payload.code ?? 'unknown_error', |
| 128 | message: payload.message ?? 'unknown error', |
| 129 | durationMs, |
| 130 | deploymentId: deployment.id, |
| 131 | touchedTables, |
| 132 | }; |
| 133 | } |