@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +23 -37
  2. package/README.md +143 -17
  3. package/package.json +3 -3
  4. package/src/react-hmr-strategy.d.ts +22 -19
  5. package/src/react-hmr-strategy.js +57 -109
  6. package/src/react-hmr-strategy.ts +76 -134
  7. package/src/react-renderer.d.ts +130 -11
  8. package/src/react-renderer.js +368 -64
  9. package/src/react-renderer.ts +490 -90
  10. package/src/react.plugin.d.ts +17 -5
  11. package/src/react.plugin.js +44 -13
  12. package/src/react.plugin.ts +49 -14
  13. package/src/router-adapter.d.ts +2 -2
  14. package/src/router-adapter.ts +2 -2
  15. package/src/services/react-bundle.service.d.ts +2 -25
  16. package/src/services/react-bundle.service.js +21 -91
  17. package/src/services/react-bundle.service.ts +22 -126
  18. package/src/services/react-hydration-asset.service.js +3 -3
  19. package/src/services/react-hydration-asset.service.ts +7 -4
  20. package/src/services/react-page-module.service.d.ts +3 -0
  21. package/src/services/react-page-module.service.js +20 -16
  22. package/src/services/react-page-module.service.ts +27 -17
  23. package/src/services/react-runtime-bundle.service.d.ts +12 -12
  24. package/src/services/react-runtime-bundle.service.js +98 -180
  25. package/src/services/react-runtime-bundle.service.ts +112 -211
  26. package/src/utils/client-graph-boundary-plugin.js +147 -9
  27. package/src/utils/client-graph-boundary-plugin.ts +252 -11
  28. package/src/utils/hydration-scripts.d.ts +18 -1
  29. package/src/utils/hydration-scripts.js +83 -32
  30. package/src/utils/hydration-scripts.ts +159 -38
  31. package/src/utils/reachability-analyzer.d.ts +12 -1
  32. package/src/utils/reachability-analyzer.js +101 -5
  33. package/src/utils/reachability-analyzer.ts +161 -8
  34. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  35. package/src/utils/react-dom-runtime-interop-plugin.js +29 -0
  36. package/src/utils/react-dom-runtime-interop-plugin.ts +33 -0
  37. package/src/utils/react-mdx-loader-plugin.js +13 -5
  38. package/src/utils/react-mdx-loader-plugin.ts +28 -5
  39. package/src/utils/react-runtime-specifier-map.d.ts +6 -0
  40. package/src/utils/react-runtime-specifier-map.js +37 -0
  41. package/src/utils/react-runtime-specifier-map.ts +45 -0
  42. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  43. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  44. 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 transformModuleImports(source, filename, globallyAllowed) {
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 reachability = analyzeReachability(source, filename, program);
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 (specifier.startsWith("node:") || specifier.includes(".server.")) {
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
- if (!reachability.isFallbackRoots) {
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
- if (!reachability.isFallbackRoots) {
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 { transformed: source, modified: false };
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 reachability = analyzeReachability(source, filename, program);
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 (specifier.startsWith('node:') || specifier.includes('.server.')) {
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
- if (!reachability.isFallbackRoots) {
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
- if (!reachability.isFallbackRoots) {
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 { transformed: source, modified: false };
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
- * Generates appropriate script based on environment and router configuration.
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
  */