invoke.ts133 lines · main
1import { brivenError, NotFoundError } from '@briven/shared';
2
3import { env } from '../env.js';
4import { log } from '../lib/logger.js';
5import { getCurrentDeployment, stripFunctionExt } from './deployments.js';
6import { getPlainEnvForProject } from './project-env.js';
7
8export 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
19export 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 */
41export 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}