@crossworks/app-build 0.232.9 → 0.232.11

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossworks/app-build",
3
- "version": "0.232.9",
3
+ "version": "0.232.11",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -14,7 +14,7 @@
14
14
  "lucide-react": "^1.25.0",
15
15
  "react": "^19.2.7",
16
16
  "react-dom": "^19.2.7",
17
- "@mantle/share-ui": "npm:@crossworks/share-ui@0.232.9"
17
+ "@mantle/share-ui": "npm:@crossworks/share-ui@0.232.11"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^22.20.1",
@@ -108,6 +108,18 @@ function runtimePlugin(): esbuild.Plugin {
108
108
  export type RuntimeManifest = {
109
109
  /** specifier (as imported in app code) → hashed URL under `/app-runtime/`. */
110
110
  imports: Record<string, string>;
111
+ /** specifier → the export names that module actually provides ('default'
112
+ * included when it has one). Taken from esbuild's metafile, so it is the
113
+ * build's own account of the module rather than a parse of its output.
114
+ *
115
+ * This exists so `buildApp` can REJECT an app that imports a name the
116
+ * runtime does not export. Left unchecked, such an app builds and publishes
117
+ * clean and then fails in the browser as a module-level SyntaxError — which
118
+ * no ErrorBoundary can catch (linking precedes evaluation), so the app never
119
+ * signals ready and the user waits out the sandbox watchdog for a generic
120
+ * "couldn't load" with no cause. One `import host from '@host'` (the export
121
+ * is named, not default) cost exactly that in the field. */
122
+ exports: Record<string, string[]>;
111
123
  builtAt: string;
112
124
  esbuildVersion: string;
113
125
  };
@@ -139,10 +151,10 @@ export async function buildRuntime(
139
151
  entryNames: '[name]-[hash]',
140
152
  chunkNames: 'chunk-[hash]',
141
153
  write: true,
154
+ metafile: true,
142
155
  logLevel: 'silent',
143
156
  plugins: [runtimePlugin()],
144
157
  });
145
- void result;
146
158
 
147
159
  // Map each requested entry name → its hashed output file. esbuild names entry
148
160
  // outputs `<out>-<hash>.js`; the hash is alnum (no dashes), so the file for an
@@ -157,15 +169,29 @@ export async function buildRuntime(
157
169
  )!;
158
170
  }
159
171
 
172
+ // esbuild keys metafile outputs by path relative to cwd, not by basename.
173
+ const exportsByFile = new Map<string, string[]>();
174
+ for (const [outPath, meta] of Object.entries(result.metafile.outputs)) {
175
+ exportsByFile.set(path.basename(outPath), meta.exports ?? []);
176
+ }
177
+
160
178
  const imports: Record<string, string> = {};
179
+ const exports: Record<string, string[]> = {};
161
180
  for (const [specifier, name] of Object.entries(RUNTIME_SPECIFIERS)) {
162
181
  const file = nameToFile[name];
163
182
  if (!file) throw new Error(`runtime build produced no output for entry '${name}'`);
164
183
  imports[specifier] = urlPrefix + file;
184
+ const names = exportsByFile.get(file);
185
+ // A missing export list would silently disable the import check for this
186
+ // specifier — fail the runtime build instead, where it is one loud error at
187
+ // deploy time rather than a guard that quietly stopped guarding.
188
+ if (!names) throw new Error(`runtime build produced no metafile entry for '${file}'`);
189
+ exports[specifier] = names;
165
190
  }
166
191
 
167
192
  const manifest: RuntimeManifest = {
168
193
  imports,
194
+ exports,
169
195
  builtAt: new Date().toISOString(),
170
196
  esbuildVersion: esbuild.version,
171
197
  };
package/src/build.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { buildApp, type AppSource } from './index';
3
+ import { RUNTIME_EXPORTS_FIXTURE as RT } from './runtime-exports.fixture';
3
4
 
4
5
  /** A minimal valid app: one entry file exporting a default component. */
