@ecopages/react 0.2.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/LICENSE +21 -0
  3. package/README.md +65 -0
  4. package/package.json +76 -0
  5. package/src/declarations.d.ts +6 -0
  6. package/src/react-hmr-strategy.d.ts +143 -0
  7. package/src/react-hmr-strategy.js +332 -0
  8. package/src/react-hmr-strategy.ts +444 -0
  9. package/src/react-renderer.d.ts +106 -0
  10. package/src/react-renderer.js +302 -0
  11. package/src/react-renderer.ts +403 -0
  12. package/src/react.plugin.d.ts +147 -0
  13. package/src/react.plugin.js +126 -0
  14. package/src/react.plugin.ts +241 -0
  15. package/src/router-adapter.d.ts +87 -0
  16. package/src/router-adapter.js +0 -0
  17. package/src/router-adapter.ts +95 -0
  18. package/src/services/react-bundle.service.d.ts +68 -0
  19. package/src/services/react-bundle.service.js +145 -0
  20. package/src/services/react-bundle.service.ts +212 -0
  21. package/src/services/react-hmr-page-metadata-cache.d.ts +17 -0
  22. package/src/services/react-hmr-page-metadata-cache.js +19 -0
  23. package/src/services/react-hmr-page-metadata-cache.ts +24 -0
  24. package/src/services/react-hydration-asset.service.d.ts +75 -0
  25. package/src/services/react-hydration-asset.service.js +198 -0
  26. package/src/services/react-hydration-asset.service.ts +260 -0
  27. package/src/services/react-page-module.service.d.ts +80 -0
  28. package/src/services/react-page-module.service.js +155 -0
  29. package/src/services/react-page-module.service.ts +214 -0
  30. package/src/services/react-runtime-bundle.service.d.ts +38 -0
  31. package/src/services/react-runtime-bundle.service.js +207 -0
  32. package/src/services/react-runtime-bundle.service.ts +271 -0
  33. package/src/utils/client-graph-boundary-plugin.d.ts +43 -0
  34. package/src/utils/client-graph-boundary-plugin.js +356 -0
  35. package/src/utils/client-graph-boundary-plugin.ts +590 -0
  36. package/src/utils/client-only.d.ts +8 -0
  37. package/src/utils/client-only.js +19 -0
  38. package/src/utils/client-only.ts +27 -0
  39. package/src/utils/declared-modules.d.ts +42 -0
  40. package/src/utils/declared-modules.js +56 -0
  41. package/src/utils/declared-modules.ts +99 -0
  42. package/src/utils/dynamic.d.ts +15 -0
  43. package/src/utils/dynamic.js +12 -0
  44. package/src/utils/dynamic.ts +27 -0
  45. package/src/utils/hmr-scripts.d.ts +18 -0
  46. package/src/utils/hmr-scripts.js +31 -0
  47. package/src/utils/hmr-scripts.ts +47 -0
  48. package/src/utils/html-boundary.d.ts +7 -0
  49. package/src/utils/html-boundary.js +55 -0
  50. package/src/utils/html-boundary.ts +66 -0
  51. package/src/utils/hydration-scripts.d.ts +71 -0
  52. package/src/utils/hydration-scripts.js +222 -0
  53. package/src/utils/hydration-scripts.ts +338 -0
  54. package/src/utils/reachability-analyzer.d.ts +55 -0
  55. package/src/utils/reachability-analyzer.js +243 -0
  56. package/src/utils/reachability-analyzer.ts +440 -0
  57. package/src/utils/react-mdx-loader-plugin.d.ts +3 -0
  58. package/src/utils/react-mdx-loader-plugin.js +37 -0
  59. package/src/utils/react-mdx-loader-plugin.ts +40 -0
