Skip to content

feat: sandboxed JS runtime + HTML preview panel - #122

Open
scolastico wants to merge 47 commits into
mainfrom
feat/sandboxed-runner-preview
Open

feat: sandboxed JS runtime + HTML preview panel#122
scolastico wants to merge 47 commits into
mainfrom
feat/sandboxed-runner-preview

Conversation

@scolastico

@scolastico scolastico commented Aug 3, 2026

Copy link
Copy Markdown
Member

Adds a CodePen-style Run / Preview panel executing 16 engines across 15 languages in a
locked-down sandbox — console + language-aware REPL for code, live preview for markup, a table
browser for SQL — without weakening !3's client-side-encryption model. Every interpreter is
self-hosted; the feature makes zero requests to any external host.

How it works

Running a note builds a complete HTML document in memory and hands it to an iframe via srcdoc
with sandbox="allow-scripts allow-modals". Because allow-same-origin is absent, the iframe gets
an opaque origin: note code cannot touch the app's DOM, localStorage, cookies, the decryption
key, or the note plaintext held by the editor. Every document also carries a restrictive CSP
(default-src 'none', no connect-src unless the user opts in) and referrerpolicy="no-referrer".

A bootstrap script injected first in <head> patches console.*, window.onerror and
unhandledrejection, and relays them to the panel over postMessage. Each run mints a random
token; the panel ignores any message that does not carry the current token and come from that
exact iframe's contentWindow.

Security invariants

  1. The sandbox iframe never gets allow-same-origin or allow-top-navigation — including the
    iframe inside the popout window.
  2. Note code never runs merely because a note was opened; execution is always an explicit user
    action. The /popout page runs nothing until its opener sends state.
  3. The parent trusts a sandbox message only when event.source === iframe.contentWindow and the
    per-run token matches. Opener ↔ popup messages additionally require
    event.origin === window.location.origin and the exact expected event.source.
  4. Everything leaving the iframe (console text, table names, column names, cell values) is
    untrusted and reaches app-origin documents only through Vue text interpolation — never
    innerHTML, never a string-built attribute or style. %c console CSS is filtered by
    sanitizeConsoleCss against a property allowlist.
  5. SQL identifiers sent by the parent (table/column names in row queries) are validated against the
    engine's own catalog inside the iframe before being quoted into a query; search terms are
    always bound parameters. Covered by a test that sends sqlite_master" -- and expects a refusal.

Supported languages

Language Engine Panel REPL Tables
JavaScript native eval console JavaScript
TypeScript TypeScript compiler console JavaScript
CoffeeScript CoffeeScript compiler console JavaScript
JSX / React Babel standalone preview JavaScript
HTML native preview JavaScript
Mermaid mermaid preview JavaScript
Vue Vue SFC + vue3-sfc-loader preview JavaScript
Svelte Svelte compiler preview JavaScript
Python Pyodide console Python
Ruby ruby.wasm console JavaScript
Lua wasmoon console JavaScript
PHP php-wasm preview JavaScript
C PicoC console JavaScript
C++ JSCPP console JavaScript
SQL SQLite (sql.js) console SQL
SQL PostgreSQL (PGlite) console SQL

Languages with two engines expose a selector in the panel toolbar; the first entry is the default.

Panel features

  • Console + REPL. The REPL speaks the runner's language: SQL notes get a SQL prompt answered by
    the live database, Python notes get a Python prompt sharing the note's interpreter globals, and
    everything else keeps JavaScript eval. A runner opts in by installing window.__not3Eval__.
  • SQL table viewer. A second tab lists the tables the note created (with row counts) and browses
    them: sortable columns, substring search, 50-row pages. Queries run inside the iframe against
    the live database and only one page crosses the postMessage boundary at a time, so a large dump
    never gets copied wholesale. Empty databases say "No tables found."
  • Popout. Moves the whole panel — toolbar, engine selector, console, REPL, table viewer — into
    its own window and leaves the editor fullscreen. Edits keep streaming to the popup, so auto-run
    still works there; closing the popup brings the panel back, and closing or navigating the editor
    tab closes the popup.
  • Auto-run with per-runner memory, so a language-detection flicker or a deliberate engine switch
    no longer resets the checkbox. Heavy interpreters (wasm VMs) still default to manual runs.
  • Network toggle, off by default: note code cannot reach any host until the user opts in.
  • Layout. Desktop keeps a drag-resizable side panel plus the nav Run/Stop button; below the sm
    breakpoint the editor and panel stack (editor on top, panel on the bottom half) and the panel
    carries its own Close button, since the nav one is hidden sm:flex.

