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

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 (102) hide show
  1. package/package.json +26 -2
  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 +8 -2
  31. package/src/build/build-adapter.js +71 -33
  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 +5 -0
  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/runtime-build-executor.d.ts +7 -13
  47. package/src/build/runtime-build-executor.js +31 -7
  48. package/src/build/server-entry-build-cache.d.ts +44 -0
  49. package/src/build/server-entry-build-cache.js +169 -0
  50. package/src/hmr/hmr.test.e2e.js +15 -4
  51. package/src/plugins/integration-plugin.d.ts +19 -8
  52. package/src/plugins/integration-plugin.js +7 -2
  53. package/src/plugins/processor.d.ts +5 -0
  54. package/src/plugins/processor.js +7 -0
  55. package/src/route-renderer/GRAPH.md +4 -4
  56. package/src/route-renderer/README.md +6 -5
  57. package/src/route-renderer/orchestration/foreign-subtree-execution.service.js +1 -3
  58. package/src/route-renderer/orchestration/integration-renderer.d.ts +10 -2
  59. package/src/route-renderer/orchestration/integration-renderer.js +32 -12
  60. package/src/route-renderer/orchestration/render-output.utils.d.ts +2 -0
  61. package/src/route-renderer/orchestration/render-output.utils.js +4 -0
  62. package/src/route-renderer/orchestration/route-render-orchestrator.d.ts +2 -2
  63. package/src/route-renderer/orchestration/route-render-orchestrator.js +17 -16
  64. package/src/router/client/navigation-coordinator.js +10 -0
  65. package/src/services/assets/asset-processing-service/browser-runtime-asset.factory.d.ts +1 -0
  66. package/src/services/assets/asset-processing-service/browser-runtime-asset.factory.js +1 -0
  67. package/src/services/assets/asset-processing-service/browser-runtime-entry.factory.d.ts +4 -2
  68. package/src/services/assets/asset-processing-service/browser-runtime-entry.factory.js +27 -7
  69. package/src/services/assets/asset-processing-service/processors/script/node-module-script.processor.js +17 -3
  70. package/src/services/assets/browser-bundle.service.d.ts +1 -0
  71. package/src/services/assets/browser-bundle.service.js +9 -3
  72. package/src/services/html/html-transformer.service.d.ts +1 -1
  73. package/src/services/html/html-transformer.service.js +4 -4
  74. package/src/services/invalidation/development-invalidation.service.d.ts +10 -1
  75. package/src/services/invalidation/development-invalidation.service.js +29 -0
  76. package/src/services/module-loading/README.md +49 -0
  77. package/src/services/module-loading/app-server-module-transpiler.service.js +4 -17
  78. package/src/services/module-loading/page-module-import.service.d.ts +4 -1
  79. package/src/services/module-loading/page-module-import.service.js +52 -29
  80. package/src/services/module-loading/route-module-build-cache-registry.d.ts +24 -0
  81. package/src/services/module-loading/route-module-build-cache-registry.js +37 -0
  82. package/src/services/module-loading/route-module-build-cache.d.ts +4 -0
  83. package/src/services/module-loading/route-module-build-cache.js +38 -0
  84. package/src/services/module-loading/route-module-build-cache.store.d.ts +57 -0
  85. package/src/services/module-loading/route-module-build-cache.store.js +203 -0
  86. package/src/services/module-loading/route-module-build-manifest.d.ts +61 -0
  87. package/src/services/module-loading/route-module-build-manifest.js +124 -0
  88. package/src/services/module-loading/route-module-dependency-hasher.d.ts +81 -0
  89. package/src/services/module-loading/route-module-dependency-hasher.js +121 -0
  90. package/src/services/module-loading/server-module-transpiler.service.js +1 -1
  91. package/src/static-site-generator/README.md +33 -0
  92. package/src/static-site-generator/static-build-invalidation.d.ts +10 -0
  93. package/src/static-site-generator/static-build-invalidation.js +78 -0
  94. package/src/static-site-generator/static-export-context.d.ts +22 -0
  95. package/src/static-site-generator/static-export-context.js +0 -0
  96. package/src/static-site-generator/static-site-generator.d.ts +17 -8
  97. package/src/static-site-generator/static-site-generator.js +176 -62
  98. package/src/types/internal-types.d.ts +8 -0
  99. package/src/utils/parse-cli-args.d.ts +1 -0
  100. package/src/utils/parse-cli-args.js +3 -0
  101. package/src/watchers/project-watcher.d.ts +8 -0
  102. package/src/watchers/project-watcher.js +32 -12
@@ -10,15 +10,12 @@ import { OwnershipValidationService } from "./ownership-validation.service.js";
10
10
  import {
11
11
  RouteRenderOrchestrator
12
12
  } from "./route-render-orchestrator.js";
