@ecopages/core 0.2.0-beta.7 → 0.2.0-beta.9

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 (110) hide show
  1. package/package.json +27 -3
  2. package/src/adapters/abstract/application-adapter.d.ts +12 -1
  3. package/src/adapters/abstract/application-adapter.js +13 -0
  4. package/src/adapters/abstract/server-adapter.d.ts +3 -0
  5. package/src/adapters/bun/client-bridge.d.ts +1 -0
  6. package/src/adapters/bun/client-bridge.js +3 -0
  7. package/src/adapters/bun/create-app.d.ts +2 -0
  8. package/src/adapters/bun/create-app.js +28 -9
  9. package/src/adapters/bun/server-adapter.d.ts +13 -0
  10. package/src/adapters/bun/server-adapter.js +29 -4
  11. package/src/adapters/create-app.d.ts +13 -0
  12. package/src/adapters/create-app.js +10 -1
  13. package/src/adapters/node/create-app.d.ts +1 -0
  14. package/src/adapters/node/create-app.js +17 -7
  15. package/src/adapters/node/node-client-bridge.d.ts +1 -0
  16. package/src/adapters/node/node-client-bridge.js +3 -0
  17. package/src/adapters/node/server-adapter.d.ts +10 -13
  18. package/src/adapters/node/server-adapter.js +37 -82
  19. package/src/adapters/node/static-content-server.d.ts +1 -0
  20. package/src/adapters/node/static-content-server.js +3 -0
  21. package/src/adapters/shared/copy-runtime-public-dir.d.ts +6 -0
  22. package/src/adapters/shared/copy-runtime-public-dir.js +24 -0
  23. package/src/adapters/shared/runtime-app-bootstrap.d.ts +0 -1
  24. package/src/adapters/shared/runtime-app-bootstrap.js +1 -6
  25. package/src/adapters/shared/server-static-builder.d.ts +4 -6
  26. package/src/adapters/shared/server-static-builder.js +90 -34
  27. package/src/adapters/shared/shared-hmr-manager.d.ts +1 -0
  28. package/src/adapters/shared/shared-hmr-manager.js +15 -2
  29. package/src/build/README.md +44 -4
  30. package/src/build/build-adapter.d.ts +27 -2
  31. package/src/build/build-adapter.js +75 -32
  32. package/src/build/build-input-fingerprint.d.ts +28 -0
  33. package/src/build/build-input-fingerprint.js +31 -0
  34. package/src/build/jsx-ownership-plugins.d.ts +10 -0
  35. package/src/build/jsx-ownership-plugins.js +17 -0
  36. package/src/build/pages-unified-graph-build.d.ts +31 -0
  37. package/src/build/pages-unified-graph-build.js +208 -0
  38. package/src/build/parallel-build-executor.d.ts +21 -0
  39. package/src/build/parallel-build-executor.js +52 -0
  40. package/src/build/rolldown-adapter-helpers.js +7 -1
  41. package/src/build/rolldown-build-adapter.js +2 -0
  42. package/src/build/rolldown-build-invocation-metrics.d.ts +12 -0
  43. package/src/build/rolldown-build-invocation-metrics.js +43 -0
  44. package/src/build/rolldown-dev-build-adapter.d.ts +2 -2
  45. package/src/build/rolldown-dev-build-adapter.js +30 -14
  46. package/src/build/rolldown-plugin-bridge.d.ts +10 -1
  47. package/src/build/rolldown-plugin-bridge.js +17 -5
  48. package/src/build/rolldown-source-transform-pass.d.ts +15 -0
  49. package/src/build/rolldown-source-transform-pass.js +58 -0
  50. package/src/build/runtime-build-executor.d.ts +7 -13
  51. package/src/build/runtime-build-executor.js +31 -7
  52. package/src/build/server-entry-build-cache.d.ts +44 -0
  53. package/src/build/server-entry-build-cache.js +169 -0
  54. package/src/config/config-builder.d.ts +11 -1
  55. package/src/config/config-builder.js +11 -1
  56. package/src/hmr/hmr.test.e2e.js +15 -4
  57. package/src/plugins/integration-plugin.d.ts +19 -8
  58. package/src/plugins/integration-plugin.js +7 -2
  59. package/src/plugins/processor.d.ts +5 -0
  60. package/src/plugins/processor.js +7 -0
  61. package/src/plugins/source-transform.d.ts +31 -0
  62. package/src/plugins/source-transform.js +23 -1
  63. package/src/route-renderer/GRAPH.md +4 -4
  64. package/src/route-renderer/README.md +6 -5
  65. package/src/route-renderer/orchestration/foreign-subtree-execution.service.js +1 -3
  66. package/src/route-renderer/orchestration/integration-renderer.d.ts +10 -2
  67. package/src/route-renderer/orchestration/integration-renderer.js +32 -12
  68. package/src/route-renderer/orchestration/render-output.utils.d.ts +2 -0
  69. package/src/route-renderer/orchestration/render-output.utils.js +4 -0
  70. package/src/route-renderer/orchestration/route-render-orchestrator.d.ts +2 -2
  71. package/src/route-renderer/orchestration/route-render-orchestrator.js +17 -16
  72. package/src/router/client/navigation-coordinator.js +10 -0
  73. package/src/services/assets/asset-processing-service/browser-runtime-asset.factory.d.ts +1 -0
  74. package/src/services/assets/asset-processing-service/browser-runtime-asset.factory.js +1 -0
  75. package/src/services/assets/asset-processing-service/browser-runtime-entry.factory.d.ts +4 -2
  76. package/src/services/assets/asset-processing-service/browser-runtime-entry.factory.js +27 -7
  77. package/src/services/assets/asset-processing-service/processors/script/node-module-script.processor.js +17 -3
  78. package/src/services/assets/browser-bundle.service.d.ts +3 -1
  79. package/src/services/assets/browser-bundle.service.js +14 -5
  80. package/src/services/html/html-transformer.service.d.ts +1 -1
  81. package/src/services/html/html-transformer.service.js +4 -4
  82. package/src/services/invalidation/development-invalidation.service.d.ts +10 -1
  83. package/src/services/invalidation/development-invalidation.service.js +29 -0
  84. package/src/services/module-loading/README.md +49 -0
  85. package/src/services/module-loading/app-server-module-transpiler.service.js +4 -17
  86. package/src/services/module-loading/page-module-import.service.d.ts +4 -1
  87. package/src/services/module-loading/page-module-import.service.js +52 -29
  88. package/src/services/module-loading/route-module-build-cache-registry.d.ts +24 -0
  89. package/src/services/module-loading/route-module-build-cache-registry.js +37 -0
  90. package/src/services/module-loading/route-module-build-cache.d.ts +4 -0
  91. package/src/services/module-loading/route-module-build-cache.js +38 -0
  92. package/src/services/module-loading/route-module-build-cache.store.d.ts +57 -0
  93. package/src/services/module-loading/route-module-build-cache.store.js +203 -0
  94. package/src/services/module-loading/route-module-build-manifest.d.ts +61 -0
  95. package/src/services/module-loading/route-module-build-manifest.js +124 -0
  96. package/src/services/module-loading/route-module-dependency-hasher.d.ts +81 -0
  97. package/src/services/module-loading/route-module-dependency-hasher.js +121 -0
  98. package/src/services/module-loading/server-module-transpiler.service.js +1 -1
  99. package/src/static-site-generator/README.md +33 -0
  100. package/src/static-site-generator/static-build-invalidation.d.ts +10 -0
  101. package/src/static-site-generator/static-build-invalidation.js +78 -0
  102. package/src/static-site-generator/static-export-context.d.ts +22 -0
  103. package/src/static-site-generator/static-export-context.js +0 -0
  104. package/src/static-site-generator/static-site-generator.d.ts +17 -8
  105. package/src/static-site-generator/static-site-generator.js +176 -62
  106. package/src/types/internal-types.d.ts +8 -0
  107. package/src/utils/parse-cli-args.d.ts +1 -0
  108. package/src/utils/parse-cli-args.js +3 -0
  109. package/src/watchers/project-watcher.d.ts +8 -0
  110. package/src/watchers/project-watcher.js +32 -12
