@ecopages/react 0.2.0-alpha.1
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 +62 -0
- package/LICENSE +21 -0
- package/README.md +65 -0
- package/package.json +76 -0
- package/src/declarations.d.ts +6 -0
- package/src/react-hmr-strategy.d.ts +143 -0
- package/src/react-hmr-strategy.js +332 -0
- package/src/react-hmr-strategy.ts +444 -0
- package/src/react-renderer.d.ts +106 -0
- package/src/react-renderer.js +302 -0
- package/src/react-renderer.ts +403 -0
- package/src/react.plugin.d.ts +147 -0
- package/src/react.plugin.js +126 -0
- package/src/react.plugin.ts +241 -0
- package/src/router-adapter.d.ts +87 -0
- package/src/router-adapter.js +0 -0
- package/src/router-adapter.ts +95 -0
- package/src/services/react-bundle.service.d.ts +68 -0
- package/src/services/react-bundle.service.js +145 -0
- package/src/services/react-bundle.service.ts +212 -0
- package/src/services/react-hmr-page-metadata-cache.d.ts +17 -0
- package/src/services/react-hmr-page-metadata-cache.js +19 -0
- package/src/services/react-hmr-page-metadata-cache.ts +24 -0
- package/src/services/react-hydration-asset.service.d.ts +75 -0
- package/src/services/react-hydration-asset.service.js +198 -0
- package/src/services/react-hydration-asset.service.ts +260 -0
- package/src/services/react-page-module.service.d.ts +80 -0
- package/src/services/react-page-module.service.js +155 -0
- package/src/services/react-page-module.service.ts +214 -0
- package/src/services/react-runtime-bundle.service.d.ts +38 -0
- package/src/services/react-runtime-bundle.service.js +207 -0
- package/src/services/react-runtime-bundle.service.ts +271 -0
- package/src/utils/client-graph-boundary-plugin.d.ts +43 -0
- package/src/utils/client-graph-boundary-plugin.js +356 -0
- package/src/utils/client-graph-boundary-plugin.ts +590 -0
- package/src/utils/client-only.d.ts +8 -0
- package/src/utils/client-only.js +19 -0
- package/src/utils/client-only.ts +27 -0
- package/src/utils/declared-modules.d.ts +42 -0
- package/src/utils/declared-modules.js +56 -0
- package/src/utils/declared-modules.ts +99 -0
- package/src/utils/dynamic.d.ts +15 -0
- package/src/utils/dynamic.js +12 -0
- package/src/utils/dynamic.ts +27 -0
- package/src/utils/hmr-scripts.d.ts +18 -0
- package/src/utils/hmr-scripts.js +31 -0
- package/src/utils/hmr-scripts.ts +47 -0
- package/src/utils/html-boundary.d.ts +7 -0
- package/src/utils/html-boundary.js +55 -0
- package/src/utils/html-boundary.ts +66 -0
- package/src/utils/hydration-scripts.d.ts +71 -0
- package/src/utils/hydration-scripts.js +222 -0
- package/src/utils/hydration-scripts.ts +338 -0
- package/src/utils/reachability-analyzer.d.ts +55 -0
- package/src/utils/reachability-analyzer.js +243 -0
- package/src/utils/reachability-analyzer.ts +440 -0
- package/src/utils/react-mdx-loader-plugin.d.ts +3 -0
- package/src/utils/react-mdx-loader-plugin.js +37 -0
- package/src/utils/react-mdx-loader-plugin.ts +40 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime bundle service for React integration.
|
|
3
|
+
*
|
|
4
|
+
* Owns creation of the browser runtime assets for React and React DOM,
|
|
5
|
+
* including temporary entry generation, specifier mapping, and React DOM
|
|
6
|
+
* interop rewriting.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
|
|
15
|
+
import { type AssetDefinition, AssetFactory } from '@ecopages/core/services/asset-processing-service';
|
|
16
|
+
import type { ReactRouterAdapter } from '../router-adapter.ts';
|
|
17
|
+
|
|
18
|
+
type RuntimeModuleConfig = {
|
|
19
|
+
specifier: string;
|
|
20
|
+
defaultExport?: boolean;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ReactRuntimeImports = {
|
|
24
|
+
react: string;
|
|
25
|
+
reactDomClient: string;
|
|
26
|
+
reactJsxRuntime: string;
|
|
27
|
+
reactJsxDevRuntime: string;
|
|
28
|
+
reactDom: string;
|
|
29
|
+
router?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export interface ReactRuntimeBundleServiceConfig {
|
|
33
|
+
routerAdapter?: ReactRouterAdapter;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ReactRuntimeBundleService {
|
|
37
|
+
constructor(private readonly config: ReactRuntimeBundleServiceConfig) {}
|
|
38
|
+
|
|
39
|
+
getRuntimeImports(): ReactRuntimeImports {
|
|
40
|
+
const runtimeImports: ReactRuntimeImports = {
|
|
41
|
+
react: this.buildImportMapSourceUrl('react.js'),
|
|
42
|
+
reactDomClient: this.buildImportMapSourceUrl('react-dom.js'),
|
|
43
|
+
reactJsxRuntime: this.buildImportMapSourceUrl('react.js'),
|
|
44
|
+
reactJsxDevRuntime: this.buildImportMapSourceUrl('react.js'),
|
|
45
|
+
reactDom: this.buildImportMapSourceUrl('react-dom.js'),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (this.config.routerAdapter) {
|
|
49
|
+
runtimeImports.router = this.buildImportMapSourceUrl(`${this.config.routerAdapter.bundle.outputName}.js`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return runtimeImports;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
getSpecifierMap(): Record<string, string> {
|
|
56
|
+
const runtimeImports = this.getRuntimeImports();
|
|
57
|
+
const map: Record<string, string> = {
|
|
58
|
+
react: runtimeImports.react,
|
|
59
|
+
'react/jsx-runtime': runtimeImports.reactJsxRuntime,
|
|
60
|
+
'react/jsx-dev-runtime': runtimeImports.reactJsxDevRuntime,
|
|
61
|
+
'react-dom': runtimeImports.reactDom,
|
|
62
|
+
'react-dom/client': runtimeImports.reactDomClient,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
if (this.config.routerAdapter && runtimeImports.router) {
|
|
66
|
+
map[this.config.routerAdapter.importMapKey] = runtimeImports.router;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
getDependencies(): AssetDefinition[] {
|
|
73
|
+
const runtimeAttrs = { type: 'module', defer: '' } as const;
|
|
74
|
+
const runtimeImports = this.getRuntimeImports();
|
|
75
|
+
const reactRuntimeAliasPlugin = this.createRuntimeSpecifierAliasPlugin({
|
|
76
|
+
react: runtimeImports.react,
|
|
77
|
+
});
|
|
78
|
+
const reactDomRuntimeInteropPlugin = this.createReactDomRuntimeInteropPlugin();
|
|
79
|
+
|
|
80
|
+
const reactEntry = this.createRuntimeEntry(
|
|
81
|
+
[
|
|
82
|
+
{ specifier: 'react', defaultExport: true },
|
|
83
|
+
{ specifier: 'react/jsx-runtime' },
|
|
84
|
+
{ specifier: 'react/jsx-dev-runtime' },
|
|
85
|
+
],
|
|
86
|
+
'react-entry.mjs',
|
|
87
|
+
);
|
|
88
|
+
const reactDomEntry = this.createRuntimeEntry(
|
|
89
|
+
[{ specifier: 'react-dom', defaultExport: true }, { specifier: 'react-dom/client' }],
|
|
90
|
+
'react-dom-entry.mjs',
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const dependencies: AssetDefinition[] = [
|
|
94
|
+
AssetFactory.createNodeModuleScript({
|
|
95
|
+
position: 'head',
|
|
96
|
+
importPath: reactEntry,
|
|
97
|
+
name: 'react',
|
|
98
|
+
excludeFromHtml: true,
|
|
99
|
+
bundleOptions: { naming: 'react.js' },
|
|
100
|
+
attributes: runtimeAttrs,
|
|
101
|
+
}),
|
|
102
|
+
AssetFactory.createNodeModuleScript({
|
|
103
|
+
position: 'head',
|
|
104
|
+
importPath: reactDomEntry,
|
|
105
|
+
name: 'react-dom',
|
|
106
|
+
excludeFromHtml: true,
|
|
107
|
+
bundleOptions: {
|
|
108
|
+
naming: 'react-dom.js',
|
|
109
|
+
plugins: [reactRuntimeAliasPlugin, reactDomRuntimeInteropPlugin],
|
|
110
|
+
},
|
|
111
|
+
attributes: runtimeAttrs,
|
|
112
|
+
}),
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
if (this.config.routerAdapter) {
|
|
116
|
+
const runtimeAliasPlugin = this.createRuntimeAliasPlugin();
|
|
117
|
+
const mappedSpecifiers = new Set(Object.keys(this.getSpecifierMap()));
|
|
118
|
+
const unresolvedExternals = this.config.routerAdapter.bundle.externals.filter(
|
|
119
|
+
(external) => !mappedSpecifiers.has(external),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
dependencies.push(
|
|
123
|
+
AssetFactory.createNodeModuleScript({
|
|
124
|
+
position: 'head',
|
|
125
|
+
importPath: this.config.routerAdapter.bundle.importPath,
|
|
126
|
+
name: this.config.routerAdapter.bundle.outputName,
|
|
127
|
+
excludeFromHtml: true,
|
|
128
|
+
bundleOptions: {
|
|
129
|
+
naming: `${this.config.routerAdapter.bundle.outputName}.js`,
|
|
130
|
+
external: unresolvedExternals,
|
|
131
|
+
plugins: [runtimeAliasPlugin],
|
|
132
|
+
},
|
|
133
|
+
attributes: runtimeAttrs,
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return dependencies;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
createRuntimeAliasPlugin(): EcoBuildPlugin {
|
|
142
|
+
const specifierMap = this.getSpecifierMap();
|
|
143
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
144
|
+
const filter = new RegExp(
|
|
145
|
+
`^(${Object.keys(specifierMap)
|
|
146
|
+
.map((key) => escapeRegExp(key))
|
|
147
|
+
.join('|')})$`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
name: 'react-plugin-runtime-alias',
|
|
152
|
+
setup(build) {
|
|
153
|
+
build.onResolve({ filter }, (args) => {
|
|
154
|
+
const mappedPath = specifierMap[args.path];
|
|
155
|
+
if (!mappedPath) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
path: mappedPath,
|
|
161
|
+
external: true,
|
|
162
|
+
};
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private buildImportMapSourceUrl(fileName: string): string {
|
|
169
|
+
return `/${AssetFactory.RESOLVED_ASSETS_VENDORS_DIR}/${fileName}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private createRuntimeSpecifierAliasPlugin(specifierMap: Record<string, string>, external = true): EcoBuildPlugin {
|
|
173
|
+
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
174
|
+
const filter = new RegExp(
|
|
175
|
+
`^(${Object.keys(specifierMap)
|
|
176
|
+
.map((key) => escapeRegExp(key))
|
|
177
|
+
.join('|')})$`,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
name: 'react-plugin-runtime-specifier-alias',
|
|
182
|
+
setup(build) {
|
|
183
|
+
build.onResolve({ filter }, (args) => {
|
|
184
|
+
const mappedPath = specifierMap[args.path];
|
|
185
|
+
if (!mappedPath) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
path: mappedPath,
|
|
191
|
+
external,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private createReactDomRuntimeInteropPlugin(): EcoBuildPlugin {
|
|
199
|
+
const reactDomFileFilter = /[\\/]react-dom[\\/].*\.js$/;
|
|
200
|
+
const reactRequirePattern = /\brequire\((['"])react\1\)/g;
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
name: 'react-dom-runtime-interop',
|
|
204
|
+
setup(build) {
|
|
205
|
+
build.onLoad({ filter: reactDomFileFilter }, (args) => {
|
|
206
|
+
const content = fs.readFileSync(args.path, 'utf-8');
|
|
207
|
+
if (!reactRequirePattern.test(content)) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
reactRequirePattern.lastIndex = 0;
|
|
212
|
+
const rewritten = content.replace(reactRequirePattern, '__ecopages_react_runtime');
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
contents: `import * as __ecopages_react_runtime from 'react';\n${rewritten}`,
|
|
216
|
+
loader: 'js',
|
|
217
|
+
resolveDir: path.dirname(args.path),
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private getRuntimeArtifactsDir(): string {
|
|
225
|
+
const tmpDir = path.join(process.cwd(), 'node_modules', '.cache', 'ecopages-react-runtime');
|
|
226
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
227
|
+
return tmpDir;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private createRuntimeEntry(modules: RuntimeModuleConfig[], fileName: string): string {
|
|
231
|
+
const tmpDir = this.getRuntimeArtifactsDir();
|
|
232
|
+
const requireFromRoot = createRequire(path.join(process.cwd(), 'package.json'));
|
|
233
|
+
const seenExports = new Set<string>();
|
|
234
|
+
const statements: string[] = [];
|
|
235
|
+
|
|
236
|
+
for (const module of modules) {
|
|
237
|
+
if (module.defaultExport) {
|
|
238
|
+
statements.push(`import __ecopages_default_export__ from '${module.specifier}';`);
|
|
239
|
+
statements.push('export default __ecopages_default_export__;');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const exportNames = this.getModuleExportNames(module.specifier, requireFromRoot).filter(
|
|
243
|
+
(name) => !seenExports.has(name),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (exportNames.length > 0) {
|
|
247
|
+
statements.push(`export { ${exportNames.join(', ')} } from '${module.specifier}';`);
|
|
248
|
+
for (const exportName of exportNames) {
|
|
249
|
+
seenExports.add(exportName);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const filePath = path.join(tmpDir, fileName);
|
|
255
|
+
fs.writeFileSync(filePath, statements.join('\n'), 'utf-8');
|
|
256
|
+
return filePath;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private getModuleExportNames(specifier: string, requireFromRoot: ReturnType<typeof createRequire>): string[] {
|
|
260
|
+
const moduleExports = requireFromRoot(specifier);
|
|
261
|
+
|
|
262
|
+
return Object.keys(moduleExports)
|
|
263
|
+
.filter((name) => name !== '__esModule' && name !== 'default')
|
|
264
|
+
.filter((name) => this.isValidExportName(name))
|
|
265
|
+
.sort();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private isValidExportName(name: string): boolean {
|
|
269
|
+
return /^[$A-Z_a-z][$\w]*$/.test(name);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ClientGraphBoundaryPlugin
|
|
3
|
+
*
|
|
4
|
+
* This module defines the primary esbuild plugin responsible for securing the Ecopages
|
|
5
|
+
* isomorphic compilation pipeline. It ensures that backend-only code, sensitive Node.js APIs,
|
|
6
|
+
* and massive server utilities do not accidentally leak into the browser bundle.
|
|
7
|
+
*
|
|
8
|
+
* It achieves this by intercepting all client module compilation passes and applying the
|
|
9
|
+
* `analyzeReachability` AST pass. If a forbidden import (e.g. `node:fs` or `*.server.ts`)
|
|
10
|
+
* is completely unreachable from the client component's `render` function, it is surgically
|
|
11
|
+
* pruned. If a forbidden import IS reachable, the build is intentionally failed to prevent
|
|
12
|
+
* runtime hydration crashes.
|
|
13
|
+
*
|
|
14
|
+
* Additionally, this plugin provides a build-time transform that statically resolves and
|
|
15
|
+
* inlines `fs.readFileSync(path.resolve(...))` calls to prevent server/client data mismatches.
|
|
16
|
+
*/
|
|
17
|
+
import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
|
|
18
|
+
/**
|
|
19
|
+
* Configuration options for the Client Graph Boundary esbuild plugin.
|
|
20
|
+
*
|
|
21
|
+
* This plugin serves as the primary security layer between server-only logic and the client-side JavaScript bundle.
|
|
22
|
+
* It prevents Node.js built-ins (`node:fs`, `node:path`) and backend-exclusive dependencies (e.g. `pg`, `redis`)
|
|
23
|
+
* from accidentally leaking into the browser compilation step, which would cause immediate crashes.
|
|
24
|
+
*/
|
|
25
|
+
type ClientGraphBoundaryOptions = {
|
|
26
|
+
/** Absolute path to the current working directory, used as a root fallback for resolving inline file reads. */
|
|
27
|
+
absWorkingDir?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Array of module specifiers that are explicitly whitelisted to be bundled in the client code.
|
|
30
|
+
* This is typically populated by parsing `modules: ["..."]` declarations in React/Lit components.
|
|
31
|
+
*/
|
|
32
|
+
declaredModules?: string[];
|
|
33
|
+
/** Array of emergency escape-hatch specifiers that always bypass the boundary checks regardless of component declarations. */
|
|
34
|
+
alwaysAllowSpecifiers?: string[];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Instantiates the client graph boundary esbuild plugin.
|
|
38
|
+
*
|
|
39
|
+
* @param options - Configuration options for the graph boundary.
|
|
40
|
+
* @returns The resulting `EcoBuildPlugin`.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOptions): EcoBuildPlugin;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, resolve } from "node:path";
|
|
3
|
+
import { parseSync } from "oxc-parser";
|
|
4
|
+
import { analyzeReachability } from "./reachability-analyzer";
|
|
5
|
+
const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
|
|
6
|
+
function isBareSpecifier(specifier) {
|
|
7
|
+
if (specifier.startsWith(".")) return false;
|
|
8
|
+
if (specifier.startsWith("/")) return false;
|
|
9
|
+
if (specifier.includes("://")) return false;
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
function isProjectAliasSpecifier(specifier) {
|
|
13
|
+
return specifier.startsWith("@/") || specifier.startsWith("~/") || specifier.startsWith("ecopages:");
|
|
14
|
+
}
|
|
15
|
+
function toModuleBaseSpecifier(specifier) {
|
|
16
|
+
if (!isBareSpecifier(specifier) || specifier.startsWith("node:")) {
|
|
17
|
+
return specifier;
|
|
18
|
+
}
|
|
19
|
+
if (specifier.startsWith("@")) {
|
|
20
|
+
const [scope, name2] = specifier.split("/");
|
|
21
|
+
if (!scope || !name2) return specifier;
|
|
22
|
+
return `${scope}/${name2}`;
|
|
23
|
+
}
|
|
24
|
+
const [name] = specifier.split("/");
|
|
25
|
+
return name ?? specifier;
|
|
26
|
+
}
|
|
27
|
+
function parseDeclaredModules(moduleDeclarations) {
|
|
28
|
+
const map = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const declaration of moduleDeclarations ?? []) {
|
|
30
|
+
const source = declaration.trim();
|
|
31
|
+
if (source.length === 0) continue;
|
|
32
|
+
const openBraceIndex = source.indexOf("{");
|
|
33
|
+
if (openBraceIndex < 0) {
|
|
34
|
+
map.set(toModuleBaseSpecifier(source), "*");
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const closeBraceIndex = source.indexOf("}", openBraceIndex);
|
|
38
|
+
const rawPkg = source.slice(0, openBraceIndex).trim();
|
|
39
|
+
if (rawPkg.length === 0) continue;
|
|
40
|
+
const pkg = toModuleBaseSpecifier(rawPkg);
|
|
41
|
+
const namedImportsStr = closeBraceIndex > openBraceIndex ? source.slice(openBraceIndex + 1, closeBraceIndex) : source.slice(openBraceIndex + 1);
|
|
42
|
+
const namedImports = namedImportsStr.split(",").map((s) => s.trim()).filter(Boolean);
|
|
43
|
+
const existing = map.get(pkg);
|
|
44
|
+
if (existing === "*") continue;
|
|
45
|
+
if (!existing) {
|
|
46
|
+
if (namedImports.length === 0) {
|
|
47
|
+
map.set(pkg, "*");
|
|
48
|
+
} else {
|
|
49
|
+
map.set(pkg, new Set(namedImports));
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
for (const name of namedImports) {
|
|
53
|
+
existing.add(name);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return map;
|
|
58
|
+
}
|
|
59
|
+
function mergeDeclaredModulesMap(a, b) {
|
|
60
|
+
const result = new Map(a);
|
|
61
|
+
for (const [pkg, imports] of b.entries()) {
|
|
62
|
+
const existing = result.get(pkg);
|
|
63
|
+
if (existing === "*") continue;
|
|
64
|
+
if (imports === "*") {
|
|
65
|
+
result.set(pkg, "*");
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!existing) {
|
|
69
|
+
result.set(pkg, imports);
|
|
70
|
+
} else {
|
|
71
|
+
for (const name of imports) {
|
|
72
|
+
existing.add(name);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
function parserLanguageForFile(filename) {
|
|
79
|
+
const extension = extname(filename).toLowerCase();
|
|
80
|
+
if (extension === ".tsx") return "tsx";
|
|
81
|
+
if (extension === ".ts") return "ts";
|
|
82
|
+
if (extension === ".jsx") return "jsx";
|
|
83
|
+
return "js";
|
|
84
|
+
}
|
|
85
|
+
function transformModuleImports(source, filename, globallyAllowed) {
|
|
86
|
+
let result;
|
|
87
|
+
try {
|
|
88
|
+
result = parseSync(filename, source, {
|
|
89
|
+
sourceType: "module",
|
|
90
|
+
lang: parserLanguageForFile(filename)
|
|
91
|
+
});
|
|
92
|
+
} catch {
|
|
93
|
+
return { transformed: source, modified: false };
|
|
94
|
+
}
|
|
95
|
+
const { program } = result;
|
|
96
|
+
const localDeclared = [];
|
|
97
|
+
function walk(node) {
|
|
98
|
+
if (!node || typeof node !== "object") return;
|
|
99
|
+
if (Array.isArray(node)) {
|
|
100
|
+
for (const child of node) walk(child);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (node.type === "Property" && node.key?.name === "modules" && node.value?.type === "ArrayExpression") {
|
|
104
|
+
for (const el of node.value.elements) {
|
|
105
|
+
if ((el.type === "StringLiteral" || el.type === "Literal") && typeof el.value === "string") {
|
|
106
|
+
localDeclared.push(el.value);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const key in node) {
|
|
111
|
+
if (key !== "type" && key !== "start" && key !== "end") {
|
|
112
|
+
walk(node[key]);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
walk(program);
|
|
117
|
+
const locallyAllowed = parseDeclaredModules(localDeclared);
|
|
118
|
+
const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
|
|
119
|
+
const reachability = analyzeReachability(source, filename, program);
|
|
120
|
+
const edits = [];
|
|
121
|
+
function processSpecifier(specifier) {
|
|
122
|
+
const moduleBase = toModuleBaseSpecifier(specifier);
|
|
123
|
+
const explicitRules = allowedMap.get(moduleBase);
|
|
124
|
+
if (specifier.startsWith("node:") || specifier.includes(".server.")) {
|
|
125
|
+
if (explicitRules) {
|
|
126
|
+
return { allowed: true, rules: explicitRules };
|
|
127
|
+
}
|
|
128
|
+
return { allowed: false };
|
|
129
|
+
}
|
|
130
|
+
if (isProjectAliasSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? "*" };
|
|
131
|
+
if (!isBareSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? "*" };
|
|
132
|
+
return { allowed: true, rules: explicitRules ?? "*" };
|
|
133
|
+
}
|
|
134
|
+
function walkImports(node) {
|
|
135
|
+
if (!node || typeof node !== "object") return;
|
|
136
|
+
if (Array.isArray(node)) {
|
|
137
|
+
for (const child of node) walkImports(child);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (node.type === "ImportDeclaration") {
|
|
141
|
+
const specifier = node.source.value;
|
|
142
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
143
|
+
const { allowed, rules } = processSpecifier(specifier);
|
|
144
|
+
if (!allowed) {
|
|
145
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`[Ecopages Client Reachability] Forbidden client import '${specifier}' at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`
|
|
148
|
+
);
|
|
149
|
+
} else {
|
|
150
|
+
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (rules instanceof Set && node.specifiers && node.specifiers.length > 0) {
|
|
155
|
+
let keptSpecifierCount = 0;
|
|
156
|
+
let defaultImportLocal;
|
|
157
|
+
let namespaceImportLocal;
|
|
158
|
+
const namedImportNodes = [];
|
|
159
|
+
for (const spec of node.specifiers) {
|
|
160
|
+
if (spec.type === "ImportSpecifier") {
|
|
161
|
+
const importedName = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
|
|
162
|
+
if (rules.has(importedName)) {
|
|
163
|
+
keptSpecifierCount += 1;
|
|
164
|
+
const localName = spec.local?.name;
|
|
165
|
+
if (localName && localName !== importedName) {
|
|
166
|
+
namedImportNodes.push(`${importedName} as ${localName}`);
|
|
167
|
+
} else {
|
|
168
|
+
namedImportNodes.push(importedName);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} else if (spec.type === "ImportDefaultSpecifier") {
|
|
172
|
+
if (rules.has("default")) {
|
|
173
|
+
keptSpecifierCount += 1;
|
|
174
|
+
defaultImportLocal = spec.local.name;
|
|
175
|
+
}
|
|
176
|
+
} else if (spec.type === "ImportNamespaceSpecifier") {
|
|
177
|
+
if (rules.has("*")) {
|
|
178
|
+
keptSpecifierCount += 1;
|
|
179
|
+
namespaceImportLocal = spec.local.name;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (keptSpecifierCount === 0) {
|
|
184
|
+
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
185
|
+
} else if (keptSpecifierCount < node.specifiers.length) {
|
|
186
|
+
let newDeclaration = "";
|
|
187
|
+
if (defaultImportLocal && namespaceImportLocal) {
|
|
188
|
+
newDeclaration = `import ${defaultImportLocal}, * as ${namespaceImportLocal} from '${specifier}';`;
|
|
189
|
+
} else if (namespaceImportLocal) {
|
|
190
|
+
newDeclaration = `import * as ${namespaceImportLocal} from '${specifier}';`;
|
|
191
|
+
} else if (defaultImportLocal && namedImportNodes.length > 0) {
|
|
192
|
+
newDeclaration = `import ${defaultImportLocal}, { ${namedImportNodes.join(", ")} } from '${specifier}';`;
|
|
193
|
+
} else if (defaultImportLocal) {
|
|
194
|
+
newDeclaration = `import ${defaultImportLocal} from '${specifier}';`;
|
|
195
|
+
} else {
|
|
196
|
+
newDeclaration = `import { ${namedImportNodes.join(", ")} } from '${specifier}';`;
|
|
197
|
+
}
|
|
198
|
+
edits.push({ start: node.start, end: node.end, replacement: newDeclaration });
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (!reachableRules && (!node.specifiers || node.specifiers.length === 0)) {
|
|
203
|
+
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (node.type === "ExportNamedDeclaration" && node.source) {
|
|
208
|
+
const specifier = node.source.value;
|
|
209
|
+
const { allowed } = processSpecifier(specifier);
|
|
210
|
+
if (!allowed) {
|
|
211
|
+
if (!reachability.isFallbackRoots) {
|
|
212
|
+
throw new Error(
|
|
213
|
+
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}.`
|
|
214
|
+
);
|
|
215
|
+
} else {
|
|
216
|
+
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (node.type === "ExportAllDeclaration" && node.source) {
|
|
222
|
+
const specifier = node.source.value;
|
|
223
|
+
const { allowed } = processSpecifier(specifier);
|
|
224
|
+
if (!allowed) {
|
|
225
|
+
if (!reachability.isFallbackRoots) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}.`
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (node.type === "ImportExpression" && node.source?.value) {
|
|
236
|
+
const specifier = node.source.value;
|
|
237
|
+
const { allowed } = processSpecifier(specifier);
|
|
238
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
239
|
+
if (!reachableRules) {
|
|
240
|
+
if (!allowed) {
|
|
241
|
+
edits.push({ start: node.start, end: node.end, replacement: "Promise.resolve({})" });
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!allowed) {
|
|
246
|
+
if (!reachability.isFallbackRoots) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`[Ecopages Client Reachability] Forbidden dynamic import('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`
|
|
249
|
+
);
|
|
250
|
+
} else {
|
|
251
|
+
edits.push({ start: node.start, end: node.end, replacement: "Promise.resolve({})" });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === "require") {
|
|
257
|
+
const arg = node.arguments?.[0];
|
|
258
|
+
if (arg && (arg.type === "StringLiteral" || arg.type === "Literal") && typeof arg.value === "string") {
|
|
259
|
+
const specifier = arg.value;
|
|
260
|
+
const { allowed } = processSpecifier(specifier);
|
|
261
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
262
|
+
if (!reachableRules) {
|
|
263
|
+
if (!allowed) {
|
|
264
|
+
edits.push({ start: node.start, end: node.end, replacement: "({})" });
|
|
265
|
+
}
|
|
266
|
+
} else if (!allowed) {
|
|
267
|
+
if (!reachability.isFallbackRoots) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
`[Ecopages Client Reachability] Forbidden require('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
edits.push({ start: node.start, end: node.end, replacement: "({})" });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
for (const key in node) {
|
|
278
|
+
if (key !== "type" && key !== "start" && key !== "end") {
|
|
279
|
+
walkImports(node[key]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
walkImports(program);
|
|
284
|
+
if (edits.length === 0) {
|
|
285
|
+
return { transformed: source, modified: false };
|
|
286
|
+
}
|
|
287
|
+
edits.sort((a, b) => b.start - a.start);
|
|
288
|
+
let transformed = source;
|
|
289
|
+
for (const edit of edits) {
|
|
290
|
+
transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
|
|
291
|
+
}
|
|
292
|
+
return { transformed, modified: true };
|
|
293
|
+
}
|
|
294
|
+
function createClientGraphBoundaryPlugin(options) {
|
|
295
|
+
return {
|
|
296
|
+
name: "ecopages-client-graph-boundary",
|
|
297
|
+
setup(build) {
|
|
298
|
+
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
299
|
+
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
300
|
+
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
301
|
+
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), "*");
|
|
302
|
+
}
|
|
303
|
+
build.onLoad({ filter: SOURCE_FILE_FILTER }, (args) => {
|
|
304
|
+
let source;
|
|
305
|
+
try {
|
|
306
|
+
source = readFileSync(args.path, "utf-8");
|
|
307
|
+
} catch {
|
|
308
|
+
return void 0;
|
|
309
|
+
}
|
|
310
|
+
let transformed = source;
|
|
311
|
+
let modified = false;
|
|
312
|
+
if (source.includes("readFileSync")) {
|
|
313
|
+
const readFileTransformed = transformed.replace(
|
|
314
|
+
/\bfs\.readFileSync\s*\(\s*path\.resolve\s*\(\s*(['"`])([^'"`\n]+)\1\s*\)\s*,\s*['"`]utf-?8['"`]\s*\)/g,
|
|
315
|
+
(_match, _q, relPath) => {
|
|
316
|
+
modified = true;
|
|
317
|
+
try {
|
|
318
|
+
const sourceDir = dirname(args.path);
|
|
319
|
+
const srcDirIndex = args.path.lastIndexOf("/src/");
|
|
320
|
+
const inferredProjectRoot = srcDirIndex >= 0 ? args.path.slice(0, srcDirIndex) : void 0;
|
|
321
|
+
const candidates = [
|
|
322
|
+
resolve(absWorkingDir, relPath),
|
|
323
|
+
resolve(process.cwd(), relPath),
|
|
324
|
+
resolve(sourceDir, relPath),
|
|
325
|
+
...inferredProjectRoot ? [resolve(inferredProjectRoot, relPath)] : []
|
|
326
|
+
];
|
|
327
|
+
const absolutePath = candidates.find((candidate) => existsSync(candidate));
|
|
328
|
+
if (!absolutePath) return '""';
|
|
329
|
+
const content = readFileSync(absolutePath, "utf-8");
|
|
330
|
+
return JSON.stringify(content);
|
|
331
|
+
} catch {
|
|
332
|
+
return '""';
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
transformed = readFileTransformed;
|
|
337
|
+
}
|
|
338
|
+
const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
|
|
339
|
+
transformed,
|
|
340
|
+
args.path,
|
|
341
|
+
globallyDeclaredSources
|
|
342
|
+
);
|
|
343
|
+
if (importsModified) {
|
|
344
|
+
modified = true;
|
|
345
|
+
transformed = oxcTransformed;
|
|
346
|
+
}
|
|
347
|
+
if (!modified) return void 0;
|
|
348
|
+
const ext = extname(args.path).slice(1);
|
|
349
|
+
return { contents: transformed, loader: ext };
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
export {
|
|
355
|
+
createClientGraphBoundaryPlugin
|
|
356
|
+
};
|