@ecopages/react 0.2.0-alpha.4 → 0.2.0-alpha.40

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 (70) hide show
  1. package/README.md +161 -18
  2. package/package.json +16 -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 +42 -32
  6. package/src/react-hmr-strategy.js +103 -124
  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 +38 -111
  12. package/src/react.plugin.js +132 -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/services/react-bundle.service.d.ts +15 -26
  17. package/src/services/react-bundle.service.js +45 -93
  18. package/src/services/react-hmr-page-metadata-cache.d.ts +9 -0
  19. package/src/services/react-hmr-page-metadata-cache.js +18 -2
  20. package/src/services/react-hydration-asset.service.d.ts +26 -19
  21. package/src/services/react-hydration-asset.service.js +72 -66
  22. package/src/services/react-mdx-config-dependency.service.d.ts +36 -0
  23. package/src/services/react-mdx-config-dependency.service.js +122 -0
  24. package/src/services/react-page-module.service.d.ts +10 -2
  25. package/src/services/react-page-module.service.js +47 -39
  26. package/src/services/react-page-payload.service.d.ts +46 -0
  27. package/src/services/react-page-payload.service.js +67 -0
  28. package/src/services/react-runtime-bundle.service.d.ts +15 -13
  29. package/src/services/react-runtime-bundle.service.js +103 -180
  30. package/src/utils/client-graph-boundary-plugin.d.ts +1 -1
  31. package/src/utils/client-graph-boundary-plugin.js +149 -11
  32. package/src/utils/component-config-traversal.d.ts +36 -0
  33. package/src/utils/component-config-traversal.js +54 -0
  34. package/src/utils/declared-modules.d.ts +1 -1
  35. package/src/utils/declared-modules.js +7 -16
  36. package/src/utils/dynamic.test.browser.d.ts +1 -0
  37. package/src/utils/dynamic.test.browser.js +33 -0
  38. package/src/utils/hydration-scripts.d.ts +25 -6
  39. package/src/utils/hydration-scripts.js +150 -44
  40. package/src/utils/hydration-scripts.test.browser.d.ts +1 -0
  41. package/src/utils/hydration-scripts.test.browser.js +198 -0
  42. package/src/utils/reachability-analyzer.d.ts +12 -1
  43. package/src/utils/reachability-analyzer.js +101 -5
  44. package/src/utils/react-dom-runtime-interop-plugin.d.ts +5 -0
  45. package/src/utils/react-dom-runtime-interop-plugin.js +29 -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 +6 -0
  49. package/src/utils/react-runtime-alias-map.js +33 -0
  50. package/src/utils/use-sync-external-store-shim-plugin.d.ts +5 -0
  51. package/src/utils/use-sync-external-store-shim-plugin.js +41 -0
  52. package/CHANGELOG.md +0 -62
  53. package/src/react-hmr-strategy.ts +0 -444
  54. package/src/react-renderer.ts +0 -403
  55. package/src/react.plugin.ts +0 -241
  56. package/src/router-adapter.ts +0 -95
  57. package/src/services/react-bundle.service.ts +0 -212
  58. package/src/services/react-hmr-page-metadata-cache.ts +0 -24
  59. package/src/services/react-hydration-asset.service.ts +0 -260
  60. package/src/services/react-page-module.service.ts +0 -214
  61. package/src/services/react-runtime-bundle.service.ts +0 -271
  62. package/src/utils/client-graph-boundary-plugin.ts +0 -590
  63. package/src/utils/client-only.ts +0 -27
  64. package/src/utils/declared-modules.ts +0 -99
  65. package/src/utils/dynamic.ts +0 -27
  66. package/src/utils/hmr-scripts.ts +0 -47
  67. package/src/utils/html-boundary.ts +0 -66
  68. package/src/utils/hydration-scripts.ts +0 -338
  69. package/src/utils/reachability-analyzer.ts +0 -440
  70. package/src/utils/react-mdx-loader-plugin.ts +0 -40
