invoke.ts141 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 // 401 from the runtime almost always means BRIVEN_RUNTIME_SHARED_SECRET
96 // drifted between api and runtime after a partial redeploy (see skill gotcha #17).
97 const likelySecretMismatch =
98 res.status === 401 && body.includes('runtime is not open to the public');
99 log.error(likelySecretMismatch ? 'runtime_shared_secret_mismatch' : 'runtime_error', {
100 projectId: input.projectId,
101 status: res.status,
102 body: body.slice(0, 500),
103 });
104 throw new brivenError(
105 likelySecretMismatch ? 'runtime_auth_failed' : 'runtime_error',
106 likelySecretMismatch
107 ? 'function runtime rejected the control plane (shared secret mismatch — recreate runtime with .env.prod)'
108 : 'function runtime returned an error',
109 { status: 502 },
110 );
111 }
112
113 const payload = (await res.json()) as {
114 ok: boolean;
115 value?: unknown;
116 code?: string;
117 message?: string;
118 durationMs?: number;
119 touchedTables?: string[];
120 };
121
122 const durationMs = typeof payload.durationMs === 'number' ? payload.durationMs : 0;
123 const touchedTables = payload.touchedTables ?? [];
124 if (payload.ok) {
125 return {
126 ok: true,
127 value: payload.value,
128 durationMs,
129 deploymentId: deployment.id,
130 touchedTables,
131 };
132 }
133 return {
134 ok: false,
135 code: payload.code ?? 'unknown_error',
136 message: payload.message ?? 'unknown error',
137 durationMs,
138 deploymentId: deployment.id,
139 touchedTables,
140 };
141}