13
- import { normalizeUnresolvedMarkerArtifactHtml } from "./render-output.utils.js";
13
+ import { normalizeUnresolvedMarkerArtifactHtml, isMarkupNodeLike } from "./render-output.utils.js";
14
14
  import {
15
15
  ForeignSubtreeExecutionService
16
16
  } from "./foreign-subtree-execution.service.js";
17
17
  import {} from "./queued-foreign-subtree-resolution.service.js";
18
18
  import { buildProcessedAssetDedupeKey } from "./processed-asset-dedupe.js";
19
- function isMarkupNodeLike(value) {
20
- return typeof value === "object" && value !== null && "nodeType" in value && typeof value.nodeType === "number" && "outerHTML" in value && typeof value.outerHTML === "string";
21
- }
22
19
  class IntegrationRenderer {
23
20
  appConfig;
24
21
  assetProcessingService;
@@ -31,7 +28,22 @@ class IntegrationRenderer {
31
28
  pageModuleLoaderService;
32
29
  routeRenderOrchestrator;
33
30
  foreignSubtreeExecutionService = new ForeignSubtreeExecutionService();
31
+ /**
32
+ * Serializes route and view renders that mutate `htmlTransformer` state.
33
+ *
34
+ * Integration renderers are cached per integration, so concurrent static builds
35
+ * and overlapping SSR requests must not share one transformer page package.
36
+ */
37
+ renderExclusiveChain = Promise.resolve();
34
38
  DOC_TYPE = "<!DOCTYPE html>";
39
+ runRenderExclusive(operation) {
40
+ const run = this.renderExclusiveChain.then(operation, operation);
41
+ this.renderExclusiveChain = run.then(
42
+ () => void 0,
43
+ () => void 0
44
+ );
45
+ return run;
46
+ }
35
47
  /**
36
48
  * Loads one route module through the owning renderer's import path.
37
49
  *
@@ -397,7 +409,7 @@ class IntegrationRenderer {
397
409
  * @returns Structured component render result for orchestration paths.
398
410
  */
399
411
  async renderStringComponentWithSerializedChildren(input, component) {
400
- const serializedChildren = input.children === void 0 ? void 0 : typeof input.children === "string" ? input.children : isMarkupNodeLike(input.children) ? input.children.outerHTML : void 0;
412
+ const serializedChildren = input.children === void 0 ? void 0 : typeof input.children === "string" ? input.children : isMarkupNodeLike(input.children) && typeof input.children.outerHTML === "string" ? input.children.outerHTML : void 0;
401
413
  if (input.children !== void 0 && serializedChildren === void 0) {
402
414
  const componentFile = input.component.config?.__eco?.file ?? "unknown component";
403
415
  const childTag = Object.prototype.toString.call(input.children);
@@ -533,7 +545,7 @@ class IntegrationRenderer {
533
545
  * @returns The imported module.
534
546
  */
535
547
  async importPageFile(file, options) {
536
- const bypassCache = options?.bypassCache ?? (typeof Bun !== "undefined" && process.env.NODE_ENV === "development");
548
+ const bypassCache = options?.bypassCache ?? false;
537
549
  const pageModule = this.usesIntegrationPageImporter(file) ? await this.importIntegrationPageFile(file, {
538
550
  bypassCache,
539
551
  cacheScope: options?.cacheScope
@@ -633,7 +645,7 @@ class IntegrationRenderer {
633
645
  resolveRoutePageComponentRender: (input) => this.resolveRoutePageComponentRender(input),
634
646
  renderRouteBody: (renderOptions) => this.renderRouteBody(renderOptions),
635
647
  getRouteHtmlFinalization: (renderOptions) => this.getRouteHtmlFinalization(renderOptions),
636
- transformRouteResponse: (response, htmlContributions) => this.transformRouteResponse(response, htmlContributions)
648
+ transformRouteResponse: (response, htmlContributions, pagePackage) => this.transformRouteResponse(response, htmlContributions, pagePackage)
637
649
  };
638
650
  }
639
651
  async resolveRouteRenderInputs(routeOptions) {
@@ -706,8 +718,13 @@ class IntegrationRenderer {
706
718
  }
707
719
  };
708
720
  }
709
- async transformRouteResponse(response, htmlContributions) {
710
- const transformedResponse = await this.htmlTransformer.transform(response, htmlContributions);
721
+ async transformRouteResponse(response, htmlContributions, pagePackage) {
722
+ const resolvedPagePackage = this.htmlTransformer.getPagePackage() ?? pagePackage;
723
+ const transformedResponse = await this.htmlTransformer.transform(
724
+ response,
725
+ htmlContributions,
726
+ resolvedPagePackage
727
+ );
711
728
  return transformedResponse.body ?? await transformedResponse.text();
712
729
  }
713
730
  /**
@@ -752,9 +769,12 @@ class IntegrationRenderer {
752
769
  * @returns Rendered route body plus effective cache strategy.
753
770
  */
754
771
  async execute(options) {
755
- const adapter = this.createRouteRenderOrchestratorAdapter();
756
- const renderOptions = await this.prepareRenderOptions(options, adapter);
757
- return this.routeRenderOrchestrator.executePrepared(renderOptions, adapter);
772
+ return this.runRenderExclusive(async () => {
773
+ this.htmlTransformer.setProcessedDependencies([]);
774
+ const adapter = this.createRouteRenderOrchestratorAdapter();
775
+ const renderOptions = await this.prepareRenderOptions(options, adapter);
776
+ return this.routeRenderOrchestrator.executePrepared(renderOptions, adapter);
777
+ });
758
778
  }
759
779
  /**
760
780
  * Finalizes already-resolved HTML for explicit renderer-owned paths.
@@ -21,6 +21,8 @@ type MarkupNodeLikeShape = {
21
21
  * @typeParam T Expected resolved type of the thenable.
22
22
  */
23
23
  export declare function isThenable<T>(value: unknown): value is PromiseLike<T>;
24
+ /** Returns whether a value looks like a DOM-like markup node from SSR integrations. */
25
+ export declare function isMarkupNodeLike(value: unknown): value is MarkupNodeLikeShape;
24
26
  /**
25
27
  * Injects `data-eco-trigger` into the first real HTML element opening tag of
26
28
  * a component's rendered output string.
@@ -82,6 +82,9 @@ function isTemplateContentShape(value) {
82
82
  function isMarkupNodeLikeShape(value) {
83
83
  return typeof value === "object" && value !== null && typeof value.nodeType === "number";
84
84
  }
85
+ function isMarkupNodeLike(value) {
86
+ return isMarkupNodeLikeShape(value);
87
+ }
85
88
  function injectTriggerAttributeIntoString(content, triggerId) {
86
89
  const str = content;
87
90
  let i = 0;
@@ -165,6 +168,7 @@ export {
165
168
  addTriggerAttribute,
166
169
  decodeHtmlEntities,
167
170
  inspectUnresolvedMarkerArtifactHtml,
171
+ isMarkupNodeLike,
168
172
  isThenable,
169
173
  normalizeUnresolvedMarkerArtifactHtml,
170
174
  wrapWithScriptsInjector
@@ -1,5 +1,5 @@
1
1
  import type { EcoPagesAppConfig } from '../../types/internal-types.js';
2
- import type { ComponentRenderResult, EcoComponent, EcoPageComponent, EcoPageFile, HtmlTemplateProps, IntegrationRendererRenderOptions, PageBrowserGraphContribution, PageBrowserGraphResult, PageMetadataProps, RouteRendererBody, RouteRendererOptions, RouteRenderResult } from '../../types/public-types.js';
2
+ import type { ComponentRenderResult, EcoComponent, EcoPageComponent, EcoPageFile, HtmlTemplateProps, IntegrationRendererRenderOptions, PageBrowserGraphContribution, PageBrowserGraphResult, PageMetadataProps, PagePackageResult, RouteRendererBody, RouteRendererOptions, RouteRenderResult } from '../../types/public-types.js';
3
3
  import { type AssetProcessingService, type ProcessedAsset } from '../../services/assets/asset-processing-service/index.js';
4
4
  import type { HtmlDocumentContribution } from '../../services/html/html-transformer.service.js';
5
5
  import { OwnershipValidationService } from './ownership-validation.service.js';
@@ -64,7 +64,7 @@ export interface RouteRenderOrchestratorAdapter<C> {
64
64
  /**
65
65
  * Runs SSR-policy response transformation and returns the body value exposed to callers.
66
66
  */
67
- transformRouteResponse(response: Response, htmlContributions?: HtmlDocumentContribution[]): Promise<RouteRendererBody>;
67
+ transformRouteResponse(response: Response, htmlContributions?: HtmlDocumentContribution[], pagePackage?: PagePackageResult): Promise<RouteRendererBody>;
68
68
  }
69
69
  /**
70
70
  * Captured route-render output in both replayable body and string HTML forms.
@@ -205,21 +205,20 @@ class RouteRenderOrchestrator {
205
205
  return result.assetsByRoute;
206
206
  }
207
207
  const cacheKey = input.integrationName;
208
- const cached = this.groupedPageBrowserGraphCache.get(cacheKey);
209
- if (cached) {
210
- return await cached;
211
- }
212
- const pendingGroupedAssets = this.buildGroupedPageBrowserAssets(input, currentContribution).then((result) => {
213
- if (result.hasCollectionFailures) {
208
+ let pending = this.groupedPageBrowserGraphCache.get(cacheKey);
209
+ if (!pending) {
210
+ pending = this.buildGroupedPageBrowserAssets(input, currentContribution).then((result) => {
211
+ if (result.hasCollectionFailures) {
212
+ this.groupedPageBrowserGraphCache.delete(cacheKey);
213
+ }
214
+ return result.assetsByRoute;
215
+ }).catch((error) => {
214
216
  this.groupedPageBrowserGraphCache.delete(cacheKey);
215
- }
216
- return result.assetsByRoute;
217
- }).catch((error) => {
218
- this.groupedPageBrowserGraphCache.delete(cacheKey);
219
- throw error;
220
- });
221
- this.groupedPageBrowserGraphCache.set(cacheKey, pendingGroupedAssets);
222
- return await pendingGroupedAssets;
217
+ throw error;
218
+ });
219
+ this.groupedPageBrowserGraphCache.set(cacheKey, pending);
220
+ }
221
+ return await pending;
223
222
  }
224
223
  /**
225
224
  * Builds the shared grouped page-browser asset map for one integration.
@@ -362,7 +361,8 @@ class RouteRenderOrchestrator {
362
361
  "Content-Type": "text/html"
363
362
  }
364
363
  }),
365
- htmlFinalization.htmlContributions
364
+ htmlFinalization.htmlContributions,
365
+ renderOptions.pagePackage
366
366
  );
367
367
  return {
368
368
  body: body2,
@@ -376,7 +376,8 @@ class RouteRenderOrchestrator {
376
376
  "Content-Type": "text/html"
377
377
  }
378
378
  }),
379
- htmlFinalization.htmlContributions
379
+ htmlFinalization.htmlContributions,
380
+ renderOptions.pagePackage
380
381
  );
381
382
  return {
382
383
  body,
@@ -198,6 +198,16 @@ function createEcoNavigationRuntime() {
198
198
  await runtime.cleanupOwner(owner);
199
199
  }
200
200
  };
201
+ if (typeof window !== "undefined") {
202
+ window.addEventListener("pageshow", (event) => {
203
+ if (event.persisted) {
204
+ runtime.cancelCurrentNavigationTransaction();
205
+ }
206
+ });
207
+ window.addEventListener("pagehide", () => {
208
+ runtime.cancelCurrentNavigationTransaction();
209
+ });
210
+ }
201
211
  return runtime;
202
212
  }
203
213
  function getEcoNavigationRuntime(windowObject = window) {
@@ -45,6 +45,7 @@ export declare function createBrowserRuntimeModuleAsset(options: {
45
45
  fileName: string;
46
46
  cacheDirName?: string;
47
47
  rootDir?: string;
48
+ workDir?: string;
48
49
  bundleOptions?: {
49
50
  define?: Record<string, string>;
50
51
  minify?: boolean;
@@ -31,6 +31,7 @@ function createBrowserRuntimeModuleAsset(options) {
31
31
  modules: options.modules,
32
32
  fileName: `${options.name}-entry.mjs`,
33
33
  rootDir: options.rootDir,
34
+ workDir: options.workDir,
34
35
  cacheDirName: options.cacheDirName
35
36
  });
36
37
  return createBrowserRuntimeScriptAsset({
@@ -9,12 +9,14 @@ export type BrowserRuntimeEntryModuleConfig = {
9
9
  * @remarks
10
10
  * Integrations use this helper when they need a browser runtime asset composed
11
11
  * from multiple bare specifiers but do not want to own temporary file assembly
12
- * logic themselves. The generated file lives under a cache directory inside the
13
- * consuming app root so repeated runs can reuse the same location.
12
+ * logic themselves. The generated file lives under the app work directory so
13
+ * repeated runs can reuse the same location without placing sources inside
14
+ * `node_modules`, which can cause bundlers to externalize bare imports.
14
15
  */
15
16
  export declare function createBrowserRuntimeEntryModule(options: {
16
17
  modules: BrowserRuntimeEntryModuleConfig[];
17
18
  fileName: string;
18
19
  rootDir?: string;
20
+ workDir?: string;
19
21
  cacheDirName?: string;
20
22
  }): string;
@@ -1,37 +1,57 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createRequire } from "node:module";
4
+ import { DEFAULT_ECOPAGES_WORK_DIR } from "../../../config/constants.js";
4
5
  function createBrowserRuntimeEntryModule(options) {
6
+ if (options.modules.some((module) => !module.specifier.startsWith("node:")) && !options.rootDir) {
7
+ throw new Error("createBrowserRuntimeEntryModule requires rootDir to resolve package specifiers");
8
+ }
5
9
  const rootDir = options.rootDir ?? process.cwd();
10
+ const workDir = options.workDir ?? path.join(rootDir, DEFAULT_ECOPAGES_WORK_DIR);
6
11
  const artifactsDir = path.join(
7
- rootDir,
8
- "node_modules",
9
- ".cache",
12
+ workDir,
13
+ ".browser-runtime-entries",
10
14
  options.cacheDirName ?? "ecopages-browser-runtime"
11
15
  );
12
16
  fs.mkdirSync(artifactsDir, { recursive: true });
13
17
  const requireFromRoot = createRequire(path.join(rootDir, "package.json"));
14
18
  const seenExports = /* @__PURE__ */ new Set();
15
19
  const statements = [];
20
+ const filePath = path.join(artifactsDir, options.fileName);
21
+ const entryDir = path.dirname(filePath);
16
22
  for (const module of options.modules) {
23
+ const importSpecifier = resolveEntryImportSpecifier(module.specifier, requireFromRoot, entryDir);
17
24
  if (module.defaultExport) {
18
- statements.push(`import __ecopages_default_export__ from '${module.specifier}';`);
25
+ statements.push(`import __ecopages_default_export__ from '${importSpecifier}';`);
19
26
  statements.push("export default __ecopages_default_export__;");
20
27
  }
21
28
  const exportNames = getModuleExportNames(module.specifier, requireFromRoot).filter(
22
29
  (name) => !seenExports.has(name)
23
30
  );
24
31
  if (exportNames.length > 0) {
25
- statements.push(`export { ${exportNames.join(", ")} } from '${module.specifier}';`);
32
+ statements.push(`export { ${exportNames.join(", ")} } from '${importSpecifier}';`);
26
33
  for (const exportName of exportNames) {
27
34
  seenExports.add(exportName);
28
35
  }
29
36
  }
30
37
  }
31
- const filePath = path.join(artifactsDir, options.fileName);
32
- fs.writeFileSync(filePath, statements.join("\n"), "utf-8");
38
+ const content = statements.join("\n");
39
+ if (!fs.existsSync(filePath) || fs.readFileSync(filePath, "utf-8") !== content) {
40
+ fs.writeFileSync(filePath, content, "utf-8");
41
+ }
33
42
  return filePath;
34
43
  }
44
+ function resolveEntryImportSpecifier(specifier, requireFromRoot, entryDir) {
45
+ if (specifier.startsWith("node:") || specifier.startsWith("file:")) {
46
+ return specifier;
47
+ }
48
+ const resolvedPath = requireFromRoot.resolve(specifier);
49
+ let relativePath = path.relative(entryDir, resolvedPath).replace(/\\/g, "/");
50
+ if (!relativePath.startsWith(".")) {
51
+ relativePath = `./${relativePath}`;
52
+ }
53
+ return relativePath;
54
+ }
35
55
  function getModuleExportNames(specifier, requireFromRoot) {
36
56
  const moduleExports = requireFromRoot(specifier);
37
57
  return Object.keys(moduleExports).filter((name) => name !== "__esModule" && name !== "default").filter((name) => /^[$A-Z_a-z][$\w]*$/.test(name)).sort();
@@ -8,19 +8,33 @@ class NodeModuleScriptProcessor extends BaseScriptProcessor {
8
8
  const modulePath = this.resolveModulePath(dep.importPath, this.appConfig.rootDir);
9
9
  const moduleName = path.basename(modulePath);
10
10
  const filename = dep.name ?? `nm-${moduleName}`;
11
+ const shouldBundle = this.shouldBundle(dep);
11
12
  const configHash = this.generateHash(
12
- JSON.stringify({ inline: dep.inline, minify: !dep.inline && this.isProduction, opts: dep.bundleOptions })
13
+ JSON.stringify({
14
+ bundle: shouldBundle,
15
+ inline: dep.inline,
16
+ minify: shouldBundle && !dep.inline && this.isProduction,
17
+ opts: dep.bundleOptions
18
+ })
13
19
  );
14
20
  const cachekey = `${this.buildCacheKey(filename, this.generateHash(modulePath), dep)}:${configHash}`;
15
21
  return this.getOrProcess(cachekey, async () => {
16
- if (dep.inline) {
22
+ if (dep.inline || !shouldBundle) {
17
23
  const content = fileSystem.readFileAsBuffer(modulePath).toString();
24
+ const filepath = dep.inline ? void 0 : path.join(this.getAssetsDir(), "vendors", filename);
25
+ if (filepath) {
26
+ fileSystem.ensureDir(path.dirname(filepath));
27
+ if (!fileSystem.exists(filepath) || fileSystem.readFileAsBuffer(filepath).toString() !== content) {
28
+ fileSystem.copyFile(modulePath, filepath);
29
+ }
30
+ }
18
31
  return {
32
+ filepath,
19
33
  content,
20
34
  kind: dep.kind,
21
35
  position: dep.position,
22
36
  attributes: dep.attributes,
23
- inline: true,
37
+ inline: dep.inline,
24
38
  excludeFromHtml: dep.excludeFromHtml,
25
39
  packageRole: dep.packageRole,
26
40
  bundledSourceFilepaths: dep.bundledSourceFilepaths
@@ -16,6 +16,7 @@ export type BrowserBundleOptions = {
16
16
  externalPackages?: boolean;
17
17
  external?: string[];
18
18
  plugins?: EcoBuildPlugin[];
19
+ executor?: 'build' | 'hmr';
19
20
  [key: string]: unknown;
20
21
  profile: BuildTranspileProfile;
21
22
  excludeAppBuildPlugins?: string[];
@@ -1,4 +1,9 @@
1
- import { getAppBrowserBuildPlugins, getAppBuildExecutor, getAppTranspileOptions } from "../../build/build-adapter.js";
1
+ import {
2
+ getAppBrowserBuildPlugins,
3
+ getAppBuildExecutor,
4
+ getAppHmrBuildExecutor,
5
+ getAppTranspileOptions
6
+ } from "../../build/build-adapter.js";
2
7
  import { mergeEcoBuildPlugins } from "../../build/build-manifest.js";
3
8
  class BrowserBundleService {
4
9
  appConfig;
@@ -17,7 +22,7 @@ class BrowserBundleService {
17
22
  * site.
18
23
  */
19
24
  async bundle(options) {
20
- const { profile, excludeAppBuildPlugins, plugins, ...rawBuildOptions } = options;
25
+ const { profile, excludeAppBuildPlugins, plugins, executor = "hmr", ...rawBuildOptions } = options;
21
26
  const appBrowserPlugins = getAppBrowserBuildPlugins(this.appConfig);
22
27
  const filteredAppBrowserPlugins = excludeAppBuildPlugins && excludeAppBuildPlugins.length > 0 ? appBrowserPlugins.filter((plugin) => !excludeAppBuildPlugins.includes(plugin.name)) : appBrowserPlugins;
23
28
  const request = {
@@ -26,7 +31,8 @@ class BrowserBundleService {
26
31
  ...getAppTranspileOptions(this.appConfig, profile),
27
32
  plugins: mergeEcoBuildPlugins(plugins, filteredAppBrowserPlugins)
28
33
  };
29
- return await getAppBuildExecutor(this.appConfig).build(request);
34
+ const buildExecutor = executor === "build" ? getAppBuildExecutor(this.appConfig) : getAppHmrBuildExecutor(this.appConfig);
35
+ return await buildExecutor.build(request);
30
36
  }
31
37
  async bundleGroupedEntries(entries, options) {
32
38
  const request = {
@@ -83,7 +83,7 @@ export declare class HtmlTransformerService {
83
83
  * string-based fallback remains in place for runtimes that cannot provide one
84
84
  * of those rewriter implementations.
85
85
  */
86
- transform(res: Response, contributions?: HtmlDocumentContribution[]): Promise<Response>;
86
+ transform(res: Response, contributions?: HtmlDocumentContribution[], pagePackage?: PagePackageResult): Promise<Response>;
87
87
  /**
88
88
  * Splits processed assets into head and body injection groups.
89
89
  */
@@ -188,8 +188,8 @@ class HtmlTransformerService {
188
188
  * string-based fallback remains in place for runtimes that cannot provide one
189
189
  * of those rewriter implementations.
190
190
  */
191
- async transform(res, contributions = []) {
192
- const { head, body } = this.groupDependenciesByPosition();
191
+ async transform(res, contributions = [], pagePackage) {
192
+ const { head, body } = this.groupDependenciesByPosition(pagePackage);
193
193
  const { headPrepend, headAppend, bodyPrepend, bodyAppend } = this.groupContributionsByPlacement(contributions);
194
194
  const htmlRewriter = await this.htmlRewriterProvider.createHtmlRewriter();
195
195
  if (htmlRewriter) {
@@ -239,8 +239,8 @@ class HtmlTransformerService {
239
239
  /**
240
240
  * Splits processed assets into head and body injection groups.
241
241
  */
242
- groupDependenciesByPosition() {
243
- const dependencies = this.pagePackage ? this.resolvePagePackageHtmlDependencies(this.pagePackage) : this.processedDependencies;
242
+ groupDependenciesByPosition(pagePackageOverride) {
243
+ const dependencies = pagePackageOverride ? this.resolvePagePackageHtmlDependencies(pagePackageOverride) : this.pagePackage ? this.resolvePagePackageHtmlDependencies(this.pagePackage) : this.processedDependencies;
244
244
  return dependencies.reduce(
245
245
  (acc, dep) => {
246
246
  if (dep.kind === "script") {
@@ -1,5 +1,5 @@
1
1
  import type { EcoPagesAppConfig } from '../../types/internal-types.js';
2
- export type DevelopmentInvalidationCategory = 'public-asset' | 'additional-watch' | 'include-source' | 'route-source' | 'processor-owned-asset' | 'server-source' | 'other';
2
+ export type DevelopmentInvalidationCategory = 'public-asset' | 'additional-watch' | 'include-source' | 'explicit-server-view' | 'route-source' | 'processor-owned-asset' | 'server-source' | 'other';
3
3
  /**
4
4
  * Framework-owned invalidation plan for one changed file.
5
5
  *
@@ -62,6 +62,15 @@ export declare class DevelopmentInvalidationService {
62
62
  * Returns whether the file is an include/template source file.
63
63
  */
64
64
  isIncludeSourceFile(filePath: string): boolean;
65
+ /**
66
+ * Returns whether the file is an explicit server-rendered view module.
67
+ *
68
+ * @remarks
69
+ * These modules are typically registered through `renderServerModule` in
70
+ * `app.ts` rather than the filesystem router. They need a full browser reload
71
+ * because their HTML is produced on the server, not through client HMR entrypoints.
72
+ */
73
+ isExplicitServerViewFile(filePath: string): boolean;
65
74
  /**
66
75
  * Returns whether the file is a server-executed source module outside the
67
76
  * special route/include buckets.
@@ -60,6 +60,16 @@ class DevelopmentInvalidationService {
60
60
  processorHandledAsset: false
61
61
  };
62
62
  }
63
+ if (this.isExplicitServerViewFile(filePath)) {
64
+ return {
65
+ category: "explicit-server-view",
66
+ invalidateServerModules: true,
67
+ refreshRoutes: false,
68
+ reloadBrowser: true,
69
+ delegateToHmr: false,
70
+ processorHandledAsset: false
71
+ };
72
+ }
63
73
  if (this.isRouteSourceFile(filePath)) {
64
74
  return {
65
75
  category: "route-source",
@@ -148,6 +158,25 @@ class DevelopmentInvalidationService {
148
158
  }
149
159
  return /\.(?:[cm]?ts|[jt]sx?|mdx)$/u.test(resolvedPath);
150
160
  }
161
+ /**
162
+ * Returns whether the file is an explicit server-rendered view module.
163
+ *
164
+ * @remarks
165
+ * These modules are typically registered through `renderServerModule` in
166
+ * `app.ts` rather than the filesystem router. They need a full browser reload
167
+ * because their HTML is produced on the server, not through client HMR entrypoints.
168
+ */
169
+ isExplicitServerViewFile(filePath) {
170
+ const resolvedPath = path.resolve(filePath);
171
+ const viewsDir = path.join(this.appConfig.absolutePaths.srcDir, "views");
172
+ if (resolvedPath !== viewsDir && !resolvedPath.startsWith(`${viewsDir}${path.sep}`)) {
173
+ return false;
174
+ }
175
+ if (this.appConfig.templatesExt.some((extension) => resolvedPath.endsWith(extension))) {
176
+ return true;
177
+ }
178
+ return /\.(?:[cm]?ts|[jt]sx?|mdx)$/u.test(resolvedPath);
179
+ }
151
180
  /**
152
181
  * Returns whether the file is a server-executed source module outside the
153
182
  * special route/include buckets.
@@ -0,0 +1,49 @@
1
+ # Module Loading
2
+
3
+ Server-side source loading for framework-owned page modules, includes, and related templates.
4
+
5
+ ## Import stack
6
+
7
+ ```
8
+ Call site (route scan, renderer, SSG, API)
9
+ └─ AppModuleLoader.importModule() app-server-module-transpiler.service.ts
10
+ ├─ merges server build plugins + JSX ownership plugins
11
+ ├─ resolves route-module BuildExecutor
12
+ └─ PageModuleImportService.importModule()
13
+ ├─ dev + host loader → host runtime (Node only, when configured)
14
+ ├─ production disk cache → route-module build cache
15
+ ├─ unified pages graph → prebuilt chunk import
16
+ └─ Rolldown per-page build → dynamic import of transpiled output
17
+ ```
18
+
19
+ `ServerModuleTranspiler` is a thin wrapper that injects `rootDir`, optional default plugins, and invalidation version into the same `PageModuleImportService` path. App code should prefer `getAppModuleLoader(appConfig)` or `getAppServerModuleTranspiler(appConfig)`.
20
+
21
+ ## Host vs app ownership
22
+
23
+ | Path | When |
24
+ | ----------------------- | -------------------------------------------------------------------------------------------- |
25
+ | Host loader | `appConfig.runtime.hostModuleLoader` set (Vite host) and file supports direct source loading |
26
+ | Ecopages build pipeline | Framework-owned template files under `pages/`, `includes/`, etc. |
27
+
28
+ `shouldAppUseHostModuleLoader()` encodes the directory + extension rules. Bun always uses the build pipeline today.
29
+
30
+ ## Caching layers
31
+
32
+ 1. **In-memory promise cache** (`PageModuleImportService.importCache`) — keyed by file path, resolved `outdir`, JSX/plugin inputs, file hash, and invalidation version. Cleared by `invalidateDevelopmentGraph()`.
33
+ 2. **Disk transpile cache** (`.eco/.server-modules/.build-cache.json`) — production only; see route-module build cache slice.
34
+ 3. **Unified graph manifest** — production static export fast path; see build layer docs.
35
+
36
+ ## Files
37
+
38
+ | File | Role |
39
+ | ----------------------------------------- | --------------------------------------------------------- |
40
+ | `page-module-import.service.ts` | Core import/cache/build orchestration |
41
+ | `app-module-loader.service.ts` | App-facing loader interface |
42
+ | `app-server-module-transpiler.service.ts` | Factory for app-scoped loader + transpiler |
43
+ | `server-module-transpiler.service.ts` | Injectable transpiler boundary for tests and bootstrap |
44
+ | `host-module-loader-registry.ts` | Process-global host loader fallback for embedded runtimes |
45
+ | `source-module-support.ts` | Extension allowlist for host direct loading |
46
+
47
+ ## Development invalidation
48
+
49
+ `DevelopmentInvalidationService.invalidateServerModules()` bumps the app-owned invalidation version and calls `appModuleLoader.invalidateDevelopmentGraph()`, which clears the in-memory import cache and increments the per-service invalidation counter used in cache keys and dev `?update=` query params.
@@ -1,5 +1,5 @@
1
- import { getAppBuildExecutor, getAppServerBuildPlugins } from "../../build/build-adapter.js";
2
- import { createForeignJsxOverridePlugin } from "../../plugins/foreign-jsx-override-plugin.js";
1
+ import { getAppRouteModuleBuildExecutor, getAppServerBuildPlugins } from "../../build/build-adapter.js";
2
+ import { getJsxOwnershipPlugins } from "../../build/jsx-ownership-plugins.js";
3
3
  import path from "node:path";
4
4
  import { DevelopmentInvalidationService } from "../invalidation/development-invalidation.service.js";
5
5
  import {} from "./app-module-loader.service.js";
@@ -37,22 +37,9 @@ function setAppHostModuleLoader(appConfig, hostModuleLoader) {
37
37
  hostModuleLoader
38
38
  };
39
39
  }
40
- function getJsxOwnershipPlugins(appConfig) {
41
- const jsxExtensions = (appConfig.integrations ?? []).filter((integration) => integration.jsxImportSource).flatMap(
42
- (integration) => integration.extensions.filter((extension) => extension.endsWith(".tsx") || extension.endsWith(".jsx")).map((extension) => ({ integration, extension }))
43
- ).sort((left, right) => right.extension.length - left.extension.length);
44
- return jsxExtensions.map(
45
- ({ integration, extension }) => createForeignJsxOverridePlugin({
46
- hostJsxImportSource: integration.jsxImportSource,
47
- foreignExtensions: [extension],
48
- excludeExtensions: jsxExtensions.filter((candidate) => candidate.extension.length > extension.length).filter((candidate) => candidate.extension.endsWith(extension)).map((candidate) => candidate.extension),
49
- name: `ecopages-jsx-ownership-${integration.name}-${extension.replace(/[^a-zA-Z0-9]+/g, "-")}`
50
- })
51
- );
52
- }
53
40
  function createAppModuleLoader(appConfig) {
54
41
  const invalidationService = new DevelopmentInvalidationService(appConfig);
55
- const pageModuleImportService = new PageModuleImportService({
42
+ const pageModuleImportService = new PageModuleImportService(appConfig, {
56
43
  canLoadSourceModuleFromHost: (filePath) => shouldAppUseHostModuleLoader(appConfig, filePath),
57
44
  getHostModuleLoader: () => getAppHostModuleLoader(appConfig)
58
45
  });
@@ -69,7 +56,7 @@ function createAppModuleLoader(appConfig) {
69
56
  return await pageModuleImportService.importModule({
70
57
  ...options,
71
58
  ...mergedPlugins.length > 0 ? { plugins: mergedPlugins } : {},
72
- buildExecutor: options.buildExecutor ?? getAppBuildExecutor(appConfig),
59
+ buildExecutor: options.buildExecutor ?? getAppRouteModuleBuildExecutor(appConfig),
73
60
  invalidationVersion
74
61
  });
75
62
  },
@@ -1,5 +1,6 @@
1
1
  import { build, type BuildExecutor, type BuildResult } from '../../build/build-adapter.js';
2
2
  import type { EcoBuildPlugin } from '../../build/build-types.js';
3
+ import type { EcoPagesAppConfig } from '../../types/internal-types.js';
3
4
  import type { SourceModuleLoaderFactory } from './module-loading-types.js';
4
5
  interface PageModuleImportBaseOptions {
5
6
  filePath: string;
@@ -59,10 +60,11 @@ export interface PageModuleImportDependencies {
59
60
  * transpilation settings, and error semantics across the different callers.
60
61
  */
61
62
  export declare class PageModuleImportService {
63
+ private readonly appConfig?;
62
64
  private readonly dependencies;
63
65
  private readonly importCache;
64
66
  private developmentInvalidationVersion;
65
- constructor(dependencies?: Partial<PageModuleImportDependencies>);
67
+ constructor(appConfig?: EcoPagesAppConfig, dependencies?: Partial<PageModuleImportDependencies>);
66
68
  /**
67
69
  * Clears the shared import cache used by framework-owned page-module loads.
68
70
  */
@@ -91,5 +93,6 @@ export declare class PageModuleImportService {
91
93
  */
92
94
  importModule<T = unknown>(options: PageModuleBuildImportOptions): Promise<T>;
93
95
  private loadModule;
96
+ private getRouteModuleBuildCache;
94
97
  }
95
98
  export {};