@@ -1,4 +1,4 @@
1
- import { createServer } from "node:http";
1
+ import {} from "node:http";
2
2
  import path from "node:path";
3
3
  import { fileSystem } from "@ecopages/file-system";
4
4
  import { getAppBrowserBuildPlugins, setupAppRuntimePlugins } from "../../build/build-adapter.js";
@@ -20,6 +20,7 @@ import {
20
20
  isHtmlResponse,
21
21
  shouldInjectHmrHtmlResponse
22
22
  } from "../shared/hmr-html-response.js";
23
+ import { copyRuntimePublicDirIfChanged } from "../shared/copy-runtime-public-dir.js";
23
24
  import { resolveServeRuntimeOrigin } from "../shared/runtime-app-bootstrap.js";
24
25
  import { NodeClientAbortError, NodeHttpRequestBridge } from "./http-request-bridge.js";
25
26
  import { NodeStaticPreviewHost } from "./static-preview-host.js";
@@ -32,6 +33,8 @@ class NodeServerAdapter extends SharedServerAdapter {
32
33
  errorHandler;
33
34
  bridge = null;
34
35
  hmrManager = null;
36
+ projectWatcher = null;
37
+ adapterDisposed = false;
35
38
  previewHost;
36
39
  requestBridge;
37
40
  devRuntimeFactory;
@@ -126,7 +129,7 @@ class NodeServerAdapter extends SharedServerAdapter {
126
129
  prepareRuntimePublicDir() {
127
130
  const srcPublicDir = path.join(this.appConfig.rootDir, this.appConfig.srcDir, this.appConfig.publicDir);
128
131
  if (fileSystem.exists(srcPublicDir)) {
129
- fileSystem.copyDir(srcPublicDir, path.join(this.appConfig.rootDir, this.appConfig.distDir));
132
+ copyRuntimePublicDirIfChanged(srcPublicDir, path.join(this.appConfig.rootDir, this.appConfig.distDir));
130
133
  }
131
134
  fileSystem.ensureDir(path.join(this.appConfig.absolutePaths.distDir, RESOLVED_ASSETS_DIR));
132
135
  }
@@ -139,20 +142,15 @@ class NodeServerAdapter extends SharedServerAdapter {
139
142
  if (!this.initialized) {
140
143
  await this.initialize();
141
144
  }
142
- const buildServer = await this.startBuildRuntimeServer();
143
- const buildRuntimeOrigin = this.getListeningServerOrigin(buildServer);
144
- try {
145
- await this.staticBuilder.build(
146
- { preview: false, baseUrl: buildRuntimeOrigin },
147
- {
148
- router: this.router,
149
- routeRendererFactory: this.routeRendererFactory,
150
- staticRoutes: this.staticRoutes
151
- }
152
- );
153
- } finally {
154
- await this.stopBuildRuntimeServer(buildServer);
155
- }
145
+ const baseUrl = resolveServeRuntimeOrigin(this.serveOptions);
146
+ await this.staticBuilder.build(
147
+ { preview: false, baseUrl, force: options?.force },
148
+ {
149
+ router: this.router,
150
+ routeRendererFactory: this.routeRendererFactory,
151
+ staticRoutes: this.staticRoutes
152
+ }
153
+ );
156
154
  if (!options?.preview) {
157
155
  return;
158
156
  }
@@ -165,71 +163,6 @@ class NodeServerAdapter extends SharedServerAdapter {
165
163
  const previewPort = this.serveOptions.port || DEFAULT_ECOPAGES_PORT;
166
164
  appLogger.info(`Preview running at http://${previewHostname}:${previewPort}`);
167
165
  }
168
- async startBuildRuntimeServer() {
169
- const hostname = String(this.serveOptions.hostname || DEFAULT_ECOPAGES_HOSTNAME);
170
- const port = 0;
171
- const server = createServer(async (req, res) => {
172
- try {
173
- const webRequest = this.requestBridge.createWebRequest(req, this.runtimeOrigin);
174
- const response = await this.handleRequest(webRequest);
175
- await this.requestBridge.sendNodeResponse(res, response);
176
- } catch (error) {
177
- if (error instanceof NodeClientAbortError) {
178
- return;
179
- }
180
- appLogger.error("Node static build runtime request failed", error);
181
- res.statusCode = 500;
182
- res.end("Internal Server Error");
183
- }
184
- });
185
- await new Promise((resolve, reject) => {
186
- server.once("error", reject);
187
- server.listen(port, hostname, () => {
188
- server.off("error", reject);
189
- resolve();
190
- });
191
- });
192
- this.serverInstance = server;
193
- appLogger.info(`Server running at ${this.getListeningServerOrigin(server)}`);
194
- return server;
195
- }
196
- getListeningServerOrigin(server) {
197
- const address = server.address();
198
- const hostname = String(this.serveOptions.hostname || DEFAULT_ECOPAGES_HOSTNAME);
199
- if (!address || typeof address === "string") {
200
- throw new Error("Build runtime server did not expose a numeric listening port");
201
- }
202
- return `http://${hostname}:${address.port}`;
203
- }
204
- /**
205
- * Gracefully shuts down the ephemeral build runtime server.
206
- *
207
- * `closeAllConnections()` is called *before* `close()` because `server.close()`
208
- * only stops accepting new connections — it waits for existing keep-alive
209
- * connections to finish naturally, which can stall the build indefinitely.
210
- * `closeAllConnections()` force-closes any lingering sockets immediately so
211
- * the `close()` callback fires promptly.
212
- *
213
- * The `NodeClientBridge` heartbeat is also destroyed here so its `setInterval`
214
- * does not prevent the Node.js process from exiting cleanly after the build.
215
- */
216
- async stopBuildRuntimeServer(server) {
217
- await new Promise((resolve, reject) => {
218
- server.close((error) => {
219
- if (error) {
220
- reject(error);
221
- return;
222
- }
223
- resolve();
224
- });
225
- server.closeAllConnections();
226
- });
227
- if (this.serverInstance === server) {
228
- this.serverInstance = null;
229
- }
230
- this.bridge?.destroy();
231
- this.bridge = null;
232
- }
233
166
  async createAdapter() {
234
167
  await this.initialize();
235
168
  return {
@@ -237,9 +170,30 @@ class NodeServerAdapter extends SharedServerAdapter {
237
170
  buildStatic: this.buildStatic.bind(this),
238
171
  completeInitialization: this.completeInitialization.bind(this),
239
172
  handleRequest: this.handleRequest.bind(this),
240
- attachUserWebSocketUpgrades: this.attachUserWebSocketUpgrades.bind(this)
173
+ attachUserWebSocketUpgrades: this.attachUserWebSocketUpgrades.bind(this),
174
+ dispose: this.dispose.bind(this)
241
175
  };
242
176
  }
177
+ /**
178
+ * Releases dev-time resources owned by the adapter.
179
+ *
180
+ * @remarks
181
+ * Safe to call multiple times. Does not stop the bound HTTP server — callers
182
+ * should shut down transport through the runtime host before disposing.
183
+ */
184
+ async dispose() {
185
+ if (this.adapterDisposed) {
186
+ return;
187
+ }
188
+ this.adapterDisposed = true;
189
+ await this.projectWatcher?.close();
190
+ this.projectWatcher = null;
191
+ this.hmrManager?.stop();
192
+ this.hmrManager = null;
193
+ this.bridge?.destroy();
194
+ this.bridge = null;
195
+ await this.previewHost.stop();
196
+ }
243
197
  /**
244
198
  * Handles a single incoming Web `Request` and returns a Web `Response`.
245
199
  *
@@ -328,6 +282,7 @@ class NodeServerAdapter extends SharedServerAdapter {
328
282
  hmrManager: this.hmrManager,
329
283
  bridge: this.bridge
330
284
  });
285
+ this.projectWatcher = watcher;
331
286
  await watcher.createWatcherSubscription();
332
287
  } else if (hasUserWs) {
333
288
  this.wireUserWebSocketUpgrades(server);
@@ -56,5 +56,6 @@ export declare class NodeStaticContentServer {
56
56
  * Stops the static preview server and optionally closes active connections.
57
57
  */
58
58
  stop(force?: boolean): Promise<void>;
59
+ [Symbol.asyncDispose](): Promise<void>;
59
60
  }
60
61
  export {};
@@ -188,6 +188,9 @@ class NodeStaticContentServer {
188
188
  }
189
189
  });
190
190
  }
