@ecopages/react 0.2.0-alpha.4 → 0.2.0-alpha.7
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 +23 -37
- package/README.md +143 -17
- package/package.json +3 -3
- package/src/react-hmr-strategy.d.ts +22 -19
- package/src/react-hmr-strategy.js +57 -109
- package/src/react-hmr-strategy.ts +76 -134
- package/src/react-renderer.d.ts +130 -11
- package/src/react-renderer.js +368 -64
- package/src/react-renderer.ts +490 -90
- package/src/react.plugin.d.ts +17 -5
- package/src/react.plugin.js +44 -13
- package/src/react.plugin.ts +49 -14
- package/src/router-adapter.d.ts +2 -2
- package/src/router-adapter.ts +2 -2
- package/src/services/react-bundle.service.d.ts +2 -25
- package/src/services/react-bundle.service.js +21 -91
- package/src/services/react-bundle.service.ts +22 -126
- package/src/services/react-hydration-asset.service.js +3 -3
- package/src/services/react-hydration-asset.service.ts +7 -4
- package/src/services/react-page-module.service.d.ts +3 -0
- package/src/services/react-page-module.service.js +20 -16
- package/src/services/react-page-module.service.ts +27 -17
- package/src/services/react-runtime-bundle.service.d.ts +12 -12
- package/src/services/react-runtime-bundle.service.js +98 -180
- package/src/services/react-runtime-bundle.service.ts +112 -211
- package/src/utils/client-graph-boundary-plugin.js +147 -9
- package/src/utils/client-graph-boundary-plugin.ts +252 -11
- package/src/utils/hydration-scripts.d.ts +18 -1
- package/src/utils/hydration-scripts.js +83 -32
- package/src/utils/hydration-scripts.ts +159 -38
- 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/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
- package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
- package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
- package/src/utils/react-mdx-loader-plugin.js +13 -5
- package/src/utils/react-mdx-loader-plugin.ts +28 -5
- package/src/utils/react-runtime-specifier-map.d.ts +6 -0
- package/src/utils/react-runtime-specifier-map.js +37 -0
- package/src/utils/react-runtime-specifier-map.ts +45 -0
- package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
- package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
- package/src/utils/use-sync-external-store-shim-plugin.ts +45 -0
|
@@ -3,6 +3,14 @@ import { dirname, extname, resolve } from "node:path";
|
|
|
3
3
|
import { parseSync } from "oxc-parser";
|
|
4
4
|
import { analyzeReachability } from "./reachability-analyzer";
|
|
5
5
|
const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
|
|
6
|
+
const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = /* @__PURE__ */ new Set([
|
|
7
|
+
"cache",
|
|
8
|
+
"middleware",
|
|
9
|
+
"requires",
|
|
10
|
+
"metadata",
|
|
11
|
+
"staticProps",
|
|
12
|
+
"staticPaths"
|
|
13
|
+
]);
|
|
6
14
|
function isBareSpecifier(specifier) {
|
|
7
15
|
if (specifier.startsWith(".")) return false;
|
|
8
16
|
if (specifier.startsWith("/")) return false;
|
|
@@ -12,6 +20,10 @@ function isBareSpecifier(specifier) {
|
|
|
12
20
|
function isProjectAliasSpecifier(specifier) {
|
|
13
21
|
return specifier.startsWith("@/") || specifier.startsWith("~/") || specifier.startsWith("ecopages:");
|
|
14
22
|
}
|
|
23
|
+
function isServerOnlySpecifier(specifier) {
|
|
24
|
+
if (specifier.startsWith("node:")) return true;
|
|
25
|
+
return /(?:^|[/])[^/]+\.server(?:$|\.)/.test(specifier);
|
|
26
|
+
}
|
|
15
27
|
function toModuleBaseSpecifier(specifier) {
|
|
16
28
|
if (!isBareSpecifier(specifier) || specifier.startsWith("node:")) {
|
|
17
29
|
return specifier;
|
|
@@ -82,7 +94,93 @@ function parserLanguageForFile(filename) {
|
|
|
82
94
|
if (extension === ".jsx") return "jsx";
|
|
83
95
|
return "js";
|
|
84
96
|
}
|
|
85
|
-
function
|
|
97
|
+
function getObjectPropertyKeyName(node) {
|
|
98
|
+
if (!node) return void 0;
|
|
99
|
+
if (node.type === "Identifier") return node.name;
|
|
100
|
+
if (node.type === "StringLiteral" || node.type === "Literal") {
|
|
101
|
+
return typeof node.value === "string" ? node.value : void 0;
|
|
102
|
+
}
|
|
103
|
+
return void 0;
|
|
104
|
+
}
|
|
105
|
+
function stripServerOnlyEcoPageOptions(source, program) {
|
|
106
|
+
const edits = [];
|
|
107
|
+
function walk(node) {
|
|
108
|
+
if (!node || typeof node !== "object") return;
|
|
109
|
+
if (Array.isArray(node)) {
|
|
110
|
+
for (const child of node) walk(child);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === "eco" && node.callee.property?.type === "Identifier" && node.callee.property.name === "page" && node.arguments?.[0]?.type === "ObjectExpression") {
|
|
114
|
+
const objectExpression = node.arguments[0];
|
|
115
|
+
const keptProperties = [];
|
|
116
|
+
let removedProperty = false;
|
|
117
|
+
for (const property of objectExpression.properties ?? []) {
|
|
118
|
+
if (property?.type === "Property") {
|
|
119
|
+
const keyName = getObjectPropertyKeyName(property.key);
|
|
120
|
+
if (keyName && SERVER_ONLY_ECO_PAGE_OPTION_KEYS.has(keyName)) {
|
|
121
|
+
removedProperty = true;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
keptProperties.push(source.slice(property.start, property.end));
|
|
126
|
+
}
|
|
127
|
+
if (removedProperty) {
|
|
128
|
+
const replacement = keptProperties.length > 0 ? `{ ${keptProperties.join(", ")} }` : "{}";
|
|
129
|
+
edits.push({
|
|
130
|
+
start: objectExpression.start,
|
|
131
|
+
end: objectExpression.end,
|
|
132
|
+
replacement
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const key in node) {
|
|
137
|
+
if (key !== "type" && key !== "start" && key !== "end") {
|
|
138
|
+
walk(node[key]);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
walk(program);
|
|
143
|
+
if (edits.length === 0) {
|
|
144
|
+
return { transformed: source, modified: false };
|
|
145
|
+
}
|
|
146
|
+
edits.sort((a, b) => b.start - a.start);
|
|
147
|
+
let transformed = source;
|
|
148
|
+
for (const edit of edits) {
|
|
149
|
+
transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
|
|
150
|
+
}
|
|
151
|
+
return { transformed, modified: true };
|
|
152
|
+
}
|
|
153
|
+
function normalizeRequestedExportsKey(pathname) {
|
|
154
|
+
let normalized = pathname.replace(/\\/g, "/");
|
|
155
|
+
normalized = normalized.replace(/\.(tsx?|jsx?)$/i, "");
|
|
156
|
+
if (normalized.endsWith("/index")) {
|
|
157
|
+
normalized = normalized.slice(0, -"/index".length);
|
|
158
|
+
}
|
|
159
|
+
return normalized;
|
|
160
|
+
}
|
|
161
|
+
function resolveRequestedExportsKey(importer, specifier) {
|
|
162
|
+
if (isBareSpecifier(specifier) || isProjectAliasSpecifier(specifier)) {
|
|
163
|
+
return void 0;
|
|
164
|
+
}
|
|
165
|
+
const resolved = specifier.startsWith("/") ? specifier : resolve(dirname(importer), specifier);
|
|
166
|
+
return normalizeRequestedExportsKey(resolved);
|
|
167
|
+
}
|
|
168
|
+
function mergeRequestedExportRules(registry, moduleKey, rules) {
|
|
169
|
+
const existing = registry.get(moduleKey);
|
|
170
|
+
if (existing === "*") return;
|
|
171
|
+
if (rules === "*") {
|
|
172
|
+
registry.set(moduleKey, "*");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (!existing) {
|
|
176
|
+
registry.set(moduleKey, new Set(rules));
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
for (const rule of rules) {
|
|
180
|
+
existing.add(rule);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function transformModuleImports(source, filename, globallyAllowed, requestedExports) {
|
|
86
184
|
let result;
|
|
87
185
|
try {
|
|
88
186
|
result = parseSync(filename, source, {
|
|
@@ -116,12 +214,35 @@ function transformModuleImports(source, filename, globallyAllowed) {
|
|
|
116
214
|
walk(program);
|
|
117
215
|
const locallyAllowed = parseDeclaredModules(localDeclared);
|
|
118
216
|
const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
|
|
119
|
-
const
|
|
217
|
+
const explicitRequestedExports = requestedExports.get(normalizeRequestedExportsKey(filename));
|
|
218
|
+
const reachability = analyzeReachability(source, filename, program, explicitRequestedExports);
|
|
219
|
+
for (const statement of program.body) {
|
|
220
|
+
if (statement.type === "ImportDeclaration") {
|
|
221
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value);
|
|
222
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value);
|
|
223
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
224
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (statement.type === "ExportNamedDeclaration" && statement.source) {
|
|
228
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value);
|
|
229
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value);
|
|
230
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
231
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (statement.type === "ExportAllDeclaration" && statement.source) {
|
|
235
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value);
|
|
236
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value);
|
|
237
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
238
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
120
241
|
const edits = [];
|
|
121
242
|
function processSpecifier(specifier) {
|
|
122
243
|
const moduleBase = toModuleBaseSpecifier(specifier);
|
|
123
244
|
const explicitRules = allowedMap.get(moduleBase);
|
|
124
|
-
if (
|
|
245
|
+
if (isServerOnlySpecifier(specifier)) {
|
|
125
246
|
if (explicitRules) {
|
|
126
247
|
return { allowed: true, rules: explicitRules };
|
|
127
248
|
}
|
|
@@ -208,9 +329,10 @@ function transformModuleImports(source, filename, globallyAllowed) {
|
|
|
208
329
|
const specifier = node.source.value;
|
|
209
330
|
const { allowed } = processSpecifier(specifier);
|
|
210
331
|
if (!allowed) {
|
|
211
|
-
|
|
332
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
333
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
212
334
|
throw new Error(
|
|
213
|
-
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}.`
|
|
335
|
+
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`
|
|
214
336
|
);
|
|
215
337
|
} else {
|
|
216
338
|
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
@@ -222,9 +344,10 @@ function transformModuleImports(source, filename, globallyAllowed) {
|
|
|
222
344
|
const specifier = node.source.value;
|
|
223
345
|
const { allowed } = processSpecifier(specifier);
|
|
224
346
|
if (!allowed) {
|
|
225
|
-
|
|
347
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
348
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
226
349
|
throw new Error(
|
|
227
|
-
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}.`
|
|
350
|
+
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`
|
|
228
351
|
);
|
|
229
352
|
} else {
|
|
230
353
|
edits.push({ start: node.start, end: node.end, replacement: "" });
|
|
@@ -282,13 +405,26 @@ function transformModuleImports(source, filename, globallyAllowed) {
|
|
|
282
405
|
}
|
|
283
406
|
walkImports(program);
|
|
284
407
|
if (edits.length === 0) {
|
|
285
|
-
return
|
|
408
|
+
return stripServerOnlyEcoPageOptions(source, program);
|
|
286
409
|
}
|
|
287
410
|
edits.sort((a, b) => b.start - a.start);
|
|
288
411
|
let transformed = source;
|
|
289
412
|
for (const edit of edits) {
|
|
290
413
|
transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
|
|
291
414
|
}
|
|
415
|
+
let reparsedResult;
|
|
416
|
+
try {
|
|
417
|
+
reparsedResult = parseSync(filename, transformed, {
|
|
418
|
+
sourceType: "module",
|
|
419
|
+
lang: parserLanguageForFile(filename)
|
|
420
|
+
});
|
|
421
|
+
} catch {
|
|
422
|
+
return { transformed, modified: true };
|
|
423
|
+
}
|
|
424
|
+
const strippedPageOptions = stripServerOnlyEcoPageOptions(transformed, reparsedResult.program);
|
|
425
|
+
if (strippedPageOptions.modified) {
|
|
426
|
+
return strippedPageOptions;
|
|
427
|
+
}
|
|
292
428
|
return { transformed, modified: true };
|
|
293
429
|
}
|
|
294
430
|
function createClientGraphBoundaryPlugin(options) {
|
|
@@ -297,6 +433,7 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
297
433
|
setup(build) {
|
|
298
434
|
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
299
435
|
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
436
|
+
const requestedExports = /* @__PURE__ */ new Map();
|
|
300
437
|
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
301
438
|
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), "*");
|
|
302
439
|
}
|
|
@@ -338,7 +475,8 @@ function createClientGraphBoundaryPlugin(options) {
|
|
|
338
475
|
const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
|
|
339
476
|
transformed,
|
|
340
477
|
args.path,
|
|
341
|
-
globallyDeclaredSources
|
|
478
|
+
globallyDeclaredSources,
|
|
479
|
+
requestedExports
|
|
342
480
|
);
|
|
343
481
|
if (importsModified) {
|
|
344
482
|
modified = true;
|
|
@@ -22,6 +22,14 @@ import { parseSync } from 'oxc-parser';
|
|
|
22
22
|
import { analyzeReachability } from './reachability-analyzer';
|
|
23
23
|
|
|
24
24
|
const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
|
|
25
|
+
const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = new Set([
|
|
26
|
+
'cache',
|
|
27
|
+
'middleware',
|
|
28
|
+
'requires',
|
|
29
|
+
'metadata',
|
|
30
|
+
'staticProps',
|
|
31
|
+
'staticPaths',
|
|
32
|
+
]);
|
|
25
33
|
|
|
26
34
|
/**
|
|
27
35
|
* Configuration options for the Client Graph Boundary esbuild plugin.
|
|
@@ -64,6 +72,20 @@ function isProjectAliasSpecifier(specifier: string): boolean {
|
|
|
64
72
|
return specifier.startsWith('@/') || specifier.startsWith('~/') || specifier.startsWith('ecopages:');
|
|
65
73
|
}
|
|
66
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Determines whether a specifier should be treated as server-only.
|
|
77
|
+
*
|
|
78
|
+
* This covers Node built-ins as well as local module conventions such as
|
|
79
|
+
* `.server.ts` and extensionless imports that resolve to `.server.*` files.
|
|
80
|
+
*
|
|
81
|
+
* @param specifier - Raw import specifier from the module source.
|
|
82
|
+
* @returns True when the import must never become client-reachable.
|
|
83
|
+
*/
|
|
84
|
+
function isServerOnlySpecifier(specifier: string): boolean {
|
|
85
|
+
if (specifier.startsWith('node:')) return true;
|
|
86
|
+
return /(?:^|[/])[^/]+\.server(?:$|\.)/.test(specifier);
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
/**
|
|
68
90
|
* Strips down a deep path module specifier to its foundational root package name.
|
|
69
91
|
*
|
|
@@ -191,6 +213,179 @@ function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
|
|
|
191
213
|
return 'js';
|
|
192
214
|
}
|
|
193
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Extracts a static property key name from an object literal property node.
|
|
218
|
+
*
|
|
219
|
+
* The client graph boundary rewrite only strips known `eco.page(...)` keys when
|
|
220
|
+
* it can prove the property name statically. Computed or otherwise dynamic keys
|
|
221
|
+
* are ignored so the transform remains conservative.
|
|
222
|
+
*
|
|
223
|
+
* @param node - OXC AST node representing an object property key.
|
|
224
|
+
* @returns Static property key name when it can be resolved, otherwise `undefined`.
|
|
225
|
+
*/
|
|
226
|
+
function getObjectPropertyKeyName(node: any): string | undefined {
|
|
227
|
+
if (!node) return undefined;
|
|
228
|
+
if (node.type === 'Identifier') return node.name;
|
|
229
|
+
if (node.type === 'StringLiteral' || node.type === 'Literal') {
|
|
230
|
+
return typeof node.value === 'string' ? node.value : undefined;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Removes server-only `eco.page(...)` options from browser-bound modules.
|
|
237
|
+
*
|
|
238
|
+
* Import pruning alone is not sufficient because a page module can still retain
|
|
239
|
+
* references to stripped server imports through config fields like `middleware`
|
|
240
|
+
* or `metadata`. This pass rewrites the `eco.page(...)` object literal so only
|
|
241
|
+
* browser-relevant properties remain.
|
|
242
|
+
*
|
|
243
|
+
* @param source - Original or already-transformed module source.
|
|
244
|
+
* @param program - Parsed OXC program for the same source text.
|
|
245
|
+
* @returns Updated source plus a flag indicating whether any rewrite occurred.
|
|
246
|
+
*/
|
|
247
|
+
function stripServerOnlyEcoPageOptions(source: string, program: any): { transformed: string; modified: boolean } {
|
|
248
|
+
const edits: { start: number; end: number; replacement: string }[] = [];
|
|
249
|
+
|
|
250
|
+
function walk(node: any) {
|
|
251
|
+
if (!node || typeof node !== 'object') return;
|
|
252
|
+
if (Array.isArray(node)) {
|
|
253
|
+
for (const child of node) walk(child);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (
|
|
258
|
+
node.type === 'CallExpression' &&
|
|
259
|
+
node.callee?.type === 'MemberExpression' &&
|
|
260
|
+
node.callee.object?.type === 'Identifier' &&
|
|
261
|
+
node.callee.object.name === 'eco' &&
|
|
262
|
+
node.callee.property?.type === 'Identifier' &&
|
|
263
|
+
node.callee.property.name === 'page' &&
|
|
264
|
+
node.arguments?.[0]?.type === 'ObjectExpression'
|
|
265
|
+
) {
|
|
266
|
+
const objectExpression = node.arguments[0];
|
|
267
|
+
const keptProperties: string[] = [];
|
|
268
|
+
let removedProperty = false;
|
|
269
|
+
|
|
270
|
+
for (const property of objectExpression.properties ?? []) {
|
|
271
|
+
if (property?.type === 'Property') {
|
|
272
|
+
const keyName = getObjectPropertyKeyName(property.key);
|
|
273
|
+
if (keyName && SERVER_ONLY_ECO_PAGE_OPTION_KEYS.has(keyName)) {
|
|
274
|
+
removedProperty = true;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
keptProperties.push(source.slice(property.start, property.end));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (removedProperty) {
|
|
283
|
+
const replacement = keptProperties.length > 0 ? `{ ${keptProperties.join(', ')} }` : '{}';
|
|
284
|
+
edits.push({
|
|
285
|
+
start: objectExpression.start,
|
|
286
|
+
end: objectExpression.end,
|
|
287
|
+
replacement,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const key in node) {
|
|
293
|
+
if (key !== 'type' && key !== 'start' && key !== 'end') {
|
|
294
|
+
walk(node[key]);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
walk(program);
|
|
300
|
+
|
|
301
|
+
if (edits.length === 0) {
|
|
302
|
+
return { transformed: source, modified: false };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
edits.sort((a, b) => b.start - a.start);
|
|
306
|
+
let transformed = source;
|
|
307
|
+
for (const edit of edits) {
|
|
308
|
+
transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return { transformed, modified: true };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Tracks the subset of exports that a downstream local module is allowed to expose.
|
|
316
|
+
*
|
|
317
|
+
* `'*'` means the full module namespace is reachable, while a `Set` limits the
|
|
318
|
+
* consumer to specific named exports.
|
|
319
|
+
*/
|
|
320
|
+
type RequestedExportRules = Set<string> | '*';
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Normalizes a file path into a registry key used for requested-export propagation.
|
|
324
|
+
*
|
|
325
|
+
* The normalization strips JS/TS extensions and collapses `/index` suffixes so
|
|
326
|
+
* equivalent local import forms resolve to the same key.
|
|
327
|
+
*
|
|
328
|
+
* @param pathname - Absolute or resolved local module path.
|
|
329
|
+
* @returns Stable registry key for `requestedExports`.
|
|
330
|
+
*/
|
|
331
|
+
function normalizeRequestedExportsKey(pathname: string): string {
|
|
332
|
+
let normalized = pathname.replace(/\\/g, '/');
|
|
333
|
+
normalized = normalized.replace(/\.(tsx?|jsx?)$/i, '');
|
|
334
|
+
if (normalized.endsWith('/index')) {
|
|
335
|
+
normalized = normalized.slice(0, -'/index'.length);
|
|
336
|
+
}
|
|
337
|
+
return normalized;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Resolves a local import specifier into a requested-export registry key.
|
|
342
|
+
*
|
|
343
|
+
* Bare package specifiers and project aliases are intentionally ignored because
|
|
344
|
+
* requested-export propagation is only used for cross-file local reachability.
|
|
345
|
+
*
|
|
346
|
+
* @param importer - Absolute path of the importing module.
|
|
347
|
+
* @param specifier - Raw import or re-export specifier.
|
|
348
|
+
* @returns Registry key for a local dependency, or `undefined` when not applicable.
|
|
349
|
+
*/
|
|
350
|
+
function resolveRequestedExportsKey(importer: string, specifier: string): string | undefined {
|
|
351
|
+
if (isBareSpecifier(specifier) || isProjectAliasSpecifier(specifier)) {
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const resolved = specifier.startsWith('/') ? specifier : resolve(dirname(importer), specifier);
|
|
356
|
+
return normalizeRequestedExportsKey(resolved);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Merges newly discovered requested-export rules into the local propagation registry.
|
|
361
|
+
*
|
|
362
|
+
* Once a module is promoted to `'*'`, it stays fully reachable for the remainder
|
|
363
|
+
* of the transform pass.
|
|
364
|
+
*
|
|
365
|
+
* @param registry - Cross-module requested-export registry.
|
|
366
|
+
* @param moduleKey - Normalized local module key.
|
|
367
|
+
* @param rules - Newly observed reachable export rules for the module.
|
|
368
|
+
*/
|
|
369
|
+
function mergeRequestedExportRules(
|
|
370
|
+
registry: Map<string, RequestedExportRules>,
|
|
371
|
+
moduleKey: string,
|
|
372
|
+
rules: Set<string> | '*',
|
|
373
|
+
) {
|
|
374
|
+
const existing = registry.get(moduleKey);
|
|
375
|
+
if (existing === '*') return;
|
|
376
|
+
if (rules === '*') {
|
|
377
|
+
registry.set(moduleKey, '*');
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (!existing) {
|
|
381
|
+
registry.set(moduleKey, new Set(rules));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
for (const rule of rules) {
|
|
385
|
+
existing.add(rule);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
194
389
|
/**
|
|
195
390
|
* Parses a module using Oxc AST and surgically removes forbidden imports.
|
|
196
391
|
* Filters down to the exact specifiers requested via `{namedImport}` syntax.
|
|
@@ -198,12 +393,14 @@ function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
|
|
|
198
393
|
* @param source - The raw string source content of the module.
|
|
199
394
|
* @param filename - The absolute path of the module.
|
|
200
395
|
* @param globallyAllowed - A map of modules declared globally allowable by the build configuration.
|
|
396
|
+
* @param requestedExports - Local requested-export registry used to propagate named reachability across files.
|
|
201
397
|
* @returns An object containing the transformed string and a boolean indicating if changes occurred.
|
|
202
398
|
*/
|
|
203
399
|
function transformModuleImports(
|
|
204
400
|
source: string,
|
|
205
401
|
filename: string,
|
|
206
402
|
globallyAllowed: Map<string, Set<string> | '*'>,
|
|
403
|
+
requestedExports: Map<string, RequestedExportRules>,
|
|
207
404
|
): { transformed: string; modified: boolean } {
|
|
208
405
|
/**
|
|
209
406
|
* Parse the source
|
|
@@ -273,7 +470,36 @@ function transformModuleImports(
|
|
|
273
470
|
*/
|
|
274
471
|
const locallyAllowed = parseDeclaredModules(localDeclared);
|
|
275
472
|
const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
|
|
276
|
-
const
|
|
473
|
+
const explicitRequestedExports = requestedExports.get(normalizeRequestedExportsKey(filename));
|
|
474
|
+
const reachability = analyzeReachability(source, filename, program, explicitRequestedExports);
|
|
475
|
+
|
|
476
|
+
for (const statement of program.body) {
|
|
477
|
+
if (statement.type === 'ImportDeclaration') {
|
|
478
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
479
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
480
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
481
|
+
|
|
482
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (statement.type === 'ExportNamedDeclaration' && statement.source) {
|
|
487
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
488
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
489
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
490
|
+
|
|
491
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (statement.type === 'ExportAllDeclaration' && statement.source) {
|
|
496
|
+
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
497
|
+
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
498
|
+
if (!requestedModuleKey || !reachableRules) continue;
|
|
499
|
+
|
|
500
|
+
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
277
503
|
|
|
278
504
|
/**
|
|
279
505
|
* Build the edit list
|
|
@@ -294,7 +520,7 @@ function transformModuleImports(
|
|
|
294
520
|
const moduleBase = toModuleBaseSpecifier(specifier);
|
|
295
521
|
const explicitRules = allowedMap.get(moduleBase);
|
|
296
522
|
|
|
297
|
-
if (
|
|
523
|
+
if (isServerOnlySpecifier(specifier)) {
|
|
298
524
|
if (explicitRules) {
|
|
299
525
|
return { allowed: true, rules: explicitRules };
|
|
300
526
|
}
|
|
@@ -405,14 +631,11 @@ function transformModuleImports(
|
|
|
405
631
|
const specifier = node.source.value as string;
|
|
406
632
|
const { allowed } = processSpecifier(specifier);
|
|
407
633
|
|
|
408
|
-
/**
|
|
409
|
-
* We skip checking reachability of re-exports for now to avoid false negatives.
|
|
410
|
-
* But we MUST check security.
|
|
411
|
-
*/
|
|
412
634
|
if (!allowed) {
|
|
413
|
-
|
|
635
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
636
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
414
637
|
throw new Error(
|
|
415
|
-
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}.`,
|
|
638
|
+
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
|
|
416
639
|
);
|
|
417
640
|
} else {
|
|
418
641
|
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
@@ -425,9 +648,10 @@ function transformModuleImports(
|
|
|
425
648
|
const specifier = node.source.value as string;
|
|
426
649
|
const { allowed } = processSpecifier(specifier);
|
|
427
650
|
if (!allowed) {
|
|
428
|
-
|
|
651
|
+
const reachableRules = reachability.reachableImports.get(specifier);
|
|
652
|
+
if (reachableRules && !reachability.isFallbackRoots) {
|
|
429
653
|
throw new Error(
|
|
430
|
-
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}.`,
|
|
654
|
+
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
|
|
431
655
|
);
|
|
432
656
|
} else {
|
|
433
657
|
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
@@ -492,7 +716,7 @@ function transformModuleImports(
|
|
|
492
716
|
walkImports(program);
|
|
493
717
|
|
|
494
718
|
if (edits.length === 0) {
|
|
495
|
-
return
|
|
719
|
+
return stripServerOnlyEcoPageOptions(source, program);
|
|
496
720
|
}
|
|
497
721
|
|
|
498
722
|
edits.sort((a, b) => b.start - a.start);
|
|
@@ -501,6 +725,21 @@ function transformModuleImports(
|
|
|
501
725
|
transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
|
|
502
726
|
}
|
|
503
727
|
|
|
728
|
+
let reparsedResult;
|
|
729
|
+
try {
|
|
730
|
+
reparsedResult = parseSync(filename, transformed, {
|
|
731
|
+
sourceType: 'module',
|
|
732
|
+
lang: parserLanguageForFile(filename),
|
|
733
|
+
});
|
|
734
|
+
} catch {
|
|
735
|
+
return { transformed, modified: true };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const strippedPageOptions = stripServerOnlyEcoPageOptions(transformed, reparsedResult.program);
|
|
739
|
+
if (strippedPageOptions.modified) {
|
|
740
|
+
return strippedPageOptions;
|
|
741
|
+
}
|
|
742
|
+
|
|
504
743
|
return { transformed, modified: true };
|
|
505
744
|
}
|
|
506
745
|
|
|
@@ -516,6 +755,7 @@ export function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOpt
|
|
|
516
755
|
setup(build) {
|
|
517
756
|
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
518
757
|
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
758
|
+
const requestedExports = new Map<string, RequestedExportRules>();
|
|
519
759
|
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
520
760
|
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), '*');
|
|
521
761
|
}
|
|
@@ -573,6 +813,7 @@ export function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOpt
|
|
|
573
813
|
transformed,
|
|
574
814
|
args.path,
|
|
575
815
|
globallyDeclaredSources,
|
|
816
|
+
requestedExports,
|
|
576
817
|
);
|
|
577
818
|
|
|
578
819
|
if (importsModified) {
|
|
@@ -43,7 +43,18 @@ export type IslandHydrationScriptOptions = {
|
|
|
43
43
|
};
|
|
44
44
|
/**
|
|
45
45
|
* Creates a hydration script for client-side React hydration.
|
|
46
|
-
*
|
|
46
|
+
*
|
|
47
|
+
* Why this dispatcher exists:
|
|
48
|
+
* the runtime matrix is small but behaviorally different across development vs
|
|
49
|
+
* production and router vs non-router pages. Keeping that branch here preserves
|
|
50
|
+
* a compact public API while allowing each emitted script to stay focused.
|
|
51
|
+
*
|
|
52
|
+
* Selection rules:
|
|
53
|
+
* - development uses readable scripts with HMR hooks
|
|
54
|
+
* - production uses minified equivalents
|
|
55
|
+
* - router presence decides whether page updates flow through the router runtime
|
|
56
|
+
* or rebuild directly from the page module
|
|
57
|
+
*
|
|
47
58
|
* @param options - Configuration options for script generation
|
|
48
59
|
* @returns The generated hydration script as a string
|
|
49
60
|
*/
|
|
@@ -63,8 +74,14 @@ export declare function createHydrationScript(options: HydrationScriptOptions):
|
|
|
63
74
|
* - resolves the component export by metadata (`componentRef`, `componentFile`)
|
|
64
75
|
* before falling back to default/first function export
|
|
65
76
|
* - selects island root using `targetSelector`
|
|
77
|
+
* - replaces the SSR host with a dedicated client-owned container
|
|
66
78
|
* - creates a fresh React root and renders with serialized `props`
|
|
67
79
|
*
|
|
80
|
+
* Why it remounts instead of hydrating:
|
|
81
|
+
* island SSR intentionally avoids synthetic wrapper elements. The runtime swaps
|
|
82
|
+
* the authored SSR node for a dedicated client-owned container before mounting
|
|
83
|
+
* so the server markup stays clean while the client still gets a stable root.
|
|
84
|
+
*
|
|
68
85
|
* @param options Island script generation options.
|
|
69
86
|
* @returns Browser-executable JavaScript module source.
|
|
70
87
|
*/
|