@ecopages/core 0.2.0-alpha.12 → 0.2.0-alpha.14

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 (51) hide show
  1. package/CHANGELOG.md +7 -28
  2. package/README.md +5 -4
  3. package/package.json +2 -2
  4. package/src/adapters/bun/hmr-manager.js +2 -2
  5. package/src/adapters/node/node-hmr-manager.js +2 -2
  6. package/src/adapters/node/server-adapter.d.ts +2 -2
  7. package/src/adapters/node/server-adapter.js +5 -5
  8. package/src/build/build-adapter.d.ts +7 -6
  9. package/src/build/build-adapter.js +6 -7
  10. package/src/eco/eco.js +15 -6
  11. package/src/eco/eco.utils.d.ts +1 -1
  12. package/src/eco/eco.utils.js +5 -1
  13. package/src/hmr/hmr-strategy.d.ts +2 -2
  14. package/src/integrations/ghtml/ghtml-renderer.d.ts +6 -1
  15. package/src/integrations/ghtml/ghtml-renderer.js +29 -28
  16. package/src/plugins/integration-plugin.d.ts +1 -24
  17. package/src/plugins/integration-plugin.js +0 -14
  18. package/src/route-renderer/GRAPH.md +54 -84
  19. package/src/route-renderer/README.md +11 -22
  20. package/src/route-renderer/orchestration/component-render-context.d.ts +33 -84
  21. package/src/route-renderer/orchestration/component-render-context.js +30 -108
  22. package/src/route-renderer/orchestration/integration-renderer.d.ts +219 -96
  23. package/src/route-renderer/orchestration/integration-renderer.js +416 -236
  24. package/src/route-renderer/orchestration/queued-boundary-runtime.service.d.ts +93 -0
  25. package/src/route-renderer/orchestration/queued-boundary-runtime.service.js +155 -0
  26. package/src/route-renderer/orchestration/render-execution.service.d.ts +8 -71
  27. package/src/route-renderer/orchestration/render-execution.service.js +28 -115
  28. package/src/route-renderer/orchestration/render-output.utils.d.ts +6 -0
  29. package/src/route-renderer/orchestration/render-output.utils.js +25 -0
  30. package/src/route-renderer/orchestration/render-preparation.service.d.ts +0 -9
  31. package/src/route-renderer/orchestration/render-preparation.service.js +3 -34
  32. package/src/route-renderer/page-loading/dependency-resolver.js +6 -1
  33. package/src/route-renderer/page-loading/page-module-loader.d.ts +1 -2
  34. package/src/route-renderer/page-loading/page-module-loader.js +0 -2
  35. package/src/router/client/navigation-coordinator.js +2 -2
  36. package/src/router/server/fs-router-scanner.js +6 -1
  37. package/src/services/runtime-state/dev-graph.service.d.ts +5 -5
  38. package/src/services/runtime-state/dev-graph.service.js +10 -10
  39. package/src/types/public-types.d.ts +2 -5
  40. package/src/eco/component-render-context.d.ts +0 -2
  41. package/src/eco/component-render-context.js +0 -12
  42. package/src/route-renderer/component-graph/component-graph-executor.d.ts +0 -33
  43. package/src/route-renderer/component-graph/component-graph-executor.js +0 -30
  44. package/src/route-renderer/component-graph/component-graph.d.ts +0 -53
  45. package/src/route-renderer/component-graph/component-graph.js +0 -94
  46. package/src/route-renderer/component-graph/component-marker.d.ts +0 -52
  47. package/src/route-renderer/component-graph/component-marker.js +0 -44
  48. package/src/route-renderer/component-graph/component-reference.d.ts +0 -10
  49. package/src/route-renderer/component-graph/component-reference.js +0 -34
  50. package/src/route-renderer/component-graph/marker-graph-resolver.d.ts +0 -79
  51. package/src/route-renderer/component-graph/marker-graph-resolver.js +0 -117
@@ -6,11 +6,13 @@ import { HttpError } from "../../errors/http-error.js";
6
6
  import { LocalsAccessError } from "../../errors/locals-access-error.js";
7
7
  import { DependencyResolverService } from "../page-loading/dependency-resolver.js";
8
8
  import { PageModuleLoaderService } from "../page-loading/page-module-loader.js";
9
- import { MarkerGraphResolver } from "../component-graph/marker-graph-resolver.js";
10
- import { createComponentMarker, parseComponentMarkers } from "../component-graph/component-marker.js";
11
9
  import { RenderExecutionService } from "./render-execution.service.js";
12
10
  import { RenderPreparationService } from "./render-preparation.service.js";
13
- import { runWithComponentRenderContext } from "./component-render-context.js";
11
+ import { normalizeBoundaryArtifactHtml } from "./render-output.utils.js";
12
+ import { getComponentRenderContext, runWithComponentRenderContext } from "./component-render-context.js";
13
+ import {
14
+ QueuedBoundaryRuntimeService
15
+ } from "./queued-boundary-runtime.service.js";
14
16
  function createLocalsProxy(filePath) {
15
17
  const errorMessage = `[ecopages] Request locals are only available during request-time rendering with cache: 'dynamic'. Page: ${filePath}. If you meant to use locals here, set cache: 'dynamic' and provide locals from route middleware/handlers.`;
16
18
  return new Proxy(
@@ -40,57 +42,7 @@ function createLocalsProxy(filePath) {
40
42
  }
41
43
  );
42
44
  }
