@noego/wood 0.5.0 → 0.6.0

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 (142) hide show
  1. package/README.md +31 -0
  2. package/dist/client/index.cjs +18 -11
  3. package/dist/client/index.cjs.map +1 -1
  4. package/dist/client/index.d.cts +21 -1
  5. package/dist/client/index.d.ts +21 -1
  6. package/dist/client/index.js +17 -11
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/codegen/renderer_app_generator.cjs +2 -9
  9. package/dist/codegen/renderer_app_generator.cjs.map +1 -1
  10. package/dist/codegen/renderer_app_generator.js +2 -9
  11. package/dist/codegen/renderer_app_generator.js.map +1 -1
  12. package/dist/controller/cleanup.cjs +39 -0
  13. package/dist/controller/cleanup.cjs.map +1 -0
  14. package/dist/controller/cleanup.d.cts +4 -0
  15. package/dist/controller/cleanup.d.ts +4 -0
  16. package/dist/controller/cleanup.js +15 -0
  17. package/dist/controller/cleanup.js.map +1 -0
  18. package/dist/controller/controller_lifecycle.cjs +26 -2
  19. package/dist/controller/controller_lifecycle.cjs.map +1 -1
  20. package/dist/controller/controller_lifecycle.d.cts +3 -1
  21. package/dist/controller/controller_lifecycle.d.ts +3 -1
  22. package/dist/controller/controller_lifecycle.js +24 -1
  23. package/dist/controller/controller_lifecycle.js.map +1 -1
  24. package/dist/frontend/frontend_lifecycle.cjs +5 -7
  25. package/dist/frontend/frontend_lifecycle.cjs.map +1 -1
  26. package/dist/frontend/frontend_lifecycle.d.cts +1 -14
  27. package/dist/frontend/frontend_lifecycle.d.ts +1 -14
  28. package/dist/frontend/frontend_lifecycle.js +5 -7
  29. package/dist/frontend/frontend_lifecycle.js.map +1 -1
  30. package/dist/frontend/frontend_surface.cjs +130 -104
  31. package/dist/frontend/frontend_surface.cjs.map +1 -1
  32. package/dist/frontend/frontend_surface.js +131 -105
  33. package/dist/frontend/frontend_surface.js.map +1 -1
  34. package/dist/frontend/work_registry.cjs +29 -4
  35. package/dist/frontend/work_registry.cjs.map +1 -1
  36. package/dist/frontend/work_registry.d.cts +2 -0
  37. package/dist/frontend/work_registry.d.ts +2 -0
  38. package/dist/frontend/work_registry.js +29 -4
  39. package/dist/frontend/work_registry.js.map +1 -1
  40. package/dist/index.d.cts +2 -0
  41. package/dist/index.d.ts +2 -0
  42. package/dist/ipc/index.cjs +6 -2
  43. package/dist/ipc/index.cjs.map +1 -1
  44. package/dist/ipc/index.d.cts +9 -0
  45. package/dist/ipc/index.d.ts +9 -0
  46. package/dist/ipc/index.js +6 -2
  47. package/dist/ipc/index.js.map +1 -1
  48. package/dist/main/index.cjs +53 -35
  49. package/dist/main/index.cjs.map +1 -1
  50. package/dist/main/index.d.cts +11 -2
  51. package/dist/main/index.d.ts +11 -2
  52. package/dist/main/index.js +56 -40
  53. package/dist/main/index.js.map +1 -1
  54. package/dist/main/startup_failure_handler.cjs +67 -0
  55. package/dist/main/startup_failure_handler.cjs.map +1 -0
  56. package/dist/main/startup_failure_handler.d.cts +16 -0
  57. package/dist/main/startup_failure_handler.d.ts +16 -0
  58. package/dist/main/startup_failure_handler.js +43 -0
  59. package/dist/main/startup_failure_handler.js.map +1 -0
  60. package/dist/router/index.d.cts +3 -0
  61. package/dist/router/index.d.ts +3 -0
  62. package/dist/router/wood_router.cjs +204 -123
  63. package/dist/router/wood_router.cjs.map +1 -1
  64. package/dist/router/wood_router.d.cts +20 -0
  65. package/dist/router/wood_router.d.ts +20 -0
  66. package/dist/router/wood_router.js +206 -125
  67. package/dist/router/wood_router.js.map +1 -1
  68. package/dist/runtime/index.cjs +13 -0
  69. package/dist/runtime/index.cjs.map +1 -1
  70. package/dist/runtime/index.d.cts +8 -4
  71. package/dist/runtime/index.d.ts +8 -4
  72. package/dist/runtime/index.js +8 -0
  73. package/dist/runtime/index.js.map +1 -1
  74. package/dist/runtime/renderer_config_extension.cjs +1 -2
  75. package/dist/runtime/renderer_config_extension.cjs.map +1 -1
  76. package/dist/runtime/renderer_config_extension.d.cts +1 -1
  77. package/dist/runtime/renderer_config_extension.d.ts +1 -1
  78. package/dist/runtime/renderer_config_extension.js +1 -2
  79. package/dist/runtime/renderer_config_extension.js.map +1 -1
  80. package/dist/runtime/renderer_log_dispatcher.cjs +2 -0
  81. package/dist/runtime/renderer_log_dispatcher.cjs.map +1 -1
  82. package/dist/runtime/renderer_log_dispatcher.d.cts +1 -1
  83. package/dist/runtime/renderer_log_dispatcher.d.ts +1 -1
  84. package/dist/runtime/renderer_log_dispatcher.js +1 -0
  85. package/dist/runtime/renderer_log_dispatcher.js.map +1 -1
  86. package/dist/runtime/runtime.cjs +107 -57
  87. package/dist/runtime/runtime.cjs.map +1 -1
  88. package/dist/runtime/runtime.d.cts +12 -0
  89. package/dist/runtime/runtime.d.ts +12 -0
  90. package/dist/runtime/runtime.js +110 -59
  91. package/dist/runtime/runtime.js.map +1 -1
  92. package/dist/runtime/runtime_module.cjs +58 -0
  93. package/dist/runtime/runtime_module.cjs.map +1 -0
  94. package/dist/runtime/runtime_module.d.cts +22 -0
  95. package/dist/runtime/runtime_module.d.ts +22 -0
  96. package/dist/runtime/runtime_module.js +30 -0
  97. package/dist/runtime/runtime_module.js.map +1 -0
  98. package/dist/testing/create_controller_harness.d.cts +3 -0
  99. package/dist/testing/create_controller_harness.d.ts +3 -0
  100. package/dist/testing/create_router_bridge.d.cts +3 -0
  101. package/dist/testing/create_router_bridge.d.ts +3 -0
  102. package/dist/testing/create_test_router.d.cts +3 -0
  103. package/dist/testing/create_test_router.d.ts +3 -0
  104. package/dist/testing/index.cjs +3 -0
  105. package/dist/testing/index.cjs.map +1 -1
  106. package/dist/testing/index.d.cts +6 -1
  107. package/dist/testing/index.d.ts +6 -1
  108. package/dist/testing/index.js +2 -0
  109. package/dist/testing/index.js.map +1 -1
  110. package/dist/testing/registration_only_root.cjs +168 -0
  111. package/dist/testing/registration_only_root.cjs.map +1 -0
  112. package/dist/testing/registration_only_root.d.cts +46 -0
  113. package/dist/testing/registration_only_root.d.ts +46 -0
  114. package/dist/testing/registration_only_root.js +141 -0
  115. package/dist/testing/registration_only_root.js.map +1 -0
  116. package/dist/testing/test_wood_config.cjs +382 -70
  117. package/dist/testing/test_wood_config.cjs.map +1 -1
  118. package/dist/testing/test_wood_config.d.cts +25 -1
  119. package/dist/testing/test_wood_config.d.ts +25 -1
  120. package/dist/testing/test_wood_config.js +388 -71
  121. package/dist/testing/test_wood_config.js.map +1 -1
  122. package/dist/testing/wood_dom_animation.cjs +213 -0
  123. package/dist/testing/wood_dom_animation.cjs.map +1 -0
  124. package/dist/testing/wood_dom_animation.d.cts +89 -0
  125. package/dist/testing/wood_dom_animation.d.ts +89 -0
  126. package/dist/testing/wood_dom_animation.js +185 -0
  127. package/dist/testing/wood_dom_animation.js.map +1 -0
  128. package/dist/testing/wood_env_renderer.cjs +81 -32
  129. package/dist/testing/wood_env_renderer.cjs.map +1 -1
  130. package/dist/testing/wood_env_renderer.d.cts +32 -1
  131. package/dist/testing/wood_env_renderer.d.ts +32 -1
  132. package/dist/testing/wood_env_renderer.js +84 -32
  133. package/dist/testing/wood_env_renderer.js.map +1 -1
  134. package/dist/transport/in_process_router_transport.d.cts +3 -0
  135. package/dist/transport/in_process_router_transport.d.ts +3 -0
  136. package/dist/transport/index.d.cts +3 -0
  137. package/dist/transport/index.d.ts +3 -0
  138. package/package.json +8 -6
  139. package/src/components/NavigationShell.svelte +12 -7
  140. package/src/controller/cleanup.ts +8 -0
  141. package/src/controller/controller_lifecycle.ts +24 -0
  142. package/src/navigation/layout_preservation.ts +45 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/frontend/frontend_surface.ts"],"sourcesContent":["/**\n * The Node frontend application surface (spec 18 §12–13, 18A D-FAS-01/02).\n *\n * frontend.open({ page, params, query }) — explicit initial page\n * frontend.current() — production navigation result\n * frontend.act(fn) / frontend.settle() — owned-work fixed-point drains\n * frontend.errors — observed owned-work failures\n *\n * Runtime honesty (D-FAS-02): this is the plain-Node tier. It proves\n * explicit PageController behavior, $state/$derived reachable through it,\n * backend calls, events, and navigation identity/lifecycle. It does not\n * create a Svelte component/root context and therefore proves no $effect,\n * mount/unmount, or DOM behavior — those belong to the happy-dom tier.\n *\n * Isolation: every environment owns its own Navigation instance, renderer\n * root, transport, and work registry. Nothing here writes globalThis and\n * nothing calls the production client getters — two environments run\n * concurrently without cross-talk. A controller that calls the module-level\n * `go()`/`getNavigation()` free functions bypasses its environment; this\n * tier requires constructor-injected `Navigation`.\n */\nimport { ExecutionContext, LoadAs } from '@noego/ioc';\nimport { Navigation, type NavigationEntry } from '../navigation/index.js';\nimport { computePreservedLayoutDepth } from '../navigation/layout_preservation.js';\nimport { disposeControllerLifecycle } from '../controller/controller_lifecycle.js';\nimport type { ViewDef } from '../types/index.cjs';\nimport { PageCatalog, PageCatalogError, pageIdentity } from './page_catalog.js';\nimport { WorkRegistry, FrontendWorkError, type WorkFailure } from './work_registry.js';\nimport { FrontendLifecycleOwner, type FrontendLifecycle } from './frontend_lifecycle.js';\nimport { createFrontendPage, type FrontendPage, type FrontendPageHandle, type MountedLayout } from './frontend_page.js';\n\nexport interface FrontendOpenOptions {\n /** Required production page identity (`window.page`, or unambiguous leaf). */\n page: string;\n params?: Record<string, string>;\n query?: Record<string, string>;\n}\n\nexport interface FrontendApplicationSurface {\n open(options: FrontendOpenOptions): Promise<FrontendPage>;\n current(): FrontendPage | null;\n act(action: () => unknown | Promise<unknown>): Promise<void>;\n settle(): Promise<void>;\n readonly errors: readonly WorkFailure[];\n /** The environment-local production Navigation instance. */\n readonly navigation: Navigation;\n /** Internal: dispose the active page and application lifecycles. */\n dispose(): Promise<void>;\n}\n\ninterface ScopeLike {\n extend(): ScopeLike;\n get(token: unknown): Promise<unknown>;\n registerClass?(target: unknown, options?: unknown): void;\n registerFunction?(token: unknown, factory: () => unknown, options?: unknown): void;\n registerValue?(token: unknown, value: unknown): void;\n dispose?(): Promise<void>;\n}\n\n/**\n * A mounted layout controller and the scope that owns it. A layout declared\n * `preserve: true` owns a DEDICATED scope that survives page transitions\n * (production parity: `createControllerSet`); every other layout shares the\n * page scope and dies with the page.\n */\ninterface ActiveLayout extends MountedLayout {\n index: number;\n /** Non-null only for a preserved layout that owns its scope. */\n ownScope: ScopeLike | null;\n /**\n * A preserved layout outlives the page that built it, so it cannot borrow\n * that page's lifecycle owner — it owns one, disposed when the layout is\n * finally dropped.\n */\n ownLifecycle: FrontendLifecycleOwner | null;\n}\n\ninterface ActivePage {\n identity: string;\n view: ViewDef;\n scope: ScopeLike;\n lifecycle: FrontendLifecycleOwner;\n handle: FrontendPageHandle;\n controller: unknown;\n layouts: ActiveLayout[];\n}\n\nexport interface PageScopeContext {\n kind: 'page' | 'layout';\n /** Layout index within the view's layout stack (layout scopes only). */\n layoutIndex?: number;\n /** Controller identity bound to this scope. */\n controller: string;\n}\n\nexport interface CreateFrontendSurfaceOptions {\n /** The selected renderer aperture. */\n catalog: PageCatalog;\n /** Fresh renderer root for this environment (separate from the main root). */\n rendererRoot: ScopeLike;\n registry: WorkRegistry;\n /** Environment-local Navigation (also registered on the renderer root). */\n navigation: Navigation;\n /** Resolve a production controller identity to its class. */\n resolveController: (identity: string, view: ViewDef) => Promise<new (...args: never[]) => unknown>;\n /** Per-pass reactive flush hooks from the active runtime adapter (Node: none). */\n flushHooks?: ReadonlyArray<() => void | Promise<void>>;\n /**\n * Register per-scope services on a newly created scope (bridge client,\n * etc.). Called for the page scope, and once for each PRESERVED layout\n * that owns a dedicated scope — `context.kind` says which, so a caller\n * that tracks \"the page scope\" does not mistake a layout scope for it.\n */\n configurePageScope?: (\n scope: ScopeLike,\n view: ViewDef,\n context: PageScopeContext,\n ) => void | Promise<void>;\n}\n\n/**\n * Plain (undecorated) controller classes are registered on their scope so\n * they resolve — unless the hierarchy already decided how the token resolves\n * (a renderer-root substitution of the controller class), which keeps\n * winning inside the page scope.\n */\nfunction registerControllerUnlessBound(scope: ScopeLike, ControllerClass: unknown): void {\n const registrable = scope as { isRegistered?(token: unknown): boolean };\n if (typeof registrable.isRegistered === 'function' && registrable.isRegistered(ControllerClass)) return;\n if (typeof scope.registerClass === 'function') scope.registerClass(ControllerClass);\n}\n\n/**\n * Make a controller's production teardown hook run at most once.\n *\n * Two owners legitimately end a controller: this surface (production\n * parity — the renderer's NavigationShell calls the hook explicitly, and\n * for a Transient controller that is the ONLY call) and IoC scope\n * disposal (which calls `dispose()`/`destroy()` on the scoped instances it\n * owns). Shadowing the hook with a once-guard means a controller sees the\n * same single teardown whichever way it was registered.\n */\nfunction guardControllerLifecycleOnce(instance: unknown): void {\n if (!instance || typeof instance !== 'object') return;\n const target = instance as Record<string, unknown>;\n for (const hook of ['dispose', 'destroy'] as const) {\n const original = target[hook];\n if (typeof original !== 'function') continue;\n if (Object.prototype.hasOwnProperty.call(target, hook)) continue;\n let called = false;\n Object.defineProperty(target, hook, {\n configurable: true,\n writable: true,\n enumerable: false,\n value: function guarded(this: unknown, ...args: unknown[]) {\n if (called) return undefined;\n called = true;\n return (original as (...rest: unknown[]) => unknown).apply(this, args);\n },\n });\n }\n}\n\nexport function createFrontendSurface(options: CreateFrontendSurfaceOptions): FrontendApplicationSurface {\n const { catalog, rendererRoot, registry, navigation } = options;\n const flushHooks = options.flushHooks ?? [];\n const application = new FrontendLifecycleOwner(registry, 'application');\n\n let active: ActivePage | null = null;\n let transitioning = false;\n let disposed = false;\n\n const currentOwner = (): string => (active ? `page ${active.identity}` : 'application');\n\n async function constructPage(\n view: ViewDef,\n params: Record<string, string>,\n query: Record<string, string>,\n preserved: ActiveLayout[] = [],\n ): Promise<ActivePage> {\n const identity = pageIdentity(view);\n if (!view.controller) {\n throw new PageCatalogError(\n `page \"${identity}\" declares no controller in its view definition; ` +\n 'the Node application slice drives public PageController input and needs one.',\n );\n }\n\n const ControllerClass = await options.resolveController(view.controller, view);\n const scope = rendererRoot.extend();\n const lifecycle = new FrontendLifecycleOwner(registry, `page ${identity}`);\n\n // The page lifecycle is injectable so controllers can register\n // deliberately detached finite work (D-FAS-01 §2.2). Scoped, not\n // Singleton: the lifecycle belongs to this page scope alone — a\n // Singleton would store at the hierarchy root and leak the first\n // page's lifecycle into every later page.\n scope.registerFunction?.(FrontendLifecycleOwner, () => lifecycle, { loadAs: LoadAs.Scoped });\n await options.configurePageScope?.(scope, view, {\n kind: 'page',\n controller: view.controller,\n });\n registerControllerUnlessBound(scope, ControllerClass);\n\n // Layout controllers mount BEFORE the view controller (production\n // ordering in `createControllerSet`): a page controller may legitimately\n // read state a layout controller published during construction.\n const layouts: ActiveLayout[] = [...preserved];\n for (let index = preserved.length; index < view.layouts.length; index += 1) {\n const layoutIdentity = view.layoutControllers?.[index];\n if (!layoutIdentity) continue;\n const preserve = view.layoutPreserve?.[index] === true;\n const LayoutClass = await options.resolveController(layoutIdentity, view);\n const ownScope = preserve ? rendererRoot.extend() : null;\n const ownLifecycle = ownScope\n ? new FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`)\n : null;\n const layoutScope = ownScope ?? scope;\n if (ownScope) {\n ownScope.registerFunction?.(FrontendLifecycleOwner, () => ownLifecycle!, { loadAs: LoadAs.Scoped });\n await options.configurePageScope?.(ownScope, view, {\n kind: 'layout',\n layoutIndex: index,\n controller: layoutIdentity,\n });\n }\n registerControllerUnlessBound(layoutScope, LayoutClass);\n const instance = (await ExecutionContext.run(layoutScope as never, () =>\n layoutScope.get(LayoutClass),\n )) as Record<string, unknown>;\n guardControllerLifecycleOnce(instance);\n layouts.push({\n index,\n path: view.layouts[index],\n controller: layoutIdentity,\n preserve,\n instance,\n scope: layoutScope,\n ownScope,\n ownLifecycle,\n });\n }\n\n const controller = (await ExecutionContext.run(scope as never, () =>\n scope.get(ControllerClass),\n )) as Record<string, unknown>;\n\n guardControllerLifecycleOnce(controller);\n\n if (typeof controller.initialize === 'function') {\n await registry.track(\n Promise.resolve(\n ExecutionContext.run(scope as never, () =>\n (controller.initialize as (load: unknown) => unknown)({ params, query }),\n ),\n ).then(() => undefined),\n { label: `initialize ${identity}`, kind: 'initialize', owner: `page ${identity}` },\n );\n }\n\n const handle = createFrontendPage({\n identity,\n controller,\n scope,\n layouts,\n onClose: async () => {\n await disposePage(identity);\n },\n });\n\n return { identity, view, scope, lifecycle, handle, controller, layouts };\n }\n\n /**\n * End a page: the page lifecycle owner first (abort signal, owned\n * resources), then the IoC scopes — scope disposal is what runs each\n * controller's production `dispose()`/`destroy()` hook, so nothing here\n * calls those directly. Layouts in the preserved prefix keep running:\n * they belong to the next page now.\n */\n async function endPage(page: ActivePage, preservedDepth = 0): Promise<void> {\n page.handle.invalidate();\n // Production teardown order (NavigationShell.disposeControllerSet): the\n // page controller's hook, then each ending layout's, then the lifecycle\n // owner and the IoC scopes. The hooks are once-guarded, so the scope\n // disposal below does not run them a second time.\n await disposeControllerLifecycle(page.controller);\n for (const layout of page.layouts) {\n if (layout.index < preservedDepth) continue;\n await disposeControllerLifecycle(layout.instance);\n }\n await page.lifecycle.dispose();\n // Page scope first, then dropped preserved layouts — production's\n // disposeControllerSet order (view controller, then layouts).\n if (typeof page.scope.dispose === 'function') {\n await page.scope.dispose();\n }\n for (const layout of page.layouts) {\n if (layout.index < preservedDepth) continue;\n await layout.ownLifecycle?.dispose();\n if (layout.ownScope && typeof layout.ownScope.dispose === 'function') {\n await layout.ownScope.dispose();\n }\n }\n }\n\n async function disposePage(identity: string): Promise<void> {\n if (!active || active.identity !== identity) return;\n const ended = active;\n active = null;\n await endPage(ended);\n }\n\n async function transitionTo(entry: NavigationEntry): Promise<void> {\n // Resolve the destination through the selected aperture — navigation\n // outside it fails without widening the environment.\n const view = catalog.resolve(entry.page);\n const previous = active;\n // Preserved layouts carry over as live instances — the transition never\n // reconstructs them (production `preserve: true` semantics).\n // Same preservation rule the renderer's NavigationShell applies.\n const depth = previous\n ? computePreservedLayoutDepth(\n { layoutPaths: previous.view.layouts, layoutControllers: previous.view.layoutControllers, layoutPreserve: previous.view.layoutPreserve },\n { layoutPaths: view.layouts, layoutControllers: view.layoutControllers, layoutPreserve: view.layoutPreserve },\n entry.options ?? {},\n )\n : 0;\n const carried = previous ? previous.layouts.filter((layout) => layout.index < depth) : [];\n const next = await constructPage(view, entry.params, entry.query, carried);\n active = next;\n if (previous) {\n await endPage(previous, depth);\n }\n }\n\n // Production navigation drives page transitions: a controller calling its\n // injected Navigation.go()/replace() inside the aperture triggers one\n // tracked finite navigation transition (18A D-FAS-01 §2.2).\n navigation.subscribe((entry) => {\n if (!entry || transitioning || disposed) return;\n if (active && entry.page === active.identity) return;\n if (!active) return; // initial open() seeds navigation itself\n registry.track(transitionTo(entry), {\n label: `navigate ${active.identity} -> ${entry.page}`,\n kind: 'navigation',\n owner: currentOwner(),\n });\n });\n\n return {\n navigation,\n\n async open(openOptions: FrontendOpenOptions): Promise<FrontendPage> {\n if (!openOptions || typeof openOptions.page !== 'string' || !openOptions.page.length) {\n throw new PageCatalogError('frontend.open() requires an explicit { page } identity');\n }\n if (active) {\n throw new PageCatalogError(\n `frontend.open() is test bootstrap; page \"${active.identity}\" is already open. ` +\n 'Drive production navigation for subsequent route changes, or close() the page first.',\n );\n }\n const view = catalog.resolve(openOptions.page);\n const params = openOptions.params ?? {};\n const query = openOptions.query ?? {};\n\n transitioning = true;\n try {\n // Seed production navigation state BEFORE construction: controllers\n // and their services legitimately read the current route (params,\n // query) during construction/initialize, exactly as in production\n // where the route exists before the page loads.\n navigation.replace(pageIdentity(view), params, query);\n active = await constructPage(view, params, query);\n } finally {\n transitioning = false;\n }\n return active.handle.page;\n },\n\n current(): FrontendPage | null {\n return active ? active.handle.page : null;\n },\n\n async act(action: () => unknown | Promise<unknown>): Promise<void> {\n await registry.track(\n Promise.resolve().then(() => action()).then(() => undefined),\n { label: 'act()', kind: 'tracked', owner: currentOwner() },\n ).catch(() => {\n // The failure is recorded by the registry; drain reports it with\n // its owner rather than losing the aggregate view.\n });\n await registry.drain(flushHooks);\n },\n\n async settle(): Promise<void> {\n await registry.drain(flushHooks);\n },\n\n get errors(): readonly WorkFailure[] {\n return registry.errors();\n },\n\n async dispose(): Promise<void> {\n if (disposed) return;\n disposed = true;\n if (active) {\n const ended = active;\n active = null;\n // Environment teardown ends everything, preserved layouts included.\n await endPage(ended);\n }\n await application.dispose();\n },\n };\n}\n\nexport type { FrontendLifecycle };\nexport { FrontendWorkError };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/frontend/frontend_surface.ts"],"sourcesContent":["/**\n * The Node frontend application surface (spec 18 §12–13, 18A D-FAS-01/02).\n *\n * frontend.open({ page, params, query }) — explicit initial page\n * frontend.current() — production navigation result\n * frontend.act(fn) / frontend.settle() — owned-work fixed-point drains\n * frontend.errors — observed owned-work failures\n *\n * Runtime honesty (D-FAS-02): this is the plain-Node tier. It proves\n * explicit PageController behavior, $state/$derived reachable through it,\n * backend calls, events, and navigation identity/lifecycle. It does not\n * create a Svelte component/root context and therefore proves no $effect,\n * mount/unmount, or DOM behavior — those belong to the happy-dom tier.\n *\n * Isolation: every environment owns its own Navigation instance, renderer\n * root, transport, and work registry. Nothing here writes globalThis and\n * nothing calls the production client getters — two environments run\n * concurrently without cross-talk. A controller that calls the module-level\n * `go()`/`getNavigation()` free functions bypasses its environment; this\n * tier requires constructor-injected `Navigation`.\n */\nimport { ExecutionContext, LoadAs } from '@noego/ioc';\nimport { Navigation, type NavigationEntry } from '../navigation/index.js';\nimport { computePreservedLayoutDepth } from '../navigation/layout_preservation.js';\nimport { cleanupAll } from '../controller/cleanup.js';\nimport { disposeControllerLifecycle, guardControllerLifecycleOnce } from '../controller/controller_lifecycle.js';\nimport type { ViewDef } from '../types/index.cjs';\nimport { PageCatalog, PageCatalogError, pageIdentity } from './page_catalog.js';\nimport { WorkRegistry, FrontendWorkError, type WorkFailure } from './work_registry.js';\nimport { FrontendLifecycleOwner, type FrontendLifecycle } from './frontend_lifecycle.js';\nimport { createFrontendPage, type FrontendPage, type FrontendPageHandle, type MountedLayout } from './frontend_page.js';\n\nexport interface FrontendOpenOptions {\n /** Required production page identity (`window.page`, or unambiguous leaf). */\n page: string;\n params?: Record<string, string>;\n query?: Record<string, string>;\n}\n\nexport interface FrontendApplicationSurface {\n open(options: FrontendOpenOptions): Promise<FrontendPage>;\n current(): FrontendPage | null;\n act(action: () => unknown | Promise<unknown>): Promise<void>;\n settle(): Promise<void>;\n readonly errors: readonly WorkFailure[];\n /** The environment-local production Navigation instance. */\n readonly navigation: Navigation;\n /** Internal: dispose the active page and application lifecycles. */\n dispose(): Promise<void>;\n}\n\ninterface ScopeLike {\n extend(): ScopeLike;\n get(token: unknown): Promise<unknown>;\n registerClass?(target: unknown, options?: unknown): void;\n registerFunction?(token: unknown, factory: () => unknown, options?: unknown): void;\n registerValue?(token: unknown, value: unknown): void;\n dispose?(): Promise<void>;\n}\n\n/**\n * A mounted layout controller and the scope that owns it. A layout declared\n * `preserve: true` owns a DEDICATED scope that survives page transitions\n * (production parity: `createControllerSet`); every other layout shares the\n * page scope and dies with the page.\n */\ninterface ActiveLayout extends MountedLayout {\n index: number;\n /** Non-null only for a preserved layout that owns its scope. */\n ownScope: ScopeLike | null;\n /**\n * A preserved layout outlives the page that built it, so it cannot borrow\n * that page's lifecycle owner — it owns one, disposed when the layout is\n * finally dropped.\n */\n ownLifecycle: FrontendLifecycleOwner | null;\n}\n\ninterface ActivePage {\n identity: string;\n view: ViewDef;\n scope: ScopeLike;\n lifecycle: FrontendLifecycleOwner;\n handle: FrontendPageHandle;\n controller: unknown;\n layouts: ActiveLayout[];\n}\n\nexport interface PageScopeContext {\n kind: 'page' | 'layout';\n /** Layout index within the view's layout stack (layout scopes only). */\n layoutIndex?: number;\n /** Controller identity bound to this scope. */\n controller: string;\n}\n\nexport interface CreateFrontendSurfaceOptions {\n /** The selected renderer aperture. */\n catalog: PageCatalog;\n /** Fresh renderer root for this environment (separate from the main root). */\n rendererRoot: ScopeLike;\n registry: WorkRegistry;\n /** Environment-local Navigation (also registered on the renderer root). */\n navigation: Navigation;\n /** Resolve a production controller identity to its class. */\n resolveController: (identity: string, view: ViewDef) => Promise<new (...args: never[]) => unknown>;\n /** Per-pass reactive flush hooks from the active runtime adapter (Node: none). */\n flushHooks?: ReadonlyArray<() => void | Promise<void>>;\n /**\n * Register per-scope services on a newly created scope (bridge client,\n * etc.). Called for the page scope, and once for each PRESERVED layout\n * that owns a dedicated scope — `context.kind` says which, so a caller\n * that tracks \"the page scope\" does not mistake a layout scope for it.\n */\n configurePageScope?: (\n scope: ScopeLike,\n view: ViewDef,\n context: PageScopeContext,\n ) => void | Promise<void>;\n}\n\n/**\n * Plain (undecorated) controller classes are registered on their scope so\n * they resolve — unless the hierarchy already decided how the token resolves\n * (a renderer-root substitution of the controller class), which keeps\n * winning inside the page scope.\n */\nfunction registerControllerUnlessBound(scope: ScopeLike, ControllerClass: unknown): void {\n const registrable = scope as { isRegistered?(token: unknown): boolean };\n if (typeof registrable.isRegistered === 'function' && registrable.isRegistered(ControllerClass)) return;\n if (typeof scope.registerClass === 'function') scope.registerClass(ControllerClass);\n}\n\nexport function createFrontendSurface(options: CreateFrontendSurfaceOptions): FrontendApplicationSurface {\n const { catalog, rendererRoot, registry, navigation } = options;\n const flushHooks = options.flushHooks ?? [];\n const application = new FrontendLifecycleOwner(registry, 'application');\n\n let active: ActivePage | null = null;\n let transitioning = false;\n let disposed = false;\n let navigationQueue: Promise<void> = Promise.resolve();\n let closing: Promise<void> | undefined;\n const retainedPageScopes = new Map<ScopeLike, FrontendLifecycleOwner>();\n const locallyOwnedControllers = new WeakSet<object>();\n const markController = (instance: unknown, token: unknown) => {\n const root = rendererRoot as ScopeLike & { isRegistered?(token: unknown): boolean };\n if (!root.isRegistered?.(token) && instance && typeof instance === 'object') locallyOwnedControllers.add(instance);\n guardControllerLifecycleOnce(instance);\n };\n const disposeLocalController = (instance: unknown) => instance && typeof instance === 'object' && locallyOwnedControllers.has(instance)\n ? disposeControllerLifecycle(instance) : Promise.resolve();\n\n const currentOwner = (): string => (active ? `page ${active.identity}` : 'application');\n\n async function constructPage(\n view: ViewDef,\n params: Record<string, string>,\n query: Record<string, string>,\n preserved: ActiveLayout[] = [],\n ): Promise<ActivePage> {\n const identity = pageIdentity(view);\n if (!view.controller) {\n throw new PageCatalogError(\n `page \"${identity}\" declares no controller in its view definition; ` +\n 'the Node application slice drives public PageController input and needs one.',\n );\n }\n\n const ControllerClass = await options.resolveController(view.controller, view);\n const scope = rendererRoot.extend();\n const lifecycle = new FrontendLifecycleOwner(registry, `page ${identity}`);\n\n const rollback: Array<() => unknown | Promise<unknown>> = [\n () => ExecutionContext.run(scope as never, () => scope.dispose?.()),\n () => lifecycle.dispose(),\n ];\n try {\n // The page lifecycle is injectable so controllers can register\n // deliberately detached finite work (D-FAS-01 §2.2). Scoped, not\n // Singleton: the lifecycle belongs to this page scope alone — a\n // Singleton would store at the hierarchy root and leak the first\n // page's lifecycle into every later page.\n scope.registerFunction?.(FrontendLifecycleOwner, () => lifecycle, { loadAs: LoadAs.Scoped });\n await ExecutionContext.run(scope as never, () => options.configurePageScope?.(scope, view, { kind: 'page', controller: view.controller! }));\n registerControllerUnlessBound(scope, ControllerClass);\n\n // Layout controllers mount BEFORE the view controller (production\n // ordering in `createControllerSet`): a page controller may legitimately\n // read state a layout controller published during construction.\n const layouts: ActiveLayout[] = [...preserved];\n for (let index = preserved.length; index < view.layouts.length; index += 1) {\n const layoutIdentity = view.layoutControllers?.[index];\n if (!layoutIdentity) continue;\n const preserve = view.layoutPreserve?.[index] === true;\n const LayoutClass = await options.resolveController(layoutIdentity, view);\n const ownScope = preserve ? rendererRoot.extend() : null;\n const ownLifecycle = ownScope\n ? new FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`)\n : null;\n const layoutScope = ownScope ?? scope;\n if (ownScope) {\n rollback.push(() => ExecutionContext.run(ownScope as never, () => ownScope.dispose?.()));\n rollback.push(() => ownLifecycle!.dispose());\n ownScope.registerFunction?.(FrontendLifecycleOwner, () => ownLifecycle!, { loadAs: LoadAs.Scoped });\n await ExecutionContext.run(ownScope as never, () => options.configurePageScope?.(ownScope, view, {\n kind: 'layout',\n layoutIndex: index,\n controller: layoutIdentity,\n }));\n }\n registerControllerUnlessBound(layoutScope, LayoutClass);\n const instance = (await ExecutionContext.run(layoutScope as never, () =>\n layoutScope.get(LayoutClass),\n )) as Record<string, unknown>;\n markController(instance, LayoutClass);\n rollback.push(() => ExecutionContext.run(layoutScope as never, () => disposeLocalController(instance)));\n layouts.push({\n index,\n path: view.layouts[index],\n controller: layoutIdentity,\n preserve,\n instance,\n scope: layoutScope,\n ownScope,\n ownLifecycle,\n });\n }\n\n const controller = (await ExecutionContext.run(scope as never, () =>\n scope.get(ControllerClass),\n )) as Record<string, unknown>;\n\n markController(controller, ControllerClass);\n rollback.push(() => ExecutionContext.run(scope as never, () => disposeLocalController(controller)));\n\n if (typeof controller.initialize === 'function') {\n await registry.track(\n Promise.resolve(\n ExecutionContext.run(scope as never, () =>\n (controller.initialize as (load: unknown) => unknown)({ params, query }),\n ),\n ).then(() => undefined),\n { label: `initialize ${identity}`, kind: 'initialize', owner: `page ${identity}` },\n );\n }\n\n const handle = createFrontendPage({\n identity,\n controller,\n scope,\n layouts,\n onClose: async () => {\n await disposePage(identity);\n },\n });\n\n return { identity, view, scope, lifecycle, handle, controller, layouts };\n } catch (error) {\n try { await cleanupAll(rollback.reverse(), 'Wood page rollback failed'); }\n catch (cleanupError) { throw Object.assign(new Error('Wood page construction and cleanup failed'), { cause: error, errors: [error, cleanupError] }); }\n throw error;\n }\n }\n\n /**\n * End a page: the page lifecycle owner first (abort signal, owned\n * resources), then the IoC scopes — scope disposal is what runs each\n * controller's production `dispose()`/`destroy()` hook, so nothing here\n * calls those directly. Layouts in the preserved prefix keep running:\n * they belong to the next page now.\n */\n async function endPage(page: ActivePage, preservedDepth = 0): Promise<void> {\n page.handle.invalidate();\n // Production teardown order (NavigationShell.disposeControllerSet): the\n // page controller's hook, then each ending layout's, then the lifecycle\n // owner and the IoC scopes. The hooks are once-guarded, so the scope\n // disposal below does not run them a second time.\n const steps: Array<() => unknown | Promise<unknown>> = [\n () => ExecutionContext.run(page.scope as never, () => disposeLocalController(page.controller)),\n ];\n for (const layout of page.layouts) {\n if (layout.index < preservedDepth) continue;\n steps.push(() => ExecutionContext.run(layout.scope as never, () => disposeLocalController(layout.instance)));\n steps.push(() => layout.ownLifecycle?.dispose());\n if (layout.ownScope) steps.push(() => ExecutionContext.run(layout.ownScope as never, () => layout.ownScope!.dispose?.()));\n }\n const carriesPageScope = page.layouts.some(layout => layout.index < preservedDepth && layout.scope === page.scope);\n if (carriesPageScope) retainedPageScopes.set(page.scope, page.lifecycle);\n else {\n steps.push(() => page.lifecycle.dispose());\n steps.push(() => ExecutionContext.run(page.scope as never, () => page.scope.dispose?.()));\n }\n for (const [scope, lifecycle] of retainedPageScopes) {\n if (active?.layouts.some(layout => layout.scope === scope)) continue;\n retainedPageScopes.delete(scope);\n steps.push(() => lifecycle.dispose());\n steps.push(() => ExecutionContext.run(scope as never, () => scope.dispose?.()));\n }\n await cleanupAll(steps, 'Wood page cleanup failed');\n }\n\n async function disposePage(identity: string): Promise<void> {\n if (!active || active.identity !== identity) return;\n const ended = active;\n active = null;\n await endPage(ended);\n }\n\n async function transitionTo(entry: NavigationEntry): Promise<void> {\n if (disposed) return;\n // Resolve the destination through the selected aperture — navigation\n // outside it fails without widening the environment.\n const view = catalog.resolve(entry.page);\n const previous = active;\n // Preserved layouts carry over as live instances — the transition never\n // reconstructs them (production `preserve: true` semantics).\n // Same preservation rule the renderer's NavigationShell applies.\n const depth = previous\n ? computePreservedLayoutDepth(\n { layoutPaths: previous.view.layouts, layoutControllers: previous.view.layoutControllers, layoutPreserve: previous.view.layoutPreserve },\n { layoutPaths: view.layouts, layoutControllers: view.layoutControllers, layoutPreserve: view.layoutPreserve },\n entry.options ?? {},\n )\n : 0;\n const carried = previous ? previous.layouts.filter((layout) => layout.index < depth) : [];\n const next = await constructPage(view, entry.params, entry.query, carried);\n if (disposed) {\n await endPage(next, depth);\n return;\n }\n active = next;\n if (previous) {\n await endPage(previous, depth);\n }\n }\n\n // Production navigation drives page transitions: a controller calling its\n // injected Navigation.go()/replace() inside the aperture triggers one\n // tracked finite navigation transition (18A D-FAS-01 §2.2).\n const unsubscribe = navigation.subscribe((entry) => {\n if (!entry || transitioning || disposed) return;\n if (active && entry.page === active.identity) return;\n if (!active) return; // initial open() seeds navigation itself\n const transition = navigationQueue.then(() => transitionTo(entry));\n navigationQueue = transition.catch(() => {});\n registry.track(transition, {\n label: `navigate ${active.identity} -> ${entry.page}`,\n kind: 'navigation',\n owner: currentOwner(),\n });\n });\n\n return {\n navigation,\n\n async open(openOptions: FrontendOpenOptions): Promise<FrontendPage> {\n if (disposed || transitioning) throw new PageCatalogError('Wood frontend is closed or opening a page');\n if (!openOptions || typeof openOptions.page !== 'string' || !openOptions.page.length) {\n throw new PageCatalogError('frontend.open() requires an explicit { page } identity');\n }\n if (active) {\n throw new PageCatalogError(\n `frontend.open() is test bootstrap; page \"${active.identity}\" is already open. ` +\n 'Drive production navigation for subsequent route changes, or close() the page first.',\n );\n }\n const view = catalog.resolve(openOptions.page);\n const params = openOptions.params ?? {};\n const query = openOptions.query ?? {};\n\n transitioning = true;\n try {\n // Seed production navigation state BEFORE construction: controllers\n // and their services legitimately read the current route (params,\n // query) during construction/initialize, exactly as in production\n // where the route exists before the page loads.\n navigation.replace(pageIdentity(view), params, query);\n const opened = await constructPage(view, params, query);\n if (disposed) { await endPage(opened); throw new PageCatalogError('Wood frontend was disposed during page construction'); }\n active = opened;\n } finally {\n transitioning = false;\n }\n return active.handle.page;\n },\n\n current(): FrontendPage | null {\n return active ? active.handle.page : null;\n },\n\n async act(action: () => unknown | Promise<unknown>): Promise<void> {\n void registry.track(\n Promise.resolve().then(() => action()).then(() => undefined),\n { label: 'act()', kind: 'tracked', owner: currentOwner() },\n ).catch(() => {\n // The failure is recorded by the registry; drain reports it with\n // its owner rather than losing the aggregate view.\n });\n await registry.drain(flushHooks);\n },\n\n async settle(): Promise<void> {\n await registry.drain(flushHooks);\n },\n\n get errors(): readonly WorkFailure[] {\n return registry.errors();\n },\n\n dispose(): Promise<void> {\n if (closing) return closing;\n disposed = true;\n unsubscribe();\n const ended = active;\n active = null;\n return closing = cleanupAll([\n () => ended ? endPage(ended) : undefined,\n () => application.dispose(),\n ], 'Wood frontend cleanup failed');\n },\n };\n}\n\nexport type { FrontendLifecycle };\nexport { FrontendWorkError };\n"],"mappings":"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","names":[]}
@@ -1,6 +1,7 @@
1
1
  import { ExecutionContext, LoadAs } from "@noego/ioc";
2
2
  import { computePreservedLayoutDepth } from "../navigation/layout_preservation.js";
3
- import { disposeControllerLifecycle } from "../controller/controller_lifecycle.js";
3
+ import { cleanupAll } from "../controller/cleanup.js";
4
+ import { disposeControllerLifecycle, guardControllerLifecycleOnce } from "../controller/controller_lifecycle.js";
4
5
  import { PageCatalogError, pageIdentity } from "./page_catalog.js";
5
6
  import { FrontendWorkError } from "./work_registry.js";
6
7
  import { FrontendLifecycleOwner } from "./frontend_lifecycle.js";
@@ -10,26 +11,6 @@ function registerControllerUnlessBound(scope, ControllerClass) {
10
11
  if (typeof registrable.isRegistered === "function" && registrable.isRegistered(ControllerClass)) return;
11
12
  if (typeof scope.registerClass === "function") scope.registerClass(ControllerClass);
12
13
  }
13
- function guardControllerLifecycleOnce(instance) {
14
- if (!instance || typeof instance !== "object") return;
15
- const target = instance;
16
- for (const hook of ["dispose", "destroy"]) {
17
- const original = target[hook];
18
- if (typeof original !== "function") continue;
19
- if (Object.prototype.hasOwnProperty.call(target, hook)) continue;
20
- let called = false;
21
- Object.defineProperty(target, hook, {
22
- configurable: true,
23
- writable: true,
24
- enumerable: false,
25
- value: function guarded(...args) {
26
- if (called) return void 0;
27
- called = true;
28
- return original.apply(this, args);
29
- }
30
- });
31
- }
32
- }
33
14
  function createFrontendSurface(options) {
34
15
  const { catalog, rendererRoot, registry, navigation } = options;
35
16
  const flushHooks = options.flushHooks ?? [];
@@ -37,6 +18,16 @@ function createFrontendSurface(options) {
37
18
  let active = null;
38
19
  let transitioning = false;
39
20
  let disposed = false;
21
+ let navigationQueue = Promise.resolve();
22
+ let closing;
23
+ const retainedPageScopes = /* @__PURE__ */ new Map();
24
+ const locallyOwnedControllers = /* @__PURE__ */ new WeakSet();
25
+ const markController = (instance, token) => {
26
+ const root = rendererRoot;
27
+ if (!root.isRegistered?.(token) && instance && typeof instance === "object") locallyOwnedControllers.add(instance);
28
+ guardControllerLifecycleOnce(instance);
29
+ };
30
+ const disposeLocalController = (instance) => instance && typeof instance === "object" && locallyOwnedControllers.has(instance) ? disposeControllerLifecycle(instance) : Promise.resolve();
40
31
  const currentOwner = () => active ? `page ${active.identity}` : "application";
41
32
  async function constructPage(view, params, query, preserved = []) {
42
33
  const identity = pageIdentity(view);
@@ -48,91 +39,111 @@ function createFrontendSurface(options) {
48
39
  const ControllerClass = await options.resolveController(view.controller, view);
49
40
  const scope = rendererRoot.extend();
50
41
  const lifecycle = new FrontendLifecycleOwner(registry, `page ${identity}`);
51
- scope.registerFunction?.(FrontendLifecycleOwner, () => lifecycle, { loadAs: LoadAs.Scoped });
52
- await options.configurePageScope?.(scope, view, {
53
- kind: "page",
54
- controller: view.controller
55
- });
56
- registerControllerUnlessBound(scope, ControllerClass);
57
- const layouts = [...preserved];
58
- for (let index = preserved.length; index < view.layouts.length; index += 1) {
59
- const layoutIdentity = view.layoutControllers?.[index];
60
- if (!layoutIdentity) continue;
61
- const preserve = view.layoutPreserve?.[index] === true;
62
- const LayoutClass = await options.resolveController(layoutIdentity, view);
63
- const ownScope = preserve ? rendererRoot.extend() : null;
64
- const ownLifecycle = ownScope ? new FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`) : null;
65
- const layoutScope = ownScope ?? scope;
66
- if (ownScope) {
67
- ownScope.registerFunction?.(FrontendLifecycleOwner, () => ownLifecycle, { loadAs: LoadAs.Scoped });
68
- await options.configurePageScope?.(ownScope, view, {
69
- kind: "layout",
70
- layoutIndex: index,
71
- controller: layoutIdentity
42
+ const rollback = [
43
+ () => ExecutionContext.run(scope, () => scope.dispose?.()),
44
+ () => lifecycle.dispose()
45
+ ];
46
+ try {
47
+ scope.registerFunction?.(FrontendLifecycleOwner, () => lifecycle, { loadAs: LoadAs.Scoped });
48
+ await ExecutionContext.run(scope, () => options.configurePageScope?.(scope, view, { kind: "page", controller: view.controller }));
49
+ registerControllerUnlessBound(scope, ControllerClass);
50
+ const layouts = [...preserved];
51
+ for (let index = preserved.length; index < view.layouts.length; index += 1) {
52
+ const layoutIdentity = view.layoutControllers?.[index];
53
+ if (!layoutIdentity) continue;
54
+ const preserve = view.layoutPreserve?.[index] === true;
55
+ const LayoutClass = await options.resolveController(layoutIdentity, view);
56
+ const ownScope = preserve ? rendererRoot.extend() : null;
57
+ const ownLifecycle = ownScope ? new FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`) : null;
58
+ const layoutScope = ownScope ?? scope;
59
+ if (ownScope) {
60
+ rollback.push(() => ExecutionContext.run(ownScope, () => ownScope.dispose?.()));
61
+ rollback.push(() => ownLifecycle.dispose());
62
+ ownScope.registerFunction?.(FrontendLifecycleOwner, () => ownLifecycle, { loadAs: LoadAs.Scoped });
63
+ await ExecutionContext.run(ownScope, () => options.configurePageScope?.(ownScope, view, {
64
+ kind: "layout",
65
+ layoutIndex: index,
66
+ controller: layoutIdentity
67
+ }));
68
+ }
69
+ registerControllerUnlessBound(layoutScope, LayoutClass);
70
+ const instance = await ExecutionContext.run(
71
+ layoutScope,
72
+ () => layoutScope.get(LayoutClass)
73
+ );
74
+ markController(instance, LayoutClass);
75
+ rollback.push(() => ExecutionContext.run(layoutScope, () => disposeLocalController(instance)));
76
+ layouts.push({
77
+ index,
78
+ path: view.layouts[index],
79
+ controller: layoutIdentity,
80
+ preserve,
81
+ instance,
82
+ scope: layoutScope,
83
+ ownScope,
84
+ ownLifecycle
72
85
  });
73
86
  }
74
- registerControllerUnlessBound(layoutScope, LayoutClass);
75
- const instance = await ExecutionContext.run(
76
- layoutScope,
77
- () => layoutScope.get(LayoutClass)
87
+ const controller = await ExecutionContext.run(
88
+ scope,
89
+ () => scope.get(ControllerClass)
78
90
  );
79
- guardControllerLifecycleOnce(instance);
80
- layouts.push({
81
- index,
82
- path: view.layouts[index],
83
- controller: layoutIdentity,
84
- preserve,
85
- instance,
86
- scope: layoutScope,
87
- ownScope,
88
- ownLifecycle
91
+ markController(controller, ControllerClass);
92
+ rollback.push(() => ExecutionContext.run(scope, () => disposeLocalController(controller)));
93
+ if (typeof controller.initialize === "function") {
94
+ await registry.track(
95
+ Promise.resolve(
96
+ ExecutionContext.run(
97
+ scope,
98
+ () => controller.initialize({ params, query })
99
+ )
100
+ ).then(() => void 0),
101
+ { label: `initialize ${identity}`, kind: "initialize", owner: `page ${identity}` }
102
+ );
103
+ }
104
+ const handle = createFrontendPage({
105
+ identity,
106
+ controller,
107
+ scope,
108
+ layouts,
109
+ onClose: async () => {
110
+ await disposePage(identity);
111
+ }
89
112
  });
90
- }
91
- const controller = await ExecutionContext.run(
92
- scope,
93
- () => scope.get(ControllerClass)
94
- );
95
- guardControllerLifecycleOnce(controller);
96
- if (typeof controller.initialize === "function") {
97
- await registry.track(
98
- Promise.resolve(
99
- ExecutionContext.run(
100
- scope,
101
- () => controller.initialize({ params, query })
102
- )
103
- ).then(() => void 0),
104
- { label: `initialize ${identity}`, kind: "initialize", owner: `page ${identity}` }
105
- );
106
- }
107
- const handle = createFrontendPage({
108
- identity,
109
- controller,
110
- scope,
111
- layouts,
112
- onClose: async () => {
113
- await disposePage(identity);
113
+ return { identity, view, scope, lifecycle, handle, controller, layouts };
114
+ } catch (error) {
115
+ try {
116
+ await cleanupAll(rollback.reverse(), "Wood page rollback failed");
117
+ } catch (cleanupError) {
118
+ throw Object.assign(new Error("Wood page construction and cleanup failed"), { cause: error, errors: [error, cleanupError] });
114
119
  }
115
- });
116
- return { identity, view, scope, lifecycle, handle, controller, layouts };
120
+ throw error;
121
+ }
117
122
  }
118
123
  async function endPage(page, preservedDepth = 0) {
119
124
  page.handle.invalidate();
120
- await disposeControllerLifecycle(page.controller);
125
+ const steps = [
126
+ () => ExecutionContext.run(page.scope, () => disposeLocalController(page.controller))
127
+ ];
121
128
  for (const layout of page.layouts) {
122
129
  if (layout.index < preservedDepth) continue;
123
- await disposeControllerLifecycle(layout.instance);
130
+ steps.push(() => ExecutionContext.run(layout.scope, () => disposeLocalController(layout.instance)));
131
+ steps.push(() => layout.ownLifecycle?.dispose());
132
+ if (layout.ownScope) steps.push(() => ExecutionContext.run(layout.ownScope, () => layout.ownScope.dispose?.()));
124
133
  }
125
- await page.lifecycle.dispose();
126
- if (typeof page.scope.dispose === "function") {
127
- await page.scope.dispose();
134
+ const carriesPageScope = page.layouts.some((layout) => layout.index < preservedDepth && layout.scope === page.scope);
135
+ if (carriesPageScope) retainedPageScopes.set(page.scope, page.lifecycle);
136
+ else {
137
+ steps.push(() => page.lifecycle.dispose());
138
+ steps.push(() => ExecutionContext.run(page.scope, () => page.scope.dispose?.()));
128
139
  }
129
- for (const layout of page.layouts) {
130
- if (layout.index < preservedDepth) continue;
131
- await layout.ownLifecycle?.dispose();
132
- if (layout.ownScope && typeof layout.ownScope.dispose === "function") {
133
- await layout.ownScope.dispose();
134
- }
140
+ for (const [scope, lifecycle] of retainedPageScopes) {
141
+ if (active?.layouts.some((layout) => layout.scope === scope)) continue;
142
+ retainedPageScopes.delete(scope);
143
+ steps.push(() => lifecycle.dispose());
144
+ steps.push(() => ExecutionContext.run(scope, () => scope.dispose?.()));
135
145
  }
146
+ await cleanupAll(steps, "Wood page cleanup failed");
136
147
  }
137
148
  async function disposePage(identity) {
138
149
  if (!active || active.identity !== identity) return;
@@ -141,6 +152,7 @@ function createFrontendSurface(options) {
141
152
  await endPage(ended);
142
153
  }
143
154
  async function transitionTo(entry) {
155
+ if (disposed) return;
144
156
  const view = catalog.resolve(entry.page);
145
157
  const previous = active;
146
158
  const depth = previous ? computePreservedLayoutDepth(
@@ -150,16 +162,23 @@ function createFrontendSurface(options) {
150
162
  ) : 0;
151
163
  const carried = previous ? previous.layouts.filter((layout) => layout.index < depth) : [];
152
164
  const next = await constructPage(view, entry.params, entry.query, carried);
165
+ if (disposed) {
166
+ await endPage(next, depth);
167
+ return;
168
+ }
153
169
  active = next;
154
170
  if (previous) {
155
171
  await endPage(previous, depth);
156
172
  }
157
173
  }
158
- navigation.subscribe((entry) => {
174
+ const unsubscribe = navigation.subscribe((entry) => {
159
175
  if (!entry || transitioning || disposed) return;
160
176
  if (active && entry.page === active.identity) return;
161
177
  if (!active) return;
162
- registry.track(transitionTo(entry), {
178
+ const transition = navigationQueue.then(() => transitionTo(entry));
179
+ navigationQueue = transition.catch(() => {
180
+ });
181
+ registry.track(transition, {
163
182
  label: `navigate ${active.identity} -> ${entry.page}`,
164
183
  kind: "navigation",
165
184
  owner: currentOwner()
@@ -168,6 +187,7 @@ function createFrontendSurface(options) {
168
187
  return {
169
188
  navigation,
170
189
  async open(openOptions) {
190
+ if (disposed || transitioning) throw new PageCatalogError("Wood frontend is closed or opening a page");
171
191
  if (!openOptions || typeof openOptions.page !== "string" || !openOptions.page.length) {
172
192
  throw new PageCatalogError("frontend.open() requires an explicit { page } identity");
173
193
  }
@@ -182,7 +202,12 @@ function createFrontendSurface(options) {
182
202
  transitioning = true;
183
203
  try {
184
204
  navigation.replace(pageIdentity(view), params, query);
185
- active = await constructPage(view, params, query);
205
+ const opened = await constructPage(view, params, query);
206
+ if (disposed) {
207
+ await endPage(opened);
208
+ throw new PageCatalogError("Wood frontend was disposed during page construction");
209
+ }
210
+ active = opened;
186
211
  } finally {
187
212
  transitioning = false;
188
213
  }
@@ -192,7 +217,7 @@ function createFrontendSurface(options) {
192
217
  return active ? active.handle.page : null;
193
218
  },
194
219
  async act(action) {
195
- await registry.track(
220
+ void registry.track(
196
221
  Promise.resolve().then(() => action()).then(() => void 0),
197
222
  { label: "act()", kind: "tracked", owner: currentOwner() }
198
223
  ).catch(() => {
@@ -205,15 +230,16 @@ function createFrontendSurface(options) {
205
230
  get errors() {
206
231
  return registry.errors();
207
232
  },
208
- async dispose() {
209
- if (disposed) return;
233
+ dispose() {
234
+ if (closing) return closing;
210
235
  disposed = true;
211
- if (active) {
212
- const ended = active;
213
- active = null;
214
- await endPage(ended);
215
- }
216
- await application.dispose();
236
+ unsubscribe();
237
+ const ended = active;
238
+ active = null;
239
+ return closing = cleanupAll([
240
+ () => ended ? endPage(ended) : void 0,
241
+ () => application.dispose()
242
+ ], "Wood frontend cleanup failed");
217
243
  }
218
244
  };
219
245
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/frontend/frontend_surface.ts"],"sourcesContent":["/**\n * The Node frontend application surface (spec 18 §12–13, 18A D-FAS-01/02).\n *\n * frontend.open({ page, params, query }) — explicit initial page\n * frontend.current() — production navigation result\n * frontend.act(fn) / frontend.settle() — owned-work fixed-point drains\n * frontend.errors — observed owned-work failures\n *\n * Runtime honesty (D-FAS-02): this is the plain-Node tier. It proves\n * explicit PageController behavior, $state/$derived reachable through it,\n * backend calls, events, and navigation identity/lifecycle. It does not\n * create a Svelte component/root context and therefore proves no $effect,\n * mount/unmount, or DOM behavior — those belong to the happy-dom tier.\n *\n * Isolation: every environment owns its own Navigation instance, renderer\n * root, transport, and work registry. Nothing here writes globalThis and\n * nothing calls the production client getters — two environments run\n * concurrently without cross-talk. A controller that calls the module-level\n * `go()`/`getNavigation()` free functions bypasses its environment; this\n * tier requires constructor-injected `Navigation`.\n */\nimport { ExecutionContext, LoadAs } from '@noego/ioc';\nimport { Navigation, type NavigationEntry } from '../navigation/index.js';\nimport { computePreservedLayoutDepth } from '../navigation/layout_preservation.js';\nimport { disposeControllerLifecycle } from '../controller/controller_lifecycle.js';\nimport type { ViewDef } from '../types/index.js';\nimport { PageCatalog, PageCatalogError, pageIdentity } from './page_catalog.js';\nimport { WorkRegistry, FrontendWorkError, type WorkFailure } from './work_registry.js';\nimport { FrontendLifecycleOwner, type FrontendLifecycle } from './frontend_lifecycle.js';\nimport { createFrontendPage, type FrontendPage, type FrontendPageHandle, type MountedLayout } from './frontend_page.js';\n\nexport interface FrontendOpenOptions {\n /** Required production page identity (`window.page`, or unambiguous leaf). */\n page: string;\n params?: Record<string, string>;\n query?: Record<string, string>;\n}\n\nexport interface FrontendApplicationSurface {\n open(options: FrontendOpenOptions): Promise<FrontendPage>;\n current(): FrontendPage | null;\n act(action: () => unknown | Promise<unknown>): Promise<void>;\n settle(): Promise<void>;\n readonly errors: readonly WorkFailure[];\n /** The environment-local production Navigation instance. */\n readonly navigation: Navigation;\n /** Internal: dispose the active page and application lifecycles. */\n dispose(): Promise<void>;\n}\n\ninterface ScopeLike {\n extend(): ScopeLike;\n get(token: unknown): Promise<unknown>;\n registerClass?(target: unknown, options?: unknown): void;\n registerFunction?(token: unknown, factory: () => unknown, options?: unknown): void;\n registerValue?(token: unknown, value: unknown): void;\n dispose?(): Promise<void>;\n}\n\n/**\n * A mounted layout controller and the scope that owns it. A layout declared\n * `preserve: true` owns a DEDICATED scope that survives page transitions\n * (production parity: `createControllerSet`); every other layout shares the\n * page scope and dies with the page.\n */\ninterface ActiveLayout extends MountedLayout {\n index: number;\n /** Non-null only for a preserved layout that owns its scope. */\n ownScope: ScopeLike | null;\n /**\n * A preserved layout outlives the page that built it, so it cannot borrow\n * that page's lifecycle owner — it owns one, disposed when the layout is\n * finally dropped.\n */\n ownLifecycle: FrontendLifecycleOwner | null;\n}\n\ninterface ActivePage {\n identity: string;\n view: ViewDef;\n scope: ScopeLike;\n lifecycle: FrontendLifecycleOwner;\n handle: FrontendPageHandle;\n controller: unknown;\n layouts: ActiveLayout[];\n}\n\nexport interface PageScopeContext {\n kind: 'page' | 'layout';\n /** Layout index within the view's layout stack (layout scopes only). */\n layoutIndex?: number;\n /** Controller identity bound to this scope. */\n controller: string;\n}\n\nexport interface CreateFrontendSurfaceOptions {\n /** The selected renderer aperture. */\n catalog: PageCatalog;\n /** Fresh renderer root for this environment (separate from the main root). */\n rendererRoot: ScopeLike;\n registry: WorkRegistry;\n /** Environment-local Navigation (also registered on the renderer root). */\n navigation: Navigation;\n /** Resolve a production controller identity to its class. */\n resolveController: (identity: string, view: ViewDef) => Promise<new (...args: never[]) => unknown>;\n /** Per-pass reactive flush hooks from the active runtime adapter (Node: none). */\n flushHooks?: ReadonlyArray<() => void | Promise<void>>;\n /**\n * Register per-scope services on a newly created scope (bridge client,\n * etc.). Called for the page scope, and once for each PRESERVED layout\n * that owns a dedicated scope — `context.kind` says which, so a caller\n * that tracks \"the page scope\" does not mistake a layout scope for it.\n */\n configurePageScope?: (\n scope: ScopeLike,\n view: ViewDef,\n context: PageScopeContext,\n ) => void | Promise<void>;\n}\n\n/**\n * Plain (undecorated) controller classes are registered on their scope so\n * they resolve — unless the hierarchy already decided how the token resolves\n * (a renderer-root substitution of the controller class), which keeps\n * winning inside the page scope.\n */\nfunction registerControllerUnlessBound(scope: ScopeLike, ControllerClass: unknown): void {\n const registrable = scope as { isRegistered?(token: unknown): boolean };\n if (typeof registrable.isRegistered === 'function' && registrable.isRegistered(ControllerClass)) return;\n if (typeof scope.registerClass === 'function') scope.registerClass(ControllerClass);\n}\n\n/**\n * Make a controller's production teardown hook run at most once.\n *\n * Two owners legitimately end a controller: this surface (production\n * parity — the renderer's NavigationShell calls the hook explicitly, and\n * for a Transient controller that is the ONLY call) and IoC scope\n * disposal (which calls `dispose()`/`destroy()` on the scoped instances it\n * owns). Shadowing the hook with a once-guard means a controller sees the\n * same single teardown whichever way it was registered.\n */\nfunction guardControllerLifecycleOnce(instance: unknown): void {\n if (!instance || typeof instance !== 'object') return;\n const target = instance as Record<string, unknown>;\n for (const hook of ['dispose', 'destroy'] as const) {\n const original = target[hook];\n if (typeof original !== 'function') continue;\n if (Object.prototype.hasOwnProperty.call(target, hook)) continue;\n let called = false;\n Object.defineProperty(target, hook, {\n configurable: true,\n writable: true,\n enumerable: false,\n value: function guarded(this: unknown, ...args: unknown[]) {\n if (called) return undefined;\n called = true;\n return (original as (...rest: unknown[]) => unknown).apply(this, args);\n },\n });\n }\n}\n\nexport function createFrontendSurface(options: CreateFrontendSurfaceOptions): FrontendApplicationSurface {\n const { catalog, rendererRoot, registry, navigation } = options;\n const flushHooks = options.flushHooks ?? [];\n const application = new FrontendLifecycleOwner(registry, 'application');\n\n let active: ActivePage | null = null;\n let transitioning = false;\n let disposed = false;\n\n const currentOwner = (): string => (active ? `page ${active.identity}` : 'application');\n\n async function constructPage(\n view: ViewDef,\n params: Record<string, string>,\n query: Record<string, string>,\n preserved: ActiveLayout[] = [],\n ): Promise<ActivePage> {\n const identity = pageIdentity(view);\n if (!view.controller) {\n throw new PageCatalogError(\n `page \"${identity}\" declares no controller in its view definition; ` +\n 'the Node application slice drives public PageController input and needs one.',\n );\n }\n\n const ControllerClass = await options.resolveController(view.controller, view);\n const scope = rendererRoot.extend();\n const lifecycle = new FrontendLifecycleOwner(registry, `page ${identity}`);\n\n // The page lifecycle is injectable so controllers can register\n // deliberately detached finite work (D-FAS-01 §2.2). Scoped, not\n // Singleton: the lifecycle belongs to this page scope alone — a\n // Singleton would store at the hierarchy root and leak the first\n // page's lifecycle into every later page.\n scope.registerFunction?.(FrontendLifecycleOwner, () => lifecycle, { loadAs: LoadAs.Scoped });\n await options.configurePageScope?.(scope, view, {\n kind: 'page',\n controller: view.controller,\n });\n registerControllerUnlessBound(scope, ControllerClass);\n\n // Layout controllers mount BEFORE the view controller (production\n // ordering in `createControllerSet`): a page controller may legitimately\n // read state a layout controller published during construction.\n const layouts: ActiveLayout[] = [...preserved];\n for (let index = preserved.length; index < view.layouts.length; index += 1) {\n const layoutIdentity = view.layoutControllers?.[index];\n if (!layoutIdentity) continue;\n const preserve = view.layoutPreserve?.[index] === true;\n const LayoutClass = await options.resolveController(layoutIdentity, view);\n const ownScope = preserve ? rendererRoot.extend() : null;\n const ownLifecycle = ownScope\n ? new FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`)\n : null;\n const layoutScope = ownScope ?? scope;\n if (ownScope) {\n ownScope.registerFunction?.(FrontendLifecycleOwner, () => ownLifecycle!, { loadAs: LoadAs.Scoped });\n await options.configurePageScope?.(ownScope, view, {\n kind: 'layout',\n layoutIndex: index,\n controller: layoutIdentity,\n });\n }\n registerControllerUnlessBound(layoutScope, LayoutClass);\n const instance = (await ExecutionContext.run(layoutScope as never, () =>\n layoutScope.get(LayoutClass),\n )) as Record<string, unknown>;\n guardControllerLifecycleOnce(instance);\n layouts.push({\n index,\n path: view.layouts[index],\n controller: layoutIdentity,\n preserve,\n instance,\n scope: layoutScope,\n ownScope,\n ownLifecycle,\n });\n }\n\n const controller = (await ExecutionContext.run(scope as never, () =>\n scope.get(ControllerClass),\n )) as Record<string, unknown>;\n\n guardControllerLifecycleOnce(controller);\n\n if (typeof controller.initialize === 'function') {\n await registry.track(\n Promise.resolve(\n ExecutionContext.run(scope as never, () =>\n (controller.initialize as (load: unknown) => unknown)({ params, query }),\n ),\n ).then(() => undefined),\n { label: `initialize ${identity}`, kind: 'initialize', owner: `page ${identity}` },\n );\n }\n\n const handle = createFrontendPage({\n identity,\n controller,\n scope,\n layouts,\n onClose: async () => {\n await disposePage(identity);\n },\n });\n\n return { identity, view, scope, lifecycle, handle, controller, layouts };\n }\n\n /**\n * End a page: the page lifecycle owner first (abort signal, owned\n * resources), then the IoC scopes — scope disposal is what runs each\n * controller's production `dispose()`/`destroy()` hook, so nothing here\n * calls those directly. Layouts in the preserved prefix keep running:\n * they belong to the next page now.\n */\n async function endPage(page: ActivePage, preservedDepth = 0): Promise<void> {\n page.handle.invalidate();\n // Production teardown order (NavigationShell.disposeControllerSet): the\n // page controller's hook, then each ending layout's, then the lifecycle\n // owner and the IoC scopes. The hooks are once-guarded, so the scope\n // disposal below does not run them a second time.\n await disposeControllerLifecycle(page.controller);\n for (const layout of page.layouts) {\n if (layout.index < preservedDepth) continue;\n await disposeControllerLifecycle(layout.instance);\n }\n await page.lifecycle.dispose();\n // Page scope first, then dropped preserved layouts — production's\n // disposeControllerSet order (view controller, then layouts).\n if (typeof page.scope.dispose === 'function') {\n await page.scope.dispose();\n }\n for (const layout of page.layouts) {\n if (layout.index < preservedDepth) continue;\n await layout.ownLifecycle?.dispose();\n if (layout.ownScope && typeof layout.ownScope.dispose === 'function') {\n await layout.ownScope.dispose();\n }\n }\n }\n\n async function disposePage(identity: string): Promise<void> {\n if (!active || active.identity !== identity) return;\n const ended = active;\n active = null;\n await endPage(ended);\n }\n\n async function transitionTo(entry: NavigationEntry): Promise<void> {\n // Resolve the destination through the selected aperture — navigation\n // outside it fails without widening the environment.\n const view = catalog.resolve(entry.page);\n const previous = active;\n // Preserved layouts carry over as live instances — the transition never\n // reconstructs them (production `preserve: true` semantics).\n // Same preservation rule the renderer's NavigationShell applies.\n const depth = previous\n ? computePreservedLayoutDepth(\n { layoutPaths: previous.view.layouts, layoutControllers: previous.view.layoutControllers, layoutPreserve: previous.view.layoutPreserve },\n { layoutPaths: view.layouts, layoutControllers: view.layoutControllers, layoutPreserve: view.layoutPreserve },\n entry.options ?? {},\n )\n : 0;\n const carried = previous ? previous.layouts.filter((layout) => layout.index < depth) : [];\n const next = await constructPage(view, entry.params, entry.query, carried);\n active = next;\n if (previous) {\n await endPage(previous, depth);\n }\n }\n\n // Production navigation drives page transitions: a controller calling its\n // injected Navigation.go()/replace() inside the aperture triggers one\n // tracked finite navigation transition (18A D-FAS-01 §2.2).\n navigation.subscribe((entry) => {\n if (!entry || transitioning || disposed) return;\n if (active && entry.page === active.identity) return;\n if (!active) return; // initial open() seeds navigation itself\n registry.track(transitionTo(entry), {\n label: `navigate ${active.identity} -> ${entry.page}`,\n kind: 'navigation',\n owner: currentOwner(),\n });\n });\n\n return {\n navigation,\n\n async open(openOptions: FrontendOpenOptions): Promise<FrontendPage> {\n if (!openOptions || typeof openOptions.page !== 'string' || !openOptions.page.length) {\n throw new PageCatalogError('frontend.open() requires an explicit { page } identity');\n }\n if (active) {\n throw new PageCatalogError(\n `frontend.open() is test bootstrap; page \"${active.identity}\" is already open. ` +\n 'Drive production navigation for subsequent route changes, or close() the page first.',\n );\n }\n const view = catalog.resolve(openOptions.page);\n const params = openOptions.params ?? {};\n const query = openOptions.query ?? {};\n\n transitioning = true;\n try {\n // Seed production navigation state BEFORE construction: controllers\n // and their services legitimately read the current route (params,\n // query) during construction/initialize, exactly as in production\n // where the route exists before the page loads.\n navigation.replace(pageIdentity(view), params, query);\n active = await constructPage(view, params, query);\n } finally {\n transitioning = false;\n }\n return active.handle.page;\n },\n\n current(): FrontendPage | null {\n return active ? active.handle.page : null;\n },\n\n async act(action: () => unknown | Promise<unknown>): Promise<void> {\n await registry.track(\n Promise.resolve().then(() => action()).then(() => undefined),\n { label: 'act()', kind: 'tracked', owner: currentOwner() },\n ).catch(() => {\n // The failure is recorded by the registry; drain reports it with\n // its owner rather than losing the aggregate view.\n });\n await registry.drain(flushHooks);\n },\n\n async settle(): Promise<void> {\n await registry.drain(flushHooks);\n },\n\n get errors(): readonly WorkFailure[] {\n return registry.errors();\n },\n\n async dispose(): Promise<void> {\n if (disposed) return;\n disposed = true;\n if (active) {\n const ended = active;\n active = null;\n // Environment teardown ends everything, preserved layouts included.\n await endPage(ended);\n }\n await application.dispose();\n },\n };\n}\n\nexport type { FrontendLifecycle };\nexport { FrontendWorkError };\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/frontend/frontend_surface.ts"],"sourcesContent":["/**\n * The Node frontend application surface (spec 18 §12–13, 18A D-FAS-01/02).\n *\n * frontend.open({ page, params, query }) — explicit initial page\n * frontend.current() — production navigation result\n * frontend.act(fn) / frontend.settle() — owned-work fixed-point drains\n * frontend.errors — observed owned-work failures\n *\n * Runtime honesty (D-FAS-02): this is the plain-Node tier. It proves\n * explicit PageController behavior, $state/$derived reachable through it,\n * backend calls, events, and navigation identity/lifecycle. It does not\n * create a Svelte component/root context and therefore proves no $effect,\n * mount/unmount, or DOM behavior — those belong to the happy-dom tier.\n *\n * Isolation: every environment owns its own Navigation instance, renderer\n * root, transport, and work registry. Nothing here writes globalThis and\n * nothing calls the production client getters — two environments run\n * concurrently without cross-talk. A controller that calls the module-level\n * `go()`/`getNavigation()` free functions bypasses its environment; this\n * tier requires constructor-injected `Navigation`.\n */\nimport { ExecutionContext, LoadAs } from '@noego/ioc';\nimport { Navigation, type NavigationEntry } from '../navigation/index.js';\nimport { computePreservedLayoutDepth } from '../navigation/layout_preservation.js';\nimport { cleanupAll } from '../controller/cleanup.js';\nimport { disposeControllerLifecycle, guardControllerLifecycleOnce } from '../controller/controller_lifecycle.js';\nimport type { ViewDef } from '../types/index.js';\nimport { PageCatalog, PageCatalogError, pageIdentity } from './page_catalog.js';\nimport { WorkRegistry, FrontendWorkError, type WorkFailure } from './work_registry.js';\nimport { FrontendLifecycleOwner, type FrontendLifecycle } from './frontend_lifecycle.js';\nimport { createFrontendPage, type FrontendPage, type FrontendPageHandle, type MountedLayout } from './frontend_page.js';\n\nexport interface FrontendOpenOptions {\n /** Required production page identity (`window.page`, or unambiguous leaf). */\n page: string;\n params?: Record<string, string>;\n query?: Record<string, string>;\n}\n\nexport interface FrontendApplicationSurface {\n open(options: FrontendOpenOptions): Promise<FrontendPage>;\n current(): FrontendPage | null;\n act(action: () => unknown | Promise<unknown>): Promise<void>;\n settle(): Promise<void>;\n readonly errors: readonly WorkFailure[];\n /** The environment-local production Navigation instance. */\n readonly navigation: Navigation;\n /** Internal: dispose the active page and application lifecycles. */\n dispose(): Promise<void>;\n}\n\ninterface ScopeLike {\n extend(): ScopeLike;\n get(token: unknown): Promise<unknown>;\n registerClass?(target: unknown, options?: unknown): void;\n registerFunction?(token: unknown, factory: () => unknown, options?: unknown): void;\n registerValue?(token: unknown, value: unknown): void;\n dispose?(): Promise<void>;\n}\n\n/**\n * A mounted layout controller and the scope that owns it. A layout declared\n * `preserve: true` owns a DEDICATED scope that survives page transitions\n * (production parity: `createControllerSet`); every other layout shares the\n * page scope and dies with the page.\n */\ninterface ActiveLayout extends MountedLayout {\n index: number;\n /** Non-null only for a preserved layout that owns its scope. */\n ownScope: ScopeLike | null;\n /**\n * A preserved layout outlives the page that built it, so it cannot borrow\n * that page's lifecycle owner — it owns one, disposed when the layout is\n * finally dropped.\n */\n ownLifecycle: FrontendLifecycleOwner | null;\n}\n\ninterface ActivePage {\n identity: string;\n view: ViewDef;\n scope: ScopeLike;\n lifecycle: FrontendLifecycleOwner;\n handle: FrontendPageHandle;\n controller: unknown;\n layouts: ActiveLayout[];\n}\n\nexport interface PageScopeContext {\n kind: 'page' | 'layout';\n /** Layout index within the view's layout stack (layout scopes only). */\n layoutIndex?: number;\n /** Controller identity bound to this scope. */\n controller: string;\n}\n\nexport interface CreateFrontendSurfaceOptions {\n /** The selected renderer aperture. */\n catalog: PageCatalog;\n /** Fresh renderer root for this environment (separate from the main root). */\n rendererRoot: ScopeLike;\n registry: WorkRegistry;\n /** Environment-local Navigation (also registered on the renderer root). */\n navigation: Navigation;\n /** Resolve a production controller identity to its class. */\n resolveController: (identity: string, view: ViewDef) => Promise<new (...args: never[]) => unknown>;\n /** Per-pass reactive flush hooks from the active runtime adapter (Node: none). */\n flushHooks?: ReadonlyArray<() => void | Promise<void>>;\n /**\n * Register per-scope services on a newly created scope (bridge client,\n * etc.). Called for the page scope, and once for each PRESERVED layout\n * that owns a dedicated scope — `context.kind` says which, so a caller\n * that tracks \"the page scope\" does not mistake a layout scope for it.\n */\n configurePageScope?: (\n scope: ScopeLike,\n view: ViewDef,\n context: PageScopeContext,\n ) => void | Promise<void>;\n}\n\n/**\n * Plain (undecorated) controller classes are registered on their scope so\n * they resolve — unless the hierarchy already decided how the token resolves\n * (a renderer-root substitution of the controller class), which keeps\n * winning inside the page scope.\n */\nfunction registerControllerUnlessBound(scope: ScopeLike, ControllerClass: unknown): void {\n const registrable = scope as { isRegistered?(token: unknown): boolean };\n if (typeof registrable.isRegistered === 'function' && registrable.isRegistered(ControllerClass)) return;\n if (typeof scope.registerClass === 'function') scope.registerClass(ControllerClass);\n}\n\nexport function createFrontendSurface(options: CreateFrontendSurfaceOptions): FrontendApplicationSurface {\n const { catalog, rendererRoot, registry, navigation } = options;\n const flushHooks = options.flushHooks ?? [];\n const application = new FrontendLifecycleOwner(registry, 'application');\n\n let active: ActivePage | null = null;\n let transitioning = false;\n let disposed = false;\n let navigationQueue: Promise<void> = Promise.resolve();\n let closing: Promise<void> | undefined;\n const retainedPageScopes = new Map<ScopeLike, FrontendLifecycleOwner>();\n const locallyOwnedControllers = new WeakSet<object>();\n const markController = (instance: unknown, token: unknown) => {\n const root = rendererRoot as ScopeLike & { isRegistered?(token: unknown): boolean };\n if (!root.isRegistered?.(token) && instance && typeof instance === 'object') locallyOwnedControllers.add(instance);\n guardControllerLifecycleOnce(instance);\n };\n const disposeLocalController = (instance: unknown) => instance && typeof instance === 'object' && locallyOwnedControllers.has(instance)\n ? disposeControllerLifecycle(instance) : Promise.resolve();\n\n const currentOwner = (): string => (active ? `page ${active.identity}` : 'application');\n\n async function constructPage(\n view: ViewDef,\n params: Record<string, string>,\n query: Record<string, string>,\n preserved: ActiveLayout[] = [],\n ): Promise<ActivePage> {\n const identity = pageIdentity(view);\n if (!view.controller) {\n throw new PageCatalogError(\n `page \"${identity}\" declares no controller in its view definition; ` +\n 'the Node application slice drives public PageController input and needs one.',\n );\n }\n\n const ControllerClass = await options.resolveController(view.controller, view);\n const scope = rendererRoot.extend();\n const lifecycle = new FrontendLifecycleOwner(registry, `page ${identity}`);\n\n const rollback: Array<() => unknown | Promise<unknown>> = [\n () => ExecutionContext.run(scope as never, () => scope.dispose?.()),\n () => lifecycle.dispose(),\n ];\n try {\n // The page lifecycle is injectable so controllers can register\n // deliberately detached finite work (D-FAS-01 §2.2). Scoped, not\n // Singleton: the lifecycle belongs to this page scope alone — a\n // Singleton would store at the hierarchy root and leak the first\n // page's lifecycle into every later page.\n scope.registerFunction?.(FrontendLifecycleOwner, () => lifecycle, { loadAs: LoadAs.Scoped });\n await ExecutionContext.run(scope as never, () => options.configurePageScope?.(scope, view, { kind: 'page', controller: view.controller! }));\n registerControllerUnlessBound(scope, ControllerClass);\n\n // Layout controllers mount BEFORE the view controller (production\n // ordering in `createControllerSet`): a page controller may legitimately\n // read state a layout controller published during construction.\n const layouts: ActiveLayout[] = [...preserved];\n for (let index = preserved.length; index < view.layouts.length; index += 1) {\n const layoutIdentity = view.layoutControllers?.[index];\n if (!layoutIdentity) continue;\n const preserve = view.layoutPreserve?.[index] === true;\n const LayoutClass = await options.resolveController(layoutIdentity, view);\n const ownScope = preserve ? rendererRoot.extend() : null;\n const ownLifecycle = ownScope\n ? new FrontendLifecycleOwner(registry, `layout ${layoutIdentity}`)\n : null;\n const layoutScope = ownScope ?? scope;\n if (ownScope) {\n rollback.push(() => ExecutionContext.run(ownScope as never, () => ownScope.dispose?.()));\n rollback.push(() => ownLifecycle!.dispose());\n ownScope.registerFunction?.(FrontendLifecycleOwner, () => ownLifecycle!, { loadAs: LoadAs.Scoped });\n await ExecutionContext.run(ownScope as never, () => options.configurePageScope?.(ownScope, view, {\n kind: 'layout',\n layoutIndex: index,\n controller: layoutIdentity,\n }));\n }\n registerControllerUnlessBound(layoutScope, LayoutClass);\n const instance = (await ExecutionContext.run(layoutScope as never, () =>\n layoutScope.get(LayoutClass),\n )) as Record<string, unknown>;\n markController(instance, LayoutClass);\n rollback.push(() => ExecutionContext.run(layoutScope as never, () => disposeLocalController(instance)));\n layouts.push({\n index,\n path: view.layouts[index],\n controller: layoutIdentity,\n preserve,\n instance,\n scope: layoutScope,\n ownScope,\n ownLifecycle,\n });\n }\n\n const controller = (await ExecutionContext.run(scope as never, () =>\n scope.get(ControllerClass),\n )) as Record<string, unknown>;\n\n markController(controller, ControllerClass);\n rollback.push(() => ExecutionContext.run(scope as never, () => disposeLocalController(controller)));\n\n if (typeof controller.initialize === 'function') {\n await registry.track(\n Promise.resolve(\n ExecutionContext.run(scope as never, () =>\n (controller.initialize as (load: unknown) => unknown)({ params, query }),\n ),\n ).then(() => undefined),\n { label: `initialize ${identity}`, kind: 'initialize', owner: `page ${identity}` },\n );\n }\n\n const handle = createFrontendPage({\n identity,\n controller,\n scope,\n layouts,\n onClose: async () => {\n await disposePage(identity);\n },\n });\n\n return { identity, view, scope, lifecycle, handle, controller, layouts };\n } catch (error) {\n try { await cleanupAll(rollback.reverse(), 'Wood page rollback failed'); }\n catch (cleanupError) { throw Object.assign(new Error('Wood page construction and cleanup failed'), { cause: error, errors: [error, cleanupError] }); }\n throw error;\n }\n }\n\n /**\n * End a page: the page lifecycle owner first (abort signal, owned\n * resources), then the IoC scopes — scope disposal is what runs each\n * controller's production `dispose()`/`destroy()` hook, so nothing here\n * calls those directly. Layouts in the preserved prefix keep running:\n * they belong to the next page now.\n */\n async function endPage(page: ActivePage, preservedDepth = 0): Promise<void> {\n page.handle.invalidate();\n // Production teardown order (NavigationShell.disposeControllerSet): the\n // page controller's hook, then each ending layout's, then the lifecycle\n // owner and the IoC scopes. The hooks are once-guarded, so the scope\n // disposal below does not run them a second time.\n const steps: Array<() => unknown | Promise<unknown>> = [\n () => ExecutionContext.run(page.scope as never, () => disposeLocalController(page.controller)),\n ];\n for (const layout of page.layouts) {\n if (layout.index < preservedDepth) continue;\n steps.push(() => ExecutionContext.run(layout.scope as never, () => disposeLocalController(layout.instance)));\n steps.push(() => layout.ownLifecycle?.dispose());\n if (layout.ownScope) steps.push(() => ExecutionContext.run(layout.ownScope as never, () => layout.ownScope!.dispose?.()));\n }\n const carriesPageScope = page.layouts.some(layout => layout.index < preservedDepth && layout.scope === page.scope);\n if (carriesPageScope) retainedPageScopes.set(page.scope, page.lifecycle);\n else {\n steps.push(() => page.lifecycle.dispose());\n steps.push(() => ExecutionContext.run(page.scope as never, () => page.scope.dispose?.()));\n }\n for (const [scope, lifecycle] of retainedPageScopes) {\n if (active?.layouts.some(layout => layout.scope === scope)) continue;\n retainedPageScopes.delete(scope);\n steps.push(() => lifecycle.dispose());\n steps.push(() => ExecutionContext.run(scope as never, () => scope.dispose?.()));\n }\n await cleanupAll(steps, 'Wood page cleanup failed');\n }\n\n async function disposePage(identity: string): Promise<void> {\n if (!active || active.identity !== identity) return;\n const ended = active;\n active = null;\n await endPage(ended);\n }\n\n async function transitionTo(entry: NavigationEntry): Promise<void> {\n if (disposed) return;\n // Resolve the destination through the selected aperture — navigation\n // outside it fails without widening the environment.\n const view = catalog.resolve(entry.page);\n const previous = active;\n // Preserved layouts carry over as live instances — the transition never\n // reconstructs them (production `preserve: true` semantics).\n // Same preservation rule the renderer's NavigationShell applies.\n const depth = previous\n ? computePreservedLayoutDepth(\n { layoutPaths: previous.view.layouts, layoutControllers: previous.view.layoutControllers, layoutPreserve: previous.view.layoutPreserve },\n { layoutPaths: view.layouts, layoutControllers: view.layoutControllers, layoutPreserve: view.layoutPreserve },\n entry.options ?? {},\n )\n : 0;\n const carried = previous ? previous.layouts.filter((layout) => layout.index < depth) : [];\n const next = await constructPage(view, entry.params, entry.query, carried);\n if (disposed) {\n await endPage(next, depth);\n return;\n }\n active = next;\n if (previous) {\n await endPage(previous, depth);\n }\n }\n\n // Production navigation drives page transitions: a controller calling its\n // injected Navigation.go()/replace() inside the aperture triggers one\n // tracked finite navigation transition (18A D-FAS-01 §2.2).\n const unsubscribe = navigation.subscribe((entry) => {\n if (!entry || transitioning || disposed) return;\n if (active && entry.page === active.identity) return;\n if (!active) return; // initial open() seeds navigation itself\n const transition = navigationQueue.then(() => transitionTo(entry));\n navigationQueue = transition.catch(() => {});\n registry.track(transition, {\n label: `navigate ${active.identity} -> ${entry.page}`,\n kind: 'navigation',\n owner: currentOwner(),\n });\n });\n\n return {\n navigation,\n\n async open(openOptions: FrontendOpenOptions): Promise<FrontendPage> {\n if (disposed || transitioning) throw new PageCatalogError('Wood frontend is closed or opening a page');\n if (!openOptions || typeof openOptions.page !== 'string' || !openOptions.page.length) {\n throw new PageCatalogError('frontend.open() requires an explicit { page } identity');\n }\n if (active) {\n throw new PageCatalogError(\n `frontend.open() is test bootstrap; page \"${active.identity}\" is already open. ` +\n 'Drive production navigation for subsequent route changes, or close() the page first.',\n );\n }\n const view = catalog.resolve(openOptions.page);\n const params = openOptions.params ?? {};\n const query = openOptions.query ?? {};\n\n transitioning = true;\n try {\n // Seed production navigation state BEFORE construction: controllers\n // and their services legitimately read the current route (params,\n // query) during construction/initialize, exactly as in production\n // where the route exists before the page loads.\n navigation.replace(pageIdentity(view), params, query);\n const opened = await constructPage(view, params, query);\n if (disposed) { await endPage(opened); throw new PageCatalogError('Wood frontend was disposed during page construction'); }\n active = opened;\n } finally {\n transitioning = false;\n }\n return active.handle.page;\n },\n\n current(): FrontendPage | null {\n return active ? active.handle.page : null;\n },\n\n async act(action: () => unknown | Promise<unknown>): Promise<void> {\n void registry.track(\n Promise.resolve().then(() => action()).then(() => undefined),\n { label: 'act()', kind: 'tracked', owner: currentOwner() },\n ).catch(() => {\n // The failure is recorded by the registry; drain reports it with\n // its owner rather than losing the aggregate view.\n });\n await registry.drain(flushHooks);\n },\n\n async settle(): Promise<void> {\n await registry.drain(flushHooks);\n },\n\n get errors(): readonly WorkFailure[] {\n return registry.errors();\n },\n\n dispose(): Promise<void> {\n if (closing) return closing;\n disposed = true;\n unsubscribe();\n const ended = active;\n active = null;\n return closing = cleanupAll([\n () => ended ? endPage(ended) : undefined,\n () => application.dispose(),\n ], 'Wood frontend cleanup failed');\n },\n };\n}\n\nexport type { FrontendLifecycle };\nexport { FrontendWorkError };\n"],"mappings":"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","names":[]}