Self-hosting

scripts/copy-sandbox-vendor.mjs (postinstall) copies each interpreter out of node_modules into
public/vendor/ — selectively, never whole packages — and scripts/fetch-pyodide-wheels.mjs
fetches pinned Pyodide wheels at build time. A sanitisation pass rewrites hardcoded CDN references
found inside vendored packages (pyodide, wasmoon), and tests assert both the per-engine size budgets
and the absence of any cdn.jsdelivr.net / unpkg.com / cdnjs.cloudflare.com / esm.sh string in
the vendored tree. public/vendor/ is gitignored and regenerated on install: the copy script reports
20/20 engine entries and 90.1 MB across 15 directories, ~103 MB on disk once the Pyodide wheels are
added — against a 200 MB total budget asserted by the tests.

Notable fixes

  • CORS headers for /vendor, without which the opaque-origin iframe cannot load any interpreter.
  • Vendor URLs resolved under the Nuxt app base path, so subpath deployments (the PR previews live at
    /pr-preview/pr-<n>/) work.
  • Several language-detection collisions (lua/less stealing plain JS, xml stealing html/vue/svelte,
    jsx/mermaid/coffeescript false positives).
  • Docker build ordering so postinstall can vendor the assets; CSP tightening (worker-src is only
    declared when a runner actually needs it).
  • C++: JSCPP has no namespace support at all, so the canonical std::-qualified hello world was
    a hard parse error. The runner now strips std:: qualifiers (what JSCPP expects for the subset it
    implements) and says so in the console.
  • React: the Babel react preset leaves ES module syntax untouched, so
    export default function App() died in eval with Unexpected token 'export'. Now compiled with
    transform-modules-commonjs and executed with require/module/exports shims; the component is
    resolved from exports.default, exports.App, or a top-level App.
  • Vue: the vendored build was vue.global.prod.js, which strips every runtime warning, so broken
    notes were completely silent. Now ships the dev build with explicit warnHandler/errorHandler,
    and template warnings plus render/lifecycle errors reach the console.
  • PHP: php-wasm serialises every operation through navigator.locks.request, and the Web Locks
    API rejects in opaque-origin contexts — the first run always died as an unhandled rejection. A
    promise-chain mutex now shadows navigator.locks before the module loads (one iframe is one queue,
    so cross-tab locking was meaningless here anyway).

Testing

  • pnpm test — 161 unit tests in 25 files (Vitest + happy-dom): protocol validation and clamping,
    the console bootstrap (including %c formatting and the REPL/table hooks), CSP construction,
    srcdoc assembly, per-runner document generation, the vendor pipeline, auto-run memory, and the
    popout message validator.
  • pnpm test:runners20 tests in real Chromium (Playwright, new): every engine actually
    executes against the vendored assets in a genuinely sandboxed, opaque-origin iframe. This exists
    because string-matching generated HTML cannot catch the bug class that produced most of the fixes
    above — UMD-vs-ESM loading, wasm boot failures, a rejected Web Locks call, Babel leaving module
    syntax alone, a build that silently swallows warnings. It also covers the REPL for Python and SQL,
    the table/rows protocol end-to-end, and the SQL identifier-validation refusal. Not part of
    pnpm test: it needs a browser and public/vendor/ populated.