43
- function protectPassedThroughMarkers(html, propsByRef) {
44
- let protectedHtml = html;
45
- const placeholders = /* @__PURE__ */ new Map();
46
- let index = 0;
47
- for (const marker of parseComponentMarkers(html)) {
48
- if (marker.propsRef in propsByRef) {
49
- continue;
50
- }
51
- const markerHtml = createComponentMarker(marker);
52
- const placeholder = `<!--__ECO_PASSTHROUGH_MARKER_${index}__-->`;
53
- index += 1;
54
- placeholders.set(placeholder, markerHtml);
55
- protectedHtml = protectedHtml.replace(markerHtml, placeholder);
56
- }
57
- return {
58
- html: protectedHtml,
59
- restore: (nextHtml) => {
60
- let restoredHtml = nextHtml;
61
- for (const [placeholder, markerHtml] of placeholders) {
62
- restoredHtml = restoredHtml.replaceAll(placeholder, markerHtml);
63
- }
64
- return restoredHtml;
65
- }
66
- };
67
- }
68
- function createRendererDeferredTemplateValueSerializer(serializers) {
69
- if (!serializers || serializers.length === 0) {
70
- return void 0;
71
- }
72
- return (value, serializeValue) => {
73
- for (const serializer of serializers) {
74
- if (serializer.matches(value)) {
75
- return serializer.serialize(value, serializeValue);
76
- }
77
- }
78
- return void 0;
79
- };
80
- }
81
45
  class IntegrationRenderer {
82
- /**
83
- * Integration-owned serializers for deferred template payloads that may cross
84
- * mixed-renderer boundaries before final HTML assembly.
85
- *
86
- * @remarks
87
- * Declare framework-specific template shape adapters here when the integration
88
- * can emit deferred child payloads that core must serialize generically.
89
- * The base renderer registers these serializers automatically during
90
- * construction, so integrations should prefer this colocated declaration over
91
- * side-effect imports or ad hoc bootstrap registration.
92
- */
93
- static deferredTemplateSerializers;
94
46
  appConfig;
95
47
  assetProcessingService;
96
48
  htmlTransformer;
@@ -100,11 +52,55 @@ class IntegrationRenderer {
100
52
  runtimeOrigin;
101
53
  dependencyResolverService;
102
54
  pageModuleLoaderService;
103
- markerGraphResolver;
104
55
  renderPreparationService;
105
56
  renderExecutionService;
106
- deferredTemplateValueSerializer;
57
+ queuedBoundaryRuntimeService = new QueuedBoundaryRuntimeService();
107
58
  DOC_TYPE = "<!DOCTYPE html>";
59
+ /**
60
+ * Reads the execution-scoped foreign renderer cache from one boundary input.
61
+ *
62
+ * Shared page/layout/document shell helpers pass one cache through
63
+ * `integrationContext` so repeated delegation to the same foreign integration
64
+ * can reuse a single initialized renderer instance during one render flow.
65
+ * The cache is deliberately scoped to the current render execution rather than
66
+ * stored on the renderer, which avoids leaking mutable integration state across
67
+ * requests while still preventing redundant renderer initialization.
68
+ *
69
+ * @param integrationContext - Optional boundary context carried with one render input.
70
+ * @returns The current execution cache when present.
71
+ */
72
+ getBoundaryRendererCache(integrationContext) {
73
+ if (typeof integrationContext === "object" && integrationContext !== null && "rendererCache" in integrationContext && integrationContext.rendererCache instanceof Map) {
74
+ return integrationContext.rendererCache;
75
+ }
76
+ return void 0;
77
+ }
78
+ getRegisteredBoundaryOwner(component) {
79
+ const integrationName = component.config?.integration ?? component.config?.__eco?.integration;
80
+ if (!integrationName || integrationName === this.name) {
81
+ return void 0;
82
+ }
83
+ return this.appConfig.integrations.some((integration) => integration.name === integrationName) ? integrationName : void 0;
84
+ }
85
+ /**
86
+ * Attaches an execution-scoped foreign renderer cache to one boundary input.
87
+ *
88
+ * Foreign-owned page, layout, or document shells may delegate several times in
89
+ * the same render flow. Threading the cache through `integrationContext`
90
+ * preserves renderer reuse without changing the public boundary input contract.
91
+ * Existing integration-specific context is preserved and augmented.
92
+ *
93
+ * @param input - Original boundary render input.
94
+ * @param rendererCache - Execution-scoped renderer cache to propagate.
95
+ * @returns Boundary input augmented with the shared renderer cache.
96
+ */
97
+ withBoundaryRendererCache(input, rendererCache) {
98
+ const integrationContext = input.integrationContext;
99
+ return {
100
+ ...input,
101
+ integrationContext: typeof integrationContext === "object" && integrationContext !== null ? { ...integrationContext, rendererCache } : { rendererCache }
102
+ };
103
+ }
108
104
  getRendererModuleValue(key) {
109
105
  if (!this.rendererModules || typeof this.rendererModules !== "object") {
110
106
  return void 0;
@@ -195,6 +191,265 @@ class IntegrationRenderer {
195
191
  this.htmlTransformer.setProcessedDependencies(resolvedDependencies);
196
192
  return resolvedDependencies;
197
193
  }
194
+ /**
195
+ * Merges component-scoped assets into the active HTML transformer state.
196
+ *
197
+ * Explicit page, layout, and document shell composition can produce assets at
198
+ * each boundary. This helper deduplicates those groups and folds them back into
199
+ * the transformer so downstream HTML finalization sees one canonical asset set.
200
+ *
201
+ * @param assetGroups - Optional groups of processed assets to merge.
202
+ * @returns The deduplicated asset subset contributed by this merge operation.
203
+ */
204
+ appendProcessedDependencies(...assetGroups) {
205
+ const nextDependencies = this.htmlTransformer.dedupeProcessedAssets(
206
+ assetGroups.flatMap((assets) => assets ?? [])
207
+ );
208
+ if (nextDependencies.length === 0) {
209
+ return nextDependencies;
210
+ }
211
+ this.htmlTransformer.setProcessedDependencies(
212
+ this.htmlTransformer.dedupeProcessedAssets([
213
+ ...this.htmlTransformer.getProcessedDependencies(),
214
+ ...nextDependencies
215
+ ])
216
+ );
217
+ return nextDependencies;
218
+ }
219
+ /**
220
+ * Resolves metadata for explicit view rendering.
221
+ *
222
+ * When a view declares a `metadata()` function, that contract owns the final
223
+ * metadata for the explicit render. Otherwise the app-level default metadata is
224
+ * reused so explicit routes and page-module routes share the same fallback.
225
+ *
226
+ * @param view - View component being rendered.
227
+ * @param props - Props passed to the view.
228
+ * @returns Resolved metadata for the final document shell.
229
+ */
230
+ async resolveViewMetadata(view, props) {
231
+ return view.metadata ? await view.metadata({
232
+ params: {},
233
+ query: {},
234
+ props,
235
+ appConfig: this.appConfig
236
+ }) : this.appConfig.defaultMetadata;
237
+ }
238
+ /**
239
+ * Renders one explicit view response in partial mode.
240
+ *
241
+ * Same-integration views can optionally stream or render inline via the caller's
242
+ * `renderInline()` hook. Once a view may cross integration boundaries, this
243
+ * helper routes the render through `renderComponentBoundary()` instead so mixed
244
+ * shells can reuse the execution-scoped renderer cache and resolve nested
245
+ * foreign ownership before the partial response is returned.
246
+ *
247
+ * @param input - View render options for the partial response.
248
+ * @returns HTML response for the partial render.
249
+ */
250
+ async renderPartialViewResponse(input) {
251
+ if (input.renderInline && !this.hasForeignBoundaryDescendants(input.view)) {
252
+ return this.createHtmlResponse(await input.renderInline(), input.ctx);
253
+ }
254
+ const rendererCache = /* @__PURE__ */ new Map();
255
+ const viewRender = await this.renderComponentBoundary({
256
+ component: input.view,
257
+ props: input.props ?? {},
258
+ integrationContext: { rendererCache }
259
+ });
260
+ const html = input.transformHtml ? input.transformHtml(viewRender.html) : viewRender.html;
261
+ return this.createHtmlResponse(html, input.ctx);
262
+ }
263
+ /**
264
+ * Renders an explicit view through optional layout and document shells.
265
+ *
266
+ * This helper is the shared explicit-route path for string-oriented and mixed
267
+ * integrations. It prepares view dependencies, resolves metadata, and composes
268
+ * view, layout, and html template boundaries with one execution-scoped renderer
269
+ * cache so repeated foreign shell delegation can reuse initialized renderers
270
+ * during the same render flow.
271
+ *
272
+ * @param input - View, props, and optional layout metadata for the render.
273
+ * @returns HTML response for the explicit view render.
274
+ */
275
+ async renderViewWithDocumentShell(input) {
276
+ const normalizedProps = input.props ?? {};
277
+ if (input.ctx.partial) {
278
+ return this.renderPartialViewResponse({
279
+ view: input.view,
280
+ props: input.props,
281
+ ctx: input.ctx
282
+ });
283
+ }
284
+ await this.prepareViewDependencies(input.view, input.layout);
285
+ const HtmlTemplate = await this.getHtmlTemplate();
286
+ const metadata = await this.resolveViewMetadata(input.view, input.props);
287
+ const rendererCache = /* @__PURE__ */ new Map();
288
+ const viewRender = await this.renderComponentBoundary({
289
+ component: input.view,
290
+ props: normalizedProps,
291
+ integrationContext: { rendererCache }
292
+ });
293
+ const layoutRender = input.layout ? await this.renderComponentBoundary({
294
+ component: input.layout,
295
+ props: {},
296
+ children: viewRender.html,
297
+ integrationContext: { rendererCache }
298
+ }) : void 0;
299
+ const documentRender = await this.renderComponentBoundary({
300
+ component: HtmlTemplate,
301
+ props: {
302
+ metadata,
303
+ pageProps: normalizedProps
304
+ },
305
+ children: layoutRender?.html ?? viewRender.html,
306
+ integrationContext: { rendererCache }
307
+ });
308
+ this.appendProcessedDependencies(viewRender.assets, layoutRender?.assets, documentRender.assets);
309
+ const html = await this.finalizeResolvedHtml({
310
+ html: `${this.DOC_TYPE}${documentRender.html}`,
311
+ partial: false
312
+ });
313
+ return this.createHtmlResponse(html, input.ctx);
314
+ }
315
+ /**
316
+ * Renders a route page through optional layout and document shells.
317
+ *
318
+ * Route rendering and explicit view rendering now share the same boundary-owned
319
+ * shell composition model. This helper composes page, layout, and html template
320
+ * boundaries while threading one execution-scoped renderer cache through every
321
+ * delegated boundary so foreign shell ownership remains stable and renderer
322
+ * initialization is reused inside the current request.
323
+ *
324
+ * @param input - Page, layout, document, and metadata inputs for the route render.
325
+ * @returns Final serialized document HTML including the doctype prefix.
326
+ */
327
+ async renderPageWithDocumentShell(input) {
328
+ const rendererCache = /* @__PURE__ */ new Map();
329
+ const pageRender = await this.renderComponentBoundary({
330
+ component: input.page.component,
331
+ props: input.page.props,
332
+ integrationContext: { rendererCache }
333
+ });
334
+ const layoutRender = input.layout ? await this.renderComponentBoundary({
335
+ component: input.layout.component,
336
+ props: input.layout.props ?? {},
337
+ children: pageRender.html,
338
+ integrationContext: { rendererCache }
339
+ }) : void 0;
340
+ const documentRender = await this.renderComponentBoundary({
341
+ component: input.htmlTemplate,
342
+ props: {
343
+ metadata: input.metadata,
344
+ pageProps: input.pageProps,
345
+ ...input.documentProps ?? {}
346
+ },
347
+ children: layoutRender?.html ?? pageRender.html,
348
+ integrationContext: { rendererCache }
349
+ });
350
+ this.appendProcessedDependencies(pageRender.assets, layoutRender?.assets, documentRender.assets);
351
+ const documentHtml = input.transformDocumentHtml ? input.transformDocumentHtml(documentRender.html) : documentRender.html;
352
+ return `${this.DOC_TYPE}${documentHtml}`;
353
+ }
354
+ /**
355
+ * Renders one string-first component boundary and collects its assets.
356
+ *
357
+ * String-oriented integrations frequently share the same boundary contract:
358
+ * pass serialized children through props, coerce the render result to HTML, and
359
+ * attach any component-scoped dependencies. This helper centralizes that flow
360
+ * so integrations can opt into shared orchestration without repeating the same
361
+ * boundary boilerplate.
362
+ *
363
+ * @param input - Boundary render input.
364
+ * @param component - String-oriented component implementation to execute.
365
+ * @returns Structured component render result for orchestration paths.
366
+ */
367
+ async renderStringComponentBoundary(input, component) {
368
+ const props = input.children === void 0 ? input.props : { ...input.props, children: input.children };
369
+ const content = await component(props);
370
+ const html = String(content);
371
+ const assets = input.component.config?.dependencies && typeof this.assetProcessingService?.processDependencies === "function" ? await this.processComponentDependencies([input.component]) : void 0;
372
+ return {
373
+ html,
374
+ canAttachAttributes: true,
375
+ rootTag: this.getRootTagName(html),
376
+ integrationName: this.name,
377
+ assets
378
+ };
379
+ }
380
+ getBoundaryTokenPrefix() {
381
+ return `__${this.name}_boundary__`;
382
+ }
383
+ getBoundaryRuntimeContextKey() {
384
+ return `__${this.name}_boundary_runtime__`;
385
+ }
386
+ getQueuedBoundaryRuntime(input, runtimeContextKey = this.getBoundaryRuntimeContextKey()) {
387
+ return this.queuedBoundaryRuntimeService.getRuntimeContext(input, runtimeContextKey);
388
+ }
389
+ async resolveQueuedBoundaryTokens(html, queuedResolutionsByToken, resolveToken) {
390
+ let resolvedHtml = html;
391
+ for (const token of queuedResolutionsByToken.keys()) {
392
+ if (!resolvedHtml.includes(token)) {
393
+ continue;
394
+ }
395
+ resolvedHtml = resolvedHtml.split(token).join(await resolveToken(token));
396
+ }
397
+ return resolvedHtml;
398
+ }
399
+ createQueuedBoundaryRuntime(options) {
400
+ return this.queuedBoundaryRuntimeService.createRuntime({
401
+ boundaryInput: options.boundaryInput,
402
+ rendererCache: options.rendererCache,
403
+ runtimeContextKey: options.runtimeContextKey ?? this.getBoundaryRuntimeContextKey(),
404
+ tokenPrefix: options.tokenPrefix ?? this.getBoundaryTokenPrefix(),
405
+ shouldQueueBoundary: (input) => this.shouldResolveBoundaryInOwningRenderer(input),
406
+ createRuntimeContext: options.createRuntimeContext
407
+ });
408
+ }
409
+ async resolveRendererOwnedQueuedBoundaryHtml(options) {
410
+ return this.queuedBoundaryRuntimeService.resolveQueuedHtml({
411
+ html: options.html,
412
+ runtimeContext: options.runtimeContext,
413
+ queueLabel: options.queueLabel,
414
+ renderQueuedChildren: options.renderQueuedChildren,
415
+ resolveBoundary: (input, rendererCache) => this.resolveBoundaryInOwningRenderer(input, rendererCache),
416
+ applyAttributesToFirstElement: (html, attributes) => this.htmlTransformer.applyAttributesToFirstElement(html, attributes),
417
+ dedupeProcessedAssets: (assets) => this.htmlTransformer.dedupeProcessedAssets(assets)
418
+ });
419
+ }
420
+ /**
421
+ * Renders a string-first component, then resolves any queued foreign
422
+ * boundaries before returning final component HTML.
423
+ */
424
+ async renderStringComponentBoundaryWithQueuedForeignBoundaries(input, component) {
425
+ const componentRender = await this.renderStringComponentBoundary(input, component);
426
+ const queuedBoundaryResolution = await this.resolveRendererOwnedQueuedBoundaryHtml({
427
+ html: componentRender.html,
428
+ runtimeContext: this.getQueuedBoundaryRuntime(input),
429
+ queueLabel: "String",
430
+ renderQueuedChildren: async (children, _runtimeContext, queuedResolutionsByToken, resolveToken) => {
431
+ if (children === void 0) {
432
+ return { assets: [], html: void 0 };
433
+ }
434
+ const html = await this.resolveQueuedBoundaryTokens(
435
+ typeof children === "string" ? children : String(children ?? ""),
436
+ queuedResolutionsByToken,
437
+ resolveToken
438
+ );
439
+ return { assets: [], html };
440
+ }
441
+ });
442
+ const mergedAssets = this.htmlTransformer.dedupeProcessedAssets([
443
+ ...componentRender.assets ?? [],
444
+ ...queuedBoundaryResolution.assets
445
+ ]);
446
+ return {
447
+ ...componentRender,
448
+ html: queuedBoundaryResolution.html,
449
+ rootTag: this.getRootTagName(queuedBoundaryResolution.html),
450
+ assets: mergedAssets.length > 0 ? mergedAssets : void 0
451
+ };
452
+ }
198
453
  constructor({
199
454
  appConfig,
200
455
  assetProcessingService,
@@ -210,13 +465,8 @@ class IntegrationRenderer {
210
465
  this.runtimeOrigin = runtimeOrigin;
211
466
  this.dependencyResolverService = new DependencyResolverService(appConfig, assetProcessingService);
212
467
  this.pageModuleLoaderService = new PageModuleLoaderService(appConfig, runtimeOrigin);
213
- this.markerGraphResolver = new MarkerGraphResolver();
214
468
  this.renderPreparationService = new RenderPreparationService(appConfig, assetProcessingService);
215
469
  this.renderExecutionService = new RenderExecutionService();
216
- const rendererClass = this.constructor;
217
- this.deferredTemplateValueSerializer = createRendererDeferredTemplateValueSerializer(
218
- rendererClass.deferredTemplateSerializers
219
- );
220
470
  }
221
471
  /**
222
472
  * Returns the HTML path from the provided file path.
@@ -384,15 +634,13 @@ class IntegrationRenderer {
384
634
  resolveDependencies: (components) => this.resolveDependencies(components),
385
635
  buildRouteRenderAssets: (file) => this.buildRouteRenderAssets(file),
386
636
  shouldRenderPageComponent: (input) => this.shouldRenderPageComponent(input),
387
- renderPageComponent: ({ component, props }) => this.renderComponent({
637
+ renderPageComponent: ({ component, props }) => this.renderComponentBoundary({
388
638
  component,
389
639
  props,
390
640
  integrationContext: {
391
641
  componentInstanceId: "eco-page-root"
392
642
  }
393
643
  }),
394
- getComponentRenderBoundaryContext: () => this.getComponentRenderBoundaryContext(),
395
- serializeDeferredValue: this.deferredTemplateValueSerializer,
396
644
  setProcessedDependencies: (dependencies) => this.htmlTransformer.setProcessedDependencies(dependencies),
397
645
  dedupeProcessedAssets: (assets) => this.htmlTransformer.dedupeProcessedAssets(assets),
398
646
  createPageLocalsProxy: (filePath) => createLocalsProxy(filePath)
@@ -432,11 +680,10 @@ class IntegrationRenderer {
432
680
  *
433
681
  * Execution flow:
434
682
  * 1. Build normalized render options (`prepareRenderOptions`).
435
- * 2. Render once inside component render context to capture marker graph refs.
436
- * 3. Merge captured refs with optional explicit page-module graph context.
437
- * 4. Resolve any `eco-marker` graph bottom-up and merge produced assets.
438
- * 5. Optionally apply root attributes for page/component root boundaries.
439
- * 6. Run HTML transformer with final dependency set.
683
+ * 2. Render the route body once.
684
+ * 3. Reject unresolved route-level boundary artifacts.
685
+ * 4. Optionally apply root attributes for page/component root boundaries.
686
+ * 5. Run HTML transformer with final dependency set.
440
687
  *
441
688
  * Stream-safety note: the first render result is normalized to a string once,
442
689
  * then the pipeline continues with that immutable HTML value to avoid disturbed
@@ -446,87 +693,41 @@ class IntegrationRenderer {
446
693
  * @returns Rendered route body plus effective cache strategy.
447
694
  */
448
695
  async execute(options) {
449
- return this.renderExecutionService.execute(options, this.name, {
696
+ return this.renderExecutionService.execute(options, {
450
697
  prepareRenderOptions: (routeOptions) => this.prepareRenderOptions(routeOptions),
451
698
  render: (renderOptions) => this.render(renderOptions),
452
- getComponentRenderBoundaryContext: () => this.getComponentRenderBoundaryContext(),
453
- serializeDeferredValue: this.deferredTemplateValueSerializer,
454
- resolveMarkerGraphHtml: (input) => this.resolveMarkerGraphHtml({
455
- html: input.html,
456
- componentsToResolve: input.componentsToResolve,
457
- graphContext: input.graphContext
458
- }),
459
699
  getDocumentAttributes: (renderOptions) => this.getDocumentAttributes(renderOptions),
460
- dedupeProcessedAssets: (assets) => this.htmlTransformer.dedupeProcessedAssets(assets),
461
- getProcessedDependencies: () => this.htmlTransformer.getProcessedDependencies(),
462
- setProcessedDependencies: (dependencies) => this.htmlTransformer.setProcessedDependencies(dependencies),
463
700
  applyAttributesToHtmlElement: (html, attributes) => this.htmlTransformer.applyAttributesToHtmlElement(html, attributes),
464
701
  applyAttributesToFirstBodyElement: (html, attributes) => this.htmlTransformer.applyAttributesToFirstBodyElement(html, attributes),
465
702
  transformResponse: async (response) => {
466
703
  const transformedResponse = await this.htmlTransformer.transform(response);
467
- return transformedResponse.body;
704
+ return transformedResponse.body ?? await transformedResponse.text();
468
705
  }
469
706
  });
470
707
  }
471
708
  /**
472
- * Captures a render pass as immutable HTML along with the graph context needed
473
- * for deferred marker resolution.
709
+ * Finalizes already-resolved HTML for explicit renderer-owned paths.
474
710
  *
475
- * This is the shared entry point for direct `renderToResponse()` flows that
476
- * need the same component graph capture semantics as route execution without
477
- * going through `prepareRenderOptions()`.
711
+ * This keeps document and root-attribute stamping plus HTML transformation
712
+ * available after a renderer has completed nested boundary resolution without
713
+ * routing back through shared route execution.
478
714
  */
479
- async captureHtmlRender(render) {
480
- return this.renderExecutionService.captureHtmlRender(
481
- this.name,
482
- this.getComponentRenderBoundaryContext(),
483
- this.deferredTemplateValueSerializer,
484
- render
485
- );
486
- }
487
- /**
488
- * Finalizes previously captured HTML by resolving deferred markers, merging
489
- * any emitted assets, stamping optional attributes, and optionally running the
490
- * HTML transformer for full-document flows.
491
- */
492
- async finalizeCapturedHtmlRender(options) {
715
+ async finalizeResolvedHtml(options) {
493
716
  const rendererBootstrapDependencies = this.getRendererBootstrapDependencies(options.partial);
494
- if (rendererBootstrapDependencies.length > 0) {
495
- this.htmlTransformer.setProcessedDependencies(
496
- this.htmlTransformer.dedupeProcessedAssets([
497
- ...this.htmlTransformer.getProcessedDependencies(),
498
- ...rendererBootstrapDependencies
499
- ])
500
- );
717
+ this.appendProcessedDependencies(rendererBootstrapDependencies);
718
+ let html = options.html;
719
+ if (options.componentRootAttributes && Object.keys(options.componentRootAttributes).length > 0) {
720
+ html = this.htmlTransformer.applyAttributesToFirstBodyElement(html, options.componentRootAttributes);
721
+ }
722
+ if (options.documentAttributes && Object.keys(options.documentAttributes).length > 0) {
723
+ html = this.htmlTransformer.applyAttributesToHtmlElement(html, options.documentAttributes);
501
724
  }
502
- const finalization = await this.renderExecutionService.finalizeHtmlRender(
503
- {
504
- html: options.html,
505
- graphContext: options.graphContext,
506
- componentsToResolve: options.componentsToResolve,
507
- componentRootAttributes: options.componentRootAttributes,
508
- documentAttributes: options.documentAttributes,
509
- mergeAssets: options.mergeAssets ?? !options.partial
510
- },
511
- {
512
- resolveMarkerGraphHtml: (input) => this.resolveMarkerGraphHtml({
513
- html: input.html,
514
- componentsToResolve: input.componentsToResolve,
515
- graphContext: input.graphContext
516
- }),
517
- dedupeProcessedAssets: (assets) => this.htmlTransformer.dedupeProcessedAssets(assets),
518
- getProcessedDependencies: () => this.htmlTransformer.getProcessedDependencies(),
519
- setProcessedDependencies: (dependencies) => this.htmlTransformer.setProcessedDependencies(dependencies),
520
- applyAttributesToHtmlElement: (html, attributes) => this.htmlTransformer.applyAttributesToHtmlElement(html, attributes),
521
- applyAttributesToFirstBodyElement: (html, attributes) => this.htmlTransformer.applyAttributesToFirstBodyElement(html, attributes)
522
- }
523
- );
524
725
  const shouldTransform = options.transformHtml ?? !options.partial;
525
726
  if (!shouldTransform) {
526
- return finalization.html;
727
+ return html;
527
728
  }
528
729
  const transformedResponse = await this.htmlTransformer.transform(
529
- new Response(finalization.html, {
730
+ new Response(html, {
530
731
  headers: { "Content-Type": "text/html" }
531
732
  })
532
733
  );
@@ -541,40 +742,6 @@ class IntegrationRenderer {
541
742
  getDocumentAttributes(_renderOptions) {
542
743
  return void 0;
543
744
  }
544
- /**
545
- * Resolves all `eco-marker` placeholders in rendered HTML using integration
546
- * dispatch and bottom-up graph execution.
547
- *
548
- * Responsibility split:
549
- * - core decodes markers into component refs, props, slot children, and target
550
- * integration dispatch
551
- * - the selected integration renderer performs the actual component render via
552
- * `renderComponent()`
553
- *
554
- * Resolver callback behavior per marker:
555
- * - resolve component definition by `componentRef`
556
- * - resolve serialized props by `propsRef`
557
- * - stitch resolved child HTML when `slotRef` is present
558
- * - dispatch to target integration `renderComponent`
559
- * - collect produced assets and apply root attributes when attachable
560
- *
561
- * @param options.html HTML that may still contain marker tokens.
562
- * @param options.componentsToResolve Component set used to build component ref registry.
563
- * @param options.graphContext Props/slot linkage captured during render.
564
- * @returns Resolved HTML plus any component-scoped assets produced while resolving nodes.
565
- * @throws Error when marker component refs or props refs cannot be resolved.
566
- */
567
- async resolveMarkerGraphHtml(options) {
568
- const integrationRendererCache = /* @__PURE__ */ new Map();
569
- return this.markerGraphResolver.resolve({
570
- html: options.html,
571
- componentsToResolve: options.componentsToResolve,
572
- graphContext: options.graphContext,
573
- instanceIdScope: options.instanceIdScope,
574
- resolveRenderer: (integrationName) => this.getIntegrationRendererForName(integrationName, integrationRendererCache),
575
- applyAttributesToFirstElement: (html, attributes) => this.htmlTransformer.applyAttributesToFirstElement(html, attributes)
576
- });
577
- }
578
745
  /**
579
746
  * Returns a renderer instance for a given integration name.
580
747
  *
@@ -596,62 +763,80 @@ class IntegrationRenderer {
596
763
  const integrationPlugin = this.appConfig.integrations.find(
597
764
  (integration) => integration.name === integrationName
598
765
  );
599
- invariant(!!integrationPlugin, `[ecopages] Integration not found for marker: ${integrationName}`);
766
+ invariant(!!integrationPlugin, `[ecopages] Integration not found for boundary owner: ${integrationName}`);
600
767
  const renderer = integrationPlugin.initializeRenderer({
601
768
  rendererModules: this.appConfig.runtime?.rendererModuleContext
602
769
  });
603
770
  cache.set(integrationName, renderer);
604
771
  return renderer;
605
772
  }
773
+ async resolveBoundaryInOwningRenderer(input, rendererCache) {
774
+ const boundaryOwner = this.getRegisteredBoundaryOwner(input.component);
775
+ if (!boundaryOwner) {
776
+ return void 0;
777
+ }
778
+ return await this.getIntegrationRendererForName(boundaryOwner, rendererCache).renderComponentBoundary(
779
+ this.withBoundaryRendererCache(input, rendererCache)
780
+ );
781
+ }
606
782
  /**
607
- * Renders one deferred marker-graph node under this integration's boundary
608
- * context so nested cross-integration children can continue to defer while the
609
- * graph is being resolved bottom-up.
783
+ * Renders one component under this integration's boundary runtime and resolves
784
+ * any nested foreign boundaries captured during that render.
610
785
  *
611
- * Without this wrapper, resolving a deferred React or Kita node would render
612
- * any nested foreign components with no active render context, causing them to
613
- * fall back to inline escaped HTML instead of emitting the next marker layer.
786
+ * Without this wrapper, a component tree with foreign-owned descendants would
787
+ * render them with no active boundary runtime, which bypasses the owning
788
+ * renderer's nested-boundary handoff.
614
789
  */
615
- async renderComponentForMarkerGraph(input) {
790
+ async renderComponentBoundary(input) {
791
+ const rendererCache = this.getBoundaryRendererCache(input.integrationContext) ?? /* @__PURE__ */ new Map();
792
+ const delegatedBoundaryRender = await this.resolveBoundaryInOwningRenderer(input, rendererCache);
793
+ if (delegatedBoundaryRender) {
794
+ return delegatedBoundaryRender;
795
+ }
796
+ const hasForeignBoundaries = this.hasForeignBoundaryDescendants(input.component);
797
+ const activeRenderContext = getComponentRenderContext();
798
+ if (!hasForeignBoundaries) {
799
+ if (!activeRenderContext || activeRenderContext.currentIntegration === this.name) {
800
+ return this.normalizeComponentBoundaryRender(await this.renderComponent(input));
801
+ }
802
+ const sameIntegrationExecution = await runWithComponentRenderContext(
803
+ {
804
+ currentIntegration: this.name
805
+ },
806
+ async () => this.renderComponent(input)
807
+ );
808
+ return this.normalizeComponentBoundaryRender(sameIntegrationExecution.value);
809
+ }
616
810
  const execution = await runWithComponentRenderContext(
617
811
  {
618
812
  currentIntegration: this.name,
619
- boundaryContext: this.getComponentRenderBoundaryContext(),
620
- serializeDeferredValue: this.deferredTemplateValueSerializer
813
+ boundaryRuntime: this.createComponentBoundaryRuntime({
814
+ boundaryInput: input,
815
+ rendererCache
816
+ })
621
817
  },
622
818
  async () => this.renderComponent(input)
623
819
  );
624
- if (!this.shouldWrapMarkerGraphComponent(input.component)) {
625
- return execution.value;
626
- }
627
- if (!execution.value.html.includes("<eco-marker")) {
628
- return execution.value;
629
- }
630
- const hasCapturedNestedGraph = Object.keys(execution.graphContext.propsByRef ?? {}).length > 0 || Object.keys(execution.graphContext.slotChildrenByRef ?? {}).length > 0;
631
- if (!hasCapturedNestedGraph) {
632
- return execution.value;
633
- }
634
- const parentInstanceId = input.integrationContext?.componentInstanceId;
635
- const protectedMarkers = protectPassedThroughMarkers(
636
- execution.value.html,
637
- execution.graphContext.propsByRef ?? {}
638
- );
639
- const nestedResolution = await this.resolveMarkerGraphHtml({
640
- html: protectedMarkers.html,
641
- componentsToResolve: [input.component],
642
- graphContext: execution.graphContext,
643
- instanceIdScope: parentInstanceId
644
- });
645
- return {
646
- ...execution.value,
647
- html: protectedMarkers.restore(nestedResolution.html),
648
- assets: this.htmlTransformer.dedupeProcessedAssets([
649
- ...execution.value.assets ?? [],
650
- ...nestedResolution.assets
651
- ])
820
+ return this.normalizeComponentBoundaryRender(execution.value);
821
+ }
822
+ normalizeComponentBoundaryRender(result) {
823
+ const normalizedHtml = this.normalizeBoundaryArtifactHtml(result.html);
824
+ return normalizedHtml === result.html ? result : {
825
+ ...result,
826
+ html: normalizedHtml
652
827
  };
653
828
  }
654
- shouldWrapMarkerGraphComponent(component) {
829
+ normalizeBoundaryArtifactHtml(html) {
830
+ return normalizeBoundaryArtifactHtml(html);
831
+ }
832
+ /**
833
+ * Returns whether the component dependency tree crosses into another
834
+ * integration.
835
+ *
836
+ * This keeps boundary-runtime setup narrow: same-integration trees can render
837
+ * directly without paying the queue orchestration cost.
838
+ */
839
+ hasForeignBoundaryDescendants(component) {
655
840
  const stack = [component];
656
841
  const seen = /* @__PURE__ */ new Set();
657
842
  while (stack.length > 0) {
@@ -674,7 +859,7 @@ class IntegrationRenderer {
674
859
  * Default behavior delegates to `renderToResponse` in partial mode and wraps
675
860
  * the resulting HTML into the `ComponentRenderResult` contract.
676
861
  *
677
- * In marker resolution, this method is the integration-owned step that turns an
862
+ * In boundary resolution, this method is the integration-owned step that turns an
678
863
  * already-resolved deferred boundary into concrete HTML, assets, and optional
679
864
  * root attributes.
680
865
  *
@@ -682,7 +867,7 @@ class IntegrationRenderer {
682
867
  * root attributes, integration-specific hydration metadata).
683
868
  *
684
869
  * @param input Component render request.
685
- * @returns Structured render result used by marker/page orchestration.
870
+ * @returns Structured render result used by component/page orchestration.
686
871
  */
687
872
  async renderComponent(input) {
688
873
  const response = await this.renderToResponse(
@@ -719,46 +904,41 @@ class IntegrationRenderer {
719
904
  return void 0;
720
905
  }
721
906
  /**
722
- * Builds the narrow boundary policy facade injected into component render
723
- * context for this render pass.
907
+ * Creates the per-render boundary runtime adopted by the shared component
908
+ * render context.
724
909
  *
725
- * `eco.component()` consumes this facade without knowing about integration
726
- * registries or plugin instances.
727
- *
728
- * @returns Boundary policy context for the active integration renderer.
910
+ * Real mixed-integration renderers should override this and keep foreign
911
+ * boundary resolution inside their own renderer-owned queue. The base runtime
912
+ * fails fast when a renderer crosses into a foreign owner without providing its
913
+ * own handoff mechanism.
729
914
  */
730
- getComponentRenderBoundaryContext() {
731
- return {
732
- decideBoundaryRender: (input) => this.shouldDeferComponentBoundary(input) ? "defer" : "inline"
915
+ createComponentBoundaryRuntime(_options) {
916
+ const decideBoundaryInterception = (input) => {
917
+ if (!this.shouldResolveBoundaryInOwningRenderer(input)) {
918
+ return { kind: "inline" };
919
+ }
920
+ throw new Error(
921
+ `[ecopages] ${this.name} renderer crossed into ${input.targetIntegration} without a renderer-owned boundary runtime. Override createComponentBoundaryRuntime() to resolve foreign boundaries inside the owning renderer.`
922
+ );
923
+ };
924
+ const runtime = {
925
+ interceptBoundary: decideBoundaryInterception,
926
+ interceptBoundarySync: decideBoundaryInterception
733
927
  };
928
+ return runtime;
734
929
  }
735
930
  /**
736
- * Resolves whether a component boundary should be deferred by consulting the
737
- * target integration plugin.
931
+ * Resolves whether a boundary should leave the current render pass and be
932
+ * resolved by its owning renderer.
738
933
  *
739
- * Boundaries targeting the current integration always render inline. Cross-
740
- * integration boundaries delegate the decision to the target integration's
741
- * `shouldDeferComponentBoundary()` policy.
934
+ * Boundaries owned by the current integration always render inline. Foreign-
935
+ * owned boundaries must be handed off by a renderer-owned runtime.
742
936
  *
743
937
  * @param input Boundary metadata for the active render pass.
744
- * @returns `true` when the boundary should emit a marker; otherwise `false`.
938
+ * @returns `true` when the boundary should leave the current pass; otherwise `false`.
745
939
  */
746
- shouldDeferComponentBoundary(input) {
747
- if (!input.targetIntegration || input.targetIntegration === input.currentIntegration) {
748
- return false;
749
- }
750
- const targetIntegration = this.appConfig.integrations.find(
751
- (integration) => integration.name === input.targetIntegration
752
- );
753
- invariant(
754
- !!targetIntegration,
755
- `[ecopages] Integration not found for component boundary: ${input.targetIntegration}`
756
- );
757
- return targetIntegration.shouldDeferComponentBoundary({
758
- currentIntegration: input.currentIntegration,
759
- targetIntegration: input.targetIntegration,
760
- component: input.component
761
- });
940
+ shouldResolveBoundaryInOwningRenderer(input) {
941
+ return !!input.targetIntegration && input.targetIntegration !== input.currentIntegration;
762
942
  }
763
943
  }
764
944
  export {