diff --git a/crates/yaak-templates/src/renderer.rs b/crates/yaak-templates/src/renderer.rs index 23cd5981..be3be098 100644 --- a/crates/yaak-templates/src/renderer.rs +++ b/crates/yaak-templates/src/renderer.rs @@ -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"))] diff --git a/crates/yaak-wasm/pkg/yaak_wasm.d.ts b/crates/yaak-wasm/pkg/yaak_wasm.d.ts index 65892d3b..385084ab 100644 --- a/crates/yaak-wasm/pkg/yaak_wasm.d.ts +++ b/crates/yaak-wasm/pkg/yaak_wasm.d.ts @@ -26,28 +26,22 @@ export function blob_put(id: string, bytes: Uint8Array): void; export function boot(): Promise; /** - * 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; /** - * 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; diff --git a/crates/yaak-wasm/pkg/yaak_wasm_bg.js b/crates/yaak-wasm/pkg/yaak_wasm_bg.js index bebabdf1..40ba3616 100644 --- a/crates/yaak-wasm/pkg/yaak_wasm_bg.js +++ b/crates/yaak-wasm/pkg/yaak_wasm_bg.js @@ -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} @@ -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} diff --git a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm index c9213efb..f5686930 100644 Binary files a/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm and b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm differ diff --git a/crates/yaak-wasm/src/lib.rs b/crates/yaak-wasm/src/lib.rs index 7bc01342..8cc1fa7e 100644 --- a/crates/yaak-wasm/src/lib.rs +++ b/crates/yaak-wasm/src/lib.rs @@ -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, } -/// 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, } @@ -507,7 +499,6 @@ impl TemplateCallback for JsTemplateCallback { fn_name: &str, args: HashMap, ) -> impl std::future::Future> { - // 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::().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 { let req: PrepareHttpSendReq = from_js(payload)?; @@ -631,12 +617,9 @@ struct RenderTemplateReq { ignore_error: Option, } -/// 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 { let req: RenderTemplateReq = from_js(payload)?; diff --git a/packages/common-lib/pluginContext.ts b/packages/common-lib/pluginContext.ts index 7e046368..8639fd1f 100644 --- a/packages/common-lib/pluginContext.ts +++ b/packages/common-lib/pluginContext.ts @@ -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>; - /** 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; } -/** - * 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 = (payload: InternalEventPayload): Promise => transport.request(context, payload) as Promise; - /** 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({ @@ -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) => { 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({ type: "template_render_request", diff --git a/packages/common-lib/pluginForms.ts b/packages/common-lib/pluginForms.ts index 0b434b36..01a0cd68 100644 --- a/packages/common-lib/pluginForms.ts +++ b/packages/common-lib/pluginForms.ts @@ -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 diff --git a/packages/platform/src/web/connection.ts b/packages/platform/src/web/connection.ts index 74ae3861..9a9c6ed7 100644 --- a/packages/platform/src/web/connection.ts +++ b/packages/platform/src/web/connection.ts @@ -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) | 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): void { this.templateFunctions = handler; } diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index a5b78988..59dee100 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -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 diff --git a/packages/platform/src/web/plugins.ts b/packages/platform/src/web/plugins.ts index a0c4d3df..489f8cfe 100644 --- a/packages/platform/src/web/plugins.ts +++ b/packages/platform/src/web/plugins.ts @@ -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 | null = null; - /** Loaded plugin ids, by what they contribute. */ private readonly byTemplateFunction = new Map(); private readonly byAuthName = new Map(); 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 { 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(type: string, ids: Iterable): Promise { 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 { 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 { 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 { const { pluginRefId, payload } = JSON.parse(envelope) as { @@ -299,7 +248,6 @@ export class WebPlugins { const reply = async (): Promise => { 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("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); diff --git a/packages/platform/src/web/protocol.ts b/packages/platform/src/web/protocol.ts index e3b7921d..45f73235 100644 --- a/packages/platform/src/web/protocol.ts +++ b/packages/platform/src/web/protocol.ts @@ -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. */ diff --git a/packages/platform/src/web/send.ts b/packages/platform/src/web/send.ts index b22aeff6..aad3e751 100644 --- a/packages/platform/src/web/send.ts +++ b/packages/platform/src/web/send.ts @@ -52,7 +52,7 @@ type ResponsePatch = Partial; /** 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 }; diff --git a/packages/platform/src/web/worker.ts b/packages/platform/src/web/worker.ts index d22266cb..623711dd 100644 --- a/packages/platform/src/web/worker.ts +++ b/packages/platform/src/web/worker.ts @@ -109,17 +109,9 @@ function bootOnce(): Promise { } /** - * 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 void>(); let nextTemplateFunctionId = 1; diff --git a/packages/plugin-sandbox/bench/import.mjs b/packages/plugin-sandbox/bench/import.mjs index 7f587996..79b511c3 100644 --- a/packages/plugin-sandbox/bench/import.mjs +++ b/packages/plugin-sandbox/bench/import.mjs @@ -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 [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 }; } diff --git a/packages/plugin-sandbox/build-guest.mjs b/packages/plugin-sandbox/build-guest.mjs index 692807b4..b756aa3f 100644 --- a/packages/plugin-sandbox/build-guest.mjs +++ b/packages/plugin-sandbox/build-guest.mjs @@ -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", diff --git a/packages/plugin-sandbox/src/generated/guest.ts b/packages/plugin-sandbox/src/generated/guest.ts index 4f9769e3..0084c355 100644 --- a/packages/plugin-sandbox/src/generated/guest.ts +++ b/packages/plugin-sandbox/src/generated/guest.ts @@ -3,4 +3,4 @@ // The runtime shell, as source text, for evaluation inside QuickJS. // Regenerate with `npm run build --workspace @yaakapp-internal/plugin-sandbox`. -export const GUEST_SOURCE = "\"use strict\";\n(() => {\n // ../common-lib/templateFunction.ts\n function validateTemplateFunctionArgs(fnName, args, values) {\n for (const arg of args) {\n if (\"inputs\" in arg && arg.inputs) {\n const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);\n if (err) return err;\n }\n if (!(\"name\" in arg)) continue;\n if (arg.optional) continue;\n if (arg.defaultValue != null) continue;\n if (arg.hidden) continue;\n if (values[arg.name] != null) continue;\n return `Missing required argument \"${arg.label || arg.name}\" for template function ${fnName}()`;\n }\n return null;\n }\n function applyFormInputDefaults(inputs, values) {\n let newValues = { ...values };\n for (const input of inputs) {\n if (\"defaultValue\" in input && values[input.name] === void 0) {\n newValues[input.name] = input.defaultValue;\n }\n if (input.type === \"checkbox\" && values[input.name] === void 0) {\n newValues[input.name] = false;\n }\n if (\"inputs\" in input) {\n newValues = applyFormInputDefaults(input.inputs ?? [], newValues);\n }\n }\n return newValues;\n }\n\n // ../common-lib/pluginForms.ts\n async function applyDynamicFormInput(ctx, args, callArgs) {\n const resolvedArgs = [];\n for (const { dynamic, ...arg } of args) {\n const dynamicResult = typeof dynamic === \"function\" ? await dynamic(\n ctx,\n callArgs\n ) : void 0;\n const newArg = {\n ...arg,\n ...dynamicResult\n };\n if (\"inputs\" in newArg && Array.isArray(newArg.inputs)) {\n try {\n newArg.inputs = await applyDynamicFormInput(\n ctx,\n newArg.inputs,\n callArgs\n );\n } catch (e) {\n console.error(\"Failed to apply dynamic form input\", e);\n }\n }\n resolvedArgs.push(newArg);\n }\n return resolvedArgs;\n }\n function stripDynamicCallbacks(inputs) {\n return inputs.map((input) => {\n const { dynamic: _dynamic, ...rest } = input;\n if (\"inputs\" in rest && Array.isArray(rest.inputs)) {\n rest.inputs = stripDynamicCallbacks(rest.inputs);\n }\n return rest;\n });\n }\n function migrateTemplateFunctionSelectOptions(f) {\n const migratedArgs = f.args.map((a) => {\n if (a.type === \"select\") {\n a.options = a.options.map((o) => {\n const legacy = o;\n return { label: legacy.label ?? legacy.name ?? \"\", value: legacy.value };\n });\n }\n return a;\n });\n return { ...f, args: migratedArgs };\n }\n\n // ../common-lib/responseBody.ts\n var DEFAULT_CHUNK_SIZE = 1024 * 1024;\n var DEFAULT_MAX_BYTES = 32 * 1024 * 1024;\n var DEFAULT_POLL_INTERVAL_MS = 100;\n function createResponseBody(info, readChunk, { refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = {}) {\n const { responseId, contentLength, contentType, complete } = info;\n async function* chunks(options) {\n const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));\n let known = contentLength;\n let done = complete;\n let offset = 0;\n while (true) {\n if (done && offset >= known) return;\n const want = done ? Math.min(chunkSize, known - offset) : chunkSize;\n const chunk = await readChunk(offset, want);\n if (chunk.byteLength > 0) {\n yield chunk;\n offset += chunk.byteLength;\n continue;\n }\n if (done || refresh == null) return;\n ({ contentLength: known, complete: done } = await refresh());\n if (offset < known) continue;\n if (done) return;\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n async function readAll(accessor, options) {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n refuseIfTooBig(accessor, contentLength, maxBytes);\n const parts = [];\n let total = 0;\n for await (const chunk of chunks(options)) {\n total += chunk.byteLength;\n refuseIfTooBig(accessor, total, maxBytes);\n parts.push(chunk);\n }\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n bytes.set(part, offset);\n offset += part.byteLength;\n }\n return bytes;\n }\n return {\n responseId,\n contentLength,\n contentType,\n complete,\n chunks,\n async arrayBuffer(options) {\n const bytes = await readAll(\"arrayBuffer\", options);\n return bytes.buffer;\n },\n async text(options) {\n return decodeBody(await readAll(\"text\", options), contentType);\n },\n async json(options) {\n return JSON.parse(decodeBody(await readAll(\"json\", options), contentType));\n }\n };\n }\n function refuseIfTooBig(accessor, bytes, maxBytes) {\n if (bytes <= maxBytes) return;\n throw new Error(\n `Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`\n );\n }\n function decodeBody(bytes, contentType) {\n const charset = parseCharset(contentType);\n if (charset != null) {\n try {\n return new TextDecoder(charset).decode(bytes);\n } catch {\n }\n }\n return new TextDecoder(\"utf-8\").decode(bytes);\n }\n function parseCharset(contentType) {\n const match = contentType?.match(/;\\s*charset\\s*=\\s*\"?([^\";]+)\"?/i);\n return match?.[1]?.trim() || null;\n }\n function formatBytes(bytes) {\n if (bytes === Infinity) return \"unlimited\";\n if (bytes < 1024) return `${bytes} B`;\n const units = [\"KB\", \"MB\", \"GB\"];\n let value = bytes / 1024;\n let unit = 0;\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n return `${value.toFixed(1)} ${units[unit]}`;\n }\n function decodeBase64Chunk(data) {\n if (typeof Buffer !== \"undefined\") {\n const buf = Buffer.from(data, \"base64\");\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n }\n const binary = atob(data);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n }\n\n // ../common-lib/pluginContext.ts\n function forPlugin(httpResponse) {\n const { bodyPath: _bodyPath, ...rest } = httpResponse;\n return rest;\n }\n function createPluginContext(transport2, context) {\n const send = (payload) => transport2.request(context, payload);\n const storedBody = async (responseId) => {\n const bodyInfo = () => send({\n type: \"get_http_response_body_info_request\",\n responseId\n });\n const info = await bodyInfo();\n return createResponseBody(\n {\n responseId,\n contentLength: info.contentLength,\n contentType: info.contentType ?? null,\n complete: info.complete\n },\n async (offset, length) => {\n const chunk = await send({\n type: \"read_http_response_body_chunk_request\",\n responseId,\n offset,\n length\n });\n return decodeBase64Chunk(chunk.data);\n },\n { refresh: bodyInfo }\n );\n };\n const windowInfo = async () => {\n if (context.label == null) {\n throw new Error(\"Can't get window context without an active window\");\n }\n return send({ type: \"window_info_request\", label: context.label });\n };\n const ctx = {\n clipboard: {\n copyText: async (text) => {\n await send({ type: \"copy_text_request\", text });\n }\n },\n toast: {\n show: async (args) => {\n await send({\n type: \"show_toast_request\",\n // Defaulted here because null and undefined both become None in Rust.\n timeout: args.timeout === void 0 ? 5e3 : args.timeout,\n ...args\n });\n }\n },\n window: {\n requestId: async () => (await windowInfo()).requestId,\n workspaceId: async () => (await windowInfo()).workspaceId,\n environmentId: async () => (await windowInfo()).environmentId,\n openUrl: async ({ onNavigate, onClose, ...args }) => {\n if (transport2.stream == null) {\n throw new Error(\"ctx.window.openUrl is not available in this runtime\");\n }\n args.label = args.label || `${Math.random()}`;\n transport2.stream(context, { type: \"open_window_request\", ...args }, (event) => {\n if (event.type === \"window_navigate_event\") onNavigate?.(event);\n else if (event.type === \"window_close_event\") onClose?.();\n });\n return {\n close: () => {\n transport2.notify(context, { type: \"close_window_request\", label: args.label });\n }\n };\n },\n openExternalUrl: async (url) => {\n await send({ type: \"open_external_url_request\", url });\n }\n },\n prompt: {\n text: async (args) => {\n const reply = await send({ type: \"prompt_text_request\", ...args });\n return reply.value;\n },\n form: async (args) => {\n const resolve = async (values) => {\n const callArgs = { values };\n const resolved = await applyDynamicFormInput(\n ctx,\n args.inputs,\n callArgs\n );\n return stripDynamicCallbacks(resolved);\n };\n const initial = await resolve(applyFormInputDefaults(args.inputs, {}));\n const payload = {\n type: \"prompt_form_request\",\n ...args,\n inputs: initial\n };\n if (transport2.form == null) {\n const reply2 = await send(payload);\n return reply2.values;\n }\n const reply = await transport2.form(context, payload, async (values) => {\n if (values == null || Object.keys(values).length === 0) return null;\n return { type: \"prompt_form_request\", ...args, inputs: await resolve(values) };\n });\n return reply.values;\n }\n },\n httpResponse: {\n find: async (args) => {\n const { httpResponses } = await send({\n type: \"find_http_responses_request\",\n ...args\n });\n return httpResponses.map(forPlugin);\n },\n body: ({ responseId }) => storedBody(responseId)\n },\n grpcRequest: {\n render: async (args) => {\n const { grpcRequest } = await send({\n type: \"render_grpc_request_request\",\n ...args\n });\n return grpcRequest;\n }\n },\n httpRequest: {\n getById: async (args) => {\n const { httpRequest } = await send({\n type: \"get_http_request_by_id_request\",\n ...args\n });\n return httpRequest;\n },\n send: async (args) => {\n const { httpResponse, body } = await send({\n type: \"send_http_request_request\",\n ...args\n });\n if (body == null) {\n return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };\n }\n const bytes = decodeBase64Chunk(body);\n return {\n httpResponse: forPlugin(httpResponse),\n body: createResponseBody(\n {\n responseId: httpResponse.id,\n contentLength: bytes.byteLength,\n contentType: httpResponse.headers.find((h) => h.name.toLowerCase() === \"content-type\")?.value ?? null,\n // The host waited for the whole send before replying.\n complete: true\n },\n async (offset, length) => bytes.slice(offset, offset + length)\n )\n };\n },\n render: async (args) => {\n const { httpRequest } = await send({\n type: \"render_http_request_request\",\n ...args\n });\n return httpRequest;\n },\n list: async (args) => {\n const payload = {\n type: \"list_http_requests_request\",\n folderId: args?.folderId\n };\n const { httpRequests } = await send(payload);\n return httpRequests;\n },\n create: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { name: \"\", method: \"GET\", ...args, id: \"\", model: \"http_request\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"http_request\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"http_request\",\n id: args.id\n });\n return response.model;\n }\n },\n folder: {\n list: async () => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders;\n },\n getById: async (args) => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders.find((f) => f.id === args.id) ?? null;\n },\n create: async ({ name, ...args }) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { ...args, name: name ?? \"\", id: \"\", model: \"folder\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"folder\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"folder\",\n id: args.id\n });\n return response.model;\n }\n },\n cookies: {\n getValue: async (args) => {\n const { value } = await send({\n type: \"get_cookie_value_request\",\n ...args\n });\n return value;\n },\n listNames: async () => {\n const { names } = await send({ type: \"list_cookie_names_request\" });\n return names;\n }\n },\n templates: {\n /**\n * Invoke Yaak's template engine to render a value. If the value is a nested\n * type (eg. object), it will be recursively rendered.\n */\n render: async (args) => {\n const result = await send({\n type: \"template_render_request\",\n ...args\n });\n return result.data;\n }\n },\n store: {\n get: async (key) => {\n const result = await send({ type: \"get_key_value_request\", key });\n return result.value ? JSON.parse(result.value) : void 0;\n },\n set: async (key, value) => {\n await send({\n type: \"set_key_value_request\",\n key,\n value: JSON.stringify(value)\n });\n },\n delete: async (key) => {\n const result = await send({\n type: \"delete_key_value_request\",\n key\n });\n return result.deleted;\n }\n },\n plugin: {\n reload: () => {\n transport2.notify(context, { type: \"reload_response\", silent: true });\n }\n },\n workspace: {\n list: async () => {\n const response = await send({\n type: \"list_open_workspaces_request\"\n });\n return response.workspaces.map((w) => {\n return {\n id: w.id,\n name: w.name,\n // Kept for routing, hidden from plugin authors.\n _label: w.label\n };\n });\n },\n withContext: (handle) => createPluginContext(transport2, {\n ...context,\n label: handle._label || null,\n workspaceId: handle.id\n })\n }\n };\n return ctx;\n }\n\n // src/guest/globals.ts\n function formatArgs(args) {\n return args.map((arg) => {\n if (typeof arg === \"string\") return arg;\n if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;\n try {\n return JSON.stringify(arg, replacer()) ?? String(arg);\n } catch {\n return String(arg);\n }\n }).join(\" \");\n }\n function replacer() {\n const seen = /* @__PURE__ */ new WeakSet();\n return (_key, value) => {\n if (typeof value === \"bigint\") return `${value}n`;\n if (typeof value === \"function\") return `[Function ${value.name || \"anonymous\"}]`;\n if (typeof value === \"object\" && value !== null) {\n if (seen.has(value)) return \"[Circular]\";\n seen.add(value);\n }\n return value;\n };\n }\n function installConsole() {\n const log = (level) => (...args) => __yaak_log(level, formatArgs(args));\n globalThis.console = {\n log: log(\"log\"),\n info: log(\"info\"),\n warn: log(\"warn\"),\n error: log(\"error\"),\n debug: log(\"debug\"),\n trace: log(\"debug\")\n };\n }\n var timerCallbacks = /* @__PURE__ */ new Map();\n var nextTimerId = 1;\n function installTimers() {\n const g = globalThis;\n g.setTimeout = (callback, ms, ...args) => {\n const id = nextTimerId++;\n timerCallbacks.set(id, () => callback(...args));\n __yaak_timer_start(id, Math.max(0, Number(ms) || 0));\n return id;\n };\n g.clearTimeout = (id) => {\n if (!timerCallbacks.delete(id)) return;\n __yaak_timer_cancel(id);\n };\n g.setInterval = void 0;\n g.clearInterval = void 0;\n }\n function fireTimer(id) {\n const callback = timerCallbacks.get(id);\n timerCallbacks.delete(id);\n callback?.();\n }\n var SandboxTextEncoder = class {\n encoding = \"utf-8\";\n encode(input = \"\") {\n const out = [];\n for (let i = 0; i < input.length; i++) {\n let code = input.charCodeAt(i);\n if (code >= 55296 && code <= 56319) {\n const next = input.charCodeAt(i + 1);\n if (next >= 56320 && next <= 57343) {\n code = (code - 55296) * 1024 + (next - 56320) + 65536;\n i++;\n } else {\n code = 65533;\n }\n } else if (code >= 56320 && code <= 57343) {\n code = 65533;\n }\n if (code < 128) out.push(code);\n else if (code < 2048) out.push(192 | code >> 6, 128 | code & 63);\n else if (code < 65536)\n out.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);\n else\n out.push(\n 240 | code >> 18,\n 128 | code >> 12 & 63,\n 128 | code >> 6 & 63,\n 128 | code & 63\n );\n }\n return new Uint8Array(out);\n }\n };\n var SandboxTextDecoder = class {\n encoding = \"utf-8\";\n decode(input) {\n if (input == null) return \"\";\n const bytes = input instanceof Uint8Array ? input : ArrayBuffer.isView(input) ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) : new Uint8Array(input);\n let out = \"\";\n for (let i = 0; i < bytes.length; ) {\n const byte = bytes[i];\n let code;\n let size;\n if (byte < 128) {\n code = byte;\n size = 1;\n } else if ((byte & 224) === 192) {\n code = byte & 31;\n size = 2;\n } else if ((byte & 240) === 224) {\n code = byte & 15;\n size = 3;\n } else if ((byte & 248) === 240) {\n code = byte & 7;\n size = 4;\n } else {\n out += \"\\uFFFD\";\n i++;\n continue;\n }\n if (i + size > bytes.length) {\n out += \"\\uFFFD\";\n break;\n }\n for (let k = 1; k < size; k++) {\n const cont = bytes[i + k];\n if ((cont & 192) !== 128) {\n code = -1;\n break;\n }\n code = code << 6 | cont & 63;\n }\n i += size;\n if (code < 0 || code > 1114111 || code >= 55296 && code <= 57343) out += \"\\uFFFD\";\n else if (code < 65536) out += String.fromCharCode(code);\n else {\n const c = code - 65536;\n out += String.fromCharCode(55296 + (c >> 10), 56320 + (c & 1023));\n }\n }\n return out;\n }\n };\n function installTextCodecs() {\n const g = globalThis;\n g.TextEncoder = SandboxTextEncoder;\n g.TextDecoder = SandboxTextDecoder;\n }\n var B64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n function installBase64() {\n const g = globalThis;\n g.btoa = (input) => {\n let out = \"\";\n for (let i = 0; i < input.length; i += 3) {\n const a = input.charCodeAt(i);\n const b = input.charCodeAt(i + 1);\n const c = input.charCodeAt(i + 2);\n if (a > 255 || b > 255 || c > 255) {\n throw new Error(\"btoa: string contains characters outside of the Latin1 range\");\n }\n const chunk = a << 16 | (Number.isNaN(b) ? 0 : b) << 8 | (Number.isNaN(c) ? 0 : c);\n out += B64[chunk >> 18 & 63] + B64[chunk >> 12 & 63];\n out += Number.isNaN(b) ? \"=\" : B64[chunk >> 6 & 63];\n out += Number.isNaN(c) ? \"=\" : B64[chunk & 63];\n }\n return out;\n };\n g.atob = (input) => {\n const clean = input.replace(/[\\t\\n\\f\\r ]/g, \"\").replace(/=+$/, \"\");\n let out = \"\";\n let bits = 0;\n let acc = 0;\n for (const ch of clean) {\n const value = B64.indexOf(ch);\n if (value < 0) throw new Error(\"atob: string contains invalid characters\");\n acc = acc << 6 | value;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out += String.fromCharCode(acc >> bits & 255);\n }\n }\n return out;\n };\n }\n function installGlobals() {\n installConsole();\n installTimers();\n installTextCodecs();\n installBase64();\n return { fireTimer };\n }\n\n // src/guest/index.ts\n var { fireTimer: fireTimer2 } = installGlobals();\n var mod = {};\n var pluginRefId = \"\";\n function load(source, refId) {\n const module = { exports: {} };\n const require2 = (specifier) => {\n throw new Error(\n `Module \"${specifier}\" is not available in the sandbox runtime. Plugins must be bundled with no external or built-in modules.`\n );\n };\n const factory = new Function(\"module\", \"exports\", \"require\", source);\n factory(module, module.exports, require2);\n const loaded = module.exports.plugin ?? module.exports.default;\n if (loaded == null || typeof loaded !== \"object\") {\n throw new Error(\"Module did not export `plugin`\");\n }\n mod = loaded;\n pluginRefId = refId;\n }\n function summary() {\n return {\n templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),\n authentication: mod.authentication?.name ?? null,\n importer: mod.importer != null,\n filter: mod.filter != null,\n themes: (mod.themes ?? []).length,\n httpRequestActions: (mod.httpRequestActions ?? []).length,\n workspaceActions: (mod.workspaceActions ?? []).length,\n folderActions: (mod.folderActions ?? []).length,\n grpcRequestActions: (mod.grpcRequestActions ?? []).length,\n websocketRequestActions: (mod.websocketRequestActions ?? []).length\n };\n }\n var EMPTY = { type: \"empty_response\" };\n var transport = {\n async request(context, payload) {\n const replyJson = await __yaak_call(\n // The id rides along because the host multiplexes every loaded module\n // through one handler, and a plugin's storage is namespaced by which\n // plugin it is.\n JSON.stringify({ pluginRefId, context, payload })\n );\n const reply = JSON.parse(replyJson);\n if (reply.type === \"error_response\") {\n throw new Error(reply.error || `Host failed to handle ${payload.type}`);\n }\n const { type: _type, ...rest } = reply;\n return rest;\n },\n notify(context, payload) {\n void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));\n }\n };\n async function dispatch(context, payload) {\n const ctx = createPluginContext(transport, context);\n if (payload.type === \"boot_request\") {\n await mod.init?.(ctx);\n return { type: \"boot_response\" };\n }\n if (payload.type === \"terminate_request\") {\n await mod.dispose?.();\n return { type: \"terminate_response\" };\n }\n if (payload.type === \"import_request\" && typeof mod.importer?.onImport === \"function\") {\n const reply = await mod.importer.onImport(ctx, { text: payload.content });\n if (reply != null) {\n return { type: \"import_response\", resources: reply.resources };\n }\n return EMPTY;\n }\n if (payload.type === \"filter_request\" && typeof mod.filter?.onFilter === \"function\") {\n const reply = await mod.filter.onFilter(ctx, {\n filter: payload.filter,\n payload: payload.content,\n mimeType: payload.type\n });\n return { type: \"filter_response\", ...reply };\n }\n if (payload.type === \"get_themes_request\" && Array.isArray(mod.themes)) {\n return { type: \"get_themes_response\", themes: mod.themes };\n }\n if (payload.type === \"get_template_function_summary_request\" && Array.isArray(mod.templateFunctions)) {\n const functions = mod.templateFunctions.map((f) => ({\n ...migrateTemplateFunctionSelectOptions(f),\n onRender: void 0\n }));\n return { type: \"get_template_function_summary_response\", pluginRefId, functions };\n }\n if (payload.type === \"get_template_function_config_request\" && Array.isArray(mod.templateFunctions)) {\n const found = mod.templateFunctions.find((f) => f.name === payload.name);\n if (found == null) return EMPTY;\n const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: void 0 };\n payload.values = applyFormInputDefaults(fn.args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, fn.args, {\n ...payload,\n purpose: \"preview\"\n });\n return {\n type: \"get_template_function_config_response\",\n pluginRefId,\n function: { ...fn, args: stripDynamicCallbacks(resolved) }\n };\n }\n if (payload.type === \"call_template_function_request\" && Array.isArray(mod.templateFunctions)) {\n const fn = mod.templateFunctions.find((f) => f.name === payload.name);\n if (payload.args.purpose === \"preview\" && (fn?.previewType === \"click\" || fn?.previewType === \"none\")) {\n return {\n type: \"call_template_function_response\",\n value: null,\n error: \"Live preview disabled for this function\"\n };\n }\n if (typeof fn?.onRender === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);\n const values = applyFormInputDefaults(resolved, payload.args.values);\n const error = validateTemplateFunctionArgs(fn.name, resolved, values);\n if (error && payload.args.purpose !== \"preview\") {\n return { type: \"call_template_function_response\", value: null, error };\n }\n const result = await fn.onRender(ctx, { ...payload.args, values });\n return { type: \"call_template_function_response\", value: result ?? null };\n }\n }\n if (payload.type === \"get_http_authentication_summary_request\" && mod.authentication) {\n return { type: \"get_http_authentication_summary_response\", ...mod.authentication };\n }\n if (payload.type === \"get_http_authentication_config_request\" && mod.authentication) {\n const { args, actions } = mod.authentication;\n payload.values = applyFormInputDefaults(args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, args, payload);\n const resolvedActions = [];\n for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);\n return {\n type: \"get_http_authentication_config_response\",\n args: stripDynamicCallbacks(resolved),\n actions: resolvedActions,\n pluginRefId\n };\n }\n if (payload.type === \"call_http_authentication_request\" && mod.authentication) {\n const auth = mod.authentication;\n if (typeof auth.onApply === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, auth.args, payload);\n payload.values = applyFormInputDefaults(resolved, payload.values);\n return { type: \"call_http_authentication_response\", ...await auth.onApply(ctx, payload) };\n }\n }\n if (payload.type === \"call_http_authentication_action_request\" && mod.authentication != null) {\n const action = mod.authentication.actions?.[payload.index];\n if (typeof action?.onSelect === \"function\") {\n await action.onSelect(ctx, payload.args);\n return EMPTY;\n }\n }\n if (payload.type === \"get_http_request_actions_request\" && Array.isArray(mod.httpRequestActions)) {\n const actions = mod.httpRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_http_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_websocket_request_actions_request\" && Array.isArray(mod.websocketRequestActions)) {\n const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_websocket_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_grpc_request_actions_request\" && Array.isArray(mod.grpcRequestActions)) {\n const actions = mod.grpcRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_grpc_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_workspace_actions_request\" && Array.isArray(mod.workspaceActions)) {\n const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_workspace_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_folder_actions_request\" && Array.isArray(mod.folderActions)) {\n const actions = mod.folderActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_folder_actions_response\", pluginRefId, actions };\n }\n const called = await callAction(ctx, payload);\n if (called) return EMPTY;\n return EMPTY;\n }\n async function callAction(ctx, payload) {\n const lists = {\n call_http_request_action_request: mod.httpRequestActions,\n call_websocket_request_action_request: mod.websocketRequestActions,\n call_grpc_request_action_request: mod.grpcRequestActions,\n call_workspace_action_request: mod.workspaceActions,\n call_folder_action_request: mod.folderActions\n };\n const list = lists[payload.type];\n if (!Array.isArray(list)) return false;\n const action = list[payload.index];\n if (typeof action?.onSelect !== \"function\") return false;\n await action.onSelect(ctx, payload.args);\n return true;\n }\n globalThis.__yaak_guest = {\n load,\n summary,\n fireTimer: fireTimer2,\n dispatch: async (envelopeJson) => {\n const { context, payload } = JSON.parse(envelopeJson);\n try {\n return JSON.stringify(await dispatch(context, payload));\n } catch (err) {\n const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\\s*/g, \"\");\n return JSON.stringify({ type: \"error_response\", error });\n }\n }\n };\n})();\n"; +export const GUEST_SOURCE = "\"use strict\";\n(() => {\n // ../common-lib/templateFunction.ts\n function validateTemplateFunctionArgs(fnName, args, values) {\n for (const arg of args) {\n if (\"inputs\" in arg && arg.inputs) {\n const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);\n if (err) return err;\n }\n if (!(\"name\" in arg)) continue;\n if (arg.optional) continue;\n if (arg.defaultValue != null) continue;\n if (arg.hidden) continue;\n if (values[arg.name] != null) continue;\n return `Missing required argument \"${arg.label || arg.name}\" for template function ${fnName}()`;\n }\n return null;\n }\n function applyFormInputDefaults(inputs, values) {\n let newValues = { ...values };\n for (const input of inputs) {\n if (\"defaultValue\" in input && values[input.name] === void 0) {\n newValues[input.name] = input.defaultValue;\n }\n if (input.type === \"checkbox\" && values[input.name] === void 0) {\n newValues[input.name] = false;\n }\n if (\"inputs\" in input) {\n newValues = applyFormInputDefaults(input.inputs ?? [], newValues);\n }\n }\n return newValues;\n }\n\n // ../common-lib/pluginForms.ts\n async function applyDynamicFormInput(ctx, args, callArgs) {\n const resolvedArgs = [];\n for (const { dynamic, ...arg } of args) {\n const dynamicResult = typeof dynamic === \"function\" ? await dynamic(\n ctx,\n callArgs\n ) : void 0;\n const newArg = {\n ...arg,\n ...dynamicResult\n };\n if (\"inputs\" in newArg && Array.isArray(newArg.inputs)) {\n try {\n newArg.inputs = await applyDynamicFormInput(\n ctx,\n newArg.inputs,\n callArgs\n );\n } catch (e) {\n console.error(\"Failed to apply dynamic form input\", e);\n }\n }\n resolvedArgs.push(newArg);\n }\n return resolvedArgs;\n }\n function stripDynamicCallbacks(inputs) {\n return inputs.map((input) => {\n const { dynamic: _dynamic, ...rest } = input;\n if (\"inputs\" in rest && Array.isArray(rest.inputs)) {\n rest.inputs = stripDynamicCallbacks(rest.inputs);\n }\n return rest;\n });\n }\n function migrateTemplateFunctionSelectOptions(f) {\n const migratedArgs = f.args.map((a) => {\n if (a.type === \"select\") {\n a.options = a.options.map((o) => {\n const legacy = o;\n return { label: legacy.label ?? legacy.name ?? \"\", value: legacy.value };\n });\n }\n return a;\n });\n return { ...f, args: migratedArgs };\n }\n\n // ../common-lib/responseBody.ts\n var DEFAULT_CHUNK_SIZE = 1024 * 1024;\n var DEFAULT_MAX_BYTES = 32 * 1024 * 1024;\n var DEFAULT_POLL_INTERVAL_MS = 100;\n function createResponseBody(info, readChunk, { refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = {}) {\n const { responseId, contentLength, contentType, complete } = info;\n async function* chunks(options) {\n const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));\n let known = contentLength;\n let done = complete;\n let offset = 0;\n while (true) {\n if (done && offset >= known) return;\n const want = done ? Math.min(chunkSize, known - offset) : chunkSize;\n const chunk = await readChunk(offset, want);\n if (chunk.byteLength > 0) {\n yield chunk;\n offset += chunk.byteLength;\n continue;\n }\n if (done || refresh == null) return;\n ({ contentLength: known, complete: done } = await refresh());\n if (offset < known) continue;\n if (done) return;\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n async function readAll(accessor, options) {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n refuseIfTooBig(accessor, contentLength, maxBytes);\n const parts = [];\n let total = 0;\n for await (const chunk of chunks(options)) {\n total += chunk.byteLength;\n refuseIfTooBig(accessor, total, maxBytes);\n parts.push(chunk);\n }\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n bytes.set(part, offset);\n offset += part.byteLength;\n }\n return bytes;\n }\n return {\n responseId,\n contentLength,\n contentType,\n complete,\n chunks,\n async arrayBuffer(options) {\n const bytes = await readAll(\"arrayBuffer\", options);\n return bytes.buffer;\n },\n async text(options) {\n return decodeBody(await readAll(\"text\", options), contentType);\n },\n async json(options) {\n return JSON.parse(decodeBody(await readAll(\"json\", options), contentType));\n }\n };\n }\n function refuseIfTooBig(accessor, bytes, maxBytes) {\n if (bytes <= maxBytes) return;\n throw new Error(\n `Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`\n );\n }\n function decodeBody(bytes, contentType) {\n const charset = parseCharset(contentType);\n if (charset != null) {\n try {\n return new TextDecoder(charset).decode(bytes);\n } catch {\n }\n }\n return new TextDecoder(\"utf-8\").decode(bytes);\n }\n function parseCharset(contentType) {\n const match = contentType?.match(/;\\s*charset\\s*=\\s*\"?([^\";]+)\"?/i);\n return match?.[1]?.trim() || null;\n }\n function formatBytes(bytes) {\n if (bytes === Infinity) return \"unlimited\";\n if (bytes < 1024) return `${bytes} B`;\n const units = [\"KB\", \"MB\", \"GB\"];\n let value = bytes / 1024;\n let unit = 0;\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n return `${value.toFixed(1)} ${units[unit]}`;\n }\n function decodeBase64Chunk(data) {\n if (typeof Buffer !== \"undefined\") {\n const buf = Buffer.from(data, \"base64\");\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n }\n const binary = atob(data);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n }\n\n // ../common-lib/pluginContext.ts\n function forPlugin(httpResponse) {\n const { bodyPath: _bodyPath, ...rest } = httpResponse;\n return rest;\n }\n function createPluginContext(transport2, context) {\n const send = (payload) => transport2.request(context, payload);\n const storedBody = async (responseId) => {\n const bodyInfo = () => send({\n type: \"get_http_response_body_info_request\",\n responseId\n });\n const info = await bodyInfo();\n return createResponseBody(\n {\n responseId,\n contentLength: info.contentLength,\n contentType: info.contentType ?? null,\n complete: info.complete\n },\n async (offset, length) => {\n const chunk = await send({\n type: \"read_http_response_body_chunk_request\",\n responseId,\n offset,\n length\n });\n return decodeBase64Chunk(chunk.data);\n },\n { refresh: bodyInfo }\n );\n };\n const windowInfo = async () => {\n if (context.label == null) {\n throw new Error(\"Can't get window context without an active window\");\n }\n return send({ type: \"window_info_request\", label: context.label });\n };\n const ctx = {\n clipboard: {\n copyText: async (text) => {\n await send({ type: \"copy_text_request\", text });\n }\n },\n toast: {\n show: async (args) => {\n await send({\n type: \"show_toast_request\",\n // Defaulted here because null and undefined both become None in Rust.\n timeout: args.timeout === void 0 ? 5e3 : args.timeout,\n ...args\n });\n }\n },\n window: {\n requestId: async () => (await windowInfo()).requestId,\n workspaceId: async () => (await windowInfo()).workspaceId,\n environmentId: async () => (await windowInfo()).environmentId,\n openUrl: async ({ onNavigate, onClose, ...args }) => {\n if (transport2.stream == null) {\n throw new Error(\"ctx.window.openUrl is not available in this runtime\");\n }\n args.label = args.label || `${Math.random()}`;\n transport2.stream(context, { type: \"open_window_request\", ...args }, (event) => {\n if (event.type === \"window_navigate_event\") onNavigate?.(event);\n else if (event.type === \"window_close_event\") onClose?.();\n });\n return {\n close: () => {\n transport2.notify(context, { type: \"close_window_request\", label: args.label });\n }\n };\n },\n openExternalUrl: async (url) => {\n await send({ type: \"open_external_url_request\", url });\n }\n },\n prompt: {\n text: async (args) => {\n const reply = await send({ type: \"prompt_text_request\", ...args });\n return reply.value;\n },\n form: async (args) => {\n const resolve = async (values) => {\n const callArgs = { values };\n const resolved = await applyDynamicFormInput(\n ctx,\n args.inputs,\n callArgs\n );\n return stripDynamicCallbacks(resolved);\n };\n const initial = await resolve(applyFormInputDefaults(args.inputs, {}));\n const payload = {\n type: \"prompt_form_request\",\n ...args,\n inputs: initial\n };\n if (transport2.form == null) {\n const reply2 = await send(payload);\n return reply2.values;\n }\n const reply = await transport2.form(context, payload, async (values) => {\n if (values == null || Object.keys(values).length === 0) return null;\n return { type: \"prompt_form_request\", ...args, inputs: await resolve(values) };\n });\n return reply.values;\n }\n },\n httpResponse: {\n find: async (args) => {\n const { httpResponses } = await send({\n type: \"find_http_responses_request\",\n ...args\n });\n return httpResponses.map(forPlugin);\n },\n body: ({ responseId }) => storedBody(responseId)\n },\n grpcRequest: {\n render: async (args) => {\n const { grpcRequest } = await send({\n type: \"render_grpc_request_request\",\n ...args\n });\n return grpcRequest;\n }\n },\n httpRequest: {\n getById: async (args) => {\n const { httpRequest } = await send({\n type: \"get_http_request_by_id_request\",\n ...args\n });\n return httpRequest;\n },\n send: async (args) => {\n const { httpResponse, body } = await send({\n type: \"send_http_request_request\",\n ...args\n });\n if (body == null) {\n return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };\n }\n const bytes = decodeBase64Chunk(body);\n return {\n httpResponse: forPlugin(httpResponse),\n body: createResponseBody(\n {\n responseId: httpResponse.id,\n contentLength: bytes.byteLength,\n contentType: httpResponse.headers.find((h) => h.name.toLowerCase() === \"content-type\")?.value ?? null,\n // The host waited for the whole send before replying.\n complete: true\n },\n async (offset, length) => bytes.slice(offset, offset + length)\n )\n };\n },\n render: async (args) => {\n const { httpRequest } = await send({\n type: \"render_http_request_request\",\n ...args\n });\n return httpRequest;\n },\n list: async (args) => {\n const payload = {\n type: \"list_http_requests_request\",\n folderId: args?.folderId\n };\n const { httpRequests } = await send(payload);\n return httpRequests;\n },\n create: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { name: \"\", method: \"GET\", ...args, id: \"\", model: \"http_request\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"http_request\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"http_request\",\n id: args.id\n });\n return response.model;\n }\n },\n folder: {\n list: async () => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders;\n },\n getById: async (args) => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders.find((f) => f.id === args.id) ?? null;\n },\n create: async ({ name, ...args }) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { ...args, name: name ?? \"\", id: \"\", model: \"folder\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"folder\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"folder\",\n id: args.id\n });\n return response.model;\n }\n },\n cookies: {\n getValue: async (args) => {\n const { value } = await send({\n type: \"get_cookie_value_request\",\n ...args\n });\n return value;\n },\n listNames: async () => {\n const { names } = await send({ type: \"list_cookie_names_request\" });\n return names;\n }\n },\n templates: {\n render: async (args) => {\n const result = await send({\n type: \"template_render_request\",\n ...args\n });\n return result.data;\n }\n },\n store: {\n get: async (key) => {\n const result = await send({ type: \"get_key_value_request\", key });\n return result.value ? JSON.parse(result.value) : void 0;\n },\n set: async (key, value) => {\n await send({\n type: \"set_key_value_request\",\n key,\n value: JSON.stringify(value)\n });\n },\n delete: async (key) => {\n const result = await send({\n type: \"delete_key_value_request\",\n key\n });\n return result.deleted;\n }\n },\n plugin: {\n reload: () => {\n transport2.notify(context, { type: \"reload_response\", silent: true });\n }\n },\n workspace: {\n list: async () => {\n const response = await send({\n type: \"list_open_workspaces_request\"\n });\n return response.workspaces.map((w) => {\n return {\n id: w.id,\n name: w.name,\n // Kept for routing, hidden from plugin authors.\n _label: w.label\n };\n });\n },\n withContext: (handle) => createPluginContext(transport2, {\n ...context,\n label: handle._label || null,\n workspaceId: handle.id\n })\n }\n };\n return ctx;\n }\n\n // src/guest/globals.ts\n function formatArgs(args) {\n return args.map((arg) => {\n if (typeof arg === \"string\") return arg;\n if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;\n try {\n return JSON.stringify(arg, replacer()) ?? String(arg);\n } catch {\n return String(arg);\n }\n }).join(\" \");\n }\n function replacer() {\n const seen = /* @__PURE__ */ new WeakSet();\n return (_key, value) => {\n if (typeof value === \"bigint\") return `${value}n`;\n if (typeof value === \"function\") return `[Function ${value.name || \"anonymous\"}]`;\n if (typeof value === \"object\" && value !== null) {\n if (seen.has(value)) return \"[Circular]\";\n seen.add(value);\n }\n return value;\n };\n }\n function installConsole() {\n const log = (level) => (...args) => __yaak_log(level, formatArgs(args));\n globalThis.console = {\n log: log(\"log\"),\n info: log(\"info\"),\n warn: log(\"warn\"),\n error: log(\"error\"),\n debug: log(\"debug\"),\n trace: log(\"debug\")\n };\n }\n var timerCallbacks = /* @__PURE__ */ new Map();\n var nextTimerId = 1;\n function installTimers() {\n const g = globalThis;\n g.setTimeout = (callback, ms, ...args) => {\n const id = nextTimerId++;\n timerCallbacks.set(id, () => callback(...args));\n __yaak_timer_start(id, Math.max(0, Number(ms) || 0));\n return id;\n };\n g.clearTimeout = (id) => {\n if (!timerCallbacks.delete(id)) return;\n __yaak_timer_cancel(id);\n };\n g.setInterval = void 0;\n g.clearInterval = void 0;\n }\n function fireTimer(id) {\n const callback = timerCallbacks.get(id);\n timerCallbacks.delete(id);\n callback?.();\n }\n var SandboxTextEncoder = class {\n encoding = \"utf-8\";\n encode(input = \"\") {\n const out = [];\n for (let i = 0; i < input.length; i++) {\n let code = input.charCodeAt(i);\n if (code >= 55296 && code <= 56319) {\n const next = input.charCodeAt(i + 1);\n if (next >= 56320 && next <= 57343) {\n code = (code - 55296) * 1024 + (next - 56320) + 65536;\n i++;\n } else {\n code = 65533;\n }\n } else if (code >= 56320 && code <= 57343) {\n code = 65533;\n }\n if (code < 128) out.push(code);\n else if (code < 2048) out.push(192 | code >> 6, 128 | code & 63);\n else if (code < 65536)\n out.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);\n else\n out.push(\n 240 | code >> 18,\n 128 | code >> 12 & 63,\n 128 | code >> 6 & 63,\n 128 | code & 63\n );\n }\n return new Uint8Array(out);\n }\n };\n var SandboxTextDecoder = class {\n encoding = \"utf-8\";\n decode(input) {\n if (input == null) return \"\";\n const bytes = input instanceof Uint8Array ? input : ArrayBuffer.isView(input) ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) : new Uint8Array(input);\n let out = \"\";\n for (let i = 0; i < bytes.length; ) {\n const byte = bytes[i];\n let code;\n let size;\n if (byte < 128) {\n code = byte;\n size = 1;\n } else if ((byte & 224) === 192) {\n code = byte & 31;\n size = 2;\n } else if ((byte & 240) === 224) {\n code = byte & 15;\n size = 3;\n } else if ((byte & 248) === 240) {\n code = byte & 7;\n size = 4;\n } else {\n out += \"\\uFFFD\";\n i++;\n continue;\n }\n if (i + size > bytes.length) {\n out += \"\\uFFFD\";\n break;\n }\n for (let k = 1; k < size; k++) {\n const cont = bytes[i + k];\n if ((cont & 192) !== 128) {\n code = -1;\n break;\n }\n code = code << 6 | cont & 63;\n }\n i += size;\n if (code < 0 || code > 1114111 || code >= 55296 && code <= 57343) out += \"\\uFFFD\";\n else if (code < 65536) out += String.fromCharCode(code);\n else {\n const c = code - 65536;\n out += String.fromCharCode(55296 + (c >> 10), 56320 + (c & 1023));\n }\n }\n return out;\n }\n };\n function installTextCodecs() {\n const g = globalThis;\n g.TextEncoder = SandboxTextEncoder;\n g.TextDecoder = SandboxTextDecoder;\n }\n var B64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n function installBase64() {\n const g = globalThis;\n g.btoa = (input) => {\n let out = \"\";\n for (let i = 0; i < input.length; i += 3) {\n const a = input.charCodeAt(i);\n const b = input.charCodeAt(i + 1);\n const c = input.charCodeAt(i + 2);\n if (a > 255 || b > 255 || c > 255) {\n throw new Error(\"btoa: string contains characters outside of the Latin1 range\");\n }\n const chunk = a << 16 | (Number.isNaN(b) ? 0 : b) << 8 | (Number.isNaN(c) ? 0 : c);\n out += B64[chunk >> 18 & 63] + B64[chunk >> 12 & 63];\n out += Number.isNaN(b) ? \"=\" : B64[chunk >> 6 & 63];\n out += Number.isNaN(c) ? \"=\" : B64[chunk & 63];\n }\n return out;\n };\n g.atob = (input) => {\n const clean = input.replace(/[\\t\\n\\f\\r ]/g, \"\").replace(/=+$/, \"\");\n let out = \"\";\n let bits = 0;\n let acc = 0;\n for (const ch of clean) {\n const value = B64.indexOf(ch);\n if (value < 0) throw new Error(\"atob: string contains invalid characters\");\n acc = acc << 6 | value;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out += String.fromCharCode(acc >> bits & 255);\n }\n }\n return out;\n };\n }\n function installGlobals() {\n installConsole();\n installTimers();\n installTextCodecs();\n installBase64();\n return { fireTimer };\n }\n\n // src/guest/index.ts\n var { fireTimer: fireTimer2 } = installGlobals();\n var mod = {};\n var pluginRefId = \"\";\n function load(source, refId) {\n const module = { exports: {} };\n const require2 = (specifier) => {\n throw new Error(\n `Module \"${specifier}\" is not available in the sandbox runtime. Plugins must be bundled with no external or built-in modules.`\n );\n };\n const factory = new Function(\"module\", \"exports\", \"require\", source);\n factory(module, module.exports, require2);\n const loaded = module.exports.plugin ?? module.exports.default;\n if (loaded == null || typeof loaded !== \"object\") {\n throw new Error(\"Module did not export `plugin`\");\n }\n mod = loaded;\n pluginRefId = refId;\n }\n function summary() {\n return {\n templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),\n authentication: mod.authentication?.name ?? null,\n importer: mod.importer != null,\n filter: mod.filter != null,\n themes: (mod.themes ?? []).length,\n httpRequestActions: (mod.httpRequestActions ?? []).length,\n workspaceActions: (mod.workspaceActions ?? []).length,\n folderActions: (mod.folderActions ?? []).length,\n grpcRequestActions: (mod.grpcRequestActions ?? []).length,\n websocketRequestActions: (mod.websocketRequestActions ?? []).length\n };\n }\n var EMPTY = { type: \"empty_response\" };\n var transport = {\n async request(context, payload) {\n const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));\n const reply = JSON.parse(replyJson);\n if (reply.type === \"error_response\") {\n throw new Error(reply.error || `Host failed to handle ${payload.type}`);\n }\n const { type: _type, ...rest } = reply;\n return rest;\n },\n notify(context, payload) {\n void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));\n }\n };\n async function dispatch(context, payload) {\n const ctx = createPluginContext(transport, context);\n if (payload.type === \"boot_request\") {\n await mod.init?.(ctx);\n return { type: \"boot_response\" };\n }\n if (payload.type === \"terminate_request\") {\n await mod.dispose?.();\n return { type: \"terminate_response\" };\n }\n if (payload.type === \"import_request\" && typeof mod.importer?.onImport === \"function\") {\n const reply = await mod.importer.onImport(ctx, { text: payload.content });\n if (reply != null) {\n return { type: \"import_response\", resources: reply.resources };\n }\n return EMPTY;\n }\n if (payload.type === \"filter_request\" && typeof mod.filter?.onFilter === \"function\") {\n const reply = await mod.filter.onFilter(ctx, {\n filter: payload.filter,\n payload: payload.content,\n mimeType: payload.type\n });\n return { type: \"filter_response\", ...reply };\n }\n if (payload.type === \"get_themes_request\" && Array.isArray(mod.themes)) {\n return { type: \"get_themes_response\", themes: mod.themes };\n }\n if (payload.type === \"get_template_function_summary_request\" && Array.isArray(mod.templateFunctions)) {\n const functions = mod.templateFunctions.map((f) => ({\n ...migrateTemplateFunctionSelectOptions(f),\n onRender: void 0\n }));\n return { type: \"get_template_function_summary_response\", pluginRefId, functions };\n }\n if (payload.type === \"get_template_function_config_request\" && Array.isArray(mod.templateFunctions)) {\n const found = mod.templateFunctions.find((f) => f.name === payload.name);\n if (found == null) return EMPTY;\n const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: void 0 };\n payload.values = applyFormInputDefaults(fn.args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, fn.args, {\n ...payload,\n purpose: \"preview\"\n });\n return {\n type: \"get_template_function_config_response\",\n pluginRefId,\n function: { ...fn, args: stripDynamicCallbacks(resolved) }\n };\n }\n if (payload.type === \"call_template_function_request\" && Array.isArray(mod.templateFunctions)) {\n const fn = mod.templateFunctions.find((f) => f.name === payload.name);\n if (payload.args.purpose === \"preview\" && (fn?.previewType === \"click\" || fn?.previewType === \"none\")) {\n return {\n type: \"call_template_function_response\",\n value: null,\n error: \"Live preview disabled for this function\"\n };\n }\n if (typeof fn?.onRender === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);\n const values = applyFormInputDefaults(resolved, payload.args.values);\n const error = validateTemplateFunctionArgs(fn.name, resolved, values);\n if (error && payload.args.purpose !== \"preview\") {\n return { type: \"call_template_function_response\", value: null, error };\n }\n const result = await fn.onRender(ctx, { ...payload.args, values });\n return { type: \"call_template_function_response\", value: result ?? null };\n }\n }\n if (payload.type === \"get_http_authentication_summary_request\" && mod.authentication) {\n return { type: \"get_http_authentication_summary_response\", ...mod.authentication };\n }\n if (payload.type === \"get_http_authentication_config_request\" && mod.authentication) {\n const { args, actions } = mod.authentication;\n payload.values = applyFormInputDefaults(args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, args, payload);\n const resolvedActions = [];\n for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);\n return {\n type: \"get_http_authentication_config_response\",\n args: stripDynamicCallbacks(resolved),\n actions: resolvedActions,\n pluginRefId\n };\n }\n if (payload.type === \"call_http_authentication_request\" && mod.authentication) {\n const auth = mod.authentication;\n if (typeof auth.onApply === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, auth.args, payload);\n payload.values = applyFormInputDefaults(resolved, payload.values);\n return { type: \"call_http_authentication_response\", ...await auth.onApply(ctx, payload) };\n }\n }\n if (payload.type === \"call_http_authentication_action_request\" && mod.authentication != null) {\n const action = mod.authentication.actions?.[payload.index];\n if (typeof action?.onSelect === \"function\") {\n await action.onSelect(ctx, payload.args);\n return EMPTY;\n }\n }\n if (payload.type === \"get_http_request_actions_request\" && Array.isArray(mod.httpRequestActions)) {\n const actions = mod.httpRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_http_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_websocket_request_actions_request\" && Array.isArray(mod.websocketRequestActions)) {\n const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_websocket_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_grpc_request_actions_request\" && Array.isArray(mod.grpcRequestActions)) {\n const actions = mod.grpcRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_grpc_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_workspace_actions_request\" && Array.isArray(mod.workspaceActions)) {\n const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_workspace_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_folder_actions_request\" && Array.isArray(mod.folderActions)) {\n const actions = mod.folderActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_folder_actions_response\", pluginRefId, actions };\n }\n const called = await callAction(ctx, payload);\n if (called) return EMPTY;\n return EMPTY;\n }\n async function callAction(ctx, payload) {\n const lists = {\n call_http_request_action_request: mod.httpRequestActions,\n call_websocket_request_action_request: mod.websocketRequestActions,\n call_grpc_request_action_request: mod.grpcRequestActions,\n call_workspace_action_request: mod.workspaceActions,\n call_folder_action_request: mod.folderActions\n };\n const list = lists[payload.type];\n if (!Array.isArray(list)) return false;\n const action = list[payload.index];\n if (typeof action?.onSelect !== \"function\") return false;\n await action.onSelect(ctx, payload.args);\n return true;\n }\n globalThis.__yaak_guest = {\n load,\n summary,\n fireTimer: fireTimer2,\n dispatch: async (envelopeJson) => {\n const { context, payload } = JSON.parse(envelopeJson);\n try {\n return JSON.stringify(await dispatch(context, payload));\n } catch (err) {\n const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\\s*/g, \"\");\n return JSON.stringify({ type: \"error_response\", error });\n }\n }\n };\n})();\n"; diff --git a/packages/plugin-sandbox/src/guest/globals.ts b/packages/plugin-sandbox/src/guest/globals.ts index 99af62df..23986306 100644 --- a/packages/plugin-sandbox/src/guest/globals.ts +++ b/packages/plugin-sandbox/src/guest/globals.ts @@ -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 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; - // 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) { diff --git a/packages/plugin-sandbox/src/guest/index.ts b/packages/plugin-sandbox/src/guest/index.ts index 77974f87..e05f6f21 100644 --- a/packages/plugin-sandbox/src/guest/index.ts +++ b/packages/plugin-sandbox/src/guest/index.ts @@ -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; 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 } = { 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 { return { templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name), @@ -102,31 +84,20 @@ function summary(): Record { 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, 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).__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 }); } diff --git a/packages/plugin-sandbox/src/host/sandbox.ts b/packages/plugin-sandbox/src/host/sandbox.ts index d9efcdbc..a1fc990d 100644 --- a/packages/plugin-sandbox/src/host/sandbox.ts +++ b/packages/plugin-sandbox/src/host/sandbox.ts @@ -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; @@ -65,13 +30,10 @@ export interface SandboxLog { let modulePromise: Promise | null = null; function quickjs(): Promise { - // 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> { 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 { 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.(...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; @@ -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 ` + diff --git a/packages/plugin-sandbox/src/index.ts b/packages/plugin-sandbox/src/index.ts index 4056b5f9..d44ba8cb 100644 --- a/packages/plugin-sandbox/src/index.ts +++ b/packages/plugin-sandbox/src/index.ts @@ -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; 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 { return this.request((id) => ({ type: "load", id, pluginRefId, source })); } @@ -65,7 +56,6 @@ export class PluginSandbox { return this.request((id) => ({ type: "unload", id, pluginRefId })); } - /** Send one event to one loaded module; resolves with its reply payload. */ async dispatch( 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"); diff --git a/packages/plugin-sandbox/src/protocol.ts b/packages/plugin-sandbox/src/protocol.ts index e25630e7..25d1e82a 100644 --- a/packages/plugin-sandbox/src/protocol.ts +++ b/packages/plugin-sandbox/src/protocol.ts @@ -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 */ diff --git a/packages/plugin-sandbox/src/worker.ts b/packages/plugin-sandbox/src/worker.ts index 87635c42..bbdfe821 100644 --- a/packages/plugin-sandbox/src/worker.ts +++ b/packages/plugin-sandbox/src/worker.ts @@ -1,21 +1,8 @@ /// /** - * 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 void>(); let nextHostCallId = 1; diff --git a/scripts/bundle-sandbox-plugins.mjs b/scripts/bundle-sandbox-plugins.mjs index 8c6c48e3..930837bf 100644 --- a/scripts/bundle-sandbox-plugins.mjs +++ b/scripts/bundle-sandbox-plugins.mjs @@ -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",