@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 +2 -2
- package/src/build-runtime.ts +27 -1
- package/src/build.test.ts +15 -14
- package/src/css.test.ts +6 -1
- package/src/index.ts +101 -1
- package/src/runtime-exports.drift.test.ts +44 -0
- package/src/runtime-exports.fixture.ts +31 -0
- package/src/runtime-exports.ts +44 -0
- package/src/runtime-imports.test.ts +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/app-build",
|
|
3
|
-
"version": "0.232.
|
|
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.
|
|
17
|
+
"@mantle/share-ui": "npm:@crossworks/share-ui@0.232.10"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^22.20.1",
|
package/src/build-runtime.ts
CHANGED
|
@@ -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: {
|
|
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
|
+
});
|