canvas-webapp — show a web app on the dashboard canvas
The dashboard opens a loopback canvas(kind:"url") target inside a
sandbox="allow-scripts" iframe with no allow-same-origin (opaque origin),
proxied under /live/<id>/ (LiveServerViewer.tsx → live-server-proxy). That
sandbox breaks two common serving strategies; this skill is the fix.
When to Use
Use when you must display a running web app / React / Vite / MUI mockup on the
pi-dashboard canvas via canvas(target:{kind:"url"|"server"}) and it shows up
blank white, an empty surface, or a /live/<id> 500 ECONNREFUSED.
Procedure
- Do NOT point the canvas at a Vite DEV server. The dashboard proxies
loopback targets under
/live/<id>/, but Vite dev emits ABSOLUTE asset paths
(/main.tsx, /@vite/client, and runtime fetches like /__schema.json) that
resolve against the dashboard root, not the proxy prefix → 404 → blank page.
- Produce a STATIC production build with a RELATIVE base: a vite config with
base:'./', a dedicated index.html entry, and the runtime data
(schema/props) IMPORTED statically (no fetch of an absolute path). Run
npx vite build --config <config>.
- Verify the built
index.html references ./assets/... (relative). Copy
the entry html to index.html so the proxy root (/live/<id>/) serves it.
- Serve the dist with a tiny node static server that sets
Access-Control-Allow-Origin: * (and Cross-Origin-Resource-Policy: cross-origin) on every response. This is REQUIRED: the opaque-origin iframe
fetches <script type=module> in CORS mode with Origin: null, so without
ACAO:* the module is blocked → blank white even though the build is correct. A
plain python3 -m http.server does NOT set CORS and renders blank.
- Point the canvas:
canvas(target:{kind:'url', url:'http://127.0.0.1:<port>/'}, mode:'replace'). Loopback is required (SSRF
gate); 127.0.0.1 is safest.
- Self-verify by iframing your own harness before touching the canvas:
serve an HTML with
<iframe sandbox="allow-scripts allow-forms allow-popups" src="http://127.0.0.1:<port>/"> on another port, open it in the browser tool,
and screenshot — this reproduces the exact dashboard sandbox.
Minimal CORS static server
// canvas-serve.mjs — node canvas-serve.mjs <port> (serves ./canvas-dist/)
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import { extname, join, normalize } from "node:path";
const ROOT = new URL("./canvas-dist/", import.meta.url).pathname;
const PORT = Number(process.argv[2] ?? 5181);
const MIME = { ".html":"text/html;charset=utf-8", ".js":"text/javascript;charset=utf-8",
".css":"text/css;charset=utf-8", ".json":"application/json", ".woff":"font/woff",
".woff2":"font/woff2", ".svg":"image/svg+xml", ".png":"image/png", ".ico":"image/x-icon" };
createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
let p = decodeURIComponent((req.url ?? "/").split("?")[0]);
if (p.endsWith("/")) p += "index.html";
const full = normalize(join(ROOT, p));
if (!full.startsWith(ROOT)) { res.statusCode = 403; return res.end("forbidden"); }
const s = await stat(full).catch(() => null);
if (!s?.isFile()) { res.statusCode = 404; return res.end("not found"); }
res.setHeader("Content-Type", MIME[extname(full)] ?? "application/octet-stream");
res.end(await readFile(full));
}).listen(PORT, "127.0.0.1", () => console.log(`CORS static on http://127.0.0.1:${PORT}/`));
Pitfalls
- bash
kill %1 job control does NOT carry across separate Bash tool calls — a
dev server started in one call cannot be killed by %1 in another. Kill stale
servers by PID: lsof -tiTCP:<port> -sTCP:LISTEN | xargs kill -9.
- Vite dev may bind IPv6
[::1]:<port> only, while the dashboard proxy dials
IPv4 127.0.0.1:<port> → the /live/<id> route returns 500
FST_REPLY_FROM_INTERNAL_SERVER_ERROR 'connect ECONNREFUSED 127.0.0.1:<port>'.
Another reason to avoid dev servers and bind static servers explicitly to
127.0.0.1.
canvas(target:{kind:'server', port}) produces a tap-to-open CHIP on desktop,
not an auto-opened view; kind:'url' with a loopback URL auto-opens via
openLiveTarget. Prefer kind:'url'.
- The build succeeds and renders fine in a NORMAL browser tab yet is blank in the
dashboard — that difference is the sandbox/CORS issue, not a build bug. Don't
chase the build.
- Non-loopback/remote URLs are refused by the dashboard SSRF gate
(
validateLiveTarget).
Verification
- The static server responds 200 with header
Access-Control-Allow-Origin: *
(curl -D - -o /dev/null).
- An
<iframe sandbox="allow-scripts"> pointed at the server renders the app
(browser-tool screenshot), matching the dashboard's opaque-origin sandbox.
- After
canvas(kind:'url'), the user confirms the app is visible on the canvas
(not blank, no 500).
1---2name: canvas-webapp3description: Render a React/Vite (or any bundled) web app on the pi-dashboard canvas, which loads loopback URLs in a sandboxed opaque-origin iframe. Use when a canvas(target:{kind:"url"|"server"}) target shows up blank white, an empty surface, or a /live/<id> 500 ECONNREFUSED. Covers why Vite dev servers and non-CORS static servers fail there, and the static-build + CORS-server recipe that works.4license: MIT5---67# canvas-webapp — show a web app on the dashboard canvas89The dashboard opens a loopback `canvas(kind:"url")` target inside a10`sandbox="allow-scripts"` iframe with **no `allow-same-origin`** (opaque origin),11proxied under `/live/<id>/` (`LiveServerViewer.tsx` → `live-server-proxy`). That12sandbox breaks two common serving strategies; this skill is the fix.1314## When to Use1516Use when you must display a running web app / React / Vite / MUI mockup on the17pi-dashboard canvas via `canvas(target:{kind:"url"|"server"})` and it shows up18blank white, an empty surface, or a `/live/<id>` 500 ECONNREFUSED.1920## Procedure21221. **Do NOT point the canvas at a Vite DEV server.** The dashboard proxies23 loopback targets under `/live/<id>/`, but Vite dev emits ABSOLUTE asset paths24 (`/main.tsx`, `/@vite/client`, and runtime fetches like `/__schema.json`) that25 resolve against the dashboard root, not the proxy prefix → 404 → blank page.262. **Produce a STATIC production build with a RELATIVE base:** a vite config with27 `base:'./'`, a dedicated `index.html` entry, and the runtime data28 (schema/props) IMPORTED statically (no fetch of an absolute path). Run29 `npx vite build --config <config>`.303. **Verify** the built `index.html` references `./assets/...` (relative). Copy31 the entry html to `index.html` so the proxy root (`/live/<id>/`) serves it.324. **Serve the dist with a tiny node static server that sets33 `Access-Control-Allow-Origin: *`** (and `Cross-Origin-Resource-Policy:34 cross-origin`) on every response. This is REQUIRED: the opaque-origin iframe35 fetches `<script type=module>` in CORS mode with `Origin: null`, so without36 ACAO:* the module is blocked → blank white even though the build is correct. A37 plain `python3 -m http.server` does NOT set CORS and renders blank.385. **Point the canvas:** `canvas(target:{kind:'url',39 url:'http://127.0.0.1:<port>/'}, mode:'replace')`. Loopback is required (SSRF40 gate); `127.0.0.1` is safest.416. **Self-verify** by iframing your own harness before touching the canvas:42 serve an HTML with `<iframe sandbox="allow-scripts allow-forms allow-popups"43 src="http://127.0.0.1:<port>/">` on another port, open it in the browser tool,44 and screenshot — this reproduces the exact dashboard sandbox.4546## Minimal CORS static server4748```js49// canvas-serve.mjs — node canvas-serve.mjs <port> (serves ./canvas-dist/)50import { createServer } from "node:http";51import { readFile, stat } from "node:fs/promises";52import { extname, join, normalize } from "node:path";53const ROOT = new URL("./canvas-dist/", import.meta.url).pathname;54const PORT = Number(process.argv[2] ?? 5181);55const MIME = { ".html":"text/html;charset=utf-8", ".js":"text/javascript;charset=utf-8",56 ".css":"text/css;charset=utf-8", ".json":"application/json", ".woff":"font/woff",57 ".woff2":"font/woff2", ".svg":"image/svg+xml", ".png":"image/png", ".ico":"image/x-icon" };58createServer(async (req, res) => {59 res.setHeader("Access-Control-Allow-Origin", "*");60 res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");61 let p = decodeURIComponent((req.url ?? "/").split("?")[0]);62 if (p.endsWith("/")) p += "index.html";63 const full = normalize(join(ROOT, p));64 if (!full.startsWith(ROOT)) { res.statusCode = 403; return res.end("forbidden"); }65 const s = await stat(full).catch(() => null);66 if (!s?.isFile()) { res.statusCode = 404; return res.end("not found"); }67 res.setHeader("Content-Type", MIME[extname(full)] ?? "application/octet-stream");68 res.end(await readFile(full));69}).listen(PORT, "127.0.0.1", () => console.log(`CORS static on http://127.0.0.1:${PORT}/`));70```7172## Pitfalls7374- bash `kill %1` job control does NOT carry across separate Bash tool calls — a75 dev server started in one call cannot be killed by `%1` in another. Kill stale76 servers by PID: `lsof -tiTCP:<port> -sTCP:LISTEN | xargs kill -9`.77- Vite dev may bind IPv6 `[::1]:<port>` only, while the dashboard proxy dials78 IPv4 `127.0.0.1:<port>` → the `/live/<id>` route returns 50079 `FST_REPLY_FROM_INTERNAL_SERVER_ERROR 'connect ECONNREFUSED 127.0.0.1:<port>'`.80 Another reason to avoid dev servers and bind static servers explicitly to81 `127.0.0.1`.82- `canvas(target:{kind:'server', port})` produces a tap-to-open CHIP on desktop,83 not an auto-opened view; `kind:'url'` with a loopback URL auto-opens via84 `openLiveTarget`. Prefer `kind:'url'`.85- The build succeeds and renders fine in a NORMAL browser tab yet is blank in the86 dashboard — that difference is the sandbox/CORS issue, not a build bug. Don't87 chase the build.88- Non-loopback/remote URLs are refused by the dashboard SSRF gate89 (`validateLiveTarget`).9091## Verification92931. The static server responds 200 with header `Access-Control-Allow-Origin: *`94 (`curl -D - -o /dev/null`).952. An `<iframe sandbox="allow-scripts">` pointed at the server renders the app96 (browser-tool screenshot), matching the dashboard's opaque-origin sandbox.973. After `canvas(kind:'url')`, the user confirms the app is visible on the canvas98 (not blank, no 500).