BlackBeltTechnology

canvas-webapp

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.

BlackBeltTechnology 331 52 Actualitzat fa 1 setm.

Recursos

1
GitHub

Instal·la

npx skillscat add blackbelttechnology/pi-agent-dashboard/canvas-webapp

Instal·la des del registre de SkillsCat.

SKILL.md

canvas-webapp — show a web app on the dashboard canvas

The dashboard opens a loopback canvas(kind:"url") target inside a
sandbox="allow-scripts allow-forms allow-popups allow-downloads" 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

  1. 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.
  2. 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>.
  3. Verify the built index.html references ./assets/... (relative). Copy
    the entry html to index.html so the proxy root (/live/<id>/) serves it.
  4. 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.
  5. 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.
  6. Self-verify by iframing your own harness before touching the canvas:
    serve an HTML with <iframe sandbox="allow-scripts allow-forms allow-popups allow-downloads" 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

  1. The static server responds 200 with header Access-Control-Allow-Origin: *
    (curl -D - -o /dev/null).
  2. An <iframe sandbox="allow-scripts allow-forms allow-popups allow-downloads"> pointed at the server renders the app
    (browser-tool screenshot), matching the dashboard's opaque-origin sandbox.
  3. After canvas(kind:'url'), the user confirms the app is visible on the canvas
    (not blank, no 500).