5
6
  function app(files: Record<string, string>, entry = 'App.tsx'): AppSource {
@@ -10,7 +11,7 @@ const TRIVIAL = 'export default function App() {\n return <div>hi</div>;\n}\n';
10
11
 
11
12
  describe('buildApp — happy path', () => {
12
13
  it('bundles a trivial app into self-mounting ESM', async () => {
13
- const res = await buildApp(app({ 'App.tsx': TRIVIAL }));
14
+ const res = await buildApp(app({ 'App.tsx': TRIVIAL }), { runtimeExports: RT });
14
15
  expect(res.ok).toBe(true);
15
16
  expect(typeof res.code).toBe('string');
16
17
  expect(res.code!.length).toBeGreaterThan(0);
@@ -19,7 +20,7 @@ describe('buildApp — happy path', () => {
19
20
  });
20
21
 
21
22
  it('externalizes React + @host (resolved via the shared runtime import map)', async () => {
22
- const res = await buildApp(app({ 'App.tsx': TRIVIAL }));
23
+ const res = await buildApp(app({ 'App.tsx': TRIVIAL }), { runtimeExports: RT });
23
24
  expect(res.ok).toBe(true);
24
25
  // React (here the automatic-JSX runtime) and the host bridge are NOT
25
26
  // bundled — they remain bare imports the iframe's import map resolves to the
@@ -43,7 +44,7 @@ describe('buildApp — happy path', () => {
43
44
  ' return <Button>{n}</Button>;\n' +
44
45
  '}\n',
45
46
  });
46
- const res = await buildApp(src);
47
+ const res = await buildApp(src, { runtimeExports: RT });
47
48
  expect(res.ok).toBe(true);
48
49
  expect(res.errors).toEqual([]);
49
50
  });
@@ -55,7 +56,7 @@ describe('buildApp — happy path', () => {
55
56
  'lib/util.ts': "export { greet } from './greet';\n",
56
57
  'lib/greet/index.ts': "export function greet() { return 'hi'; }\n",
57
58
  });
58
- const res = await buildApp(src);
59
+ const res = await buildApp(src, { runtimeExports: RT });
59
60
  expect(res.ok).toBe(true);
60
61
  });
61
62
  });
@@ -66,7 +67,7 @@ describe('buildApp — the import allowlist (security boundary)', () => {
66
67
  'App.tsx':
67
68
  "import _ from 'lodash';\nexport default function App() { return <div>{String(_)}</div>; }\n",
68
69
  });
69
- const res = await buildApp(src);
70
+ const res = await buildApp(src, { runtimeExports: RT });
70
71
  expect(res.ok).toBe(false);
71
72
  expect(res.errors.some((e) => /not allowed in a mini app|lodash/i.test(e.text))).toBe(true);
72
73
  });
@@ -76,7 +77,7 @@ describe('buildApp — the import allowlist (security boundary)', () => {
76
77
  'App.tsx':
77
78
  "import fs from 'node:fs';\nexport default function App() { return <div>{String(fs)}</div>; }\n",
78
79
  });
79
- const res = await buildApp(src);
80
+ const res = await buildApp(src, { runtimeExports: RT });
80
81
  expect(res.ok).toBe(false);
81
82
  });
82
83
 
@@ -85,7 +86,7 @@ describe('buildApp — the import allowlist (security boundary)', () => {
85
86
  'App.tsx':
86
87
  "import x from '@/components/ui/table';\nexport default function App() { return <div>{String(x)}</div>; }\n",
87
88
  });
88
- const res = await buildApp(src);
89
+ const res = await buildApp(src, { runtimeExports: RT });
89
90
  expect(res.ok).toBe(false);
90
91
  expect(res.errors.some((e) => /Unknown import/i.test(e.text))).toBe(true);
91
92
  });
