Cut comments back to the non-obvious

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.
This commit is contained in:
Gregory Schier
2026-08-18 15:23:10 -07:00
parent 20c0efc2a5
commit 85ba3e6852
23 changed files with 139 additions and 526 deletions
+4 -8
View File
@@ -8,14 +8,10 @@ use std::future::Future;
const MAX_DEPTH: usize = 50; const MAX_DEPTH: usize = 50;
/// `Send`, except where nothing can be. /// `Send`, except on wasm32, where a template function is a call into
/// /// JavaScript: the future holds a `JsFuture` and the callback an `Rc` pool,
/// Rendering a template function is a host call, and on a host with one thread /// neither of which can be `Send`. Every other host spawns rendering onto a
/// it is a call into JavaScript: the future holds a `JsFuture` and the callback /// thread pool and needs the bound.
/// holds the `Rc` connection pool, neither of which is `Send` nor can be made
/// so. Every other host spawns rendering onto a thread pool and needs the bound.
/// So the bound belongs to the targets that can keep it, rather than to the
/// trait every host must implement.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub trait MaybeSend: Send {} pub trait MaybeSend: Send {}
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
+10 -16
View File
@@ -26,28 +26,22 @@ export function blob_put(id: string, bytes: Uint8Array): void;
export function boot(): Promise<void>; export function boot(): Promise<void>;
/** /**
* Resolve and render a request for sending, exactly as the desktop does before it puts the * Resolve and render a request for sending, exactly as the desktop does: the environment
* request on the network: the environment chain, inherited headers and auth, request * chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab * a socket.
* posts to the Yaak server.
* *
* `plugins` is the template function bridge a JavaScript function taking a name and its * `plugins` is the template function bridge: a JS function taking a name and JSON args,
* JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes * resolving to the rendered string. Passing nothing is allowed.
* every template function a refusal naming it.
* *
* Authentication is *not* applied here even though it is part of preparing a send. It is * Authentication is applied by the caller, not here, because the plugin that applies it
* applied to the rendered request by the caller, because the plugin that applies it wants to * needs to see the request as it will be sent.
* see the request as it will be sent, and the caller is the side that knows that.
*/ */
export function prepare_http_send(payload: any, plugins: any): Promise<any>; export function prepare_http_send(payload: any, plugins: any): Promise<any>;
/** /**
* Render one template string against an environment chain. * What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
* * shows an empty string where a send would refuse, since a half-typed template is not yet a
* What `cmd_render_template` does on the desktop, for the same callers: the value previews * mistake.
* under an editor, and anywhere the app shows what a template will become. `ignore_error`
* picks the same behaviour it picks there — a preview shows an empty string where a send
* would refuse, because a half-typed template is not yet a mistake.
*/ */
export function render_template(payload: any, plugins: any): Promise<any>; export function render_template(payload: any, plugins: any): Promise<any>;
+10 -16
View File
@@ -63,18 +63,15 @@ export function boot() {
} }
/** /**
* Resolve and render a request for sending, exactly as the desktop does before it puts the * Resolve and render a request for sending, exactly as the desktop does: the environment
* request on the network: the environment chain, inherited headers and auth, request * chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab * a socket.
* posts to the send proxy.
* *
* `plugins` is the template function bridge a JavaScript function taking a name and its * `plugins` is the template function bridge: a JS function taking a name and JSON args,
* JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes * resolving to the rendered string. Passing nothing is allowed.
* every template function a refusal naming it.
* *
* Authentication is *not* applied here even though it is part of preparing a send. It is * Authentication is applied by the caller, not here, because the plugin that applies it
* applied to the rendered request by the caller, because the plugin that applies it wants to * needs to see the request as it will be sent.
* see the request as it will be sent, and the caller is the side that knows that.
* @param {any} payload * @param {any} payload
* @param {any} plugins * @param {any} plugins
* @returns {Promise<any>} * @returns {Promise<any>}
@@ -85,12 +82,9 @@ export function prepare_http_send(payload, plugins) {
} }
/** /**
* Render one template string against an environment chain. * What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
* * shows an empty string where a send would refuse, since a half-typed template is not yet a
* What `cmd_render_template` does on the desktop, for the same callers: the value previews * mistake.
* under an editor, and anywhere the app shows what a template will become. `ignore_error`
* picks the same behaviour it picks there — a preview shows an empty string where a send
* would refuse, because a half-typed template is not yet a mistake.
* @param {any} payload * @param {any} payload
* @param {any} plugins * @param {any} plugins
* @returns {Promise<any>} * @returns {Promise<any>}
Binary file not shown.
+17 -34
View File
@@ -423,9 +423,8 @@ fn dispatch(
to_json(()) to_json(())
} }
// A plugin's own storage, namespaced by plugin name exactly as the desktop namespaces // Namespaced by plugin name exactly as `build_shared_reply` does in
// it (`build_shared_reply` in crates/yaak/src/plugin_events.rs), so a plugin that keeps // crates/yaak/src/plugin_events.rs, so a token is found under the same key on either host.
// a token here finds it under the same key on either host.
"web_plugin_kv_get" => { "web_plugin_kv_get" => {
let req: PluginKeyValueReq = from_js(payload)?; let req: PluginKeyValueReq = from_js(payload)?;
let found = host.queries.connect().get_plugin_key_value(&req.plugin_name, &req.key); let found = host.queries.connect().get_plugin_key_value(&req.plugin_name, &req.key);
@@ -476,9 +475,8 @@ struct PreparedHttpSend {
/// The request with inherited headers and authentication applied and every template /// The request with inherited headers and authentication applied and every template
/// rendered. What the proxy sends, and what the response records as its request. /// rendered. What the proxy sends, and what the response records as its request.
request: HttpRequest, request: HttpRequest,
/// Identifies whichever model the authentication was inherited from, hashed the way the /// Whichever model the auth was inherited from, hashed as the desktop hashes it. An
/// desktop hashes it. Plugins key their stored state on it — an OAuth token cache belongs /// OAuth token cache belongs to the folder that declared the auth, not to each request.
/// to the folder that declared the auth, not to each request under it.
auth_context_id: String, auth_context_id: String,
settings: HttpSendSettings, settings: HttpSendSettings,
/// The `* Setting name=value` timeline lines the desktop writes at the top of a send, /// The `* Setting name=value` timeline lines the desktop writes at the top of a send,
@@ -488,15 +486,9 @@ struct PreparedHttpSend {
cookie_jar: Option<CookieJar>, cookie_jar: Option<CookieJar>,
} }
/// A template callback that calls a template function wherever the caller keeps them. /// Reaches a template function through a JavaScript function the worker installed, which
/// /// forwards to the plugin sandbox. Without one, a template function is a refusal naming it
/// The desktop's equivalent (`PluginTemplateCallback`) reaches a plugin process; this one /// rather than an empty string sent in its place.
/// reaches a JavaScript function the worker installed, which forwards to the sandbox and back.
/// Both hand the renderer the same thing — a string, or an error naming what failed — so a
/// template renders identically on either host or fails for the same reason.
///
/// Without a function to call, a template function is a clear refusal naming it, which tells
/// the user what the request needs rather than sending an empty string in its place.
struct JsTemplateCallback { struct JsTemplateCallback {
call: Option<js_sys::Function>, call: Option<js_sys::Function>,
} }
@@ -507,7 +499,6 @@ impl TemplateCallback for JsTemplateCallback {
fn_name: &str, fn_name: &str,
args: HashMap<String, serde_json::Value>, args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> { ) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> {
// Built before the async block so the future holds only owned values.
let call = self.call.clone(); let call = self.call.clone();
let fn_name = fn_name.to_string(); let fn_name = fn_name.to_string();
let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()); let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into());
@@ -545,7 +536,6 @@ impl TemplateCallback for JsTemplateCallback {
} }
} }
/// The message out of a rejected promise or a thrown error, without the `Error:` wrapper.
fn js_message(value: &JsValue) -> String { fn js_message(value: &JsValue) -> String {
if let Some(text) = value.as_string() { if let Some(text) = value.as_string() {
return text; return text;
@@ -556,23 +546,19 @@ fn js_message(value: &JsValue) -> String {
message.unwrap_or_else(|| format!("{value:?}")) message.unwrap_or_else(|| format!("{value:?}"))
} }
/// The template function bridge, or none when the caller passed nothing.
fn template_callback(plugins: JsValue) -> JsTemplateCallback { fn template_callback(plugins: JsValue) -> JsTemplateCallback {
JsTemplateCallback { call: plugins.dyn_into::<js_sys::Function>().ok() } JsTemplateCallback { call: plugins.dyn_into::<js_sys::Function>().ok() }
} }
/// Resolve and render a request for sending, exactly as the desktop does before it puts the /// Resolve and render a request for sending, exactly as the desktop does: the environment
/// request on the network: the environment chain, inherited headers and auth, request /// chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab /// a socket.
/// posts to the Yaak server.
/// ///
/// `plugins` is the template function bridge a JavaScript function taking a name and its /// `plugins` is the template function bridge: a JS function taking a name and JSON args,
/// JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes /// resolving to the rendered string. Passing nothing is allowed.
/// every template function a refusal naming it.
/// ///
/// Authentication is *not* applied here even though it is part of preparing a send. It is /// Authentication is applied by the caller, not here, because the plugin that applies it
/// applied to the rendered request by the caller, because the plugin that applies it wants to /// needs to see the request as it will be sent.
/// see the request as it will be sent, and the caller is the side that knows that.
#[wasm_bindgen] #[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsValue> { pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
let req: PrepareHttpSendReq = from_js(payload)?; let req: PrepareHttpSendReq = from_js(payload)?;
@@ -631,12 +617,9 @@ struct RenderTemplateReq {
ignore_error: Option<bool>, ignore_error: Option<bool>,
} }
/// Render one template string against an environment chain. /// What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
/// /// shows an empty string where a send would refuse, since a half-typed template is not yet a
/// What `cmd_render_template` does on the desktop, for the same callers: the value previews /// mistake.
/// under an editor, and anywhere the app shows what a template will become. `ignore_error`
/// picks the same behaviour it picks there — a preview shows an empty string where a send
/// would refuse, because a half-typed template is not yet a mistake.
#[wasm_bindgen] #[wasm_bindgen]
pub async fn render_template(payload: JsValue, plugins: JsValue) -> Result<JsValue> { pub async fn render_template(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
let req: RenderTemplateReq = from_js(payload)?; let req: RenderTemplateReq = from_js(payload)?;
+12 -41
View File
@@ -1,15 +1,9 @@
/** /**
* `ctx`, as a plugin sees it, built once for every runtime that has one. * `ctx`, built once for every runtime that has one. A runtime supplies only how
* a payload reaches its host.
* *
* Two runtimes host plugins today — the Node sidecar over a WebSocket, and the * `stream` and `form` are optional because they are the two places a host
* QuickJS sandbox over a message port — and a third will when the sandbox is * genuinely differs: both need a conversation rather than one reply.
* embedded in Rust. What `ctx.httpRequest.send(...)` *means* is the same in all
* of them, so it is built here, and the only thing a runtime supplies is how a
* payload gets to its host and back.
*
* `stream` and `form` are optional because they are the two places where a host
* genuinely differs: both need a conversation rather than one reply, and a
* runtime that cannot hold one degrades honestly instead of pretending.
*/ */
import type { import type {
@@ -53,19 +47,14 @@ import { createResponseBody, decodeBase64Chunk } from "./responseBody";
import { applyFormInputDefaults } from "./templateFunction"; import { applyFormInputDefaults } from "./templateFunction";
export interface PluginTransport { export interface PluginTransport {
/** One request out, one reply back. Rejects if the host couldn't answer. */
request( request(
context: PluginContext, context: PluginContext,
payload: InternalEventPayload, payload: InternalEventPayload,
): Promise<Record<string, unknown>>; ): Promise<Record<string, unknown>>;
/** Send with no reply expected. */
notify(context: PluginContext, payload: InternalEventPayload): void; notify(context: PluginContext, payload: InternalEventPayload): void;
/** /** Send once, keep receiving. Windows report navigation until they close. */
* Send once and keep receiving. Used by windows, which report navigation
* until they close. Absent where a host has no windows to open.
*/
stream?( stream?(
context: PluginContext, context: PluginContext,
payload: InternalEventPayload, payload: InternalEventPayload,
@@ -73,11 +62,8 @@ export interface PluginTransport {
): void; ): void;
/** /**
* Show a form that may re-render before it settles. * A form that may re-render before it settles: `onChange` answers with the
* * form to show next. Without it, a form is drawn once from its defaults.
* `onChange` is called with the values entered so far and answers with the
* form to show next, so inputs that compute themselves from other inputs stay
* live. A host without it gets a form drawn once from its defaults.
*/ */
form?( form?(
context: PluginContext, context: PluginContext,
@@ -88,14 +74,7 @@ export interface PluginTransport {
): Promise<PromptFormResponse>; ): Promise<PromptFormResponse>;
} }
/** /** `bodyPath` names a file on a host's disk; plugins address bodies by id. */
* A response as a plugin should see it.
*
* `bodyPath` names a file on a host's disk: meaningless to a plugin, absent
* once bodies move off the filesystem, impossible in a browser. Plugins address
* bodies by response id, so it is dropped rather than left for one to grow a
* dependency on.
*/
function forPlugin(httpResponse: HttpResponse): HttpResponse { function forPlugin(httpResponse: HttpResponse): HttpResponse {
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & { const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
bodyPath?: string | null; bodyPath?: string | null;
@@ -110,7 +89,6 @@ export function createPluginContext(
const send = <T>(payload: InternalEventPayload): Promise<T> => const send = <T>(payload: InternalEventPayload): Promise<T> =>
transport.request(context, payload) as Promise<T>; transport.request(context, payload) as Promise<T>;
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
const storedBody = async (responseId: string) => { const storedBody = async (responseId: string) => {
const bodyInfo = () => const bodyInfo = () =>
send<GetHttpResponseBodyInfoResponse>({ send<GetHttpResponseBodyInfoResponse>({
@@ -191,9 +169,8 @@ export function createPluginContext(
return reply.value; return reply.value;
}, },
form: async (args) => { form: async (args) => {
// Inputs may compute themselves from the values entered so far, and a // Inputs may compute from the values entered so far, and a function
// function cannot cross to a host — so they are resolved against the // cannot cross to a host.
// defaults before the form is drawn, then stripped.
const resolve = async (values: Record<string, unknown>) => { const resolve = async (values: Record<string, unknown>) => {
const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs; const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs;
const resolved = await applyDynamicFormInput( const resolved = await applyDynamicFormInput(
@@ -217,8 +194,7 @@ export function createPluginContext(
} }
const reply = await transport.form(context, payload, async (values) => { const reply = await transport.form(context, payload, async (values) => {
// Fired on mount before any interaction, when there is nothing to // Fired on mount, before there is anything to recompute from.
// recompute from.
if (values == null || Object.keys(values).length === 0) return null; if (values == null || Object.keys(values).length === 0) return null;
return { type: "prompt_form_request", ...args, inputs: await resolve(values) }; return { type: "prompt_form_request", ...args, inputs: await resolve(values) };
}); });
@@ -259,8 +235,7 @@ export function createPluginContext(
}); });
// A send with no request behind it saves nothing, so the reply carries // A send with no request behind it saves nothing, so the reply carries
// the only copy of its body. A saved one is read back from the host like // the only copy of its body.
// any other. Callers get the same thing either way.
if (body == null) { if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) }; return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
} }
@@ -366,10 +341,6 @@ export function createPluginContext(
}, },
}, },
templates: { templates: {
/**
* Invoke Yaak's template engine to render a value. If the value is a nested
* type (eg. object), it will be recursively rendered.
*/
render: async (args: TemplateRenderRequest) => { render: async (args: TemplateRenderRequest) => {
const result = await send<TemplateRenderResponse>({ const result = await send<TemplateRenderResponse>({
type: "template_render_request", type: "template_render_request",
+1 -16
View File
@@ -1,13 +1,3 @@
/**
* The form handling every plugin runtime does, wherever it runs.
*
* A plugin declares its inputs as data, but any of them may compute itself
* from the values entered so far — so a runtime has to resolve those callbacks
* before a host can draw the form, then strip them, because a function cannot
* cross a process, a worker, or a sandbox boundary. That is the same work for
* the Node runtime and the QuickJS one, so it lives here rather than in either.
*/
import type { import type {
CallPromptFormDynamicArgs, CallPromptFormDynamicArgs,
Context, Context,
@@ -86,12 +76,7 @@ export async function applyDynamicFormInput(
return resolvedArgs; return resolvedArgs;
} }
/** /** What a host receives has to be data all the way down. */
* Drop the `dynamic` callbacks, recursively, leaving inputs that serialize.
*
* Called on the way out of a runtime, after [`applyDynamicFormInput`] has run
* them: what a host receives has to be data all the way down.
*/
export function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] { export function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
return inputs.map((input) => { return inputs.map((input) => {
// oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type // oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type
+1 -9
View File
@@ -50,14 +50,7 @@ export class WorkerConnection {
/** True once the worker has said anything at all. */ /** True once the worker has said anything at all. */
private heard = false; private heard = false;
/** /** Unset until the sandbox is up; a render before then gets a refusal. */
* Who answers a template function, once something can.
*
* The engine renders in the worker but the functions come from plugins in
* this tab's sandbox, so the worker asks back through this. Unset until the
* sandbox is up, and a render that arrives before then gets the same refusal
* a host with no plugins gives — which is the truth at that moment.
*/
private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null; private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null;
constructor() { constructor() {
@@ -172,7 +165,6 @@ export class WorkerConnection {
}); });
} }
/** Hand the worker somewhere to send template functions. */
setTemplateFunctionHandler(handler: (name: string, args: string) => Promise<string>): void { setTemplateFunctionHandler(handler: (name: string, args: string) => Promise<string>): void {
this.templateFunctions = handler; this.templateFunctions = handler;
} }
+1 -3
View File
@@ -166,9 +166,7 @@ export function createWebPlatform(): Platform {
const plugins = new WebPlugins(db); const plugins = new WebPlugins(db);
const capabilities = capabilitiesFor(); const capabilities = capabilitiesFor();
// Rendering happens in the worker and template functions live in the sandbox, // Registered before anything can render, not inside the first send.
// so the worker needs a way back here to call one. Registered before anything
// can render, which is why it is here rather than inside the first send.
db.setTemplateFunctionHandler((name, args) => plugins.callTemplateFunction(name, args)); db.setTemplateFunctionHandler((name, args) => plugins.callTemplateFunction(name, args));
// Without this, IndexedDB is best-effort storage and a browser reclaiming // Without this, IndexedDB is best-effort storage and a browser reclaiming
+15 -68
View File
@@ -1,17 +1,7 @@
/** /**
* The plugins this host runs, and everything they are allowed to reach. * Keeps a sandbox, loads the bundled plugins into it, routes by what each one
* * contributes, and answers the `ctx` calls they make. `hostRequest` below is
* Two jobs. Outward: keep a sandbox, load the bundled plugins into it, and know * the whole of what a plugin can do to the world here.
* which of them answers what — the app asks for "the bearer auth config" and
* this decides that means `auth-bearer`. Inward: answer the `ctx` calls those
* plugins make, which is where the sandbox stops being a sealed box and starts
* being a host. Everything a plugin can do to the world is in `hostRequest`
* below, by name, with a refusal for anything not listed.
*
* The plugins are bundled into the app rather than installed, for now — see
* `scripts/bundle-sandbox-plugins.mjs`. Which three, and why only three, is a
* decision that belongs to this slice and not to the sandbox: the runtime does
* not know how many plugins exist.
*/ */
import { PluginSandbox, type PluginSummary } from "@yaakapp-internal/plugin-sandbox"; import { PluginSandbox, type PluginSummary } from "@yaakapp-internal/plugin-sandbox";
@@ -28,7 +18,6 @@ import type {
import type { WorkerConnection } from "./connection"; import type { WorkerConnection } from "./connection";
import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated"; import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated";
/** What a plugin's own storage is keyed under, matching the desktop's namespacing. */
type KeyValueRequest = { key: string }; type KeyValueRequest = { key: string };
export interface AppliedAuthentication { export interface AppliedAuthentication {
@@ -41,7 +30,6 @@ export class WebPlugins {
private sandbox: PluginSandbox | null = null; private sandbox: PluginSandbox | null = null;
private loading: Promise<void> | null = null; private loading: Promise<void> | null = null;
/** Loaded plugin ids, by what they contribute. */
private readonly byTemplateFunction = new Map<string, string>(); private readonly byTemplateFunction = new Map<string, string>();
private readonly byAuthName = new Map<string, string>(); private readonly byAuthName = new Map<string, string>();
private readonly importers: string[] = []; private readonly importers: string[] = [];
@@ -52,12 +40,8 @@ export class WebPlugins {
} }
/** /**
* Bring the sandbox up and load every bundled plugin, once.
*
* Called from every entry point rather than at construction, so a session * Called from every entry point rather than at construction, so a session
* that never touches a plugin never pays for QuickJS — and so a tab that * that never touches a plugin never pays for QuickJS.
* cannot start the worker still boots the app, with plugin-shaped features
* failing individually instead of the page failing entirely.
*/ */
ready(): Promise<void> { ready(): Promise<void> {
this.loading ??= this.start(); this.loading ??= this.start();
@@ -68,16 +52,13 @@ export class WebPlugins {
const sandbox = new PluginSandbox({ const sandbox = new PluginSandbox({
onHostRequest: (envelope) => this.hostRequest(envelope), onHostRequest: (envelope) => this.hostRequest(envelope),
onLog: ({ pluginRefId, level, message }) => { onLog: ({ pluginRefId, level, message }) => {
// Prefixed, because otherwise a plugin's console output is // Prefixed, or a plugin's console output blames the app's own code.
// indistinguishable from the app's own and blames the wrong code.
const write = level === "error" ? console.error : console.log; const write = level === "error" ? console.error : console.log;
write(`[plugin ${pluginRefId}] ${message}`); write(`[plugin ${pluginRefId}] ${message}`);
}, },
}); });
this.sandbox = sandbox; this.sandbox = sandbox;
// In parallel: each is an independent QuickJS context and none of them
// observes the others.
await Promise.all( await Promise.all(
SANDBOX_PLUGINS.map(async ({ name, source }) => { SANDBOX_PLUGINS.map(async ({ name, source }) => {
try { try {
@@ -106,13 +87,7 @@ export class WebPlugins {
return this.gather("get_http_authentication_summary_request", this.byAuthName.values()); return this.gather("get_http_authentication_summary_request", this.byAuthName.values());
} }
/** /** One broken plugin must not empty the picker for the others. */
* Ask several plugins the same question and keep the answers that came back.
*
* A plugin that has nothing to say answers `empty_response`, which is not an
* answer and is dropped; one that throws is logged and dropped too, so a
* single broken plugin cannot empty the picker for all the others.
*/
private async gather<T>(type: string, ids: Iterable<string>): Promise<T[]> { private async gather<T>(type: string, ids: Iterable<string>): Promise<T[]> {
const replies = await Promise.all( const replies = await Promise.all(
Array.from(ids).map(async (id): Promise<{ type: string } | null> => { Array.from(ids).map(async (id): Promise<{ type: string } | null> => {
@@ -146,13 +121,9 @@ export class WebPlugins {
} }
/** /**
* Run one template function. * What the engine's render calls back into. A function nothing provides is a
* * throw naming it, not an empty string: a request sent with a silently blank
* This is what the engine's render calls back into, so its contract is the * token is worse than one that refuses to be sent.
* engine's: a string, or a throw whose message says what went wrong. A
* function nothing provides is a throw naming it rather than an empty
* string, because a request sent with a silently blank token is worse than
* one that refuses to be sent.
*/ */
async callTemplateFunction(name: string, argsJson: string): Promise<string> { async callTemplateFunction(name: string, argsJson: string): Promise<string> {
await this.ready(); await this.ready();
@@ -204,13 +175,7 @@ export class WebPlugins {
} as InternalEventPayload); } as InternalEventPayload);
} }
/**
* Apply an authentication method to a request that is about to be sent.
*
* The plugin is shown the request as it stands and hands back headers and
* query parameters to add — the same exchange the desktop has, at the same
* point in the send.
*/
async applyHttpAuthentication( async applyHttpAuthentication(
authName: string, authName: string,
request: { request: {
@@ -235,13 +200,7 @@ export class WebPlugins {
} as InternalEventPayload); } as InternalEventPayload);
} }
/** /** First importer that recognizes the text wins, as `import_data` decides too. */
* Import whatever this text turns out to be.
*
* Every importer is asked and the first one that recognizes it wins, which
* is how the desktop's `import_data` decides too — an importer that does not
* recognize its input returns nothing rather than guessing.
*/
async import(content: string): Promise<ImportResources | null> { async import(content: string): Promise<ImportResources | null> {
await this.ready(); await this.ready();
for (const id of this.importers) { for (const id of this.importers) {
@@ -269,26 +228,16 @@ export class WebPlugins {
} }
/** /**
* The context a plugin sees. * `label` names a desktop window, so it stays null and the calls needing one
* * refuse rather than guess which request the user is looking at.
* `label` is null and stays null: it names a desktop window, and the calls
* that need one — `ctx.window.requestId()` and its neighbours — are refused
* rather than answered with a guess about which request the user is looking
* at. `workspaceId` is genuinely unknown here for the same reason; the
* commands that know it pass it themselves.
*/ */
private context(): PluginContext { private context(): PluginContext {
return { id: "web", label: null, workspaceId: null }; return { id: "web", label: null, workspaceId: null };
} }
/** /**
* Answer one `ctx` call. * Every addition here is a capability decision, which is why they are written
* * out one at a time instead of forwarded wholesale.
* The list is short on purpose. What is here is what a plugin can do in a
* browser tab today; what is missing refuses by name, so a plugin that needs
* it fails with a sentence someone can act on rather than a hang or an
* undefined. Every addition to this list is a capability decision, which is
* why they are written out one at a time instead of forwarded wholesale.
*/ */
private async hostRequest(envelope: string): Promise<string> { private async hostRequest(envelope: string): Promise<string> {
const { pluginRefId, payload } = JSON.parse(envelope) as { const { pluginRefId, payload } = JSON.parse(envelope) as {
@@ -299,7 +248,6 @@ export class WebPlugins {
const reply = async (): Promise<InternalEventPayload> => { const reply = async (): Promise<InternalEventPayload> => {
switch (payload.type) { switch (payload.type) {
/* A plugin's own storage, namespaced by plugin in the database. */
case "get_key_value_request": { case "get_key_value_request": {
const value = await this.db.rpc<string | null>("web_plugin_kv_get", { const value = await this.db.rpc<string | null>("web_plugin_kv_get", {
pluginName: pluginRefId, pluginName: pluginRefId,
@@ -324,7 +272,6 @@ export class WebPlugins {
return { type: "delete_key_value_response", deleted } as InternalEventPayload; return { type: "delete_key_value_response", deleted } as InternalEventPayload;
} }
/* A message for the user, delivered where every other one is. */
case "show_toast_request": { case "show_toast_request": {
const { type: _type, ...toast } = payload; const { type: _type, ...toast } = payload;
this.db.deliver("show_toast", toast); this.db.deliver("show_toast", toast);
+2 -12
View File
@@ -16,10 +16,7 @@ export type ToWorker =
* async in the engine (rendering is), where every `rpc` command is not. * async in the engine (rendering is), where every `rpc` command is not.
*/ */
| { type: "prepare_http_send"; id: number; payload: unknown } | { type: "prepare_http_send"; id: number; payload: unknown }
/** /** Async for the same reason `prepare_http_send` is: it can call a plugin. */
* Render one template string. Async for the same reason `prepare_http_send`
* is: a template function is a call out to a plugin, and plugins are not here.
*/
| { type: "render_template"; id: number; payload: unknown } | { type: "render_template"; id: number; payload: unknown }
/** The tab's answer to a `template_function` call. */ /** The tab's answer to a `template_function` call. */
| { | {
@@ -51,14 +48,7 @@ export type FromWorker =
| { type: "error"; id: number; message: string } | { type: "error"; id: number; message: string }
/** A backend event for the app — today only `model_writes`. Sent to every port. */ /** A backend event for the app — today only `model_writes`. Sent to every port. */
| { type: "event"; event: string; payload: unknown } | { type: "event"; event: string; payload: unknown }
/** /** The one message that runs the other way: the engine asking for a plugin. */
* Render a template function, please.
*
* The one message that runs the other way. Rendering happens in the engine,
* here, but the functions it calls live in a plugin sandbox the tab owns —
* so the engine asks, and it asks the port that started the render rather
* than broadcasting, because only that tab is waiting.
*/
| { type: "template_function"; id: number; name: string; args: string }; | { type: "template_function"; id: number; name: string; args: string };
/** What the worker registers itself under. Tabs on one origin share it. */ /** What the worker registers itself under. Tabs on one origin share it. */
+9 -19
View File
@@ -52,7 +52,7 @@ type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */ /** What `prepare_http_send` (crates/yaak-wasm) hands back. */
interface PreparedHttpSend { interface PreparedHttpSend {
request: HttpRequest; request: HttpRequest;
/** Hashed id of the model the authentication came from; a plugin keys its state on it. */ /** Hashed id of the model the auth came from; plugins key stored state on it. */
authContextId: string; authContextId: string;
settings: HttpSendSettings; settings: HttpSendSettings;
settingEvents: HttpResponseEventData[]; settingEvents: HttpResponseEventData[];
@@ -60,20 +60,13 @@ interface PreparedHttpSend {
} }
/** /**
* Apply the request's authentication method, if it has one. * The desktop applies auth to the sendable request; here the proxy builds that,
* so the plugin's answer goes onto the model and the proxy folds it in. Same
* bytes for a method that sets a header, which is every one that runs here.
* *
* The desktop does this to the request it is about to put on the wire, after * Not the same for one that *signs*, since the plugin sees the request before
* rendering and after building the sendable form of it. Here the sendable form * the proxy assembles it. AWS SigV4 and OAuth 1.0 are refused rather than
* is built by the proxy, so the plugin's answer is applied to the model instead * mis-signed; see the sandbox README.
* — headers onto `headers`, query parameters onto `urlParameters` — and the
* proxy folds both in exactly as it would any others. The result on the wire is
* the same for a method that sets a header, which is every method whose plugin
* runs in the browser today.
*
* It is not the same for a method that *signs* the request, because the plugin
* is shown the request before the proxy assembles it: AWS SigV4 and OAuth 1.0
* would sign a URL and a header set slightly different from the ones sent. Both
* are refused rather than silently mis-signed — see the sandbox README.
*/ */
async function applyAuthentication( async function applyAuthentication(
plugins: WebPlugins, plugins: WebPlugins,
@@ -90,17 +83,14 @@ async function applyAuthentication(
method: request.method, method: request.method,
url: request.url, url: request.url,
headers: request.headers.filter((h) => h.enabled !== false), headers: request.headers.filter((h) => h.enabled !== false),
// The desktop passes the body so signing schemes can hash it. This host // Only signing schemes hash the body, and those are already refused.
// does not have it in bytes at this point, and the schemes that would use
// it are the ones already refused.
body: null, body: null,
}); });
const headers = [...request.headers]; const headers = [...request.headers];
for (const header of applied.setHeaders ?? []) { for (const header of applied.setHeaders ?? []) {
// Replace-or-append, case-insensitively, matching `insert_header` in // Replace-or-append, case-insensitively, matching `insert_header` in
// crates/yaak-http: a plugin setting Authorization must not end up with the // crates/yaak-http.
// request's own Authorization also on the wire.
const at = headers.findIndex((h) => h.name.toLowerCase() === header.name.toLowerCase()); const at = headers.findIndex((h) => h.name.toLowerCase() === header.name.toLowerCase());
const entry = { name: header.name, value: header.value, enabled: true }; const entry = { name: header.name, value: header.value, enabled: true };
if (at >= 0) headers[at] = { ...headers[at], ...entry }; if (at >= 0) headers[at] = { ...headers[at], ...entry };
+3 -11
View File
@@ -109,17 +109,9 @@ function bootOnce(): Promise<void> {
} }
/** /**
* Template functions, which live somewhere this worker cannot reach. * Rendering happens here; the functions it calls live in a sandbox the tab
* * owns. Asked of the port that started the render, not every port, because
* Rendering is the engine's, and the engine is here. The functions it calls * only that tab is waiting and only its sandbox has those plugins.
* come from plugins, which run in a sandbox the tab owns — so the engine is
* handed a function that asks the tab. It asks the port that started the
* render, not every port, because only that tab is waiting on the answer and
* only its sandbox has the plugins the render was started against.
*
* A failure comes back as a rejection, which the engine turns into a render
* error naming the function. That matters: rendering `${[ uuid.v4() ]}` to an
* empty string and sending it would be worse than not sending at all.
*/ */
const pendingTemplateFunctions = new Map<number, (result: string | Error) => void>(); const pendingTemplateFunctions = new Map<number, (result: string | Error) => void>();
let nextTemplateFunctionId = 1; let nextTemplateFunctionId = 1;
+5 -16
View File
@@ -1,15 +1,8 @@
/** /**
* How much slower is an importer inside the sandbox? * How much slower is an importer inside the sandbox? Yaak's OpenAPI importer is
* first-party JavaScript, so a large spec is parsed by whatever engine the
* runtime uses. Numbers are in the README.
* *
* This is the number the tiered-runtime decision rests on. Template functions
* and auth signing are small enough that engine speed cannot matter; importing
* is not. Yaak's OpenAPI importer is first-party JavaScript, not Rust, so a
* large specification is parsed and walked by whatever engine the runtime uses
* — QuickJS in a browser tab, V8 on the desktop today. If the gap is large
* enough to be felt on a real document, importers need a different path before
* the corpus is ported.
*
* Usage:
* node packages/plugin-sandbox/bench/import.mjs <spec.json> [iterations] * node packages/plugin-sandbox/bench/import.mjs <spec.json> [iterations]
*/ */
@@ -34,10 +27,7 @@ const spec = readFileSync(specPath, "utf8");
console.log(`Spec: ${specPath} (${(spec.length / 1024 / 1024).toFixed(1)} MB)`); console.log(`Spec: ${specPath} (${(spec.length / 1024 / 1024).toFixed(1)} MB)`);
console.log(`Iterations: ${iterations}\n`); console.log(`Iterations: ${iterations}\n`);
/** The sandbox host, bundled for Node so this script can drive it directly. */
async function loadHost() { async function loadHost() {
// Inside node_modules so the emitted bundle's own imports of the QuickJS
// variant resolve the way any other module's would.
const outDir = join(root, "node_modules", ".cache", "yaak-plugin-sandbox"); const outDir = join(root, "node_modules", ".cache", "yaak-plugin-sandbox");
mkdirSync(outDir, { recursive: true }); mkdirSync(outDir, { recursive: true });
const outfile = join(outDir, "host.mjs"); const outfile = join(outDir, "host.mjs");
@@ -69,9 +59,8 @@ function report(label, times, resourceCount) {
`best ${min.toFixed(0).padStart(5)} ms ` + `best ${min.toFixed(0).padStart(5)} ms ` +
`median ${median.toFixed(0).padStart(5)} ms (${resourceCount} requests)`, `median ${median.toFixed(0).padStart(5)} ms (${resourceCount} requests)`,
); );
// Every run, because the spread is the point: V8 compiles this workload // The spread is the point: V8 compiles this across the first few passes and
// across the first few passes and QuickJS, which does not compile at all, // QuickJS does not compile at all.
// does not. Quoting one ratio would pick a winner by choosing when to look.
console.log(`${" ".repeat(20)} runs: ${times.map((t) => t.toFixed(0)).join(", ")} ms`); console.log(`${" ".repeat(20)} runs: ${times.map((t) => t.toFixed(0)).join(", ")} ms`);
return { first: times[0], best: min }; return { first: times[0], best: min };
} }
+3 -17
View File
@@ -1,14 +1,7 @@
/** /**
* Bundle the guest shell into a string the host can evaluate. * The shell has to reach QuickJS as source text. Emitted as a `.ts` module, not
* * a `.js` asset, so Vite and plain Node get at it the same way. Committed, like
* The shell runs inside QuickJS, which has no module loader and no filesystem, * the wasm packages, so a checkout builds without this having run.
* so it has to arrive as source text. Emitting it as a `.ts` module rather than
* a `.js` asset is what lets every consumer — Vite for the browser build, plain
* Node for the benchmarks — get at it the same way, with no loader plugin and
* no `?raw` import that only one bundler understands.
*
* The output is committed, like the wasm packages are, so a checkout builds
* without this step having run.
*/ */
import { build } from "esbuild"; import { build } from "esbuild";
@@ -23,15 +16,8 @@ const result = await build({
entryPoints: [join(here, "src", "guest", "index.ts")], entryPoints: [join(here, "src", "guest", "index.ts")],
bundle: true, bundle: true,
write: false, write: false,
// A script, not a module: the host evaluates it with `evalCode`, and it
// announces itself by assigning `globalThis.__yaak_guest`.
format: "iife", format: "iife",
// Nothing here may reach for a Node built-in, and "browser" is the closest
// description of a target with globals and no filesystem. QuickJS itself has
// fewer globals than any browser, which is what `guest/globals.ts` is for.
platform: "browser", platform: "browser",
// QuickJS is ES2023-complete, so nothing needs downleveling. Keeping the
// source as written also keeps stack traces from the guest readable.
target: "es2022", target: "es2022",
minify: false, minify: false,
legalComments: "none", legalComments: "none",
File diff suppressed because one or more lines are too long
+7 -36
View File
@@ -1,17 +1,6 @@
/** /**
* The globals that exist inside the sandbox. * Everything a plugin can reach that isn't the language itself. The Rust host
* * must install this same list; see the README.
* QuickJS is the language and nothing else: it has `Promise`, `BigInt` and the
* ES2024 built-ins, and no `console`, no `setTimeout`, no `TextEncoder`. The
* platform globals a browser or Node would supply are not there because there
* is no platform — which is the point. What a plugin can reach is what this
* file installs, and every one of these has to exist identically on the Rust
* host too, so the list is kept short and boring on purpose.
*
* Two of them are implemented here in pure JavaScript rather than bridged to
* the host: the text codecs are twenty lines and a bridge would cost a copy
* each way for no gain. Timers cannot be — the sandbox has no event loop of its
* own — so those are the host's.
*/ */
declare const __yaak_log: (level: string, message: string) => void; declare const __yaak_log: (level: string, message: string) => void;
@@ -20,13 +9,7 @@ declare const __yaak_timer_cancel: (id: number) => void;
/* -------------------------------- console -------------------------------- */ /* -------------------------------- console -------------------------------- */
/** /** Formatted in here, so only strings cross the boundary. */
* Arguments as a line of text, formatted here rather than at the host.
*
* Only strings cross the boundary, so a plugin logging an object gets it
* serialized inside the sandbox, where its own prototypes still exist and a
* cycle is this function's problem rather than the host's.
*/
function formatArgs(args: unknown[]): string { function formatArgs(args: unknown[]): string {
return args return args
.map((arg) => { .map((arg) => {
@@ -68,14 +51,7 @@ function installConsole(): void {
/* --------------------------------- timers -------------------------------- */ /* --------------------------------- timers -------------------------------- */
/** /** QuickJS has no clock to wake on, so the host holds the real timer. */
* Timers, owned by the host.
*
* QuickJS has no clock to wake on: `executePendingJobs` drains microtasks and
* returns, so a `setTimeout` implemented in here would either never fire or
* spin. The host holds the real timer and calls back in, which also means a
* sandbox torn down mid-wait takes its pending timers with it.
*/
const timerCallbacks = new Map<number, () => void>(); const timerCallbacks = new Map<number, () => void>();
let nextTimerId = 1; let nextTimerId = 1;
@@ -94,14 +70,12 @@ function installTimers(): void {
__yaak_timer_cancel(id); __yaak_timer_cancel(id);
}; };
// Same contract, and deliberately not repeating: an interval is a timer that // An interval is a timer that rearms, and nothing in a plugin should poll.
// rearms, and nothing in a plugin should be polling anyway. A plugin that
// wants one can build it from `setTimeout`, visibly.
g.setInterval = undefined; g.setInterval = undefined;
g.clearInterval = undefined; g.clearInterval = undefined;
} }
/** Called by the host when a timer it is holding comes due. */ /** Called by the host when a timer comes due. */
function fireTimer(id: number): void { function fireTimer(id: number): void {
const callback = timerCallbacks.get(id); const callback = timerCallbacks.get(id);
timerCallbacks.delete(id); timerCallbacks.delete(id);
@@ -117,8 +91,7 @@ class SandboxTextEncoder {
const out: number[] = []; const out: number[] = [];
for (let i = 0; i < input.length; i++) { for (let i = 0; i < input.length; i++) {
let code = input.charCodeAt(i); let code = input.charCodeAt(i);
// A surrogate pair is one code point; a lone surrogate becomes U+FFFD, // A lone surrogate becomes U+FFFD, as the standard encoder does.
// which is what the standard encoder does rather than erroring.
if (code >= 0xd800 && code <= 0xdbff) { if (code >= 0xd800 && code <= 0xdbff) {
const next = input.charCodeAt(i + 1); const next = input.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) { if (next >= 0xdc00 && next <= 0xdfff) {
@@ -220,8 +193,6 @@ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function installBase64(): void { function installBase64(): void {
const g = globalThis as Record<string, unknown>; const g = globalThis as Record<string, unknown>;
// Latin-1 in, base64 out — the same narrow contract the browser's have, so a
// plugin that reaches for them behaves the same here as it does there.
g.btoa = (input: string): string => { g.btoa = (input: string): string => {
let out = ""; let out = "";
for (let i = 0; i < input.length; i += 3) { for (let i = 0; i < input.length; i += 3) {
+20 -58
View File
@@ -1,19 +1,10 @@
/** /**
* The runtime shell, as it exists inside the sandbox. * What QuickJS evaluates before any untrusted code does: install globals, load
* one module, answer events against it.
* *
* This is the whole of what QuickJS evaluates before any untrusted code does: * `load` takes source and `dispatch` takes an event, so what a module *is* — a
* it installs the globals, loads one module, and answers events against it. * plugin today, a workspace script later — is the host's decision, not this
* The Node runtime's `PluginInstance` does the same job on the other side of a * file's. See the README on why scripts never get a second runtime.
* WebSocket; the difference is that this one has no filesystem to load from and
* no host objects to reach for, so the module arrives as source text and every
* capability arrives as a reply.
*
* It is deliberately not plugin-shaped underneath. `load` takes source and
* `dispatch` takes an event: what a module *is* — a plugin today, a workspace
* script later — is decided by the payloads the host sends, not by this file.
* Scripts are the reason that matters. A plugin is installed, so someone
* consented to it; a script arrives inside a workspace, as data, with no such
* moment, which is why scripts will never get a runtime other than this one.
*/ */
import type { PluginDefinition } from "@yaakapp/api"; import type { PluginDefinition } from "@yaakapp/api";
@@ -45,18 +36,13 @@ declare const __yaak_call: (payloadJson: string) => Promise<string>;
const { fireTimer } = installGlobals(); const { fireTimer } = installGlobals();
/** The loaded module, and the id the host knows it by. */
let mod: PluginDefinition = {}; let mod: PluginDefinition = {};
let pluginRefId = ""; let pluginRefId = "";
/** /**
* Evaluate a module's source. * `require` exists only to fail, by name: a bundle that still calls it was not
* * built for this target, and naming the specifier beats an undefined that
* The bundles are CommonJS, so they are handed the three names that implies and * surfaces ten frames later.
* nothing else. `require` is the interesting one: it exists only to fail, by
* name, because a bundle that still calls it did not get bundled for this
* target and the honest outcome is a message saying which specifier is missing
* rather than an undefined that surfaces ten frames later.
*/ */
function load(source: string, refId: string): void { function load(source: string, refId: string): void {
const module: { exports: Record<string, unknown> } = { exports: {} }; const module: { exports: Record<string, unknown> } = { exports: {} };
@@ -67,10 +53,7 @@ function load(source: string, refId: string): void {
); );
}; };
// `new Function` rather than an ES module so the bundle's own top-level names // Isolation is the QuickJS context around this, not a lint rule.
// cannot collide with this shell's, and so the source can arrive as a string
// with no loader hook. Evaluating untrusted source is the entire job of this
// file; the isolation is the QuickJS context around it, not a lint rule.
// oxlint-disable-next-line no-implied-eval // oxlint-disable-next-line no-implied-eval
const factory = new Function("module", "exports", "require", source); const factory = new Function("module", "exports", "require", source);
factory(module, module.exports, require); factory(module, module.exports, require);
@@ -83,7 +66,6 @@ function load(source: string, refId: string): void {
pluginRefId = refId; pluginRefId = refId;
} }
/** Everything a module contributes, without the functions that implement it. */
function summary(): Record<string, unknown> { function summary(): Record<string, unknown> {
return { return {
templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name), templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),
@@ -102,31 +84,20 @@ function summary(): Record<string, unknown> {
const EMPTY: InternalEventPayload = { type: "empty_response" }; const EMPTY: InternalEventPayload = { type: "empty_response" };
/** /**
* Answer one event against the loaded module. * Every branch mirrors the Node runtime's: same payloads, so a plugin cannot
* * tell which runtime it is in. An unmatched event gets `empty_response` rather
* Every branch mirrors the Node runtime's, because the payloads are the same * than silence, so no caller waits forever.
* payloads — a plugin cannot tell which runtime it is in, and that is the
* promise the whole design exists to keep. An unmatched event gets
* `empty_response` rather than silence, so a caller never waits forever for a
* capability this module doesn't have.
*/ */
/** /**
* How a plugin reaches the world from in here: one JSON envelope out, one back. * No `stream` and no `form`: both need the host to hold a conversation open,
* * which this protocol deliberately does not. `openUrl` refuses and a prompt
* No `stream` and no `form`, and that is the honest shape rather than a * form is drawn once from its defaults, rather than quietly doing nothing.
* shortcut. Both need the host to hold a conversation open, which the sandbox
* protocol deliberately does not do — so `ctx.window.openUrl` refuses and a
* prompt form is drawn once from its defaults, instead of either quietly doing
* nothing.
*/ */
const transport: PluginTransport = { const transport: PluginTransport = {
async request(context, payload) { async request(context, payload) {
const replyJson = await __yaak_call( // The id rides along because one host handler serves every loaded module,
// The id rides along because the host multiplexes every loaded module // and a plugin's storage is namespaced by which plugin it is.
// through one handler, and a plugin's storage is namespaced by which const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
// plugin it is.
JSON.stringify({ pluginRefId, context, payload }),
);
const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string }; const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string };
if (reply.type === "error_response") { if (reply.type === "error_response") {
throw new Error(reply.error || `Host failed to handle ${payload.type}`); throw new Error(reply.error || `Host failed to handle ${payload.type}`);
@@ -318,7 +289,6 @@ async function dispatch(
return EMPTY; return EMPTY;
} }
/** The five action kinds, which differ only in which list they index into. */
async function callAction( async function callAction(
ctx: ReturnType<typeof createPluginContext>, ctx: ReturnType<typeof createPluginContext>,
payload: InternalEventPayload, payload: InternalEventPayload,
@@ -342,13 +312,7 @@ async function callAction(
} }
/**
* What the host can reach.
*
* Named on `globalThis` because the host calls them by evaluating an
* expression, and kept to four: load a module, ask what it has, send it an
* event, wake a timer.
*/
(globalThis as Record<string, unknown>).__yaak_guest = { (globalThis as Record<string, unknown>).__yaak_guest = {
load, load,
summary, summary,
@@ -361,9 +325,7 @@ async function callAction(
try { try {
return JSON.stringify(await dispatch(context, payload)); return JSON.stringify(await dispatch(context, payload));
} catch (err) { } catch (err) {
// A throw from inside a plugin is an answer, not a crash: the host turns // A throw from a plugin is an answer, not a crash.
// it into the same `error_response` the Node runtime sends, and whatever
// asked for this gets a message instead of a hang.
const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\s*/g, ""); const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\s*/g, "");
return JSON.stringify({ type: "error_response", error }); return JSON.stringify({ type: "error_response", error });
} }
+5 -77
View File
@@ -1,18 +1,6 @@
/** /**
* The sandbox host: QuickJS, and the four things that cross into it. * One runtime, one context per module. The engine choice and the limits below
* * are argued in this package's README, which is also the spec for the Rust host.
* One QuickJS runtime holds one context per loaded module. A context is the
* isolation boundary — its own globals, its own `Object`, its own prototypes —
* so two plugins cannot see or patch each other, and neither can reach the
* worker's own scope. Sharing a runtime between them is deliberate: the engine
* and its wasm instance are the expensive part, contexts are not.
*
* The engine is `quickjs-ng`, not Bellard's, and the sync variant rather than
* the ASYNCIFY one. Both choices are recorded in this package's README along
* with what they cost; the short version is that the Rust host has no choice
* (rquickjs vendors quickjs-ng and offers no alternative), and the sync build
* still gives the guest real `await` through a deferred promise, at half the
* size and twice the speed.
*/ */
import variant from "@jitl/quickjs-ng-wasmfile-release-sync"; import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
@@ -24,34 +12,11 @@ import {
} from "quickjs-emscripten-core"; } from "quickjs-emscripten-core";
import { GUEST_SOURCE } from "../generated/guest"; import { GUEST_SOURCE } from "../generated/guest";
/**
* What a module may allocate.
*
* Sized for the job rather than for comfort: an importer holding a large spec
* and the objects it parses into is the high-water mark, and a plugin that
* wants more than this is doing something a plugin should not. Hitting it
* throws inside the sandbox and unwinds as an ordinary error.
*/
const MEMORY_LIMIT_BYTES = 256 * 1024 * 1024; const MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
/** Deep recursion is a stack overflow inside the guest, not a crash of the worker. */
const STACK_SIZE_BYTES = 2 * 1024 * 1024; const STACK_SIZE_BYTES = 2 * 1024 * 1024;
/** /** Bounds synchronous execution only: a plugin awaiting the host is not looping. */
* How long a module may run without yielding.
*
* This bounds *synchronous* execution only, and it has to: a plugin awaiting
* the host is not looping, it is waiting for us. So the clock is set when a
* dispatch begins and pushed back whenever the guest hands control back, which
* makes it a watchdog for `while (true)` rather than a limit on how long real
* work may take.
*
* Generous, because it costs nothing to be: a plugin runs in its own worker,
* so one stuck here blocks no database command and no frame. It is sized off
* the slowest real work measured (`bench/import.mjs`: GitHub's 12 MB OpenAPI
* description takes about four seconds), with room for a document several
* times larger before a legitimate import looks like a hang.
*/
const SYNC_BUDGET_MS = 60_000; const SYNC_BUDGET_MS = 60_000;
export type HostRequestHandler = (envelopeJson: string) => Promise<string>; export type HostRequestHandler = (envelopeJson: string) => Promise<string>;
@@ -65,13 +30,10 @@ export interface SandboxLog {
let modulePromise: Promise<QuickJSWASMModule> | null = null; let modulePromise: Promise<QuickJSWASMModule> | null = null;
function quickjs(): Promise<QuickJSWASMModule> { function quickjs(): Promise<QuickJSWASMModule> {
// Loaded once per worker, on first use. The wasm is ~529 KB and there is no
// reason to pay for it in a session where nothing calls a plugin.
modulePromise ??= newQuickJSWASMModuleFromVariant(variant); modulePromise ??= newQuickJSWASMModuleFromVariant(variant);
return modulePromise; return modulePromise;
} }
/** One loaded module, and the context it lives in. */
class LoadedPlugin { class LoadedPlugin {
readonly pluginRefId: string; readonly pluginRefId: string;
readonly context: QuickJSContext; readonly context: QuickJSContext;
@@ -85,7 +47,6 @@ class LoadedPlugin {
this.context = context; this.context = context;
} }
/** Push the synchronous-execution deadline back; called whenever the guest yields. */
touch(): void { touch(): void {
if (this.deadline != null) this.deadline = Date.now() + SYNC_BUDGET_MS; if (this.deadline != null) this.deadline = Date.now() + SYNC_BUDGET_MS;
} }
@@ -125,13 +86,6 @@ export class PluginSandboxHost {
private readonly onLog: (log: SandboxLog) => void, private readonly onLog: (log: SandboxLog) => void,
) {} ) {}
/**
* Load one module's source under an id.
*
* Replaces whatever was loaded under that id, disposing it first, so a
* reload is a fresh context rather than a re-evaluation on top of the old
* one's globals.
*/
async load(pluginRefId: string, source: string): Promise<Record<string, unknown>> { async load(pluginRefId: string, source: string): Promise<Record<string, unknown>> {
const module = await quickjs(); const module = await quickjs();
@@ -139,8 +93,6 @@ export class PluginSandboxHost {
this.runtime = module.newRuntime(); this.runtime = module.newRuntime();
this.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES); this.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES);
this.runtime.setMaxStackSize(STACK_SIZE_BYTES); this.runtime.setMaxStackSize(STACK_SIZE_BYTES);
// One handler for every context on the runtime. A plugin that is merely
// waiting has no deadline set, so it is never interrupted.
this.runtime.setInterruptHandler(() => { this.runtime.setInterruptHandler(() => {
const now = Date.now(); const now = Date.now();
for (const plugin of this.plugins.values()) { for (const plugin of this.plugins.values()) {
@@ -176,7 +128,6 @@ export class PluginSandboxHost {
this.plugins.delete(pluginRefId); this.plugins.delete(pluginRefId);
} }
/** Send one event to one loaded module and wait for its reply payload. */
async dispatch(pluginRefId: string, envelopeJson: string): Promise<string> { async dispatch(pluginRefId: string, envelopeJson: string): Promise<string> {
const plugin = this.plugins.get(pluginRefId); const plugin = this.plugins.get(pluginRefId);
if (plugin == null) throw new Error(`No plugin loaded as \`${pluginRefId}\``); if (plugin == null) throw new Error(`No plugin loaded as \`${pluginRefId}\``);
@@ -213,8 +164,7 @@ export class PluginSandboxHost {
define("__yaak_timer_start", (idHandle, msHandle) => { define("__yaak_timer_start", (idHandle, msHandle) => {
const id = context.getNumber(idHandle); const id = context.getNumber(idHandle);
plugin.startTimer(id, context.getNumber(msHandle), () => { plugin.startTimer(id, context.getNumber(msHandle), () => {
// Waking a timer re-enters the guest, so it gets a fresh budget. plugin.touch();
plugin.touch();
this.callGuestSync(plugin, "fireTimer", [id]); this.callGuestSync(plugin, "fireTimer", [id]);
this.pump(plugin); this.pump(plugin);
}); });
@@ -224,14 +174,9 @@ export class PluginSandboxHost {
plugin.cancelTimer(context.getNumber(idHandle)); plugin.cancelTimer(context.getNumber(idHandle));
}); });
// The one door out. Everything a plugin does to the world arrives here as
// a JSON envelope and leaves as a JSON reply; the guest's whole `ctx` is
// built from this single function.
define("__yaak_call", (envelopeHandle) => { define("__yaak_call", (envelopeHandle) => {
const envelope = context.getString(envelopeHandle); const envelope = context.getString(envelopeHandle);
// While the host answers, the guest is suspended, not looping — so the
// watchdog stops until it comes back.
const wasWatching = plugin.deadline != null; const wasWatching = plugin.deadline != null;
plugin.deadline = null; plugin.deadline = null;
@@ -242,15 +187,11 @@ export class PluginSandboxHost {
}, },
(err: unknown) => { (err: unknown) => {
if (wasWatching) plugin.touch(); if (wasWatching) plugin.touch();
// Rejections come back as an error the guest can catch, which is
// what a host that cannot answer should look like from inside.
return context.newError(err instanceof Error ? err.message : String(err)); return context.newError(err instanceof Error ? err.message : String(err));
}, },
); );
const deferred = context.newPromise(settle); const deferred = context.newPromise(settle);
// Resolving a promise only queues its reactions; something has to run
// them, and inside a sandbox that something is us.
void deferred.settled.then(() => { void deferred.settled.then(() => {
this.pump(plugin); this.pump(plugin);
deferred.dispose(); deferred.dispose();
@@ -259,7 +200,6 @@ export class PluginSandboxHost {
}); });
} }
/** Drain the guest's microtask queue. */
private pump(plugin: LoadedPlugin): void { private pump(plugin: LoadedPlugin): void {
const result = this.runtime?.executePendingJobs(); const result = this.runtime?.executePendingJobs();
if (result?.error != null) { if (result?.error != null) {
@@ -286,7 +226,6 @@ export class PluginSandboxHost {
} }
} }
/** Call `__yaak_guest.<method>(...args)`, awaiting the result if it is a promise. */
private async callGuest( private async callGuest(
plugin: LoadedPlugin, plugin: LoadedPlugin,
method: string, method: string,
@@ -311,8 +250,6 @@ export class PluginSandboxHost {
const value = called.value; const value = called.value;
const state = context.getPromiseState(value); const state = context.getPromiseState(value);
if (state.type !== "fulfilled" || state.notAPromise !== true) { if (state.type !== "fulfilled" || state.notAPromise !== true) {
// A promise: hand control back so the guest can make progress, then
// wait for it on this side.
const resolved = context.resolvePromise(value); const resolved = context.resolvePromise(value);
value.dispose(); value.dispose();
this.pump(plugin); this.pump(plugin);
@@ -332,7 +269,6 @@ export class PluginSandboxHost {
} }
} }
/** The timer path: fire and forget, because nothing is waiting on it. */
private callGuestSync(plugin: LoadedPlugin, method: string, args: number[]): void { private callGuestSync(plugin: LoadedPlugin, method: string, args: number[]): void {
const { context } = plugin; const { context } = plugin;
const guest = context.getProp(context.global, "__yaak_guest"); const guest = context.getProp(context.global, "__yaak_guest");
@@ -356,13 +292,6 @@ export class PluginSandboxHost {
} }
} }
/**
* A dumped QuickJS error as a host `Error`.
*
* The guest's stack is kept in the message: it names lines in the plugin's
* own bundle, which is the only stack that means anything to whoever wrote
* it — the worker's own stack would just say "sandbox.ts".
*/
private toError(plugin: LoadedPlugin, dumped: unknown): Error { private toError(plugin: LoadedPlugin, dumped: unknown): Error {
if (dumped != null && typeof dumped === "object") { if (dumped != null && typeof dumped === "object") {
const { message, name, stack } = dumped as Record<string, string | undefined>; const { message, name, stack } = dumped as Record<string, string | undefined>;
@@ -371,8 +300,7 @@ export class PluginSandboxHost {
if (stack != null) error.stack = `${name ?? "Error"}: ${message ?? ""}\n${stack}`; if (stack != null) error.stack = `${name ?? "Error"}: ${message ?? ""}\n${stack}`;
return error; return error;
} }
// An interrupted plugin surfaces as `null` with no error object at all, // An interrupted plugin surfaces as `null` with no error object.
// which would otherwise read as a mysterious empty failure.
if (dumped == null) { if (dumped == null) {
return new Error( return new Error(
`Plugin \`${plugin.pluginRefId}\` was stopped after running for ` + `Plugin \`${plugin.pluginRefId}\` was stopped after running for ` +
+6 -22
View File
@@ -1,18 +1,12 @@
/** /**
* A tab's handle on its sandbox. * A tab's handle on its sandbox. `onHostRequest` is the entire answer to "what
* * can a plugin do here?", and this package deliberately has no opinion on it.
* Owns the worker, keeps track of what is loaded in it, and turns the two
* message flows into promises. The interesting half is `onHostRequest`: the
* caller supplies it, and it is the entire answer to "what can a plugin do
* here?" — this package deliberately has no idea. A browser host answers those
* against a wasm database and a send proxy; something else could answer them
* differently; a host that answers nothing still runs plugins that only compute.
*/ */
import type { FromSandbox, ToSandbox } from "./protocol"; import type { FromSandbox, ToSandbox } from "./protocol";
/** Answers one `ctx` call. Gets the JSON envelope, returns the JSON reply. */ /** Answers one `ctx` call: JSON envelope in, JSON reply out. */
export type HostRequestHandler = (envelope: string) => Promise<string>; export type HostRequestHandler = (envelope: string) => Promise<string>;
export interface PluginSandboxOptions { export interface PluginSandboxOptions {
@@ -20,7 +14,6 @@ export interface PluginSandboxOptions {
onLog?: (log: { pluginRefId: string; level: string; message: string }) => void; onLog?: (log: { pluginRefId: string; level: string; message: string }) => void;
} }
/** What a module turned out to contribute, as reported after loading. */
export interface PluginSummary { export interface PluginSummary {
templateFunctions: string[]; templateFunctions: string[];
authentication: string | null; authentication: string | null;
@@ -45,9 +38,8 @@ export class PluginSandbox {
constructor(options: PluginSandboxOptions) { constructor(options: PluginSandboxOptions) {
this.options = options; this.options = options;
// `new URL("./worker.ts", import.meta.url)` is written inline because that // Written inline because that exact syntax is what the bundler
// exact syntax is what the bundler pattern-matches to know it must bundle a // pattern-matches; hoisted into a variable it ships as raw TypeScript.
// worker entry. Hoisted into a variable it ships as raw TypeScript.
this.worker = new Worker(new URL("./worker.ts", import.meta.url), { this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module", type: "module",
name: "yaak-plugins", name: "yaak-plugins",
@@ -56,7 +48,6 @@ export class PluginSandbox {
this.worker.onerror = () => this.failEverything("The plugin sandbox failed to start"); this.worker.onerror = () => this.failEverything("The plugin sandbox failed to start");
} }
/** Load a module's source under an id, replacing anything already there. */
load(pluginRefId: string, source: string): Promise<PluginSummary> { load(pluginRefId: string, source: string): Promise<PluginSummary> {
return this.request<PluginSummary>((id) => ({ type: "load", id, pluginRefId, source })); return this.request<PluginSummary>((id) => ({ type: "load", id, pluginRefId, source }));
} }
@@ -65,7 +56,6 @@ export class PluginSandbox {
return this.request<void>((id) => ({ type: "unload", id, pluginRefId })); return this.request<void>((id) => ({ type: "unload", id, pluginRefId }));
} }
/** Send one event to one loaded module; resolves with its reply payload. */
async dispatch<T>( async dispatch<T>(
pluginRefId: string, pluginRefId: string,
context: unknown, context: unknown,
@@ -85,13 +75,7 @@ export class PluginSandbox {
return parsed as T & { type: string }; return parsed as T & { type: string };
} }
/** /** `terminate()`, not a polite shutdown: the reason to call this is a plugin that won't stop. */
* End the sandbox now.
*
* `terminate()` rather than a polite shutdown, on purpose: the reason to
* reach for this is a plugin that will not stop, and asking it to stop is
* exactly what does not work then.
*/
dispose(): void { dispose(): void {
this.worker.terminate(); this.worker.terminate();
this.failEverything("The plugin sandbox was shut down"); this.failEverything("The plugin sandbox was shut down");
+3 -9
View File
@@ -1,13 +1,7 @@
/** /**
* The messages between a tab and its sandbox worker. * Two request/reply flows in opposite directions. Payloads are JSON strings
* * rather than objects because they must be strings to cross into QuickJS
* Two request/reply flows in opposite directions. The tab asks the worker to * anyway, so structured-cloning them first would only be undone.
* load a module or send it an event; the worker asks the tab to answer a
* plugin's `ctx` call, because the tab is the only side with a database, a
* network and a user. Both carry payloads as JSON strings rather than objects:
* they have to be strings to cross into QuickJS anyway, and serializing once at
* the edge is cheaper than structured-cloning an object the worker will only
* stringify again.
*/ */
/** Tab → worker */ /** Tab → worker */
+2 -16
View File
@@ -1,21 +1,8 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
/** /**
* The worker plugins run in. * A dedicated worker owned by the tab, deliberately not the SharedWorker that
* * owns the database. Reasons and the cost are in the README.
* A dedicated worker, owned by the tab that made it — deliberately not the
* SharedWorker that owns the database, for three reasons. Plugin work is slow
* by design (see `bench/import.mjs`) and the database worker answers every
* tab's commands synchronously, so a large import in there would stall every
* other tab's reads. A plugin that never returns can be ended with
* `terminate()`, which is not something you can do to the worker holding the
* database. And the capabilities a plugin actually asks for — a prompt, a
* toast, the active request — belong to a tab rather than to a database, so
* routing through the tab is the shorter path anyway, not a detour.
*
* That leaves the database one hop further away than it would otherwise be:
* `ctx.store` goes worker → tab → database worker. It is a message either way,
* and this direction is the one where a stuck plugin costs nothing.
*/ */
import { PluginSandboxHost } from "./host/sandbox"; import { PluginSandboxHost } from "./host/sandbox";
@@ -27,7 +14,6 @@ function send(message: FromSandbox): void {
scope.postMessage(message); scope.postMessage(message);
} }
/** Host calls waiting on the tab, by id. */
const pendingHostCalls = new Map<number, (reply: string | Error) => void>(); const pendingHostCalls = new Map<number, (reply: string | Error) => void>();
let nextHostCallId = 1; let nextHostCallId = 1;
+2 -21
View File
@@ -1,16 +1,6 @@
/** /**
* Bundle plugins for the sandbox runtime.
*
* A stand-in for `yaakcli build --target sandbox`, which does not exist yet. * A stand-in for `yaakcli build --target sandbox`, which does not exist yet.
* The difference from the Node target is small and entirely in the resolver: * What the CLI would need instead is at the bottom of this file.
* nothing may resolve to a Node built-in, because the sandbox has none — see
* `packages/plugin-sandbox/README.md` for the full contract. Bundling here
* rather than in the CLI keeps the CLI out of this slice; what the CLI would
* need is written down at the bottom of this file.
*
* Output is a generated TypeScript module holding each bundle as a string,
* which is how the browser host ships them today. That is the part most
* obviously temporary: see the note at the bottom.
*/ */
import { build } from "esbuild"; import { build } from "esbuild";
@@ -20,16 +10,9 @@ import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** /** Three, not the corpus: one template function, one importer, one auth method. */
* The plugins the browser tier ships.
*
* Three, not the whole corpus: this slice is about the runtime existing and
* being proven, and each of these proves a different path through it — a
* template function, an importer, an authentication method.
*/
const PLUGINS = ["template-function-timestamp", "importer-curl", "auth-bearer"]; const PLUGINS = ["template-function-timestamp", "importer-curl", "auth-bearer"];
/** Refuse Node built-ins loudly at build time rather than at first call. */
const noNodeBuiltins = { const noNodeBuiltins = {
name: "no-node-builtins", name: "no-node-builtins",
setup(build) { setup(build) {
@@ -50,8 +33,6 @@ export async function bundlePlugin(name, { dir = join(root, "plugins", name) } =
entryPoints: [join(dir, "src", "index.ts")], entryPoints: [join(dir, "src", "index.ts")],
bundle: true, bundle: true,
write: false, write: false,
// CommonJS because that is what the shell evaluates: a `new Function` with
// `module`, `exports` and a `require` that only throws.
format: "cjs", format: "cjs",
platform: "browser", platform: "browser",
target: "es2022", target: "es2022",