@crossworks/app-build 0.232.10 → 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 +2 -2
- package/src/runtime-exports.test.ts +94 -0
- package/src/runtime-exports.ts +60 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crossworks/app-build",
|
|
3
|
-
"version": "0.232.
|
|
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.
|
|
17
|
+
"@mantle/share-ui": "npm:@crossworks/share-ui@0.232.11"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^22.20.1",
|
|
@@ -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
|
+
});
|
package/src/runtime-exports.ts
CHANGED
|
@@ -7,38 +7,81 @@
|
|
|
7
7
|
* names here) keeps one source of truth: the check can only ever describe the
|
|
8
8
|
* runtime that is actually deployed alongside it.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
13
19
|
*
|
|
14
|
-
* A missing or malformed manifest THROWS
|
|
15
|
-
* exports known"
|
|
16
|
-
* an optional
|
|
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.
|
|
17
23
|
*/
|
|
18
24
|
import { readFile } from 'node:fs/promises';
|
|
25
|
+
import * as path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
19
27
|
|
|
20
|
-
const
|
|
28
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
29
|
|
|
22
|
-
|
|
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[]>>>();
|
|
23
46
|
|
|
24
47
|
export function loadRuntimeExports(
|
|
25
|
-
|
|
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(),
|
|
26
52
|
): Promise<Record<string, string[]>> {
|
|
53
|
+
const key = candidates.join('\u0000');
|
|
54
|
+
let cached = cache.get(key);
|
|
27
55
|
if (!cached) {
|
|
28
|
-
cached =
|
|
29
|
-
|
|
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.
|
|
30
68
|
const m = JSON.parse(raw) as { exports?: Record<string, string[]> };
|
|
31
69
|
if (!m.exports || Object.keys(m.exports).length === 0) {
|
|
32
70
|
throw new Error(
|
|
33
|
-
`${
|
|
71
|
+
`${candidate} has no 'exports' map — regenerate it with the app-runtime build`,
|
|
34
72
|
);
|
|
35
73
|
}
|
|
36
74
|
return m.exports;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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);
|
|
42
85
|
}
|
|
43
86
|
return cached;
|
|
44
87
|
}
|