Deviations & out of scope

  • @click bound to a nonexistent method stays silent in Vue. This was expected to be fixed by
    the dev build, but the real cause turned out to be different, and is a Vue design decision rather
    than a sandbox bug: the SFC compiler turns @click="asd" into a cached inline handler
    (_cache[0] = () => _ctx.asd && _ctx.asd(...)), so the missing property is read on click rather
    than during render, and Vue's "accessed during render" warning is gated on there being a rendering
    instance. Neither build warns, and vue3-sfc-loader exposes no way to disable handler caching.
    The note still renders and the click is simply inert; a Playwright test documents the behaviour.
    The dev build is still a real improvement for everything that is render-time.
  • php-wasm instead of WordPress Playground, and PicoC instead of TinyCC (no usable wasm build).
  • REPL support for Lua/Ruby/PHP is not wired up; the __not3Eval__ extension point is in place.
  • The table viewer is SQL-only.
  • The Playwright suite covers runner execution only. App-level e2e (the popout window, the mobile
    split, save/load against ../api's docker-compose) would need the built Nuxt app and the API
    container; it is a possible follow-up. The popout flow, mobile split and SQL table UI were
    verified manually in Chromium against the dev server for this PR.
  • Not persisted: panel width, popout geometry. No vertical drag-resize on mobile, no REPL history,
    no editable table cells.

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://beta.not-th.re/pr-preview/pr-122/

Built to branch html-previews at 2026-08-04 15:28 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Replaces the hardcoded javascript/html SandboxMode union with a
SandboxRunner registry so later tasks can register additional
interpreters (Python, Ruby, SQL, React, ...) without touching srcdoc
generation. buildCsp and buildSrcdoc now take a runner plus origin so
only vendor-enabled runners widen the CSP to the app origin.
worker-src blob: was emitted unconditionally, letting plain javascript/html
notes spawn blob: Workers they couldn't before this refactor. Only declare
worker-src when a runner sets vendorOrigin or scriptBlob, restoring the
pre-refactor default-src 'none' fallback for everyone else.

Also replaces a substring-based CSP assertion with a tokenized directive
check: ORIGIN's own text contains "https:", so the previous assertion could
not detect an allowNetwork/vendorOrigin flag conflation. Restores a dropped
TOKEN-ordering assertion in the html-runner test.
Adds interpreter packages as devDependencies and a postinstall script that
copies only the files each runner actually loads into public/vendor/ (a
gitignored, regenerated-on-install directory), so multi-language sandbox
runners can load them from the app's own origin instead of a CDN.
wasmoon's LuaFactory defaults customWasmUri to an unpkg.com URL, and
pyodide's loadPyodide() unconditionally sets a jsdelivr.net package base
URL. Both violate the zero-external-request mandate even though the sandbox
CSP already blocks them at the network layer when off, since the user can
enable network access for their own code. Add a post-copy sanitisation pass
that rewrites any known CDN origin to the non-resolving vendored.invalid
across all copied assets, plus a test enforcing it tree-wide so a future
dependency bump can't reintroduce one silently. Also tighten five per-engine
size budgets that were loose enough to hide a partial regression.
Replace the single-runner computed with an engine selector so a note's
language can resolve to multiple runners: an engineId ref backed by a
dropdown, an availableRunners list derived from runnersForLanguage, and
a runner computed that falls back to the first available runner when
no engine is explicitly selected. Heavy runners now default autoRun to
off (on mount and on runner change) so switching to a slow wasm
interpreter doesn't re-boot it on every keystroke. Also clarifies the
run-warning dialog to mention that a language runtime may be fetched
from the app's own server.
Add a 6px pointer-events resize handle on the panel's left edge.
Pointer capture keeps the drag alive once the pointer leaves the
narrow handle; width is clamped to 20-80% of the parent's width and
applied via a CSS custom property so the panel stays full-width on
mobile (the sm: breakpoint is what actually engages the variable
width). The iframe gets pointer-events disabled while dragging so it
doesn't swallow the pointermove stream. Width state is transient and
intentionally not persisted.
Adds buildPopoutDocument(), which renders an app-origin host page whose
own inline script validates event.source and the per-run token before
turning console messages into text nodes, keeping the note's code inside
a nested iframe with the same sandbox flags as the panel. Wires a Popout
button into the sandbox panel that snapshots the current run's srcdoc and
token into a new window.
…x panel

- Bind lostpointercapture on the resize handle so capture lost without a
  pointerup/pointercancel (e.g. alt-tab mid-drag) no longer leaves
  resizing stuck true, which had been permanently disabling pointer
  events on the preview iframe.
- Bail out of onResizeMove when the parent rect has zero width instead
  of letting the math produce NaN/Infinity, which corrupted the
  --sandbox-w custom property and collapsed the panel to width: auto.
- Seed engineId from the first available runner in onMounted so the
  engine <select> shows the runner that actually executes on first
  mount, instead of rendering blank until the user touches it.
…uard)

Post-review hardening for the sandbox popout, which runs in the same
renderer process as the app (same-origin, live window.opener):

