@ecopages/core 0.2.0-alpha.34 → 0.2.0-alpha.36

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 (34) hide show
  1. package/package.json +14 -4
  2. package/src/adapters/create-app.js +2 -2
  3. package/src/adapters/node/node-hmr-manager.js +5 -0
  4. package/src/adapters/node/server-adapter.d.ts +3 -0
  5. package/src/adapters/node/server-adapter.js +21 -3
  6. package/src/adapters/shared/hmr-html-response.js +4 -3
  7. package/src/adapters/shared/render-context.d.ts +1 -0
  8. package/src/adapters/shared/render-context.js +21 -0
  9. package/src/adapters/shared/server-adapter.js +8 -1
  10. package/src/build/build-adapter.d.ts +8 -0
  11. package/src/build/build-adapter.js +4 -0
  12. package/src/config/config-builder.js +1 -1
  13. package/src/eco/eco.js +19 -6
  14. package/src/hmr/strategies/js-hmr-strategy.js +1 -2
  15. package/src/plugins/alias-resolver-plugin.d.ts +1 -0
  16. package/src/plugins/alias-resolver-plugin.js +12 -4
  17. package/src/plugins/eco-component-meta-plugin.js +16 -3
  18. package/src/plugins/foreign-jsx-override-plugin.d.ts +2 -0
  19. package/src/plugins/foreign-jsx-override-plugin.js +6 -0
  20. package/src/route-renderer/orchestration/component-render-context.d.ts +10 -2
  21. package/src/route-renderer/orchestration/component-render-context.js +2 -2
  22. package/src/route-renderer/orchestration/foreign-subtree-execution.service.d.ts +2 -0
  23. package/src/route-renderer/orchestration/foreign-subtree-execution.service.js +31 -4
  24. package/src/route-renderer/orchestration/integration-renderer.js +12 -1
  25. package/src/route-renderer/orchestration/queued-foreign-subtree-resolution.service.d.ts +1 -0
  26. package/src/route-renderer/orchestration/queued-foreign-subtree-resolution.service.js +18 -6
  27. package/src/services/module-loading/app-module-loader.service.d.ts +2 -2
  28. package/src/services/module-loading/app-server-module-transpiler.service.js +38 -2
  29. package/src/services/module-loading/node-bootstrap-plugin.js +82 -1
  30. package/src/services/module-loading/page-module-import.service.d.ts +29 -10
  31. package/src/services/module-loading/page-module-import.service.js +39 -18
  32. package/src/services/module-loading/server-module-transpiler.service.d.ts +2 -2
  33. package/src/types/public-types.d.ts +23 -5
  34. package/CHANGELOG.md +0 -91
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/core",
3
- "version": "0.2.0-alpha.34",
3
+ "version": "0.2.0-alpha.36",
4
4
  "description": "Core package for Ecopages",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -17,15 +17,15 @@
17
17
  "directory": "packages/core"
18
18
  },
19
19
  "dependencies": {
20
- "@ecopages/file-system": "0.2.0-alpha.34",
20
+ "@ecopages/file-system": "0.2.0-alpha.36",
21
21
  "@ecopages/logger": "^0.2.3",
22
- "@ecopages/scripts-injector": "^0.1.3",
22
+ "@ecopages/scripts-injector": "^0.1.5",
23
23
  "@worker-tools/html-rewriter": "0.1.0-pre.19",
24
24
  "chokidar": "^5.0.0",
25
25
  "esbuild": "^0.28.0",
26
26
  "ghtml": "^4.0.2",
27
27
  "oxc-parser": "^0.124.0",
28
- "ws": "^8.18.0"
28
+ "ws": "^8.20.1"
29
29
  },
