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;
/// `Send`, except where nothing can be.
///
/// Rendering a template function is a host call, and on a host with one thread
/// it is a call into JavaScript: the future holds a `JsFuture` and the callback
/// 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.
/// `Send`, except on wasm32, where a template function is a call into
/// JavaScript: the future holds a `JsFuture` and the callback an `Rc` pool,
/// neither of which can be `Send`. Every other host spawns rendering onto a
/// thread pool and needs the bound.
#[cfg(not(target_arch = "wasm32"))]
pub trait MaybeSend: Send {}
#[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>;
/**
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the Yaak server.
* Resolve and render a request for sending, exactly as the desktop does: the environment
* chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
* a socket.
*
* `plugins` is the template function bridge a JavaScript function taking a name and its
* JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes
* every template function a refusal naming it.
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
* resolving to the rendered string. Passing nothing is allowed.
*
* Authentication is *not* applied here even though it is part of preparing a send. It is
* applied to the rendered request by the caller, because the plugin that applies it wants to
* see the request as it will be sent, and the caller is the side that knows that.
* Authentication is applied by the caller, not here, because the plugin that applies it
* needs to see the request as it will be sent.
*/
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, for the same callers: the value previews
* 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.
* 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
* mistake.
*/
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
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the send proxy.
* Resolve and render a request for sending, exactly as the desktop does: the environment
* chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
* a socket.
*
* `plugins` is the template function bridge a JavaScript function taking a name and its
* JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes
* every template function a refusal naming it.
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
* resolving to the rendered string. Passing nothing is allowed.
*
* Authentication is *not* applied here even though it is part of preparing a send. It is
* applied to the rendered request by the caller, because the plugin that applies it wants to
* see the request as it will be sent, and the caller is the side that knows that.
* Authentication is applied by the caller, not here, because the plugin that applies it
* needs to see the request as it will be sent.
* @param {any} payload
* @param {any} plugins
* @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, for the same callers: the value previews
* 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.
* 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
* mistake.
* @param {any} payload
* @param {any} plugins
* @returns {Promise<any>}
Binary file not shown.
+17 -34
View File
@@ -423,9 +423,8 @@ fn dispatch(
to_json(())
}
// A plugin's own storage, namespaced by plugin name exactly as the desktop namespaces
// it (`build_shared_reply` in crates/yaak/src/plugin_events.rs), so a plugin that keeps
// a token here finds it under the same key on either host.
// Namespaced by plugin name exactly as `build_shared_reply` does in
// crates/yaak/src/plugin_events.rs, so a token is found under the same key on either host.
"web_plugin_kv_get" => {
let req: PluginKeyValueReq = from_js(payload)?;
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
/// rendered. What the proxy sends, and what the response records as its request.
request: HttpRequest,
/// Identifies whichever model the authentication was inherited from, hashed the way the
/// desktop hashes it. Plugins key their stored state on it — an OAuth token cache belongs
/// to the folder that declared the auth, not to each request under it.
/// Whichever model the auth was inherited from, hashed as the desktop hashes it. An
/// OAuth token cache belongs to the folder that declared the auth, not to each request.
auth_context_id: String,
settings: HttpSendSettings,
/// 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>,
}
/// A template callback that calls a template function wherever the caller keeps them.
///
/// The desktop's equivalent (`PluginTemplateCallback`) reaches a plugin process; this one
/// 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.
/// 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
/// rather than an empty string sent in its place.
struct JsTemplateCallback {
call: Option<js_sys::Function>,
}
@@ -507,7 +499,6 @@ impl TemplateCallback for JsTemplateCallback {
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> 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 fn_name = fn_name.to_string();
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 {
if let Some(text) = value.as_string() {
return text;
@@ -556,23 +546,19 @@ fn js_message(value: &JsValue) -> String {
message.unwrap_or_else(|| format!("{value:?}"))
}
/// The template function bridge, or none when the caller passed nothing.
fn template_callback(plugins: JsValue) -> JsTemplateCallback {
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
/// request on the network: the environment chain, inherited headers and auth, request
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
/// posts to the Yaak server.
/// Resolve and render a request for sending, exactly as the desktop does: the environment
/// chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
/// a socket.
///
/// `plugins` is the template function bridge a JavaScript function taking a name and its
/// JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes
/// every template function a refusal naming it.
/// `plugins` is the template function bridge: a JS function taking a name and JSON args,
/// resolving to the rendered string. Passing nothing is allowed.
///
/// Authentication is *not* applied here even though it is part of preparing a send. It is
/// applied to the rendered request by the caller, because the plugin that applies it wants to
/// see the request as it will be sent, and the caller is the side that knows that.
/// Authentication is applied by the caller, not here, because the plugin that applies it
/// needs to see the request as it will be sent.
#[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
let req: PrepareHttpSendReq = from_js(payload)?;
@@ -631,12 +617,9 @@ struct RenderTemplateReq {
ignore_error: Option<bool>,
}
/// Render one template string against an environment chain.
///
/// What `cmd_render_template` does on the desktop, for the same callers: the value previews
/// 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.
/// 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
/// mistake.
#[wasm_bindgen]
pub async fn render_template(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
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
* QuickJS sandbox over a message port — and a third will when the sandbox is
* 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.
* `stream` and `form` are optional because they are the two places a host
* genuinely differs: both need a conversation rather than one reply.
*/
import type {
@@ -53,19 +47,14 @@ import { createResponseBody, decodeBase64Chunk } from "./responseBody";
import { applyFormInputDefaults } from "./templateFunction";
export interface PluginTransport {
/** One request out, one reply back. Rejects if the host couldn't answer. */
request(
context: PluginContext,
payload: InternalEventPayload,
): Promise<Record<string, unknown>>;
/** Send with no reply expected. */
notify(context: PluginContext, payload: InternalEventPayload): void;
/**
* Send once and keep receiving. Used by windows, which report navigation
* until they close. Absent where a host has no windows to open.
*/
/** Send once, keep receiving. Windows report navigation until they close. */
stream?(
context: PluginContext,
payload: InternalEventPayload,
@@ -73,11 +62,8 @@ export interface PluginTransport {
): void;
/**
* Show a form that may re-render before it settles.
*
* `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.
* 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.
*/
form?(
context: PluginContext,
@@ -88,14 +74,7 @@ export interface PluginTransport {
): Promise<PromptFormResponse>;
}
/**
* 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.
*/
/** `bodyPath` names a file on a host's disk; plugins address bodies by id. */
function forPlugin(httpResponse: HttpResponse): HttpResponse {
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
bodyPath?: string | null;
@@ -110,7 +89,6 @@ export function createPluginContext(
const send = <T>(payload: InternalEventPayload): 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 bodyInfo = () =>
send<GetHttpResponseBodyInfoResponse>({
@@ -191,9 +169,8 @@ export function createPluginContext(
return reply.value;
},
form: async (args) => {
// Inputs may compute themselves from the values entered so far, and a
// function cannot cross to a host — so they are resolved against the
// defaults before the form is drawn, then stripped.
// Inputs may compute from the values entered so far, and a function
// cannot cross to a host.
const resolve = async (values: Record<string, unknown>) => {
const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs;
const resolved = await applyDynamicFormInput(
@@ -217,8 +194,7 @@ export function createPluginContext(
}
const reply = await transport.form(context, payload, async (values) => {
// Fired on mount before any interaction, when there is nothing to
// recompute from.
// Fired on mount, before there is anything to recompute from.
if (values == null || Object.keys(values).length === 0) return null;
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
// the only copy of its body. A saved one is read back from the host like
// any other. Callers get the same thing either way.
// the only copy of its body.
if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
}
@@ -366,10 +341,6 @@ export function createPluginContext(
},
},
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) => {
const result = await send<TemplateRenderResponse>({
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 {
CallPromptFormDynamicArgs,
Context,
@@ -86,12 +76,7 @@ export async function applyDynamicFormInput(
return resolvedArgs;
}
/**
* 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.
*/
/** What a host receives has to be data all the way down. */
export function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
return inputs.map((input) => {
// 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. */
private heard = false;
/**
* 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.
*/
/** Unset until the sandbox is up; a render before then gets a refusal. */
private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null;
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 {
this.templateFunctions = handler;
}
+1 -3
View File
@@ -166,9 +166,7 @@ export function createWebPlatform(): Platform {
const plugins = new WebPlugins(db);
const capabilities = capabilitiesFor();
// Rendering happens in the worker and template functions live in the sandbox,
// 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.
// Registered before anything can render, not inside the first send.
db.setTemplateFunctionHandler((name, args) => plugins.callTemplateFunction(name, args));
// 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.
*
* Two jobs. Outward: keep a sandbox, load the bundled plugins into it, and know
* 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.
* 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
* the whole of what a plugin can do to the world here.
*/
import { PluginSandbox, type PluginSummary } from "@yaakapp-internal/plugin-sandbox";
@@ -28,7 +18,6 @@ import type {
import type { WorkerConnection } from "./connection";
import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated";
/** What a plugin's own storage is keyed under, matching the desktop's namespacing. */
type KeyValueRequest = { key: string };
export interface AppliedAuthentication {
@@ -41,7 +30,6 @@ export class WebPlugins {
private sandbox: PluginSandbox | null = null;
private loading: Promise<void> | null = null;
/** Loaded plugin ids, by what they contribute. */
private readonly byTemplateFunction = new Map<string, string>();
private readonly byAuthName = new Map<string, 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
* that never touches a plugin never pays for QuickJS — and so a tab that
* cannot start the worker still boots the app, with plugin-shaped features
* failing individually instead of the page failing entirely.
* that never touches a plugin never pays for QuickJS.
*/
ready(): Promise<void> {
this.loading ??= this.start();
@@ -68,16 +52,13 @@ export class WebPlugins {
const sandbox = new PluginSandbox({
onHostRequest: (envelope) => this.hostRequest(envelope),
onLog: ({ pluginRefId, level, message }) => {
// Prefixed, because otherwise a plugin's console output is
// indistinguishable from the app's own and blames the wrong code.
// Prefixed, or a plugin's console output blames the app's own code.
const write = level === "error" ? console.error : console.log;
write(`[plugin ${pluginRefId}] ${message}`);
},
});
this.sandbox = sandbox;
// In parallel: each is an independent QuickJS context and none of them
// observes the others.
await Promise.all(
SANDBOX_PLUGINS.map(async ({ name, source }) => {
try {
@@ -106,13 +87,7 @@ export class WebPlugins {
return this.gather("get_http_authentication_summary_request", this.byAuthName.values());
}
/**
* 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.
*/
/** One broken plugin must not empty the picker for the others. */
private async gather<T>(type: string, ids: Iterable<string>): Promise<T[]> {
const replies = await Promise.all(
Array.from(ids).map(async (id): Promise<{ type: string } | null> => {
@@ -146,13 +121,9 @@ export class WebPlugins {
}
/**
* Run one template function.
*
* This is what the engine's render calls back into, so its contract is the
* 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.
* 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
* token is worse than one that refuses to be sent.
*/
async callTemplateFunction(name: string, argsJson: string): Promise<string> {
await this.ready();
@@ -204,13 +175,7 @@ export class WebPlugins {
} 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(
authName: string,
request: {
@@ -235,13 +200,7 @@ export class WebPlugins {
} as InternalEventPayload);
}
/**
* 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.
*/
/** First importer that recognizes the text wins, as `import_data` decides too. */
async import(content: string): Promise<ImportResources | null> {
await this.ready();
for (const id of this.importers) {
@@ -269,26 +228,16 @@ export class WebPlugins {
}
/**
* The context a plugin sees.
*
* `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.
* `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.
*/
private context(): PluginContext {
return { id: "web", label: null, workspaceId: null };
}
/**
* Answer one `ctx` call.
*
* 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.
* Every addition here 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> {
const { pluginRefId, payload } = JSON.parse(envelope) as {
@@ -299,7 +248,6 @@ export class WebPlugins {
const reply = async (): Promise<InternalEventPayload> => {
switch (payload.type) {
/* A plugin's own storage, namespaced by plugin in the database. */
case "get_key_value_request": {
const value = await this.db.rpc<string | null>("web_plugin_kv_get", {
pluginName: pluginRefId,
@@ -324,7 +272,6 @@ export class WebPlugins {
return { type: "delete_key_value_response", deleted } as InternalEventPayload;
}
/* A message for the user, delivered where every other one is. */
case "show_toast_request": {
const { type: _type, ...toast } = payload;
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.
*/
| { type: "prepare_http_send"; id: number; payload: unknown }
/**
* 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.
*/
/** Async for the same reason `prepare_http_send` is: it can call a plugin. */
| { type: "render_template"; id: number; payload: unknown }
/** The tab's answer to a `template_function` call. */
| {
@@ -51,14 +48,7 @@ export type FromWorker =
| { type: "error"; id: number; message: string }
/** A backend event for the app — today only `model_writes`. Sent to every port. */
| { type: "event"; event: string; payload: unknown }
/**
* 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.
*/
/** The one message that runs the other way: the engine asking for a plugin. */
| { type: "template_function"; id: number; name: string; args: string };
/** 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. */
interface PreparedHttpSend {
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;
settings: HttpSendSettings;
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
* rendering and after building the sendable form of it. Here the sendable form
* is built by the proxy, so the plugin's answer is applied to the model instead
* — 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.
* Not the same for one that *signs*, since the plugin sees the request before
* the proxy assembles it. AWS SigV4 and OAuth 1.0 are refused rather than
* mis-signed; see the sandbox README.
*/
async function applyAuthentication(
plugins: WebPlugins,
@@ -90,17 +83,14 @@ async function applyAuthentication(
method: request.method,
url: request.url,
headers: request.headers.filter((h) => h.enabled !== false),
// The desktop passes the body so signing schemes can hash it. This host
// does not have it in bytes at this point, and the schemes that would use
// it are the ones already refused.
// Only signing schemes hash the body, and those are already refused.
body: null,
});
const headers = [...request.headers];
for (const header of applied.setHeaders ?? []) {
// Replace-or-append, case-insensitively, matching `insert_header` in
// crates/yaak-http: a plugin setting Authorization must not end up with the
// request's own Authorization also on the wire.
// crates/yaak-http.
const at = headers.findIndex((h) => h.name.toLowerCase() === header.name.toLowerCase());
const entry = { name: header.name, value: header.value, enabled: true };
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 is the engine's, and the engine is here. The functions it calls
* 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.
* 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
* only that tab is waiting and only its sandbox has those plugins.
*/
const pendingTemplateFunctions = new Map<number, (result: string | Error) => void>();
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]
*/
@@ -34,10 +27,7 @@ const spec = readFileSync(specPath, "utf8");
console.log(`Spec: ${specPath} (${(spec.length / 1024 / 1024).toFixed(1)} MB)`);
console.log(`Iterations: ${iterations}\n`);
/** The sandbox host, bundled for Node so this script can drive it directly. */
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");
mkdirSync(outDir, { recursive: true });
const outfile = join(outDir, "host.mjs");
@@ -69,9 +59,8 @@ function report(label, times, resourceCount) {
`best ${min.toFixed(0).padStart(5)} ms ` +
`median ${median.toFixed(0).padStart(5)} ms (${resourceCount} requests)`,
);
// Every run, because the spread is the point: V8 compiles this workload
// across the first few passes and QuickJS, which does not compile at all,
// does not. Quoting one ratio would pick a winner by choosing when to look.
// The spread is the point: V8 compiles this across the first few passes and
// QuickJS does not compile at all.
console.log(`${" ".repeat(20)} runs: ${times.map((t) => t.toFixed(0)).join(", ")} ms`);
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 runs inside QuickJS, which has no module loader and no filesystem,
* 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.
* 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 wasm packages, so a checkout builds without this having run.
*/
import { build } from "esbuild";
@@ -23,15 +16,8 @@ const result = await build({
entryPoints: [join(here, "src", "guest", "index.ts")],
bundle: true,
write: false,
// A script, not a module: the host evaluates it with `evalCode`, and it
// announces itself by assigning `globalThis.__yaak_guest`.
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",
// QuickJS is ES2023-complete, so nothing needs downleveling. Keeping the
// source as written also keeps stack traces from the guest readable.
target: "es2022",
minify: false,
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.
*
* 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.
* Everything a plugin can reach that isn't the language itself. The Rust host
* must install this same list; see the README.
*/
declare const __yaak_log: (level: string, message: string) => void;
@@ -20,13 +9,7 @@ declare const __yaak_timer_cancel: (id: number) => void;
/* -------------------------------- console -------------------------------- */
/**
* 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.
*/
/** Formatted in here, so only strings cross the boundary. */
function formatArgs(args: unknown[]): string {
return args
.map((arg) => {
@@ -68,14 +51,7 @@ function installConsole(): void {
/* --------------------------------- timers -------------------------------- */
/**
* 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.
*/
/** QuickJS has no clock to wake on, so the host holds the real timer. */
const timerCallbacks = new Map<number, () => void>();
let nextTimerId = 1;
@@ -94,14 +70,12 @@ function installTimers(): void {
__yaak_timer_cancel(id);
};
// Same contract, and deliberately not repeating: an interval is a timer that
// rearms, and nothing in a plugin should be polling anyway. A plugin that
// wants one can build it from `setTimeout`, visibly.
// An interval is a timer that rearms, and nothing in a plugin should poll.
g.setInterval = 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 {
const callback = timerCallbacks.get(id);
timerCallbacks.delete(id);
@@ -117,8 +91,7 @@ class SandboxTextEncoder {
const out: number[] = [];
for (let i = 0; i < input.length; i++) {
let code = input.charCodeAt(i);
// A surrogate pair is one code point; a lone surrogate becomes U+FFFD,
// which is what the standard encoder does rather than erroring.
// A lone surrogate becomes U+FFFD, as the standard encoder does.
if (code >= 0xd800 && code <= 0xdbff) {
const next = input.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
@@ -220,8 +193,6 @@ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function installBase64(): void {
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 => {
let out = "";
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:
* it installs the globals, loads one module, and answers events against it.
* The Node runtime's `PluginInstance` does the same job on the other side of a
* 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.
* `load` takes source and `dispatch` takes an event, so what a module *is* — a
* plugin today, a workspace script later — is the host's decision, not this
* file's. See the README on why scripts never get a second runtime.
*/
import type { PluginDefinition } from "@yaakapp/api";
@@ -45,18 +36,13 @@ declare const __yaak_call: (payloadJson: string) => Promise<string>;
const { fireTimer } = installGlobals();
/** The loaded module, and the id the host knows it by. */
let mod: PluginDefinition = {};
let pluginRefId = "";
/**
* Evaluate a module's source.
*
* The bundles are CommonJS, so they are handed the three names that implies and
* 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.
* `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
* surfaces ten frames later.
*/
function load(source: string, refId: string): void {
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
// 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.
// Isolation is the QuickJS context around this, not a lint rule.
// oxlint-disable-next-line no-implied-eval
const factory = new Function("module", "exports", "require", source);
factory(module, module.exports, require);
@@ -83,7 +66,6 @@ function load(source: string, refId: string): void {
pluginRefId = refId;
}
/** Everything a module contributes, without the functions that implement it. */
function summary(): Record<string, unknown> {
return {
templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),
@@ -102,31 +84,20 @@ function summary(): Record<string, unknown> {
const EMPTY: InternalEventPayload = { type: "empty_response" };
/**
* Answer one event against the loaded module.
*
* Every branch mirrors the Node runtime's, because the payloads are the same
* 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.
* 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
* than silence, so no caller waits forever.
*/
/**
* How a plugin reaches the world from in here: one JSON envelope out, one back.
*
* No `stream` and no `form`, and that is the honest shape rather than a
* 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.
* 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
* form is drawn once from its defaults, rather than quietly doing nothing.
*/
const transport: PluginTransport = {
async request(context, payload) {
const replyJson = await __yaak_call(
// The id rides along because the host multiplexes every loaded module
// through one handler, and a plugin's storage is namespaced by which
// plugin it is.
JSON.stringify({ pluginRefId, context, payload }),
);
// The id rides along because one host handler serves every loaded module,
// and a plugin's storage is namespaced by which plugin it is.
const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string };
if (reply.type === "error_response") {
throw new Error(reply.error || `Host failed to handle ${payload.type}`);
@@ -318,7 +289,6 @@ async function dispatch(
return EMPTY;
}
/** The five action kinds, which differ only in which list they index into. */
async function callAction(
ctx: ReturnType<typeof createPluginContext>,
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 = {
load,
summary,
@@ -361,9 +325,7 @@ async function callAction(
try {
return JSON.stringify(await dispatch(context, payload));
} catch (err) {
// A throw from inside a plugin is an answer, not a crash: the host turns
// it into the same `error_response` the Node runtime sends, and whatever
// asked for this gets a message instead of a hang.
// A throw from a plugin is an answer, not a crash.
const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\s*/g, "");
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 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.
* 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.
*/
import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
@@ -24,34 +12,11 @@ import {
} from "quickjs-emscripten-core";
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;
/** Deep recursion is a stack overflow inside the guest, not a crash of the worker. */
const STACK_SIZE_BYTES = 2 * 1024 * 1024;
/**
* 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.
*/
/** Bounds synchronous execution only: a plugin awaiting the host is not looping. */
const SYNC_BUDGET_MS = 60_000;
export type HostRequestHandler = (envelopeJson: string) => Promise<string>;
@@ -65,13 +30,10 @@ export interface SandboxLog {
let modulePromise: Promise<QuickJSWASMModule> | null = null;
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);
return modulePromise;
}
/** One loaded module, and the context it lives in. */
class LoadedPlugin {
readonly pluginRefId: string;
readonly context: QuickJSContext;
@@ -85,7 +47,6 @@ class LoadedPlugin {
this.context = context;
}
/** Push the synchronous-execution deadline back; called whenever the guest yields. */
touch(): void {
if (this.deadline != null) this.deadline = Date.now() + SYNC_BUDGET_MS;
}
@@ -125,13 +86,6 @@ export class PluginSandboxHost {
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>> {
const module = await quickjs();
@@ -139,8 +93,6 @@ export class PluginSandboxHost {
this.runtime = module.newRuntime();
this.runtime.setMemoryLimit(MEMORY_LIMIT_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(() => {
const now = Date.now();
for (const plugin of this.plugins.values()) {
@@ -176,7 +128,6 @@ export class PluginSandboxHost {
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> {
const plugin = this.plugins.get(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) => {
const id = context.getNumber(idHandle);
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.pump(plugin);
});
@@ -224,14 +174,9 @@ export class PluginSandboxHost {
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) => {
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;
plugin.deadline = null;
@@ -242,15 +187,11 @@ export class PluginSandboxHost {
},
(err: unknown) => {
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));
},
);
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(() => {
this.pump(plugin);
deferred.dispose();
@@ -259,7 +200,6 @@ export class PluginSandboxHost {
});
}
/** Drain the guest's microtask queue. */
private pump(plugin: LoadedPlugin): void {
const result = this.runtime?.executePendingJobs();
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(
plugin: LoadedPlugin,
method: string,
@@ -311,8 +250,6 @@ export class PluginSandboxHost {
const value = called.value;
const state = context.getPromiseState(value);
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);
value.dispose();
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 {
const { context } = plugin;
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 {
if (dumped != null && typeof dumped === "object") {
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}`;
return error;
}
// An interrupted plugin surfaces as `null` with no error object at all,
// which would otherwise read as a mysterious empty failure.
// An interrupted plugin surfaces as `null` with no error object.
if (dumped == null) {
return new Error(
`Plugin \`${plugin.pluginRefId}\` was stopped after running for ` +
+6 -22
View File
@@ -1,18 +1,12 @@
/**
* A tab's handle on its sandbox.
*
* 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.
* 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.
*/
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 interface PluginSandboxOptions {
@@ -20,7 +14,6 @@ export interface PluginSandboxOptions {
onLog?: (log: { pluginRefId: string; level: string; message: string }) => void;
}
/** What a module turned out to contribute, as reported after loading. */
export interface PluginSummary {
templateFunctions: string[];
authentication: string | null;
@@ -45,9 +38,8 @@ export class PluginSandbox {
constructor(options: PluginSandboxOptions) {
this.options = options;
// `new URL("./worker.ts", import.meta.url)` is written inline because that
// exact syntax is what the bundler pattern-matches to know it must bundle a
// worker entry. Hoisted into a variable it ships as raw TypeScript.
// Written inline because that exact syntax is what the bundler
// pattern-matches; hoisted into a variable it ships as raw TypeScript.
this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
name: "yaak-plugins",
@@ -56,7 +48,6 @@ export class PluginSandbox {
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> {
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 }));
}
/** Send one event to one loaded module; resolves with its reply payload. */
async dispatch<T>(
pluginRefId: string,
context: unknown,
@@ -85,13 +75,7 @@ export class PluginSandbox {
return parsed as T & { type: string };
}
/**
* 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.
*/
/** `terminate()`, not a polite shutdown: the reason to call this is a plugin that won't stop. */
dispose(): void {
this.worker.terminate();
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. The tab asks the worker to
* 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.
* Two request/reply flows in opposite directions. Payloads are JSON strings
* rather than objects because they must be strings to cross into QuickJS
* anyway, so structured-cloning them first would only be undone.
*/
/** Tab → worker */
+2 -16
View File
@@ -1,21 +1,8 @@
/// <reference lib="webworker" />
/**
* The worker plugins run in.
*
* 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.
* A dedicated worker owned by the tab, deliberately not the SharedWorker that
* owns the database. Reasons and the cost are in the README.
*/
import { PluginSandboxHost } from "./host/sandbox";
@@ -27,7 +14,6 @@ function send(message: FromSandbox): void {
scope.postMessage(message);
}
/** Host calls waiting on the tab, by id. */
const pendingHostCalls = new Map<number, (reply: string | Error) => void>();
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.
* The difference from the Node target is small and entirely in the resolver:
* 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.
* What the CLI would need instead is at the bottom of this file.
*/
import { build } from "esbuild";
@@ -20,16 +10,9 @@ import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/**
* 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.
*/
/** Three, not the corpus: one template function, one importer, one auth method. */
const PLUGINS = ["template-function-timestamp", "importer-curl", "auth-bearer"];
/** Refuse Node built-ins loudly at build time rather than at first call. */
const noNodeBuiltins = {
name: "no-node-builtins",
setup(build) {
@@ -50,8 +33,6 @@ export async function bundlePlugin(name, { dir = join(root, "plugins", name) } =
entryPoints: [join(dir, "src", "index.ts")],
bundle: true,
write: false,
// CommonJS because that is what the shell evaluates: a `new Function` with
// `module`, `exports` and a `require` that only throws.
format: "cjs",
platform: "browser",
target: "es2022",