191
+ async [Symbol.asyncDispose]() {
192
+ await this.stop();
193
+ }
191
194
  }
192
195
  export {
193
196
  NodeStaticContentServer
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Copies a source public directory into the runtime dist dir, skipping unchanged files.
3
+ *
4
+ * Dev restarts call this on every boot; byte comparison avoids rewriting identical assets.
5
+ */
6
+ export declare function copyRuntimePublicDirIfChanged(sourceDir: string, destinationDir: string): void;
@@ -0,0 +1,24 @@
1
+ import { readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileSystem } from "@ecopages/file-system";
4
+ function copyRuntimePublicDirIfChanged(sourceDir, destinationDir) {
5
+ fileSystem.ensureDir(destinationDir);
6
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
7
+ const sourcePath = path.join(sourceDir, entry.name);
8
+ const destinationPath = path.join(destinationDir, entry.name);
9
+ if (entry.isDirectory()) {
10
+ copyRuntimePublicDirIfChanged(sourcePath, destinationPath);
11
+ continue;
12
+ }
13
+ if (!entry.isFile()) {
14
+ continue;
15
+ }
16
+ if (fileSystem.exists(destinationPath) && Buffer.compare(fileSystem.readFileAsBuffer(sourcePath), fileSystem.readFileAsBuffer(destinationPath)) === 0) {
17
+ continue;
18
+ }
19
+ fileSystem.copyFile(sourcePath, destinationPath);
20
+ }
21
+ }
22
+ export {
23
+ copyRuntimePublicDirIfChanged
24
+ };
@@ -8,7 +8,6 @@ export type RuntimeBinding = {
8
8
  watch: boolean;
9
9
  };