@@ -0,0 +1,332 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { HmrStrategy, HmrStrategyType } from "@ecopages/core/hmr/hmr-strategy";
4
+ import { defaultBuildAdapter } from "@ecopages/core/build/build-adapter";
5
+ import { FileNotFoundError, fileSystem } from "@ecopages/file-system";
6
+ import { Logger } from "@ecopages/logger";
7
+ import { injectHmrHandler } from "./utils/hmr-scripts.js";
8
+ import { createClientGraphBoundaryPlugin } from "./utils/client-graph-boundary-plugin.js";
9
+ import { collectPageDeclaredModules, collectPageDeclaredModulesFromModule } from "./utils/declared-modules.js";
10
+ const appLogger = new Logger("[ReactHmrStrategy]");
11
+ class ReactHmrStrategy extends HmrStrategy {
12
+ /**
13
+ * Creates a new React HMR strategy instance.
14
+ *
15
+ * @param context - The HMR context providing access to watched files, plugins, build directories,
16
+ * and the layouts directory for detecting layout file changes that require full
17
+ * page reloads instead of module-level HMR updates.
18
+ * @param pageMetadataCache - React-only cache of declared browser modules discovered during
19
+ * server rendering. This avoids re-importing unchanged page modules
20
+ * during save-time Fast Refresh rebuilds.
21
+ * @param mdxCompilerOptions - Optional MDX compiler options for processing .mdx files
22
+ * @param explicitGraphEnabled - Enables explicit graph mode for React HMR bundling.
23
+ * In explicit mode, HMR builds omit AST server-only stripping plugins in React paths.
24
+ */
25
+ constructor(context, pageMetadataCache, mdxCompilerOptions, explicitGraphEnabled = false) {
26
+ super();
27
+ this.context = context;
28
+ this.pageMetadataCache = pageMetadataCache;
29
+ this.explicitGraphEnabled = explicitGraphEnabled;
30
+ this.mdxCompilerOptions = mdxCompilerOptions;
31
+ }
32
+ type = HmrStrategyType.INTEGRATION;
33
+ mdxCompilerOptions;
34
+ knownEntrypoints = /* @__PURE__ */ new Set();
35
+ async importNodePageModule(entrypointPath) {
36
+ const srcDir = this.context.getSrcDir();
37
+ const rootDir = path.dirname(srcDir);
38
+ const outdir = path.join(path.resolve(this.context.getDistDir(), "..", ".."), ".server-modules");
39
+ const fileBaseName = path.basename(entrypointPath, path.extname(entrypointPath));
40
+ const fileHash = fileSystem.hash(entrypointPath);
41
+ const outputFileName = `${fileBaseName}-${fileHash}.js`;
42
+ const buildResult = await defaultBuildAdapter.build({
43
+ entrypoints: [entrypointPath],
44
+ root: rootDir,
45
+ outdir,
46
+ target: "node",
47
+ format: "esm",
48
+ sourcemap: "none",
49
+ splitting: false,
50
+ minify: false,
51
+ naming: outputFileName
52
+ });
53
+ if (!buildResult.success) {
54
+ const details = buildResult.logs.map((log) => log.message).join(" | ");
55
+ throw new Error(`Error transpiling React HMR page module: ${details}`);
56
+ }
57
+ const preferredOutputPath = path.join(outdir, outputFileName);
58
+ const compiledOutput = buildResult.outputs.find((output) => output.path === preferredOutputPath)?.path ?? buildResult.outputs.find((output) => output.path.endsWith(".js"))?.path;
59
+ if (!compiledOutput) {
60
+ throw new Error(`No transpiled output generated for React HMR page module: ${entrypointPath}`);
61
+ }
62
+ return await import(pathToFileURL(compiledOutput).href);
63
+ }
64
+ createUseSyncExternalStoreShimPlugin() {
65
+ return {
66
+ name: "react-hmr-use-sync-external-store-shim",
67
+ setup(build) {
68
+ build.onResolve({ filter: /^use-sync-external-store\/shim(?:\/index\.js)?$/ }, () => ({
69
+ path: "use-sync-external-store/shim",
70
+ namespace: "ecopages-react-hmr-shim"
71
+ }));
72
+ build.onLoad(
73
+ { filter: /^use-sync-external-store\/shim$/, namespace: "ecopages-react-hmr-shim" },
74
+ () => ({
75
+ contents: "export { useSyncExternalStore } from 'react';",
76
+ loader: "js"
77
+ })
78
+ );
79
+ build.onLoad({ filter: /[\\/]use-sync-external-store[\\/]shim[\\/]index\.js$/ }, () => ({
80
+ contents: "export { useSyncExternalStore } from 'react';",
81
+ loader: "js"
82
+ }));
83
+ build.onLoad(
84
+ {
85
+ filter: /[\\/]use-sync-external-store[\\/]cjs[\\/]use-sync-external-store-shim\.development\.js$/
86
+ },
87
+ () => ({
88
+ contents: "export { useSyncExternalStore } from 'react';",
89
+ loader: "js"
90
+ })
91
+ );
92
+ }
93
+ };
94
+ }
95
+ /**
96
+ * Returns build plugins for React HMR bundling.
97
+ *
98
+ * Includes the client graph boundary plugin to prevent undeclared imports
99
+ * (including `node:*`) from breaking the browser bundle.
100
+ */
101
+ getBuildPlugins(declaredModules) {
102
+ const allowSpecifiers = [
103
+ "@ecopages/core",
104
+ "react",
105
+ "react-dom",
106
+ "react/jsx-runtime",
107
+ "react/jsx-dev-runtime",
108
+ "react-dom/client",
109
+ ...Array.from(this.context.getSpecifierMap().keys())
110
+ ];
111
+ return [
112
+ createClientGraphBoundaryPlugin({
113
+ absWorkingDir: path.dirname(this.context.getSrcDir()),
114
+ alwaysAllowSpecifiers: allowSpecifiers,
115
+ declaredModules
116
+ }),
117
+ ...this.context.getPlugins(),
118
+ this.createUseSyncExternalStoreShimPlugin()
119
+ ];
120
+ }
121
+ isReactEntrypoint(filePath) {
122
+ if (filePath.endsWith(".tsx")) {
123
+ return true;
124
+ }
125
+ return filePath.endsWith(".mdx") && this.mdxCompilerOptions !== void 0;
126
+ }
127
+ /**
128
+ * Determines if the file is a React/MDX entrypoint that's registered for HMR.
129
+ *
130
+ * @param filePath - Absolute path to the changed file
131
+ * @returns True if this is a registered React or MDX entrypoint
132
+ */
133
+ matches(filePath) {
134
+ const watchedFiles = this.context.getWatchedFiles();
135
+ appLogger.debug(`Checking ${filePath}. Watched: ${watchedFiles.size}`);
136
+ if (watchedFiles.size === 0) {
137
+ return false;
138
+ }
139
+ return this.isReactEntrypoint(filePath);
140
+ }
141
+ /**
142
+ * Checks if a file is a layout file.
143
+ *
144
+ * Layout files require special HMR handling because they wrap multiple pages and affect
145
+ * the entire page structure. When a layout changes, we trigger a 'layout-update' event
146
+ * instead of a regular 'update' event, which instructs the browser to perform a full
147
+ * page reload (or clear cache and re-render) rather than attempting module-level HMR.
148
+ *
149
+ * @param filePath - Absolute path to the file
150
+ * @returns True if the file is in the layouts directory
151
+ */
152
+ isLayoutFile(filePath) {
153
+ return filePath.startsWith(this.context.getLayoutsDir());
154
+ }
155
+ /**
156
+ * Processes a React file change by rebuilding all React entrypoints.
157
+ *
158
+ * For layout files, broadcasts a 'layout-update' event to trigger full page reload.
159
+ * For regular components/pages, broadcasts 'update' events for module-level HMR.
160
+ * When a page entrypoint is first registered, only that entrypoint is built.
161
+ * Subsequent file updates rebuild all watched React entrypoints as usual.
162
+ *
163
+ * @param _filePath - Absolute path to the changed file
164
+ * @returns Action to broadcast update events (layout-update for layouts, update for components)
165
+ */
166
+ async process(_filePath) {
167
+ appLogger.debug(`Processing ${_filePath}`);
168
+ const watchedFiles = this.context.getWatchedFiles();
169
+ if (watchedFiles.size === 0) {
170
+ appLogger.debug(`No watched files`);
171
+ return { type: "none" };
172
+ }
173
+ const isLayout = this.isLayoutFile(_filePath);
174
+ if (isLayout) {
175
+ appLogger.debug(`Detected layout file change: ${_filePath}`);
176
+ }
177
+ const entrypointsToBuild = !this.knownEntrypoints.has(_filePath) && watchedFiles.has(_filePath) ? [[_filePath, watchedFiles.get(_filePath)]] : watchedFiles.entries();
178
+ this.knownEntrypoints.add(_filePath);
179
+ const updates = [];
180
+ for (const [entrypoint, outputUrl] of entrypointsToBuild) {
181
+ if (!this.isReactEntrypoint(entrypoint)) {
182
+ continue;
183
+ }
184
+ appLogger.debug(`Bundling ${entrypoint}`);
185
+ const success = await this.bundleReactEntrypoint(entrypoint, outputUrl);
186
+ if (success) {
187
+ updates.push(outputUrl);
188
+ }
189
+ }
190
+ if (updates.length > 0) {
191
+ if (isLayout) {
192
+ appLogger.debug(`Layout update detected, sending layout-update event`);
193
+ return {
194
+ type: "broadcast",
195
+ events: [
196
+ {
197
+ type: "layout-update"
198
+ }
199
+ ]
200
+ };
201
+ }
202
+ appLogger.debug(`Broadcasting ${updates.length} updates`);
203
+ return {
204
+ type: "broadcast",
205
+ events: updates.map((path2) => ({
206
+ type: "update",
207
+ path: path2,
208
+ timestamp: Date.now()
209
+ }))
210
+ };
211
+ }
212
+ appLogger.debug(`No updates generated`);
213
+ return { type: "none" };
214
+ }
215
+ /**
216
+ * Bundles a single React/MDX entrypoint with HMR support.
217
+ *
218
+ * @param entrypointPath - Absolute path to the source file
219
+ * @param outputUrl - URL path for the bundled file
220
+ * @returns True if bundling was successful
221
+ */
222
+ async bundleReactEntrypoint(entrypointPath, outputUrl) {
223
+ try {
224
+ const isMdx = entrypointPath.endsWith(".mdx");
225
+ const srcDir = this.context.getSrcDir();
226
+ const relativePath = path.relative(srcDir, entrypointPath);
227
+ const relativePathJs = relativePath.replace(/\.(tsx?|jsx?|mdx)$/, ".js");
228
+ const encodedPathJs = this.encodeDynamicSegments(relativePathJs);
229
+ const outputPath = path.join(this.context.getDistDir(), encodedPathJs);
230
+ const tempDir = path.dirname(outputPath);
231
+ const declaredModules = this.pageMetadataCache.getDeclaredModules(entrypointPath) ? this.pageMetadataCache.getDeclaredModules(entrypointPath) : isMdx ? await collectPageDeclaredModules(entrypointPath) : collectPageDeclaredModulesFromModule(await this.importNodePageModule(entrypointPath));
232
+ const plugins = this.getBuildPlugins(declaredModules);
233
+ if (isMdx && this.mdxCompilerOptions) {
234
+ const { createReactMdxLoaderPlugin } = await import("./utils/react-mdx-loader-plugin.js");
235
+ const mdxPlugin = createReactMdxLoaderPlugin(this.mdxCompilerOptions);
236
+ plugins.unshift(mdxPlugin);
237
+ }
238
+ const result = await defaultBuildAdapter.build({
239
+ entrypoints: [entrypointPath],
240
+ outdir: tempDir,
241
+ naming: `[name].[hash].tmp`,
242
+ target: "browser",
243
+ format: "esm",
244
+ sourcemap: "none",
245
+ plugins,
246
+ minify: false,
247
+ external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime", "react-dom/client"]
248
+ });
249
+ if (!result.success) {
250
+ appLogger.error(`Failed to build ${entrypointPath}:`, result.logs);
251
+ return false;
252
+ }
253
+ const tempFile = result.outputs[0]?.path;
254
+ if (!tempFile) {
255
+ appLogger.error(`No output file generated for ${entrypointPath}`);
256
+ return false;
257
+ }
258
+ const processed = await this.processOutput(tempFile, outputPath, outputUrl);
259
+ return processed;
260
+ } catch (error) {
261
+ appLogger.error(`Error bundling ${entrypointPath}:`, error);
262
+ return false;
263
+ }
264
+ }
265
+ /**
266
+ * Encodes dynamic route segments (brackets) in file paths.
267
+ * Converts `[slug]` to `_slug_` to avoid filesystem issues.
268
+ */
269
+ encodeDynamicSegments(filepath) {
270
+ return filepath.replace(/\[([^\]]+)\]/g, "_$1_");
271
+ }
272
+ /**
273
+ * Processes bundled output by replacing specifiers and injecting HMR handler.
274
+ * Writes to temp file first, then renames atomically to avoid conflicts.
275
+ *
276
+ * @param tempPath - Path to the temporary bundled file
277
+ * @param finalPath - Final destination path
278
+ * @param url - URL path for logging
279
+ * @returns True if processing was successful
280
+ */
281
+ async processOutput(tempPath, finalPath, url) {
282
+ if (!fileSystem.exists(tempPath)) {
283
+ appLogger.debug(`Skipping stale temp output for ${url}: ${tempPath}`);
284
+ return false;
285
+ }
286
+ try {
287
+ let code = await fileSystem.readFile(tempPath);
288
+ code = this.replaceBareSpecifiers(code);
289
+ code = injectHmrHandler(code);
290
+ await fileSystem.writeAsync(finalPath, code);
291
+ await fileSystem.removeAsync(tempPath).catch(() => {
292
+ });
293
+ appLogger.debug(`Processed ${url} with HMR handler`);
294
+ return true;
295
+ } catch (error) {
296
+ if (error instanceof FileNotFoundError || error instanceof Error && error.message.includes("not found") || error instanceof Error && "code" in error && error.code === "ENOENT") {
297
+ appLogger.debug(`Skipping stale temp output for ${url}: ${tempPath}`);
298
+ await fileSystem.removeAsync(tempPath).catch(() => {
299
+ });
300
+ return false;
301
+ }
302
+ appLogger.error(`Error processing output for ${url}:`, error);
303
+ await fileSystem.removeAsync(tempPath).catch(() => {
304
+ });
305
+ return false;
306
+ }
307
+ }
308
+ /**
309
+ * Replaces bare specifiers with runtime URLs.
310
+ *
311
+ * Handles both static imports and dynamic imports.
312
+ *
313
+ * @param code - The bundled code to transform
314
+ * @returns The transformed code with runtime URLs
315
+ */
316
+ replaceBareSpecifiers(code) {
317
+ const specifierMap = this.context.getSpecifierMap();
318
+ if (specifierMap.size === 0) {
319
+ return code;
320
+ }
321
+ let result = code;
322
+ for (const [bareSpec, runtimeUrl] of specifierMap.entries()) {
323
+ const escaped = bareSpec.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
324
+ result = result.replace(new RegExp(`from\\s*["']${escaped}["']`, "g"), `from "${runtimeUrl}"`);
325
+ result = result.replace(new RegExp(`import\\(["']${escaped}["']\\)`, "g"), `import("${runtimeUrl}")`);
326
+ }
327
+ return result;
328
+ }
329
+ }
330
+ export {
331
+ ReactHmrStrategy
332
+ };