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