@@ -1,590 +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
- * Strips down a deep path module specifier to its foundational root package name.
69
- *
70
- * When checking against the "allowed modules" whitelist, a component might import something deeply
71
- * nested like `lodash/fp/map` or `@myorg/ui/button`. However, the user configuration only whitelists
72
- * the root `lodash` or `@myorg/ui` package. This normalizer ensures we are comparing apples to apples
73
- * by extracting the base package scope before checking the authorization list.
74
- *
75
- * @example
76
- * toModuleBaseSpecifier('@scope/package/deep/file') -> '@scope/package'
77
- * toModuleBaseSpecifier('lodash/cloneDeep') -> 'lodash'
78
- * toModuleBaseSpecifier('node:fs') -> 'node:fs'
79
- *
80
- * @param specifier - The raw import specifier from the code.
81
- * @returns The root package name, preserving scoped npm organizations.
82
- */
83
- function toModuleBaseSpecifier(specifier: string): string {
84
- if (!isBareSpecifier(specifier) || specifier.startsWith('node:')) {
85
- return specifier;
86
- }
87
-
88
- if (specifier.startsWith('@')) {
89
- const [scope, name] = specifier.split('/');
90
- if (!scope || !name) return specifier;
91
- return `${scope}/${name}`;
92
- }
93
-
94
- const [name] = specifier.split('/');
95
- return name ?? specifier;
96
- }
97
-
98
- /**
99
- * Parses the grammar syntax of declared modules.
100
- * Handles patterns like `@pkg/name` and `@pkg/name{namedImport,anotherImport}`
101
- * returning a map of base packages to their explicitly allowed specifiers.
102
- *
103
- * @param moduleDeclarations - A list of module declaration strings.
104
- * @returns A structured map of allowed packages and their named exports.
105
- */
106
- function parseDeclaredModules(moduleDeclarations: string[] | undefined): Map<string, Set<string> | '*'> {
107
- const map = new Map<string, Set<string> | '*'>();
108
- for (const declaration of moduleDeclarations ?? []) {
109
- const source = declaration.trim();
110
- if (source.length === 0) continue;
111
- const openBraceIndex = source.indexOf('{');
112
- if (openBraceIndex < 0) {
113
- map.set(toModuleBaseSpecifier(source), '*');
114
- continue;
115
- }
116
-
117
- const closeBraceIndex = source.indexOf('}', openBraceIndex);
118
- const rawPkg = source.slice(0, openBraceIndex).trim();
119
- if (rawPkg.length === 0) continue;
120
- const pkg = toModuleBaseSpecifier(rawPkg);
121
-
122
- const namedImportsStr =
123
- closeBraceIndex > openBraceIndex
124
- ? source.slice(openBraceIndex + 1, closeBraceIndex)
125
- : source.slice(openBraceIndex + 1);
126
-
127
- const namedImports = namedImportsStr
128
- .split(',')
129
- .map((s) => s.trim())
130
- .filter(Boolean);
131
-
132
- const existing = map.get(pkg);
133
- if (existing === '*') continue;
134
-
135
- if (!existing) {
136
- if (namedImports.length === 0) {
137
- map.set(pkg, '*');
138
- } else {
139
- map.set(pkg, new Set(namedImports));
140
- }
141
- } else {
142
- for (const name of namedImports) {
143
- existing.add(name);
144
- }
145
- }
146
- }
147
- return map;
148
- }
149
-
150
- /**
151
- * Merges two declared module maps, combining their allowable specific scopes.
152
- *
153
- * @param a - First map of declared modules.
154
- * @param b - Second map of declared modules to merge into the first.
155
- * @returns A unified map of both declarations.
156
- */
157
- function mergeDeclaredModulesMap(
158
- a: Map<string, Set<string> | '*'>,
159
- b: Map<string, Set<string> | '*'>,
160
- ): Map<string, Set<string> | '*'> {
161
- const result = new Map(a);
162
- for (const [pkg, imports] of b.entries()) {
163
- const existing = result.get(pkg);
164
- if (existing === '*') continue;
165
- if (imports === '*') {
166
- result.set(pkg, '*');
167
- continue;
168
- }
169
- if (!existing) {
170
- result.set(pkg, imports);
171
- } else {
172
- for (const name of imports) {
173
- existing.add(name);
174
- }
175
- }
176
- }
177
- return result;
178
- }
179
-
180
- /**
181
- * Returns the proper OxC parser dialect to use for string source parsing.
182
- *
183
- * @param filename - File path.
184
- * @returns Language string.
185
- */
186
- function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
187
- const extension = extname(filename).toLowerCase();
188
- if (extension === '.tsx') return 'tsx';
189
- if (extension === '.ts') return 'ts';
190
- if (extension === '.jsx') return 'jsx';
191
- return 'js';
192
- }
193
-
194
- /**
195
- * Parses a module using Oxc AST and surgically removes forbidden imports.
196
- * Filters down to the exact specifiers requested via `{namedImport}` syntax.
197
- *
198
- * @param source - The raw string source content of the module.
199
- * @param filename - The absolute path of the module.
200
- * @param globallyAllowed - A map of modules declared globally allowable by the build configuration.
201
- * @returns An object containing the transformed string and a boolean indicating if changes occurred.
202
- */
203
- function transformModuleImports(
204
- source: string,
205
- filename: string,
206
- globallyAllowed: Map<string, Set<string> | '*'>,
207
- ): { transformed: string; modified: boolean } {
208
- /**
209
- * Parse the source
210
- *
211
- * We parse once here and then reuse the resulting `program` AST for both
212
- * the local `modules` declaration walk (step 2) and the reachability analysis
213
- * (step 3). Passing it through avoids a redundant second `parseSync` call inside
214
- * `analyzeReachability`, cutting the per-file OxC work roughly in half.
215
- */
216
- let result;
217
- try {
218
- result = parseSync(filename, source, {
219
- sourceType: 'module',
220
- lang: parserLanguageForFile(filename),
221
- });
222
- } catch {
223
- return { transformed: source, modified: false };
224
- }
225
-
226
- const { program } = result;
227
- const localDeclared: string[] = [];
228
-
229
- /**
230
- * Collect locally declared modules
231
- *
232
- * Walk the AST looking for `modules: [...]` array properties. These are the
233
- * component-level allowlist declarations that a developer writes inside
234
- * `eco.page({ modules: ['react', '@myorg/ui{Button}'] })` to explicitly opt
235
- * specific packages into the client bundle.
236
- *
237
- * The collected specifiers are merged with the globally configured allowlist
238
- * before any import filtering takes place.
239
- */
240
-
241
- function walk(node: any) {
242
- if (!node || typeof node !== 'object') return;
243
- if (Array.isArray(node)) {
244
- for (const child of node) walk(child);
245
- return;
246
- }
247
-
248
- if (node.type === 'Property' && node.key?.name === 'modules' && node.value?.type === 'ArrayExpression') {
249
- for (const el of node.value.elements) {
250
- if ((el.type === 'StringLiteral' || el.type === 'Literal') && typeof el.value === 'string') {
251
- localDeclared.push(el.value);
252
- }
253
- }
254
- }
255
-
256
- for (const key in node) {
257
- if (key !== 'type' && key !== 'start' && key !== 'end') {
258
- walk(node[key]);
259
- }
260
- }
261
- }
262
-
263
- walk(program);
264
-
265
- /**
266
- * Merge allowlists and compute reachability
267
- *
268
- * Combine the globally declared modules (from plugin config) with the locally
269
- * declared ones (from the component's `modules` array) into a single authoritative map.
270
- *
271
- * Then run the reachability analysis, passing the already-parsed `program` so the
272
- * analyser skips its own internal `parseSync` call (see step 1 above).
273
- */
274
- const locallyAllowed = parseDeclaredModules(localDeclared);
275
- const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
276
- const reachability = analyzeReachability(source, filename, program);
277
-
278
- /**
279
- * Build the edit list
280
- *
281
- * Walk the AST a second time, this time inspecting every import/export/dynamic-import
282
- * node against the combined allowlist and the reachability graph:
283
- *
284
- * - **Forbidden + unreachable** → replace with empty string (pruned).
285
- * - **Forbidden + reachable from a known client root** → throw a build error so the
286
- * developer is forced to resolve the server-client boundary violation explicitly.
287
- * - **Allowed with specific named rules** → surgically rewrite the import to keep only
288
- * the permitted named bindings; esbuild tree-shakes the rest.
289
- * - **Allowed with no restrictions** → left untouched; esbuild handles tree-shaking.
290
- */
291
- const edits: { start: number; end: number; replacement: string }[] = [];
292
-
293
- function processSpecifier(specifier: string): { allowed: boolean; rules?: Set<string> | '*' } {
294
- const moduleBase = toModuleBaseSpecifier(specifier);
295
- const explicitRules = allowedMap.get(moduleBase);
296
-
297
- if (specifier.startsWith('node:') || specifier.includes('.server.')) {
298
- if (explicitRules) {
299
- return { allowed: true, rules: explicitRules };
300
- }
301
- return { allowed: false };
302
- }
303
-
304
- if (isProjectAliasSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? '*' };
305
- if (!isBareSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? '*' };
306
-
307
- /** By default, bare specifiers (NPM modules) are allowed entirely. */
308
- return { allowed: true, rules: explicitRules ?? '*' };
309
- }
310
-
311
- function walkImports(node: any) {
312
- if (!node || typeof node !== 'object') return;
313
- if (Array.isArray(node)) {
314
- for (const child of node) walkImports(child);
315
- return;
316
- }
317
-
318
- if (node.type === 'ImportDeclaration') {
319
- const specifier = node.source.value as string;
320
- const reachableRules = reachability.reachableImports.get(specifier);
321
- const { allowed, rules } = processSpecifier(specifier);
322
-
323
- if (!allowed) {
324
- if (reachableRules && !reachability.isFallbackRoots) {
325
- throw new Error(
326
- `[Ecopages Client Reachability] Forbidden client import '${specifier}' at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
327
- );
328
- } else {
329
- edits.push({ start: node.start, end: node.end, replacement: '' });
330
- }
331
- return;
332
- }
333
-
334
- /**
335
- * If it IS allowed by the base specifier, we must check if there are specific named rules.
336
- * If there are specific rules (a Set), we must surgically remove any specifiers that aren't in the rule.
337
- */
338
- if (rules instanceof Set && node.specifiers && node.specifiers.length > 0) {
339
- let keptSpecifierCount = 0;
340
- let defaultImportLocal: string | undefined;
341
- let namespaceImportLocal: string | undefined;
342
- const namedImportNodes: string[] = [];
343
- for (const spec of node.specifiers) {
344
- if (spec.type === 'ImportSpecifier') {
345
- const importedName =
346
- spec.imported.type === 'Identifier' ? spec.imported.name : (spec.imported.value as string);
347
- if (rules.has(importedName)) {
348
- keptSpecifierCount += 1;
349
- const localName = spec.local?.name;
350
- if (localName && localName !== importedName) {
351
- namedImportNodes.push(`${importedName} as ${localName}`);
352
- } else {
353
- namedImportNodes.push(importedName);
354
- }
355
- }
356
- } else if (spec.type === 'ImportDefaultSpecifier') {
357
- if (rules.has('default')) {
358
- keptSpecifierCount += 1;
359
- defaultImportLocal = spec.local.name;
360
- }
361
- } else if (spec.type === 'ImportNamespaceSpecifier') {
362
- if (rules.has('*')) {
363
- keptSpecifierCount += 1;
364
- namespaceImportLocal = spec.local.name;
365
- }
366
- }
367
- }
368
-
369
- if (keptSpecifierCount === 0) {
370
- edits.push({ start: node.start, end: node.end, replacement: '' });
371
- } else if (keptSpecifierCount < node.specifiers.length) {
372
- let newDeclaration = '';
373
- if (defaultImportLocal && namespaceImportLocal) {
374
- newDeclaration = `import ${defaultImportLocal}, * as ${namespaceImportLocal} from '${specifier}';`;
375
- } else if (namespaceImportLocal) {
376
- newDeclaration = `import * as ${namespaceImportLocal} from '${specifier}';`;
377
- } else if (defaultImportLocal && namedImportNodes.length > 0) {
378
- newDeclaration = `import ${defaultImportLocal}, { ${namedImportNodes.join(', ')} } from '${specifier}';`;
379
- } else if (defaultImportLocal) {
380
- newDeclaration = `import ${defaultImportLocal} from '${specifier}';`;
381
- } else {
382
- newDeclaration = `import { ${namedImportNodes.join(', ')} } from '${specifier}';`;
383
- }
384
- edits.push({ start: node.start, end: node.end, replacement: newDeclaration });
385
- }
386
- return;
387
- }
388
-
389
- /**
390
- * If it IS allowed (globally or all specifiers match) and IS reachable,
391
- * we can safely just leave the import alone.
392
- * ESBuild will natively treeshake any bindings that are actually unused.
393
- *
394
- * However, if it is completely unreachable, and it's a side-effect import
395
- * (no specifiers), we want to proactively prune it.
396
- */
397
- if (!reachableRules && (!node.specifiers || node.specifiers.length === 0)) {
398
- edits.push({ start: node.start, end: node.end, replacement: '' });
399
- }
400
-
401
- return;
402
- }
403
-
404
- if (node.type === 'ExportNamedDeclaration' && node.source) {
405
- const specifier = node.source.value as string;
406
- const { allowed } = processSpecifier(specifier);
407
-
408
- /**
409
- * We skip checking reachability of re-exports for now to avoid false negatives.
410
- * But we MUST check security.
411
- */
412
- if (!allowed) {
413
- if (!reachability.isFallbackRoots) {
414
- throw new Error(
415
- `[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}.`,
416
- );
417
- } else {
418
- edits.push({ start: node.start, end: node.end, replacement: '' });
419
- }
420
- }
421
- return;
422
- }
423
-
424
- if (node.type === 'ExportAllDeclaration' && node.source) {
425
- const specifier = node.source.value as string;
426
- const { allowed } = processSpecifier(specifier);
427
- if (!allowed) {
428
- if (!reachability.isFallbackRoots) {
429
- throw new Error(
430
- `[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}.`,
431
- );
432
- } else {
433
- edits.push({ start: node.start, end: node.end, replacement: '' });
434
- }
435
- }
436
- return;
437
- }
438
-
439
- if (node.type === 'ImportExpression' && node.source?.value) {
440
- const specifier = node.source.value as string;
441
- const { allowed } = processSpecifier(specifier);
442
- const reachableRules = reachability.reachableImports.get(specifier);
443
-
444
- if (!reachableRules) {
445
- if (!allowed) {
446
- edits.push({ start: node.start, end: node.end, replacement: 'Promise.resolve({})' });
447
- }
448
- return;
449
- }
450
-
451
- if (!allowed) {
452
- if (!reachability.isFallbackRoots) {
453
- throw new Error(
454
- `[Ecopages Client Reachability] Forbidden dynamic import('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
455
- );
456
- } else {
457
- edits.push({ start: node.start, end: node.end, replacement: 'Promise.resolve({})' });
458
- }
459
- }
460
- return;
461
- }
462
-
463
- if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'require') {
464
- const arg = node.arguments?.[0];
465
- if (arg && (arg.type === 'StringLiteral' || arg.type === 'Literal') && typeof arg.value === 'string') {
466
- const specifier = arg.value;
467
- const { allowed } = processSpecifier(specifier);
468
- const reachableRules = reachability.reachableImports.get(specifier);
469
-
470
- if (!reachableRules) {
471
- if (!allowed) {
472
- edits.push({ start: node.start, end: node.end, replacement: '({})' });
473
- }
474
- } else if (!allowed) {
475
- if (!reachability.isFallbackRoots) {
476
- throw new Error(
477
- `[Ecopages Client Reachability] Forbidden require('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
478
- );
479
- } else {
480
- edits.push({ start: node.start, end: node.end, replacement: '({})' });
481
- }
482
- }
483
- }
484
- }
485
-
486
- for (const key in node) {
487
- if (key !== 'type' && key !== 'start' && key !== 'end') {
488
- walkImports(node[key]);
489
- }
490
- }
491
- }
492
- walkImports(program);
493
-
494
- if (edits.length === 0) {
495
- return { transformed: source, modified: false };
496
- }
497
-
498
- edits.sort((a, b) => b.start - a.start);
499
- let transformed = source;
500
- for (const edit of edits) {
501
- transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
502
- }
503
-
504
- return { transformed, modified: true };
505
- }
506
-
507
- /**
508
- * Instantiates the client graph boundary esbuild plugin.
509
- *
510
- * @param options - Configuration options for the graph boundary.
511
- * @returns The resulting `EcoBuildPlugin`.
512
- */
513
- export function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOptions): EcoBuildPlugin {
514
- return {
515
- name: 'ecopages-client-graph-boundary',
516
- setup(build) {
517
- const absWorkingDir = options?.absWorkingDir ?? process.cwd();
518
- const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
519
- for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
520
- globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), '*');
521
- }
522
-
523
- /**
524
- * Source-level transform: replace static `fs.readFileSync(path.resolve('./...'), 'utf-8')`
525
- * calls with the actual file content inlined as a string literal at build time.
526
- *
527
- * This prevents server/client hydration mismatches when components read files at module
528
- * scope — the browser bundle will contain the same content the server rendered, so React
529
- * never needs to enter client-render recovery mode.
530
- */
531
- build.onLoad({ filter: SOURCE_FILE_FILTER }, (args) => {
532
- let source: string;
533
- try {
534
- source = readFileSync(args.path, 'utf-8');
535
- } catch {
536
- return undefined;
537
- }
538
-
539
- let transformed = source;
540
- let modified = false;
541
-
542
- if (source.includes('readFileSync')) {
543
- const readFileTransformed = transformed.replace(
544
- /\bfs\.readFileSync\s*\(\s*path\.resolve\s*\(\s*(['"`])([^'"`\n]+)\1\s*\)\s*,\s*['"`]utf-?8['"`]\s*\)/g,
545
- (_match, _q, relPath) => {
546
- modified = true;
547
- try {
548
- const sourceDir = dirname(args.path);
549
- const srcDirIndex = args.path.lastIndexOf('/src/');
550
- const inferredProjectRoot =
551
- srcDirIndex >= 0 ? args.path.slice(0, srcDirIndex) : undefined;
552
- const candidates = [
553
- resolve(absWorkingDir, relPath),
554
- resolve(process.cwd(), relPath),
555
- resolve(sourceDir, relPath),
556
- ...(inferredProjectRoot ? [resolve(inferredProjectRoot, relPath)] : []),
557
- ];
558
-
559
- const absolutePath = candidates.find((candidate) => existsSync(candidate));
560
- if (!absolutePath) return '""';
561
-
562
- const content = readFileSync(absolutePath, 'utf-8');
563
- return JSON.stringify(content);
564
- } catch {
565
- return '""';
566
- }
567
- },
568
- );
569
- transformed = readFileTransformed;
570
- }
571
-
572
- const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
573
- transformed,
574
- args.path,
575
- globallyDeclaredSources,
576
- );
577
-
578
- if (importsModified) {
579
- modified = true;
580
- transformed = oxcTransformed;
581
- }
582
-
583
- if (!modified) return undefined;
584
-
585
- const ext = extname(args.path).slice(1) as 'ts' | 'tsx' | 'js' | 'jsx';
586
- return { contents: transformed, loader: ext };
587
- });
588
- },
589
- };
590
- }
@@ -1,27 +0,0 @@
1
- import type { ReactNode } from 'react';
2
- import { useEffect, useState } from 'react';
3
-
4
- type ClientOnlyProps = {
5
- children: ReactNode;
6
- fallback?: ReactNode;
7
- };
8
-
9
- export const useIsClient = (): boolean => {
10
- const [isClient, setIsClient] = useState(false);
11
-
12
- useEffect(() => {
13
- setIsClient(true);
14
- }, []);
15
-
16
- return isClient;
17
- };
18
-
19
- export const ClientOnly = ({ children, fallback = null }: ClientOnlyProps): ReactNode => {
20
- const isClient = useIsClient();
21
-
22
- if (!isClient) {
23
- return fallback;
24
- }
25
-
26
- return children;
27
- };