- Cap rendered console lines at 500 (mirrors the panel's MAX_ENTRIES),
  evicting the oldest line so a note that logs in a tight loop can no
  longer grow the DOM until the shared renderer is killed.
- Whitelist the console level against CONSOLE_LEVELS from
  lib/sandbox/protocol.ts before using it, closing the seam where an
  attacker-chosen level could probe object internals via COLORS[d.level]
  and preventing the popout's notion of "valid level" from drifting from
  the protocol's. Documents that any future %c-style formatting in the
  popout must route CSS through sanitizeConsoleCss first.
- Guard against event.source === null matching a detached iframe's
  frame.contentWindow === null.

Also replaces a near-vacuous escaping test with a real DOMParser
round-trip across four adversarial srcdoc payloads, asserting byte-exact
attribute round-trip and no injected <script>/<iframe>.
Boots Pyodide from vendored assets only (indexURL points at
public/vendor/pyodide/), relays stdout/stderr to the console, and
auto-loads wheels for imported packages from the local index — with a
clear console warning when an import isn't vendored instead of a silent
hang. Registered as the default engine for "python" and marked heavy so
the panel doesn't auto-run on every keystroke.
Downloads numpy, micropip and packaging (plus their transitive
dependencies) from the pyodide CDN into public/vendor/pyodide at
build time only — never at runtime — verifying each wheel's sha256
against the vendored pyodide-lock.json. Soft-fails on any network or
verification error so a network-less build still produces a working,
stdlib-only Python. Wired into postinstall and the Dockerfile image
build, right after copy-sandbox-vendor.

Measured: 3/3 wheels fetched, public/vendor/pyodide grows to ~25 MB,
still under its existing 30 MB budget.
Lua boots wasmoon from a vendored <script> tag and reads the UMD global
off window.wasmoon rather than dynamic import()'ing it as an ES module:
public/vendor/wasmoon/index.js has no `export` declarations, so
`await import(...)` resolves to an empty module namespace and
destructuring LuaFactory from it would silently be undefined at
runtime (verified by reading the vendored file). The <script src>
pattern used by every other console runner here sidesteps that
entirely. LuaFactory is constructed with the vendored glue.wasm path
so it never falls back to unpkg.com.

Ruby boots ruby.wasm via the side-effect-free UMD build
(browser.umd.js), fetching ruby+stdlib.wasm and compiling it with
WebAssembly.compile (not compileStreaming, so it's independent of the
Content-Type the static host serves .wasm with), then calling
window["ruby-wasm-wasi"].DefaultRubyVM explicitly. Deliberately does
NOT use <script type="text/ruby">: that mechanism lives in
browser.script.iife.js, which hardcodes a cdn.jsdelivr.net fetch on
load and is intentionally not vendored.

Both registered as the default engine for their language ("lua",
"ruby" — no collisions with existing runners); Ruby is marked heavy
since booting the interpreter is slow, Lua is light.
Adds two SQL engines: sql.js/SQLite (default) and PGlite/PostgreSQL
(selectable, heavy). Registration order in runners/index.ts keeps
sql.js as the default engine for "sql" notes.
php-tags.mjs's <script type="text/php"> tag-scanning mechanism only
writes stdout/stderr into the DOM when the tag carries a data-stdout /
data-stderr attribute; without one it silently discards all output.
Uses the PhpWeb class directly instead (php-wasm's other documented
entrypoint), wiring output/error events explicitly so echoed HTML
renders in the preview pane and errors reach the console bootstrap.
Vendor path/copy list updated to PhpWeb.mjs; php-tags.mjs is no
longer copied since nothing loads it.
JSCPP matches the brief's assumed API exactly (global JSCPP, run(code,
input, { stdio: { write } })). picoc-js needed two corrections: its
dist/bundle.js is an ES module with top-level `import ... from 'path'`
(a Node builtin) that browsers cannot resolve, and its actual export
is `runC(cprog, consoleWrite)` — a single callback, not an
`{ output, error }` options object. Uses dist/bundle.umd.js as a
plain <script> tag instead (window.picocjs.runC), the same pattern
already used for wasmoon.

No maintained TinyCC wasm package exists on npm (tcc is native Node
bindings), so C uses the PicoC interpreter as noted in the brief.
Corrects the plan's brief: svelte/compiler/index.js is a UMD bundle
(package.json's ESM entry for ./compiler is src/compiler/index.js, not
the vendored prebuilt bundle), so it is loaded via a classic <script
src> and the window.svelte UMD global rather than await import(). Also
adds esm-env and clsx to the vendor pipeline and import map: they are
svelte's own transitive runtime deps, reachable via real (non-JSDoc)
bare imports from the internal/client module graph, and were missing
from the brief's import map.
…x assets