@@ -93,7 +94,7 @@ describe('buildApp — the import allowlist (security boundary)', () => {
93
94
 
94
95
  describe('buildApp — error reporting', () => {
95
96
  it('fails when the entry file is missing from the tree', async () => {
96
- const res = await buildApp(app({ 'Other.tsx': TRIVIAL }, 'App.tsx'));
97
+ const res = await buildApp(app({ 'Other.tsx': TRIVIAL }, 'App.tsx'), { runtimeExports: RT });
97
98
  expect(res.ok).toBe(false);
98
99
  expect(res.errors[0]?.text).toMatch(/not found/i);
99
100
  });
@@ -103,7 +104,7 @@ describe('buildApp — error reporting', () => {
103
104
  'App.tsx':
104
105
  "import { x } from './missing';\nexport default function App() { return <div>{x}</div>; }\n",
105
106
  });
106
- const res = await buildApp(src);
107
+ const res = await buildApp(src, { runtimeExports: RT });
107
108
  expect(res.ok).toBe(false);
108
109
  expect(res.errors.some((e) => /Cannot resolve/i.test(e.text))).toBe(true);
109
110
  });
@@ -112,7 +113,7 @@ describe('buildApp — error reporting', () => {
112
113
  const src = app({
113
114
  'App.tsx': 'export default function App() { return <div>oops</div> // missing brace\n',
114
115
  });
115
- const res = await buildApp(src);
116
+ const res = await buildApp(src, { runtimeExports: RT });
116
117
  expect(res.ok).toBe(false);
117
118
  expect(res.errors.length).toBeGreaterThan(0);
118
119
  expect(res.errors.some((e) => e.location !== null)).toBe(true);
@@ -129,7 +130,7 @@ describe('lintToolRefs — undeclared host.tools.call', () => {
129
130
  ' return <button onClick={go}>go</button>;\n' +
130
131
  '}\n',
131
132
  });
132
- const res = await buildApp(src, { declaredToolSlugs: [] });
133
+ const res = await buildApp(src, { declaredToolSlugs: [], runtimeExports: RT });
133
134
  expect(res.ok).toBe(true); // warning-only: the build still succeeds
134
135
  expect(res.warnings.some((w) => /openweather_geocode/.test(w.text))).toBe(true);
135
136
  const w = res.warnings.find((w) => /openweather_geocode/.test(w.text));
@@ -146,7 +147,7 @@ describe('lintToolRefs — undeclared host.tools.call', () => {
146
147
  ' return <div>ok</div>;\n' +
147
148
  '}\n',
148
149
  });
149
- const res = await buildApp(src, { declaredToolSlugs: ['weather_now'] });
150
+ const res = await buildApp(src, { declaredToolSlugs: ['weather_now'], runtimeExports: RT });
150
151
  expect(res.ok).toBe(true);
151
152
  expect(res.warnings.some((w) => /weather_now/.test(w.text))).toBe(false);
152
153
  });
@@ -160,7 +161,7 @@ describe('lintToolRefs — undeclared host.tools.call', () => {
160
161
  ' return <div>ok</div>;\n' +
161
162
  '}\n',
162
163
  });
163
- const res = await buildApp(src);
164
+ const res = await buildApp(src, { runtimeExports: RT });
164
165
  expect(res.warnings.some((w) => /isn't in the app's declared tools/.test(w.text))).toBe(false);
165
166
  });
166
167
 
@@ -174,7 +175,7 @@ describe('lintToolRefs — undeclared host.tools.call', () => {
174
175
  ' return <div>ok</div>;\n' +
175
176
  '}\n',
176
177
  });
177
- const res = await buildApp(src, { declaredToolSlugs: [] });
178
+ const res = await buildApp(src, { declaredToolSlugs: [], runtimeExports: RT });
178
179
  expect(res.ok).toBe(true);
179
180
  expect(res.warnings.length).toBe(0);
180
181
  });
package/src/css.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { buildApp, type AppSource } from './index';
3
+ import { RUNTIME_EXPORTS_FIXTURE as RT } from './runtime-exports.fixture';
3
4
 
4
5
  function app(files: Record<string, string>, entry = 'App.tsx'): AppSource {
5
6
  return { entry, files };
@@ -18,6 +19,7 @@ describe('buildApp — per-app CSS', () => {
18
19
  }
19
20
  `,
20
21
  }),
22
+ { runtimeExports: RT },
21
23
  );
22
24
  expect(res.ok).toBe(true);
23
25
  expect(res.css).toBeTruthy();
@@ -41,6 +43,7 @@ describe('buildApp — per-app CSS', () => {
41
43
  export default function App() { return <Card>x</Card>; }
42
44
  `,
43
45
  }),
46
+ { runtimeExports: RT },
44
47
  );
45
48
  expect(res.ok).toBe(true);
46
49
  // rounded-xl comes from the kit Card source, not the app source.
@@ -48,7 +51,9 @@ export default function App() { return <Card>x</Card>; }
48
51
  });
49
52
 
50
53
  it('a failed JS build produces no css', async () => {
51
- const res = await buildApp(app({ 'App.tsx': 'export default function App( {' }));
54
+ const res = await buildApp(app({ 'App.tsx': 'export default function App( {' }), {
55
+ runtimeExports: RT,
56
+ });
52
57
  expect(res.ok).toBe(false);
53
58
  expect(res.css).toBeUndefined();
54
59
  });
package/src/index.ts CHANGED
@@ -207,13 +207,113 @@ export function lintToolRefs(source: AppSource, declaredSlugs: string[]): BuildM
207
207
  return out;
208
208
  }
209
209
 
