bundle-materializer.ts147 lines · main
1import { copyFile, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
2import { join } from 'node:path';
3
4import type { Bundle } from './types.js';
5
6export interface MaterializerConfig {
7 /** Base directory for isolate working dirs, e.g. '/tmp'. */
8 isolateBaseDir: string;
9 /** Directory containing the host-vendored loop.ts/server.ts/types.ts files. */
10 runtimeStubDir: string;
11}
12
13export interface MaterializeResult {
14 /** /tmp/briven-isolate-<id> */
15 tmpDir: string;
16 /** <tmpDir>/__entry.ts */
17 entryPath: string;
18 /** <tmpDir>/import-map.json */
19 importMapPath: string;
20}
21
22const ISOLATE_PREFIX = 'briven-isolate-';
23
24/**
25 * Materialize the per-isolate tmp dir layout:
26 *
27 * /tmp/briven-isolate-<id>/
28 * ├── .briven-runtime/ # host-vendored, never customer-controlled
29 * │ ├── server.ts
30 * │ ├── loop.ts
31 * │ └── types.ts
32 * ├── briven/functions/ # customer files, copied from bundle.directory
33 * ├── import-map.json # @briven/cli/server → .briven-runtime/server.ts
34 * └── __entry.ts # host-generated dispatch + loop kickoff
35 *
36 * The isolate's `--allow-read` and `--allow-write` permissions are scoped
37 * to <tmpDir> only (CLAUDE.md §7.3).
38 */
39export async function materializeIsolate(
40 isolateId: string,
41 bundle: Bundle,
42 config: MaterializerConfig,
43): Promise<MaterializeResult> {
44 const tmpDir = join(config.isolateBaseDir, `${ISOLATE_PREFIX}${isolateId}`);
45 await rm(tmpDir, { recursive: true, force: true });
46 await mkdir(tmpDir, { recursive: true });
47
48 // .briven-runtime/ — copy loop.ts, server.ts, types.ts verbatim
49 const stubDir = join(tmpDir, '.briven-runtime');
50 await mkdir(stubDir, { recursive: true });
51 for (const f of ['loop.ts', 'server.ts', 'types.ts']) {
52 await copyFile(join(config.runtimeStubDir, f), join(stubDir, f));
53 }
54
55 // briven/functions/ — copy each function source from bundle.directory
56 const fnDir = join(tmpDir, 'briven', 'functions');
57 await mkdir(fnDir, { recursive: true });
58 for (const name of bundle.functionNames) {
59 const src = join(bundle.directory, 'functions', `${name}.ts`);
60 const dest = join(fnDir, `${name}.ts`);
61 await copyFile(src, dest);
62 }
63
64 // import-map.json — single mapping for the @briven/cli/server stub
65 const importMap = {
66 imports: {
67 '@briven/cli/server': './.briven-runtime/server.ts',
68 },
69 };
70 const importMapPath = join(tmpDir, 'import-map.json');
71 await writeFile(importMapPath, JSON.stringify(importMap, null, 2));
72
73 // __entry.ts — host-generated dispatch table + loop kickoff
74 const entryPath = join(tmpDir, '__entry.ts');
75 await writeFile(entryPath, generateEntry(bundle));
76
77 return { tmpDir, entryPath, importMapPath };
78}
79
80function generateEntry(bundle: Bundle): string {
81 // Fault isolation: load each function file with its OWN dynamic import()
82 // inside a try/catch, rather than a single static top-level import graph.
83 //
84 // A static `import * as fn from './briven/functions/<bad>.ts'` fails during
85 // __entry.ts's module evaluation if that one file has a bad/unresolvable
86 // import — which happens BEFORE runIsolateLoop() runs, so the isolate never
87 // emits `ready` and EVERY sibling function in the bundle is killed at spawn.
88 //
89 // Dynamic import per function contains the blast radius: a broken file is
90 // captured in `loadErrors` and excluded from `dispatch`, while every healthy
91 // function still loads and the isolate still emits `ready`. Invoking a broken
92 // function later surfaces its real load error (handled in runIsolateLoop).
93 const loadBlocks = bundle.functionNames
94 .map(
95 (n) => `try {
96 const mod = (await import('./briven/functions/${JSON.stringify(n).slice(1, -1)}.ts')) as Record<string, unknown>;
97 const handler = (mod[${JSON.stringify(n)}] ?? mod.default) as
98 | ((ctx: any, args: any) => unknown)
99 | undefined;
100 if (typeof handler === 'function') {
101 dispatch[${JSON.stringify(n)}] = handler;
102 } else {
103 loadErrors[${JSON.stringify(n)}] =
104 'function ${JSON.stringify(n).slice(1, -1)} has no matching or default export';
105 }
106} catch (err) {
107 loadErrors[${JSON.stringify(n)}] = err instanceof Error ? err.message : String(err);
108}`,
109 )
110 .join('\n');
111 return `// host-generated entry
112import { runIsolateLoop } from './.briven-runtime/loop.ts';
113
114const dispatch: Record<string, (ctx: any, args: any) => unknown> = {};
115const loadErrors: Record<string, string> = {};
116${loadBlocks}
117
118await runIsolateLoop(dispatch, ${JSON.stringify(bundle.deploymentId)}, loadErrors);
119`;
120}
121
122export async function cleanupIsolate(tmpDir: string): Promise<void> {
123 await rm(tmpDir, { recursive: true, force: true });
124}
125
126/**
127 * Remove every `briven-isolate-*` directory under `isolateBaseDir` whose
128 * id is not in `liveIsolateIds`. Used at startup to clear orphans left by
129 * a previous host process, and after a host-cap eviction.
130 */
131export async function sweepOrphans(
132 isolateBaseDir: string,
133 liveIsolateIds: ReadonlySet<string>,
134): Promise<void> {
135 let entries: string[];
136 try {
137 entries = await readdir(isolateBaseDir);
138 } catch {
139 return;
140 }
141 for (const entry of entries) {
142 if (!entry.startsWith(ISOLATE_PREFIX)) continue;
143 const id = entry.slice(ISOLATE_PREFIX.length);
144 if (liveIsolateIds.has(id)) continue;
145 await rm(join(isolateBaseDir, entry), { recursive: true, force: true }).catch(() => {});
146 }
147}