package.json's postinstall now runs scripts/copy-sandbox-vendor.mjs and
scripts/fetch-pyodide-wheels.mjs, but the Dockerfile ran pnpm install
before COPY . ., so those scripts didn't exist yet and pnpm install
exited non-zero, breaking the Docker image build (used by the publish
and nightly workflows). Moving COPY . . before pnpm install and
dropping the now-redundant explicit vendor RUN fixes it, at the cost
of losing dependency-install layer caching: any source change now
re-runs pnpm install on the next build.
… false positives

React runner now JSON-embeds the note and invokes Babel.transform explicitly
instead of splicing raw source into a type="text/babel" tag, which broke on
a literal <script> element inside JSX and on unbalanced <!--<script> HTML
comments. Tightened jsx/mermaid detection patterns to stop matching mail-merge
placeholders, plain DOM className assignment, and prose containing arrow-like
text, and replaced coffeescript's unless/until pattern (shared with Ruby) with
distinctive fat-arrow and for-of signals. Added a securityLevel assertion to
the mermaid runner test.
…n load runner assets

The Run/Preview panel executes note code in an <iframe sandbox="allow-scripts
allow-modals"> with no allow-same-origin, so it has a permanently opaque
origin and is cross-origin to our own server. Classic <script src> tags still
load in no-cors mode, but fetch() and dynamic import() (used by the python,
ruby, lua, sql.js, pglite, php and svelte runners to load .wasm/.mjs
interpreter assets) are blocked without an Access-Control-Allow-Origin
header, breaking about half of the 14 language runners in production.

Add the header, scoped to /vendor only, in both the Docker entrypoint server
and nuxt.config.ts routeRules (for dev/nitro-served builds). These are
public, credential-free interpreter binaries with no cookies or user data
reachable through /vendor, so a wildcard origin is safe.
The Run/Preview panel picks a runner straight from
detectLanguageFromContent's verdict, so a language misdetection no longer
just disables a button — it silently routes the note through the wrong
interpreter.

Two verified collisions:
- A plain `function greet(name) { ... }` declaration scored higher on lua
  than on javascript, because lua.ts's generic function pattern
  (`\bfunction\s+\w+[.:]?\w*\s*\(`) also matches brace-bodied, C-family
  declarations. Lua function headers are never brace-terminated (blocks
  close with `end`), so anchor the pattern on a header whose line ends in
  nothing but the closing paren.
- Ordinary DOM/console JS (`document.querySelector(...)`,
  `console.log(...)`) scored 0 against javascript.ts's four narrow patterns,
  letting less.ts's generic `.method();` pattern win. Add JS-specific
  signals (console.* calls, document./window. access, const/let
  declarations, arrow functions, template literals with ${}, ===/!==) with
  weights tuned to decisively win real JS/DOM code without outscoring
  TypeScript, JSX, or CoffeeScript on their own samples.

Added tests/lib/monaco/runner-language-collisions.test.ts as a regression
suite covering both collisions plus real Lua, Python, Ruby, CoffeeScript,
React/JSX, Mermaid, Vue SFC and Svelte samples, so a future language
definition can't silently hijack another one's notes again.

less.ts, rust.ts and xml.ts are intentionally untouched: none of those
three have a sandbox runner, so a misdetection into them only disables the
Run button (pre-existing behaviour), unlike the lua/less collisions above
which actively executed JS in the wrong interpreter.
…svelte notes

xml.ts had two catch-all patterns at weight 2 — one matching any tag, one
matching any open/close pair — both uncapped beyond MAX_MATCHES_PER_PATTERN
(10). Any markup document scored ~20-40 purely from tag count, drowning out
html.ts (caps ~11), vue.ts and svelte.ts (~7-8) on their own, far more
specific patterns. Since html, vue and svelte all have sandbox runners
(html shipped in an earlier PR; vue/svelte are added by this branch),
ordinarily-decorated notes in any of those three languages lost their
auto-detected Run button entirely, forcing a manual language pick every
time — the same "generic pattern outscores a specific one" failure already
fixed for lua vs. javascript, just with three runners at stake instead of a
wrong interpreter.