210
+ /** `import <what> from "<spec>"` — captures the clause and the specifier.
211
+ * Covers the three ESM shapes esbuild leaves external: default, namespace,
212
+ * and named (including a default+named combination). */
213
+ const IMPORT_RE = /import\s+([^'";]+?)\s+from\s*['"]([^'"]+)['"]/g;
214
+
215
+ /** Split an import clause into the names it binds FROM the module.
216
+ * `Foo` → default
217
+ * `* as ns` → namespace (binds nothing by name; nothing to check)
218
+ * `{ a, b as c }` → a, b
219
+ * `Foo, { a }` → default, a
220
+ * Returns the names as the MODULE must provide them, not the local aliases. */
221
+ function importedNames(clause: string): { names: string[]; namespace: boolean } {
222
+ const names: string[] = [];
223
+ let namespace = false;
224
+ const braced = clause.match(/\{([^}]*)\}/);
225
+ const head = clause
226
+ .slice(0, braced ? clause.indexOf('{') : undefined)
227
+ .replace(/,\s*$/, '')
228
+ .trim();
229
+ if (head) {
230
+ if (/^\*\s+as\s+/.test(head)) namespace = true;
231
+ else if (/^[A-Za-z_$][\w$]*$/.test(head)) names.push('default');
232
+ }
233
+ if (braced) {
234
+ for (const part of braced[1]!.split(',')) {
235
+ const name = (part.split(/\s+as\s+/)[0] ?? '').trim();
236
+ if (name) names.push(name);
237
+ }
238
+ }
239
+ return { names, namespace };
240
+ }
241
+
242
+ /**
243
+ * Static lint: every import of a SHARED RUNTIME specifier must name something
244
+ * that specifier actually exports.
245
+ *
246
+ * These specifiers are external — esbuild leaves them as bare imports for the
247
+ * sandbox's import map to resolve, so it never checks their shape, and a wrong
248
+ * name survives into a clean, publishable build. The browser then rejects the
249
+ * module while LINKING, before any code runs: no ErrorBoundary can catch it
250
+ * (there is nothing to render yet) and the app never signals ready, so the
251
+ * sandbox can only time out with a generic failure. `import host from '@host'`
252
+ * — where the export is named, not default — cost exactly that in the field:
253
+ * a green build, then a twenty-second spinner and "couldn't load the app".
254
+ *
255
+ * Errors, not warnings: the app is guaranteed not to run.
256
+ *
257
+ * Local/relative imports are the bundler's business and are skipped, as is a
258
+ * namespace import (`* as ns`), which binds no names and cannot mismatch.
259
+ */
260
+ export function lintRuntimeImports(
261
+ source: AppSource,
262
+ runtimeExports: Record<string, string[]>,
263
+ ): BuildMessage[] {
264
+ const out: BuildMessage[] = [];
265
+ for (const [file, text] of Object.entries(source.files)) {
266
+ IMPORT_RE.lastIndex = 0;
267
+ let m: RegExpExecArray | null;
268
+ while ((m = IMPORT_RE.exec(text)) !== null) {
269
+ const spec = m[2]!;
270
+ if (spec.startsWith('.') || spec.startsWith('/')) continue;
271
+ const available = runtimeExports[spec];
272
+ if (!available) continue; // not a shared-runtime specifier; esbuild will rule on it
273
+ const { names, namespace } = importedNames(m[1]!);
274
+ if (namespace) continue;
275
+ const have = new Set(available);
276
+ for (const name of names) {
277
+ if (have.has(name)) continue;
278
+ const pre = text.slice(0, m.index);
279
+ const line = pre.length - pre.replace(/\n/g, '').length + 1; // 1-based
280
+ const column = m.index - (pre.lastIndexOf('\n') + 1); // 0-based within line
281
+ const hint =
282
+ name === 'default'
283
+ ? `'${spec}' has no default export. Use a named import, e.g. import { ${available[0]} } from '${spec}'.`
284
+ : `'${spec}' does not export '${name}'.`;
285
+ out.push({
286
+ text: `${hint} It exports: ${available.join(', ')}. Left as-is the app builds but fails to load in the browser, with no error the app can catch.`,
287
+ location: { file, line, column },
288
+ });
289
+ }
290
+ }
291
+ }
292
+ return out;
293
+ }
294
+
210
295
  /** Bundle an app's source tree into one self-mounting ESM module. When
211
296
  * `declaredToolSlugs` is supplied, undeclared `host.tools.call` slugs are
212
297
  * appended to the build's warnings (see lintToolRefs). */
