Host-less Mode
Authoritative guide for the standalone-aware design contract that lets every Wippy micro frontend app and web component build, run, and test without the Wippy Web Host wrapping it.
Default injection state: The dev overlay starts with
themeConfig,primevue,markdown, andiframedisabled, butcustomCssandcustomVariablesenabled. So an app that relies only on custom overrides may appear to work, while one that expects the platform theme variables or PrimeVue styles will render unstyled until you enable those injections. Open the overlay FAB → enable the injections you need → check "Auto-accept on reload" to persist across reloads.
Table of contents
- Mental model — apps and WCs are intentionally standalone-aware
- The
@wippy/scriptsswitchpoint — one tag, two boot paths - What
dev-proxy.jsactually does - The dev overlay (config modal)
- Host stubs — the standalone
hostAPI - Web components — host-less playground and tests
- Common deviations and how to spot them
- Troubleshooting
- Related docs
Mental model — apps and WCs are intentionally standalone-aware
Every Wippy micro frontend app and web component is built around a small, deliberate constraint:
The runtime contract is the proxy API surface. Nothing else.
What that means in practice:
- The only thing an app or WC touches at runtime is the proxy API surface: the sync getters imported from
@wippy-fe/proxy(host,api,on,config,state,ws,logger). Both apps and WCs use the same imports; under the hood they resolve to the sameProxyApiInstancethat the runtime installs as internal globals (window.$W,window.__WIPPY_APP_API__— never read these directly). - Apps and WCs do not import code from neighboring apps, the parent module's Lua side, the Wippy Web Host, or any other module in the project. They live in their own folder, declare their externals (
vue,pinia,vue-router,@iconify/vue,axios,@wippy-fe/proxy, etc.) in their ownpackage.json, and read their ownwippy.yaml/package.jsonmetadata. - The same
app.ts(or WCindex.ts) boots correctly in two environments:- Hosted — inside a Wippy Web Host that injects
proxy.js, AppConfig, importmap, and CSS. - Host-less — running its
app.htmldirectly via Vite dev server, file://, a unit-test page, a Storybook-style playground, etc.
- Hosted — inside a Wippy Web Host that injects
You can think of every app/WC as a "small program with a tiny standardized I/O surface." The host is one possible runtime; standalone is another. The app code does not know which one it's in.
This isn't an accident or an afterthought. It is what makes:
- Local FE iteration possible without spinning up a full Wippy backend.
- WCs unit-testable in isolation under vitest + jsdom.
- Apps shareable between Wippy modules — every micro-frontend-app and web component builds with the same toolchain regardless of which module ships it.
- Customer-specific overlays viable — operators patch metadata (theming, importmap, env) without rebuilding the FE bundle.
The @wippy/scripts switchpoint — one tag, two boot paths
Every canonical app's app.html ships with one script tag that decides the boot path at load time:
<!-- URL MUST include a release-tag segment: https://web-host.wippy.ai/<release-tag>/dev-proxy.js -->
<script
src="https://web-host.wippy.ai/<release-tag>/dev-proxy.js"
data-role="@wippy/scripts"
></script>
Full app.html scaffold in Micro Frontend App.
Two attributes on that one tag carry the entire dual-mode contract:
| Attribute | Role | Used by |
|---|---|---|
data-role="@wippy/scripts" |
Marker for the host. When present, the host removes this <script> element before serving the iframe and injects its own loading.js + proxy.js + importmap + AppConfig before the marker. The element disappears in hosted mode. |
Wippy Web Host |
src="…/dev-proxy.js" |
Fallback URL. Used when no host is present — the browser loads dev-proxy.js directly and that script bootstraps the page. The src= attribute is irrelevant in hosted mode (the <script> element no longer exists). |
Standalone browser load |
Pick a URL that matches your environment. Note that the Web Host URL always requires a release-tag segment in the path — /dev-proxy.js directly off the host root is NOT valid; you must address a specific build (/<release-tag>/dev-proxy.js). This guarantees every dev-mode boot is pinned to a known, reproducible bundle and avoids the "host CDN updated overnight, my preview broke" class of surprise.
| Environment | Sample src= value |
|---|---|
| Public CDN (standard) | https://web-host.wippy.ai/<release-tag>/dev-proxy.js |
| Self-hosted Wippy deployment | https://<your-wippy-host>/<release-tag>/dev-proxy.js |
The tag must match the release version used by the facade's fe_facade_url. Pin it explicitly — /dev-proxy.js without a tag segment is not valid. The same bundle works for local iteration, CI, and shareable preview links.
So the same line of HTML is the host's "inject your scripts here" anchor and the host-less fallback boot — without any conditional logic.
What goes in the importmap?
Exactly the packages your bundle declares as externals. Open your vite.config.ts and look at build.rollupOptions.external — every name in that array MUST have a matching entry in the importmap. Concretely:
// vite.config.ts
external: ['vue', 'pinia', 'vue-router', '@iconify/vue', 'axios', 'luxon']
<!-- app.html — must mirror the externals exactly -->
<script type="importmap">
{
"imports": {
"vue": "https://esm.sh/vue@3",
"pinia": "https://esm.sh/pinia",
"vue-router": "https://esm.sh/vue-router@4",
"@iconify/vue": "https://esm.sh/@iconify/vue",
"axios": "https://esm.sh/axios",
"luxon": "https://esm.sh/luxon"
}
}
</script>
Conventions:
- Use
https://esm.sh/<pkg>URLs. They're the de-facto Wippy default (see canonical app-template apps); no build step or local server needed. - Pin majors only (
vue@3,vue-router@4) unless you have a reason to lock a minor. esm.sh resolves to the latest patch automatically and the host's importmap (which overrides yours when wrapping) decides the canonical version anyway. - Don't include
@wippy-fe/proxy. dev-proxy.js / the host injects it for you. The same goes for@wippy-fe/markdown-iframe(only include it explicitly if your app code imports the markdown iframe directly — the canonical app-template main app does). - Don't include packages that are not external. Anything bundled into your output (your shared utils, internal components) doesn't need an entry.
The host's processWebPage merges the host's importmap with whatever you declare in app.html — keys you declare are kept, host adds the wippy-side entries. So the same app.html works in both modes without conditionals.
Exposing package.json to dev-proxy (canonical scaffold)
Every Wippy app's package.json carries metadata that determines runtime defaults — proxy injections (wippy.proxy.injections.css.*), per-page theming overrides (wippy.configOverrides.customization), iconify icon collections, etc. In hosted mode the host reads these from the registry. In host-less mode dev-proxy needs the same data to apply the same defaults.
The canonical pattern is wippyPagePlugin() from @wippy-fe/vite-plugin ≥ 0.0.32, added once to your vite.config.ts. The plugin reads your package.json at build time and does two things:
- Resolves
file://references in thewippyblock (any string value of the form"file://<relative>"is replaced with the referenced file's UTF-8 contents — see*.do-not-link.<ext>naming convention in build-system.md). - Emits two outputs with the resolved JSON:
<head>-injected<script type="application/json" data-role="@wippy/package">for host-less / dev-proxy boot.dist/wippy-meta.jsonfor wippy-hosted mode —wippy/views≥0.5.0reads this file when serving/pages/content/{id}and/components/by-tag/{tag}instead of synthesizing from YAML.
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { wippyPagePlugin } from '@wippy-fe/vite-plugin'
export default defineConfig({
plugins: [
vue(),
wippyPagePlugin(),
],
// …
})
For web components (view.component, ESM-only — no HTML entry to inject into) use wippyComponentPlugin() from the same package. It only emits dist/wippy-meta.json; no transformIndexHtml step.
// vite.config.ts for a web component
import { wippyComponentPlugin } from '@wippy-fe/vite-plugin'
export default defineConfig({ plugins: [wippyComponentPlugin()] })
Renamed in
0.0.31.wippyPackagePlugin(the old single export) is the predecessor of today'swippyPagePlugin. If you're on the old import name, switch — both the rename and the meta-emit landed in0.0.31and the old name is gone. The component-only path (wippyComponentPlugin) is new in the same release.
The plugin emits this into the top of <head> in the built app.html:
<script type="application/json" data-role="@wippy/package">
{ "name": "@wippy/your-app", "version": "1.0.0", "wippy": { "proxy": {...}, "configOverrides": {...} } }
</script>
dev-proxy.js reads this synchronously at boot via
document.querySelector('script[data-role="@wippy/package"]') and uses wippy.proxy.injections to seed the proxy-config defaults and wippy.configOverrides.customization to seed appConfig.theming.global. The data-role string @wippy/package is exported as WIPPY_PACKAGE_DATA_ROLE from @wippy-fe/shared so both sides of the boundary share the constant.
Why this shape:
- No duplication.
package.jsonis the single source of truth — the plugin reads it at build time, nothing in yoursrc/references it. - No fetch. Inline in the served HTML — readable synchronously by
dev-proxy.jsbefore any app code runs. - Right ordering. Injected at the top of
<head>before any script tag, so it's in the DOM by the time the dev-proxy executes (dev-proxy is a sync UMD script; module scripts are deferred and run later). - No
app.htmlediting. The template stays clean; the plugin owns the injection. - Constant from shared package. The string
'@wippy/package'lives in exactly one place (@wippy-fe/shared→WIPPY_PACKAGE_DATA_ROLE); apps don't reference it directly, dev-proxy and the plugin both import it from there. - Cleanly ignored under a real host. The host's
processWebPagereadspackage.jsonfrom the registry server-side; the inline JSON tag is harmless metadata.
dev-proxy reads the JSON during resolveDevConfig() and uses it to populate the dev-overlay defaults. If the script tag is absent (older app, plugin not yet added), dev-proxy falls back to getDefaultProxyConfig(). So adding the plugin is purely additive — apps without it keep working with the generic defaults.
Why a plugin and not a runtime
windowglobal? Dev-proxy.js is a non-module synchronous script that runs early during<head>parsing — before any module script (including yourapp.ts) has loaded. Soapp.tscannot set a global before dev-proxy reads it. A build-time HTML transform places the data in the DOM up-front, available the instant dev-proxy executes.
Why one tag and not two? A second
<script>block (e.g. anif (!window.__WIPPY__) load dev-proxy) would only run after the host's injection completes; if the marker is gone, the conditional has nothing to attach to. The single-tag pattern means the marker is always in the source HTML, and the host's job is exactly "delete this marker and replace it." The standalone case happens precisely when nobody deleted it.
The host contract requires that the HTML file specified in wippy.path MUST include a <script type="text/javascript" data-role="@wippy/scripts"> element where additional scripts will be automatically injected.
The canonical app-template apps ship with the src="…/dev-proxy.js" populated. That is the recommended shape: always include the src= fallback unless your app cannot run host-less (rare, and worth justifying).
What dev-proxy.js actually does
dev-proxy.js is the host-less boot bundle, served from the Wippy Web Host CDN at https://web-host.wippy.ai/<release-tag>/dev-proxy.js.
Its job is to make the @wippy-fe/proxy getters resolve correctly without any host — by installing the same internal globals (window.$W, window.__WIPPY_APP_API__) the real host would. App and WC code never touches those globals; it just imports from @wippy-fe/proxy and the getters work. dev-proxy does this in roughly five steps:
- Install history guard (
installHistoryGuard()) — stubspushState/replaceStateso vue-router doesn't try to mutate browser history outside an iframe-srcdoc context. - Resolve a config (
resolveDevConfig()insrc/proxy/dev/resolve-dev.ts):- Read
localStorage['@wippy-dev/config']andlocalStorage['@wippy-dev/proxy-config']. - If
localStorage['@wippy-dev/auto-accept'] === 'true'AND a stored config exists → use it immediately, render the overlay in monitoring mode. - Otherwise → render the overlay in waiting mode (FAB pulses blue, "Accept config to continue loading" speech bubble) and block boot until the developer clicks Accept.
- Read
- Build a fake
ProxyApiInstancewired to:- The accepted
ChildAppConfig(whatconfigfrom@wippy-fe/proxyreturns). - A nanoevents emitter for
on(...)subscriptions and@history/@visibilitysimulations. hoststubs that console-log every method (createDevHostAPI()insrc/proxy/dev/host-stubs.ts).- A real axios instance backing
apifrom@wippy-fe/proxy, configured against the URL the developer entered (env.APP_API_URLdefaults to${location.origin}/api). - A logger / state / ws stub that mirrors the production proxy shape.
- The accepted
- Apply CSS injection based on the proxy config the developer chose:
themeConfig: true→ injectstheme-config.cssfrom@wippy-fe/theme.iframe,primevue,markdown→ ditto, the inline-CSS bundles fromsrc/proxy/dev/css-inline.ts.customCss/customVariables→ appliesappConfig.theming.global.customCSS/cssVariables(including the@dark/@lightblocks described in micro-frontend-app-theming.md).
- Install the internal proxy globals with the same shape as
entry.iframe.ts, so the@wippy-fe/proxygetters (config,host,api,on,logger,state,ws,loadWebComponent) resolve. Any app or WC code that imports from@wippy-fe/proxyworks unchanged. (The globals themselves —window.$Wet al. — are internal; see Proxy & Isolation § Internals.)
Default ChildAppConfig (from getDefaultConfig() in config-store.ts):
{
$schema: '<built schema URL>',
auth: { token: 'dev-token', expiresAt: '' },
env: {
APP_API_URL: `${location.origin}/api`,
APP_AUTH_API_URL: `${location.origin}/api`,
APP_WEBSOCKET_URL: `${location.origin.replace(/^http/, 'ws')}/ws`,
},
theming: { global: {} },
context: { resourceId: '', resourceType: 'page' },
}
You override any of this in the modal (or by editing localStorage['@wippy-dev/config']).
The dev overlay (config modal)
Visually the dev overlay is a tiny shadow-DOM web component (<wippy-dev-overlay>) that renders:
- A FAB (floating action button) in the bottom-right corner — the only visible affordance until clicked.
- A speech bubble in waiting mode: "Accept config to continue loading."
- A panel that opens when the FAB is clicked. The panel has three sections:
- Monitor — live readout of current path, document title, viewport size; "Trigger Refresh" button that fires
@visibility(true)so the app can re-fetch. - Configuration (collapsible):
App Config (JSON)— fullChildAppConfigas editable JSON. Validates on Accept.Proxy Injections— checkboxes for every proxy injection flag (themeConfig,iframe,primevue,markdown,customCss,customVariables,tailwindConfig,resizeObserver,preventLinkClicks,iconifyIcons,refreshWhenVisible,historyPolyfill,errorCapture).Options— "Auto-accept on reload" checkbox (writes the auto-accept flag to localStorage).
- Footer — Reset (clears all
@wippy-dev/*localStorage keys), Accept (saves config + resolves the boot promise).
- Monitor — live readout of current path, document title, viewport size; "Trigger Refresh" button that fires
LocalStorage keys it uses (defined in src/proxy/dev/config-store.ts):
| Key | What it stores |
|---|---|
@wippy-dev/config |
The accepted ChildAppConfig JSON |
@wippy-dev/proxy-config |
The accepted partial ProxyConfig (injection flags) |
@wippy-dev/auto-accept |
'true' to skip the manual accept step on reload |
Auto-accept makes "iterate against a host-less build" feel near-native: refresh, the app boots immediately with last-known config, the FAB stays visible so you can monitor or tweak.
Host stubs — the standalone host API
The host API (import { host } from '@wippy-fe/proxy') is the surface the app uses to ask the host to do things — toast, navigate, open a session, set context, format URLs, etc. With no real host, dev-proxy substitutes a stub layer in src/proxy/dev/host-stubs.ts:
| Method | Standalone behavior |
|---|---|
host.toast(message) |
Console-log only |
host.confirm({ message }) |
Browser window.confirm() |
host.startChat(token, options) |
Console-log |
host.openSession(uuid, options) |
Console-log |
host.openArtifact(uuid, options) |
Console-log |
host.navigate(url) |
Console-log + emits @history so the child router picks it up + updates the overlay path readout |
host.onRouteChanged(path) |
Console-log + updates the overlay path readout |
host.handleError(code, error) |
console.error |
host.setContext(context, sessionUUID, source) |
Console-log |
host.formatUrl(rel) |
Returns `${appConfig.routePrefix |
host.classifyLink(href) |
Real implementation — uses mountRoutes / routePrefix from the accepted config |
host.layout.* |
No-op stubs that satisfy the type contract |
The stubs are intentionally chatty: console output is a substitute for the host's real side-effects so a developer can see what would have happened without actually wiring the host. If your app's correctness depends on the side-effect (e.g. host.openSession actually opens a session), test that path under a host; the stubs will not.
Web components — host-less playground and tests
Web components share the same dual-mode design but are loaded as ES modules instead of iframes. The proxy contract for WCs is import { api, host, on, ... } from '@wippy-fe/proxy' — and that import resolves at runtime by reading window.__WIPPY_APP_API__ (set by either the real proxy or dev-proxy).
Playground / demo HTML page
<!-- demo.html in your WC project -->
<!DOCTYPE html>
<html>
<head>
<script type="importmap">
{
"imports": {
"vue": "https://esm.sh/vue@3",
"@iconify/vue": "https://esm.sh/@iconify/vue",
"@wippy-fe/proxy": "/path/to/proxy.js"
}
}
</script>
<script src="https://web-host.wippy.ai/<release-tag>/dev-proxy.js" data-role="@wippy/scripts"></script>
</head>
<body>
<my-component prop1="value"></my-component>
<script type="module" src="./src/index.ts"></script>
</body>
</html>
Same switchpoint, same dev overlay. Your WC's index.ts calls define(import.meta.url, ...) and the element registers itself; dev-proxy provides the host stubs.
If dev-proxy.js fails to load (or you forget to include it), entry.web-component.ts throws an explicit error:
@wippy-fe/proxy: Proxy globals not found. For dev/testing without the Wippy host, add <script src="dev-proxy.js"></script> to your HTML.
That error is the canonical signal that you're missing the host-less boot script.
Vitest / jsdom tests
For unit tests the dev overlay is unnecessary — tests don't have a UI to interact with. The pattern is to fake the host context directly by attaching the wrapper object the host would attach:
import { describe, expect, it } from 'vitest'
import { WippyElement } from './base-element'
class TestEl extends WippyElement {
static get wippyConfig() {
return { propsSchema: { properties: {} }, hostCssKeys: [] }
}
protected onMount(): void {}
protected onUnmount(): void {}
}
const TAG = 'wippy-test-el'
customElements.define(TAG, TestEl)
it('reads host wrapper attached by resolver as __wippyHost', () => {
const el = document.createElement(TAG) as TestEl
const fakeHost = { layout: { broadcast: () => {} } }
;(el as any).__wippyHost = fakeHost
expect(el.host).toBe(fakeHost)
})
The __wippyHost property is the contract the managed-layout host uses. Tests that need API or proxy globals can either mount dev-proxy via a vitest setup file, or stub window.__WIPPY_APP_API__ themselves:
// vitest.setup.ts
;(window as any).__WIPPY_APP_API__ = {
api: mockApi,
host: mockHost,
on: mockOn,
// ...other ProxyApiInstance fields
}
Either approach is "host-less" in the same sense as the browser dev-proxy: the proxy contract is satisfied by code the test owns rather than a real Wippy server.
Common deviations and how to spot them
When an app or WC has drifted from the standalone-aware contract, the symptoms are predictable:
| Symptom | Probable cause | Fix |
|---|---|---|
app.html has <script data-role="@wippy/scripts"></script> with no src= |
Page can't boot host-less. Loading the file directly produces a blank page — the proxy runtime never installs, so @wippy-fe/proxy imports fail to resolve. |
Add src="https://web-host.wippy.ai/<release-tag>/dev-proxy.js" to the tag — the URL always requires a release-tag segment. |
app.html has the dev-proxy <script src=…> but no <script type="importmap"> above it |
Browser can't resolve the bundle's bare-specifier imports (vue, pinia, @iconify/vue, axios, etc.). The first module-script load fails silently with Failed to resolve module specifier "vue" and the page never bootstraps Vue. |
Declare the importmap inline in <head>, BEFORE the dev-proxy script. Mirror your bundler's external array exactly — every name listed in vite.config.ts rollupOptions.external MUST have an entry. Use https://esm.sh/<pkg>@<major> for vendor packages. See the main example. |
app.html body has a custom SVG spinner / <div>Loading…</div> instead of <wippy-loading title="…"> |
Pre-bootstrap loader doesn't match the canonical Wippy idiom. The custom markup keeps showing while the WC ecosystem (which would render a styled, theme-aware loader) is fully booted. | Replace with <wippy-loading title="Loading..."></wippy-loading>. The <wippy-loading> web component is registered by dev-proxy.js (it imports @wippy-fe/loading synchronously) before the <body> parses, so the element resolves correctly even at very early page load. |
import from a sibling app's source files |
Shared code is being copy-pasted across module boundaries. | Extract to a workspace package or duplicate intentionally; never reach across app folders. |
Hardcoded fetch('/api/…') calls |
Bypasses the axios instance the proxy provides; won't pick up env.APP_API_URL overrides. |
Use useApi() (apps) or import { api } from '@wippy-fe/proxy' (WCs). |
new EventSource(...) for live data |
Bypasses the host's auth/relay bridge; standalone mode has no equivalent. | Use on('your.topic', cb) — works in both modes (in standalone the topic just doesn't fire unless you simulate it). |
document.documentElement.setAttribute('data-theme', ...) for theme switch |
Custom theme attribute is invisible to the proxy's cssVariables.@dark/@light (those bind to prefers-color-scheme). |
Either drive theme from OS preference and use @dark/@light blocks, or document the attribute as a project-specific extension. See micro-frontend-app-theming.md. |
import '@wippy-fe/theme/theme-config.css' in app.ts |
Redundant — the host injects theme-config via themeConfig: true proxy injection. In host-less mode dev-proxy injects it too. |
Remove the import. |
| Hardcoded API base URLs in api/ modules | Won't work in host-less mode against a different env. | Read from appConfig.env.APP_API_URL via useApi(). |
Troubleshooting
"Proxy globals not found" error.
The WC bundle ran but neither real proxy nor dev-proxy initialized window.__WIPPY_APP_API__. Check that <script src=".../dev-proxy.js" data-role="@wippy/scripts"> is in the page and the URL is reachable. In production-host mode this error means the host failed to inject proxy.js — check the host logs.
Dev overlay never appears.
The overlay is a shadow-DOM custom element appended to document.body after DOMContentLoaded. If you load dev-proxy.js from inside <head> and the body is missing or has display: none, the overlay can't render. Move the script to the bottom of the body, or unhide the body.
Auto-accept "stuck" with bad config.
If the stored config is broken and auto-accept is on, the overlay still renders (in monitoring mode); click the FAB → Reset to clear all @wippy-dev/* localStorage keys, then reload.
Theme is wrong in dev mode.
By default getDefaultProxyConfig() enables customCss and customVariables but disables themeConfig, iframe, primevue, markdown. If your app expects PrimeVue's theme-config CSS, toggle those checkboxes in the panel. Auto-accept will remember.
Importmap mismatch between hosted and standalone.
The Wippy Web Host injects its own importmap at runtime. The standalone app.html declares its own importmap inline. Keep the standalone one in sync (same packages, same versions) so a host-less build behaves like a hosted one. The canonical app-template apps demonstrate this.
WC test fails with "host getter returned null".
Tests need to set el.__wippyHost = fakeWrapper before connectedCallback fires. Either set it before document.body.appendChild(el), or fake the wrapper through whatever resolver pattern your suite uses.
Related docs
- proxy-api.md — full
@wippy-fe/proxyreference (works identically in hosted and host-less mode) - micro-frontend-app.md — building micro frontend apps (the boot path is the dual-mode
app.htmlpattern this doc covers) - web-component.md — building web components (
WippyVueElement,define(), host-less playground/tests) - theming.md — per-page theme overrides via
config_overrides(also feed dev-proxy viatheming.global.cssVariables/customCSS) - compliance-checklist.md — §9 Host-less mode checklist with full REJECT rules