diff --git a/src/facade/Makefile b/src/facade/Makefile
index b68e5e1..de5350c 100644
--- a/src/facade/Makefile
+++ b/src/facade/Makefile
@@ -6,7 +6,7 @@
# Run `make sync` after every Wippy Web Host version bump to pull fresh copies.
# Web Host CDN base — update version when Wippy Web Host releases
-WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.44
+WEB_HOST_CDN = https://web-host.wippy.ai/webcomponents-1.0.45
# Files to sync from CDN into public/@wippy-fe/
CDN_FILES = loading.js
diff --git a/src/facade/README.md b/src/facade/README.md
index 7d30313..6719446 100644
--- a/src/facade/README.md
+++ b/src/facade/README.md
@@ -66,9 +66,10 @@ These fields are NOT configurable via requirements — they are computed at runt
| Requirement | Default | Description |
|---|---|---|
-| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.44` | CDN base URL for the Web Host frontend bundle |
+| `fe_facade_url` | `https://web-host.wippy.ai/webcomponents-1.0.45` | CDN base URL for the Web Host frontend bundle |
| `fe_entry_path` | `/iframe.html` | Iframe HTML entry point path (appended to `fe_facade_url`) |
| `fe_mode` | `compat` | `compat` (default — loads `module.js`) or `managed` (loads `managed-layout.js` for declarative multi-panel apps). See [Modes](#modes) above |
+| `render_engine` | `iframe` | Global page render engine for packaged `view.page` micro-frontends: `iframe` (default — legacy srcdoc iframe) or `fragment` (Web Fragment / reframed realm reflected into a shadow root). `fragment` **requires** the `wippy/views` fragment gateway and a fragment-capable host bundle serving `/@wippy-fe/proxy-fragment.js`. Flows to the host as `hostConfig.renderEngine`; the child app is engine-agnostic (same package renders under either engine). |
### App Identity
@@ -377,9 +378,9 @@ Returns an empty body (200 OK) when no variables are configured. Response has `C
```json
{
- "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.44",
+ "facade_url": "https://web-host.wippy.ai/webcomponents-1.0.45",
"iframe_origin": "https://web-host.wippy.ai",
- "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.44/iframe.html?waitForCustomConfig",
+ "iframe_url": "https://web-host.wippy.ai/webcomponents-1.0.45/iframe.html?waitForCustomConfig",
"login_path": "/login.html",
"login_redirect_param": null,
"env": {
diff --git a/src/facade/_index.yaml b/src/facade/_index.yaml
index f101c6d..0d1fe8a 100644
--- a/src/facade/_index.yaml
+++ b/src/facade/_index.yaml
@@ -39,7 +39,7 @@ entries:
targets:
- entry: wippy.facade:fe_facade_url
path: .default
- default: https://web-host.wippy.ai/webcomponents-1.0.44
+ default: https://web-host.wippy.ai/webcomponents-1.0.45
- name: fe_entry_path
kind: ns.requirement
@@ -63,6 +63,23 @@ entries:
path: .default
default: compat
+ - name: render_engine
+ kind: ns.requirement
+ meta:
+ description: |
+ Global page render engine for packaged view.page micro-frontends.
+ `iframe` (default) renders each page in a legacy srcdoc iframe.
+ `fragment` renders each page as a Web Fragment (a reframed realm
+ reflected into a shadow root). `fragment` REQUIRES the wippy/views
+ fragment gateway AND a fragment-capable host bundle serving
+ /@wippy-fe/proxy-fragment.js. Flows to the host as
+ hostConfig.renderEngine; the child app is engine-agnostic (the same
+ package renders identically under either engine).
+ targets:
+ - entry: wippy.facade:render_engine
+ path: .default
+ default: iframe
+
# App identity
- name: app_title
kind: ns.requirement
diff --git a/src/facade/config_handler.lua b/src/facade/config_handler.lua
index 444e6f5..a7fcac8 100644
--- a/src/facade/config_handler.lua
+++ b/src/facade/config_handler.lua
@@ -84,6 +84,15 @@ local function handler()
fe_mode = "compat"
end
+ -- Global page render engine (EE2-2313). Only the exact string "fragment"
+ -- opts a deployment into the Web Fragment engine; anything else (default,
+ -- typo, blank) clamps to the legacy srcdoc "iframe" engine so a bad value
+ -- can never silently ship the non-legacy path.
+ local render_engine = get_req("render_engine")
+ if render_engine ~= "fragment" then
+ render_engine = "iframe"
+ end
+
-- Module filename loaded by index.html. `compat` keeps the historical
-- module.js (full Wippy host chrome); `managed` loads managed-layout.js
-- (declarative multi-panel host via hostConfig.layout).
@@ -141,6 +150,7 @@ local function handler()
hideNavBar = get_req("hide_nav_bar") == "true",
disableRightPanel = get_req("disable_right_panel") == "true",
hideSessionSelector = get_req("hide_session_selector") == "true",
+ renderEngine = render_engine,
}
local api_routes = non_empty_map_or_nil(get_req_json_any("api_routes"))
diff --git a/src/facade/config_handler_test.lua b/src/facade/config_handler_test.lua
index d613d4d..a445400 100644
--- a/src/facade/config_handler_test.lua
+++ b/src/facade/config_handler_test.lua
@@ -5,7 +5,7 @@ local registry = require("registry")
local NS = "wippy.facade:"
local REQ_NAMES: {string} = {
- "fe_facade_url", "fe_entry_path", "fe_mode", "session_type",
+ "fe_facade_url", "fe_entry_path", "fe_mode", "render_engine", "session_type",
"history_mode", "show_admin", "start_nav_open", "allow_select_model",
"hide_nav_bar", "disable_right_panel", "hide_session_selector",
"custom_css", "css_variables", "icon_sets",
@@ -26,6 +26,7 @@ local function setup_registry(overrides: {[string]: string}?)
fe_facade_url = "https://front.wippy.ai",
fe_entry_path = "/iframe.html",
fe_mode = "compat",
+ render_engine = "iframe",
session_type = "non-persistent",
history_mode = "hash",
show_admin = "true",
@@ -113,6 +114,15 @@ local function clamp_theme_storage_key(value: string): string
return value
end
+-- Mirrors the clamp in config_handler.lua: only the exact string "fragment"
+-- opts into the Web Fragment engine; anything else (default/typo/blank) → iframe.
+local function clamp_render_engine(value: string): string
+ if value ~= "fragment" then
+ return "iframe"
+ end
+ return value
+end
+
local function define_tests()
test.describe("config handler", function()
test.describe("ws url derivation", function()
@@ -155,6 +165,19 @@ local function define_tests()
end)
end)
+ test.describe("render engine clamping", function()
+ test.it("keeps fragment as-is", function()
+ test.eq(clamp_render_engine("fragment"), "fragment")
+ end)
+
+ test.it("clamps default / unknown / typo / blank to iframe", function()
+ test.eq(clamp_render_engine("iframe"), "iframe")
+ test.eq(clamp_render_engine(""), "iframe")
+ test.eq(clamp_render_engine("Fragment"), "iframe")
+ test.eq(clamp_render_engine("srcdoc"), "iframe")
+ end)
+ end)
+
test.describe("iframe URL construction", function()
test.it("builds iframe URL from facade_url and entry_path", function()
local facade_url = "https://front.wippy.ai"
@@ -165,7 +188,7 @@ local function define_tests()
end)
test.it("extracts iframe origin from facade URL", function()
- local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.44"
+ local facade_url = "https://web-host.wippy.ai/webcomponents-1.0.45"
local origin = facade_url:match("^(https?://[^/]+)")
test.eq(origin, "https://web-host.wippy.ai")
@@ -256,6 +279,9 @@ local function define_tests()
entry = registry.get(NS .. "history_mode")
test.eq(entry.data.default, "hash")
+
+ entry = registry.get(NS .. "render_engine")
+ test.eq(entry.data.default, "iframe")
end)
test.it("host_config_layout requirement defaults to empty JSON object", function()
diff --git a/src/facade/index_handler.lua b/src/facade/index_handler.lua
index 53a26bd..3732cf7 100644
--- a/src/facade/index_handler.lua
+++ b/src/facade/index_handler.lua
@@ -63,7 +63,13 @@ local function handler()
end
end
- local set = templates.get(TEMPLATE_SET)
+ local set, get_err = templates.get(TEMPLATE_SET)
+ if not set then
+ res:set_status(http.STATUS.INTERNAL_SERVER_ERROR)
+ res:set_content_type("text/html")
+ res:write("
WippyFailed to load shell template set: " .. tostring(get_err))
+ return nil, get_err
+ end
local html, err = set:render("index", {
hasTheme = has_theme,
themeClass = theme_class,
@@ -81,6 +87,7 @@ local function handler()
res:set_header("Cache-Control", "no-store")
res:set_status(http.STATUS.OK)
res:write(html)
+ set:release()
end
return { handler = handler }
diff --git a/src/facade/public/@wippy-fe/loading.js b/src/facade/public/@wippy-fe/loading.js
index 98b00b2..9fe6ab7 100644
--- a/src/facade/public/@wippy-fe/loading.js
+++ b/src/facade/public/@wippy-fe/loading.js
@@ -1,4 +1,4 @@
-var WippyLoading=(function(t){"use strict";var w=Object.defineProperty;var y=(t,i,r)=>i in t?w(t,i,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[i]=r;var n=(t,i,r)=>y(t,typeof i!="symbol"?i+"":i,r);const p={circle:'',triangle:'',sad:''},f=`
+var WippyLoading=(function(i){"use strict";const s={circle:'',triangle:'',sad:''},g=`
*, *::before, *::after { box-sizing: border-box; }
:host {
@@ -84,7 +84,7 @@ var WippyLoading=(function(t){"use strict";var w=Object.defineProperty;var y=(t,
color: var(--p-text-muted-color, #a1a1aa);
}
}
-`;class s extends HTMLElement{constructor(){super(...arguments);n(this,"_iconEl",null);n(this,"_titleEl",null);n(this,"_messageEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const l=document.createElement("style");l.textContent=f,e.appendChild(l);const o=document.createElement("div");o.className="container",this._iconEl=document.createElement("div"),this._iconEl.className="icon",this._iconEl.setAttribute("part","icon"),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._messageEl=document.createElement("div"),this._messageEl.className="message",this._messageEl.setAttribute("part","message"),this._update(),o.append(this._iconEl,this._titleEl,this._messageEl),e.appendChild(o)}disconnectedCallback(){this._iconEl=null,this._titleEl=null,this._messageEl=null}attributeChangedCallback(){this._update()}_update(){if(this._iconEl){const e=this.getAttribute("icon")??"circle";this._iconEl.innerHTML=p[e]??p.circle}this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??"Something went wrong"),this._messageEl&&(this._messageEl.textContent=this.getAttribute("message")??"")}}n(s,"observedAttributes",["title","message","icon","severity"]);const m=3e3,g=2e3,E=`
+`;class a extends HTMLElement{static observedAttributes=["title","message","icon","severity"];_iconEl=null;_titleEl=null;_messageEl=null;connectedCallback(){const t=this.shadowRoot??this.attachShadow({mode:"open"});t.textContent="";const n=document.createElement("style");n.textContent=g,t.appendChild(n);const e=document.createElement("div");e.className="container",this._iconEl=document.createElement("div"),this._iconEl.className="icon",this._iconEl.setAttribute("part","icon"),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._messageEl=document.createElement("div"),this._messageEl.className="message",this._messageEl.setAttribute("part","message"),this._update(),e.append(this._iconEl,this._titleEl,this._messageEl),t.appendChild(e)}disconnectedCallback(){this._iconEl=null,this._titleEl=null,this._messageEl=null}attributeChangedCallback(){this._update()}_update(){if(this._iconEl){const t=this.getAttribute("icon")??"circle";this._iconEl.innerHTML=s[t]??s.circle}this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??"Something went wrong"),this._messageEl&&(this._messageEl.textContent=this.getAttribute("message")??"")}}const c=3e3,d=2e3,u=`
*, *::before, *::after { box-sizing: border-box; }
:host {
@@ -132,7 +132,7 @@ var WippyLoading=(function(t){"use strict";var w=Object.defineProperty;var y=(t,
width: 48px;
height: 48px;
border: 3px solid var(--p-surface-300, #d4d4d8);
- animation: spin-outer ${m}ms linear infinite;
+ animation: spin-outer ${c}ms linear infinite;
}
.ring.inner {
@@ -143,7 +143,7 @@ var WippyLoading=(function(t){"use strict";var w=Object.defineProperty;var y=(t,
border: 3px solid transparent;
border-top-color: var(--p-primary, rgb(0, 95, 178));
border-bottom-color: var(--p-primary, rgb(0, 95, 178));
- animation: spin-inner ${g}ms linear infinite;
+ animation: spin-inner ${d}ms linear infinite;
}
.title {
@@ -191,5 +191,5 @@ var WippyLoading=(function(t){"use strict";var w=Object.defineProperty;var y=(t,
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(-360deg); }
}
-`;class a extends HTMLElement{constructor(){super(...arguments);n(this,"_titleEl",null);n(this,"_subtitleEl",null)}connectedCallback(){const e=this.shadowRoot??this.attachShadow({mode:"open"});e.textContent="";const l=document.createElement("style");l.textContent=E,e.appendChild(l);const o=document.createElement("div");o.className="container";const c=document.createElement("div");c.className="spinner";const d=document.createElement("div");d.className="ring outer";const h=document.createElement("div");h.className="ring inner",c.append(d,h),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._subtitleEl=document.createElement("div"),this._subtitleEl.className="subtitle",this._subtitleEl.setAttribute("part","subtitle"),this._updateText(),o.append(c,this._titleEl,this._subtitleEl),e.appendChild(o);const b=Date.now();d.style.animationDelay=`-${b%m}ms`,h.style.animationDelay=`-${b%g}ms`}disconnectedCallback(){this._titleEl=null,this._subtitleEl=null}attributeChangedCallback(){this._updateText()}_updateText(){this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??""),this._subtitleEl&&(this._subtitleEl.textContent=this.getAttribute("subtitle")??"")}}n(a,"observedAttributes",["title","subtitle"]);function u(){customElements.get("wippy-loading")||customElements.define("wippy-loading",a),customElements.get("wippy-error")||customElements.define("wippy-error",s)}return u(),t.WippyErrorElement=s,t.WippyLoadingElement=a,t.register=u,Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),t})({});
+`;class h extends HTMLElement{static observedAttributes=["title","subtitle"];_titleEl=null;_subtitleEl=null;connectedCallback(){const t=this.shadowRoot??this.attachShadow({mode:"open"});t.textContent="";const n=document.createElement("style");n.textContent=u,t.appendChild(n);const e=document.createElement("div");e.className="container";const o=document.createElement("div");o.className="spinner";const r=document.createElement("div");r.className="ring outer";const l=document.createElement("div");l.className="ring inner",o.append(r,l),this._titleEl=document.createElement("div"),this._titleEl.className="title",this._titleEl.setAttribute("part","title"),this._subtitleEl=document.createElement("div"),this._subtitleEl.className="subtitle",this._subtitleEl.setAttribute("part","subtitle"),this._updateText(),e.append(o,this._titleEl,this._subtitleEl),t.appendChild(e);const m=Date.now();r.style.animationDelay=`-${m%c}ms`,l.style.animationDelay=`-${m%d}ms`}disconnectedCallback(){this._titleEl=null,this._subtitleEl=null}attributeChangedCallback(){this._updateText()}_updateText(){this._titleEl&&(this._titleEl.textContent=this.getAttribute("title")??""),this._subtitleEl&&(this._subtitleEl.textContent=this.getAttribute("subtitle")??"")}}function p(){customElements.get("wippy-loading")||customElements.define("wippy-loading",h),customElements.get("wippy-error")||customElements.define("wippy-error",a)}return p(),i.WippyErrorElement=a,i.WippyLoadingElement=h,i.register=p,Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}),i})({});
//# sourceMappingURL=loading.js.map
diff --git a/src/facade/security_policy_test.lua b/src/facade/security_policy_test.lua
index 1e2e334..779b47a 100644
--- a/src/facade/security_policy_test.lua
+++ b/src/facade/security_policy_test.lua
@@ -23,6 +23,7 @@ local function run()
test.it("grants facade config registry and public API env reads only", function()
test.eq(decision("wippy.facade:config_read_runtime", "registry.get", "wippy.facade:fe_facade_url"), "allow")
test.eq(decision("wippy.facade:config_read_runtime", "env.get", "PUBLIC_API_URL"), "allow")
+ test.eq(decision("wippy.facade:config_read_runtime", "template.get", "wippy.facade:templates"), "allow")
test.eq(decision("wippy.facade:config_read_runtime", "env.get", "ENCRYPTION_KEY"), "undefined")
test.eq(decision("wippy.facade:config_read_runtime", "registry.apply", "wippy.facade:fe_facade_url"), "undefined")
end)
diff --git a/src/views/_index.yaml b/src/views/_index.yaml
index 135cb25..73170b8 100644
--- a/src/views/_index.yaml
+++ b/src/views/_index.yaml
@@ -36,6 +36,10 @@ entries:
path: .meta.router
- entry: wippy.views.api:list_routes.endpoint
path: .meta.router
+ - entry: wippy.views.api:fragment_gateway
+ path: .meta.router
+ - entry: wippy.views.api:fragment_gateway.endpoint
+ path: .meta.router
- name: public_api_url
kind: env.variable
meta:
diff --git a/src/views/api/_index.yaml b/src/views/api/_index.yaml
index 0f13ec9..7813223 100644
--- a/src/views/api/_index.yaml
+++ b/src/views/api/_index.yaml
@@ -188,3 +188,41 @@ entries:
func: list_routes
method: GET
path: /pages/routes
+ # Web-Fragments gateway (EE2-2313). Serves the reframing contract for a
+ # view.page rendered as a fragment: reframed stub (Sec-Fetch-Dest: iframe),
+ # the app HTML with the fragment proxy + injected (empty/document),
+ # and asset proxying (everything else). Delivery-mode wrapper — render.lua
+ # is untouched. Auth-agnostic: the injected proxy handshakes the host for config.
+ - name: fragment_gateway
+ kind: function.lua
+ meta:
+ comment: Web-Fragments gateway — reframing contract + asset proxy for a fragment-mode view.page
+ description: Fragment gateway endpoint
+ router: api_router
+ source: file://fragment_gateway.lua
+ imports:
+ page_registry: wippy.views:page_registry
+ method: handler
+ modules:
+ - http
+ - http_client
+ - registry
+ - json
+ security:
+ actor:
+ id: wippy.views.api:fragment_gateway
+ policies:
+ - wippy.views:public_registry_runtime
+ - wippy.views:public_metadata_http_runtime
+ - wippy.views:public_metadata_private_ip_runtime
+ pool:
+ size: 4
+ - name: fragment_gateway.endpoint
+ kind: http.endpoint
+ meta:
+ comment: Web-Fragments gateway endpoint (reframing + asset proxy)
+ description: Fragment reframing contract endpoint
+ router: api_router
+ func: fragment_gateway
+ method: GET
+ path: /frag/{id}/{path...}
diff --git a/src/views/api/fragment_gateway.lua b/src/views/api/fragment_gateway.lua
new file mode 100644
index 0000000..09b0d3e
--- /dev/null
+++ b/src/views/api/fragment_gateway.lua
@@ -0,0 +1,282 @@
+-- fragment_gateway.lua (EE2-2313, Phase 1)
+--
+-- Minimal Web-Fragments gateway. Serves the reframing contract for a Wippy
+-- view.page rendered as a web-fragment, on the routes /frag/{id}/{path...}.
+--
+-- The `web-fragments` client (reframed) hits the SAME url three ways,
+-- distinguished by the `Sec-Fetch-Dest` request header:
+-- * "iframe" -> the hidden realm iframe's `src` load. Return the tiny
+-- reframed stub document (scripts execute in this realm).
+-- * "empty"/"document"-> reframed's `fetch()` of the fragment. Return the
+-- view.page's app HTML, transformed for the fragment realm.
+-- * anything else -> a relative asset request. Proxy it to the page's base_url.
+--
+-- HTML transform for the document (mirrors the host's client-side processWebPage):
+-- * inject so the app's relative assets resolve;
+-- * MERGE the Web Host import map ({fe_facade_url}/import-map.json) into the
+-- app's import map (host wins) so bare specifiers like `@wippy-fe/proxy`,
+-- `vue`, ... resolve, and move it FIRST in (import maps must precede
+-- module scripts);
+-- * inject the fragment proxy bundle ')
+ if s then
+ local block = html:sub(s, e)
+ local body = block:match('>%s*(.-)%s*')
+ if body then
+ local decoded = json.decode(body)
+ if type(decoded) == "table" and type(decoded.imports) == "table" then
+ app_imports = decoded.imports :: {[string]: any}
+ end
+ end
+ html = html:sub(1, s - 1) .. html:sub(e + 1)
+ end
+ for k, v in pairs(host_imports) do
+ app_imports[k] = v
+ end
+ local merged = json.encode({ imports = app_imports }) or '{"imports":{}}'
+ return html, merged
+end
+
+-- The reframed client contract: the fragment body is streamed into the
+-- shadow root, and reframed re-executes the (neutralized) scripts
+-- inside the realm iframe. So we must, exactly like the reference gateway:
+-- * strip the doctype;
+-- * rename // -> // (they are not
+-- the realm's real document elements);
+-- * neutralize scripts (type -> "inert", original kept in data-script-type) and
+-- preload/prefetch/modulepreload links (rel -> "inert-...") so the browser
+-- does not run them in the shadow — reframed re-activates them in the realm.
+local function strip_doctype(html: string): string
+ return (html:gsub("]*>", "", 1))
+end
+
+local function prefix_wf(html: string): string
+ html = html:gsub("<(/?)[hH][tT][mM][lL]([%s>])", "<%1wf-html%2")
+ html = html:gsub("<(/?)[hH][eE][aA][dD]([%s>])", "<%1wf-head%2")
+ html = html:gsub("<(/?)[bB][oO][dD][yY]([%s>])", "<%1wf-body%2")
+ return html
+end
+
+local function neutralize(html: string): string
+ html = html:gsub("<[sS][cC][rR][iI][pP][tT]([^>]*)>", function(attrs: string): string
+ local t = attrs:match('[tT][yY][pP][eE]%s*=%s*"([^"]*)"')
+ if t then
+ attrs = attrs:gsub('[tT][yY][pP][eE]%s*=%s*"[^"]*"', 'data-script-type="' .. t .. '" type="inert"', 1)
+ end
+ return "'
+ .. ''
+ .. ''
+end
+
+-- Fragment document (Sec-Fetch-Dest: empty) — streamed into the
+-- shadow by reframeWithFetch; scripts run via writable-dom in the realm. Apply
+-- ONLY the reference's prefixHtmlHeadBody, PLUS: drop the app's own
+-- ', '', 1)
+ -- Remove the @wippy/scripts dev-proxy placeholder. It shows "Accept config to
+ -- continue loading" and waits for the host to swap in the real proxy + config
+ -- (what addAppConfigToDocument does for srcdoc). Our real proxy is in the realm
+ -- stub, so drop the placeholder or it hijacks the render.
+ html = html:gsub('', '', 1)
+ -- Rewrite relative asset URLs (href/src="./x") to ABSOLUTE base_url. A
+ -- in the REFLECTED shadow is NOT honored, so relative /
+ -- URLs would resolve against the host page origin (404 → unstyled app).
+ -- Module scripts still work (reframed runs them in the realm at the gateway
+ -- path), but static links/images need an absolute href here.
+ html = html:gsub('href="%./', 'href="' .. base_url)
+ html = html:gsub('src="%./', 'src="' .. base_url)
+ -- Inject host CSS at the START of (base first, app CSS layers on top).
+ if fbase and fbase ~= "" then
+ local links = host_css_links(fbase)
+ html = html:gsub('(<[hH][eE][aA][dD][^>]*>)', function(h: string): string
+ return h .. links
+ end, 1)
+ end
+ return prefix_wf(html)
+end
+
+local function handler()
+ local req = http.request()
+ local res = http.response()
+ if not req or not res then
+ return nil, "Failed to get HTTP context"
+ end
+
+ local sfd = req:header("Sec-Fetch-Dest") or ""
+
+ -- 1) Realm iframe src load -> reframed stub (carries import map + proxy).
+ if sfd == "iframe" then
+ res:set_header("Vary", "sec-fetch-dest")
+ res:set_content_type("text/html")
+ res:set_status(http.STATUS.OK)
+ res:write(build_stub(facade_base()))
+ return
+ end
+
+ local page_id = req:param("id")
+ if not page_id or page_id == "" then
+ res:set_status(http.STATUS.BAD_REQUEST)
+ res:write("Missing fragment id")
+ return
+ end
+
+ local page, page_err = page_registry.get(page_id)
+ if page_err or not page then
+ res:set_status(http.STATUS.NOT_FOUND)
+ res:write("Fragment page not found")
+ return
+ end
+ if not page_registry.can_access(page) then
+ res:set_status(http.STATUS.UNAUTHORIZED)
+ res:write("Access denied")
+ return
+ end
+
+ local base_url = page_registry.resolve_base_url(page)
+ if not base_url or base_url == "" then
+ res:set_status(http.STATUS.INTERNAL_ERROR)
+ res:write("Fragment base URL unresolved")
+ return
+ end
+
+ -- Derive the subpath from the full request path — the router does not surface
+ -- the {path...} wildcard via param(). Empty subpath = the document; a non-empty
+ -- subpath = an asset request.
+ local full_path = req:path() or ""
+ local marker = "/frag/" .. page_id .. "/"
+ local subpath = ""
+ local mi = full_path:find(marker, 1, true)
+ if mi then
+ subpath = full_path:sub(mi + #marker)
+ end
+
+ -- 2) Bare fragment path -> the app HTML document, transformed for the realm.
+ -- Discriminate on subpath, NOT Sec-Fetch-Dest: realm module fetches can arrive
+ -- with an empty/absent Sec-Fetch-Dest, and must still be proxied as assets.
+ if subpath == "" then
+ local entry = page.entry_point or "index.html"
+ local app_url = base_url .. entry
+ local resp, err = http_client.get(app_url, { timeout = 20 })
+ if err or not resp or (resp.status_code or 500) >= 400 then
+ res:set_status(http.STATUS.BAD_GATEWAY)
+ res:write("Fragment document fetch failed: " .. tostring(err or (resp and resp.status_code)))
+ return
+ end
+ local html = transform_document(resp.body or "", base_url, facade_base())
+ res:set_header("Vary", "sec-fetch-dest")
+ res:set_content_type("text/html")
+ res:set_status(http.STATUS.OK)
+ res:write(html)
+ return
+ end
+
+ -- 3) Asset -> proxy to the page's real asset base_url + subpath.
+ local asset_url = base_url .. subpath
+ local resp, err = http_client.get(asset_url, { timeout = 20 })
+ if err or not resp then
+ res:set_status(http.STATUS.BAD_GATEWAY)
+ res:write("Fragment asset fetch failed: " .. tostring(err))
+ return
+ end
+ local headers = resp.headers or {}
+ local ct = headers["Content-Type"] or headers["content-type"]
+ if ct then
+ res:set_content_type(ct)
+ end
+ res:set_status(resp.status_code or http.STATUS.OK)
+ res:write(resp.body or "")
+end
+
+return { handler = handler }