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) + html = html:gsub("<[lL][iI][nN][kK]([^>]*)>", function(attrs: string): string + local rel = attrs:match('[rR][eE][lL]%s*=%s*"([^"]*)"') + if rel == "preload" or rel == "prefetch" or rel == "modulepreload" then + attrs = attrs:gsub('[rR][eE][lL]%s*=%s*"[^"]*"', 'rel="inert-' .. rel .. '"', 1) + end + return "" + end) + return html +end + +-- Realm stub (Sec-Fetch-Dest: iframe) — the reframed realm iframe's document. +-- The app's scripts are executed IN THIS REALM by reframed, so the import map +-- (bare-specifier resolution) and the fragment proxy bundle (sets up $W / +-- __WIPPY_APP_API__ before the app runs) live HERE, not in the streamed content. +-- The host import map ({fe_facade_url}/import-map.json) already carries the +-- shared vendors + @wippy-fe/* the apps externalize. +local function build_stub(fbase: string): string + local imports = fetch_host_imports(fbase) + local map = json.encode({ imports = imports }) or '{"imports":{}}' + -- The canonical injector's `scripts` array is [loading.js, proxy.js, chat.js]. + -- For a fragment the applicable subset is loading.js + the reframed-realm proxy: + -- * loading.js (classic) registers / (used by the + -- app's initial shell + error states); + -- * proxy-fragment.js (module) is our realm adapter, replacing srcdoc proxy.js. + -- chat.js (registers ) is DELIBERATELY omitted: app-main uses the + -- HOST's chat (right panel) via a host command, not an embedded , and + -- eagerly loading the heavy chat bundle into every realm destabilises boot. A + -- fragment that embeds chat directly would opt in per-page (future meta flag). + return REFRAMED_STUB + .. '' + .. '' + .. '' +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(']-data%-role="@wippy/scripts"[^>]->%s*', '', 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 }