@ecopages/react 0.2.0-alpha.4 → 0.2.0-alpha.5
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/CHANGELOG.md +5 -0
- package/README.md +20 -0
- package/package.json +3 -3
- package/src/react-hmr-strategy.d.ts +11 -0
- package/src/react-hmr-strategy.js +11 -0
- package/src/react-hmr-strategy.ts +11 -0
- package/src/services/react-bundle.service.d.ts +9 -4
- package/src/services/react-bundle.service.js +11 -6
- package/src/services/react-bundle.service.ts +11 -6
- package/src/utils/client-graph-boundary-plugin.js +69 -8
- package/src/utils/client-graph-boundary-plugin.ts +130 -10
- package/src/utils/reachability-analyzer.d.ts +12 -1
- package/src/utils/reachability-analyzer.js +101 -5
- package/src/utils/reachability-analyzer.ts +161 -8
package/CHANGELOG.md
CHANGED
|
@@ -46,6 +46,11 @@ All notable changes to `@ecopages/react` are documented here.
|
|
|
46
46
|
- Inlined the React MDX loader so React apps no longer need to install `@ecopages/mdx` when enabling React MDX support (`unreleased`).
|
|
47
47
|
- Fixed stale temp module race during Fast Refresh cycles (`b2cf8466`).
|
|
48
48
|
- Fixed client graph boundary wiring for runtime dependencies (`4b6cd32e`).
|
|
49
|
+
- Fixed shared React barrel handling so client-reachable server-only re-exports now fail the build instead of being silently pruned (`unreleased`).
|
|
50
|
+
|
|
51
|
+
### Documentation
|
|
52
|
+
|
|
53
|
+
- Documented the React integration server/client graph contract for shared modules and barrel exports (`unreleased`).
|
|
49
54
|
|
|
50
55
|
### Tests
|
|
51
56
|
|
package/README.md
CHANGED
|
@@ -63,3 +63,23 @@ Current behavior:
|
|
|
63
63
|
This design preserves global CSS/layout selectors while keeping runtime ownership isolated per island instance.
|
|
64
64
|
|
|
65
65
|
For full React pages with client-side navigation, prefer [@ecopages/react-router](../react-router/README.md), where routing and hydration are handled by the React-specific runtime.
|
|
66
|
+
|
|
67
|
+
## Server And Client Graph Contract
|
|
68
|
+
|
|
69
|
+
The React integration supports Node.js modules and server-only code, but only on the server execution graph.
|
|
70
|
+
|
|
71
|
+
- Server rendering can import and execute `node:*` modules, database clients, filesystem utilities, and `*.server.*` modules.
|
|
72
|
+
- Client-hydrated React code must resolve to browser-safe modules only.
|
|
73
|
+
- Shared files and barrel files are allowed when the exports that become client-reachable are browser-safe.
|
|
74
|
+
- If a server-only import becomes client-reachable, the client build fails instead of silently replacing the import.
|
|
75
|
+
|
|
76
|
+
In practice, this means you can keep server helpers close to your React code, but the browser bundle boundary is strict:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
export { Button } from './button';
|
|
80
|
+
export { db } from './db.server';
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
If a client entry only reaches `Button`, the `db` re-export is removed from the browser transform. If a client entry reaches `db`, the build fails because the server-only export crossed into the client graph.
|
|
84
|
+
|
|
85
|
+
This contract keeps SSR and server functions free to use Node.js while ensuring the final browser bundle contains no client-reachable server-only code.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/react",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.5",
|
|
4
4
|
"description": "React integration for Ecopages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -53,14 +53,14 @@
|
|
|
53
53
|
"directory": "packages/integrations/react"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
|
-
"@ecopages/core": "0.2.0-alpha.
|
|
56
|
+
"@ecopages/core": "0.2.0-alpha.5",
|
|
57
57
|
"@types/react": "^19",
|
|
58
58
|
"@types/react-dom": "^19",
|
|
59
59
|
"react": "^19",
|
|
60
60
|
"react-dom": "^19"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@ecopages/file-system": "0.2.0-alpha.
|
|
63
|
+
"@ecopages/file-system": "0.2.0-alpha.5",
|
|
64
64
|
"@ecopages/logger": "latest",
|
|
65
65
|
"@mdx-js/esbuild": "^3.0.1",
|
|
66
66
|
"@mdx-js/mdx": "^3.1.0",
|
|
@@ -54,6 +54,17 @@ export declare class ReactHmrStrategy extends HmrStrategy {
|
|
|
54
54
|
private mdxCompilerOptions?;
|
|
55
55
|
private readonly knownEntrypoints;
|
|
56
56
|
private importNodePageModule;
|
|
57
|
+
/**
|
|
58
|
+
* Redirects `use-sync-external-store/shim` imports to React's built-in
|
|
59
|
+
* `useSyncExternalStore`.
|
|
60
|
+
*
|
|
61
|
+
* Libraries like React Aria still list `use-sync-external-store` as a
|
|
62
|
+
* dependency to support React 16/17. On React 18+ the `/shim` export is
|
|
63
|
+
* already a pass-through, but without this plugin esbuild would bundle
|
|
64
|
+
* the full CJS shim (including `process.env` branching) into the browser
|
|
65
|
+
* bundle. The plugin short-circuits the resolution so only a single clean
|
|
66
|
+
* ESM re-export is emitted.
|
|
67
|
+
*/
|
|
57
68
|
private createUseSyncExternalStoreShimPlugin;
|
|
58
69
|
/**
|
|
59
70
|
* Creates a new React HMR strategy instance.
|
|
@@ -61,6 +61,17 @@ class ReactHmrStrategy extends HmrStrategy {
|
|
|
61
61
|
}
|
|
62
62
|
return await import(pathToFileURL(compiledOutput).href);
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Redirects `use-sync-external-store/shim` imports to React's built-in
|
|
66
|
+
* `useSyncExternalStore`.
|
|
67
|
+
*
|
|
68
|
+
* Libraries like React Aria still list `use-sync-external-store` as a
|
|
69
|
+
* dependency to support React 16/17. On React 18+ the `/shim` export is
|
|
70
|
+
* already a pass-through, but without this plugin esbuild would bundle
|
|
71
|
+
* the full CJS shim (including `process.env` branching) into the browser
|
|
72
|
+
* bundle. The plugin short-circuits the resolution so only a single clean
|
|
73
|
+
* ESM re-export is emitted.
|
|
74
|
+
*/
|
|
64
75
|
createUseSyncExternalStoreShimPlugin() {
|
|
65
76
|
return {
|
|
66
77
|
name: "react-hmr-use-sync-external-store-shim",
|
|
@@ -108,6 +108,17 @@ export class ReactHmrStrategy extends HmrStrategy {
|
|
|
108
108
|
};
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Redirects `use-sync-external-store/shim` imports to React's built-in
|
|
113
|
+
* `useSyncExternalStore`.
|
|
114
|
+
*
|
|
115
|
+
* Libraries like React Aria still list `use-sync-external-store` as a
|
|
116
|
+
* dependency to support React 16/17. On React 18+ the `/shim` export is
|
|
117
|
+
* already a pass-through, but without this plugin esbuild would bundle
|
|
118
|
+
* the full CJS shim (including `process.env` branching) into the browser
|
|
119
|
+
* bundle. The plugin short-circuits the resolution so only a single clean
|
|
120
|
+
* ESM re-export is emitted.
|
|
121
|
+
*/
|
|
111
122
|
private createUseSyncExternalStoreShimPlugin(): EcoBuildPlugin {
|
|
112
123
|
return {
|
|
113
124
|
name: 'react-hmr-use-sync-external-store-shim',
|
|
@@ -59,10 +59,15 @@ export declare class ReactBundleService {
|
|
|
59
59
|
}): void;
|
|
60
60
|
};
|
|
61
61
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
62
|
+
* Redirects `use-sync-external-store/shim` imports to React's built-in
|
|
63
|
+
* `useSyncExternalStore`.
|
|
64
|
+
*
|
|
65
|
+
* Libraries like React Aria still list `use-sync-external-store` as a
|
|
66
|
+
* dependency to support React 16/17. On React 18+ the `/shim` export is
|
|
67
|
+
* already a pass-through, but without this plugin esbuild would bundle
|
|
68
|
+
* the full CJS shim (including `process.env` branching) into the browser
|
|
69
|
+
* bundle. The plugin short-circuits the resolution so only a single clean
|
|
70
|
+
* ESM re-export is emitted.
|
|
66
71
|
*/
|
|
67
72
|
private createSyncExternalStorePlugin;
|
|
68
73
|
}
|
|
@@ -52,9 +52,9 @@ class ReactBundleService {
|
|
|
52
52
|
if (isMdx && this.config.mdxCompilerOptions) {
|
|
53
53
|
const { createReactMdxLoaderPlugin } = await import("../utils/react-mdx-loader-plugin.js");
|
|
54
54
|
const mdxPlugin = createReactMdxLoaderPlugin(this.config.mdxCompilerOptions);
|
|
55
|
-
options.plugins = [runtimeAliasPlugin, mdxPlugin, useSyncExternalStoreShimPlugin
|
|
55
|
+
options.plugins = [graphBoundaryPlugin, runtimeAliasPlugin, mdxPlugin, useSyncExternalStoreShimPlugin];
|
|
56
56
|
} else {
|
|
57
|
-
options.plugins = [runtimeAliasPlugin, useSyncExternalStoreShimPlugin
|
|
57
|
+
options.plugins = [graphBoundaryPlugin, runtimeAliasPlugin, useSyncExternalStoreShimPlugin];
|
|
58
58
|
}
|
|
59
59
|
return options;
|
|
60
60
|
}
|
|
@@ -94,10 +94,15 @@ class ReactBundleService {
|
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
97
|
+
* Redirects `use-sync-external-store/shim` imports to React's built-in
|
|
98
|
+
* `useSyncExternalStore`.
|
|
99
|
+
*
|
|
100
|
+
* Libraries like React Aria still list `use-sync-external-store` as a
|
|
101
|
+
* dependency to support React 16/17. On React 18+ the `/shim` export is
|
|
102
|
+
* already a pass-through, but without this plugin esbuild would bundle
|
|
103
|
+
* the full CJS shim (including `process.env` branching) into the browser
|
|
104
|
+
* bundle. The plugin short-circuits the resolution so only a single clean
|
|
105
|
+
* ESM re-export is emitted.
|
|
101
106
|
*/
|
|
102
107
|
createSyncExternalStorePlugin() {
|
|
103
108
|
return {
|
|
@@ -85,9 +85,9 @@ export class ReactBundleService {
|
|
|
85
85
|
if (isMdx && this.config.mdxCompilerOptions) {
|
|
86
86
|
const { createReactMdxLoaderPlugin } = await import('../utils/react-mdx-loader-plugin.ts');
|
|
87
87
|
const mdxPlugin = createReactMdxLoaderPlugin(this.config.mdxCompilerOptions);
|
|
88
|
-
options.plugins = [runtimeAliasPlugin, mdxPlugin, useSyncExternalStoreShimPlugin
|
|
88
|
+
options.plugins = [graphBoundaryPlugin, runtimeAliasPlugin, mdxPlugin, useSyncExternalStoreShimPlugin];
|
|
89
89
|
} else {
|
|
90
|
-
options.plugins = [runtimeAliasPlugin, useSyncExternalStoreShimPlugin
|
|
90
|
+
options.plugins = [graphBoundaryPlugin, runtimeAliasPlugin, useSyncExternalStoreShimPlugin];
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
return options;
|
|
@@ -144,10 +144,15 @@ export class ReactBundleService {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
147
|
+
* Redirects `use-sync-external-store/shim` imports to React's built-in
|
|
148
|
+
* `useSyncExternalStore`.
|
|
149
|
+
*
|
|
150
|
+
* Libraries like React Aria still list `use-sync-external-store` as a
|
|
151
|
+
* dependency to support React 16/17. On React 18+ the `/shim` export is
|
|
152
|
+
* already a pass-through, but without this plugin esbuild would bundle
|
|
153
|
+
* the full CJS shim (including `process.env` branching) into the browser
|
|
154
|
+
* bundle. The plugin short-circuits the resolution so only a single clean
|
|
155
|
+
* ESM re-export is emitted.
|
|
151
156
|
*/
|
|
152
157
|
private createSyncExternalStorePlugin() {
|
|
153
158
|
return {
|
|
@@ -12,6 +12,10 @@ function isBareSpecifier(specifier) {
|
|
|
12
12
|
function isProjectAliasSpecifier(specifier) {
|
|
13
13
|
return specifier.startsWith("@/") || specifier.startsWith("~/") || specifier.startsWith("ecopages:");
|
|
14
14
|
}
|
|
15
|
+
function isServerOnlySpecifier(specifier) {
|
|
16
|
+
if (specifier.startsWith("node:")) return true;
|
|
17
|
+
return /(?:^|[/])[^/]+\.server(?:$|\.)/.test(specifier);
|
|
18
|
+
}
|
|
15
19
|
function toModuleBaseSpecifier(specifier) {
|
|
16
20
|
if (!isBareSpecifier(specifier) || specifier.startsWith("node:")) {
|
|
17
21
|
return specifier;
|
|
@@ -82,7 +86,37 @@ function parserLanguageForFile(filename) {
|
|
|
82
86
|
if (extension === ".jsx") return "jsx";
|
|
83
87
|
return "js";
|
|
84
88
|
}
|
|
85
|
-
function
|
|
89
|
+
function normalizeRequestedExportsKey(pathname) {
|
|
90
|
+
let normalized = pathname.replace(/\\/g, "/");
|
|
91
|
+
normalized = normalized.replace(/\.(tsx?|jsx?)$/i, "");
|
|
92
|
+
if (normalized.endsWith("/index")) {
|
|
93
|
+
normalized = normalized.slice(0, -"/index".length);
|
|
94
|
+
}
|
|
95
|
+
return normalized;
|
|
96
|
+
}
|
|
97
|
+
function resolveRequestedExportsKey(importer, specifier) {
|
|
98
|
+
if (isBareSpecifier(specifier) || isProjectAliasSpecifier(specifier)) {
|
|
99
|
+
return void 0;
|
|
100
|
+
}
|
|
101
|
+
const resolved = specifier.startsWith("/") ? specifier : resolve(dirname(importer), specifier);
|
|
102
|
+
return normalizeRequestedExportsKey(resolved);
|
|
103
|
+
}
|
|
104
|
+
function mergeRequestedExportRules(registry, moduleKey, rules) {
|
|
105
|
+
const existing = registry.get(moduleKey);
|
|
106
|
+
if (existing === "*") return;
|
|
107
|
+
if (rules === "*") {
|
|
108
|
+
registry.set(moduleKey, "*");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!existing) {
|
|
112
|
+
registry.set(moduleKey, new Set(rules));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
for (const rule of rules) {
|
|
116
|
+
existing.add(rule);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function transformModuleImports(source, filename, globallyAllowed, requestedExports) {
|
|
86
120
|
let result;
|
|
87
121
|
try {
|
|
88
122
|
result = parseSync(filename, source, {
|
|
@@ -116,12 +150,35 @@ function transformModuleImports(source, filename, globallyAllowed) {
|
|
|
116
150
|
walk(program);
|
|
117
151
|
const locallyAllowed = parseDeclaredModules(localDeclared);
|
|
118
152
|
const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
|
|
119
|
-
const
|
|
153
|
+
const explicitRequestedExports = requestedExports.get(normalizeRequestedExportsKey(filename));
|
|
154
|
+
const reachability = analyzeReachability(source, filename, program, explicitRequestedExports);
|
|
155
|
+
for (const statement of program.body) {
|
|
156
|
+
if (statement.type === "ImportDeclaration") {
|
|
157
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value);
|
|
158
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value);
|
|
159
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
160
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (statement.type === "ExportNamedDeclaration" && statement.source) {
|
|
164
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value);
|
|
165
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value);
|
|
166
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
167
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (statement.type === "ExportAllDeclaration" && statement.source) {
|
|
171
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value);
|
|
172
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value);
|
|
173
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
174
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
120
177
|
const edits = [];
|
|
121
178
|
function processSpecifier(specifier) {
|
|
122
179
|
const moduleBase = toModuleBaseSpecifier(specifier);
|
|
123
180
|
const explicitRules = allowedMap.get(moduleBase);
|
|
124
|
-
if (
|
|
181
|
+
if (isServerOnlySpecifier(specifier)) {
|
|
125
182
|
if (explicitRules) {
|
|
126
183
|
return { allowed: true, rules: explicitRules };
|
|
127
184
|
}
|
|
@@ -208,9 +265,10 @@ function transformModuleImports(source, filename, globallyAllowed) {
|
|
|
208
265
|
const specifier = node.source.value;
|
|
209
266
|
const { allowed } = processSpecifier(specifier);
|
|
210
267
|
if (!allowed) {
|
|
211
|
-
|
|
268
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
269
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
212
270
|
throw new Error(
|
|
213
|
-
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}.`
|
|
271
|
+
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`
|
|
214
272
|
);
|
|
215
273
|
} else {
|
|
216
274
|
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
@@ -222,9 +280,10 @@ function transformModuleImports(source, filename, globallyAllowed) {
|
|
|
222
280
|
const specifier = node.source.value;
|
|
223
281
|
const { allowed } = processSpecifier(specifier);
|
|
224
282
|
if (!allowed) {
|
|
225
|
-
|
|
283
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
284
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
226
285
|
throw new Error(
|
|
227
|
-
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}.`
|
|
286
|
+
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`
|
|
228
287
|
);
|
|
229
288
|
} else {
|
|
230
289
|
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
@@ -297,6 +356,7 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
297
356
|
setup(build) {
|
|
298
357
|
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
299
358
|
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
359
|
+
const requestedExports = /* @__PURE__ */ new Map();
|
|
300
360
|
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
301
361
|
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), "*");
|
|
302
362
|
}
|
|
@@ -338,7 +398,8 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
338
398
|
const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
|
|
339
399
|
transformed,
|
|
340
400
|
args.path,
|
|
341
|
-
globallyDeclaredSources
|
|
401
|
+
globallyDeclaredSources,
|
|
402
|
+
requestedExports
|
|
342
403
|
);
|
|
343
404
|
if (importsModified) {
|
|
344
405
|
modified = true;
|
|
@@ -64,6 +64,20 @@ function isProjectAliasSpecifier(specifier: string): boolean {
|
|
|
64
64
|
return specifier.startsWith('@/') || specifier.startsWith('~/') || specifier.startsWith('ecopages:');
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Determines whether a specifier should be treated as server-only.
|
|
69
|
+
*
|
|
70
|
+
* This covers Node built-ins as well as local module conventions such as
|
|
71
|
+
* `.server.ts` and extensionless imports that resolve to `.server.*` files.
|
|
72
|
+
*
|
|
73
|
+
* @param specifier - Raw import specifier from the module source.
|
|
74
|
+
* @returns True when the import must never become client-reachable.
|
|
75
|
+
*/
|
|
76
|
+
function isServerOnlySpecifier(specifier: string): boolean {
|
|
77
|
+
if (specifier.startsWith('node:')) return true;
|
|
78
|
+
return /(?:^|[/])[^/]+\.server(?:$|\.)/.test(specifier);
|
|
79
|
+
}
|
|
80
|
+
|
|
67
81
|
/**
|
|
68
82
|
* Strips down a deep path module specifier to its foundational root package name.
|
|
69
83
|
*
|
|
@@ -191,6 +205,81 @@ function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
|
|
|
191
205
|
return 'js';
|
|
192
206
|
}
|
|
193
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Tracks the subset of exports that a downstream local module is allowed to expose.
|
|
210
|
+
*
|
|
211
|
+
* `'*'` means the full module namespace is reachable, while a `Set` limits the
|
|
212
|
+
* consumer to specific named exports.
|
|
213
|
+
*/
|
|
214
|
+
type RequestedExportRules = Set<string> | '*';
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Normalizes a file path into a registry key used for requested-export propagation.
|
|
218
|
+
*
|
|
219
|
+
* The normalization strips JS/TS extensions and collapses `/index` suffixes so
|
|
220
|
+
* equivalent local import forms resolve to the same key.
|
|
221
|
+
*
|
|
222
|
+
* @param pathname - Absolute or resolved local module path.
|
|
223
|
+
* @returns Stable registry key for `requestedExports`.
|
|
224
|
+
*/
|
|
225
|
+
function normalizeRequestedExportsKey(pathname: string): string {
|
|
226
|
+
let normalized = pathname.replace(/\\/g, '/');
|
|
227
|
+
normalized = normalized.replace(/\.(tsx?|jsx?)$/i, '');
|
|
228
|
+
if (normalized.endsWith('/index')) {
|
|
229
|
+
normalized = normalized.slice(0, -'/index'.length);
|
|
230
|
+
}
|
|
231
|
+
return normalized;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Resolves a local import specifier into a requested-export registry key.
|
|
236
|
+
*
|
|
237
|
+
* Bare package specifiers and project aliases are intentionally ignored because
|
|
238
|
+
* requested-export propagation is only used for cross-file local reachability.
|
|
239
|
+
*
|
|
240
|
+
* @param importer - Absolute path of the importing module.
|
|
241
|
+
* @param specifier - Raw import or re-export specifier.
|
|
242
|
+
* @returns Registry key for a local dependency, or `undefined` when not applicable.
|
|
243
|
+
*/
|
|
244
|
+
function resolveRequestedExportsKey(importer: string, specifier: string): string | undefined {
|
|
245
|
+
if (isBareSpecifier(specifier) || isProjectAliasSpecifier(specifier)) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const resolved = specifier.startsWith('/') ? specifier : resolve(dirname(importer), specifier);
|
|
250
|
+
return normalizeRequestedExportsKey(resolved);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Merges newly discovered requested-export rules into the local propagation registry.
|
|
255
|
+
*
|
|
256
|
+
* Once a module is promoted to `'*'`, it stays fully reachable for the remainder
|
|
257
|
+
* of the transform pass.
|
|
258
|
+
*
|
|
259
|
+
* @param registry - Cross-module requested-export registry.
|
|
260
|
+
* @param moduleKey - Normalized local module key.
|
|
261
|
+
* @param rules - Newly observed reachable export rules for the module.
|
|
262
|
+
*/
|
|
263
|
+
function mergeRequestedExportRules(
|
|
264
|
+
registry: Map<string, RequestedExportRules>,
|
|
265
|
+
moduleKey: string,
|
|
266
|
+
rules: Set<string> | '*',
|
|
267
|
+
) {
|
|
268
|
+
const existing = registry.get(moduleKey);
|
|
269
|
+
if (existing === '*') return;
|
|
270
|
+
if (rules === '*') {
|
|
271
|
+
registry.set(moduleKey, '*');
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (!existing) {
|
|
275
|
+
registry.set(moduleKey, new Set(rules));
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const rule of rules) {
|
|
279
|
+
existing.add(rule);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
194
283
|
/**
|
|
195
284
|
* Parses a module using Oxc AST and surgically removes forbidden imports.
|
|
196
285
|
* Filters down to the exact specifiers requested via `{namedImport}` syntax.
|
|
@@ -198,12 +287,14 @@ function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
|
|
|
198
287
|
* @param source - The raw string source content of the module.
|
|
199
288
|
* @param filename - The absolute path of the module.
|
|
200
289
|
* @param globallyAllowed - A map of modules declared globally allowable by the build configuration.
|
|
290
|
+
* @param requestedExports - Local requested-export registry used to propagate named reachability across files.
|
|
201
291
|
* @returns An object containing the transformed string and a boolean indicating if changes occurred.
|
|
202
292
|
*/
|
|
203
293
|
function transformModuleImports(
|
|
204
294
|
source: string,
|
|
205
295
|
filename: string,
|
|
206
296
|
globallyAllowed: Map<string, Set<string> | '*'>,
|
|
297
|
+
requestedExports: Map<string, RequestedExportRules>,
|
|
207
298
|
): { transformed: string; modified: boolean } {
|
|
208
299
|
/**
|
|
209
300
|
* Parse the source
|
|
@@ -273,7 +364,36 @@ function transformModuleImports(
|
|
|
273
364
|
*/
|
|
274
365
|
const locallyAllowed = parseDeclaredModules(localDeclared);
|
|
275
366
|
const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
|
|
276
|
-
const
|
|
367
|
+
const explicitRequestedExports = requestedExports.get(normalizeRequestedExportsKey(filename));
|
|
368
|
+
const reachability = analyzeReachability(source, filename, program, explicitRequestedExports);
|
|
369
|
+
|
|
370
|
+
for (const statement of program.body) {
|
|
371
|
+
if (statement.type === 'ImportDeclaration') {
|
|
372
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
373
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
374
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
375
|
+
|
|
376
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (statement.type === 'ExportNamedDeclaration' && statement.source) {
|
|
381
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
382
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
383
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
384
|
+
|
|
385
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (statement.type === 'ExportAllDeclaration' && statement.source) {
|
|
390
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
391
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
392
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
393
|
+
|
|
394
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
277
397
|
|
|
278
398
|
/**
|
|
279
399
|
* Build the edit list
|
|
@@ -294,7 +414,7 @@ function transformModuleImports(
|
|
|
294
414
|
const moduleBase = toModuleBaseSpecifier(specifier);
|
|
295
415
|
const explicitRules = allowedMap.get(moduleBase);
|
|
296
416
|
|
|
297
|
-
if (
|
|
417
|
+
if (isServerOnlySpecifier(specifier)) {
|
|
298
418
|
if (explicitRules) {
|
|
299
419
|
return { allowed: true, rules: explicitRules };
|
|
300
420
|
}
|
|
@@ -405,14 +525,11 @@ function transformModuleImports(
|
|
|
405
525
|
const specifier = node.source.value as string;
|
|
406
526
|
const { allowed } = processSpecifier(specifier);
|
|
407
527
|
|
|
408
|
-
/**
|
|
409
|
-
* We skip checking reachability of re-exports for now to avoid false negatives.
|
|
410
|
-
* But we MUST check security.
|
|
411
|
-
*/
|
|
412
528
|
if (!allowed) {
|
|
413
|
-
|
|
529
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
530
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
414
531
|
throw new Error(
|
|
415
|
-
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}.`,
|
|
532
|
+
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
|
|
416
533
|
);
|
|
417
534
|
} else {
|
|
418
535
|
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
@@ -425,9 +542,10 @@ function transformModuleImports(
|
|
|
425
542
|
const specifier = node.source.value as string;
|
|
426
543
|
const { allowed } = processSpecifier(specifier);
|
|
427
544
|
if (!allowed) {
|
|
428
|
-
|
|
545
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
546
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
429
547
|
throw new Error(
|
|
430
|
-
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}.`,
|
|
548
|
+
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
|
|
431
549
|
);
|
|
432
550
|
} else {
|
|
433
551
|
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
@@ -516,6 +634,7 @@ export function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOpt
|
|
|
516
634
|
setup(build) {
|
|
517
635
|
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
518
636
|
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
637
|
+
const requestedExports = new Map<string, RequestedExportRules>();
|
|
519
638
|
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
520
639
|
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), '*');
|
|
521
640
|
}
|
|
@@ -573,6 +692,7 @@ export function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOpt
|
|
|
573
692
|
transformed,
|
|
574
693
|
args.path,
|
|
575
694
|
globallyDeclaredSources,
|
|
695
|
+
requestedExports,
|
|
576
696
|
);
|
|
577
697
|
|
|
578
698
|
if (importsModified) {
|
|
@@ -42,6 +42,15 @@ export type ReachabilityResult = {
|
|
|
42
42
|
*/
|
|
43
43
|
analyzed: boolean;
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Optional export filter supplied by the client graph boundary when a local
|
|
47
|
+
* module is imported through a narrower named-export surface.
|
|
48
|
+
*
|
|
49
|
+
* `'*'` means the whole module namespace is considered reachable, while a
|
|
50
|
+
* `Set` restricts analysis to the named exports that are actually requested by
|
|
51
|
+
* downstream client-reachable modules.
|
|
52
|
+
*/
|
|
53
|
+
type ExplicitlyRequestedExports = Set<string> | '*';
|
|
45
54
|
/**
|
|
46
55
|
* Analyzes a module using Oxc AST and extracts a strict reachability graph
|
|
47
56
|
* starting from client roots (`render`, `errorBoundary`, `loadingFallback` of `eco.page` or `eco.component`).
|
|
@@ -50,6 +59,8 @@ export type ReachabilityResult = {
|
|
|
50
59
|
* @param filename - Absolute or relative path to the module file.
|
|
51
60
|
* @param program - Optional pre-parsed Oxc program AST. When supplied, the
|
|
52
61
|
* internal `parseSync` call is skipped entirely (avoids double-parsing).
|
|
62
|
+
* @param explicitlyRequestedExports - Optional named export filter propagated
|
|
63
|
+
* from a downstream importer when this module is only partially reachable.
|
|
53
64
|
*/
|
|
54
|
-
export declare function analyzeReachability(source: string, filename: string, program?: ReturnType<typeof parseSync>['program']): ReachabilityResult;
|
|
65
|
+
export declare function analyzeReachability(source: string, filename: string, program?: ReturnType<typeof parseSync>['program'], explicitlyRequestedExports?: ExplicitlyRequestedExports): ReachabilityResult;
|
|
55
66
|
export {};
|
|
@@ -7,7 +7,7 @@ function parserLanguageForFile(filename) {
|
|
|
7
7
|
if (extension === ".jsx") return "jsx";
|
|
8
8
|
return "js";
|
|
9
9
|
}
|
|
10
|
-
function analyzeReachability(source, filename, program) {
|
|
10
|
+
function analyzeReachability(source, filename, program, explicitlyRequestedExports) {
|
|
11
11
|
let resolvedProgram;
|
|
12
12
|
if (program) {
|
|
13
13
|
resolvedProgram = program;
|
|
@@ -114,12 +114,86 @@ function analyzeReachability(source, filename, program) {
|
|
|
114
114
|
potentialClientRoots.push(node);
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
function getExportedName(specifier) {
|
|
118
|
+
if (specifier?.exported?.type === "Identifier") return specifier.exported.name;
|
|
119
|
+
if (typeof specifier?.exported?.value === "string") return specifier.exported.value;
|
|
120
|
+
if (specifier?.local?.type === "Identifier") return specifier.local.name;
|
|
121
|
+
if (typeof specifier?.local?.value === "string") return specifier.local.value;
|
|
122
|
+
return void 0;
|
|
123
|
+
}
|
|
124
|
+
function getReexportedImportName(specifier) {
|
|
125
|
+
if (specifier?.local?.type === "Identifier") return specifier.local.name;
|
|
126
|
+
if (typeof specifier?.local?.value === "string") return specifier.local.value;
|
|
127
|
+
if (specifier?.imported?.type === "Identifier") return specifier.imported.name;
|
|
128
|
+
if (typeof specifier?.imported?.value === "string") return specifier.imported.value;
|
|
129
|
+
return getExportedName(specifier);
|
|
130
|
+
}
|
|
131
|
+
function getLocalExportName(specifier) {
|
|
132
|
+
if (specifier?.local?.type === "Identifier") return specifier.local.name;
|
|
133
|
+
if (typeof specifier?.local?.value === "string") return specifier.local.value;
|
|
134
|
+
return void 0;
|
|
135
|
+
}
|
|
136
|
+
function isExplicitlyRequestedExport(name) {
|
|
137
|
+
if (explicitlyRequestedExports === "*") return true;
|
|
138
|
+
return explicitlyRequestedExports?.has(name) ?? false;
|
|
139
|
+
}
|
|
117
140
|
let isFallbackRoots = false;
|
|
118
141
|
if (potentialClientRoots.length === 0) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
142
|
+
if (explicitlyRequestedExports) {
|
|
143
|
+
for (const node of resolvedProgram.body) {
|
|
144
|
+
if (node.type === "ExportNamedDeclaration") {
|
|
145
|
+
const exportNode = node;
|
|
146
|
+
if (exportNode.source && exportNode.specifiers?.length) {
|
|
147
|
+
const hasRequestedReexport = exportNode.specifiers.some((specifier) => {
|
|
148
|
+
const exportedName = getExportedName(specifier);
|
|
149
|
+
return exportedName ? isExplicitlyRequestedExport(exportedName) : false;
|
|
150
|
+
});
|
|
151
|
+
if (hasRequestedReexport) {
|
|
152
|
+
potentialClientRoots.push(node);
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (exportNode.declaration?.type === "FunctionDeclaration" || exportNode.declaration?.type === "ClassDeclaration") {
|
|
157
|
+
const declarationName = exportNode.declaration.id?.name;
|
|
158
|
+
if (declarationName && isExplicitlyRequestedExport(declarationName)) {
|
|
159
|
+
potentialClientRoots.push(node);
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (exportNode.declaration?.type === "VariableDeclaration") {
|
|
164
|
+
const hasRequestedDeclaration = exportNode.declaration.declarations.some(
|
|
165
|
+
(declaration) => declaration.id?.type === "Identifier" && isExplicitlyRequestedExport(declaration.id.name)
|
|
166
|
+
);
|
|
167
|
+
if (hasRequestedDeclaration) {
|
|
168
|
+
potentialClientRoots.push(node);
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (exportNode.specifiers?.length) {
|
|
173
|
+
const hasRequestedSpecifier = exportNode.specifiers.some((specifier) => {
|
|
174
|
+
const exportedName = getExportedName(specifier);
|
|
175
|
+
return exportedName ? isExplicitlyRequestedExport(exportedName) : false;
|
|
176
|
+
});
|
|
177
|
+
if (hasRequestedSpecifier) {
|
|
178
|
+
potentialClientRoots.push(node);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} else if (node.type === "ExportDefaultDeclaration") {
|
|
182
|
+
if (isExplicitlyRequestedExport("default")) {
|
|
183
|
+
potentialClientRoots.push(node);
|
|
184
|
+
}
|
|
185
|
+
} else if (node.type === "ExportAllDeclaration") {
|
|
186
|
+
if (explicitlyRequestedExports === "*") {
|
|
187
|
+
potentialClientRoots.push(node);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
isFallbackRoots = true;
|
|
193
|
+
for (const node of resolvedProgram.body) {
|
|
194
|
+
if (node.type === "ExportNamedDeclaration" || node.type === "ExportDefaultDeclaration" || node.type === "ExportAllDeclaration") {
|
|
195
|
+
potentialClientRoots.push(node);
|
|
196
|
+
}
|
|
123
197
|
}
|
|
124
198
|
}
|
|
125
199
|
}
|
|
@@ -167,6 +241,28 @@ function analyzeReachability(source, filename, program) {
|
|
|
167
241
|
markImportReachable(node.source.value, "*");
|
|
168
242
|
return;
|
|
169
243
|
}
|
|
244
|
+
if (node.type === "ExportNamedDeclaration" && typeof node.source?.value === "string") {
|
|
245
|
+
for (const specifier of node.specifiers ?? []) {
|
|
246
|
+
const importedName = getReexportedImportName(specifier);
|
|
247
|
+
if (importedName) {
|
|
248
|
+
markImportReachable(node.source.value, importedName);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (node.type === "ExportNamedDeclaration" && !node.source && explicitlyRequestedExports && node.specifiers?.length) {
|
|
254
|
+
for (const specifier of node.specifiers) {
|
|
255
|
+
const exportedName = getExportedName(specifier);
|
|
256
|
+
if (!exportedName || !isExplicitlyRequestedExport(exportedName)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const localName = getLocalExportName(specifier);
|
|
260
|
+
if (localName && !currentScope.has(localName)) {
|
|
261
|
+
checkIdentifier(localName);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
170
266
|
if (node.type === "Identifier" || node.type === "JSXIdentifier" && /^[A-Z]/.test(node.name)) {
|
|
171
267
|
if (!currentScope.has(node.name)) {
|
|
172
268
|
checkIdentifier(node.name);
|
|
@@ -58,6 +58,16 @@ export type ReachabilityResult = {
|
|
|
58
58
|
analyzed: boolean;
|
|
59
59
|
};
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Optional export filter supplied by the client graph boundary when a local
|
|
63
|
+
* module is imported through a narrower named-export surface.
|
|
64
|
+
*
|
|
65
|
+
* `'*'` means the whole module namespace is considered reachable, while a
|
|
66
|
+
* `Set` restricts analysis to the named exports that are actually requested by
|
|
67
|
+
* downstream client-reachable modules.
|
|
68
|
+
*/
|
|
69
|
+
type ExplicitlyRequestedExports = Set<string> | '*';
|
|
70
|
+
|
|
61
71
|
/**
|
|
62
72
|
* Analyzes a module using Oxc AST and extracts a strict reachability graph
|
|
63
73
|
* starting from client roots (`render`, `errorBoundary`, `loadingFallback` of `eco.page` or `eco.component`).
|
|
@@ -66,11 +76,14 @@ export type ReachabilityResult = {
|
|
|
66
76
|
* @param filename - Absolute or relative path to the module file.
|
|
67
77
|
* @param program - Optional pre-parsed Oxc program AST. When supplied, the
|
|
68
78
|
* internal `parseSync` call is skipped entirely (avoids double-parsing).
|
|
79
|
+
* @param explicitlyRequestedExports - Optional named export filter propagated
|
|
80
|
+
* from a downstream importer when this module is only partially reachable.
|
|
69
81
|
*/
|
|
70
82
|
export function analyzeReachability(
|
|
71
83
|
source: string,
|
|
72
84
|
filename: string,
|
|
73
85
|
program?: ReturnType<typeof parseSync>['program'],
|
|
86
|
+
explicitlyRequestedExports?: ExplicitlyRequestedExports,
|
|
74
87
|
): ReachabilityResult {
|
|
75
88
|
/**
|
|
76
89
|
* AST Resolution
|
|
@@ -242,6 +255,57 @@ export function analyzeReachability(
|
|
|
242
255
|
}
|
|
243
256
|
}
|
|
244
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Resolves the externally visible export name from an export specifier.
|
|
260
|
+
*
|
|
261
|
+
* @param specifier - Oxc export specifier node.
|
|
262
|
+
* @returns The exported binding name when available.
|
|
263
|
+
*/
|
|
264
|
+
function getExportedName(specifier: any): string | undefined {
|
|
265
|
+
if (specifier?.exported?.type === 'Identifier') return specifier.exported.name;
|
|
266
|
+
if (typeof specifier?.exported?.value === 'string') return specifier.exported.value;
|
|
267
|
+
if (specifier?.local?.type === 'Identifier') return specifier.local.name;
|
|
268
|
+
if (typeof specifier?.local?.value === 'string') return specifier.local.value;
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolves the imported binding name represented by a re-export specifier.
|
|
274
|
+
*
|
|
275
|
+
* @param specifier - Oxc export specifier node.
|
|
276
|
+
* @returns The source-module binding name that should be marked reachable.
|
|
277
|
+
*/
|
|
278
|
+
function getReexportedImportName(specifier: any): string | undefined {
|
|
279
|
+
if (specifier?.local?.type === 'Identifier') return specifier.local.name;
|
|
280
|
+
if (typeof specifier?.local?.value === 'string') return specifier.local.value;
|
|
281
|
+
if (specifier?.imported?.type === 'Identifier') return specifier.imported.name;
|
|
282
|
+
if (typeof specifier?.imported?.value === 'string') return specifier.imported.value;
|
|
283
|
+
return getExportedName(specifier);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Resolves the local identifier used by a local export list entry.
|
|
288
|
+
*
|
|
289
|
+
* @param specifier - Oxc export specifier node.
|
|
290
|
+
* @returns The local symbol name referenced by the export list.
|
|
291
|
+
*/
|
|
292
|
+
function getLocalExportName(specifier: any): string | undefined {
|
|
293
|
+
if (specifier?.local?.type === 'Identifier') return specifier.local.name;
|
|
294
|
+
if (typeof specifier?.local?.value === 'string') return specifier.local.value;
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Checks whether a named export is part of the explicitly requested subset.
|
|
300
|
+
*
|
|
301
|
+
* @param name - Export name to test.
|
|
302
|
+
* @returns True when the export should seed or continue traversal.
|
|
303
|
+
*/
|
|
304
|
+
function isExplicitlyRequestedExport(name: string): boolean {
|
|
305
|
+
if (explicitlyRequestedExports === '*') return true;
|
|
306
|
+
return explicitlyRequestedExports?.has(name) ?? false;
|
|
307
|
+
}
|
|
308
|
+
|
|
245
309
|
/**
|
|
246
310
|
* Client root resolution (fallback mode)
|
|
247
311
|
*
|
|
@@ -255,14 +319,73 @@ export function analyzeReachability(
|
|
|
255
319
|
*/
|
|
256
320
|
let isFallbackRoots = false;
|
|
257
321
|
if (potentialClientRoots.length === 0) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
322
|
+
if (explicitlyRequestedExports) {
|
|
323
|
+
for (const node of resolvedProgram.body) {
|
|
324
|
+
if ((node as { type: string }).type === 'ExportNamedDeclaration') {
|
|
325
|
+
const exportNode = node as any;
|
|
326
|
+
if (exportNode.source && exportNode.specifiers?.length) {
|
|
327
|
+
const hasRequestedReexport = exportNode.specifiers.some((specifier: any) => {
|
|
328
|
+
const exportedName = getExportedName(specifier);
|
|
329
|
+
return exportedName ? isExplicitlyRequestedExport(exportedName) : false;
|
|
330
|
+
});
|
|
331
|
+
if (hasRequestedReexport) {
|
|
332
|
+
potentialClientRoots.push(node);
|
|
333
|
+
}
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (
|
|
338
|
+
exportNode.declaration?.type === 'FunctionDeclaration' ||
|
|
339
|
+
exportNode.declaration?.type === 'ClassDeclaration'
|
|
340
|
+
) {
|
|
341
|
+
const declarationName = exportNode.declaration.id?.name;
|
|
342
|
+
if (declarationName && isExplicitlyRequestedExport(declarationName)) {
|
|
343
|
+
potentialClientRoots.push(node);
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (exportNode.declaration?.type === 'VariableDeclaration') {
|
|
349
|
+
const hasRequestedDeclaration = exportNode.declaration.declarations.some(
|
|
350
|
+
(declaration: any) =>
|
|
351
|
+
declaration.id?.type === 'Identifier' &&
|
|
352
|
+
isExplicitlyRequestedExport(declaration.id.name),
|
|
353
|
+
);
|
|
354
|
+
if (hasRequestedDeclaration) {
|
|
355
|
+
potentialClientRoots.push(node);
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (exportNode.specifiers?.length) {
|
|
361
|
+
const hasRequestedSpecifier = exportNode.specifiers.some((specifier: any) => {
|
|
362
|
+
const exportedName = getExportedName(specifier);
|
|
363
|
+
return exportedName ? isExplicitlyRequestedExport(exportedName) : false;
|
|
364
|
+
});
|
|
365
|
+
if (hasRequestedSpecifier) {
|
|
366
|
+
potentialClientRoots.push(node);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} else if ((node as { type: string }).type === 'ExportDefaultDeclaration') {
|
|
370
|
+
if (isExplicitlyRequestedExport('default')) {
|
|
371
|
+
potentialClientRoots.push(node);
|
|
372
|
+
}
|
|
373
|
+
} else if ((node as { type: string }).type === 'ExportAllDeclaration') {
|
|
374
|
+
if (explicitlyRequestedExports === '*') {
|
|
375
|
+
potentialClientRoots.push(node);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
isFallbackRoots = true;
|
|
381
|
+
for (const node of resolvedProgram.body) {
|
|
382
|
+
if (
|
|
383
|
+
(node as { type: string }).type === 'ExportNamedDeclaration' ||
|
|
384
|
+
(node as { type: string }).type === 'ExportDefaultDeclaration' ||
|
|
385
|
+
(node as { type: string }).type === 'ExportAllDeclaration'
|
|
386
|
+
) {
|
|
387
|
+
potentialClientRoots.push(node);
|
|
388
|
+
}
|
|
266
389
|
}
|
|
267
390
|
}
|
|
268
391
|
}
|
|
@@ -353,6 +476,36 @@ export function analyzeReachability(
|
|
|
353
476
|
return;
|
|
354
477
|
}
|
|
355
478
|
|
|
479
|
+
if (node.type === 'ExportNamedDeclaration' && typeof node.source?.value === 'string') {
|
|
480
|
+
for (const specifier of node.specifiers ?? []) {
|
|
481
|
+
const importedName = getReexportedImportName(specifier);
|
|
482
|
+
if (importedName) {
|
|
483
|
+
markImportReachable(node.source.value as string, importedName);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (
|
|
490
|
+
node.type === 'ExportNamedDeclaration' &&
|
|
491
|
+
!node.source &&
|
|
492
|
+
explicitlyRequestedExports &&
|
|
493
|
+
node.specifiers?.length
|
|
494
|
+
) {
|
|
495
|
+
for (const specifier of node.specifiers) {
|
|
496
|
+
const exportedName = getExportedName(specifier);
|
|
497
|
+
if (!exportedName || !isExplicitlyRequestedExport(exportedName)) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const localName = getLocalExportName(specifier);
|
|
502
|
+
if (localName && !currentScope.has(localName)) {
|
|
503
|
+
checkIdentifier(localName);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
356
509
|
if (node.type === 'Identifier' || (node.type === 'JSXIdentifier' && /^[A-Z]/.test(node.name))) {
|
|
357
510
|
if (!currentScope.has(node.name)) {
|
|
358
511
|
checkIdentifier(node.name);
|