Replaced the two catch-alls with genuinely XML-specific signals: the
<?xml …?> declaration/prolog, xmlns/xmlns: namespace declarations,
namespace-prefixed tags (<ns:tag>), CDATA sections, non-prolog processing
instructions, and DTD SYSTEM/PUBLIC references (narrower than a bare
doctype, since every ordinary HTML5 page's `<!DOCTYPE html>` was quietly
counting as an xml signal here too). Kept one deliberately tiny (weight
0.05) generic-tag fallback so bare, prolog-free XML snippets still beat
plaintext, but far too weak to ever outscore a real language's own pattern.

Accepted tradeoff: a namespace-free, prolog-free XML document with no DTD
reference may now detect as html instead of xml. Both are markup, xml has
no sandbox runner either way, and the misdetection costs nothing — unlike
the previous behaviour, which cost html/vue/svelte their auto-detection on
any realistically-decorated document.

Extended tests/lib/monaco/runner-language-collisions.test.ts with a
realistic (not trimmed-down) HTML page, a minimal HTML fragment, a
realistic Vue SFC (template + script setup + scoped style), a realistic
Svelte component ($state, {#if}, on:click, a style block), and real XML
with a prolog and xmlns declarations. less.ts and rust.ts remain untouched
and out of scope (still no runner; "prose with arrows" still resolves to
rust and a plaintext form-letter still resolves to xml when it lacks any of
the signals above — both pre-existing and low severity).
A small fragment like `<h1 style="...">Hello</h1>` followed by an inline
`<script>console.log(...)</script>` tied at 2 points apiece between html
(only its <script>...</script> pattern fired) and javascript (its
console.log pattern from the previous commit), and javascript won the tie
by array order — close to the HTML-preview example in the PR description,
so this is a realistic paste, and running it through the JS runner produces
a syntax error instead of a preview.

Added two low-weight patterns for common text/block elements (h1-h6, p,
span, ul/ol/li, table/tr/td/th, button, section) that html.ts previously had
no signal for at all: one for the element carrying a real attribute, one for
it wrapping actual text with a matching closing tag. Both require more than
a bare tag-name mention, so a stray "table" in prose doesn't count.

Verified against the full regression suite that this doesn't disturb the
xml.ts fix from the previous commit: the realistic Vue SFC (dense with
exactly these tags in its template) still resolves to vue by a wide margin
(10 vs 5), the realistic Svelte component still resolves to svelte (8 vs 4),
and real XML with a prolog is unaffected. Added the reported fragment to
tests/lib/monaco/runner-language-collisions.test.ts as a pinned regression
case.
@github-actions github-actions Bot added docker Pull requests that update Docker code package labels Aug 4, 2026
mimeTypes: ["text/x-vue"],
detectionPatterns: [
{ pattern: /^\s*<template>/m, weight: 3 },
{ pattern: /^\s*<script(\s+setup)?(\s+lang="ts")?\s*>/m, weight: 2 },
// vendored.invalid uses the RFC 2606 reserved .invalid TLD, which can never
// resolve in DNS — any code path that still tries to fetch it fails loudly
// (offline) instead of silently reaching a real host.
const CDN_HOSTS = ["cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com", "esm.sh"];
// vendored.invalid uses the RFC 2606 reserved .invalid TLD, which can never
// resolve in DNS — any code path that still tries to fetch it fails loudly
// (offline) instead of silently reaching a real host.
const CDN_HOSTS = ["cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com", "esm.sh"];
// resolve in DNS — any code path that still tries to fetch it fails loudly
// (offline) instead of silently reaching a real host.
const CDN_HOSTS = ["cdn.jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com", "esm.sh"];
const CDN_PATTERN = new RegExp(`https://(?:${CDN_HOSTS.map((h) => h.replace(/\./g, "\\.")).join("|")})`, "g");
Deployments served from a subpath (the PR previews live at
/pr-preview/pr-<n>/) keep public/vendor behind that prefix, but buildSrcdoc
composed vendor URLs from window.location.origin alone. Every vendor-backed
runner therefore 404'd there, e.g. PGlite failing with
"error loading dynamically imported module".

buildSrcdoc now takes the app base path (runtimeConfig.public.uiBaseURL,
i.e. NUXT_APP_BASE_URL) and composes it into the vendor prefix. The CSP is
origin-based and so is deliberately left unchanged.
@github-actions github-actions Bot added the pages label Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

components docker Pull requests that update Docker code lib package pages

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants