@ecopages/react 0.2.0-alpha.5 → 0.2.0-alpha.51

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 (68) hide show
  1. package/README.md +152 -29
  2. package/package.json +24 -12
  3. package/src/eco-embed.d.ts +11 -0
  4. package/src/eco-embed.js +11 -0
  5. package/src/react-hmr-strategy.d.ts +65 -43
  6. package/src/react-hmr-strategy.js +298 -145
  7. package/src/react-renderer.d.ts +169 -42
  8. package/src/react-renderer.js +484 -164
  9. package/src/react.constants.d.ts +1 -0
  10. package/src/react.constants.js +4 -0
  11. package/src/react.plugin.d.ts +40 -111
  12. package/src/react.plugin.js +136 -61
  13. package/src/react.types.d.ts +88 -0
  14. package/src/react.types.js +0 -0
  15. package/src/router-adapter.d.ts +7 -14
  16. package/src/runtime/use-sync-external-store-with-selector.d.ts +3 -0
  17. package/src/runtime/use-sync-external-store-with-selector.js +56 -0
  18. package/src/services/react-bundle.service.d.ts +22 -35
  19. package/src/services/react-bundle.service.js +41 -105
  20. package/src/services/react-hmr-page-metadata-cache.d.ts +9 -0
  21. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  22. package/src/services/react-hydration-asset.service.d.ts +28 -19
  23. package/src/services/react-hydration-asset.service.js +85 -66
  24. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  25. package/src/services/react-mdx-config-dependency.service.js +122 -0
  26. package/src/services/react-page-module.service.d.ts +10 -2
  27. package/src/services/react-page-module.service.js +47 -39
  28. package/src/services/react-page-payload.service.d.ts +46 -0
  29. package/src/services/react-page-payload.service.js +67 -0
  30. package/src/services/react-runtime-bundle.service.d.ts +20 -13
  31. package/src/services/react-runtime-bundle.service.js +146 -179
  32. package/src/utils/client-graph-boundary-plugin.d.ts +1 -1
  33. package/src/utils/client-graph-boundary-plugin.js +80 -3
  34. package/src/utils/component-config-traversal.d.ts +36 -0
  35. package/src/utils/component-config-traversal.js +54 -0
  36. package/src/utils/declared-modules.d.ts +1 -1
  37. package/src/utils/declared-modules.js +7 -16
  38. package/src/utils/dynamic.test.browser.d.ts +1 -0
  39. package/src/utils/dynamic.test.browser.js +33 -0
  40. package/src/utils/hydration-scripts.d.ts +27 -6
  41. package/src/utils/hydration-scripts.js +177 -44
  42. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  43. package/src/utils/hydration-scripts.test.browser.js +198 -0
  44. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  45. package/src/utils/react-dom-runtime-interop-plugin.js +38 -0
  46. package/src/utils/react-mdx-loader-plugin.d.ts +1 -1
  47. package/src/utils/react-mdx-loader-plugin.js +13 -5
  48. package/src/utils/react-runtime-alias-map.d.ts +8 -0
  49. package/src/utils/react-runtime-alias-map.js +90 -0
  50. package/CHANGELOG.md +0 -67
  51. package/src/react-hmr-strategy.ts +0 -455
  52. package/src/react-renderer.ts +0 -403
  53. package/src/react.plugin.ts +0 -241
  54. package/src/router-adapter.ts +0 -95
  55. package/src/services/react-bundle.service.ts +0 -217
  56. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  57. package/src/services/react-hydration-asset.service.ts +0 -260
  58. package/src/services/react-page-module.service.ts +0 -214
  59. package/src/services/react-runtime-bundle.service.ts +0 -271
  60. package/src/utils/client-graph-boundary-plugin.ts +0 -710
  61. package/src/utils/client-only.ts +0 -27
  62. package/src/utils/declared-modules.ts +0 -99
  63. package/src/utils/dynamic.ts +0 -27
  64. package/src/utils/hmr-scripts.ts +0 -47
  65. package/src/utils/html-boundary.ts +0 -66
  66. package/src/utils/hydration-scripts.ts +0 -338
  67. package/src/utils/reachability-analyzer.ts +0 -593
  68. package/src/utils/react-mdx-loader-plugin.ts +0 -40
@@ -1,710 +0,0 @@
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
-
18
- import { existsSync, readFileSync } from 'node:fs';
19
- import { dirname, extname, resolve } from 'node:path';
20
- import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
21
- import { parseSync } from 'oxc-parser';
22
- import { analyzeReachability } from './reachability-analyzer';
23
-
24
- const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
25
-
26
- /**
27
- * Configuration options for the Client Graph Boundary esbuild plugin.
28
- *
29
- * This plugin serves as the primary security layer between server-only logic and the client-side JavaScript bundle.
30
- * It prevents Node.js built-ins (`node:fs`, `node:path`) and backend-exclusive dependencies (e.g. `pg`, `redis`)
31
- * from accidentally leaking into the browser compilation step, which would cause immediate crashes.
32
- */
33
- type ClientGraphBoundaryOptions = {
34
- /** Absolute path to the current working directory, used as a root fallback for resolving inline file reads. */
35
- absWorkingDir?: string;
36
- /**
37
- * Array of module specifiers that are explicitly whitelisted to be bundled in the client code.
38
- * This is typically populated by parsing `modules: ["..."]` declarations in React/Lit components.
39
- */
40
- declaredModules?: string[];
41
- /** Array of emergency escape-hatch specifiers that always bypass the boundary checks regardless of component declarations. */
42
- alwaysAllowSpecifiers?: string[];
43
- };
44
-
45
- /**
46
- * Evaluates whether a module import is referencing an external package dependency
47
- * (e.g., `react` or `lodash`) as opposed to a local internal file (e.g., `./component` or `/absolute/path`).
48
- *
49
- * This is a critical building block for the graph boundary. We only want to restrict
50
- * specific external dependencies (like server-only utilities) from entering the client bundle,
51
- * while allowing all normal local relative UI component imports to flow through Esbuild safely.
52
- *
53
- * @param specifier - The raw import string found in the source code (e.g., `./Button.tsx` or `node:fs`)
54
- * @returns True if the import string refers to a top-level package or Node built-in.
55
- */
56
- function isBareSpecifier(specifier: string): boolean {
57
- if (specifier.startsWith('.')) return false;
58
- if (specifier.startsWith('/')) return false;
59
- if (specifier.includes('://')) return false;
60
- return true;
61
- }
62
-
63
- function isProjectAliasSpecifier(specifier: string): boolean {
64
- return specifier.startsWith('@/') || specifier.startsWith('~/') || specifier.startsWith('ecopages:');
65
- }
66
-
67
- /**
68
- * Determines whether a specifier should be treated as server-only.
69
- *
70
- * This covers Node built-ins as well as local module conventions such as
71
- * `.server.ts` and extensionless imports that resolve to `.server.*` files.
72
- *
73
- * @param specifier - Raw import specifier from the module source.
74
- * @returns True when the import must never become client-reachable.
75
- */
76
- function isServerOnlySpecifier(specifier: string): boolean {
77
- if (specifier.startsWith('node:')) return true;
78
- return /(?:^|[/])[^/]+\.server(?:$|\.)/.test(specifier);
79
- }
80
-
81
- /**
82
- * Strips down a deep path module specifier to its foundational root package name.
83
- *
84
- * When checking against the "allowed modules" whitelist, a component might import something deeply
85
- * nested like `lodash/fp/map` or `@myorg/ui/button`. However, the user configuration only whitelists
86
- * the root `lodash` or `@myorg/ui` package. This normalizer ensures we are comparing apples to apples
87
- * by extracting the base package scope before checking the authorization list.
88
- *
89
- * @example
90
- * toModuleBaseSpecifier('@scope/package/deep/file') -> '@scope/package'
91
- * toModuleBaseSpecifier('lodash/cloneDeep') -> 'lodash'
92
- * toModuleBaseSpecifier('node:fs') -> 'node:fs'
93
- *
94
- * @param specifier - The raw import specifier from the code.
95
- * @returns The root package name, preserving scoped npm organizations.
96
- */
97
- function toModuleBaseSpecifier(specifier: string): string {
98
- if (!isBareSpecifier(specifier) || specifier.startsWith('node:')) {
99
- return specifier;
100
- }
101
-
102
- if (specifier.startsWith('@')) {
103
- const [scope, name] = specifier.split('/');
104
- if (!scope || !name) return specifier;
105
- return `${scope}/${name}`;
106
- }
107
-
108
- const [name] = specifier.split('/');
109
- return name ?? specifier;
110
- }
111
-
112
- /**
113
- * Parses the grammar syntax of declared modules.
114
- * Handles patterns like `@pkg/name` and `@pkg/name{namedImport,anotherImport}`
115
- * returning a map of base packages to their explicitly allowed specifiers.
116
- *
117
- * @param moduleDeclarations - A list of module declaration strings.
118
- * @returns A structured map of allowed packages and their named exports.
119
- */
120
- function parseDeclaredModules(moduleDeclarations: string[] | undefined): Map<string, Set<string> | '*'> {
121
- const map = new Map<string, Set<string> | '*'>();
122
- for (const declaration of moduleDeclarations ?? []) {
123
- const source = declaration.trim();
124
- if (source.length === 0) continue;
125
- const openBraceIndex = source.indexOf('{');
126
- if (openBraceIndex < 0) {
127
- map.set(toModuleBaseSpecifier(source), '*');
128
- continue;
129
- }
130
-
131
- const closeBraceIndex = source.indexOf('}', openBraceIndex);
132
- const rawPkg = source.slice(0, openBraceIndex).trim();
133
- if (rawPkg.length === 0) continue;
134
- const pkg = toModuleBaseSpecifier(rawPkg);
135
-
136
- const namedImportsStr =
137
- closeBraceIndex > openBraceIndex
138
- ? source.slice(openBraceIndex + 1, closeBraceIndex)
139
- : source.slice(openBraceIndex + 1);
140
-
141
- const namedImports = namedImportsStr
142
- .split(',')
143
- .map((s) => s.trim())
144
- .filter(Boolean);
145
-
146
- const existing = map.get(pkg);
147
- if (existing === '*') continue;
148
-
149
- if (!existing) {
150
- if (namedImports.length === 0) {
151
- map.set(pkg, '*');
152
- } else {
153
- map.set(pkg, new Set(namedImports));
154
- }
155
- } else {
156
- for (const name of namedImports) {
157
- existing.add(name);
158
- }
159
- }
160
- }
161
- return map;
162
- }
163
-
164
- /**
165
- * Merges two declared module maps, combining their allowable specific scopes.
166
- *
167
- * @param a - First map of declared modules.
168
- * @param b - Second map of declared modules to merge into the first.
169
- * @returns A unified map of both declarations.
170
- */
171
- function mergeDeclaredModulesMap(
172
- a: Map<string, Set<string> | '*'>,
173
- b: Map<string, Set<string> | '*'>,
174
- ): Map<string, Set<string> | '*'> {
175
- const result = new Map(a);
176
- for (const [pkg, imports] of b.entries()) {
177
- const existing = result.get(pkg);
178
- if (existing === '*') continue;
179
- if (imports === '*') {
180
- result.set(pkg, '*');
181
- continue;
182
- }
183
- if (!existing) {
184
- result.set(pkg, imports);
185
- } else {
186
- for (const name of imports) {
187
- existing.add(name);
188
- }
189
- }
190
- }
191
- return result;
192
- }
193
-
194
- /**
195
- * Returns the proper OxC parser dialect to use for string source parsing.
196
- *
197
- * @param filename - File path.
198
- * @returns Language string.
199
- */
200
- function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
201
- const extension = extname(filename).toLowerCase();
202
- if (extension === '.tsx') return 'tsx';
203
- if (extension === '.ts') return 'ts';
204
- if (extension === '.jsx') return 'jsx';
205
- return 'js';
206
- }
207
-
208
- /**
209
- * Tracks the subset of exports that a downstream local module is allowed to expose.
210
- *
211
- * `'*'` means the full module namespace is reachable, while a `Set` limits the
212
- * consumer to specific named exports.
213
- */
214
- type RequestedExportRules = Set<string> | '*';
215
-
216
- /**
217
- * Normalizes a file path into a registry key used for requested-export propagation.
218
- *
219
- * The normalization strips JS/TS extensions and collapses `/index` suffixes so
220
- * equivalent local import forms resolve to the same key.
221
- *
222
- * @param pathname - Absolute or resolved local module path.
223
- * @returns Stable registry key for `requestedExports`.
224
- */
225
- function normalizeRequestedExportsKey(pathname: string): string {
226
- let normalized = pathname.replace(/\\/g, '/');
227
- normalized = normalized.replace(/\.(tsx?|jsx?)$/i, '');
228
- if (normalized.endsWith('/index')) {
229
- normalized = normalized.slice(0, -'/index'.length);
230
- }
231
- return normalized;
232
- }
233
-
234
- /**
235
- * Resolves a local import specifier into a requested-export registry key.
236
- *
237
- * Bare package specifiers and project aliases are intentionally ignored because
238
- * requested-export propagation is only used for cross-file local reachability.
239
- *
240
- * @param importer - Absolute path of the importing module.
241
- * @param specifier - Raw import or re-export specifier.
242
- * @returns Registry key for a local dependency, or `undefined` when not applicable.
243
- */
244
- function resolveRequestedExportsKey(importer: string, specifier: string): string | undefined {
245
- if (isBareSpecifier(specifier) || isProjectAliasSpecifier(specifier)) {
246
- return undefined;
247
- }
248
-
249
- const resolved = specifier.startsWith('/') ? specifier : resolve(dirname(importer), specifier);
250
- return normalizeRequestedExportsKey(resolved);
251
- }
252
-
253
- /**
254
- * Merges newly discovered requested-export rules into the local propagation registry.
255
- *
256
- * Once a module is promoted to `'*'`, it stays fully reachable for the remainder
257
- * of the transform pass.
258
- *
259
- * @param registry - Cross-module requested-export registry.
260
- * @param moduleKey - Normalized local module key.
261
- * @param rules - Newly observed reachable export rules for the module.
262
- */
263
- function mergeRequestedExportRules(
264
- registry: Map<string, RequestedExportRules>,
265
- moduleKey: string,
266
- rules: Set<string> | '*',
267
- ) {
268
- const existing = registry.get(moduleKey);
269
- if (existing === '*') return;
270
- if (rules === '*') {
271
- registry.set(moduleKey, '*');
272
- return;
273
- }
274
- if (!existing) {
275
- registry.set(moduleKey, new Set(rules));
276
- return;
277
- }
278
- for (const rule of rules) {
279
- existing.add(rule);
280
- }
281
- }
282
-
283
- /**
284
- * Parses a module using Oxc AST and surgically removes forbidden imports.
285
- * Filters down to the exact specifiers requested via `{namedImport}` syntax.
286
- *
287
- * @param source - The raw string source content of the module.
288
- * @param filename - The absolute path of the module.
289
- * @param globallyAllowed - A map of modules declared globally allowable by the build configuration.
290
- * @param requestedExports - Local requested-export registry used to propagate named reachability across files.
291
- * @returns An object containing the transformed string and a boolean indicating if changes occurred.
292
- */
293
- function transformModuleImports(
294
- source: string,
295
- filename: string,
296
- globallyAllowed: Map<string, Set<string> | '*'>,
297
- requestedExports: Map<string, RequestedExportRules>,
298
- ): { transformed: string; modified: boolean } {
299
- /**
300
- * Parse the source
301
- *
302
- * We parse once here and then reuse the resulting `program` AST for both
303
- * the local `modules` declaration walk (step 2) and the reachability analysis
304
- * (step 3). Passing it through avoids a redundant second `parseSync` call inside
305
- * `analyzeReachability`, cutting the per-file OxC work roughly in half.
306
- */
307
- let result;
308
- try {
309
- result = parseSync(filename, source, {
310
- sourceType: 'module',
311
- lang: parserLanguageForFile(filename),
312
- });
313
- } catch {
314
- return { transformed: source, modified: false };
315
- }
316
-
317
- const { program } = result;
318
- const localDeclared: string[] = [];
319
-
320
- /**
321
- * Collect locally declared modules
322
- *
323
- * Walk the AST looking for `modules: [...]` array properties. These are the
324
- * component-level allowlist declarations that a developer writes inside
325
- * `eco.page({ modules: ['react', '@myorg/ui{Button}'] })` to explicitly opt
326
- * specific packages into the client bundle.
327
- *
328
- * The collected specifiers are merged with the globally configured allowlist
329
- * before any import filtering takes place.
330
- */
331
-
332
- function walk(node: any) {
333
- if (!node || typeof node !== 'object') return;
334
- if (Array.isArray(node)) {
335
- for (const child of node) walk(child);
336
- return;
337
- }
338
-
339
- if (node.type === 'Property' && node.key?.name === 'modules' && node.value?.type === 'ArrayExpression') {
340
- for (const el of node.value.elements) {
341
- if ((el.type === 'StringLiteral' || el.type === 'Literal') && typeof el.value === 'string') {
342
- localDeclared.push(el.value);
343
- }
344
- }
345
- }
346
-
347
- for (const key in node) {
348
- if (key !== 'type' && key !== 'start' && key !== 'end') {
349
- walk(node[key]);
350
- }
351
- }
352
- }
353
-
354
- walk(program);
355
-
356
- /**
357
- * Merge allowlists and compute reachability
358
- *
359
- * Combine the globally declared modules (from plugin config) with the locally
360
- * declared ones (from the component's `modules` array) into a single authoritative map.
361
- *
362
- * Then run the reachability analysis, passing the already-parsed `program` so the
363
- * analyser skips its own internal `parseSync` call (see step 1 above).
364
- */
365
- const locallyAllowed = parseDeclaredModules(localDeclared);
366
- const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
367
- const explicitRequestedExports = requestedExports.get(normalizeRequestedExportsKey(filename));
368
- const reachability = analyzeReachability(source, filename, program, explicitRequestedExports);
369
-
370
- for (const statement of program.body) {
371
- if (statement.type === 'ImportDeclaration') {
372
- const reachableRules = reachability.reachableImports.get(statement.source.value as string);
373
- const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
374
- if (!requestedModuleKey || !reachableRules) continue;
375
-
376
- mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
377
- continue;
378
- }
379
-
380
- if (statement.type === 'ExportNamedDeclaration' && statement.source) {
381
- const reachableRules = reachability.reachableImports.get(statement.source.value as string);
382
- const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
383
- if (!requestedModuleKey || !reachableRules) continue;
384
-
385
- mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
386
- continue;
387
- }
388
-
389
- if (statement.type === 'ExportAllDeclaration' && statement.source) {
390
- const reachableRules = reachability.reachableImports.get(statement.source.value as string);
391
- const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
392
- if (!requestedModuleKey || !reachableRules) continue;
393
-
394
- mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
395
- }
396
- }
397
-
398
- /**
399
- * Build the edit list
400
- *
401
- * Walk the AST a second time, this time inspecting every import/export/dynamic-import
402
- * node against the combined allowlist and the reachability graph:
403
- *
404
- * - **Forbidden + unreachable** → replace with empty string (pruned).
405
- * - **Forbidden + reachable from a known client root** → throw a build error so the
406
- * developer is forced to resolve the server-client boundary violation explicitly.
407
- * - **Allowed with specific named rules** → surgically rewrite the import to keep only
408
- * the permitted named bindings; esbuild tree-shakes the rest.
409
- * - **Allowed with no restrictions** → left untouched; esbuild handles tree-shaking.
410
- */
411
- const edits: { start: number; end: number; replacement: string }[] = [];
412
-
413
- function processSpecifier(specifier: string): { allowed: boolean; rules?: Set<string> | '*' } {
414
- const moduleBase = toModuleBaseSpecifier(specifier);
415
- const explicitRules = allowedMap.get(moduleBase);
416
-
417
- if (isServerOnlySpecifier(specifier)) {
418
- if (explicitRules) {
419
- return { allowed: true, rules: explicitRules };
420
- }
421
- return { allowed: false };
422
- }
423
-
424
- if (isProjectAliasSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? '*' };
425
- if (!isBareSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? '*' };
426
-
427
- /** By default, bare specifiers (NPM modules) are allowed entirely. */
428
- return { allowed: true, rules: explicitRules ?? '*' };
429
- }
430
-
431
- function walkImports(node: any) {
432
- if (!node || typeof node !== 'object') return;
433
- if (Array.isArray(node)) {
434
- for (const child of node) walkImports(child);
435
- return;
436
- }
437
-
438
- if (node.type === 'ImportDeclaration') {
439
- const specifier = node.source.value as string;
440
- const reachableRules = reachability.reachableImports.get(specifier);
441
- const { allowed, rules } = processSpecifier(specifier);
442
-
443
- if (!allowed) {
444
- if (reachableRules && !reachability.isFallbackRoots) {
445
- throw new Error(
446
- `[Ecopages Client Reachability] Forbidden client import '${specifier}' at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
447
- );
448
- } else {
449
- edits.push({ start: node.start, end: node.end, replacement: '' });
450
- }
451
- return;
452
- }
453
-
454
- /**
455
- * If it IS allowed by the base specifier, we must check if there are specific named rules.
456
- * If there are specific rules (a Set), we must surgically remove any specifiers that aren't in the rule.
457
- */
458
- if (rules instanceof Set && node.specifiers && node.specifiers.length > 0) {
459
- let keptSpecifierCount = 0;
460
- let defaultImportLocal: string | undefined;
461
- let namespaceImportLocal: string | undefined;
462
- const namedImportNodes: string[] = [];
463
- for (const spec of node.specifiers) {
464
- if (spec.type === 'ImportSpecifier') {
465
- const importedName =
466
- spec.imported.type === 'Identifier' ? spec.imported.name : (spec.imported.value as string);
467
- if (rules.has(importedName)) {
468
- keptSpecifierCount += 1;
469
- const localName = spec.local?.name;
470
- if (localName && localName !== importedName) {
471
- namedImportNodes.push(`${importedName} as ${localName}`);
472
- } else {
473
- namedImportNodes.push(importedName);
474
- }
475
- }
476
- } else if (spec.type === 'ImportDefaultSpecifier') {
477
- if (rules.has('default')) {
478
- keptSpecifierCount += 1;
479
- defaultImportLocal = spec.local.name;
480
- }
481
- } else if (spec.type === 'ImportNamespaceSpecifier') {
482
- if (rules.has('*')) {
483
- keptSpecifierCount += 1;
484
- namespaceImportLocal = spec.local.name;
485
- }
486
- }
487
- }
488
-
489
- if (keptSpecifierCount === 0) {
490
- edits.push({ start: node.start, end: node.end, replacement: '' });
491
- } else if (keptSpecifierCount < node.specifiers.length) {
492
- let newDeclaration = '';
493
- if (defaultImportLocal && namespaceImportLocal) {
494
- newDeclaration = `import ${defaultImportLocal}, * as ${namespaceImportLocal} from '${specifier}';`;
495
- } else if (namespaceImportLocal) {
496
- newDeclaration = `import * as ${namespaceImportLocal} from '${specifier}';`;
497
- } else if (defaultImportLocal && namedImportNodes.length > 0) {
498
- newDeclaration = `import ${defaultImportLocal}, { ${namedImportNodes.join(', ')} } from '${specifier}';`;
499
- } else if (defaultImportLocal) {
500
- newDeclaration = `import ${defaultImportLocal} from '${specifier}';`;
501
- } else {
502
- newDeclaration = `import { ${namedImportNodes.join(', ')} } from '${specifier}';`;
503
- }
504
- edits.push({ start: node.start, end: node.end, replacement: newDeclaration });
505
- }
506
- return;
507
- }
508
-
509
- /**
510
- * If it IS allowed (globally or all specifiers match) and IS reachable,
511
- * we can safely just leave the import alone.
512
- * ESBuild will natively treeshake any bindings that are actually unused.
513
- *
514
- * However, if it is completely unreachable, and it's a side-effect import
515
- * (no specifiers), we want to proactively prune it.
516
- */
517
- if (!reachableRules && (!node.specifiers || node.specifiers.length === 0)) {
518
- edits.push({ start: node.start, end: node.end, replacement: '' });
519
- }
520
-
521
- return;
522
- }
523
-
524
- if (node.type === 'ExportNamedDeclaration' && node.source) {
525
- const specifier = node.source.value as string;
526
- const { allowed } = processSpecifier(specifier);
527
-
528
- if (!allowed) {
529
- const reachableRules = reachability.reachableImports.get(specifier);
530
- if (reachableRules && !reachability.isFallbackRoots) {
531
- throw new Error(
532
- `[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
533
- );
534
- } else {
535
- edits.push({ start: node.start, end: node.end, replacement: '' });
536
- }
537
- }
538
- return;
539
- }
540
-
541
- if (node.type === 'ExportAllDeclaration' && node.source) {
542
- const specifier = node.source.value as string;
543
- const { allowed } = processSpecifier(specifier);
544
- if (!allowed) {
545
- const reachableRules = reachability.reachableImports.get(specifier);
546
- if (reachableRules && !reachability.isFallbackRoots) {
547
- throw new Error(
548
- `[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
549
- );
550
- } else {
551
- edits.push({ start: node.start, end: node.end, replacement: '' });
552
- }
553
- }
554
- return;
555
- }
556
-
557
- if (node.type === 'ImportExpression' && node.source?.value) {
558
- const specifier = node.source.value as string;
559
- const { allowed } = processSpecifier(specifier);
560
- const reachableRules = reachability.reachableImports.get(specifier);
561
-
562
- if (!reachableRules) {
563
- if (!allowed) {
564
- edits.push({ start: node.start, end: node.end, replacement: 'Promise.resolve({})' });
565
- }
566
- return;
567
- }
568
-
569
- if (!allowed) {
570
- if (!reachability.isFallbackRoots) {
571
- throw new Error(
572
- `[Ecopages Client Reachability] Forbidden dynamic import('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
573
- );
574
- } else {
575
- edits.push({ start: node.start, end: node.end, replacement: 'Promise.resolve({})' });
576
- }
577
- }
578
- return;
579
- }
580
-
581
- if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'require') {
582
- const arg = node.arguments?.[0];
583
- if (arg && (arg.type === 'StringLiteral' || arg.type === 'Literal') && typeof arg.value === 'string') {
584
- const specifier = arg.value;
585
- const { allowed } = processSpecifier(specifier);
586
- const reachableRules = reachability.reachableImports.get(specifier);
587
-
588
- if (!reachableRules) {
589
- if (!allowed) {
590
- edits.push({ start: node.start, end: node.end, replacement: '({})' });
591
- }
592
- } else if (!allowed) {
593
- if (!reachability.isFallbackRoots) {
594
- throw new Error(
595
- `[Ecopages Client Reachability] Forbidden require('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
596
- );
597
- } else {
598
- edits.push({ start: node.start, end: node.end, replacement: '({})' });
599
- }
600
- }
601
- }
602
- }
603
-
604
- for (const key in node) {
605
- if (key !== 'type' && key !== 'start' && key !== 'end') {
606
- walkImports(node[key]);
607
- }
608
- }
609
- }
610
- walkImports(program);
611
-
612
- if (edits.length === 0) {
613
- return { transformed: source, modified: false };
614
- }
615
-
616
- edits.sort((a, b) => b.start - a.start);
617
- let transformed = source;
618
- for (const edit of edits) {
619
- transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
620
- }
621
-
622
- return { transformed, modified: true };
623
- }
624
-
625
- /**
626
- * Instantiates the client graph boundary esbuild plugin.
627
- *
628
- * @param options - Configuration options for the graph boundary.
629
- * @returns The resulting `EcoBuildPlugin`.
630
- */
631
- export function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOptions): EcoBuildPlugin {
632
- return {
633
- name: 'ecopages-client-graph-boundary',
634
- setup(build) {
635
- const absWorkingDir = options?.absWorkingDir ?? process.cwd();
636
- const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
637
- const requestedExports = new Map<string, RequestedExportRules>();
638
- for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
639
- globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), '*');
640
- }
641
-
642
- /**
643
- * Source-level transform: replace static `fs.readFileSync(path.resolve('./...'), 'utf-8')`
644
- * calls with the actual file content inlined as a string literal at build time.
645
- *
646
- * This prevents server/client hydration mismatches when components read files at module
647
- * scope — the browser bundle will contain the same content the server rendered, so React
648
- * never needs to enter client-render recovery mode.
649
- */
650
- build.onLoad({ filter: SOURCE_FILE_FILTER }, (args) => {
651
- let source: string;
652
- try {
653
- source = readFileSync(args.path, 'utf-8');
654
- } catch {
655
- return undefined;
656
- }
657
-
658
- let transformed = source;
659
- let modified = false;
660
-
661
- if (source.includes('readFileSync')) {
662
- const readFileTransformed = transformed.replace(
663
- /\bfs\.readFileSync\s*\(\s*path\.resolve\s*\(\s*(['"`])([^'"`\n]+)\1\s*\)\s*,\s*['"`]utf-?8['"`]\s*\)/g,
664
- (_match, _q, relPath) => {
665
- modified = true;
666
- try {
667
- const sourceDir = dirname(args.path);
668
- const srcDirIndex = args.path.lastIndexOf('/src/');
669
- const inferredProjectRoot =
670
- srcDirIndex >= 0 ? args.path.slice(0, srcDirIndex) : undefined;
671
- const candidates = [
672
- resolve(absWorkingDir, relPath),
673
- resolve(process.cwd(), relPath),
674
- resolve(sourceDir, relPath),
675
- ...(inferredProjectRoot ? [resolve(inferredProjectRoot, relPath)] : []),
676
- ];
677
-
678
- const absolutePath = candidates.find((candidate) => existsSync(candidate));
679
- if (!absolutePath) return '""';
680
-
681
- const content = readFileSync(absolutePath, 'utf-8');
682
- return JSON.stringify(content);
683
- } catch {
684
- return '""';
685
- }
686
- },
687
- );
688
- transformed = readFileTransformed;
689
- }
690
-
691
- const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
692
- transformed,
693
- args.path,
694
- globallyDeclaredSources,
695
- requestedExports,
696
- );
697
-
698
- if (importsModified) {
699
- modified = true;
700
- transformed = oxcTransformed;
701
- }
702
-
703
- if (!modified) return undefined;
704
-
705
- const ext = extname(args.path).slice(1) as 'ts' | 'tsx' | 'js' | 'jsx';
706
- return { contents: transformed, loader: ext };
707
- });
708
- },
709
- };
710
- }