Skip to content

Run plugins in a QuickJS sandbox in the browser - #580

Draft
gschier wants to merge 5 commits into
mainfrom
claude/serene-dewdney-4c1527
Draft

Run plugins in a QuickJS sandbox in the browser#580
gschier wants to merge 5 commits into
mainfrom
claude/serene-dewdney-4c1527

Conversation

@gschier

@gschier gschier commented Aug 18, 2026

Copy link
Copy Markdown
Member

Stacks on #572 (base is that branch, not main). The bearer proof needs the send proxy.

Adds packages/plugin-sandbox: QuickJS compiled to wasm, in a dedicated worker, with a runtime shell inside it that loads a plugin bundle and answers the same InternalEventPayload events the Node runtime answers. The three proof plugins are unmodified; only their bundling changes.

Pins (reasons in packages/plugin-sandbox/README.md, which is written as the spec for the eventual Rust host):

  • quickjs-ng, because rquickjs vendors it as a submodule and offers no alternative. Browser is on ng 0.12.1 vs rquickjs 0.15.1; closing that skew is slice 2.
  • release-sync, not ASYNCIFY. Half the wasm (529 KB vs 1.08 MB), 2.2x faster, and ASYNCIFY can only suspend one host call at a time. The guest still gets real await through a deferred promise.
  • A tab-owned dedicated worker, not the database SharedWorker. The database worker answers every tab's commands synchronously, so a multi-second import in there would stall every tab, and a runaway plugin can be ended with terminate() only if it is not the worker holding the database.

Perf. GitHub's 12.3 MB OpenAPI description, 1220 requests, identical output in both engines:

First run Best of 6
Node (V8) 304 ms 164 ms
QuickJS 2503 ms 2017 ms

8x on the first run, ~12x once V8 has compiled. Inside the 10 to 50x folklore, and first run is what a user waits for. Importers stay in the sandbox. Reproduce with node packages/plugin-sandbox/bench/import.mjs <spec.json> 6.

Verified against the production build in real Chrome, screenshots in the thread: timestamp.iso8601() rendered into a sent URL and echoed by httpbin, a pasted curl command becoming a request, and Authorization: Bearer ... applied by auth-bearer and echoed back through the send proxy.

Desktop is untouched. Three helpers moved from packages/plugin-runtime/src into @yaakapp-internal/lib so both runtimes share them; the vendored index.cjs rebuilds byte identical.

Known gaps, all slice 2:

  • Auth is applied to the rendered model rather than to a SendableHttpRequest (that type lives in yaak-http, which does not build for wasm32). Fine for bearer, basic and apikey. AWS SigV4 and OAuth 1.0 would sign a slightly different URL than the proxy sends, so they are refused instead of mis-signed.
  • template-function-uuid does not run: uuid reaches for node:crypto. Used -timestamp instead. The noble conversion is the decided fix.
  • Plugin bundles are inlined as strings. That does not scale to the corpus and cannot work for runtime installed plugins; follow-ups are listed at the bottom of scripts/bundle-sandbox-plugins.mjs.
  • The auth picker now lists only what a loaded plugin can apply (bearer), replacing the static list of eight. Visible product change, worth a look.

Base automatically changed from claude/affectionate-swirles-c0d950 to main August 18, 2026 15:28
Adds packages/plugin-sandbox: QuickJS-ng compiled to wasm, running in a
dedicated worker, with a runtime shell inside it that loads a plugin bundle
and answers the same InternalEventPayload events the Node runtime answers.
Plugins are unmodified.

Wires the browser host's template function, authentication, cURL import and
template render commands to it, and relaxes TemplateCallback's Send bound on
wasm32 so the engine's renderer can call back out to a plugin.
The sandbox's context builder was a near-copy of the Node runtime's. Both
now come from createPluginContext in @yaakapp-internal/lib, with each
runtime supplying only a transport.

The two places hosts genuinely differ are optional transport methods:
`stream` (a window reporting navigation until it closes) and `form` (a
prompt that re-renders as values change). The sandbox has neither, so
openUrl refuses and a form is drawn once from its defaults.
Rationale that explains a decision rather than the code below it belongs in
the sandbox README or the PR, not in a header paragraph on every file.
Rebased onto main after #572 merged. The wasm crate moved to crates/yaak-wasm
and crates-server/yaak-web is now the server, so the render_template export,
the regenerated pkg/ and the proxy/server wording move with them.
@gschier
gschier force-pushed the claude/serene-dewdney-4c1527 branch from 45adc2d to 252b151 Compare August 18, 2026 23:36
if (message.type === "host_result") {
const settle = pendingHostCalls.get(message.id);
pendingHostCalls.delete(message.id);
settle?.(message.error != null ? new Error(message.error) : (message.reply ?? "{}"));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants