@shipfox/client-shell 0.2.0 → 1.0.0
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/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +15 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/dist/vite/evaluate-features.d.ts +0 -1
- package/dist/vite/evaluate-features.d.ts.map +1 -1
- package/dist/vite/evaluate-features.js +60 -47
- package/dist/vite/evaluate-features.js.map +1 -1
- package/dist/vite/generate.js +2 -2
- package/dist/vite/generate.js.map +1 -1
- package/dist/vite/plugin.d.ts.map +1 -1
- package/dist/vite/plugin.js +3 -12
- package/dist/vite/plugin.js.map +1 -1
- package/package.json +28 -49
- package/src/vite/evaluate-features.test.ts +58 -3
- package/src/vite/evaluate-features.ts +71 -50
- package/src/vite/generate.ts +2 -2
- package/src/vite/plugin.test.ts +59 -9
- package/src/vite/plugin.ts +3 -16
- package/test/external/FINDINGS.md +33 -45
- package/test/external/README.md +21 -23
- package/test/external/fixture/package.json +1 -2
- package/test/external/fixture/src/app.fixture.tsx +78 -46
- package/test/external/fixture/src/features/external-settings.tsx +19 -0
- package/test/external/fixture/src/features/login-override.tsx +9 -0
- package/test/external/fixture/src/features.collision.ts +5 -5
- package/test/external/fixture/src/features.ts +49 -3
- package/test/external/fixture/src/link-typecheck.tsx +5 -5
- package/test/external/fixture/src/main.tsx +83 -1
- package/test/external/fixture/src/provider.ts +45 -0
- package/test/external/fixture/tsconfig.json +1 -1
- package/test/external/fixture/vite.config.ts +13 -2
- package/test/external/fixture/vitest.config.ts +3 -1
- package/test/external/verify.mjs +233 -47
- package/test/typecheck/shipfox-app.gen.ts +6 -6
- package/test/typecheck/types.tsx +6 -1
- package/tsconfig.build.tsbuildinfo +1 -1
- package/test/external/fixture/src/features/override-impl.tsx +0 -12
- package/test/external/fixture/src/features/override.tsx +0 -20
- package/test/external/toy-feature/CHANGELOG.md +0 -15
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-check +0 -43
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-format +0 -43
- package/test/external/toy-feature/node_modules/.bin/shipfox-biome-lint +0 -43
- package/test/external/toy-feature/node_modules/.bin/shipfox-swc +0 -43
- package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-check +0 -43
- package/test/external/toy-feature/node_modules/.bin/shipfox-tsc-emit +0 -43
- package/test/external/toy-feature/package.json +0 -57
- package/test/external/toy-feature/src/config.ts +0 -5
- package/test/external/toy-feature/src/index.ts +0 -64
- package/test/external/toy-feature/src/provider.tsx +0 -33
- package/test/external/toy-feature/src/routes/insights.tsx +0 -7
- package/test/external/toy-feature/src/routes/settings.tsx +0 -7
- package/test/external/toy-feature/tsconfig.build.json +0 -5
- package/test/external/toy-feature/tsconfig.json +0 -1
- package/test/not-route-impl.ts +0 -1
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {randomUUID} from 'node:crypto';
|
|
2
|
+
import {realpath} from 'node:fs/promises';
|
|
2
3
|
import {createRequire} from 'node:module';
|
|
3
4
|
import {resolve} from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import {createJiti} from 'jiti';
|
|
5
|
+
import {register} from 'tsx/cjs/api';
|
|
6
6
|
import type {ClientFeature} from '#contract.js';
|
|
7
7
|
|
|
8
8
|
const require = createRequire(import.meta.url);
|
|
9
|
-
const importPattern =
|
|
10
|
-
/(?:\b(?:import|export)\s+(?:[^'"]*?\s+from\s+)?|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
|
|
11
9
|
const nodeModulesPathPattern = /[\\/]node_modules[\\/]/;
|
|
12
10
|
|
|
13
11
|
export interface EvaluatedFeatures {
|
|
@@ -15,71 +13,91 @@ export interface EvaluatedFeatures {
|
|
|
15
13
|
loadedFiles: readonly string[];
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
16
|
+
function loadedModuleGraph(namespaceQuery: string): {
|
|
17
|
+
cacheKeys: Set<string>;
|
|
18
|
+
loadedPaths: Set<string>;
|
|
19
|
+
} {
|
|
20
|
+
const cacheKeys = new Set<string>();
|
|
21
|
+
const loadedPaths = new Set<string>();
|
|
22
|
+
const keysByModule = new Map<NodeJS.Module, string[]>();
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
for (const [cacheKey, cachedModule] of Object.entries(require.cache)) {
|
|
25
|
+
if (!cachedModule) continue;
|
|
26
|
+
const keys = keysByModule.get(cachedModule) ?? [];
|
|
27
|
+
keys.push(cacheKey);
|
|
28
|
+
keysByModule.set(cachedModule, keys);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
const visited = new Set<NodeJS.Module>();
|
|
32
|
+
function visit(cachedModule: NodeJS.Module): void {
|
|
33
|
+
if (visited.has(cachedModule)) return;
|
|
34
|
+
visited.add(cachedModule);
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
for (const cacheKey of keysByModule.get(cachedModule) ?? []) {
|
|
37
|
+
const path = cacheKey.endsWith(namespaceQuery)
|
|
38
|
+
? cacheKey.slice(0, -namespaceQuery.length)
|
|
39
|
+
: cacheKey;
|
|
40
|
+
const isLocal = !nodeModulesPathPattern.test(path);
|
|
41
|
+
if (isLocal) loadedPaths.add(path);
|
|
42
|
+
if (isLocal || cacheKey.endsWith(namespaceQuery)) cacheKeys.add(cacheKey);
|
|
43
|
+
}
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if (path.startsWith('node:') || nodeModulesPathPattern.test(path) || files.has(path)) return;
|
|
44
|
-
const resolvedPath = await realpath(path).catch(() => path);
|
|
45
|
-
files.add(resolvedPath);
|
|
46
|
-
const source = await readFile(resolvedPath, 'utf8').catch(() => undefined);
|
|
47
|
-
if (!source) return;
|
|
45
|
+
for (const child of cachedModule.children) visit(child);
|
|
46
|
+
}
|
|
48
47
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (!specifier) continue;
|
|
52
|
-
const dependency = jiti.esmResolve(specifier, {
|
|
53
|
-
parentURL: pathToFileURL(resolvedPath),
|
|
54
|
-
try: true,
|
|
55
|
-
});
|
|
56
|
-
if (dependency) await visit(dependency);
|
|
57
|
-
}
|
|
48
|
+
for (const [cacheKey, cachedModule] of Object.entries(require.cache)) {
|
|
49
|
+
if (cachedModule && cacheKey.endsWith(namespaceQuery)) visit(cachedModule);
|
|
58
50
|
}
|
|
59
51
|
|
|
60
|
-
|
|
52
|
+
return {cacheKeys, loadedPaths};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function localFiles(paths: Iterable<string>): Promise<string[]> {
|
|
56
|
+
const files = new Set<string>();
|
|
57
|
+
for (const path of paths) {
|
|
58
|
+
if (nodeModulesPathPattern.test(path)) continue;
|
|
59
|
+
files.add(await realpath(path).catch(() => path));
|
|
60
|
+
}
|
|
61
61
|
return [...files];
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export async function evaluateFeatures(featuresModule: string): Promise<EvaluatedFeatures> {
|
|
65
65
|
const resolvedFeaturesModule = await realpath(resolve(featuresModule));
|
|
66
|
-
const
|
|
66
|
+
const namespace = randomUUID();
|
|
67
|
+
const namespaceQuery = `?namespace=${namespace}`;
|
|
68
|
+
const loader = register({namespace});
|
|
69
|
+
const loadedPaths = new Set<string>([resolvedFeaturesModule]);
|
|
70
|
+
const cacheKeys = new Set<string>();
|
|
71
|
+
const initialCacheKeys = new Set(Object.keys(require.cache));
|
|
67
72
|
let module: {default?: unknown; features?: unknown};
|
|
68
|
-
let loadedFiles: string[];
|
|
69
73
|
try {
|
|
70
|
-
module =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
...(await staticallyImportedModules(resolvedFeaturesModule, jiti)),
|
|
75
|
-
]),
|
|
76
|
-
];
|
|
74
|
+
module = loader.require(resolvedFeaturesModule, import.meta.url) as {
|
|
75
|
+
default?: unknown;
|
|
76
|
+
features?: unknown;
|
|
77
|
+
};
|
|
77
78
|
} catch (error) {
|
|
78
79
|
const message = error instanceof Error ? error.message : String(error);
|
|
79
80
|
throw new Error(
|
|
80
81
|
`Failed to evaluate features module "${resolvedFeaturesModule}". Features modules must be Node-safe: ${message}`,
|
|
81
82
|
{cause: error},
|
|
82
83
|
);
|
|
84
|
+
} finally {
|
|
85
|
+
const graph = loadedModuleGraph(namespaceQuery);
|
|
86
|
+
for (const path of graph.loadedPaths) loadedPaths.add(path);
|
|
87
|
+
for (const cacheKey of graph.cacheKeys) cacheKeys.add(cacheKey);
|
|
88
|
+
for (const cacheKey of Object.keys(require.cache)) {
|
|
89
|
+
if (initialCacheKeys.has(cacheKey)) continue;
|
|
90
|
+
const path = cacheKey.endsWith(namespaceQuery)
|
|
91
|
+
? cacheKey.slice(0, -namespaceQuery.length)
|
|
92
|
+
: cacheKey;
|
|
93
|
+
const isLocal = !nodeModulesPathPattern.test(path);
|
|
94
|
+
if (isLocal) loadedPaths.add(path);
|
|
95
|
+
if (isLocal || cacheKey.endsWith(namespaceQuery)) cacheKeys.add(cacheKey);
|
|
96
|
+
}
|
|
97
|
+
loader.unregister();
|
|
98
|
+
for (const cacheKey of cacheKeys) delete require.cache[cacheKey];
|
|
99
|
+
for (const cacheKey of Object.keys(require.cache))
|
|
100
|
+
if (cacheKey.endsWith(namespaceQuery)) delete require.cache[cacheKey];
|
|
83
101
|
}
|
|
84
102
|
|
|
85
103
|
const features = module.features ?? module.default;
|
|
@@ -89,5 +107,8 @@ export async function evaluateFeatures(featuresModule: string): Promise<Evaluate
|
|
|
89
107
|
);
|
|
90
108
|
}
|
|
91
109
|
|
|
92
|
-
return {
|
|
110
|
+
return {
|
|
111
|
+
features: features as readonly ClientFeature[],
|
|
112
|
+
loadedFiles: await localFiles(loadedPaths),
|
|
113
|
+
};
|
|
93
114
|
}
|
package/src/vite/generate.ts
CHANGED
|
@@ -34,14 +34,14 @@ export function generateAppModule({
|
|
|
34
34
|
routePath: routePathForAnchor(route.parent, route.path),
|
|
35
35
|
}));
|
|
36
36
|
const imports = routes
|
|
37
|
-
.map((route, index) => `import route${index}
|
|
37
|
+
.map((route, index) => `import * as route${index}Module from ${literal(route.impl)};`)
|
|
38
38
|
.join('\n');
|
|
39
39
|
const routeDeclarations = generatedRoutes
|
|
40
40
|
.map(
|
|
41
41
|
(route, index) => `const route${index} = createRoute({
|
|
42
42
|
getParentRoute: () => ${route.parent === 'root' ? 'skeleton.rootRoute' : `skeleton.${route.parent}`},
|
|
43
43
|
path: ${literal(route.routePath)},
|
|
44
|
-
...routeOptions(route${index}
|
|
44
|
+
...routeOptions(route${index}Module.default, ${literal(route.impl)}, ${literal(route.path)}),
|
|
45
45
|
});`,
|
|
46
46
|
)
|
|
47
47
|
.join('\n\n');
|
package/src/vite/plugin.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ import {mkdtemp, readFile, realpath, rm, writeFile} from 'node:fs/promises';
|
|
|
2
2
|
import {tmpdir} from 'node:os';
|
|
3
3
|
import {join} from 'node:path';
|
|
4
4
|
import {fileURLToPath} from 'node:url';
|
|
5
|
-
import type
|
|
5
|
+
import {type Plugin, type ResolvedConfig, build as viteBuild} from 'vite';
|
|
6
6
|
import {shipfoxClientComposition} from './plugin.js';
|
|
7
7
|
|
|
8
8
|
const fixtureFeatures = fileURLToPath(new URL('../../test/fixtures/features.ts', import.meta.url));
|
|
@@ -35,13 +35,12 @@ function resolveRouteImplementation(source: string): string | undefined {
|
|
|
35
35
|
const files: Record<string, string> = {
|
|
36
36
|
'#test/default-route-impl.js': join(testDirectory, 'default-route-impl.tsx'),
|
|
37
37
|
'#test/named-route-impl.js': join(testDirectory, 'named-route-impl.tsx'),
|
|
38
|
-
'#test/not-route-impl.js': join(testDirectory, 'not-route-impl.ts'),
|
|
39
38
|
'#test/search-route-impl.js': join(testDirectory, 'search-route-impl.tsx'),
|
|
40
39
|
};
|
|
41
40
|
return files[source];
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
function pluginContext() {
|
|
43
|
+
function pluginContext(resolveImplementation = resolveRouteImplementation) {
|
|
45
44
|
const watchedFiles: string[] = [];
|
|
46
45
|
return {
|
|
47
46
|
watchedFiles,
|
|
@@ -49,13 +48,13 @@ function pluginContext() {
|
|
|
49
48
|
watchedFiles.push(file);
|
|
50
49
|
},
|
|
51
50
|
resolve(source: string) {
|
|
52
|
-
const id =
|
|
51
|
+
const id = resolveImplementation(source);
|
|
53
52
|
return Promise.resolve(id ? {id} : null);
|
|
54
53
|
},
|
|
55
54
|
environment: {
|
|
56
55
|
pluginContainer: {
|
|
57
56
|
resolveId(source: string) {
|
|
58
|
-
const id =
|
|
57
|
+
const id = resolveImplementation(source);
|
|
59
58
|
return Promise.resolve(id ? {id} : null);
|
|
60
59
|
},
|
|
61
60
|
},
|
|
@@ -95,7 +94,7 @@ describe('shipfoxClientComposition', () => {
|
|
|
95
94
|
await build(plugin, context);
|
|
96
95
|
|
|
97
96
|
await expect(readFile(out, 'utf8')).resolves.toContain(
|
|
98
|
-
'import
|
|
97
|
+
'import * as route0Module from "#test/search-route-impl.js";',
|
|
99
98
|
);
|
|
100
99
|
expect(context.watchedFiles).toContain(fixtureFeatures);
|
|
101
100
|
});
|
|
@@ -118,18 +117,69 @@ describe('shipfoxClientComposition', () => {
|
|
|
118
117
|
);
|
|
119
118
|
});
|
|
120
119
|
|
|
121
|
-
test('
|
|
120
|
+
test('fails the Vite build when a route implementation cannot resolve', async () => {
|
|
122
121
|
const features = join(directory, 'features.ts');
|
|
123
122
|
await writeFile(
|
|
124
123
|
features,
|
|
125
|
-
`export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/
|
|
124
|
+
`export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: '#test/missing-route-impl.js'}]}];`,
|
|
126
125
|
);
|
|
127
126
|
const plugin = shipfoxClientComposition({features, out: join(directory, 'shipfox-app.gen.ts')});
|
|
128
127
|
const context = pluginContext();
|
|
129
128
|
configure(plugin);
|
|
130
129
|
|
|
131
130
|
await expect(build(plugin, context)).rejects.toThrow(
|
|
132
|
-
'
|
|
131
|
+
'Could not resolve route implementation "#test/missing-route-impl.js" for "/projects".',
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('generates when Vite resolves a route implementation with a version query', async () => {
|
|
136
|
+
const out = join(directory, 'shipfox-app.gen.ts');
|
|
137
|
+
const plugin = shipfoxClientComposition({features: fixtureFeatures, out});
|
|
138
|
+
const context = pluginContext((source) => {
|
|
139
|
+
if (source === '#test/search-route-impl.js') {
|
|
140
|
+
return `${join(testDirectory, 'search-route-impl.tsx')}?v=abc123`;
|
|
141
|
+
}
|
|
142
|
+
return resolveRouteImplementation(source);
|
|
143
|
+
});
|
|
144
|
+
configure(plugin);
|
|
145
|
+
|
|
146
|
+
await build(plugin, context);
|
|
147
|
+
|
|
148
|
+
await expect(readFile(out, 'utf8')).resolves.toContain(
|
|
149
|
+
'import * as route0Module from "#test/search-route-impl.js";',
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('bundles a named-only route module so the runtime export guard can report it', async () => {
|
|
154
|
+
const features = join(directory, 'features.ts');
|
|
155
|
+
const implementation = join(directory, 'not-route-impl.ts');
|
|
156
|
+
const output = join(directory, 'shipfox-app.gen.ts');
|
|
157
|
+
const entry = join(directory, 'main.ts');
|
|
158
|
+
await Promise.all([
|
|
159
|
+
writeFile(
|
|
160
|
+
features,
|
|
161
|
+
`export const features = [{id: 'acme.projects', routes: [{path: '/projects', parent: 'root', impl: './not-route-impl.ts'}]}];`,
|
|
162
|
+
),
|
|
163
|
+
writeFile(implementation, 'export const Route = () => null;'),
|
|
164
|
+
writeFile(entry, "import './shipfox-app.gen.ts';"),
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
await expect(
|
|
168
|
+
viteBuild({
|
|
169
|
+
root: process.cwd(),
|
|
170
|
+
logLevel: 'silent',
|
|
171
|
+
plugins: [shipfoxClientComposition({features, out: output})],
|
|
172
|
+
build: {
|
|
173
|
+
write: false,
|
|
174
|
+
rolldownOptions: {
|
|
175
|
+
input: entry,
|
|
176
|
+
external: ['@shipfox/client-shell/runtime', '@tanstack/react-router'],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
).resolves.toBeDefined();
|
|
181
|
+
await expect(readFile(output, 'utf8')).resolves.toContain(
|
|
182
|
+
'routeOptions(route0Module.default, "./not-route-impl.ts", "/projects")',
|
|
133
183
|
);
|
|
134
184
|
});
|
|
135
185
|
|
package/src/vite/plugin.ts
CHANGED
|
@@ -6,11 +6,9 @@ import {mergeConfigShapes} from '#compose/merge-config.js';
|
|
|
6
6
|
import {validateProviderIds} from '#compose/validate-providers.js';
|
|
7
7
|
import {validateNavigation, validateSettingsSections} from '#compose/validate-registries.js';
|
|
8
8
|
import {navigationEntries, settingsEntries} from '#runtime/registries.js';
|
|
9
|
-
import {evaluateFeatures
|
|
9
|
+
import {evaluateFeatures} from './evaluate-features.js';
|
|
10
10
|
import {generateAppModule} from './generate.js';
|
|
11
11
|
|
|
12
|
-
const defaultExportPattern = /\bexport\s+default\b|\bexport\s*\{[\s\S]*?\bas\s+default\b[\s\S]*?\}/;
|
|
13
|
-
|
|
14
12
|
export interface ShipfoxClientCompositionOptions {
|
|
15
13
|
features: string;
|
|
16
14
|
out?: string;
|
|
@@ -18,10 +16,6 @@ export interface ShipfoxClientCompositionOptions {
|
|
|
18
16
|
|
|
19
17
|
type RouteResolver = (source: string, importer?: string) => Promise<{id: string} | null>;
|
|
20
18
|
|
|
21
|
-
function hasDefaultExport(source: string): boolean {
|
|
22
|
-
return defaultExportPattern.test(source);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
19
|
export function shipfoxClientComposition({
|
|
26
20
|
features,
|
|
27
21
|
out = './src/shipfox-app.gen.ts',
|
|
@@ -32,7 +26,7 @@ export function shipfoxClientComposition({
|
|
|
32
26
|
const outputPath = () => resolve(config?.root ?? process.cwd(), out);
|
|
33
27
|
const featuresPath = () => resolve(config?.root ?? process.cwd(), features);
|
|
34
28
|
|
|
35
|
-
async function
|
|
29
|
+
async function assertRoutesResolve(
|
|
36
30
|
resolveRoute: RouteResolver,
|
|
37
31
|
routes: ReturnType<typeof composeRoutes>,
|
|
38
32
|
): Promise<void> {
|
|
@@ -43,12 +37,6 @@ export function shipfoxClientComposition({
|
|
|
43
37
|
`Could not resolve route implementation "${route.impl}" for "${route.path}".`,
|
|
44
38
|
);
|
|
45
39
|
}
|
|
46
|
-
const source = await readFile(resolvedRoute.id, 'utf8');
|
|
47
|
-
if (!hasDefaultExport(source)) {
|
|
48
|
-
throw new Error(
|
|
49
|
-
`Route implementation "${route.impl}" for "${route.path}" must export default defineRoute(...).`,
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
40
|
}
|
|
53
41
|
}
|
|
54
42
|
|
|
@@ -59,7 +47,6 @@ export function shipfoxClientComposition({
|
|
|
59
47
|
addWatchFile(file: string): void;
|
|
60
48
|
resolveRoute: RouteResolver;
|
|
61
49
|
}): Promise<void> {
|
|
62
|
-
invalidateFeatures(watchedFiles);
|
|
63
50
|
const evaluated = await evaluateFeatures(featuresPath());
|
|
64
51
|
const routes = composeRoutes(evaluated.features);
|
|
65
52
|
validateProviderIds(evaluated.features);
|
|
@@ -72,7 +59,7 @@ export function shipfoxClientComposition({
|
|
|
72
59
|
routes.map((route) => route.path),
|
|
73
60
|
);
|
|
74
61
|
mergeConfigShapes(evaluated.features);
|
|
75
|
-
await
|
|
62
|
+
await assertRoutesResolve(resolveRoute, routes);
|
|
76
63
|
|
|
77
64
|
watchedFiles = new Set(evaluated.loadedFiles);
|
|
78
65
|
for (const file of watchedFiles) addWatchFile(file);
|
|
@@ -1,53 +1,41 @@
|
|
|
1
1
|
# External composition findings
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
## Generated-file developer experience
|
|
36
|
-
|
|
37
|
-
The generated file keeps both forms of route implementation specifier readable:
|
|
38
|
-
|
|
39
|
-
- App-local implementations use a path relative to `src/shipfox-app.gen.ts`, such as
|
|
40
|
-
`./features/override-impl`.
|
|
41
|
-
- Packaged implementations use an exported package subpath, such as
|
|
42
|
-
`@shipfox/client-shell-fixture-feature/routes/insights`.
|
|
43
|
-
|
|
44
|
-
The app's Node-evaluated feature manifest imports local TypeScript modules without a `.js` suffix so
|
|
45
|
-
jiti resolves the source file. Route implementation modules are never evaluated by jiti.
|
|
3
|
+
The graduated fixture uses the production `@shipfox/client-features` default composition and the
|
|
4
|
+
complete recursive client runtime closure from `publication-closure.json`. The external feature is
|
|
5
|
+
application-local, so route implementations and generated types cross the same package boundary as
|
|
6
|
+
a downstream distribution.
|
|
7
|
+
|
|
8
|
+
## Contract proof
|
|
9
|
+
|
|
10
|
+
The fixture proves that:
|
|
11
|
+
|
|
12
|
+
- every default feature contributes its production routes to the generated application module;
|
|
13
|
+
- an application-local settings route is added and the default login route is explicitly replaced;
|
|
14
|
+
- two application-local providers receive the shell query client and Jotai store, then nest in
|
|
15
|
+
declaration order;
|
|
16
|
+
- application navigation and settings data render through the shell-owned registries;
|
|
17
|
+
- the external config fragment is required, merged, and readable by both providers and the route;
|
|
18
|
+
- the generated router types an application-local `Link` and `useParams` call; and
|
|
19
|
+
- the unapproved login collision fails with the exact normative diagnostic.
|
|
20
|
+
|
|
21
|
+
## Distribution isolation
|
|
22
|
+
|
|
23
|
+
Packed mode builds declarations and runtime files before creating tarballs, then productionizes a
|
|
24
|
+
temporary copy of each package manifest like `release:publish` so source conditions cannot leak into
|
|
25
|
+
the consumer artifact or mutate the worktree. The fixture declares only the documented client
|
|
26
|
+
composition roots, plus `@shipfox/client-config` for its own config proof, while `file:` overrides
|
|
27
|
+
keep every first-party runtime dependency in the full closure on its local tarball. It rejects
|
|
28
|
+
registry-resolved Shipfox packages and `workspace:` ranges across every installed closure package,
|
|
29
|
+
checks that generated package imports are direct fixture dependencies, and confirms full-closure
|
|
30
|
+
runtime imports resolve through `dist` under default and `development` conditions while TypeScript
|
|
31
|
+
checks every packed declaration graph. Linked mode keeps the
|
|
32
|
+
minimal-consumer, generated-route, behavior, collision, and type checks for faster local iteration;
|
|
33
|
+
its workspace packages intentionally resolve `development` to source.
|
|
46
34
|
|
|
47
35
|
## Collision diagnostic
|
|
48
36
|
|
|
49
|
-
The rejected build
|
|
37
|
+
The rejected build must return this diagnostic and a non-zero status:
|
|
50
38
|
|
|
51
39
|
```text
|
|
52
|
-
Route "/
|
|
40
|
+
Route "/auth/login" is contributed by both features "shipfox.auth" and "fixture.unapproved-collision". Set override: true to replace it explicitly.
|
|
53
41
|
```
|
package/test/external/README.md
CHANGED
|
@@ -1,39 +1,37 @@
|
|
|
1
1
|
# External client runtime fixture
|
|
2
2
|
|
|
3
3
|
This fixture packs the published client runtime closure into a Vite application outside the pnpm
|
|
4
|
-
workspace.
|
|
4
|
+
workspace. The application starts from `defaultFeatures()` and adds one external feature that
|
|
5
|
+
replaces the login route, adds a settings route, appends providers, contributes navigation and
|
|
6
|
+
settings entries, and merges a config fragment.
|
|
5
7
|
|
|
6
|
-
##
|
|
8
|
+
## Run the required packed gate
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
The package script builds runtime files and declarations from a clean checkout before packing the
|
|
11
|
+
full client closure:
|
|
9
12
|
|
|
10
13
|
```sh
|
|
11
|
-
|
|
12
|
-
--filter='./libs/client/**' \
|
|
13
|
-
--filter=@shipfox/application-release
|
|
14
|
+
pnpm --filter=@shipfox/client-shell test:external
|
|
14
15
|
```
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
The gate installs only the nine documented client composition roots, plus
|
|
18
|
+
`@shipfox/client-config` used by the fixture's own config proof, as direct dependencies. It uses
|
|
19
|
+
release-shaped local tarballs and overrides for the full `@shipfox/*` runtime closure. It verifies
|
|
20
|
+
that generated package imports are declared direct dependencies, checks default and `development`
|
|
21
|
+
condition resolution through `dist`, type-checks every packed declaration graph, generates the
|
|
22
|
+
composed TanStack router, builds and type-checks the consumer, runs the behavioral fixture, and
|
|
23
|
+
asserts the exact rejected-collision diagnostic. CI runs this command during static verification.
|
|
17
24
|
|
|
18
|
-
|
|
19
|
-
node libs/client/shell/test/external/verify.mjs --link
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
This copies the Vite template to a temporary directory and links the workspace-built closure. It
|
|
23
|
-
runs a Vite build and resolves every non-pattern declaration entry point from the consumer.
|
|
24
|
-
|
|
25
|
-
## Run the packed exit gate
|
|
25
|
+
## Run the linked iteration mode
|
|
26
26
|
|
|
27
27
|
```sh
|
|
28
|
-
|
|
28
|
+
pnpm --filter=@shipfox/client-shell test:external -- --link
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
This
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
This copies the Vite template to a temporary directory and links the workspace-built closure. It
|
|
32
|
+
runs the same minimal-consumer, generated-route, behavior, collision, and type assertions without
|
|
33
|
+
packing tarballs. The packed-only `development` check is omitted because linked workspace packages
|
|
34
|
+
intentionally resolve that condition to source.
|
|
35
35
|
|
|
36
36
|
Both modes remove their temporary directories after completion. The behavioral composition fixture
|
|
37
|
-
|
|
38
|
-
work to the normal CI test graph. Its source files and Vitest wiring remain in the fixture template
|
|
39
|
-
for that follow-up.
|
|
37
|
+
uses Vitest with JSDOM and does not need browser E2E infrastructure.
|