10
10
  export type StaticRuntimeMode = {
11
- requiresFetchRuntime: boolean;
12
11
  canBuildWithoutRuntimeServer: boolean;
13
12
  };
14
13
  export declare function resolveServeRuntimeOrigin(serveOptions: {
@@ -30,13 +30,8 @@ function resolveRuntimeBinding(options) {
30
30
  };
31
31
  }
32
32
  function resolveStaticRuntimeMode(options) {
33
- const requiresFetchRuntime = options.appConfig.integrations.some(
34
- (integration) => integration.staticBuildStep === "fetch"
35
- );
36
- const canBuildWithoutRuntimeServer = (options.cliArgs.build || options.cliArgs.preview) && !requiresFetchRuntime;
37
33
  return {
38
- requiresFetchRuntime,
39
- canBuildWithoutRuntimeServer
34
+ canBuildWithoutRuntimeServer: options.cliArgs.build || options.cliArgs.preview
40
35
  };
41
36
  }
42
37
  export {
@@ -6,6 +6,7 @@ import type { StaticGenerationRendererResolver } from '../../route-renderer/rout
6
6
  export interface StaticBuildOptions {
7
7
  preview?: boolean;
8
8
  baseUrl?: string;
9
+ force?: boolean;
9
10
  }
10
11
  export interface ServeOptions {
11
12
  hostname?: string;
@@ -61,17 +62,14 @@ export declare class ServerStaticBuilder {
61
62
  * Bundles the server entry file for production use.
62
63
  *
63
64
  * @remarks
64
- * When the project has API endpoints, the entry file must be bundled
65
- * into a single JS file so the production server can start without
66
- * on-the-fly TypeScript transpilation. The bundle is output to
67
- * `dist/{SERVER_BUNDLE_DIR}/{SERVER_BUNDLE_FILENAME}` and used by
68
- * `ecopages start` in production.
65
+ * Every production build emits a runnable server entry for `ecopages start`,
66
+ * including static-only and websocket-only apps. Incremental builds may skip
67
+ * Rolldown when the `.eco/.server-entry` cache is still valid.
69
68
  *
70
69
  * Package imports remain external so native addons and runtime-owned
71
70
  * dependencies continue to load through the app's installed
72
71
  * `node_modules` tree. Only the app entry graph is bundled.
73
72
  *
74
- * Skips silently when no API endpoints are registered (static-only site).
75
73
  * Throws if the build adapter is unavailable, is owned by a host
76
74
  * runtime, or bundling fails.
77
75
  *
@@ -3,8 +3,19 @@ import { fileSystem } from "@ecopages/file-system";
3
3
  import { DEFAULT_ECOPAGES_HOSTNAME, DEFAULT_ECOPAGES_PORT } from "../../config/constants.js";
4
4
  import { StaticContentServer } from "../../dev/sc-server.js";
5
5
  import { appLogger } from "../../global/app-logger.js";
6
- import { getAppBuildAdapter } from "../../build/build-adapter.js";
7
- import { resolveEntryFile, SERVER_BUNDLE_DIR, SERVER_BUNDLE_FILENAME } from "../../utils/resolve-entry-file.js";
6
+ import { build, getAppBuildAdapter } from "../../build/build-adapter.js";
7
+ import {
8
+ getServerBundleOutputPaths,
9
+ lookupServerEntryBuildCache,
10
+ recordServerEntryBuildCache,
11
+ writeServerBundleDeployManifest
12
+ } from "../../build/server-entry-build-cache.js";
13
+ import { getInstalledServerEntryBuildExecutor } from "../../build/runtime-build-executor.js";
14
+ import {
15
+ clearProductionBuildCaches,
16
+ shouldResetStaticExportDirectory
17
+ } from "../../static-site-generator/static-build-invalidation.js";
18
+ import { resolveEntryFile, SERVER_BUNDLE_FILENAME } from "../../utils/resolve-entry-file.js";
8
19
  class ServerStaticBuilder {
9
20
  appConfig;
10
21
  staticSiteGenerator;
@@ -30,9 +41,19 @@ class ServerStaticBuilder {
30
41
  this.previewServerFactory = previewServerFactory ?? StaticContentServer;
31
42
  this.entryFile = resolveEntryFile({ entryFile });
32
43
  }
33
- prepareExportDirectory() {
44
+ prepareExportDirectory(force) {
45
+ if (force) {
46
+ clearProductionBuildCaches(this.appConfig);
47
+ }
34
48
  const exportDir = this.appConfig.absolutePaths?.distDir ?? path.join(this.appConfig.rootDir, this.appConfig.distDir);
35
- fileSystem.ensureDir(exportDir, true);
49
+ const shouldCleanDist = shouldResetStaticExportDirectory(this.appConfig, force);
50
+ fileSystem.ensureDir(exportDir, shouldCleanDist);
51
+ if (shouldCleanDist) {
52
+ this.appConfig.runtime = {
53
+ ...this.appConfig.runtime ?? {},
54
+ runtimeAssetsPrepared: false
55
+ };
56
+ }
36
57
  const srcPublicDir = path.join(
37
58
  this.appConfig.rootDir,
38
59
  this.appConfig.srcDir ?? "src",
@@ -41,39 +62,47 @@ class ServerStaticBuilder {
41
62
  if (fileSystem.exists(srcPublicDir)) {
42
63
  fileSystem.copyDir(srcPublicDir, exportDir);
43
64
  }
65
+ return !shouldCleanDist;
44
66
  }
45
67
  async refreshRuntimeAssets() {
46
- for (const processor of this.appConfig.processors.values()) {
47
- await processor.setup();
68
+ if (this.appConfig.runtime?.runtimeAssetsPrepared) {
69
+ appLogger.debug("Skipped refreshRuntimeAssets: runtime assets already prepared");
70
+ return;
48
71
  }
49
- for (const integration of this.appConfig.integrations) {
50
- await integration.setup();
72
+ appLogger.debugTime("refreshRuntimeAssets");
73
+ try {
74
+ for (const processor of this.appConfig.processors.values()) {
75
+ await processor.setup();
76
+ }
77
+ for (const integration of this.appConfig.integrations) {
78
+ await integration.setup();
79
+ }
80
+ } finally {
81
+ appLogger.debugTimeEnd("refreshRuntimeAssets");
51
82
  }
83
+ this.appConfig.runtime = {
84
+ ...this.appConfig.runtime ?? {},
85
+ runtimeAssetsPrepared: true
86
+ };
52
87
  }
53
88
  /**
54
89
  * Bundles the server entry file for production use.
55
90
  *
56
91
  * @remarks
57
- * When the project has API endpoints, the entry file must be bundled
58
- * into a single JS file so the production server can start without
59
- * on-the-fly TypeScript transpilation. The bundle is output to
60
- * `dist/{SERVER_BUNDLE_DIR}/{SERVER_BUNDLE_FILENAME}` and used by
61
- * `ecopages start` in production.
92
+ * Every production build emits a runnable server entry for `ecopages start`,
93
+ * including static-only and websocket-only apps. Incremental builds may skip
94
+ * Rolldown when the `.eco/.server-entry` cache is still valid.
62
95
  *
63
96
  * Package imports remain external so native addons and runtime-owned
64
97
  * dependencies continue to load through the app's installed
65
98
  * `node_modules` tree. Only the app entry graph is bundled.
66
99
  *
67
- * Skips silently when no API endpoints are registered (static-only site).
68
100
  * Throws if the build adapter is unavailable, is owned by a host
69
101
  * runtime, or bundling fails.
70
102
  *
71
103
  * @throws If the build adapter is unavailable, is host-owned, or bundling fails.
72
104
  */
73
- async bundleServerEntry() {
74
- if (this.apiHandlers.length === 0) {
75
- return;
76
- }
105
+ async bundleServerEntry(options) {
77
106
  const buildAdapter = getAppBuildAdapter(this.appConfig);
78
107
  if (buildAdapter.ownership === "vite-host") {
79
108
  throw new Error(
@@ -86,24 +115,49 @@ class ServerStaticBuilder {
86
115
  `Cannot bundle server entry: file "${this.entryFile}" not found in "${this.appConfig.rootDir}".`
87
116
  );
88
117
  }
89
- const distDir = this.appConfig.absolutePaths?.distDir ?? path.join(this.appConfig.rootDir, this.appConfig.distDir);
90
- const serverOutdir = path.join(distDir, SERVER_BUNDLE_DIR);
91
- this.logger.info("Bundling server entry file...");
92
- const result = await buildAdapter.build({
93
- entrypoints: [entryPath],
94
- outdir: serverOutdir,
95
- naming: SERVER_BUNDLE_FILENAME,
96
- target: "node",
97
- format: "esm",
98
- sourcemap: "hidden",
99
- externalPackages: true,
100
- root: this.appConfig.rootDir
118
+ const { serverOutdir, serverEntryPath } = getServerBundleOutputPaths(this.appConfig);
119
+ const cached = lookupServerEntryBuildCache({
120
+ appConfig: this.appConfig,
121
+ entryPath,
122
+ force: options?.force
101
123
  });
124
+ if (cached) {
125
+ const hasBundle = cached.outputPaths.some(
126
+ (outputPath) => path.resolve(outputPath) === path.resolve(serverEntryPath) && fileSystem.exists(outputPath)
127
+ );
128
+ if (hasBundle) {
129
+ this.logger.info("Reusing cached server entry bundle");
130
+ writeServerBundleDeployManifest(this.appConfig, serverEntryPath);
131
+ return;
132
+ }
133
+ }
134
+ this.logger.info("Bundling server entry file...");
135
+ const result = await build(
136
+ {
137
+ entrypoints: [entryPath],
138
+ outdir: serverOutdir,
139
+ naming: SERVER_BUNDLE_FILENAME,
140
+ target: "node",
141
+ format: "esm",
142
+ sourcemap: "hidden",
143
+ externalPackages: true,
144
+ root: this.appConfig.rootDir
145
+ },
146
+ getInstalledServerEntryBuildExecutor(this.appConfig)
147
+ );
102
148
  if (!result.success) {
103
149
  const errorMessages = result.logs.map((log) => log.message).join("\n");
104
150
  throw new Error(`Failed to bundle server entry file:
105
151
  ${errorMessages}`);
106
152
  }
153
+ const outputPaths = result.outputs.length > 0 ? result.outputs.map((output) => output.path) : fileSystem.exists(serverEntryPath) ? [serverEntryPath] : [];
154
+ recordServerEntryBuildCache({
155
+ appConfig: this.appConfig,
156
+ entryPath,
157
+ buildResult: result,
158
+ outputPaths
159
+ });
160
+ writeServerBundleDeployManifest(this.appConfig, serverEntryPath);
107
161
  this.logger.info("Server entry file bundled successfully");
108
162
  }
109
163
  /**
@@ -114,16 +168,18 @@ ${errorMessages}`);
114
168
  * @param dependencies.staticRoutes - Explicit static routes registered via app.static()
115
169
  */
116
170
  async build(options, dependencies) {
117
- const { preview = false, baseUrl: explicitBaseUrl } = options ?? {};
171
+ const { preview = false, baseUrl: explicitBaseUrl, force = false } = options ?? {};
118
172
  const baseUrl = explicitBaseUrl ?? `http://${this.serveOptions.hostname || DEFAULT_ECOPAGES_HOSTNAME}:${this.serveOptions.port || DEFAULT_ECOPAGES_PORT}`;
119
- this.prepareExportDirectory();
173
+ const preserveExportDirectory = this.prepareExportDirectory(force);
120
174
  await this.refreshRuntimeAssets();
121
- await this.bundleServerEntry();
175
+ await this.bundleServerEntry({ force });
122
176
  await this.staticSiteGenerator.run({
123
177
  router: dependencies.router,
124
178
  baseUrl,
125
179
  routeRendererFactory: dependencies.routeRendererFactory,
126
- staticRoutes: dependencies.staticRoutes
180
+ staticRoutes: dependencies.staticRoutes,
181
+ force,
182
+ preserveExportDirectory
127
183
  });
128
184
  if (!preview) {
129
185
  this.logger.info("Build completed");
@@ -56,5 +56,6 @@ export declare abstract class SharedHmrManager implements IHmrManager {
56
56
  protected emitStrictEntrypoint(entrypointPath: string): Promise<void>;
57
57
  protected emitScriptEntrypoint(entrypointPath: string, outputPath: string): Promise<void>;
58
58
  stop(): void;
59
+ [Symbol.dispose](): void;
59
60
  }
60
61
  export {};
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { RESOLVED_ASSETS_DIR } from "../../config/constants.js";
5
- import { getAppBuildExecutor } from "../../build/build-adapter.js";
5
+ import { getAppHmrBuildExecutor } from "../../build/build-adapter.js";
6
6
  import { fileSystem } from "@ecopages/file-system";
7
7
  import { HmrStrategyType } from "../../hmr/hmr-strategy.js";
8
8
  import { DefaultHmrStrategy } from "../../hmr/strategies/default-hmr-strategy.js";
@@ -193,7 +193,7 @@ class SharedHmrManager {
193
193
  getSrcDir: () => this.appConfig.absolutePaths.srcDir,
194
194
  getLayoutsDir: () => this.appConfig.absolutePaths.layoutsDir,
195
195
  getPagesDir: () => this.appConfig.absolutePaths.pagesDir,
196
- getBuildExecutor: () => getAppBuildExecutor(this.appConfig),
196
+ getBuildExecutor: () => getAppHmrBuildExecutor(this.appConfig),
197
197
  getBrowserBundleService: () => this.browserBundleService,
198
198
  getEntrypointDependencyGraph: () => this.entrypointDependencyGraph,
199
199
  importServerModule: async (filePath) => await this.serverModuleTranspiler.importModule({
@@ -238,6 +238,16 @@ class SharedHmrManager {
238
238
  appLogger.error(`[HMR] Generic script entrypoint build failed for ${entrypointPath}:`, buildResult.logs);
239
239
  return;
240
240
  }
241
+ if (!fileSystem.exists(outputPath) && buildResult.outputs.length > 0) {
242
+ const resolvedOutputPath = path.resolve(outputPath);
243
+ const emittedOutput = buildResult.outputs.find((output) => path.resolve(output.path) === resolvedOutputPath)?.path ?? buildResult.outputs.find((output) => path.basename(output.path) === path.basename(outputPath))?.path;
244
+ if (emittedOutput && fileSystem.exists(emittedOutput)) {
245
+ fileSystem.ensureDir(path.dirname(outputPath));
246
+ if (path.resolve(emittedOutput) !== resolvedOutputPath) {
247
+ fileSystem.copyFile(emittedOutput, outputPath);
248
+ }
249
+ }
250
+ }
241
251
  const entrypointDependencies = buildResult.dependencyGraph?.entrypoints?.[entrypointPath];
242
252
  if (entrypointDependencies) {
243
253
  this.entrypointDependencyGraph.setEntrypointDependencies(entrypointPath, entrypointDependencies);
@@ -253,6 +263,9 @@ class SharedHmrManager {
253
263
  this.entrypointDependencyGraph.reset();
254
264
  this.plugins = [];
255
265
  }
266
+ [Symbol.dispose]() {
267
+ this.stop();
268
+ }
256
269
  }
257
270
  export {
258
271
  SharedHmrManager,
@@ -10,7 +10,8 @@ Three concentric shapes, plus a serializer and a plugin injector:
10
10
  | -------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
11
  | `BuildAdapter` | `build-adapter.ts` | Low-level backend. Two implementations: the bundled adapter (the real bundler) and `ViteHostBuildAdapter` (a host-owned boundary marker that throws on direct use). |
12
12
  | `BuildExecutor` | `build-adapter.ts` | Narrower runtime facade. Only `build` is exposed. Stored on `appConfig.runtime.buildExecutor`. |
13
- | `SerializedBuildExecutor` | `serialized-build-executor.ts` | FIFO queue around any `BuildExecutor`. Used by the dev watch pipeline. |
13
+ | `SerializedBuildExecutor` | `serialized-build-executor.ts` | FIFO queue around any `BuildExecutor`. Used when strict single-flight ordering is required. |
14
+ | `ParallelBuildExecutor` | `parallel-build-executor.ts` | Concurrency-limited wrapper for independent route-module and HMR browser builds. |
14
15
  | `withBuildExecutorPlugins` | `build-adapter.ts` | Merges app-owned plugins into every `build` call. The single point of plugin injection. |
15
16
 
16
17
  Plus one translation bridge:
@@ -24,7 +25,8 @@ Plus one translation bridge:
24
25
  - `rolldown-build-adapter.ts`: the production `BuildAdapter`. Wraps the bundler and exposes a normalized `BuildResult` (outputs, dependency graph, logs).
25
26
  - `rolldown-plugin-bridge.ts`: `EcoBuildPlugin[]` → bundler-plugin translation.
26
27
  - `serialized-build-executor.ts`: FIFO queue primitive.
27
- - `runtime-build-executor.ts`: dev-watch entrypoint that wraps the app-owned adapter in a `SerializedBuildExecutor + withBuildExecutorPlugins` chain.
28
+ - `runtime-build-executor.ts`: server-adapter entrypoint that wraps the app-owned adapter in `ParallelBuildExecutor` layers plus `withBuildExecutorPlugins`. Server-entry bundling uses `getInstalledServerEntryBuildExecutor()` (serialized, single-flight, cached on `appConfig.runtime.serverEntryBuildExecutor`).
29
+ - `server-entry-build-cache.ts`: production server-entry bundle cache (`.eco/.server-entry/.build-cache.json` + `dist/.server/manifest.json`).
28
30
  - `*.test.ts`: regression coverage.
29
31
 
30
32
  ## Default Flow
@@ -35,8 +37,10 @@ When a server adapter initializes, it calls `installAppRuntimeBuildExecutor(appC
35
37
 
36
38
  ```
37
39
  BuildExecutor
38
- └─ SerializedBuildExecutor // FIFO queue
39
- └─ withBuildExecutorPlugins // injects app plugins
40
+ ├─ ParallelBuildExecutor (route modules) // limited concurrency
41
+ ├─ ParallelBuildExecutor (HMR browser) // separate pool, max 3
42
+ └─ SerializedBuildExecutor (server entry) // single-flight when used directly
43
+ └─ withBuildExecutorPlugins
40
44
  └─ BuildAdapter (bundled adapter or Vite-host)
41
45
  ```
42
46
 
@@ -81,6 +85,42 @@ Plugin ordering: the bundler's `resolveId` and `load` are "first" hooks. The bri
81
85
 
82
86
  These fields are kept in the type so existing call-sites compile. The proper fix is a more focused `BuildOptions` schema in a follow-up.
83
87
 
88
+ ## Dev / watch path
89
+
90
+ `installAppRuntimeBuildExecutor` wraps the app-owned `BuildAdapter` in two `ParallelBuildExecutor` pools (route modules and HMR browser scripts). Both pools share one plugin-wrapped inner executor today.
91
+
92
+ `RolldownDevBuildAdapter` (DevEngine-backed incremental rebuilds) is available via `createBuildAdapter({ ownership: 'rolldown-dev' })` but is **not** yet selected by `installAppRuntimeBuildExecutor`. HMR browser bundles in `browser-bundle.service.ts` may construct a dedicated dev adapter where needed.
93
+
94
+ `ProjectWatcher` deduplicates watch roots, ignores `.eco/` and `dist/`, and coalesces duplicate chokidar events within 150ms.
95
+
96
+ ## Production build caches
97
+
98
+ Two persisted cache layers accelerate production builds. Both use `.build-cache.json` manifests keyed by dependency hashes and a build-inputs fingerprint.
99
+
100
+ | Cache | On-disk location | Module |
101
+ | -------------------------------------- | --------------------------------------------------- | ----------------------------------- |
102
+ | Server-entry bundle | `.eco/.server-entry/.build-cache.json` | `server-entry-build-cache.ts` |
103
+ | Route-module transpile + static render | `<server-outdir>/.server-modules/.build-cache.json` | `route-module-build-cache.store.ts` |
104
+
105
+ `getInstalledServerEntryBuildExecutor()` returns one `SerializedBuildExecutor` per app instance (stored on `appConfig.runtime`). `clearProductionBuildCaches()` wipes both manifest trees and resets in-memory route-module state.
106
+
107
+ The route-module registry (`route-module-build-cache-registry.ts`) shares one `RouteModuleBuildCache` per `(app, outdir)` pair. Legacy `.server-route-modules` outdirs are still read for migration but new writes go to `.server-modules`.
108
+
109
+ ## Unified pages graph
110
+
111
+ Production static exports compile all template pages in one Rolldown invocation when `shouldBuildPagesUnifiedGraph()` is true (default in production; opt out with `ECOPAGES_UNIFIED_PAGES_GRAPH=0`).
112
+
113
+ | Artifact | Location |
114
+ | -------------- | ----------------------------------------------------- |
115
+ | Graph manifest | `.eco/.server-pages-graph/.build-cache.json` |
116
+ | Chunk outputs | `.eco/.server-modules/` (shared with per-route cache) |
117
+
118
+ `StaticSiteGenerator` calls `ensurePagesUnifiedGraphBuilt()` before the export loop. `PageModuleImportService` imports prebuilt chunks via `importPagesUnifiedGraphModule()` and falls back to per-page Rolldown on miss.
119
+
120
+ `ECOPAGES_ROLLDOWN_BUILD_METRICS=1` enables `rolldown-build-invocation-metrics.ts` counters used by bench and parity tests.
121
+
122
+ Build-input fingerprinting lives in `build-input-fingerprint.ts` and is shared with server-entry cache, unified pages graph, and static-render invalidation.
123
+
84
124
  ## Testing Strategy
85
125
 
86
126
  - `rolldown-build-adapter.test.ts` covers the adapter's `build`, `resolve`, `getTranspileOptions`, and dependency-graph extraction end-to-end.
@@ -16,6 +16,7 @@
16
16
  * the `set*` counterparts) — the supported way for runtime code to read
17
17
  * and mutate the active adapter per `EcoPagesAppConfig`.
18
18
  */
19
+ import type { EcoSourceTransform } from '../plugins/source-transform.js';
19
20
  import type { EcoBuildPlugin } from './build-types.js';
20
21
  import { type AppBuildManifest } from './build-manifest.js';
21
22
  import type { EcoPagesAppConfig } from '../types/internal-types.js';
@@ -191,6 +192,20 @@ export interface BuildOptions {
191
192
  * bridge.
192
193
  */
193
194
  plugins?: EcoBuildPlugin[];
195
+ /**
196
+ * App-owned source transforms for browser-targeted Rolldown builds.
197
+ *
198
+ * @remarks
199
+ * Applied by the Rolldown plugin bridge after first-wins `onLoad` plugins
200
+ * produce module contents. This is the canonical browser/HMR path for
201
+ * `eco-component-meta` and other transforms registered in
202
+ * `appConfig.sourceTransforms`. Server builds continue to use loader plugins
203
+ * instead; this field is ignored unless `target` is `'browser'`.
204
+ *
205
+ * {@link BrowserBundleService} forwards {@link getAppSourceTransforms} here
206
+ * automatically.
207
+ */
208
+ sourceTransforms?: EcoSourceTransform[];
194
209
  /**
195
210
  * Escape hatch for backends that need to forward unknown options
196
211
  * to their underlying driver. Consumers should prefer the typed
@@ -468,6 +483,10 @@ export declare function getAppServerBuildPlugins(appConfig: EcoPagesAppConfig):
468
483
  * Reads from the app's sealed build manifest. The browser-bundle
469
484
  * manifest is the source of truth for which plugins participate in the
470
485
  * browser bundle.
486
+ *
487
+ * Plugins whose `name` matches a registered {@link EcoSourceTransform} are
488
+ * excluded here because browser builds run those transforms via the Rolldown
489
+ * bridge post-load pass instead of as competing `onLoad` handlers.
471
490
  */
472
491
  export declare function getAppBrowserBuildPlugins(appConfig: EcoPagesAppConfig): EcoBuildPlugin[];
473
492
  /**
@@ -476,12 +495,18 @@ export declare function getAppBrowserBuildPlugins(appConfig: EcoPagesAppConfig):
476
495
  * @remarks
477
496
  * Falls back to {@link getAppBuildAdapter} when no executor is set
478
497
  * on the runtime yet. The dev-watch pipeline replaces this value with
479
- * a {@link SerializedBuildExecutor} via
498
+ * a parallel route-module executor via
480
499
  * {@link installAppRuntimeBuildExecutor}.
481
500
  */
482
501
  export declare function getAppBuildExecutor(appConfig: EcoPagesAppConfig): BuildExecutor;
483
- /** Installs the executor that should serve future builds for one app instance. */
502
+ /** Returns the HMR browser-bundle executor when installed. */
503
+ export declare function getAppHmrBuildExecutor(appConfig: EcoPagesAppConfig): BuildExecutor;
504
+ /** Route-module executor (page imports, transpile). Falls back to {@link getAppBuildExecutor}. */
505
+ export declare function getAppRouteModuleBuildExecutor(appConfig: EcoPagesAppConfig): BuildExecutor;
506
+ /** Installs the default executor for one app instance (ConfigBuilder / tests). */
484
507
  export declare function setAppBuildExecutor(appConfig: EcoPagesAppConfig, buildExecutor: BuildExecutor): void;
508
+ export declare function setAppHmrBuildExecutor(appConfig: EcoPagesAppConfig, buildExecutor: BuildExecutor): void;
509
+ export declare function setAppRouteModuleBuildExecutor(appConfig: EcoPagesAppConfig, buildExecutor: BuildExecutor): void;
485
510
  /**
486
511
  * Runs a build through the active pipeline.
487
512
  *