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