298
+ export { loadRuntimeExports } from './runtime-exports';
299
+
213
300
  export async function buildApp(
214
301
  source: AppSource,
215
- opts: { declaredToolSlugs?: string[] } = {},
302
+ opts: {
303
+ declaredToolSlugs?: string[];
304
+ /** The shared runtime's export map (`loadRuntimeExports()` in production).
305
+ * REQUIRED so the import check below cannot be skipped by forgetting it —
306
+ * an app that fails this check cannot run at all, so silently omitting the
307
+ * check would ship a guaranteed-broken app. */
308
+ runtimeExports: Record<string, string[]>;
309
+ },
216
310
  ): Promise<BuildResult> {
311
+ // Runtime-import mismatches are fatal and cheap to detect, so they short
312
+ // circuit ahead of esbuild: no point compiling an app that cannot link.
313
+ const importErrors = lintRuntimeImports(source, opts.runtimeExports);
314
+ if (importErrors.length) {
315
+ return { ok: false, errors: importErrors, warnings: [], esbuildVersion: ESBUILD_VERSION };
316
+ }
217
317
  const toolWarnings = opts.declaredToolSlugs ? lintToolRefs(source, opts.declaredToolSlugs) : [];
218
318
  const withToolWarnings = (r: BuildResult): BuildResult =>
219
319
  toolWarnings.length ? { ...r, warnings: [...r.warnings, ...toolWarnings] } : r;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The import check is only as good as the export map it checks against.
3
+ * Production reads the GENERATED manifest, so it is always accurate; the tests
4
+ * use a hand-written fixture, which is not. This builds the real runtime and
5
+ * asserts the two agree, so a runtime change that the fixture misses fails
6
+ * here instead of quietly letting every other test pass against a shape that
7
+ * no longer exists.
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+ import * as fs from 'node:fs';
11
+ import * as os from 'node:os';
12
+ import * as path from 'node:path';
13
+ import { buildRuntime } from './build-runtime';
14
+ import { RUNTIME_EXPORTS_FIXTURE } from './runtime-exports.fixture';
15
+
16
+ describe('RUNTIME_EXPORTS_FIXTURE', () => {
17
+ it('matches what buildRuntime actually emits', async () => {
18
+ const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantle-rt-'));
19
+ try {
20
+ const manifest = await buildRuntime(outDir, '/app-runtime/');
21
+
22
+ // Same specifiers on both sides.
23
+ expect(Object.keys(RUNTIME_EXPORTS_FIXTURE).sort()).toEqual(
24
+ Object.keys(manifest.exports).sort(),
25
+ );
26
+
27
+ for (const [spec, fixture] of Object.entries(RUNTIME_EXPORTS_FIXTURE)) {
28
+ const real = new Set(manifest.exports[spec]);
29
+ // The fixture may list a SUBSET of a big surface (react has hundreds of
30
+ // exports) — what it must never do is claim a name the runtime lacks,
31
+ // because that is what would let a broken import pass the check.
32
+ for (const name of fixture) {
33
+ expect(real, `${spec} should export ${name}`).toContain(name);
34
+ }
35
+ }
36
+
37
+ // The specifier the field bug hit: assert its exact shape, since the
38
+ // whole check turns on '@host' having no default export.
39
+ expect(manifest.exports['@host']).toEqual(['__mount', 'host']);
40
+ } finally {
41
+ fs.rmSync(outDir, { recursive: true, force: true });
42
+ }
43
+ }, 120_000);
44
+ });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The shared runtime's export map as a TEST fixture. Production never uses
3
+ * this — `loadRuntimeExports()` reads the generated manifest, so the real
4
+ * check always describes the runtime that is actually deployed. This exists so
5
+ * tests can call `buildApp` without building the whole runtime first.
6
+ *
7
+ * `runtime-exports.drift.test.ts` builds the real runtime and asserts this
8
+ * matches, so a fixture that falls behind fails loudly instead of quietly
9
+ * letting tests pass against a runtime shape that no longer exists.
10
+ */
11
+ export const RUNTIME_EXPORTS_FIXTURE: Record<string, string[]> = {
12
+ react: ['default', 'useState', 'useEffect', 'useRef', 'useCallback', 'useMemo'],
13
+ 'react-dom': ['default'],
14
+ 'react-dom/client': ['createRoot', 'hydrateRoot', 'version'],
15
+ 'react/jsx-runtime': ['Fragment', 'jsx', 'jsxs'],
16
+ '@/lib/utils': ['cn'],
17
+ '@/components/ui/button': ['Button'],
18
+ '@/components/ui/card': [
19
+ 'Card',
20
+ 'CardContent',
21
+ 'CardDescription',
22
+ 'CardFooter',
23
+ 'CardHeader',
24
+ 'CardTitle',
25
+ ],
26
+ '@/components/ui/input': ['Input'],
27
+ '@/components/ui/label': ['Label'],
28
+ '@/components/ui/badge': ['Badge'],
29
+ '@/components/ui/separator': ['Separator'],
30
+ '@host': ['__mount', 'host'],
31
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The manifest lookup must not depend on the working directory. app_build runs
3
+ * from BOTH server/web (Next) and server/mcp (the agent's MCP server); a bare
4
+ * cwd-relative path works in the first and resolves to nothing in the second,
5
+ * which took every agent-driven build down with ENOENT until it was fixed.
6
+ */
7
+ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
8
+ import * as fs from 'node:fs';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import { loadRuntimeExports, manifestCandidates } from './runtime-exports';
12
+
13
+ const ENV = 'MANTLE_APP_RUNTIME_MANIFEST';
14
+ let saved: string | undefined;
15
+
16
+ beforeEach(() => {
17
+ saved = process.env[ENV];
18
+ });
19
+ afterEach(() => {
20
+ if (saved === undefined) delete process.env[ENV];
21
+ else process.env[ENV] = saved;
22
+ });
23
+
24
+ describe('manifestCandidates', () => {
25
+ it('includes a package-relative path that does not depend on cwd', () => {
26
+ delete process.env[ENV];
27
+ const [pkgRelative] = manifestCandidates();
28
+ expect(path.isAbsolute(pkgRelative!)).toBe(true);
29
+ expect(pkgRelative).toContain(path.join('server', 'web', 'public', 'app-runtime'));
30
+ });
31
+
32
+ it('puts an explicit override ahead of everything else', () => {
33
+ process.env[ENV] = '/tmp/somewhere/manifest.json';
34
+ expect(manifestCandidates()[0]).toBe('/tmp/somewhere/manifest.json');
35
+ });
36
+
37
+ it('still offers the cwd convention as a fallback', () => {
38
+ delete process.env[ENV];
39
+ expect(manifestCandidates()).toContain(path.resolve('public/app-runtime/manifest.json'));
40
+ });
41
+ });
42
+
43
+ describe('loadRuntimeExports', () => {
44
+ it('reads the exports map from the override path', async () => {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantle-mf-'));
46
+ const file = path.join(dir, 'manifest.json');
47
+ fs.writeFileSync(file, JSON.stringify({ imports: {}, exports: { '@host': ['host'] } }));
48
+ process.env[ENV] = file;
49
+ try {
50
+ await expect(loadRuntimeExports()).resolves.toEqual({ '@host': ['host'] });
51
+ } finally {
52
+ fs.rmSync(dir, { recursive: true, force: true });
53
+ }
54
+ });
55
+
56
+ it('throws and names every path it tried when nothing is found', async () => {
57
+ // Candidates are injected: the real list always resolves inside this repo,
58
+ // so the not-found path cannot be reached through the environment alone.
59
+ const missing = [
60
+ path.join(os.tmpdir(), 'nope-a', 'manifest.json'),
61
+ path.join(os.tmpdir(), 'nope-b', 'manifest.json'),
62
+ ];
63
+ await expect(loadRuntimeExports(missing)).rejects.toThrow(/manifest not found[\s\S]*Tried:/);
64
+ for (const m of missing) {
65
+ await expect(loadRuntimeExports(missing)).rejects.toThrow(m);
66
+ }
67
+ });
68
+
69
+ it('does not fall through to another candidate when one is present but broken', async () => {
70
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantle-mf-'));
71
+ const broken = path.join(dir, 'manifest.json');
72
+ fs.writeFileSync(broken, JSON.stringify({ imports: {} }));
73
+ const good = path.join(dir, 'good.json');
74
+ fs.writeFileSync(good, JSON.stringify({ exports: { '@host': ['host'] } }));
75
+ try {
76
+ // A stale copy further down the list must never win over a broken one.
77
+ await expect(loadRuntimeExports([broken, good])).rejects.toThrow(/no 'exports' map/);
78
+ } finally {
79
+ fs.rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ });
82
+
83
+ it('refuses a manifest that exists but carries no exports map', async () => {
84
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mantle-mf-'));
85
+ const file = path.join(dir, 'manifest.json');
86
+ fs.writeFileSync(file, JSON.stringify({ imports: { react: '/x.js' } }));
87
+ process.env[ENV] = file;
88
+ try {
89
+ await expect(loadRuntimeExports()).rejects.toThrow(/no 'exports' map/);
90
+ } finally {
91
+ fs.rmSync(dir, { recursive: true, force: true });
92
+ }
93
+ });
94
+ });
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Load the shared runtime's export map — what `/app-runtime/manifest.json`
3
+ * recorded about each specifier during `buildRuntime`.
4
+ *
5
+ * `buildApp` needs this to reject an app that imports a name the runtime does
6
+ * not provide. Reading the GENERATED manifest (rather than re-deriving the
7
+ * names here) keeps one source of truth: the check can only ever describe the
8
+ * runtime that is actually deployed alongside it.
9
+ *
10
+ * Resolution deliberately does NOT trust the working directory. The manifest is
11
+ * generated into server/web/public, and server/web's cwd is what
12
+ * `server/web/lib/app-frame.ts` relies on — but app_build also runs from the
13
+ * MCP server, whose cwd is server/mcp, and a bare relative path there resolves
14
+ * to nothing. That cost a live build outage: every agent-driven app_build
15
+ * failed with ENOENT until the lookup stopped assuming one caller. So the
16
+ * package locates the manifest relative to ITSELF first (the repo and the image
17
+ * share the same layout), falls back to the cwd convention for anything that
18
+ * relocates it, and takes an explicit env override ahead of both.
19
+ *
20
+ * A missing or malformed manifest THROWS, naming every path it tried. It must
21
+ * never degrade to "no exports known": an empty map would flag every import as
22
+ * invalid, and an optional one would silently stop guarding.
23
+ */
24
+ import { readFile } from 'node:fs/promises';
25
+ import * as path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
29
+
30
+ /** In order of trust: explicit override, package-relative, cwd convention. */
31
+ export function manifestCandidates(): string[] {
32
+ const override = process.env.MANTLE_APP_RUNTIME_MANIFEST;
33
+ return [
34
+ ...(override ? [override] : []),
35
+ // packages/app-build/src → <root>/server/web/public/app-runtime/manifest.json
36
+ path.resolve(HERE, '../../../server/web/public/app-runtime/manifest.json'),
37
+ path.resolve('public/app-runtime/manifest.json'),
38
+ ];
39
+ }
40
+
41
+ /** Keyed by the candidate list rather than a single flag: the paths are stable
42
+ * in production (so this is still one read per process), and a changed
43
+ * override naturally re-resolves instead of serving another location's
44
+ * answer. */
45
+ const cache = new Map<string, Promise<Record<string, string[]>>>();
46
+
47
+ export function loadRuntimeExports(
48
+ /** Injectable for tests only — production always uses the real candidates.
49
+ * A test cannot isolate the not-found path otherwise, because the
50
+ * package-relative candidate resolves inside the repo itself. */
51
+ candidates: string[] = manifestCandidates(),
52
+ ): Promise<Record<string, string[]>> {
53
+ const key = candidates.join('\u0000');
54
+ let cached = cache.get(key);
55
+ if (!cached) {
56
+ cached = (async () => {
57
+ const tried: string[] = [];
58
+ for (const candidate of candidates) {
59
+ tried.push(candidate);
60
+ let raw: string;
61
+ try {
62
+ raw = await readFile(candidate, 'utf8');
63
+ } catch {
64
+ continue; // not here; try the next location
65
+ }
66
+ // Present but unusable is a hard error — never fall through to another
67
+ // candidate, or a stale copy elsewhere could silently win.
68
+ const m = JSON.parse(raw) as { exports?: Record<string, string[]> };
69
+ if (!m.exports || Object.keys(m.exports).length === 0) {
70
+ throw new Error(
71
+ `${candidate} has no 'exports' map — regenerate it with the app-runtime build`,
72
+ );
73
+ }
74
+ return m.exports;
75
+ }
76
+ throw new Error(
77
+ `app-runtime manifest not found. Tried:\n ${tried.join('\n ')}\n` +
78
+ 'Generate it with the app-runtime build, or set MANTLE_APP_RUNTIME_MANIFEST.',
79
+ );
80
+ })().catch((e: unknown) => {
81
+ cache.delete(key); // let the next call retry rather than pinning the failure
82
+ throw e;
83
+ });
84
+ cache.set(key, cached);
85
+ }
86
+ return cached;
87
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The runtime specifiers are external, so esbuild never checks their shape and
3
+ * a wrong import name survives into a green, publishable build. The browser
4
+ * then rejects the module while LINKING — before any code runs, so nothing the
5
+ * app can install (ErrorBoundary included) ever sees it, and the sandbox can
6
+ * only time out with a generic failure. These tests pin the check that turns
7
+ * that class of bug into a build error.
8
+ */
9
+ import { describe, expect, it } from 'vitest';
10
+ import { buildApp, lintRuntimeImports } from './index';
11
+ import { RUNTIME_EXPORTS_FIXTURE as RT } from './runtime-exports.fixture';
12
+
13
+ const src = (files: Record<string, string>) => ({ entry: 'App.tsx', files });
14
+
15
+ describe('lintRuntimeImports', () => {
16
+ it('catches the field bug: a default import of a named-only module', () => {
17
+ const msgs = lintRuntimeImports(src({ 'lib/store.ts': "import host from '@host';\n" }), RT);
18
+ expect(msgs).toHaveLength(1);
19
+ expect(msgs[0]!.text).toContain("'@host' has no default export");
20
+ // The message has to carry the fix, not just the complaint.
21
+ expect(msgs[0]!.text).toContain("import { __mount } from '@host'");
22
+ expect(msgs[0]!.text).toContain('__mount, host');
23
+ expect(msgs[0]!.location).toEqual({ file: 'lib/store.ts', line: 1, column: 0 });
24
+ });
25
+
26
+ it('accepts the correct named import', () => {
27
+ expect(lintRuntimeImports(src({ 'a.ts': "import { host } from '@host';\n" }), RT)).toEqual([]);
28
+ });
29
+
30
+ it('catches a named import the module does not export', () => {
31
+ const msgs = lintRuntimeImports(src({ 'a.ts': "import { nope } from '@host';\n" }), RT);
32
+ expect(msgs).toHaveLength(1);
33
+ expect(msgs[0]!.text).toContain("'@host' does not export 'nope'");
34
+ });
35
+
36
+ it('checks the module name, not the local alias', () => {
37
+ expect(lintRuntimeImports(src({ 'a.ts': "import { host as h } from '@host';\n" }), RT)).toEqual(
38
+ [],
39
+ );
40
+ expect(
41
+ lintRuntimeImports(src({ 'a.ts': "import { nope as host } from '@host';\n" }), RT),
42
+ ).toHaveLength(1);
43
+ });
44
+
45
+ it('allows a default import where the module really has one', () => {
46
+ expect(lintRuntimeImports(src({ 'a.ts': "import React from 'react';\n" }), RT)).toEqual([]);
47
+ });
48
+
49
+ it('reports the right line in a multi-line file', () => {
50
+ const text = "// header\nimport { cn } from '@/lib/utils';\nimport bad from '@host';\n";
51
+ const msgs = lintRuntimeImports(src({ 'a.ts': text }), RT);
52
+ expect(msgs).toHaveLength(1);
53
+ expect(msgs[0]!.location?.line).toBe(3);
54
+ });
55
+
56
+ it('ignores relative imports and unknown packages — esbuild rules on those', () => {
57
+ const text = "import x from './local';\nimport y from 'some-npm-pkg';\n";
58
+ expect(lintRuntimeImports(src({ 'a.ts': text }), RT)).toEqual([]);
59
+ });
60
+
61
+ it('ignores a namespace import, which binds no names to mismatch', () => {
62
+ expect(lintRuntimeImports(src({ 'a.ts': "import * as h from '@host';\n" }), RT)).toEqual([]);
63
+ });
64
+
65
+ it('handles a combined default + named import', () => {
66
+ const msgs = lintRuntimeImports(
67
+ src({ 'a.ts': "import React, { useState } from 'react';\n" }),
68
+ RT,
69
+ );
70
+ expect(msgs).toEqual([]);
71
+ });
72
+ });
73
+
74
+ describe('buildApp — runtime import check', () => {
75
+ it('FAILS the build rather than publishing an app that cannot link', async () => {
76
+ const res = await buildApp(
77
+ src({
78
+ 'App.tsx':
79
+ "import { host } from './lib/store';\nexport default function App(){return <div/>;}\n",
80
+ 'lib/store.ts': "import host from '@host';\nexport { host };\n",
81
+ }),
82
+ { runtimeExports: RT },
83
+ );
84
+ expect(res.ok).toBe(false);
85
+ expect(res.errors[0]!.text).toContain("'@host' has no default export");
86
+ // Nothing is emitted — a caller must not be able to publish this.
87
+ expect(res.code).toBeUndefined();
88
+ });
89
+
90
+ it('builds normally when the imports are right', async () => {
91
+ const res = await buildApp(
92
+ src({
93
+ 'App.tsx':
94
+ "import { host } from '@host';\nexport default function App(){return <div>{String(!!host)}</div>;}\n",
95
+ }),
96
+ { runtimeExports: RT },
97
+ );
98
+ expect(res.ok).toBe(true);
99
+ expect(res.errors).toEqual([]);
100
+ });
101
+ });