@crossworks/app-build 0.232.8 → 0.232.10

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.8",
3
+ "version": "0.232.10",
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.8"
17
+ "@mantle/share-ui": "npm:@crossworks/share-ui@0.232.10"
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,44 @@
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
+ * Path convention matches server/web/lib/app-frame.ts, which reads the same
11
+ * file for the import map: relative to cwd, which is server/web in dev and in
12
+ * the image. Cached for the process — the file only changes on deploy.
13
+ *
14
+ * A missing or malformed manifest THROWS. It must never degrade to "no
15
+ * exports known", because an empty map would flag every import as invalid, and
16
+ * an optional map would silently stop guarding.
17
+ */
18
+ import { readFile } from 'node:fs/promises';
19
+
20
+ const MANIFEST_PATH = 'public/app-runtime/manifest.json';
21
+
22
+ let cached: Promise<Record<string, string[]>> | null = null;
23
+
24
+ export function loadRuntimeExports(
25
+ manifestPath: string = MANIFEST_PATH,
26
+ ): Promise<Record<string, string[]>> {
27
+ if (!cached) {
28
+ cached = readFile(manifestPath, 'utf8')
29
+ .then((raw) => {
30
+ const m = JSON.parse(raw) as { exports?: Record<string, string[]> };
31
+ if (!m.exports || Object.keys(m.exports).length === 0) {
32
+ throw new Error(
33
+ `${manifestPath} has no 'exports' map — regenerate it with the app-runtime build`,
34
+ );
35
+ }
36
+ return m.exports;
37
+ })
38
+ .catch((e: unknown) => {
39
+ cached = null; // let the next call retry rather than pinning the failure
40
+ throw e;
41
+ });
42
+ }
43
+ return cached;
44
+ }
@@ -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
+ });