30
30
  "exports": {
31
31
  ".": {
@@ -43,6 +43,11 @@
43
43
  "types": "./src/route-renderer/orchestration/integration-renderer.d.ts",
44
44
  "default": "./src/route-renderer/orchestration/integration-renderer.js"
45
45
  },
46
+ "./route-renderer/component-render-context": {
47
+ "import": "./src/route-renderer/orchestration/component-render-context.js",
48
+ "types": "./src/route-renderer/orchestration/component-render-context.d.ts",
49
+ "default": "./src/route-renderer/orchestration/component-render-context.js"
50
+ },
46
51
  "./router/navigation-coordinator": {
47
52
  "types": "./src/router/client/navigation-coordinator.d.ts",
48
53
  "default": "./src/router/client/navigation-coordinator.js"
@@ -145,6 +150,11 @@
145
150
  "types": "./src/route-renderer/orchestration/integration-renderer.d.ts",
146
151
  "default": "./src/route-renderer/orchestration/integration-renderer.js"
147
152
  },
153
+ "./route-renderer/component-render-context.ts": {
154
+ "import": "./src/route-renderer/orchestration/component-render-context.js",
155
+ "types": "./src/route-renderer/orchestration/component-render-context.d.ts",
156
+ "default": "./src/route-renderer/orchestration/component-render-context.js"
157
+ },
148
158
  "./router/navigation-coordinator.ts": {
149
159
  "types": "./src/router/client/navigation-coordinator.d.ts",
150
160
  "default": "./src/router/client/navigation-coordinator.js"
@@ -1,12 +1,12 @@
1
1
  import { AbstractApplicationAdapter } from "./abstract/application-adapter.js";
2
2
  import { SharedApplicationAdapter } from "./shared/application-adapter.js";
3
+ import { createApp as createBunApp } from "./bun/create-app.js";
4
+ import { createNodeApp } from "./node/create-app.js";
3
5
  async function createRuntimeApp(options) {
4
6
  const bun = globalThis.Bun;
5
7
  if (bun) {
6
- const { createApp: createBunApp } = await import("./bun/create-app.js");
7
8
  return await createBunApp(options);
8
9
  }
9
- const { createApp: createNodeApp } = await import("./node/create-app.js");
10
10
  return await createNodeApp(options);
11
11
  }
12
12
  async function createApp(options) {
@@ -138,6 +138,11 @@ class NodeHmrManager {
138
138
  this.bridge.broadcast(event);
139
139
  }
140
140
  async handleFileChange(filePath, options = {}) {
141
+ if (!fileSystem.exists(filePath)) {
142
+ appLogger.debug(`[NodeHmrManager] Skipping missing file change: ${filePath}`);
143
+ this.clearFailedEntrypointRegistration(filePath);
144
+ return;
145
+ }
141
146
  const sorted = [...this.strategies].sort((a, b) => b.priority - a.priority);
142
147
  const strategy = sorted.find((s) => {
143
148
  try {
@@ -53,6 +53,9 @@ export declare class NodeServerAdapter extends SharedServerAdapter<NodeServerAda
53
53
  private previewServer;
54
54
  private bridge;
55
55
  private hmrManager;
56
+ private shouldInjectHmrScript;
57
+ private isHtmlResponse;
58
+ private maybeInjectHmrScript;
56
59
  constructor(options: NodeServerAdapterParams);
57
60
  /**
58
61
  * Prepares the adapter for use.
@@ -1,6 +1,8 @@
1
1
  import { createServer } from "node:http";
2
+ import { WebSocketServer } from "ws";
2
3
  import { appLogger } from "../../global/app-logger.js";
3
4
  import { NodeClientBridge } from "./node-client-bridge.js";
5
+ import { NodeHmrManager } from "./node-hmr-manager.js";
4
6
  import { StaticSiteGenerator } from "../../static-site-generator/static-site-generator.js";
5
7
  import { SharedServerAdapter } from "../shared/server-adapter.js";
6
8
  import { ServerStaticBuilder } from "../shared/server-static-builder.js";
@@ -13,6 +15,11 @@ import {
13
15
  } from "../shared/runtime-bootstrap.js";
14
16
  import { NodeStaticContentServer } from "./static-content-server.js";
15
17
  import { DEFAULT_ECOPAGES_HOSTNAME, DEFAULT_ECOPAGES_PORT } from "../../config/constants.js";
18
+ import {
19
+ injectHmrRuntimeIntoHtmlResponse,
20
+ isHtmlResponse,
21
+ shouldInjectHmrHtmlResponse
22
+ } from "../shared/hmr-html-response.js";
16
23
  class ClientAbortError extends Error {
17
24
  constructor() {
18
25
  super("Client closed the request");
@@ -28,6 +35,18 @@ class NodeServerAdapter extends SharedServerAdapter {
28
35
  previewServer = null;
29
36
  bridge = null;
30
37
  hmrManager = null;
38
+ shouldInjectHmrScript() {
39
+ return shouldInjectHmrHtmlResponse(this.options?.watch === true, this.hmrManager ?? void 0);
40
+ }
41
+ isHtmlResponse(response) {
42
+ return isHtmlResponse(response);
43
+ }
44
+ async maybeInjectHmrScript(response) {
45
+ if (this.shouldInjectHmrScript() && this.isHtmlResponse(response)) {
46
+ return injectHmrRuntimeIntoHtmlResponse(response);
47
+ }
48
+ return response;
49
+ }
31
50
  constructor(options) {
32
51
  super(options);
33
52
  this.apiHandlers = options.apiHandlers || [];
@@ -283,12 +302,13 @@ class NodeServerAdapter extends SharedServerAdapter {
283
302
  throw new Error("Node server adapter is not initialized. Call createAdapter() first.");
284
303
  }
285
304
  try {
286
- return await this.handleSharedRequest(request, {
305
+ const response = await this.handleSharedRequest(request, {
287
306
  apiHandlers: this.apiHandlers,
288
307
  errorHandler: this.errorHandler,
289
308
  serverInstance: this.serverInstance,
290
309
  hmrManager: this.hmrManager
291
310
  });
311
+ return await this.maybeInjectHmrScript(response);
292
312
  } catch (error) {
293
313
  if (error instanceof ClientAbortError) {
294
314
  return new Response(null, { status: 499 });
@@ -315,8 +335,6 @@ class NodeServerAdapter extends SharedServerAdapter {
315
335
  async completeInitialization(server) {
316
336
  this.serverInstance = server;
317
337
  if (this.options?.watch) {
318
- const { NodeHmrManager } = await import("./node-hmr-manager.js");
319
- const { WebSocketServer } = await import("ws");
320
338
  const wss = new WebSocketServer({ noServer: true });
321
339
  this.bridge = new NodeClientBridge();
322
340
  this.hmrManager = new NodeHmrManager({ appConfig: this.appConfig, bridge: this.bridge });
@@ -9,16 +9,17 @@ function shouldInjectHmrHtmlResponse(watch, hmrManager) {
9
9
  }
10
10
  async function injectHmrRuntimeIntoHtmlResponse(response) {
11
11
  const html = await response.text();
12
+ const headers = new Headers(response.headers);
13
+ headers.set("Cache-Control", "no-store, must-revalidate");
14
+ headers.delete("Content-Length");
12
15
  if (html.includes(HMR_RUNTIME_IMPORT)) {
13
16
  return new Response(html, {
14
17
  status: response.status,
15
18
  statusText: response.statusText,
16
- headers: response.headers
19
+ headers
17
20
  });
18
21
  }
19
22
  const updatedHtml = html.replace(/<\/html>/i, `${HMR_RUNTIME_SCRIPT}</html>`);
20
- const headers = new Headers(response.headers);
21
- headers.delete("Content-Length");
22
23
  return new Response(updatedHtml, {
23
24
  status: response.status,
24
25
  statusText: response.statusText,
@@ -3,6 +3,7 @@ import type { AnyIntegrationPlugin } from '../../plugins/integration-plugin.js';
3
3
  export interface CreateRenderContextOptions {
4
4
  integrations: AnyIntegrationPlugin[];
5
5
  rendererModules?: unknown;
6
+ importServerModule?: (filePath: string) => Promise<unknown>;
6
7
  }
7
8
  /**
8
9
  * Creates a render context for route handlers.
@@ -1,4 +1,8 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import { invariant } from "../../utils/invariant.js";
3
+ function resolveServerModulePath(filePath) {
4
+ return filePath instanceof URL ? fileURLToPath(filePath) : filePath;
5
+ }
2
6
  function mergePropsWithLocals(props, locals) {
3
7
  if (!locals || typeof props !== "object" || props === null) {
4
8
  return props;
@@ -20,6 +24,23 @@ function createRenderContext(options) {
20
24
  });
21
25
  };
22
26
  const renderContext = {
27
+ importServerModule: async (filePath) => {
28
+ invariant(!!options.importServerModule, "Server module importing is not available in this render context.");
29
+ return await options.importServerModule(resolveServerModulePath(filePath));
30
+ },
31
+ async renderServerModule(filePath, props, renderOptions) {
32
+ const module = await renderContext.importServerModule(filePath);
33
+ const view = module.default;
34
+ const locals = this?.locals;
35
+ const mergedProps = mergePropsWithLocals(props ?? {}, locals);
36
+ const renderer = getRendererForView(view);
37
+ const ctx = {
38
+ partial: false,
39
+ status: renderOptions?.status,
40
+ headers: renderOptions?.headers
41
+ };
42
+ return await renderer.renderToResponse(view, mergedProps, ctx);
43
+ },
23
44
  async render(view, props, renderOptions) {
24
45
  const locals = this?.locals;
25
46
  const mergedProps = mergePropsWithLocals(props ?? {}, locals);
@@ -157,9 +157,16 @@ class SharedServerAdapter extends AbstractServerAdapter {
157
157
  return this.fileSystemResponseMatcher?.getCacheService() ?? null;
158
158
  }
159
159
  getRenderContext() {
160
+ const serverModuleTranspiler = getAppServerModuleTranspiler(this.appConfig);
160
161
  return createRenderContext({
161
162
  integrations: this.appConfig.integrations,
162
- rendererModules: this.appConfig.runtime?.rendererModuleContext
163
+ rendererModules: this.appConfig.runtime?.rendererModuleContext,
164
+ importServerModule: async (filePath) => await serverModuleTranspiler.importModule({
165
+ filePath,
166
+ outdir: path.join(resolveInternalExecutionDir(this.appConfig), ".server-modules"),
167
+ externalPackages: true,
168
+ bypassCache: this.options?.watch === true
169
+ })
163
170
  });
164
171
  }
165
172
  /**
@@ -54,6 +54,14 @@ export interface BuildOptions {
54
54
  bundle?: boolean;
55
55
  externalPackages?: boolean;
56
56
  external?: string[];
57
+ jsx?: {
58
+ development?: boolean;
59
+ factory?: string;
60
+ fragment?: string;
61
+ importSource?: string;
62
+ runtime?: 'classic' | 'automatic';
63
+ sideEffects?: boolean;
64
+ };
57
65
  plugins?: EcoBuildPlugin[];
58
66
  [key: string]: unknown;
59
67
  }
@@ -415,6 +415,8 @@ class BunBuildAdapter {
415
415
  try {
416
416
  const contextRoot = options.root ? path.resolve(options.root) : process.cwd();
417
417
  const outdir = path.resolve(options.outdir ?? "dist/assets");
418
+ const tsconfigPath = path.join(contextRoot, "tsconfig.json");
419
+ const tsconfigExists = fs.existsSync(tsconfigPath);
418
420
  const plugins = this.getPluginsForBuild(options.plugins);
419
421
  const result = await bun.build({
420
422
  entrypoints: options.entrypoints,
@@ -429,6 +431,8 @@ class BunBuildAdapter {
429
431
  splitting: !!options.splitting,
430
432
  minify: !!options.minify,
431
433
  packages: options.target !== "browser" && options.externalPackages !== false ? "external" : void 0,
434
+ tsconfig: tsconfigExists ? tsconfigPath : void 0,
435
+ jsx: options.jsx,
432
436
  plugins: plugins.length > 0 ? [this.createEcoPluginBridge(plugins, contextRoot)] : void 0
433
437
  });
434
438
  return this.rewriteAliasedRuntimeSpecifiers(
@@ -395,7 +395,7 @@ class ConfigBuilder {
395
395
  const hasKitaJs = uniqueName.has("kitajs");
396
396
  const hasReact = uniqueName.has("react");
397
397
  if (hasKitaJs && hasReact) {
398
- appLogger.warn(CONFIG_BUILDER_ERRORS.MIXED_JSX_ENGINES);
398
+ appLogger.debug(CONFIG_BUILDER_ERRORS.MIXED_JSX_ENGINES);
399
399
  }
400
400
  const integrationsExtensions = integrations.flatMap((integration) => integration.extensions);
401
401
  const uniqueExtensions = new Set(integrationsExtensions);
package/src/eco/eco.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  finalizeComponentRender,
3
+ getComponentRenderContext,
3
4
  interceptForeignChild
4
5
  } from "../route-renderer/orchestration/component-render-context.js";
5
6
  import { isThenable } from "../route-renderer/orchestration/render-output.utils.js";
@@ -7,19 +8,31 @@ function createComponentFactory(options) {
7
8
  const integrationName = options.integration ?? options.__eco?.integration;
8
9
  const comp = ((props) => {
9
10
  const componentProps = props ?? {};
10
- const renderInline = () => finalizeComponentRender(comp, options.render(props));
11
+ const renderInline = (nextProps = props) => finalizeComponentRender(comp, options.render(nextProps));
12
+ const activeRenderContext = getComponentRenderContext();
11
13
  const foreignChildRender = interceptForeignChild({
12
14
  component: comp,
13
15
  props: componentProps,
14
16
  targetIntegration: integrationName
15
17
  });
16
18
  if (isThenable(foreignChildRender)) {
17
- return foreignChildRender.then(
18
- (resolvedForeignChildRender) => resolvedForeignChildRender !== void 0 ? resolvedForeignChildRender : renderInline()
19
- );
19
+ return foreignChildRender.then((resolvedForeignChildRender) => {
20
+ if (resolvedForeignChildRender?.kind === "resolved") {
21
+ return resolvedForeignChildRender.value;
22
+ }
23
+ return renderInline(resolvedForeignChildRender?.props ?? props);
24
+ });
25
+ }
26
+ if (foreignChildRender?.kind === "resolved") {
27
+ return foreignChildRender.value;
20
28
  }
21
- if (foreignChildRender !== void 0) {
22
- return foreignChildRender;
29
+ if (foreignChildRender?.kind === "inline") {
30
+ return renderInline(foreignChildRender.props ?? props);
31
+ }
32
+ if (activeRenderContext && activeRenderContext.foreignChildRuntime && integrationName && integrationName !== activeRenderContext.currentIntegration) {
33
+ throw new Error(
34
+ `[ecopages] Missing foreign-child interception from ${activeRenderContext.currentIntegration} to ${integrationName} for ${options.__eco?.file ?? "unknown component"}.`
35
+ );
23
36
  }
24
37
  return renderInline();
25
38
  });
@@ -23,8 +23,7 @@ class JsHmrStrategy extends HmrStrategy {
23
23
  const watchedFiles = this.context.getWatchedFiles();
24
24
  const isJsTs = /\.(ts|tsx|js|jsx)$/.test(filePath);
25
25
  const isInSrc = filePath.startsWith(this.context.getSrcDir());
26
- const isRouteTemplate = filePath.startsWith(this.context.getPagesDir()) || filePath.startsWith(this.context.getLayoutsDir());
27
- const isIntegrationTemplate = isRouteTemplate && this.context.getTemplateExtensions().some((extension) => filePath.endsWith(extension));
26
+ const isIntegrationTemplate = this.context.getTemplateExtensions().some((extension) => filePath.endsWith(extension));
28
27
  if (watchedFiles.size === 0) {
29
28
  return false;
30
29
  }
@@ -1,2 +1,3 @@
1
1
  import type { EcoBuildPlugin } from '../build/build-types.js';
2
+ export declare function resolveAppSourceAliasPath(srcDir: string, specifier: string): string | undefined;
2
3
  export declare function createAliasResolverPlugin(srcDir: string): EcoBuildPlugin;
@@ -33,15 +33,22 @@ function resolveAliasedBarrelTarget(resolvedPath) {
33
33
  const target = findResolvablePath(path.resolve(path.dirname(resolvedPath), match[1]));
34
34
  return target ?? resolvedPath;
35
35
  }
36
+ function resolveAppSourceAliasPath(srcDir, specifier) {
37
+ if (!specifier.startsWith("@/")) {
38
+ return void 0;
39
+ }
40
+ const candidate = path.join(srcDir, specifier.slice(2));
41
+ const resolved = findResolvablePath(candidate);
42
+ return resolved ? resolveAliasedBarrelTarget(resolved) : void 0;
43
+ }
36
44
  function createAliasResolverPlugin(srcDir) {
37
45
  return {
38
46
  name: "ecopages-alias-resolver",
39
47
  setup(build) {
40
48
  build.onResolve({ filter: /^@\// }, (args) => {
41
- const candidate = path.join(srcDir, args.path.slice(2));
42
- const resolved = findResolvablePath(candidate);
49
+ const resolved = resolveAppSourceAliasPath(srcDir, args.path);
43
50
  if (resolved) {
44
- return { path: resolveAliasedBarrelTarget(resolved) };
51
+ return { path: resolved };
45
52
  }
46
53
  return {};
47
54
  });
@@ -49,5 +56,6 @@ function createAliasResolverPlugin(srcDir) {
49
56
  };
50
57
  }
51
58
  export {
52
- createAliasResolverPlugin
59
+ createAliasResolverPlugin,
60
+ resolveAppSourceAliasPath
53
61
  };
@@ -18,7 +18,13 @@ function buildExtensionToIntegrationMap(integrations) {
18
18
  const mapping = [];
19
19
  for (const integration of integrations) {
20
20
  for (const ext of integration.extensions) {
21
- mapping.push([ext, integration.name]);
21
+ mapping.push([
22
+ ext,
23
+ {
24
+ name: integration.name,
25
+ jsxImportSource: integration.jsxImportSource
26
+ }
27
+ ]);
22
28
  }
23
29
  }
24
30
  mapping.sort((a, b) => b[0].length - a[0].length);
@@ -30,7 +36,14 @@ function detectIntegration(filePath, extensionToIntegration) {
30
36
  return integration;
31
37
  }
32
38
  }
33
- return "ghtml";
39
+ return { name: "ghtml" };
40
+ }
41
+ function prependJsxImportSource(code, jsxImportSource) {
42
+ if (!jsxImportSource || code.includes("@jsxImportSource")) {
43
+ return code;
44
+ }
45
+ return `/** @jsxImportSource ${jsxImportSource} */
46
+ ${code}`;
34
47
  }
35
48
  function createExtensionPattern(extensions) {
36
49
  if (extensions.length === 0) {
@@ -54,7 +67,7 @@ function createEcoComponentMetaTransform(options) {
54
67
  transform(code, id) {
55
68
  const integration = detectIntegration(id, extensionToIntegration);
56
69
  return {
57
- code: injectEcoMeta(code, id, integration)
70
+ code: prependJsxImportSource(injectEcoMeta(code, id, integration.name), integration.jsxImportSource)
58
71
  };
59
72
  }
60
73
  };
@@ -7,6 +7,8 @@ export interface ForeignJsxOverrideOptions {
7
7
  hostJsxImportSource: string;
8
8
  /** Extensions claimed by other JSX integrations that may appear in the host graph. */
9
9
  foreignExtensions: string[];
10
+ /** More specific owned suffixes that must not be claimed by this override. */
11
+ excludeExtensions?: string[];
10
12
  /** Optional plugin name override for debug output. */
11
13
  name?: string;
12
14
  }
@@ -2,6 +2,9 @@ import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  function createForeignJsxOverridePlugin(options) {
4
4
  const extensions = options.foreignExtensions.filter((ext) => ext.endsWith(".tsx") || ext.endsWith(".jsx"));
5
+ const excludedExtensions = (options.excludeExtensions ?? []).filter(
6
+ (ext) => ext.endsWith(".tsx") || ext.endsWith(".jsx")
7
+ );
5
8
  if (extensions.length === 0) {
6
9
  return {
7
10
  name: options.name ?? "foreign-jsx-override",
@@ -16,6 +19,9 @@ function createForeignJsxOverridePlugin(options) {
16
19
  name: options.name ?? "foreign-jsx-override",
17
20
  setup(build) {
18
21
  build.onLoad({ filter }, (args) => {
22
+ if (excludedExtensions.some((extension) => args.path.endsWith(extension))) {
23
+ return void 0;
24
+ }
19
25
  const source = readFileSync(args.path, "utf-8");
20
26
  const loader = args.path.endsWith(".jsx") ? "jsx" : "tsx";
21
27
  if (source.includes("@jsxImportSource")) {
@@ -8,10 +8,18 @@ import type { EcoComponent } from '../../types/public-types.js';
8
8
  */
9
9
  export type ForeignChildInterceptionResult = {
10
10
  kind: 'inline';
11
+ props?: Record<string, unknown>;
11
12
  } | {
12
13
  kind: 'resolved';
13
14
  value: unknown;
14
15
  };
16
+ export type ForeignChildRenderInterception = {
17
+ kind: 'inline';
18
+ props?: Record<string, unknown>;
19
+ } | {
20
+ kind: 'resolved';
21
+ value: unknown;
22
+ } | undefined;
15
23
  /**
16
24
  * Foreign-child metadata passed into the active renderer-owned runtime.
17
25
  */
@@ -50,7 +58,7 @@ type ForeignChildRenderInput = {
50
58
  export type ComponentRenderContext = {
51
59
  currentIntegration: string;
52
60
  foreignChildRuntime?: ForeignChildRuntime;
53
- interceptForeignChild(input: ForeignChildRenderInput): Promise<unknown | undefined> | unknown | undefined;
61
+ interceptForeignChild(input: ForeignChildRenderInput): Promise<ForeignChildRenderInterception> | ForeignChildRenderInterception;
54
62
  finalizeComponentRender<T>(component: EcoComponent, content: T): T;
55
63
  };
56
64
  /**
@@ -64,7 +72,7 @@ export declare function getComponentRenderContext(): ComponentRenderContext | un
64
72
  *
65
73
  * The active runtime may resolve the foreign child immediately or keep it inline.
66
74
  */
67
- export declare function interceptForeignChild(input: ForeignChildRenderInput): Promise<unknown | undefined> | unknown | undefined;
75
+ export declare function interceptForeignChild(input: ForeignChildRenderInput): Promise<ForeignChildRenderInterception> | ForeignChildRenderInterception;
68
76
  /**
69
77
  * Applies lazy trigger or injector wrapping to completed component output.
70
78
  *
@@ -32,9 +32,9 @@ class ContextualComponentRenderRuntime extends ComponentRenderOutputRuntime {
32
32
  }
33
33
  applyForeignChildInterceptionResult(result) {
34
34
  if (result.kind === "resolved") {
35
- return result.value;
35
+ return result;
36
36
  }
37
- return void 0;
37
+ return result.props ? result : void 0;
38
38
  }
39
39
  /**
40
40
  * Resolves one foreign-child interception through the active runtime.
@@ -47,6 +47,7 @@ export interface ForeignSubtreeQueuedHtmlOptions<TContext extends QueuedForeignS
47
47
  queueLabel: string;
48
48
  renderQueuedChildren(children: unknown, runtimeContext: TContext, queuedResolutionsByToken: Map<string, QueuedForeignSubtreeResolution>, resolveToken: (token: string) => Promise<string>): Promise<{
49
49
  assets: ProcessedAsset[];
50
+ children?: unknown;
50
51
  html?: string;
51
52
  }>;
52
53
  getOwningRenderer(integrationName: string, rendererCache: Map<string, ForeignSubtreeExecutionOwningRenderer>): ForeignSubtreeExecutionOwningRenderer;
@@ -64,6 +65,7 @@ export interface ForeignSubtreeQueuedHtmlOptions<TContext extends QueuedForeignS
64
65
  export declare class ForeignSubtreeExecutionService {
65
66
  private readonly queuedForeignSubtreeResolutionService;
66
67
  constructor(queuedForeignSubtreeResolutionService?: QueuedForeignSubtreeResolutionService);
68
+ private requiresForeignChildRuntime;
67
69
  /**
68
70
  * Returns whether the current render pass must hand the child off to a foreign owner.
69
71
  */
@@ -5,11 +5,27 @@ import {
5
5
  import {
6
6
  QueuedForeignSubtreeResolutionService
7
7
  } from "./queued-foreign-subtree-resolution.service.js";
8
+ function isMarkupNodeLike(value) {
9
+ return typeof value === "object" && value !== null && "nodeType" in value && typeof value.nodeType === "number" && (!("outerHTML" in value) || typeof value.outerHTML === "string");
10
+ }
8
11
  class ForeignSubtreeExecutionService {
9
12
  queuedForeignSubtreeResolutionService;
10
13
  constructor(queuedForeignSubtreeResolutionService = new QueuedForeignSubtreeResolutionService()) {
11
14
  this.queuedForeignSubtreeResolutionService = queuedForeignSubtreeResolutionService;
12
15
  }
16
+ requiresForeignChildRuntime(input) {
17
+ const children = input.children ?? input.props.children;
18
+ if (children === void 0 || children === null) {
19
+ return false;
20
+ }
21
+ if (typeof children === "string") {
22
+ return false;
23
+ }
24
+ if (typeof children !== "object") {
25
+ return false;
26
+ }
27
+ return !isMarkupNodeLike(children);
28
+ }
13
29
  /**
14
30
  * Returns whether the current render pass must hand the child off to a foreign owner.
15
31
  */
@@ -25,7 +41,10 @@ class ForeignSubtreeExecutionService {
25
41
  createFailFastRuntime(rendererName) {
26
42
  const interceptForeignChild = (input) => {
27
43
  if (!this.shouldDelegateForeignChild(input)) {
28
- return { kind: "inline" };
44
+ return {
45
+ kind: "inline",
46
+ props: "props" in input && input.props ? { ...input.props } : void 0
47
+ };
29
48
  }
30
49
  throw new Error(
31
50
  `[ecopages] ${rendererName} renderer crossed into ${input.targetIntegration} without a renderer-owned foreign-child runtime. Override createForeignChildRuntime() to resolve foreign children inside the owning renderer.`
@@ -63,8 +82,11 @@ class ForeignSubtreeExecutionService {
63
82
  if (children === void 0) {
64
83
  return { assets: [], html: void 0 };
65
84
  }
85
+ if (typeof children !== "string" && !isMarkupNodeLike(children)) {
86
+ return { assets: [], children };
87
+ }
66
88
  const html = await this.resolveQueuedTokens(
67
- typeof children === "string" ? children : String(children ?? ""),
89
+ typeof children === "string" ? children : children.outerHTML ?? String(children ?? ""),
68
90
  queuedResolutionsByToken,
69
91
  resolveToken
70
92
  );
@@ -105,7 +127,7 @@ class ForeignSubtreeExecutionService {
105
127
  if (delegatedForeignChildRender) {
106
128
  return delegatedForeignChildRender;
107
129
  }
108
- const hasForeignChildren = options.hasForeignChildDescendants(options.input.component);
130
+ const hasForeignChildren = options.hasForeignChildDescendants(options.input.component) || this.requiresForeignChildRuntime(options.input);
109
131
  const activeRenderContext = getComponentRenderContext();
110
132
  if (!hasForeignChildren) {
111
133
  if (!activeRenderContext || activeRenderContext.currentIntegration === options.currentIntegrationName) {
@@ -188,7 +210,12 @@ class ForeignSubtreeExecutionService {
188
210
  if (owningRenderer.name === options.currentIntegrationName) {
189
211
  return void 0;
190
212
  }
191
- return await options.run(owningRenderer, this.withRendererCache(options.input, options.rendererCache));
213
+ const delegatedInputWithCache = this.withRendererCache(options.input, options.rendererCache);
214
+ const delegatedChildren = delegatedInputWithCache.children ?? delegatedInputWithCache.props.children;
215
+ return await options.run(owningRenderer, {
216
+ ...delegatedInputWithCache,
217
+ children: delegatedChildren
218
+ });
192
219
  }
193
220
  async resolveQueuedTokens(html, queuedResolutionsByToken, resolveToken) {
194
221
  let resolvedHtml = html;
@@ -15,6 +15,9 @@ import {
15
15
  ForeignSubtreeExecutionService
16
16
  } from "./foreign-subtree-execution.service.js";
17
17
  import {} from "./queued-foreign-subtree-resolution.service.js";
18
+ function isMarkupNodeLike(value) {
19
+ return typeof value === "object" && value !== null && "nodeType" in value && typeof value.nodeType === "number" && "outerHTML" in value && typeof value.outerHTML === "string";
20
+ }
18
21
  class IntegrationRenderer {
19
22
  appConfig;
20
23
  assetProcessingService;
@@ -348,7 +351,15 @@ class IntegrationRenderer {
348
351
  * @returns Structured component render result for orchestration paths.
349
352
  */
350
353
  async renderStringComponentWithSerializedChildren(input, component) {
351
- const props = input.children === void 0 ? input.props : { ...input.props, children: input.children };
354
+ const serializedChildren = input.children === void 0 ? void 0 : typeof input.children === "string" ? input.children : isMarkupNodeLike(input.children) ? input.children.outerHTML : void 0;
355
+ if (input.children !== void 0 && serializedChildren === void 0) {
356
+ const componentFile = input.component.config?.__eco?.file ?? "unknown component";
357
+ const childTag = Object.prototype.toString.call(input.children);
358
+ throw new TypeError(
359
+ `[ecopages] ${this.name} renderer expected serialized children for ${componentFile}, received ${childTag}.`
360
+ );
361
+ }
362
+ const props = serializedChildren === void 0 ? input.props : { ...input.props, children: serializedChildren };
352
363
  const content = await component(props);
353
364
  const html = String(content);
354
365
  const assets = input.component.config?.dependencies && typeof this.assetProcessingService?.processDependencies === "function" ? await this.processComponentDependencies([input.component]) : void 0;
@@ -30,6 +30,7 @@ type QueuedForeignSubtreeIntegrationContext = BaseIntegrationContext & Record<st
30
30
  type QueuedForeignSubtreeChildRenderResult = {
31
31
  assets: ProcessedAsset[];
32
32
  html?: string;
33
+ children?: unknown;
33
34
  };
34
35
  /**
35
36
  * Lower-level queue orchestration for renderer-owned foreign-child runtimes that emit
@@ -21,7 +21,10 @@ class QueuedForeignSubtreeResolutionService {
21
21
  const runtimeContext = this.ensureRuntimeContext(options);
22
22
  const interceptForeignChild = (input) => {
23
23
  if (!options.shouldQueueForeignChild(input)) {
24
- return { kind: "inline" };
24
+ return {
25
+ kind: "inline",
26
+ props: { ...input.props }
27
+ };
25
28
  }
26
29
  runtimeContext.nextForeignSubtreeId += 1;
27
30
  const foreignSubtreeId = runtimeContext.nextForeignSubtreeId;
@@ -54,13 +57,19 @@ class QueuedForeignSubtreeResolutionService {
54
57
  return { assets: [], html: options.html };
55
58
  }
56
59
  const runtimeContext = options.runtimeContext;
57
- const queuedResolutionsByToken = new Map(
58
- runtimeContext.queuedResolutions.map((resolution) => [resolution.token, resolution])
59
- );
60
+ const queuedResolutionsByToken = /* @__PURE__ */ new Map();
60
61
  const resolvedHtmlByToken = /* @__PURE__ */ new Map();
61
62
  const resolvingTokens = /* @__PURE__ */ new Set();
62
63
  const collectedAssets = [];
64
+ const syncQueuedResolutions = () => {
65
+ for (const resolution of runtimeContext.queuedResolutions) {
66
+ if (!queuedResolutionsByToken.has(resolution.token)) {
67
+ queuedResolutionsByToken.set(resolution.token, resolution);
68
+ }
69
+ }
70
+ };
63
71
  const resolveToken = async (token) => {
72
+ syncQueuedResolutions();
64
73
  const cachedHtml = resolvedHtmlByToken.get(token);
65
74
  if (cachedHtml) {
66
75
  return cachedHtml;
@@ -82,6 +91,7 @@ class QueuedForeignSubtreeResolutionService {
82
91
  queuedResolutionsByToken,
83
92
  resolveToken
84
93
  );
94
+ syncQueuedResolutions();
85
95
  if (renderedChildren.assets.length > 0) {
86
96
  collectedAssets.push(...renderedChildren.assets);
87
97
  }
@@ -89,7 +99,7 @@ class QueuedForeignSubtreeResolutionService {
89
99
  {
90
100
  component: resolution.component,
91
101
  props: { ...resolution.props },
92
- children: renderedChildren.html,
102
+ children: renderedChildren.html ?? renderedChildren.children,
93
103
  integrationContext: {
94
104
  rendererCache: runtimeContext.rendererCache,
95
105
  componentInstanceId: resolution.componentInstanceId
@@ -116,7 +126,9 @@ class QueuedForeignSubtreeResolutionService {
116
126
  }
117
127
  };
118
128
  let resolvedHtml = options.html;
119
- for (const resolution of runtimeContext.queuedResolutions) {
129
+ for (let index = 0; index < runtimeContext.queuedResolutions.length; index += 1) {
130
+ syncQueuedResolutions();
131
+ const resolution = runtimeContext.queuedResolutions[index];
120
132
  if (!resolvedHtml.includes(resolution.token)) {
121
133
  continue;
122
134
  }
@@ -1,7 +1,7 @@
1
- import type { PageModuleImportOptions } from './page-module-import.service.js';
1
+ import type { PageModuleBuildImportOptions } from './page-module-import.service.js';
2
2
  export type AppModuleLoaderOwner = 'bun' | 'host';
3
3
  export interface AppModuleLoader {
4
4
  readonly owner: AppModuleLoaderOwner;
5
- importModule<T = unknown>(options: PageModuleImportOptions): Promise<T>;
5
+ importModule<T = unknown>(options: PageModuleBuildImportOptions): Promise<T>;
6
6
  invalidateDevelopmentGraph(): void;
7
7
  }
@@ -1,5 +1,6 @@
1
1
  import { getAppBuildExecutor } from "../../build/build-adapter.js";
2
2
  import path from "node:path";
3
+ import { createForeignJsxOverridePlugin } from "../../plugins/foreign-jsx-override-plugin.js";
3
4
  import { DevelopmentInvalidationService } from "../invalidation/development-invalidation.service.js";
4
5
  import {} from "./app-module-loader.service.js";
5
6
  import { createAppNodeBootstrapPlugin } from "./node-bootstrap-plugin.js";
@@ -37,6 +38,24 @@ function setAppHostModuleLoader(appConfig, hostModuleLoader) {
37
38
  hostModuleLoader
38
39
  };
39
40
  }
41
+ function getOwningIntegration(appConfig, filePath) {
42
+ return appConfig.integrations?.flatMap(
43
+ (integration) => integration.extensions.filter((extension) => filePath.endsWith(extension)).map((extension) => ({ integration, extension }))
44
+ ).sort((left, right) => right.extension.length - left.extension.length)[0]?.integration;
45
+ }
46
+ function getBunOwnedJsxPlugins(appConfig) {
47
+ const jsxExtensions = (appConfig.integrations ?? []).filter((integration) => integration.jsxImportSource).flatMap(
48
+ (integration) => integration.extensions.filter((extension) => extension.endsWith(".tsx") || extension.endsWith(".jsx")).map((extension) => ({ integration, extension }))
49
+ ).sort((left, right) => right.extension.length - left.extension.length);
50
+ return jsxExtensions.map(
51
+ ({ integration, extension }) => createForeignJsxOverridePlugin({
52
+ hostJsxImportSource: integration.jsxImportSource,
53
+ foreignExtensions: [extension],
54
+ excludeExtensions: jsxExtensions.filter((candidate) => candidate.extension.length > extension.length).filter((candidate) => candidate.extension.endsWith(extension)).map((candidate) => candidate.extension),
55
+ name: `ecopages-bun-jsx-ownership-${integration.name}-${extension.replace(/[^a-zA-Z0-9]+/g, "-")}`
56
+ })
57
+ );
58
+ }
40
59
  function createAppModuleLoader(appConfig) {
41
60
  const invalidationService = new DevelopmentInvalidationService(appConfig);
42
61
  const pageModuleImportService = new PageModuleImportService({
@@ -44,18 +63,35 @@ function createAppModuleLoader(appConfig) {
44
63
  getHostModuleLoader: () => getAppHostModuleLoader(appConfig)
45
64
  });
46
65
  const getDefaultPlugins = typeof Bun === "undefined" && appConfig.rootDir ? () => [createAppNodeBootstrapPlugin(appConfig)] : () => [];
66
+ const appLoaderPlugins = Array.from(appConfig.loaders?.values() ?? []);
67
+ const bunOwnedJsxPlugins = typeof Bun !== "undefined" ? getBunOwnedJsxPlugins(appConfig) : [];
47
68
  const appModuleLoader = {
48
69
  get owner() {
49
70
  return getAppModuleLoaderOwner(appConfig);
50
71
  },
51
72
  pageModuleImportService,
52
73
  async importModule(options) {
53
- const mergedPlugins = [...getDefaultPlugins(), ...options.plugins ?? []];
74
+ const invalidationVersion = options.invalidationVersion ?? invalidationService.getServerModuleInvalidationVersion();
75
+ const owningIntegration = getOwningIntegration(appConfig, options.filePath);
76
+ const owningJsxImportSource = owningIntegration?.jsxImportSource;
77
+ const mergedPlugins = [
78
+ ...getDefaultPlugins(),
79
+ ...appLoaderPlugins,
80
+ ...bunOwnedJsxPlugins,
81
+ ...options.plugins ?? []
82
+ ];
54
83
  return await pageModuleImportService.importModule({
55
84
  ...options,
56
85
  ...mergedPlugins.length > 0 ? { plugins: mergedPlugins } : {},
86
+ ...typeof Bun !== "undefined" && owningJsxImportSource ? {
87
+ jsx: {
88
+ development: process.env.NODE_ENV === "development",
89
+ importSource: owningJsxImportSource,
90
+ runtime: "automatic"
91
+ }
92
+ } : {},
57
93
  buildExecutor: options.buildExecutor ?? getAppBuildExecutor(appConfig),
58
- invalidationVersion: options.invalidationVersion ?? invalidationService.getServerModuleInvalidationVersion()
94
+ invalidationVersion
59
95
  });
60
96
  },
61
97
  invalidateDevelopmentGraph() {
@@ -91,6 +91,78 @@ function resolveSpecifier(specifier, parentPath) {
91
91
  function resolveFromCore(specifier) {
92
92
  return createRequire(import.meta.url).resolve(specifier);
93
93
  }
94
+ function readPackageManifest(packageDir) {
95
+ const packageJsonPath = path.join(packageDir, "package.json");
96
+ if (!existsSync(packageJsonPath)) {
97
+ return void 0;
98
+ }
99
+ try {
100
+ return JSON.parse(readFileSync(packageJsonPath, "utf8"));
101
+ } catch {
102
+ return void 0;
103
+ }
104
+ }
105
+ function findInstalledPackageDir(packageName, parentPath) {
106
+ let currentPath = path.dirname(parentPath);
107
+ const packageSegments = packageName.split("/");
108
+ while (true) {
109
+ const candidateDir = path.join(currentPath, "node_modules", ...packageSegments);
110
+ if (existsSync(path.join(candidateDir, "package.json"))) {
111
+ return candidateDir;
112
+ }
113
+ const nextPath = path.dirname(currentPath);
114
+ if (nextPath === currentPath) {
115
+ return void 0;
116
+ }
117
+ currentPath = nextPath;
118
+ }
119
+ }
120
+ function resolvePackageExportTarget(packageDir, target) {
121
+ if (typeof target === "string") {
122
+ return path.resolve(packageDir, target);
123
+ }
124
+ if (Array.isArray(target)) {
125
+ for (const candidate of target) {
126
+ const resolvedTarget = resolvePackageExportTarget(packageDir, candidate);
127
+ if (resolvedTarget) {
128
+ return resolvedTarget;
129
+ }
130
+ }
131
+ return void 0;
132
+ }
133
+ if (!target || typeof target !== "object") {
134
+ return void 0;
135
+ }
136
+ const record = target;
137
+ return resolvePackageExportTarget(packageDir, record.import) ?? resolvePackageExportTarget(packageDir, record.default) ?? resolvePackageExportTarget(packageDir, record.require);
138
+ }
139
+ function resolveInstalledPackageTarget(specifier, parentPath) {
140
+ const packageName = getPackageNameFromSpecifier(specifier);
141
+ const packageDir = findInstalledPackageDir(packageName, parentPath);
142
+ if (!packageDir) {
143
+ return void 0;
144
+ }
145
+ const manifest = readPackageManifest(packageDir);
146
+ if (!manifest) {
147
+ return void 0;
148
+ }
149
+ const subpath = specifier === packageName ? "." : `./${specifier.slice(packageName.length + 1)}`;
150
+ const exportsField = manifest.exports;
151
+ if (exportsField !== void 0) {
152
+ const exportsRecord = exportsField;
153
+ const hasSubpathKeys = typeof exportsField === "object" && exportsField !== null && Object.keys(exportsRecord).some((key) => key.startsWith("."));
154
+ const exportTarget = hasSubpathKeys ? resolvePackageExportTarget(packageDir, exportsRecord[subpath]) : subpath === "." ? resolvePackageExportTarget(packageDir, exportsField) : void 0;
155
+ if (exportTarget && existsSync(exportTarget)) {
156
+ return exportTarget;
157
+ }
158
+ }
159
+ if (subpath !== ".") {
160
+ return void 0;
161
+ }
162
+ const mainTarget = manifest.module ?? manifest.main ?? "index.js";
163
+ const resolvedMainTarget = path.resolve(packageDir, mainTarget);
164
+ return existsSync(resolvedMainTarget) ? resolvedMainTarget : void 0;
165
+ }
94
166
  function findResolutionParent(importer, projectDir) {
95
167
  if (!importer || !path.isAbsolute(importer)) {
96
168
  return path.join(projectDir, "package.json");
@@ -135,6 +207,16 @@ function resolveNodeBootstrapDependency(args, options) {
135
207
  }
136
208
  const resolveParent = findResolutionParent(args.importer, options.projectDir);
137
209
  if (args.path.startsWith("@ecopages/")) {
210
+ const packageName = getPackageNameFromSpecifier(args.path);
211
+ const isBareWorkspacePackage = args.path === packageName;
212
+ const installedResolvedPath = resolveInstalledPackageTarget(args.path, resolveParent);
213
+ if (installedResolvedPath) {
214
+ return { path: installedResolvedPath };
215
+ }
216
+ if (!isBareWorkspacePackage) {
217
+ const resolvedSubpath = resolveSpecifier(args.path, resolveParent);
218
+ return { path: resolvedSubpath };
219
+ }
138
220
  let resolvedPath2;
139
221
  try {
140
222
  resolvedPath2 = resolveFromCore(args.path);
@@ -142,7 +224,6 @@ function resolveNodeBootstrapDependency(args, options) {
142
224
  try {
143
225
  resolvedPath2 = resolveSpecifier(args.path, resolveParent);
144
226
  } catch {
145
- const packageName = getPackageNameFromSpecifier(args.path);
146
227
  const candidatePath = path.join(options.projectDir, "node_modules", packageName);
147
228
  const candidatePackageJson = path.join(candidatePath, "package.json");
148
229
  if (existsSync(candidatePackageJson)) {
@@ -1,16 +1,33 @@
1
1
  import { build, type BuildExecutor, type BuildResult } from '../../build/build-adapter.js';
2
2
  import type { EcoBuildPlugin } from '../../build/build-types.js';
3
3
  import type { SourceModuleLoaderFactory } from './module-loading-types.js';
4
- export interface PageModuleImportOptions {
4
+ interface PageModuleImportBaseOptions {
5
5
  filePath: string;
6
- rootDir: string;
7
- outdir: string;
8
6
  bypassCache?: boolean;
9
7
  cacheScope?: string;
10
- buildExecutor?: BuildExecutor;
11
8
  invalidationVersion?: number;
9
+ }
10
+ /**
11
+ * Options for imports that must pass through the Ecopages build pipeline.
12
+ *
13
+ * @remarks
14
+ * Callers should use build mode for framework-owned page modules and any other
15
+ * source that relies on Ecopages build resolution before runtime execution.
16
+ */
17
+ export interface PageModuleBuildImportOptions extends PageModuleImportBaseOptions {
18
+ rootDir: string;
19
+ outdir: string;
20
+ buildExecutor?: BuildExecutor;
12
21
  splitting?: boolean;
13
22
  externalPackages?: boolean;
23
+ jsx?: {
24
+ development?: boolean;
25
+ factory?: string;
26
+ fragment?: string;
27
+ importSource?: string;
28
+ runtime?: 'classic' | 'automatic';
29
+ sideEffects?: boolean;
30
+ };
14
31
  plugins?: EcoBuildPlugin[];
15
32
  transpileErrorMessage?: (details: string) => string;
16
33
  noOutputMessage?: (filePath: string) => string;
@@ -30,12 +47,13 @@ export interface PageModuleImportDependencies {
30
47
  getHostModuleLoader: SourceModuleLoaderFactory;
31
48
  }
32
49
  /**
33
- * Loads source page modules in a runtime-agnostic way.
50
+ * Loads page-like modules through the Ecopages build pipeline.
34
51
  *
35
- * This service centralizes the Bun-vs-Node import strategy used by route
36
- * scanning, page data loading, and request-time page inspection. In Bun it can
37
- * import source files directly; in Node it transpiles the file into a dedicated
38
- * output directory first and then imports the generated module.
52
+ * This service centralizes the shared build-first import strategy used by route
53
+ * scanning, page data loading, and request-time page inspection. In Node
54
+ * development it can still delegate compatible source imports to the active
55
+ * host loader, but the public contract remains one transpile-targeted module
56
+ * loading path.
39
57
  *
40
58
  * Keeping this logic in one place prevents subtle drift in cache-busting,
41
59
  * transpilation settings, and error semantics across the different callers.
@@ -71,6 +89,7 @@ export declare class PageModuleImportService {
71
89
  * @param options Runtime-specific import settings.
72
90
  * @returns The loaded module.
73
91
  */
74
- importModule<T = unknown>(options: PageModuleImportOptions): Promise<T>;
92
+ importModule<T = unknown>(options: PageModuleBuildImportOptions): Promise<T>;
75
93
  private loadModule;
76
94
  }
95
+ export {};
@@ -47,8 +47,9 @@ class PageModuleImportService {
47
47
  * @returns The loaded module.
48
48
  */
49
49
  async importModule(options) {
50
- const { filePath, rootDir, externalPackages, splitting } = options;
50
+ const { filePath } = options;
51
51
  const invalidationVersion = options.invalidationVersion ?? this.developmentInvalidationVersion;
52
+ const { externalPackages, splitting } = options;
52
53
  const fileHash = this.dependencies.hashFile(filePath);
53
54
  const hostModuleLoader = typeof Bun === "undefined" && process.env.NODE_ENV === "development" && this.dependencies.canLoadSourceModuleFromHost(filePath) ? this.dependencies.getHostModuleLoader() : void 0;
54
55
  if (hostModuleLoader) {
@@ -67,10 +68,12 @@ class PageModuleImportService {
67
68
  const cacheKey = [
68
69
  runtime,
69
70
  filePath,
70
- rootDir,
71
+ options.rootDir,
71
72
  splitting ?? "default",
72
73
  externalPackages ?? "default",
73
74
  options.cacheScope ?? "default",
75
+ createJsxCacheKey(options.jsx),
76
+ createPluginCacheKey(options.plugins),
74
77
  fileHash,
75
78
  invalidationVersion
76
79
  ].join("::");
@@ -91,28 +94,20 @@ class PageModuleImportService {
91
94
  }
92
95
  }
93
96
  async loadModule(options) {
97
+ const { filePath, invalidationVersion = this.developmentInvalidationVersion, cacheScope, fileHash } = options;
94
98
  const {
95
- filePath,
96
99
  rootDir,
97
100
  outdir,
98
- invalidationVersion = this.developmentInvalidationVersion,
99
101
  splitting,
100
102
  externalPackages,
101
- cacheScope,
102
103
  transpileErrorMessage = (details) => `Error transpiling page module: ${details}`,
103
- noOutputMessage = (targetFilePath) => `No transpiled output generated for page module: ${targetFilePath}`,
104
- fileHash
104
+ noOutputMessage = (targetFilePath) => `No transpiled output generated for page module: ${targetFilePath}`
105
105
  } = options;
106
- const sourceModuleUrl = createRuntimeModuleUrl(filePath, fileHash, invalidationVersion, cacheScope);
107
- if (typeof Bun !== "undefined") {
108
- return await import(
109
- /* @vite-ignore */
110
- sourceModuleUrl.href
111
- );
112
- }
113
106
  const fileBaseName = path.basename(filePath, path.extname(filePath));
114
107
  const cacheScopeSuffix = cacheScope ? `-${sanitizeCacheScope(cacheScope)}` : "";
115
- const outputFileName = `${fileBaseName}-${fileHash}${cacheScopeSuffix}.js`;
108
+ const invalidationSuffix = shouldVersionBuildOutputPath(invalidationVersion) ? `-v${invalidationVersion}` : "";
109
+ const outputFileName = `${fileBaseName}-${fileHash}${cacheScopeSuffix}${invalidationSuffix}.js`;
110
+ const outputNamingTemplate = `${fileBaseName}-${fileHash}${cacheScopeSuffix}${invalidationSuffix}.[ext]`;
116
111
  const buildResult = await this.dependencies.buildModule(
117
112
  {
118
113
  entrypoints: [filePath],
@@ -123,8 +118,9 @@ class PageModuleImportService {
123
118
  sourcemap: "none",
124
119
  splitting: splitting ?? true,
125
120
  minify: false,
126
- naming: outputFileName,
121
+ naming: outputNamingTemplate,
127
122
  externalPackages: true,
123
+ jsx: options.jsx,
128
124
  plugins: options.plugins,
129
125
  ...externalPackages !== void 0 ? { externalPackages } : {}
130
126
  },
@@ -140,7 +136,7 @@ class PageModuleImportService {
140
136
  throw new Error(noOutputMessage(filePath));
141
137
  }
142
138
  const compiledOutputUrl = pathToFileURL(compiledOutput);
143
- if (process.env.NODE_ENV === "development" || cacheScope) {
139
+ if (shouldAddRuntimeUpdateQuery(invalidationVersion, cacheScope)) {
144
140
  compiledOutputUrl.searchParams.set(
145
141
  "update",
146
142
  [fileHash, invalidationVersion, cacheScope ? sanitizeCacheScope(cacheScope) : void 0].filter((value) => value !== void 0).join("-")
@@ -154,7 +150,7 @@ class PageModuleImportService {
154
150
  }
155
151
  function createRuntimeModuleUrl(filePath, fileHash, invalidationVersion, cacheScope) {
156
152
  const moduleUrl = pathToFileURL(filePath);
157
- if (process.env.NODE_ENV === "development" || cacheScope) {
153
+ if (shouldAddRuntimeUpdateQuery(invalidationVersion, cacheScope)) {
158
154
  moduleUrl.searchParams.set(
159
155
  "update",
160
156
  [fileHash, invalidationVersion, cacheScope ? sanitizeCacheScope(cacheScope) : void 0].filter((value) => value !== void 0).join("-")
@@ -162,9 +158,34 @@ function createRuntimeModuleUrl(filePath, fileHash, invalidationVersion, cacheSc
162
158
  }
163
159
  return moduleUrl;
164
160
  }
161
+ function shouldAddRuntimeUpdateQuery(invalidationVersion, cacheScope) {
162
+ return process.env.NODE_ENV === "development" || invalidationVersion > 0 || !!cacheScope;
163
+ }
164
+ function shouldVersionBuildOutputPath(invalidationVersion) {
165
+ return typeof Bun !== "undefined" && invalidationVersion > 0;
166
+ }
165
167
  function sanitizeCacheScope(cacheScope) {
166
168
  return cacheScope.replace(/[^a-zA-Z0-9_-]+/g, "-");
167
169
  }
170
+ function createJsxCacheKey(jsx) {
171
+ if (!jsx) {
172
+ return "jsx:default";
173
+ }
174
+ return JSON.stringify({
175
+ development: jsx.development ?? false,
176
+ factory: jsx.factory ?? null,
177
+ fragment: jsx.fragment ?? null,
178
+ importSource: jsx.importSource ?? null,
179
+ runtime: jsx.runtime ?? null,
180
+ sideEffects: jsx.sideEffects ?? null
181
+ });
182
+ }
183
+ function createPluginCacheKey(plugins) {
184
+ if (!plugins || plugins.length === 0) {
185
+ return "plugins:default";
186
+ }
187
+ return `plugins:${plugins.map((plugin) => plugin.name).join(",")}`;
188
+ }
168
189
  export {
169
190
  PageModuleImportService
170
191
  };
@@ -1,9 +1,9 @@
1
1
  import type { BuildExecutor } from '../../build/build-adapter.js';
2
2
  import type { EcoBuildPlugin } from '../../build/build-types.js';
3
3
  import type { AppModuleLoader } from './app-module-loader.service.js';
4
- import { type PageModuleImportOptions } from './page-module-import.service.js';
4
+ import { type PageModuleBuildImportOptions } from './page-module-import.service.js';
5
5
  import type { SourceModuleLoaderFactory } from './module-loading-types.js';
6
- export type ServerModuleTranspilerOptions = Omit<PageModuleImportOptions, 'rootDir' | 'buildExecutor'>;
6
+ export type ServerModuleTranspilerOptions = Omit<PageModuleBuildImportOptions, 'rootDir' | 'buildExecutor'>;
7
7
  export type ServerModuleImportDependency = Pick<AppModuleLoader, 'importModule' | 'invalidateDevelopmentGraph'>;
8
8
  /**
9
9
  * Immutable execution context for one server-transpiler instance.
@@ -127,7 +127,7 @@ export interface DefaultHmrContext {
127
127
  /**
128
128
  * Server-side module loader owned by the active app/runtime.
129
129
  */
130
- importServerModule<T = unknown>(filePath: string): Promise<T>;
130
+ importServerModule<T = unknown>(filePath: string | URL): Promise<T>;
131
131
  }
132
132
  /**
133
133
  * Represents an event broadcast to connected clients via the ClientBridge.
@@ -431,10 +431,10 @@ export interface LayoutProps<T = EcoPagesElement> extends Partial<RequestPageCon
431
431
  /**
432
432
  * Represents the props for the HTML template of a page.
433
433
  */
434
- export interface HtmlTemplateProps extends PageHeadProps {
435
- children: EcoPagesElement;
434
+ export interface HtmlTemplateProps<T = EcoPagesElement> extends PageHeadProps<T> {
435
+ children: T;
436
436
  language?: string;
437
- headContent?: EcoPagesElement;
437
+ headContent?: T;
438
438
  pageProps: Record<string, unknown>;
439
439
  }
440
440
  /**
@@ -823,6 +823,24 @@ export interface ResponseOptions {
823
823
  * Provides methods to render eco.page views and return formatted responses.
824
824
  */
825
825
  export interface RenderContext {
826
+ /**
827
+ * Import a server-executed module through the active Ecopages module loader.
828
+ *
829
+ * This applies the runtime's cache-busting and source-transpilation rules so
830
+ * request-time lazy imports participate in development invalidation.
831
+ *
832
+ * @param filePath - Absolute filesystem path or file URL for the server module
833
+ */
834
+ importServerModule<T = unknown>(filePath: string | URL): Promise<T>;
835
+ /**
836
+ * Import a server module through the active Ecopages module loader and render
837
+ * its default-exported eco.page view.
838
+ *
839
+ * @param filePath - Absolute filesystem path or file URL for the server module
840
+ * @param props - Props to pass to the default-exported view
841
+ * @param options - Optional status code and headers
842
+ */
843
+ renderServerModule<P = Record<string, unknown>>(filePath: string | URL, props?: P, options?: RenderOptions): Promise<Response>;
826
844
  /**
827
845
  * Render an eco.page view with full layout and includes.
828
846
  * @param view - The eco.page component to render
@@ -1020,7 +1038,7 @@ export interface ApiHandlerContext<TRequest extends Request = Request, TServer =
1020
1038
  * response helpers, but final document rendering stays owned by the page route
1021
1039
  * execution path.
1022
1040
  */
1023
- export interface FileRouteMiddlewareContext<TRequest extends Request = Request, TServer = any> extends Omit<ApiHandlerContext<TRequest, TServer>, 'render' | 'renderPartial'> {
1041
+ export interface FileRouteMiddlewareContext<TRequest extends Request = Request, TServer = any> extends Omit<ApiHandlerContext<TRequest, TServer>, 'render' | 'renderPartial' | 'importServerModule' | 'renderServerModule'> {
1024
1042
  }
1025
1043
  /**
1026
1044
  * Next function for middleware chain.
package/CHANGELOG.md DELETED
@@ -1,91 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to `@ecopages/core` are documented here.
4
-
5
- > **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
6
-
7
- ## [UNRELEASED] — TBD
8
-
9
- ### Features
10
-
11
- - Added app-owned runtime and build ownership around `createApp()`, host module loading, the browser-safe `eco` export, `eco.html()`, `eco.layout()`, and the published `EcoPagesAppConfig` surface.
12
- - Added boundary-plan metadata and a compatibility `renderBoundary()` payload contract for mixed-renderer orchestration.
13
-
14
- ### Refactoring
15
-
16
- - Renamed route-renderer ownership and foreign-child contracts across core so ownership planning, foreign-subtree payloads, and queued foreign-subtree resolution now use the simplified terminology.
17
- - Introduced a single `RouteRenderFlow` owner for route render preparation and execution, removing the separate execution service seam while keeping boundary planning shared.
18
- - Narrowed route-render orchestration onto an explicit `RouteRenderFlowAdapter` seam and one structural Html finalization plan, reducing callback-bag plumbing between `RouteRenderFlow` and `IntegrationRenderer`.
19
- - Renamed renderer-owned page browser asset preparation onto an explicit `buildPageBrowserGraph()` seam so route orchestration no longer treats emitted browser dependencies as a flat route-asset append.
20
- - Removed the generic HMR runtime-specifier registry and plugin registration seam so core no longer carries import-map-era runtime state that integrations no longer use.
21
- - Moved foreign-boundary ownership validation out of boundary-plan construction so route root graphs are validated before dependency and data preparation.
22
- - Moved page-package classification into the asset-processing module so render orchestration no longer carries a dedicated packaging service wrapper.
23
- - Split file-route page middleware onto its own context contract so page middleware no longer exposes handler-only `ctx.render()` helpers and the pipeline stops carrying fake render traps.
24
- - Narrowed route-renderer consumers onto explicit resolver contracts, moved filesystem custom 404 rendering back under the filesystem matcher, and shared explicit static render preparation between runtime and static generation.
25
-
26
- - Added the `@ecopages/core/dev/host-runtime` seam so host integrations such as the Vite plugin use one explicit development bridge instead of importing host-module-loader and invalidation internals directly.
27
- - Moved extension-facing merge and assertion helpers behind the integration and processor plugin entrypoints so MDX and image processing no longer depend on raw `utils/deep-merge` or `utils/invariant` package paths.
28
- - Re-exported shared build-plugin authoring types through the integration and processor plugin entrypoints so extension packages depend on plugin surfaces instead of the raw `build/build-types` module.
29
- - Removed the legacy `@ecopages/core/errors/locals-access-error` and `@ecopages/core/adapters/bun/client-bridge` exports after moving their remaining consumers to the public `errors` and root type surfaces.
30
- - Removed the unused `@ecopages/core/utils/parse-cli-args` and `@ecopages/core/services/module-loading/app-server-module-transpiler.service` exports from the published package surface.
31
- - Removed the unused `@ecopages/core/bun/create-app` and `@ecopages/core/route-renderer/template-serialization` exports to keep the published package surface aligned with the documented entrypoints.
32
- - Removed the legacy `@ecopages/core/internal-types` export now that the public root package exposes the supported `EcoPagesAppConfig` contract.
33
- - Removed the duplicate `@ecopages/core/router/client/navigation-coordinator` export in favor of the canonical `@ecopages/core/router/navigation-coordinator` subpath.
34
- - Bundled page-local component stylesheets and standard file scripts into page-owned assets before processing, reducing per-page asset fan-out in emitted HTML.
35
- - Grouped Ecopages JSX page and lazy dependency entries into one multi-entry browser build so shared chunks can be emitted once without relying on the runtime alias vendor path.
36
- - Removed the transitional flat dependency write from render preparation so final HTML injection now follows the structured page-package path only.
37
- - Consolidated runtime state around shared module-loading services, app-owned build execution, and the universal `createApp()` boundary.
38
- - Simplified route-renderer orchestration around renderer-owned boundary runtimes, shared string-boundary queue helpers, and a smaller component render context.
39
- - Centralized shared integration renderer bootstrapping so package integrations only append renderer-specific config instead of duplicating core lifecycle wiring.
40
- - Moved shared queued boundary resolution to attachment-policy payloads and constructor-injectable planning services.
41
- - Extracted shared page, layout, and document-shell composition into a narrow `RouteShellComposer` while keeping renderer-owned boundary handoff in `IntegrationRenderer`.
42
- - Removed marker-era compatibility capture, the shared route-level fallback resolver, deprecated `@ecopages/core/node*` escape hatches, and other dead route-renderer internals.
43
- - Replaced the split `FSRouter` and `FSRouterScanner` flow with one `RouteRegistry` seam for filesystem route discovery, request matching, and static-generation planning.
44
-
45
- ### Bug Fixes
46
-
47
- - Fixed integration registry typing so `ConfigBuilder.setIntegrations()` accepts heterogeneous framework plugins without rejecting valid JSX or React integrations at type-check time.
48
- - Fixed page-owned dependency packaging so final Html output suppresses bundled source stylesheet assets that were reintroduced later during shell-time asset merging.
49
- - Fixed development page dependency packaging so script Dependencies stay source-backed for HMR instead of being collapsed into one page-owned script bundle.
50
- - Fixed Bun preview builds to start the static preview server only after static generation releases the live build server port, preventing preview mode from double-binding the configured port.
51
- - Fixed router-owned HMR current-page reloads to clear persisted layout caches so active shared layouts pick up updated implementations during development.
52
- - Fixed router-owned HMR layout refreshes to reuse the active HMR page entry instead of stale static bootstrap assets during persisted-layout reloads.
53
- - Fixed fetch-mode static generation to normalize absolute routes onto the active build runtime origin, restoring preview prerendering for routes discovered from absolute router entries.
54
- - Fixed global lazy-trigger bootstrap emission to inline the full bootstrap runtime in final HTML, removing separate initial injector bootstrap and runtime requests.
55
- - Fixed Ecopages JSX lazy-trigger finalization to preserve SSR custom-element markup nodes instead of coercing them to `[object Object]` inside parent renders.
56
- - Fixed legacy scripts-injector wrapping and grouped content-script bundling cleanup so non-string JSX SSR output stays intact and failed grouped builds do not leak temporary entries.
57
- - Fixed Ecopages JSX dependency resolution so page bundling now follows only declared `dependencies.scripts` entries, preventing SSR-only imports and lazy-declared scripts from being promoted into the page bundle.
58
- - Fixed Ecopages JSX lazy dependency bundling to keep page and lazy entries separate, preventing lazy scripts from forcing extra shared chunk requests into the page bundle.
59
- - Fixed Ecopages JSX dependency emission to collapse bundleable component CSS and module scripts into page-owned assets while preserving lazy trigger scripts.
60
- - Fixed Ecopages JSX page-owned dependency bundles to keep using the shared JSX and Radiant vendor runtimes so lazy chunks do not trigger duplicate runtime downloads.
61
- - Fixed Ecopages JSX page-owned browser bundles to keep intrinsic custom-element scripts out of final HTML when the current component tree already imports them, reducing docs home page script fan-out to one page bundle.
62
- - Fixed Node static builds so `ecopages build` no longer fails when the configured serve port is already in use.
63
- - Fixed mixed-integration page, layout, document, and component rendering to resolve foreign boundaries inside their owning renderer across the built-in integrations.
64
- - Fixed host/runtime module loading, published build-helper exports, asset output normalization, explicit render flows, and static or preview build stability across Bun, Node, Vite, and Nitro.
65
- - Fixed development project watcher setup to register chokidar paths and handlers only once per app runtime.
66
- - Fixed development script-entry registration to build only the requested HMR entrypoint instead of fanning out across all watched script entrypoints during startup.
67
- - Fixed Node bootstrap runtime package linking to refresh dangling `.eco/node_modules` symlinks instead of failing with `EEXIST` during page transpilation.
68
- - Fixed request-time and static-generation page inspection to preserve integration-specific page loading without reusing the normal render module identity.
69
- - Fixed Node preview and static-generation React runtime resolution so app-owned page modules and server rendering share one React module identity.
70
- - Fixed Bun browser output normalization so batched multi-entrypoint HMR rebuilds match emitted files to their expected served paths instead of Bun output order.
71
- - Fixed render-preparation graph traversal so sparse component dependency arrays do not break custom 404 rendering or file-system response fallback flows.
72
-
73
- ### Documentation
74
-
75
- - Added architecture and API documentation for config, plugins, services, adapters, HMR, routing, and rendering.
76
- - Documented that Html-owned dependency assets stay shared while page, layout, and component dependency assets are packaged per route.
77
-
78
- ### Tests
79
-
80
- - Added regression coverage for app-owned runtime services, Node fallback paths, and cross-runtime invalidation behavior.
81
- - Strengthened the core ghtml integration tests so route and explicit render paths await real outcomes and cover `renderToResponse` behavior.
82
- - Added core regression coverage for boundary plans, payload contracts, and typed mixed-boundary context flow.
83
- - Added router and static-generation regression coverage for `RouteRegistry`, explicit static route expansion, and file-response fallbacks.
84
-
85
- ---
86
-
87
- ## Migration Notes
88
-
89
- - `createApp` is now the recommended entrypoint. Import it from `@ecopages/core/create-app`.
90
- - `defineApiHandler` keeps the same call shape, but the handler context is now explicitly runtime-agnostic.
91
- - The old explicit `renderingMode` config option has been removed and full orchestration is always active.