Compare commits

...
Author SHA1 Message Date
Gregory Schier 252b151fcb Follow the yaak-web to yaak-wasm rename
Rebased onto main after #572 merged. The wasm crate moved to crates/yaak-wasm
and crates-server/yaak-web is now the server, so the render_template export,
the regenerated pkg/ and the proxy/server wording move with them.
2026-08-18 15:26:10 -07:00
Gregory Schier 85ba3e6852 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.
2026-08-18 15:23:10 -07:00
Gregory Schier 20c0efc2a5 Build ctx once and share it between both plugin runtimes
The sandbox's context builder was a near-copy of the Node runtime's. Both
now come from createPluginContext in @yaakapp-internal/lib, with each
runtime supplying only a transport.

The two places hosts genuinely differ are optional transport methods:
`stream` (a window reporting navigation until it closes) and `form` (a
prompt that re-renders as values change). The sandbox has neither, so
openUrl refuses and a form is drawn once from its defaults.
2026-08-18 15:22:49 -07:00
Gregory Schier f8d6dfbdaa Run plugins in a QuickJS sandbox in the browser
Adds packages/plugin-sandbox: QuickJS-ng compiled to wasm, running in a
dedicated worker, with a runtime shell inside it that loads a plugin bundle
and answers the same InternalEventPayload events the Node runtime answers.
Plugins are unmodified.

Wires the browser host's template function, authentication, cURL import and
template render commands to it, and relaxes TemplateCallback's Send bound on
wasm32 so the engine's renderer can call back out to a plugin.
2026-08-18 15:22:49 -07:00
38 changed files with 3005 additions and 609 deletions
Generated
+1
View File
@@ -11775,6 +11775,7 @@ dependencies = [
"console_error_panic_hook",
"js-sys",
"log 0.4.29",
"md5 0.7.0",
"serde",
"serde-wasm-bindgen",
"serde_json",
+14 -1
View File
@@ -8,12 +8,25 @@ use std::future::Future;
const MAX_DEPTH: usize = 50;
/// `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"))]
impl<T: Send> MaybeSend for T {}
#[cfg(target_arch = "wasm32")]
pub trait MaybeSend {}
#[cfg(target_arch = "wasm32")]
impl<T> MaybeSend for T {}
pub trait TemplateCallback {
fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> impl Future<Output = Result<String>> + Send;
) -> impl Future<Output = Result<String>> + MaybeSend;
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String>;
}
+1
View File
@@ -25,6 +25,7 @@ crate-type = ["cdylib", "rlib"]
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
md5 = "0.7"
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
# No default features: the template exports belong to @yaakapp-internal/templates, not this module
+9 -1
View File
@@ -3,4 +3,12 @@
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
// nowhere else: it owns a SQLite database, and there must be exactly one of it
// per origin.
export { blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc } from "./pkg";
export {
blob_delete,
blob_get,
blob_put,
boot,
prepare_http_send,
render_template,
rpc,
} from "./pkg";
+16 -7
View File
@@ -26,15 +26,24 @@ 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.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
* `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 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): Promise<any>;
export function prepare_http_send(payload: any, plugins: any): Promise<any>;
/**
* 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>;
/**
* Run one command as `label` (the calling tab's identity, which stands in for
+1 -1
View File
@@ -5,5 +5,5 @@ import { __wbg_set_wasm } from "./yaak_wasm_bg.js";
__wbg_set_wasm(wasm);
wasm.__wbindgen_start();
export {
blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc
blob_delete, blob_get, blob_put, boot, prepare_http_send, render_template, rpc
} from "./yaak_wasm_bg.js";
+39 -15
View File
@@ -63,18 +63,34 @@ 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 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.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
* `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 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>}
*/
export function prepare_http_send(payload) {
const ret = wasm.prepare_http_send(payload);
export function prepare_http_send(payload, plugins) {
const ret = wasm.prepare_http_send(payload, plugins);
return ret;
}
/**
* 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>}
*/
export function render_template(payload, plugins) {
const ret = wasm.render_template(payload, plugins);
return ret;
}
@@ -225,6 +241,10 @@ export function __wbg_call_dfde26266607c996() { return handleError(function (arg
const ret = arg0.call(arg1, arg2);
return ret;
}, arguments); }
export function __wbg_call_faa0a261f288f846() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = arg0.call(arg1, arg2, arg3);
return ret;
}, arguments); }
export function __wbg_clear_bb1b3ff877b62598() { return handleError(function (arg0) {
const ret = arg0.clear();
return ret;
@@ -669,6 +689,10 @@ export function __wbg_then_837494e384b37459(arg0, arg1) {
const ret = arg0.then(arg1);
return ret;
}
export function __wbg_then_bd927500e8905df2(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
}
export function __wbg_toString_1dda136fd8f30a5f(arg0) {
const ret = arg0.toString();
return ret;
@@ -697,22 +721,22 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
console.warn(arg0, arg1);
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1117, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1140, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
return ret;
}
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 229, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
return ret;
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 74, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 227, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
return ret;
}
@@ -765,8 +789,8 @@ function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg
}
}
function wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
Binary file not shown.
+3 -2
View File
@@ -5,7 +5,8 @@ export const blob_delete: (a: number, b: number) => [number, number];
export const blob_get: (a: number, b: number) => [number, number, number, number];
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
export const boot: () => any;
export const prepare_http_send: (a: any) => any;
export const prepare_http_send: (a: any, b: any) => any;
export const render_template: (a: any, b: any) => any;
export const rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
export const rust_sqlite_wasm_abort: () => void;
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
@@ -18,7 +19,7 @@ export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
export const sqlite3_os_end: () => number;
export const sqlite3_os_init: () => number;
export const wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f: (a: number, b: number) => void;
+138 -37
View File
@@ -232,6 +232,14 @@ struct PersistSendCookiesReq {
after: Vec<Cookie>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginKeyValueReq {
plugin_name: String,
key: String,
value: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InsertResponseEventsReq {
@@ -415,6 +423,34 @@ fn dispatch(
to_json(())
}
// 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);
to_json(found.map(|kv| kv.value))
}
"web_plugin_kv_set" => {
let req: PluginKeyValueReq = from_js(payload)?;
host.queries.connect().set_plugin_key_value(
&req.plugin_name,
&req.key,
&req.value.unwrap_or_default(),
);
to_json(())
}
"web_plugin_kv_delete" => {
let req: PluginKeyValueReq = from_js(payload)?;
let deleted = host
.queries
.connect()
.delete_plugin_key_value(&req.plugin_name, &req.key)
.map_err(js_error)?;
to_json(deleted)
}
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
}
}
@@ -439,6 +475,9 @@ 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,
/// 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,
/// sources and all. The tab records them before the proxy's own events.
@@ -447,22 +486,44 @@ struct PreparedHttpSend {
cookie_jar: Option<CookieJar>,
}
/// A template callback for a host with no plugins. Variables render; a function is a clear
/// refusal naming the function, so the user knows what the request needs rather than seeing
/// an empty string sent in its place.
struct NoPluginsCallback;
/// 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>,
}
impl TemplateCallback for NoPluginsCallback {
impl TemplateCallback for JsTemplateCallback {
fn run(
&self,
fn_name: &str,
_args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> + Send {
let message = format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
Plugins aren't available in the browser yet"
);
async move { Err(yaak_templates::error::Error::RenderError(message)) }
args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> {
let call = self.call.clone();
let fn_name = fn_name.to_string();
let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into());
async move {
use yaak_templates::error::Error::RenderError;
let Some(call) = call else {
return Err(RenderError(format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
No plugin provides it"
)));
};
let promise = call
.call2(&JsValue::NULL, &JsValue::from_str(&fn_name), &JsValue::from_str(&args))
.map_err(|e| RenderError(js_message(&e)))?;
let value = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise))
.await
.map_err(|e| RenderError(js_message(&e)))?;
value.as_string().ok_or_else(|| {
RenderError(format!("Template function \"{fn_name}\" did not return a string"))
})
}
}
fn transform_arg(
@@ -475,19 +536,35 @@ impl TemplateCallback for NoPluginsCallback {
}
}
/// 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.
fn js_message(value: &JsValue) -> String {
if let Some(text) = value.as_string() {
return text;
}
let message = js_sys::Reflect::get(value, &JsValue::from_str("message"))
.ok()
.and_then(|m| m.as_string());
message.unwrap_or_else(|| format!("{value:?}"))
}
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: the environment
/// chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
/// a socket.
///
/// Refuses, with a message the user can act on, when the request needs something this host
/// doesn't have: an authentication plugin, or a template function.
/// `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 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) -> Result<JsValue> {
pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
let req: PrepareHttpSendReq = from_js(payload)?;
// Everything from the database first, then release the host borrow before rendering.
let (request, environment_chain, settings, cookie_jar) = with_host(|host| {
let (request, environment_chain, settings, cookie_jar, auth_context_id) = with_host(|host| {
let db = host.queries.connect();
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
let environment_chain = db
@@ -497,7 +574,7 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
req.environment_id.as_deref(),
)
.map_err(js_error)?;
let (authentication_type, authentication, _auth_context_id) =
let (authentication_type, authentication, auth_context_id) =
db.resolve_auth_for_http_request(&request).map_err(js_error)?;
let headers = db.resolve_headers_for_http_request(&request).map_err(js_error)?;
let settings = db.resolve_settings_for_http_request(&request).map_err(js_error)?;
@@ -506,34 +583,21 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
None => None,
};
let request = HttpRequest { authentication_type, authentication, headers, ..request };
Ok((request, environment_chain, settings, cookie_jar))
Ok((request, environment_chain, settings, cookie_jar, auth_context_id))
})?;
let rendered = render_http_request(
&request,
environment_chain,
&NoPluginsCallback,
&template_callback(plugins),
&RenderOptions::throw(),
)
.await
.map_err(js_error)?;
// Authentication is applied by a plugin on the desktop. There is no plugin here, and a
// request sent without the auth it asked for is worse than one refused with the reason.
let auth_disabled =
rendered.authentication.get("disabled").and_then(|v| v.as_bool()) == Some(true);
if let Some(auth_type) = rendered.authentication_type.as_deref()
&& auth_type != "none"
&& !auth_disabled
{
return Err(js_error(format!(
"This request uses {auth_type} authentication, which needs plugins. \
Plugins aren't available in the browser yet"
)));
}
let prepared = PreparedHttpSend {
request: rendered,
auth_context_id: format!("{:x}", md5::compute(auth_context_id)),
settings: HttpSendSettings::from(&settings),
setting_events: settings.timeline_events(),
cookie_jar,
@@ -544,6 +608,43 @@ pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
prepared.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_error)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RenderTemplateReq {
template: String,
workspace_id: String,
environment_id: Option<String>,
ignore_error: Option<bool>,
}
/// 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)?;
let environment_chain = with_host(|host| {
host.queries
.connect()
.resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())
.map_err(js_error)
})?;
let vars = yaak_models::render::make_vars_hashmap(environment_chain);
let options = if req.ignore_error == Some(true) {
RenderOptions::return_empty()
} else {
RenderOptions::throw()
};
let rendered =
yaak_templates::parse_and_render(&req.template, &vars, &template_callback(plugins), &options)
.await
.map_err(js_error)?;
to_json(rendered).map(|v| JsValue::from_str(v.as_str().unwrap_or_default()))
}
/* -------------------------------------------------------------------------- */
/* Blobs */
/* -------------------------------------------------------------------------- */
+40
View File
@@ -16,6 +16,7 @@
"packages/platform",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/plugin-sandbox",
"plugins-external/mcp-server",
"plugins-external/faker",
"plugins-external/httpsnippet",
@@ -1470,6 +1471,21 @@
"node": ">=18.0.0"
}
},
"node_modules/@jitl/quickjs-ffi-types": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
"license": "MIT"
},
"node_modules/@jitl/quickjs-ng-wasmfile-release-sync": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ng-wasmfile-release-sync/-/quickjs-ng-wasmfile-release-sync-0.32.0.tgz",
"integrity": "sha512-XAX2jjZWWh3M0YaRqi82xMKNW/gkF6mo3MpW3UY2cmVxnQai1JuboVsJQVoLU629iEL4XWvHtO4h5lo7NRnAcg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -5638,6 +5654,10 @@
"resolved": "packages/plugin-runtime",
"link": true
},
"node_modules/@yaakapp-internal/plugin-sandbox": {
"resolved": "packages/plugin-sandbox",
"link": true
},
"node_modules/@yaakapp-internal/plugins": {
"resolved": "crates/yaak-plugins",
"link": true
@@ -12799,6 +12819,15 @@
],
"license": "MIT"
},
"node_modules/quickjs-emscripten-core": {
"version": "0.32.0",
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
"license": "MIT",
"dependencies": {
"@jitl/quickjs-ffi-types": "0.32.0"
}
},
"node_modules/railroad-diagrams": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
@@ -15859,6 +15888,17 @@
"dev": true,
"license": "MIT"
},
"packages/plugin-sandbox": {
"name": "@yaakapp-internal/plugin-sandbox",
"version": "1.0.0",
"dependencies": {
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
"quickjs-emscripten-core": "^0.32.0"
},
"devDependencies": {
"esbuild": "^0.28.0"
}
},
"packages/tailwind-config": {
"name": "@yaakapp-internal/tailwind-config",
"version": "1.0.0"
+1
View File
@@ -15,6 +15,7 @@
"packages/platform",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/plugin-sandbox",
"plugins-external/mcp-server",
"plugins-external/faker",
"plugins-external/httpsnippet",
+2
View File
@@ -2,3 +2,5 @@ export * from "./debounce";
export * from "./eagerDebounceAsync";
export * from "./formatSize";
export * from "./templateFunction";
export * from "./pluginForms";
export * from "./responseBody";
+403
View File
@@ -0,0 +1,403 @@
/**
* `ctx`, built once for every runtime that has one. A runtime supplies only how
* a payload reaches its host.
*
* `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 {
CallPromptFormDynamicArgs,
Context,
DynamicPromptFormArg,
} from "@yaakapp/api";
import type {
DeleteKeyValueResponse,
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoResponse,
GetKeyValueResponse,
HttpRequest,
HttpResponse,
InternalEventPayload,
ListCookieNamesResponse,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
ListOpenWorkspacesResponse,
PluginContext,
PromptFormResponse,
PromptTextResponse,
ReadHttpResponseBodyChunkResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateRenderRequest,
TemplateRenderResponse,
UpsertModelResponse,
WindowInfoResponse,
} from "@yaakapp-internal/plugins";
import { applyDynamicFormInput, stripDynamicCallbacks } from "./pluginForms";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
import { applyFormInputDefaults } from "./templateFunction";
export interface PluginTransport {
request(
context: PluginContext,
payload: InternalEventPayload,
): Promise<Record<string, unknown>>;
notify(context: PluginContext, payload: InternalEventPayload): void;
/** Send once, keep receiving. Windows report navigation until they close. */
stream?(
context: PluginContext,
payload: InternalEventPayload,
onReply: (payload: InternalEventPayload) => void,
): void;
/**
* 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,
payload: InternalEventPayload,
onChange: (
values: Record<string, unknown>,
) => Promise<InternalEventPayload | null>,
): Promise<PromptFormResponse>;
}
/** `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;
};
return rest;
}
export function createPluginContext(
transport: PluginTransport,
context: PluginContext,
): Context {
const send = <T>(payload: InternalEventPayload): Promise<T> =>
transport.request(context, payload) as Promise<T>;
const storedBody = async (responseId: string) => {
const bodyInfo = () =>
send<GetHttpResponseBodyInfoResponse>({
type: "get_http_response_body_info_request",
responseId,
});
const info = await bodyInfo();
return createResponseBody(
{
responseId,
contentLength: info.contentLength,
contentType: info.contentType ?? null,
complete: info.complete,
},
async (offset, length) => {
const chunk = await send<ReadHttpResponseBodyChunkResponse>({
type: "read_http_response_body_chunk_request",
responseId,
offset,
length,
});
return decodeBase64Chunk(chunk.data);
},
{ refresh: bodyInfo },
);
};
const windowInfo = async () => {
if (context.label == null) {
throw new Error("Can't get window context without an active window");
}
return send<WindowInfoResponse>({ type: "window_info_request", label: context.label });
};
const ctx: Context = {
clipboard: {
copyText: async (text) => {
await send({ type: "copy_text_request", text });
},
},
toast: {
show: async (args) => {
await send({
type: "show_toast_request",
// Defaulted here because null and undefined both become None in Rust.
timeout: args.timeout === undefined ? 5000 : args.timeout,
...args,
});
},
},
window: {
requestId: async () => (await windowInfo()).requestId,
workspaceId: async () => (await windowInfo()).workspaceId,
environmentId: async () => (await windowInfo()).environmentId,
openUrl: async ({ onNavigate, onClose, ...args }) => {
if (transport.stream == null) {
throw new Error("ctx.window.openUrl is not available in this runtime");
}
args.label = args.label || `${Math.random()}`;
transport.stream(context, { type: "open_window_request", ...args }, (event) => {
if (event.type === "window_navigate_event") onNavigate?.(event);
else if (event.type === "window_close_event") onClose?.();
});
return {
close: () => {
transport.notify(context, { type: "close_window_request", label: args.label });
},
};
},
openExternalUrl: async (url) => {
await send({ type: "open_external_url_request", url });
},
},
prompt: {
text: async (args) => {
const reply = await send<PromptTextResponse>({ type: "prompt_text_request", ...args });
return reply.value;
},
form: async (args) => {
// 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(
ctx,
args.inputs as DynamicPromptFormArg[],
callArgs,
);
return stripDynamicCallbacks(resolved) as FormInput[];
};
const initial = await resolve(applyFormInputDefaults(args.inputs, {}));
const payload: InternalEventPayload = {
type: "prompt_form_request",
...args,
inputs: initial,
};
if (transport.form == null) {
const reply = await send<PromptFormResponse>(payload);
return reply.values;
}
const reply = await transport.form(context, payload, async (values) => {
// 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) };
});
return reply.values;
},
},
httpResponse: {
find: async (args) => {
const { httpResponses } = await send<FindHttpResponsesResponse>({
type: "find_http_responses_request",
...args,
});
return httpResponses.map(forPlugin);
},
body: ({ responseId }) => storedBody(responseId),
},
grpcRequest: {
render: async (args) => {
const { grpcRequest } = await send<RenderGrpcRequestResponse>({
type: "render_grpc_request_request",
...args,
});
return grpcRequest;
},
},
httpRequest: {
getById: async (args) => {
const { httpRequest } = await send<GetHttpRequestByIdResponse>({
type: "get_http_request_by_id_request",
...args,
});
return httpRequest;
},
send: async (args) => {
const { httpResponse, body } = await send<SendHttpRequestResponse>({
type: "send_http_request_request",
...args,
});
// A send with no request behind it saves nothing, so the reply carries
// the only copy of its body.
if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
}
const bytes = decodeBase64Chunk(body);
return {
httpResponse: forPlugin(httpResponse),
body: createResponseBody(
{
responseId: httpResponse.id,
contentLength: bytes.byteLength,
contentType:
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")?.value ??
null,
// The host waited for the whole send before replying.
complete: true,
},
async (offset, length) => bytes.slice(offset, offset + length),
),
};
},
render: async (args) => {
const { httpRequest } = await send<RenderHttpRequestResponse>({
type: "render_http_request_request",
...args,
});
return httpRequest;
},
list: async (args?: { folderId?: string }) => {
const payload: InternalEventPayload = {
type: "list_http_requests_request",
folderId: args?.folderId,
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
const { httpRequests } = await send<ListHttpRequestsResponse>(payload);
return httpRequests;
},
create: async (args) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { name: "", method: "GET", ...args, id: "", model: "http_request" },
} as InternalEventPayload);
return response.model as HttpRequest;
},
update: async (args) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { model: "http_request", ...args },
} as InternalEventPayload);
return response.model as HttpRequest;
},
delete: async (args) => {
const response = await send<DeleteModelResponse>({
type: "delete_model_request",
model: "http_request",
id: args.id,
} as InternalEventPayload);
return response.model as HttpRequest;
},
},
folder: {
list: async () => {
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
return folders;
},
getById: async (args: { id: string }) => {
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
return folders.find((f) => f.id === args.id) ?? null;
},
create: async ({ name, ...args }) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { ...args, name: name ?? "", id: "", model: "folder" },
} as InternalEventPayload);
return response.model as Folder;
},
update: async (args) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { model: "folder", ...args },
} as InternalEventPayload);
return response.model as Folder;
},
delete: async (args: { id: string }) => {
const response = await send<DeleteModelResponse>({
type: "delete_model_request",
model: "folder",
id: args.id,
} as InternalEventPayload);
return response.model as Folder;
},
},
cookies: {
getValue: async (args: GetCookieValueRequest) => {
const { value } = await send<GetCookieValueResponse>({
type: "get_cookie_value_request",
...args,
});
return value;
},
listNames: async () => {
const { names } = await send<ListCookieNamesResponse>({ type: "list_cookie_names_request" });
return names;
},
},
templates: {
render: async (args: TemplateRenderRequest) => {
const result = await send<TemplateRenderResponse>({
type: "template_render_request",
...args,
});
// oxlint-disable-next-line no-explicit-any -- the caller knows its own shape
return result.data as any;
},
},
store: {
get: async <T>(key: string) => {
const result = await send<GetKeyValueResponse>({ type: "get_key_value_request", key });
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
set: async <T>(key: string, value: T) => {
await send<GetKeyValueResponse>({
type: "set_key_value_request",
key,
value: JSON.stringify(value),
});
},
delete: async (key: string) => {
const result = await send<DeleteKeyValueResponse>({
type: "delete_key_value_request",
key,
});
return result.deleted;
},
},
plugin: {
reload: () => {
transport.notify(context, { type: "reload_response", silent: true });
},
},
workspace: {
list: async () => {
const response = await send<ListOpenWorkspacesResponse>({
type: "list_open_workspaces_request",
});
return response.workspaces.map((w) => {
type WorkspaceInfoInternal = typeof w & { label?: string };
return {
id: w.id,
name: w.name,
// Kept for routing, hidden from plugin authors.
_label: (w as WorkspaceInfoInternal).label as string,
};
});
},
withContext: (handle: { id: string; name: string; _label?: string }) =>
createPluginContext(transport, {
...context,
label: handle._label || null,
workspaceId: handle.id,
}),
},
};
return ctx;
}
@@ -4,10 +4,12 @@ import type {
DynamicAuthenticationArg,
DynamicPromptFormArg,
DynamicTemplateFunctionArg,
TemplateFunctionPlugin,
} from "@yaakapp/api";
import type {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
FormInput,
} from "@yaakapp-internal/plugins";
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
@@ -73,3 +75,33 @@ export async function applyDynamicFormInput(
}
return resolvedArgs;
}
/** 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
const { dynamic: _dynamic, ...rest } = input as any;
if ("inputs" in rest && Array.isArray(rest.inputs)) {
rest.inputs = stripDynamicCallbacks(rest.inputs);
}
return rest as FormInput;
});
}
/** Select options used to carry `name` where they now carry `label`. */
export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin,
): TemplateFunctionPlugin {
const migratedArgs = f.args.map((a) => {
if (a.type === "select") {
type LegacyOption = { label?: string; value: string; name?: string };
a.options = a.options.map((o) => {
const legacy = o as LegacyOption;
return { label: legacy.label ?? legacy.name ?? "", value: legacy.value };
});
}
return a;
});
return { ...f, args: migratedArgs };
}
+112 -40
View File
@@ -17,15 +17,22 @@
* up here as a type error rather than as a runtime surprise.
*/
import type { HttpRequest } from "@yaakapp-internal/models";
import type { JsonPrimitive } from "@yaakapp-internal/plugins";
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
import type { CapabilityName, RpcPayload } from "../types";
import type { WorkerConnection } from "./connection";
import { unsupported } from "./errors";
import type { WebPlugins } from "./plugins";
import { sendHttpRequest } from "./send";
export type AppCmd = keyof RpcSchema;
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
type Handler = (
payload: RpcPayload,
db: WorkerConnection,
plugins: WebPlugins,
) => Promise<unknown>;
/** Placeholder shown wherever the desktop would show a real filesystem path. */
const NO_PATH = "";
@@ -41,6 +48,31 @@ function text(payload: RpcPayload, key: string): string {
return typeof value === "string" ? value : "";
}
/** Form values as the plugin protocol carries them. */
function values(payload: RpcPayload, key = "values"): Record<string, JsonPrimitive> {
const value = payload[key];
return value != null && typeof value === "object"
? (value as Record<string, JsonPrimitive>)
: {};
}
/**
* The id a plugin keys its stored state on.
*
* The desktop hashes the id of whichever model the configuration was read from,
* so two requests inheriting one folder's authentication share a token cache.
* The preview paths here have no such model in hand and pass what they were
* given, which is enough to be stable per form.
*/
function contextId(payload: RpcPayload): string {
const model = payload.model;
if (model != null && typeof model === "object" && "id" in model) {
const id = (model as { id?: unknown }).id;
return typeof id === "string" ? id : "";
}
return "";
}
/**
* Commands this host answers itself.
*
@@ -73,10 +105,16 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
// The tab renders and stores; a stateless server puts the bytes on the wire.
// See send.ts for the whole shape of it.
cmd_send_http_request: (payload, db) => {
cmd_send_http_request: (payload, db, plugins) => {
const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
return sendHttpRequest(
db,
plugins,
requestId,
str(payload, "environmentId"),
str(payload, "cookieJarId"),
);
},
/* -------------------------------- app ---------------------------------- */
@@ -146,22 +184,67 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
* Both of these are polled once a second until they answer with something, so
* an empty list is not a quiet no — it is a poll that never stops.
*
* The auth list names what Yaak actually offers, so the picker tells the
* truth about the product even though the form behind each entry stays empty
* until plugins run here. Template functions get the opposite treatment: one
* provider contributing no functions. That settles the poll while putting
* nothing in the autocomplete, which is the honest answer — a function the
* user could insert but nothing could evaluate would be worse than none.
* Both now answer from the plugins actually loaded in the sandbox, which is
* the only answer that stays true: an authentication method in the picker
* that no loaded plugin can apply would be a promise this host cannot keep,
* and a template function offered in the autocomplete that nothing can
* evaluate would be worse than none.
*/
async cmd_get_http_authentication_summaries() {
return HTTP_AUTHENTICATION_SUMMARIES;
async cmd_get_http_authentication_summaries(_payload, _db, plugins) {
return plugins.httpAuthenticationSummaries();
},
async cmd_template_function_summaries() {
return [{ pluginRefId: "web", functions: [] }];
async cmd_template_function_summaries(_payload, _db, plugins) {
return plugins.templateFunctionSummaries();
},
async cmd_get_http_authentication_config() {
return { args: [], pluginRefId: "web" };
async cmd_get_http_authentication_config(payload, _db, plugins) {
const authName = str(payload, "authName");
const config =
authName == null
? null
: await plugins.httpAuthenticationConfig(authName, values(payload), contextId(payload));
return config ?? { args: [], actions: [], pluginRefId: "web" };
},
async cmd_template_function_config(payload, _db, plugins) {
const name = str(payload, "functionName") ?? str(payload, "name");
if (name == null) return null;
return plugins.templateFunctionConfig(name, values(payload), contextId(payload));
},
async cmd_call_http_authentication_action(payload, _db, plugins) {
const authName = str(payload, "authName");
if (authName == null) return null;
const index = payload.actionIndex;
await plugins.callHttpAuthenticationAction(
authName,
typeof index === "number" ? index : 0,
values(payload),
contextId(payload),
);
return null;
},
/**
* Turn a pasted cURL command into a request.
*
* Routed through the same importer the desktop uses, in the sandbox, which
* is why this is a handler and no longer a refusal. The reshaping afterwards
* matches `cmd_curl_to_request` in crates/yaak-commands: the importer names a
* workspace of its own invention and mints an id, and both belong to the
* caller instead.
*/
async cmd_curl_to_request(payload, _db, plugins) {
const resources = await plugins.import(text(payload, "command"));
const imported = resources?.httpRequests?.[0];
if (imported == null) {
throw new Error("Failed to import cURL command");
}
return {
...imported,
id: "",
workspaceId: str(payload, "workspaceId") ?? imported.workspaceId,
} as HttpRequest;
},
async cmd_format_json(payload) {
@@ -176,13 +259,19 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
},
/**
* Rendering resolves variables and calls template functions, and the
* functions live in plugins. Handing the template back unrendered is what the
* preview then shows — the raw `${[...]}`, which is at least the thing the
* user typed rather than a wrong value.
* Resolve variables and call template functions, in the engine, exactly as
* `cmd_render_template` does on the desktop. The functions come back out to
* the sandbox as the render reaches them — see `templateBridge` in worker.ts.
*/
async cmd_render_template(payload) {
return text(payload, "template");
async cmd_render_template(payload, db) {
const workspaceId = str(payload, "workspaceId");
if (workspaceId == null) return text(payload, "template");
return db.renderTemplate({
template: text(payload, "template"),
workspaceId,
environmentId: str(payload, "environmentId"),
ignoreError: payload.ignoreError === true,
});
},
/* ------------------------------- bodies -------------------------------- */
@@ -224,21 +313,6 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
},
};
/**
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
* picker is truthful about the product; choosing one currently yields an empty
* config form, because the plugin that defines the form isn't running.
*/
const HTTP_AUTHENTICATION_SUMMARIES = [
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
];
/**
* Commands this host declines, each with the reason a user would need.
@@ -253,7 +327,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// ones nothing stores, used for GraphQL introspection — take the same road but
// return the body inline; not wired yet.
cmd_send_ephemeral_request: ["Sending unsaved requests isn't available in the browser yet", null],
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
// Protocols that need a real socket.
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
@@ -295,14 +368,12 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
};
@@ -329,9 +400,10 @@ export async function runCommand(
cmd: string,
payload: RpcPayload,
db: WorkerConnection,
plugins: WebPlugins,
): Promise<unknown> {
const handler = HANDLERS[cmd as AppCmd];
if (handler != null) return handler(payload, db);
if (handler != null) return handler(payload, db, plugins);
const declined = DECLINED[cmd as AppCmd];
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
+39
View File
@@ -50,6 +50,9 @@ export class WorkerConnection {
/** True once the worker has said anything at all. */
private heard = false;
/** Unset until the sandbox is up; a render before then gets a refusal. */
private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null;
constructor() {
// Both are required and neither is faked. Without a shared worker every
// tab would need its own SQLite over the same pages; without Web Locks
@@ -147,6 +150,9 @@ export class WorkerConnection {
case "event":
this.deliver(message.event, message.payload);
return;
case "template_function":
void this.runTemplateFunction(message.id, message.name, message.args);
return;
}
}
@@ -159,6 +165,34 @@ export class WorkerConnection {
});
}
setTemplateFunctionHandler(handler: (name: string, args: string) => Promise<string>): void {
this.templateFunctions = handler;
}
private async runTemplateFunction(id: number, name: string, args: string): Promise<void> {
if (this.templateFunctions == null) {
this.post({
type: "template_function_result",
id,
error: `The template function \`${name}\` needs a plugin, and none are loaded yet`,
});
return;
}
try {
this.post({
type: "template_function_result",
id,
value: await this.templateFunctions(name, args),
});
} catch (err) {
this.post({
type: "template_function_result",
id,
error: err instanceof Error ? err.message : String(err),
});
}
}
rpc<T>(cmd: string, payload: unknown): Promise<T> {
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
}
@@ -168,6 +202,11 @@ export class WorkerConnection {
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
}
/** See `render_template` in crates/yaak-wasm. */
renderTemplate(payload: unknown): Promise<string> {
return this.request<string>((id) => ({ type: "render_template", id, payload }));
}
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
return buf == null ? null : new Uint8Array(buf);
+10 -3
View File
@@ -28,6 +28,7 @@ import type {
import { commandSupport, runCommand } from "./commands";
import { WorkerConnection } from "./connection";
import { unsupported } from "./errors";
import { WebPlugins } from "./plugins";
import { requestPersistence } from "./storage";
/** What this host can do, reported honestly. */
@@ -60,7 +61,9 @@ function capabilitiesFor(): PlatformCapabilities {
// The browser already zooms the page, on the same keys, and remembers it
// per site. The app stays out of the way.
interfaceZoom: false,
plugins: false,
// Plugins run in a QuickJS sandbox, but only the bundled set: there is no
// installing them, so the plugin manager stays unavailable and says so.
plugins: true,
encryption: false,
updater: false,
// Reading needs a permission prompt at first paint, which is a bad ask for
@@ -160,8 +163,12 @@ function createWindow(db: WorkerConnection): PlatformWindow {
export function createWebPlatform(): Platform {
const db = new WorkerConnection();
const plugins = new WebPlugins(db);
const capabilities = capabilitiesFor();
// 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
// space may drop someone's workspaces. Asking is all we can do, and there is
// nothing useful to do about a refusal.
@@ -231,7 +238,7 @@ export function createWebPlatform(): Platform {
// `plugin:` commands are Tauri host plugins, not engine commands, and
// never reached the router even on the desktop.
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
return runCommand(cmd, payload ?? {}, db, plugins) as Promise<T>;
},
async rpcStream<T, M>(
@@ -244,7 +251,7 @@ export function createWebPlatform(): Platform {
const streamId = crypto.randomUUID();
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
try {
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
const result = (await runCommand(cmd, { ...payload, streamId }, db, plugins)) as T;
return { result, unlisten };
} catch (err) {
unlisten();
+298
View File
@@ -0,0 +1,298 @@
/**
* 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";
import type {
GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse,
GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse,
ImportResources,
InternalEventPayload,
JsonPrimitive,
PluginContext,
} from "@yaakapp-internal/plugins";
import type { WorkerConnection } from "./connection";
import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated";
type KeyValueRequest = { key: string };
export interface AppliedAuthentication {
setHeaders?: { name: string; value: string }[] | null;
setQueryParameters?: { name: string; value: string }[] | null;
}
export class WebPlugins {
private readonly db: WorkerConnection;
private sandbox: PluginSandbox | null = null;
private loading: Promise<void> | null = null;
private readonly byTemplateFunction = new Map<string, string>();
private readonly byAuthName = new Map<string, string>();
private readonly importers: string[] = [];
private readonly summaries = new Map<string, PluginSummary>();
constructor(db: WorkerConnection) {
this.db = db;
}
/**
* Called from every entry point rather than at construction, so a session
* that never touches a plugin never pays for QuickJS.
*/
ready(): Promise<void> {
this.loading ??= this.start();
return this.loading;
}
private async start(): Promise<void> {
const sandbox = new PluginSandbox({
onHostRequest: (envelope) => this.hostRequest(envelope),
onLog: ({ pluginRefId, level, message }) => {
// 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;
await Promise.all(
SANDBOX_PLUGINS.map(async ({ name, source }) => {
try {
const summary = await sandbox.load(name, source);
this.summaries.set(name, summary);
for (const fn of summary.templateFunctions) this.byTemplateFunction.set(fn, name);
if (summary.authentication != null) this.byAuthName.set(summary.authentication, name);
if (summary.importer) this.importers.push(name);
} catch (err) {
// One bad bundle should cost its own features and nothing else.
console.error(`Failed to load plugin \`${name}\``, err);
}
}),
);
}
/* ------------------------------ what exists ------------------------------ */
async templateFunctionSummaries(): Promise<GetTemplateFunctionSummaryResponse[]> {
await this.ready();
return this.gather("get_template_function_summary_request", this.summaries.keys());
}
async httpAuthenticationSummaries(): Promise<GetHttpAuthenticationSummaryResponse[]> {
await this.ready();
return this.gather("get_http_authentication_summary_request", this.byAuthName.values());
}
/** 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> => {
try {
return await this.dispatch(id, { type } as InternalEventPayload);
} catch (err) {
console.error(`Plugin \`${id}\` failed to answer \`${type}\``, err);
return null;
}
}),
);
return replies.filter((r) => r != null && r.type !== "empty_response") as T[];
}
/* -------------------------------- calling -------------------------------- */
async templateFunctionConfig(
name: string,
values: Record<string, JsonPrimitive>,
contextId: string,
): Promise<GetTemplateFunctionConfigResponse | null> {
await this.ready();
const id = this.byTemplateFunction.get(name);
if (id == null) return null;
return this.dispatch(id, {
type: "get_template_function_config_request",
contextId,
name,
values,
} as InternalEventPayload);
}
/**
* 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();
const id = this.byTemplateFunction.get(name);
if (id == null) {
throw new Error(`No plugin provides the template function \`${name}\``);
}
const values = JSON.parse(argsJson) as Record<string, JsonPrimitive>;
const reply = await this.dispatch<{ value: string | null; error?: string | null }>(id, {
type: "call_template_function_request",
name,
args: { purpose: "send", values },
} as InternalEventPayload);
if (reply.error) throw new Error(reply.error);
return reply.value ?? "";
}
async httpAuthenticationConfig(
authName: string,
values: Record<string, JsonPrimitive>,
contextId: string,
): Promise<GetHttpAuthenticationConfigResponse | null> {
await this.ready();
const id = this.byAuthName.get(authName);
if (id == null) return null;
return this.dispatch(id, {
type: "get_http_authentication_config_request",
contextId,
values,
} as InternalEventPayload);
}
async callHttpAuthenticationAction(
authName: string,
index: number,
values: Record<string, JsonPrimitive>,
contextId: string,
): Promise<void> {
await this.ready();
const id = this.byAuthName.get(authName);
if (id == null) throw new Error(`No plugin provides \`${authName}\` authentication`);
await this.dispatch(id, {
type: "call_http_authentication_action_request",
index,
pluginRefId: id,
args: { contextId, values },
} as InternalEventPayload);
}
async applyHttpAuthentication(
authName: string,
request: {
contextId: string;
values: Record<string, JsonPrimitive>;
method: string;
url: string;
headers: { name: string; value: string }[];
body: string | null;
},
): Promise<AppliedAuthentication> {
await this.ready();
const id = this.byAuthName.get(authName);
if (id == null) {
throw new Error(
`This request uses ${authName} authentication, which no plugin in the browser provides`,
);
}
return this.dispatch<AppliedAuthentication>(id, {
type: "call_http_authentication_request",
...request,
} as InternalEventPayload);
}
/** 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) {
try {
const reply = await this.dispatch<{ resources?: ImportResources }>(id, {
type: "import_request",
content,
} as InternalEventPayload);
if (reply.type === "import_response" && reply.resources != null) return reply.resources;
} catch (err) {
console.error(`Importer \`${id}\` failed`, err);
}
}
return null;
}
/* ------------------------------- internals ------------------------------- */
private async dispatch<T>(
pluginRefId: string,
payload: InternalEventPayload,
): Promise<T & { type: string }> {
if (this.sandbox == null) throw new Error("The plugin sandbox is not running");
return this.sandbox.dispatch<T>(pluginRefId, this.context(), payload);
}
/**
* `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 };
}
/**
* 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 {
pluginRefId: string;
context: PluginContext;
payload: InternalEventPayload;
};
const reply = async (): Promise<InternalEventPayload> => {
switch (payload.type) {
case "get_key_value_request": {
const value = await this.db.rpc<string | null>("web_plugin_kv_get", {
pluginName: pluginRefId,
key: (payload as unknown as KeyValueRequest).key,
});
return { type: "get_key_value_response", value } as InternalEventPayload;
}
case "set_key_value_request": {
const { key, value } = payload as unknown as { key: string; value: string };
await this.db.rpc("web_plugin_kv_set", {
pluginName: pluginRefId,
key,
value,
});
return { type: "set_key_value_response" } as InternalEventPayload;
}
case "delete_key_value_request": {
const deleted = await this.db.rpc<boolean>("web_plugin_kv_delete", {
pluginName: pluginRefId,
key: (payload as unknown as KeyValueRequest).key,
});
return { type: "delete_key_value_response", deleted } as InternalEventPayload;
}
case "show_toast_request": {
const { type: _type, ...toast } = payload;
this.db.deliver("show_toast", toast);
return { type: "empty_response" };
}
default:
throw new Error(
`\`${payload.type}\` isn't something a plugin can do when Yaak runs in a browser yet`,
);
}
};
try {
return JSON.stringify(await reply());
} catch (err) {
return JSON.stringify({
type: "error_response",
error: err instanceof Error ? err.message : String(err),
});
}
}
}
+12 -1
View File
@@ -16,6 +16,15 @@ export type ToWorker =
* async in the engine (rendering is), where every `rpc` command is not.
*/
| { type: "prepare_http_send"; id: number; payload: unknown }
/** 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. */
| {
type: "template_function_result";
id: number;
value?: string;
error?: string;
}
| { type: "blob_get"; id: number; blobId: string }
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
| { type: "blob_delete"; id: number; blobId: string }
@@ -38,7 +47,9 @@ export type FromWorker =
| { type: "result"; id: number; result: unknown }
| { 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 };
| { type: "event"; event: string; payload: unknown }
/** 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. */
export const WORKER_NAME = "yaak-db";
File diff suppressed because one or more lines are too long
+56 -4
View File
@@ -33,7 +33,8 @@ import type {
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection";
import { serverIdentity, serverSendUrl, readFrames } from "./server";
import type { WebPlugins } from "./plugins";
import { readFrames, serverIdentity, serverSendUrl } from "./server";
/* -------------------------------- shapes --------------------------------- */
@@ -51,11 +52,59 @@ type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
interface PreparedHttpSend {
request: HttpRequest;
/** Hashed id of the model the auth came from; plugins key stored state on it. */
authContextId: string;
settings: HttpSendSettings;
settingEvents: HttpResponseEventData[];
cookieJar: CookieJar | null;
}
/**
* The desktop applies auth to the sendable request; here the server builds that,
* so the plugin's answer goes onto the model and the server folds it in. Same
* bytes for a method that sets a header, which is every one that runs here.
*
* Not the same for one that *signs*, since the plugin sees the request before
* the server assembles it. AWS SigV4 and OAuth 1.0 are refused rather than
* mis-signed; see the sandbox README.
*/
async function applyAuthentication(
plugins: WebPlugins,
prepared: PreparedHttpSend,
): Promise<HttpRequest> {
const { request } = prepared;
const authType = request.authenticationType;
const disabled = request.authentication?.disabled === true;
if (authType == null || authType === "none" || disabled) return request;
const applied = await plugins.applyHttpAuthentication(authType, {
contextId: prepared.authContextId,
values: request.authentication as Record<string, never>,
method: request.method,
url: request.url,
headers: request.headers.filter((h) => h.enabled !== false),
// 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.
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 };
else headers.push(entry);
}
const urlParameters = [...request.urlParameters];
for (const param of applied.setQueryParameters ?? []) {
urlParameters.push({ name: param.name, value: param.value, enabled: true });
}
return { ...request, headers, urlParameters };
}
/** The desktop writes progress at most this often while a body streams in. */
const PROGRESS_INTERVAL_MS = 100;
@@ -63,6 +112,7 @@ const PROGRESS_INTERVAL_MS = 100;
export async function sendHttpRequest(
db: WorkerConnection,
plugins: WebPlugins,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
@@ -78,7 +128,7 @@ export async function sendHttpRequest(
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
try {
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
await runSend(db, plugins, response, requestId, environmentId, cookieJarId, cancel.signal);
} catch (err) {
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
await response.finish({ error: message });
@@ -90,6 +140,7 @@ export async function sendHttpRequest(
async function runSend(
db: WorkerConnection,
plugins: WebPlugins,
response: ResponseWriter,
requestId: string,
environmentId: string | null,
@@ -101,7 +152,8 @@ async function runSend(
environmentId,
cookieJarId,
});
await response.patch({ url: prepared.request.url });
const request = await applyAuthentication(plugins, prepared);
await response.patch({ url: request.url });
// The first line of the timeline says what did the sending and where. A
// request through a proxy shows a different origin to the server than the
@@ -111,7 +163,7 @@ async function runSend(
timeline.push(prepared.settingEvents);
const body: SendRequest = {
request: prepared.request,
request,
settings: prepared.settings,
cookies: prepared.cookieJar?.cookies ?? null,
};
+32 -2
View File
@@ -108,12 +108,36 @@ function bootOnce(): Promise<void> {
return booted;
}
/**
* 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;
function templateBridge(port: MessagePort): (name: string, args: string) => Promise<string> {
return (name, args) =>
new Promise<string>((resolve, reject) => {
const id = nextTemplateFunctionId++;
pendingTemplateFunctions.set(id, (r) => (r instanceof Error ? reject(r) : resolve(r)));
send(port, { type: "template_function", id, name, args });
});
}
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
if (message.type === "goodbye") {
ports.delete(port);
return;
}
if (message.type === "template_function_result") {
const settle = pendingTemplateFunctions.get(message.id);
pendingTemplateFunctions.delete(message.id);
settle?.(message.error != null ? new Error(message.error) : (message.value ?? ""));
return;
}
// Every command waits for boot rather than the tab having to. Tabs post
// the moment they load; the port queues; this drains once the DB is open.
try {
@@ -122,7 +146,8 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
return;
}
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send, render_template } =
engine!;
try {
switch (message.type) {
@@ -143,10 +168,15 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
return;
}
case "prepare_http_send": {
const prepared = await prepare_http_send(message.payload);
const prepared = await prepare_http_send(message.payload, templateBridge(port));
send(port, { type: "result", id: message.id, result: prepared });
return;
}
case "render_template": {
const rendered = await render_template(message.payload, templateBridge(port));
send(port, { type: "result", id: message.id, result: rendered });
return;
}
case "blob_get": {
const bytes = blob_get(message.blobId);
if (bytes == null) {
+57 -473
View File
@@ -2,72 +2,36 @@ import console from "node:console";
import { type Stats, statSync, watch } from "node:fs";
import path from "node:path";
import type {
CallPromptFormDynamicArgs,
Context,
DynamicPromptFormArg,
PluginDefinition,
} from "@yaakapp/api";
import {
createPluginContext,
type PluginTransport,
} from "@yaakapp-internal/lib/pluginContext";
import {
applyDynamicFormInput,
migrateTemplateFunctionSelectOptions,
stripDynamicCallbacks,
} from "@yaakapp-internal/lib/pluginForms";
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
} from "@yaakapp-internal/lib/templateFunction";
import type {
BootRequest,
DeleteKeyValueResponse,
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoResponse,
GetKeyValueResponse,
GrpcRequestAction,
HttpAuthenticationAction,
HttpRequest,
HttpRequestAction,
HttpResponse,
ImportResources,
InternalEvent,
InternalEventPayload,
ListCookieNamesResponse,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
ListOpenWorkspacesResponse,
PluginContext,
PromptFormResponse,
PromptTextResponse,
ReadHttpResponseBodyChunkResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateRenderRequest,
TemplateRenderResponse,
UpsertModelResponse,
WindowInfoResponse,
} from "@yaakapp-internal/plugins";
import { applyDynamicFormInput } from "./common";
import { EventChannel } from "./EventChannel";
import { migrateTemplateFunctionSelectOptions } from "./migrations";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
/**
* A response as a plugin should see it.
*
* The host still puts `bodyPath` on the wire for its own callers, but it names
* a file on the host's disk — meaningless to a plugin, absent once bodies move
* off the filesystem, and impossible in a browser. Plugins address bodies by
* response id, so drop it here rather than let one grow a dependency on it.
*/
function forPlugin(httpResponse: HttpResponse): HttpResponse {
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
bodyPath?: string | null;
};
return rest;
}
export interface PluginWorkerData {
bootRequest: BootRequest;
@@ -629,444 +593,64 @@ export class PluginInstance {
this.#sendEvent(eventToSend);
}
#newCtx(context: PluginContext): Context {
/** 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 = () =>
this.#sendForReply<GetHttpResponseBodyInfoResponse>(context, {
type: "get_http_response_body_info_request",
responseId,
});
const info = await bodyInfo();
/**
* How a plugin reaches the app from this runtime.
*
* Every request is an event whose reply is matched by id. This runtime can
* hold a conversation open, so it supplies `stream` and `form`: a window
* reports navigation until it closes, and a prompt form re-renders as values
* change. `ctx` itself is built from these in @yaakapp-internal/lib, the same
* way the sandbox runtime builds it.
*/
#transport: PluginTransport = {
request: (context, payload) => this.#sendForReply(context, payload),
return createResponseBody(
{
responseId,
contentLength: info.contentLength,
contentType: info.contentType ?? null,
complete: info.complete,
},
async (offset, length) => {
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
context,
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
);
return decodeBase64Chunk(chunk.data);
},
{ refresh: bodyInfo },
);
};
notify: (context, payload) => {
this.#sendPayload(context, payload, null);
},
const _windowInfo = async () => {
if (context.label == null) {
throw new Error("Can't get window context without an active window");
}
const payload: InternalEventPayload = {
type: "window_info_request",
label: context.label,
};
stream: (context, payload, onReply) => {
this.#sendAndListenForEvents(context, payload, onReply);
},
return this.#sendForReply<WindowInfoResponse>(context, payload);
};
form: (context, payload, onChange) => {
// Built by hand so the event id is available: intermediate re-renders
// reply to the original request rather than starting a new one.
const eventToSend = this.#buildEventToSend(context, payload, null);
return {
clipboard: {
copyText: async (text) => {
await this.#sendForReply(context, {
type: "copy_text_request",
text,
});
},
},
toast: {
show: async (args) => {
await this.#sendForReply(context, {
type: "show_toast_request",
// Handle default here because null/undefined both convert to None in Rust translation
timeout: args.timeout === undefined ? 5000 : args.timeout,
...args,
});
},
},
window: {
requestId: async () => {
return (await _windowInfo()).requestId;
},
async workspaceId(): Promise<string | null> {
return (await _windowInfo()).workspaceId;
},
async environmentId(): Promise<string | null> {
return (await _windowInfo()).environmentId;
},
openUrl: async ({ onNavigate, onClose, ...args }) => {
args.label = args.label || `${Math.random()}`;
const payload: InternalEventPayload = { type: "open_window_request", ...args };
const onEvent = (event: InternalEventPayload) => {
if (event.type === "window_navigate_event") {
onNavigate?.(event);
} else if (event.type === "window_close_event") {
onClose?.();
}
};
this.#sendAndListenForEvents(context, payload, onEvent);
return {
close: () => {
const closePayload: InternalEventPayload = {
type: "close_window_request",
label: args.label,
};
this.#sendPayload(context, closePayload, null);
},
};
},
openExternalUrl: async (url) => {
await this.#sendForReply(context, {
type: "open_external_url_request",
url,
});
},
},
prompt: {
text: async (args) => {
const reply: PromptTextResponse = await this.#sendForReply(context, {
type: "prompt_text_request",
...args,
});
return reply.value;
},
form: async (args) => {
// Resolve dynamic callbacks on initial inputs using default values
const defaults = applyFormInputDefaults(args.inputs, {});
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
const resolvedInputs = await applyDynamicFormInput(
this.#newCtx(context),
args.inputs,
callArgs,
);
const strippedInputs = stripDynamicCallbacks(resolvedInputs);
return new Promise<PromptFormResponse>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId !== eventToSend.id) return;
if (event.payload.type !== "prompt_form_response") return;
// Build the event manually so we can get the event ID for keying
const eventToSend = this.#buildEventToSend(
context,
{ type: "prompt_form_request", ...args, inputs: strippedInputs },
null,
);
// Store original inputs (with dynamic callbacks) for later resolution
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
const reply = await new Promise<PromptFormResponse>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId !== eventToSend.id) return;
if (event.payload.type === "prompt_form_response") {
const { done, values } = event.payload as PromptFormResponse;
if (done) {
// Final response — resolve the promise and clean up
this.#appToPluginEvents.unlisten(cb);
this.#pendingDynamicForms.delete(eventToSend.id);
resolve({ values } as PromptFormResponse);
} else {
// Intermediate value change — resolve dynamic inputs and send back
// Skip empty values (fired on initial mount before user interaction)
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
if (storedInputs && values && Object.keys(values).length > 0) {
const ctx = this.#newCtx(context);
const callArgs: CallPromptFormDynamicArgs = { values };
applyDynamicFormInput(ctx, storedInputs, callArgs)
.then((resolvedInputs) => {
const stripped = stripDynamicCallbacks(resolvedInputs);
this.#sendPayload(
context,
{ type: "prompt_form_request", ...args, inputs: stripped },
eventToSend.id,
);
})
.catch((err) => {
console.error("Failed to resolve dynamic form inputs", err);
});
}
}
}
};
this.#appToPluginEvents.listen(cb);
// Send the initial event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
});
return reply.values;
},
},
httpResponse: {
find: async (args) => {
const payload = {
type: "find_http_responses_request",
...args,
} as const;
const { httpResponses } = await this.#sendForReply<FindHttpResponsesResponse>(
context,
payload,
);
return httpResponses.map(forPlugin);
},
body: ({ responseId }) => storedBody(responseId),
},
grpcRequest: {
render: async (args) => {
const payload = {
type: "render_grpc_request_request",
...args,
} as const;
const { grpcRequest } = await this.#sendForReply<RenderGrpcRequestResponse>(
context,
payload,
);
return grpcRequest;
},
},
httpRequest: {
getById: async (args) => {
const payload = {
type: "get_http_request_by_id_request",
...args,
} as const;
const { httpRequest } = await this.#sendForReply<GetHttpRequestByIdResponse>(
context,
payload,
);
return httpRequest;
},
send: async (args) => {
const payload = {
type: "send_http_request_request",
...args,
} as const;
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
context,
payload,
);
// 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.
if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
const { done, values } = event.payload as PromptFormResponse;
if (done) {
this.#appToPluginEvents.unlisten(cb);
resolve({ values } as PromptFormResponse);
return;
}
const bytes = decodeBase64Chunk(body);
return {
httpResponse: forPlugin(httpResponse),
body: createResponseBody(
{
responseId: httpResponse.id,
contentLength: bytes.byteLength,
contentType:
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
?.value ?? null,
// The host waited for the whole send before replying.
complete: true,
},
async (offset, length) => bytes.slice(offset, offset + length),
),
};
},
render: async (args) => {
const payload = {
type: "render_http_request_request",
...args,
} as const;
const { httpRequest } = await this.#sendForReply<RenderHttpRequestResponse>(
context,
payload,
);
return httpRequest;
},
list: async (args?: { folderId?: string }) => {
const payload: InternalEventPayload = {
type: "list_http_requests_request",
folderId: args?.folderId,
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
const { httpRequests } = await this.#sendForReply<ListHttpRequestsResponse>(
context,
payload,
);
return httpRequests;
},
create: async (args) => {
const payload = {
type: "upsert_model_request",
model: {
name: "",
method: "GET",
...args,
id: "",
model: "http_request",
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as HttpRequest;
},
update: async (args) => {
const payload = {
type: "upsert_model_request",
model: {
model: "http_request",
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as HttpRequest;
},
delete: async (args) => {
const payload = {
type: "delete_model_request",
model: "http_request",
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as HttpRequest;
},
},
folder: {
list: async () => {
const payload = { type: "list_folders_request" } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders;
},
getById: async (args: { id: string }) => {
const payload = { type: "list_folders_request" } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders.find((f) => f.id === args.id) ?? null;
},
create: async ({ name, ...args }) => {
const payload = {
type: "upsert_model_request",
model: {
...args,
name: name ?? "",
id: "",
model: "folder",
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
update: async (args) => {
const payload = {
type: "upsert_model_request",
model: {
model: "folder",
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
delete: async (args: { id: string }) => {
const payload = {
type: "delete_model_request",
model: "folder",
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as Folder;
},
},
cookies: {
getValue: async (args: GetCookieValueRequest) => {
const payload = {
type: "get_cookie_value_request",
...args,
} as const;
const { value } = await this.#sendForReply<GetCookieValueResponse>(context, payload);
return value;
},
listNames: async () => {
const payload = { type: "list_cookie_names_request" } as const;
const { names } = await this.#sendForReply<ListCookieNamesResponse>(context, payload);
return names;
},
},
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 payload = { type: "template_render_request", ...args } as const;
const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);
// oxlint-disable-next-line no-explicit-any -- That's okay
return result.data as any;
},
},
store: {
get: async <T>(key: string) => {
const payload = { type: "get_key_value_request", key } as const;
const result = await this.#sendForReply<GetKeyValueResponse>(context, payload);
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
set: async <T>(key: string, value: T) => {
const valueStr = JSON.stringify(value);
const payload: InternalEventPayload = {
type: "set_key_value_request",
key,
value: valueStr,
};
await this.#sendForReply<GetKeyValueResponse>(context, payload);
},
delete: async (key: string) => {
const payload = { type: "delete_key_value_request", key } as const;
const result = await this.#sendForReply<DeleteKeyValueResponse>(context, payload);
return result.deleted;
},
},
plugin: {
reload: () => {
this.#sendPayload(context, { type: "reload_response", silent: true }, null);
},
},
workspace: {
list: async () => {
const payload = {
type: "list_open_workspaces_request",
} as InternalEventPayload;
const response = await this.#sendForReply<ListOpenWorkspacesResponse>(context, payload);
return response.workspaces.map((w) => {
// Internal workspace info includes label field not in public API
type WorkspaceInfoInternal = typeof w & { label?: string };
return {
id: w.id,
name: w.name,
// Hide label from plugin authors, but keep it for internal routing
_label: (w as WorkspaceInfoInternal).label as string,
};
});
},
withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {
// Create a new context with the workspace's window label
const newContext: PluginContext = {
...context,
label: workspaceHandle._label || null,
workspaceId: workspaceHandle.id,
};
return this.#newCtx(newContext);
},
},
};
onChange(values ?? {})
.then((next) => {
if (next != null) this.#sendPayload(context, next, eventToSend.id);
})
.catch((err: unknown) => {
console.error("Failed to resolve dynamic form inputs", err);
});
};
this.#appToPluginEvents.listen(cb);
// Sent after the listener is attached, to prevent a race.
this.#sendEvent(eventToSend);
});
},
};
#newCtx(context: PluginContext): Context {
return createPluginContext(this.#transport, context);
}
}
function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
return inputs.map((input) => {
// oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type
const { dynamic: _dynamic, ...rest } = input as any;
if ("inputs" in rest && Array.isArray(rest.inputs)) {
rest.inputs = stripDynamicCallbacks(rest.inputs);
}
return rest as FormInput;
});
}
function genId(len = 5): string {
const alphabet = "01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
-22
View File
@@ -1,22 +0,0 @@
import type { TemplateFunctionPlugin } from "@yaakapp/api";
export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin,
): TemplateFunctionPlugin {
const migratedArgs = f.args.map((a) => {
if (a.type === "select") {
// Migrate old options that had 'name' instead of 'label'
type LegacyOption = { label?: string; value: string; name?: string };
a.options = a.options.map((o) => {
const legacy = o as LegacyOption;
return {
label: legacy.label ?? legacy.name ?? "",
value: legacy.value,
};
});
}
return a;
});
return { ...f, args: migratedArgs };
}
+275
View File
@@ -0,0 +1,275 @@
# The Yaak plugin sandbox
A QuickJS interpreter, a small set of globals, and one function that calls the
host. That is the whole runtime. Everything else a plugin does — read a request,
send one, store a token, ask the user something — is a message the host chose to
answer.
This document is the contract. It is written to be implementable twice: once
here, in wasm, for the browser, and once in Rust with `rquickjs`, for the desktop
and the CLI. **If the two hosts disagree about anything below, that is a bug in
whichever one drifted, not a platform difference to work around.** The promise
to plugin authors is that there is one sandbox and it behaves the same
everywhere; a promise like that is only worth making if it is enforceable, which
is why the browser runs QuickJS rather than the Worker's own JavaScript engine.
## The engine
**quickjs-ng**, and only quickjs-ng.
There is no real choice: `rquickjs` — the Rust binding the desktop host will use
— vendors quickjs-ng as a git submodule and offers no alternative. Picking
Bellard's upstream for the browser would mean the two hosts run different
engines, which is exactly the thing this design exists to prevent.
| | Version | Notes |
|---|---|---|
| Browser (this package) | quickjs-ng **0.12.1** | via `@jitl/quickjs-ng-wasmfile-release-sync` 0.32.0 |
| Desktop (planned) | quickjs-ng **0.15.1** | via `rquickjs` 0.12.2 |
**The version skew is a known gap, and closing it is slice-2 work.** Three minor
versions is small — the differences are bug fixes and `Temporal` progress, not
semantics anything here depends on — but "identical everywhere" is not a claim
that survives being approximate indefinitely. Whoever builds the Rust host
should pin both sides to the same tag and add a test that asserts the version
string matches.
### Why the sync build, not ASYNCIFY
`quickjs-emscripten` ships an ASYNCIFY variant that lets guest code call an async
host function *synchronously*. We use the plain sync build instead:
- ASYNCIFY is about twice the wasm size (1.08 MB vs 529 KB) and, measured,
**2.2x slower**.
- It can only suspend for one host call at a time. A runtime that runs several
plugins would have to hold one wasm instance per in-flight call.
- We do not need it. The guest gets real `await` anyway: a host function returns
a QuickJS deferred promise, the host resolves it, and the host drains the job
queue. `ctx.store.get(...)` is an ordinary `await` inside a plugin.
The only thing lost is a host call that *looks* synchronous to the guest, and no
Yaak plugin wants one — the whole `ctx` API has been async since it existed.
## What exists inside the sandbox
QuickJS gives you the language and nothing else. Everything below is either
installed by `src/guest/globals.ts` or absent. **Both hosts must install exactly
this list.**
### From the engine
`Object`, `Array`, `Function`, `String`, `Number`, `Boolean`, `Symbol`, `Math`,
`JSON`, `Date`, `RegExp`, `Error` and subclasses, `Map`, `Set`, `WeakMap`,
`WeakSet`, `WeakRef`, `Promise`, `Proxy`, `Reflect`, `BigInt`, `ArrayBuffer`,
`SharedArrayBuffer`, `DataView`, all `TypedArray`s, `globalThis`,
`queueMicrotask`, `performance`.
Language level is ES2023 plus most of ES2024 — `Object.groupBy`,
`Array.prototype.at`, `String.prototype.replaceAll`, async generators, private
fields, `??=` all work.
### Installed by the runtime
| Global | Notes |
|---|---|
| `console` | `.log/.info/.warn/.error/.debug/.trace`. Arguments are formatted to a string **inside** the sandbox, so only strings cross out — a cycle or an exotic prototype is the guest's problem, not the host's. |
| `setTimeout` / `clearTimeout` | The host holds the real timer; QuickJS has no clock to wake on. A sandbox torn down mid-wait takes its pending timers with it. |
| `TextEncoder` / `TextDecoder` | UTF-8 only. Pure JavaScript, in-sandbox — a bridge would cost a copy each way. Lone surrogates encode to U+FFFD, matching the standard. |
| `btoa` / `atob` | Latin-1, same narrow contract as the browser's. |
### Deliberately absent
`fetch`, `XMLHttpRequest`, `WebSocket`, `crypto`, `structuredClone`, `URL`,
`URLSearchParams`, `setInterval`, `require`, `module`, `process`, `Buffer`,
`std`, `os`, and every Node built-in.
- **Network and storage are absent because they are `ctx`'s job.** A plugin that
could open its own socket would defeat the point of the sandbox and would not
work in a browser anyway.
- **`setInterval` is absent** because an interval is a timer that rearms and
nothing in a plugin should be polling. Build one from `setTimeout`, visibly.
- **`crypto` is absent, and this is the one real gap.** The decided direction is
pure-JavaScript `@noble/*` inside the sandbox: audited, dependency-free,
identical on both hosts, no host API to keep in sync. A `yaak.crypto` builtin
is the escape hatch **if** a hot path is measured, not before. Concretely,
`template-function-uuid` does not run in the sandbox today because its `uuid`
dependency reaches for `node:crypto`; that is a slice-2 conversion, not a
missing capability.
- **`URL` is absent** only because nothing has needed it yet. It is a reasonable
future addition; it must be added to both hosts together.
## The module contract
A module arrives as **source text**, not a file — there is no filesystem, and in
a browser there could not be one.
It is evaluated as CommonJS, via `new Function("module", "exports", "require", source)`,
and must assign `module.exports.plugin` (or `module.exports.default`). `new
Function` rather than an ES module is deliberate: the bundle's top-level names
cannot collide with the shell's, and the source needs no loader hook.
`require` exists **only to throw**, naming the specifier. A bundle that still
calls it was not bundled for this target, and saying which module is missing
beats an `undefined` that surfaces ten frames later.
Bundling requirements: CommonJS, no external modules, no Node built-ins, ES2022.
`scripts/bundle-sandbox-plugins.mjs` does this today; what a real
`yaakcli build --target sandbox` needs is listed at the bottom of that file.
## The host interface
Four functions, installed on `globalThis` before any plugin code runs. A Rust
host must expose the same four with the same names and shapes.
| Function | Direction | Shape |
|---|---|---|
| `__yaak_call(envelopeJson)` | guest → host | Returns a **promise** of the reply JSON. The one door out. |
| `__yaak_log(level, message)` | guest → host | Both strings. Fire and forget. |
| `__yaak_timer_start(id, ms)` | guest → host | Host calls `__yaak_guest.fireTimer(id)` when due. |
| `__yaak_timer_cancel(id)` | guest → host | |
And the guest exposes `globalThis.__yaak_guest`:
| Method | Shape |
|---|---|
| `load(source, pluginRefId)` | Evaluate a module. Throws if it exports no `plugin`. |
| `summary()` | What the module contributes, as plain data. |
| `dispatch(envelopeJson)` | Returns a promise of the reply payload JSON. |
| `fireTimer(id)` | |
### Envelopes
Both directions carry `InternalEventPayload` from
`crates/yaak-plugins/src/events.rs`, **unchanged**. That is what makes a plugin
unable to tell which runtime it is in.
```jsonc
// dispatch, host → guest
{ "context": { "id": "...", "label": null, "workspaceId": "..." },
"payload": { "type": "call_template_function_request", "name": "...", "args": { ... } } }
// __yaak_call, guest → host
{ "pluginRefId": "auth-bearer",
"context": { ... },
"payload": { "type": "get_key_value_request", "key": "token" } }
```
`pluginRefId` rides on outgoing calls because one host handler serves every
loaded module, and a plugin's stored state is namespaced by which plugin it is —
the same namespacing `build_shared_reply` does in `crates/yaak/src/plugin_events.rs`.
A throw inside a plugin becomes `{"type":"error_response","error":"..."}`, never
a crash and never silence: whatever asked gets a message.
## The `ctx` API
Built entirely out of `__yaak_call`. See `src/guest/context.ts` — it is the same
surface the Node runtime's `PluginInstance` builds, so it is not repeated here.
What differs is which calls a **host** answers. The browser host answers a
deliberately short list (`packages/platform/src/web/plugins.ts`) and refuses the
rest by name. Refusing by name matters: a plugin that needs something it cannot
have should fail with a sentence someone can act on.
Answered in the browser today: `get_key_value`, `set_key_value`,
`delete_key_value`, `show_toast`. Everything else — sends, model reads and
writes, prompts, response bodies, window info — refuses. Those are capability
decisions, not oversights, and each should be added one at a time.
`ctx.window.openUrl` throws in *every* sandbox host: a plugin-opened window is a
desktop affordance with no browser equivalent, and handing back a handle whose
`close()` does nothing would be worse.
## Isolation and limits
One runtime per worker, **one context per 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. Sharing the runtime is deliberate: the engine and
its wasm instance are the expensive part; contexts are not.
| Limit | Value | Why |
|---|---|---|
| Memory | 256 MB per runtime | Sized for an importer holding a large document and the objects it parses into. |
| Stack | 2 MB | Deep recursion becomes a guest stack overflow, not a worker crash. |
| Synchronous execution | 60 s | A watchdog for `while (true)`, **not** a limit on real work. |
The watchdog bounds *synchronous* execution only. A plugin awaiting the host is
not looping, so the clock stops for the duration of a host call and restarts
when the guest resumes. It is generous because it costs nothing to be: plugins
run in their own worker, so one stuck there blocks no database command and no
frame. It is sized off the slowest real work measured — GitHub's 12.3 MB OpenAPI
description takes about 2.5 s (`bench/import.mjs`) — with room for a document
several times larger before a legitimate import looks like a hang.
## Where the sandbox runs, and why not in the database worker
In the browser: a **dedicated worker owned by the tab**, separate from the
SharedWorker that owns the database.
- Plugin work is slow by design, and the database worker answers every tab's
commands synchronously. A large import in there would stall every other tab's
reads.
- A plugin that never returns can be ended with `terminate()`. You cannot do
that to the worker holding the database.
- The capabilities plugins actually ask for — a prompt, a toast, the active
request — belong to a tab, not to a database. Routing through the tab is the
shorter path, not a detour.
The cost is that `ctx.store` goes worker → tab → database worker. It is a message
either way, and this is the direction where a stuck plugin costs nothing.
Template rendering is the one flow that runs backwards: rendering happens in the
engine, in the database worker, but the functions it calls live here. So the
engine is handed a callback that asks the tab, which asks the sandbox. See
`templateBridge` in `packages/platform/src/web/worker.ts`.
## Plugins versus scripts
The shell is **not plugin-shaped underneath**. `load` takes source; `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 the runtime.
That matters for one reason. A plugin is installed, so someone consented to it,
and a plugin may one day escalate to a full Node runtime by asking. **A script
arrives inside a workspace — as data, through an import, a git sync, a shared
repository — with no consent moment at all.** So scripts get this sandbox and
only this sandbox, forever, regardless of feature pressure. Any capability added
below must be evaluated against the script case, which is the stricter one:
"would I want this to run because someone opened a workspace a stranger sent
them?"
Expected differences when scripts arrive, none of them built yet:
- A different payload set (`run_script_request` and friends) — same envelope.
- A tighter host-call allowlist. A script should probably not reach `ctx.store`
at all, and certainly not another plugin's namespace.
- A much shorter watchdog. A pre-request script that runs for a minute is broken;
an importer that does is working.
## Performance
QuickJS is an interpreter with no JIT. Measured on GitHub's 12.3 MB OpenAPI
description (1220 requests imported, **identical output** in both engines):
| | First run | Best of 6 |
|---|---|---|
| Node (V8) | 304 ms | 164 ms |
| QuickJS sandbox | 2503 ms | 2017 ms |
That is **8x on the first run** and about **12x once V8 has compiled** — well
inside the 1050x folklore, and the first-run number is the one a user waits for
because an import happens once. Reproduce with:
```bash
node packages/plugin-sandbox/bench/import.mjs <spec.json> 6
```
**Conclusion: importers stay in the sandbox.** 2.5 s in a worker, behind a
progress state, for the largest public API description that exists, is a fine
trade for one runtime everywhere. Revisit if a real document is measured
materially worse — the escape hatch is a host builtin for the hot path, not a
second runtime.
Boot cost is small: about 80140 ms to instantiate the wasm and load a plugin,
paid once and lazily, so a session that never touches a plugin never pays it.
The wasm is 529 KB, next to the 4.3 MB SQLite one.
+129
View File
@@ -0,0 +1,129 @@
/**
* 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.
*
* node packages/plugin-sandbox/bench/import.mjs <spec.json> [iterations]
*/
import { build } from "esbuild";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { pathToFileURL } from "node:url";
import { bundlePlugin } from "../../../scripts/bundle-sandbox-plugins.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const PLUGIN = "importer-openapi";
const specPath = process.argv[2];
const iterations = Number(process.argv[3] ?? 3);
if (specPath == null) {
console.error("usage: node bench/import.mjs <spec.json> [iterations]");
process.exit(1);
}
const spec = readFileSync(specPath, "utf8");
console.log(`Spec: ${specPath} (${(spec.length / 1024 / 1024).toFixed(1)} MB)`);
console.log(`Iterations: ${iterations}\n`);
async function loadHost() {
const outDir = join(root, "node_modules", ".cache", "yaak-plugin-sandbox");
mkdirSync(outDir, { recursive: true });
const outfile = join(outDir, "host.mjs");
await build({
entryPoints: [join(root, "packages/plugin-sandbox/src/host/sandbox.ts")],
bundle: true,
format: "esm",
platform: "node",
target: "node22",
outfile,
// Resolved from the repo at run time, so the wasm variant is the real one.
external: ["@jitl/*", "quickjs-emscripten-core"],
});
return import(pathToFileURL(outfile).href);
}
const ctxStub = { id: "bench", label: null, workspaceId: "wk_bench" };
function stats(times) {
const sorted = [...times].sort((a, b) => a - b);
const mean = times.reduce((a, b) => a + b, 0) / times.length;
return { min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], mean };
}
function report(label, times, resourceCount) {
const { min, median } = stats(times);
console.log(
`${label.padEnd(20)} first ${times[0].toFixed(0).padStart(5)} ms ` +
`best ${min.toFixed(0).padStart(5)} ms ` +
`median ${median.toFixed(0).padStart(5)} ms (${resourceCount} requests)`,
);
// 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 };
}
/* --------------------------------- Node ---------------------------------- */
const nodeTimes = [];
let nodeCount = 0;
{
const { createRequire } = await import("node:module");
const require = createRequire(join(root, "package.json"));
const mod = require(join(root, "plugins", PLUGIN, "build", "index.js"));
const plugin = mod.plugin ?? mod.default;
for (let i = 0; i < iterations; i++) {
const started = performance.now();
const result = await plugin.importer.onImport(ctxStub, { text: spec });
nodeTimes.push(performance.now() - started);
nodeCount = result?.resources?.httpRequests?.length ?? 0;
}
}
const node = report("Node (V8)", nodeTimes, nodeCount);
/* -------------------------------- QuickJS -------------------------------- */
const quickTimes = [];
let quickCount = 0;
{
const { PluginSandboxHost } = await loadHost();
const source = await bundlePlugin(PLUGIN);
const host = new PluginSandboxHost(
async () => JSON.stringify({ type: "empty_response" }),
(log) => console.error(`[${log.level}] ${log.message}`),
);
const loadStarted = performance.now();
await host.load(PLUGIN, source);
console.log(`(sandbox boot + load: ${(performance.now() - loadStarted).toFixed(0)} ms)\n`);
for (let i = 0; i < iterations; i++) {
const started = performance.now();
const reply = JSON.parse(
await host.dispatch(
PLUGIN,
JSON.stringify({ context: ctxStub, payload: { type: "import_request", content: spec } }),
),
);
quickTimes.push(performance.now() - started);
if (reply.type === "error_response") throw new Error(reply.error);
quickCount = reply.resources?.httpRequests?.length ?? 0;
}
host.dispose();
}
const quick = report("QuickJS (sandbox)", quickTimes, quickCount);
console.log(
`\nFirst run (what a user waits for): ${(quick.first / 1000).toFixed(1)}s in the sandbox ` +
`vs ${(node.first / 1000).toFixed(1)}s in Node — ${(quick.first / node.first).toFixed(1)}x.`,
);
console.log(
`Best run (both warm): ${(quick.best / node.best).toFixed(1)}x, which is the ceiling once V8 has compiled.`,
);
if (nodeCount !== quickCount) {
console.log(`WARNING: request counts differ (${nodeCount} vs ${quickCount}) — not the same work.`);
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 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";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const outDir = join(here, "src", "generated");
const result = await build({
entryPoints: [join(here, "src", "guest", "index.ts")],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: "es2022",
minify: false,
legalComments: "none",
});
const source = result.outputFiles[0].text;
mkdirSync(outDir, { recursive: true });
writeFileSync(
join(outDir, "guest.ts"),
[
"// Generated by build-guest.mjs. Do not edit.",
"//",
"// The runtime shell, as source text, for evaluation inside QuickJS.",
"// Regenerate with `npm run build --workspace @yaakapp-internal/plugin-sandbox`.",
"",
`export const GUEST_SOURCE = ${JSON.stringify(source)};`,
"",
].join("\n"),
);
console.log(`Bundled guest shell: ${(source.length / 1024).toFixed(1)} KB`);
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@yaakapp-internal/plugin-sandbox",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"scripts": {
"bootstrap": "npm run build",
"build": "node build-guest.mjs"
},
"dependencies": {
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
"quickjs-emscripten-core": "^0.32.0"
},
"devDependencies": {
"esbuild": "^0.28.0"
}
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,238 @@
/**
* 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;
declare const __yaak_timer_start: (id: number, ms: number) => void;
declare const __yaak_timer_cancel: (id: number) => void;
/* -------------------------------- console -------------------------------- */
/** Formatted in here, so only strings cross the boundary. */
function formatArgs(args: unknown[]): string {
return args
.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;
try {
return JSON.stringify(arg, replacer()) ?? String(arg);
} catch {
return String(arg);
}
})
.join(" ");
}
function replacer(): (key: string, value: unknown) => unknown {
const seen = new WeakSet<object>();
return (_key, value) => {
if (typeof value === "bigint") return `${value}n`;
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
};
}
function installConsole(): void {
const log = (level: string) => (...args: unknown[]) => __yaak_log(level, formatArgs(args));
(globalThis as Record<string, unknown>).console = {
log: log("log"),
info: log("info"),
warn: log("warn"),
error: log("error"),
debug: log("debug"),
trace: log("debug"),
};
}
/* --------------------------------- timers -------------------------------- */
/** QuickJS has no clock to wake on, so the host holds the real timer. */
const timerCallbacks = new Map<number, () => void>();
let nextTimerId = 1;
function installTimers(): void {
const g = globalThis as Record<string, unknown>;
g.setTimeout = (callback: (...a: unknown[]) => void, ms?: number, ...args: unknown[]) => {
const id = nextTimerId++;
timerCallbacks.set(id, () => callback(...args));
__yaak_timer_start(id, Math.max(0, Number(ms) || 0));
return id;
};
g.clearTimeout = (id: number) => {
if (!timerCallbacks.delete(id)) return;
__yaak_timer_cancel(id);
};
// 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 comes due. */
function fireTimer(id: number): void {
const callback = timerCallbacks.get(id);
timerCallbacks.delete(id);
callback?.();
}
/* ------------------------------- text codecs ------------------------------ */
class SandboxTextEncoder {
readonly encoding = "utf-8";
encode(input = ""): Uint8Array {
const out: number[] = [];
for (let i = 0; i < input.length; i++) {
let code = input.charCodeAt(i);
// 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) {
code = (code - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000;
i++;
} else {
code = 0xfffd;
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
code = 0xfffd;
}
if (code < 0x80) out.push(code);
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
else if (code < 0x10000)
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
else
out.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
}
return new Uint8Array(out);
}
}
class SandboxTextDecoder {
readonly encoding = "utf-8";
decode(input?: ArrayBuffer | ArrayBufferView): string {
if (input == null) return "";
const bytes =
input instanceof Uint8Array
? input
: ArrayBuffer.isView(input)
? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
: new Uint8Array(input);
let out = "";
for (let i = 0; i < bytes.length; ) {
const byte = bytes[i]!;
let code: number;
let size: number;
if (byte < 0x80) {
code = byte;
size = 1;
} else if ((byte & 0xe0) === 0xc0) {
code = byte & 0x1f;
size = 2;
} else if ((byte & 0xf0) === 0xe0) {
code = byte & 0x0f;
size = 3;
} else if ((byte & 0xf8) === 0xf0) {
code = byte & 0x07;
size = 4;
} else {
out += "";
i++;
continue;
}
if (i + size > bytes.length) {
out += "";
break;
}
for (let k = 1; k < size; k++) {
const cont = bytes[i + k]!;
if ((cont & 0xc0) !== 0x80) {
code = -1;
break;
}
code = (code << 6) | (cont & 0x3f);
}
i += size;
if (code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) out += "";
else if (code < 0x10000) out += String.fromCharCode(code);
else {
const c = code - 0x10000;
out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
}
}
return out;
}
}
function installTextCodecs(): void {
const g = globalThis as Record<string, unknown>;
g.TextEncoder = SandboxTextEncoder;
g.TextDecoder = SandboxTextDecoder;
}
/* ------------------------------ base64 helpers ---------------------------- */
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function installBase64(): void {
const g = globalThis as Record<string, unknown>;
g.btoa = (input: string): string => {
let out = "";
for (let i = 0; i < input.length; i += 3) {
const a = input.charCodeAt(i);
const b = input.charCodeAt(i + 1);
const c = input.charCodeAt(i + 2);
if (a > 0xff || b > 0xff || c > 0xff) {
throw new Error("btoa: string contains characters outside of the Latin1 range");
}
const chunk = (a << 16) | ((Number.isNaN(b) ? 0 : b) << 8) | (Number.isNaN(c) ? 0 : c);
out += B64[(chunk >> 18) & 63]! + B64[(chunk >> 12) & 63]!;
out += Number.isNaN(b) ? "=" : B64[(chunk >> 6) & 63]!;
out += Number.isNaN(c) ? "=" : B64[chunk & 63]!;
}
return out;
};
g.atob = (input: string): string => {
const clean = input.replace(/[\t\n\f\r ]/g, "").replace(/=+$/, "");
let out = "";
let bits = 0;
let acc = 0;
for (const ch of clean) {
const value = B64.indexOf(ch);
if (value < 0) throw new Error("atob: string contains invalid characters");
acc = (acc << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
out += String.fromCharCode((acc >> bits) & 0xff);
}
}
return out;
};
}
export function installGlobals(): { fireTimer: (id: number) => void } {
installConsole();
installTimers();
installTextCodecs();
installBase64();
return { fireTimer };
}
+333
View File
@@ -0,0 +1,333 @@
/**
* What QuickJS evaluates before any untrusted code does: install globals, load
* one module, answer events against it.
*
* `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";
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
} from "@yaakapp-internal/lib/templateFunction";
import {
applyDynamicFormInput,
migrateTemplateFunctionSelectOptions,
stripDynamicCallbacks,
} from "@yaakapp-internal/lib/pluginForms";
import type {
GrpcRequestAction,
HttpAuthenticationAction,
HttpRequestAction,
ImportResources,
InternalEventPayload,
PluginContext,
TemplateFunction,
} from "@yaakapp-internal/plugins";
import {
createPluginContext,
type PluginTransport,
} from "@yaakapp-internal/lib/pluginContext";
import { installGlobals } from "./globals";
declare const __yaak_call: (payloadJson: string) => Promise<string>;
const { fireTimer } = installGlobals();
let mod: PluginDefinition = {};
let pluginRefId = "";
/**
* `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: {} };
const require = (specifier: string) => {
throw new Error(
`Module "${specifier}" is not available in the sandbox runtime. ` +
`Plugins must be bundled with no external or built-in modules.`,
);
};
// 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);
const loaded = (module.exports.plugin ?? module.exports.default) as PluginDefinition | undefined;
if (loaded == null || typeof loaded !== "object") {
throw new Error("Module did not export `plugin`");
}
mod = loaded;
pluginRefId = refId;
}
function summary(): Record<string, unknown> {
return {
templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),
authentication: mod.authentication?.name ?? null,
importer: mod.importer != null,
filter: mod.filter != null,
themes: (mod.themes ?? []).length,
httpRequestActions: (mod.httpRequestActions ?? []).length,
workspaceActions: (mod.workspaceActions ?? []).length,
folderActions: (mod.folderActions ?? []).length,
grpcRequestActions: (mod.grpcRequestActions ?? []).length,
websocketRequestActions: (mod.websocketRequestActions ?? []).length,
};
}
const EMPTY: InternalEventPayload = { type: "empty_response" };
/**
* 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.
*/
/**
* 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) {
// 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}`);
}
const { type: _type, ...rest } = reply;
return rest as Record<string, unknown>;
},
notify(context, payload) {
void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
},
};
async function dispatch(
context: PluginContext,
payload: InternalEventPayload,
): Promise<InternalEventPayload> {
const ctx = createPluginContext(transport, context);
if (payload.type === "boot_request") {
await mod.init?.(ctx);
return { type: "boot_response" };
}
if (payload.type === "terminate_request") {
await mod.dispose?.();
return { type: "terminate_response" };
}
if (payload.type === "import_request" && typeof mod.importer?.onImport === "function") {
const reply = await mod.importer.onImport(ctx, { text: payload.content });
if (reply != null) {
return { type: "import_response", resources: reply.resources as ImportResources };
}
return EMPTY;
}
if (payload.type === "filter_request" && typeof mod.filter?.onFilter === "function") {
const reply = await mod.filter.onFilter(ctx, {
filter: payload.filter,
payload: payload.content,
mimeType: payload.type,
});
return { type: "filter_response", ...reply };
}
if (payload.type === "get_themes_request" && Array.isArray(mod.themes)) {
return { type: "get_themes_response", themes: mod.themes };
}
/* --------------------------- template functions -------------------------- */
if (
payload.type === "get_template_function_summary_request" &&
Array.isArray(mod.templateFunctions)
) {
const functions: TemplateFunction[] = mod.templateFunctions.map((f) => ({
...migrateTemplateFunctionSelectOptions(f),
onRender: undefined,
}));
return { type: "get_template_function_summary_response", pluginRefId, functions };
}
if (
payload.type === "get_template_function_config_request" &&
Array.isArray(mod.templateFunctions)
) {
const found = mod.templateFunctions.find((f) => f.name === payload.name);
if (found == null) return EMPTY;
const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: undefined };
payload.values = applyFormInputDefaults(fn.args, payload.values);
const resolved = await applyDynamicFormInput(ctx, fn.args, {
...payload,
purpose: "preview",
} as const);
return {
type: "get_template_function_config_response",
pluginRefId,
function: { ...fn, args: stripDynamicCallbacks(resolved) },
};
}
if (payload.type === "call_template_function_request" && Array.isArray(mod.templateFunctions)) {
const fn = mod.templateFunctions.find((f) => f.name === payload.name);
if (
payload.args.purpose === "preview" &&
(fn?.previewType === "click" || fn?.previewType === "none")
) {
return {
type: "call_template_function_response",
value: null,
error: "Live preview disabled for this function",
};
}
if (typeof fn?.onRender === "function") {
const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);
const values = applyFormInputDefaults(resolved, payload.args.values);
const error = validateTemplateFunctionArgs(fn.name, resolved, values);
if (error && payload.args.purpose !== "preview") {
return { type: "call_template_function_response", value: null, error };
}
const result = await fn.onRender(ctx, { ...payload.args, values });
return { type: "call_template_function_response", value: result ?? null };
}
}
/* --------------------------- http authentication ------------------------- */
if (payload.type === "get_http_authentication_summary_request" && mod.authentication) {
return { type: "get_http_authentication_summary_response", ...mod.authentication };
}
if (payload.type === "get_http_authentication_config_request" && mod.authentication) {
const { args, actions } = mod.authentication;
payload.values = applyFormInputDefaults(args, payload.values);
const resolved = await applyDynamicFormInput(ctx, args, payload);
const resolvedActions: HttpAuthenticationAction[] = [];
// oxlint-disable-next-line unbound-method
for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);
return {
type: "get_http_authentication_config_response",
args: stripDynamicCallbacks(resolved),
actions: resolvedActions,
pluginRefId,
};
}
if (payload.type === "call_http_authentication_request" && mod.authentication) {
const auth = mod.authentication;
if (typeof auth.onApply === "function") {
const resolved = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(resolved, payload.values);
return { type: "call_http_authentication_response", ...(await auth.onApply(ctx, payload)) };
}
}
if (payload.type === "call_http_authentication_action_request" && mod.authentication != null) {
const action = mod.authentication.actions?.[payload.index];
if (typeof action?.onSelect === "function") {
await action.onSelect(ctx, payload.args);
return EMPTY;
}
}
/* --------------------------------- actions ------------------------------- */
if (payload.type === "get_http_request_actions_request" && Array.isArray(mod.httpRequestActions)) {
const actions: HttpRequestAction[] = mod.httpRequestActions.map((a) => ({
...a,
onSelect: undefined,
}));
return { type: "get_http_request_actions_response", pluginRefId, actions };
}
if (
payload.type === "get_websocket_request_actions_request" &&
Array.isArray(mod.websocketRequestActions)
) {
const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: undefined }));
return { type: "get_websocket_request_actions_response", pluginRefId, actions };
}
if (payload.type === "get_grpc_request_actions_request" && Array.isArray(mod.grpcRequestActions)) {
const actions: GrpcRequestAction[] = mod.grpcRequestActions.map((a) => ({
...a,
onSelect: undefined,
}));
return { type: "get_grpc_request_actions_response", pluginRefId, actions };
}
if (payload.type === "get_workspace_actions_request" && Array.isArray(mod.workspaceActions)) {
const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: undefined }));
return { type: "get_workspace_actions_response", pluginRefId, actions };
}
if (payload.type === "get_folder_actions_request" && Array.isArray(mod.folderActions)) {
const actions = mod.folderActions.map((a) => ({ ...a, onSelect: undefined }));
return { type: "get_folder_actions_response", pluginRefId, actions };
}
const called = await callAction(ctx, payload);
if (called) return EMPTY;
return EMPTY;
}
async function callAction(
ctx: ReturnType<typeof createPluginContext>,
payload: InternalEventPayload,
): Promise<boolean> {
const lists = {
call_http_request_action_request: mod.httpRequestActions,
call_websocket_request_action_request: mod.websocketRequestActions,
call_grpc_request_action_request: mod.grpcRequestActions,
call_workspace_action_request: mod.workspaceActions,
call_folder_action_request: mod.folderActions,
} as const;
const list = lists[payload.type as keyof typeof lists];
if (!Array.isArray(list)) return false;
const action = list[(payload as { index: number }).index];
if (typeof action?.onSelect !== "function") return false;
await action.onSelect(ctx, (payload as { args: never }).args);
return true;
}
(globalThis as Record<string, unknown>).__yaak_guest = {
load,
summary,
fireTimer,
dispatch: async (envelopeJson: string): Promise<string> => {
const { context, payload } = JSON.parse(envelopeJson) as {
context: PluginContext;
payload: InternalEventPayload;
};
try {
return JSON.stringify(await dispatch(context, payload));
} catch (err) {
// 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 });
}
},
};
+312
View File
@@ -0,0 +1,312 @@
/**
* 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";
import {
newQuickJSWASMModuleFromVariant,
type QuickJSContext,
type QuickJSRuntime,
type QuickJSWASMModule,
} from "quickjs-emscripten-core";
import { GUEST_SOURCE } from "../generated/guest";
const MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
const STACK_SIZE_BYTES = 2 * 1024 * 1024;
/** 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>;
export interface SandboxLog {
pluginRefId: string;
level: string;
message: string;
}
let modulePromise: Promise<QuickJSWASMModule> | null = null;
function quickjs(): Promise<QuickJSWASMModule> {
modulePromise ??= newQuickJSWASMModuleFromVariant(variant);
return modulePromise;
}
class LoadedPlugin {
readonly pluginRefId: string;
readonly context: QuickJSContext;
/** Set while a dispatch is running; the interrupt handler reads it. */
deadline: number | null = null;
private nextTimer = new Map<number, ReturnType<typeof setTimeout>>();
private disposed = false;
constructor(pluginRefId: string, context: QuickJSContext) {
this.pluginRefId = pluginRefId;
this.context = context;
}
touch(): void {
if (this.deadline != null) this.deadline = Date.now() + SYNC_BUDGET_MS;
}
startTimer(id: number, ms: number, fire: () => void): void {
this.nextTimer.set(
id,
setTimeout(() => {
this.nextTimer.delete(id);
if (!this.disposed) fire();
}, ms),
);
}
cancelTimer(id: number): void {
const handle = this.nextTimer.get(id);
if (handle == null) return;
clearTimeout(handle);
this.nextTimer.delete(id);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
for (const handle of this.nextTimer.values()) clearTimeout(handle);
this.nextTimer.clear();
this.context.dispose();
}
}
export class PluginSandboxHost {
private runtime: QuickJSRuntime | null = null;
private readonly plugins = new Map<string, LoadedPlugin>();
constructor(
private readonly onHostRequest: HostRequestHandler,
private readonly onLog: (log: SandboxLog) => void,
) {}
async load(pluginRefId: string, source: string): Promise<Record<string, unknown>> {
const module = await quickjs();
if (this.runtime == null) {
this.runtime = module.newRuntime();
this.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES);
this.runtime.setMaxStackSize(STACK_SIZE_BYTES);
this.runtime.setInterruptHandler(() => {
const now = Date.now();
for (const plugin of this.plugins.values()) {
if (plugin.deadline != null && now > plugin.deadline) return true;
}
return false;
});
}
this.plugins.get(pluginRefId)?.dispose();
const plugin = new LoadedPlugin(pluginRefId, this.runtime.newContext());
this.plugins.set(pluginRefId, plugin);
try {
this.installHostFunctions(plugin);
this.evalOrThrow(plugin, GUEST_SOURCE, "yaak:sandbox-shell");
await this.callGuest(plugin, "load", [source, pluginRefId]);
return await this.callGuest(plugin, "summary", []);
} catch (err) {
plugin.dispose();
this.plugins.delete(pluginRefId);
throw err;
}
}
loaded(): string[] {
return Array.from(this.plugins.keys());
}
unload(pluginRefId: string): void {
this.plugins.get(pluginRefId)?.dispose();
this.plugins.delete(pluginRefId);
}
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}\``);
const reply = await this.callGuest(plugin, "dispatch", [envelopeJson]);
return reply as unknown as string;
}
dispose(): void {
for (const plugin of this.plugins.values()) plugin.dispose();
this.plugins.clear();
this.runtime?.dispose();
this.runtime = null;
}
/* ------------------------------ internals ------------------------------- */
private installHostFunctions(plugin: LoadedPlugin): void {
const { context } = plugin;
const define = (name: string, fn: Parameters<QuickJSContext["newFunction"]>[1]) => {
const handle = context.newFunction(name, fn);
context.setProp(context.global, name, handle);
handle.dispose();
};
define("__yaak_log", (levelHandle, messageHandle) => {
this.onLog({
pluginRefId: plugin.pluginRefId,
level: context.getString(levelHandle),
message: context.getString(messageHandle),
});
});
define("__yaak_timer_start", (idHandle, msHandle) => {
const id = context.getNumber(idHandle);
plugin.startTimer(id, context.getNumber(msHandle), () => {
plugin.touch();
this.callGuestSync(plugin, "fireTimer", [id]);
this.pump(plugin);
});
});
define("__yaak_timer_cancel", (idHandle) => {
plugin.cancelTimer(context.getNumber(idHandle));
});
define("__yaak_call", (envelopeHandle) => {
const envelope = context.getString(envelopeHandle);
const wasWatching = plugin.deadline != null;
plugin.deadline = null;
const settle = this.onHostRequest(envelope).then(
(reply) => {
if (wasWatching) plugin.touch();
return context.newString(reply);
},
(err: unknown) => {
if (wasWatching) plugin.touch();
return context.newError(err instanceof Error ? err.message : String(err));
},
);
const deferred = context.newPromise(settle);
void deferred.settled.then(() => {
this.pump(plugin);
deferred.dispose();
});
return deferred.handle;
});
}
private pump(plugin: LoadedPlugin): void {
const result = this.runtime?.executePendingJobs();
if (result?.error != null) {
this.onLog({
pluginRefId: plugin.pluginRefId,
level: "error",
message: `Unhandled error in sandbox: ${result.error.consume(
plugin.context.dump.bind(plugin.context),
)}`,
});
}
}
private evalOrThrow(plugin: LoadedPlugin, source: string, filename: string): void {
plugin.deadline = Date.now() + SYNC_BUDGET_MS;
try {
const result = plugin.context.evalCode(source, filename);
if (result.error != null) {
throw this.toError(plugin, result.error.consume(plugin.context.dump.bind(plugin.context)));
}
result.value.dispose();
} finally {
plugin.deadline = null;
}
}
private async callGuest(
plugin: LoadedPlugin,
method: string,
args: (string | number)[],
// oxlint-disable-next-line no-explicit-any -- the caller knows the guest's shape
): Promise<any> {
const { context } = plugin;
plugin.deadline = Date.now() + SYNC_BUDGET_MS;
const guest = context.getProp(context.global, "__yaak_guest");
const fn = context.getProp(guest, method);
const argHandles = args.map((a) =>
typeof a === "string" ? context.newString(a) : context.newNumber(a),
);
try {
const called = context.callFunction(fn, guest, ...argHandles);
if (called.error != null) {
throw this.toError(plugin, called.error.consume(context.dump.bind(context)));
}
const value = called.value;
const state = context.getPromiseState(value);
if (state.type !== "fulfilled" || state.notAPromise !== true) {
const resolved = context.resolvePromise(value);
value.dispose();
this.pump(plugin);
const settled = await resolved;
if (settled.error != null) {
throw this.toError(plugin, settled.error.consume(context.dump.bind(context)));
}
return settled.value.consume(context.dump.bind(context));
}
return value.consume(context.dump.bind(context));
} finally {
plugin.deadline = null;
for (const handle of argHandles) handle.dispose();
fn.dispose();
guest.dispose();
}
}
private callGuestSync(plugin: LoadedPlugin, method: string, args: number[]): void {
const { context } = plugin;
const guest = context.getProp(context.global, "__yaak_guest");
const fn = context.getProp(guest, method);
const argHandles = args.map((a) => context.newNumber(a));
try {
const called = context.callFunction(fn, guest, ...argHandles);
if (called.error != null) {
this.onLog({
pluginRefId: plugin.pluginRefId,
level: "error",
message: String(this.toError(plugin, called.error.consume(context.dump.bind(context)))),
});
} else {
called.value.dispose();
}
} finally {
for (const handle of argHandles) handle.dispose();
fn.dispose();
guest.dispose();
}
}
private toError(plugin: LoadedPlugin, dumped: unknown): Error {
if (dumped != null && typeof dumped === "object") {
const { message, name, stack } = dumped as Record<string, string | undefined>;
const error = new Error(message ?? JSON.stringify(dumped));
if (name != null) error.name = name;
if (stack != null) error.stack = `${name ?? "Error"}: ${message ?? ""}\n${stack}`;
return error;
}
// An interrupted plugin surfaces as `null` with no error object.
if (dumped == null) {
return new Error(
`Plugin \`${plugin.pluginRefId}\` was stopped after running for ` +
`${SYNC_BUDGET_MS / 1000}s without yielding`,
);
}
return new Error(typeof dumped === "string" ? dumped : JSON.stringify(dumped));
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* 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: JSON envelope in, JSON reply out. */
export type HostRequestHandler = (envelope: string) => Promise<string>;
export interface PluginSandboxOptions {
onHostRequest: HostRequestHandler;
onLog?: (log: { pluginRefId: string; level: string; message: string }) => void;
}
export interface PluginSummary {
templateFunctions: string[];
authentication: string | null;
importer: boolean;
filter: boolean;
themes: number;
httpRequestActions: number;
workspaceActions: number;
folderActions: number;
grpcRequestActions: number;
websocketRequestActions: number;
}
type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
export class PluginSandbox {
private readonly worker: Worker;
private readonly pending = new Map<number, Pending>();
private readonly options: PluginSandboxOptions;
private nextId = 1;
constructor(options: PluginSandboxOptions) {
this.options = options;
// 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",
});
this.worker.onmessage = (e: MessageEvent<FromSandbox>) => this.receive(e.data);
this.worker.onerror = () => this.failEverything("The plugin sandbox failed to start");
}
load(pluginRefId: string, source: string): Promise<PluginSummary> {
return this.request<PluginSummary>((id) => ({ type: "load", id, pluginRefId, source }));
}
unload(pluginRefId: string): Promise<void> {
return this.request<void>((id) => ({ type: "unload", id, pluginRefId }));
}
async dispatch<T>(
pluginRefId: string,
context: unknown,
payload: unknown,
): Promise<T & { type: string }> {
const envelope = JSON.stringify({ context, payload });
const reply = await this.request<string>((id) => ({
type: "dispatch",
id,
pluginRefId,
envelope,
}));
const parsed = JSON.parse(reply) as { type: string; error?: string };
if (parsed.type === "error_response") {
throw new Error(parsed.error || "Plugin failed");
}
return parsed as T & { type: string };
}
/** `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");
}
/* ------------------------------ internals ------------------------------- */
private request<T>(build: (id: number) => ToSandbox): Promise<T> {
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.worker.postMessage(build(id));
});
}
private receive(message: FromSandbox): void {
switch (message.type) {
case "result": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.resolve(message.result);
return;
}
case "error": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.reject(new Error(message.message));
return;
}
case "log":
this.options.onLog?.(message);
return;
case "host_call":
void this.answer(message.id, message.envelope);
return;
}
}
private async answer(id: number, envelope: string): Promise<void> {
let reply: ToSandbox;
try {
reply = { type: "host_result", id, reply: await this.options.onHostRequest(envelope) };
} catch (err) {
reply = {
type: "host_result",
id,
error: err instanceof Error ? err.message : String(err),
};
}
this.worker.postMessage(reply);
}
private failEverything(message: string): void {
for (const [id, p] of this.pending) {
this.pending.delete(id);
p.reject(new Error(message));
}
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 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 */
export type ToSandbox =
| { type: "load"; id: number; pluginRefId: string; source: string }
| { type: "unload"; id: number; pluginRefId: string }
| { type: "dispatch"; id: number; pluginRefId: string; envelope: string }
/** The tab's answer to a `host_call`. */
| { type: "host_result"; id: number; reply?: string; error?: string };
/** Worker → tab */
export type FromSandbox =
| { type: "result"; id: number; result: unknown }
| { type: "error"; id: number; message: string }
/** A plugin wants something only the tab can provide. */
| { type: "host_call"; id: number; envelope: string }
| { type: "log"; pluginRefId: string; level: string; message: string };
+68
View File
@@ -0,0 +1,68 @@
/// <reference lib="webworker" />
/**
* 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";
import type { FromSandbox, ToSandbox } from "./protocol";
const scope = self as unknown as DedicatedWorkerGlobalScope;
function send(message: FromSandbox): void {
scope.postMessage(message);
}
const pendingHostCalls = new Map<number, (reply: string | Error) => void>();
let nextHostCallId = 1;
const host = new PluginSandboxHost(
(envelope) =>
new Promise<string>((resolve, reject) => {
const id = nextHostCallId++;
pendingHostCalls.set(id, (reply) => (reply instanceof Error ? reject(reply) : resolve(reply)));
send({ type: "host_call", id, envelope });
}),
(log) => send({ type: "log", ...log }),
);
async function handle(message: ToSandbox): Promise<void> {
if (message.type === "host_result") {
const settle = pendingHostCalls.get(message.id);
pendingHostCalls.delete(message.id);
settle?.(message.error != null ? new Error(message.error) : (message.reply ?? "{}"));
return;
}
try {
switch (message.type) {
case "load":
send({
type: "result",
id: message.id,
result: await host.load(message.pluginRefId, message.source),
});
return;
case "unload":
host.unload(message.pluginRefId);
send({ type: "result", id: message.id, result: null });
return;
case "dispatch":
send({
type: "result",
id: message.id,
result: await host.dispatch(message.pluginRefId, message.envelope),
});
return;
}
} catch (err) {
send({
type: "error",
id: message.id,
message: err instanceof Error ? err.message : String(err),
});
}
}
scope.onmessage = (e: MessageEvent<ToSandbox>) => void handle(e.data);
+96
View File
@@ -0,0 +1,96 @@
/**
* A stand-in for `yaakcli build --target sandbox`, which does not exist yet.
* What the CLI would need instead is at the bottom of this file.
*/
import { build } from "esbuild";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** Three, not the corpus: one template function, one importer, one auth method. */
const PLUGINS = ["template-function-timestamp", "importer-curl", "auth-bearer"];
const noNodeBuiltins = {
name: "no-node-builtins",
setup(build) {
build.onResolve({ filter: /^(node:|fs$|path$|crypto$|buffer$|process$|os$|util$|stream$)/ }, (args) => ({
errors: [
{
text:
`\`${args.path}\` is not available in the sandbox runtime. ` +
`Replace it with a pure-JavaScript equivalent.`,
},
],
}));
},
};
export async function bundlePlugin(name, { dir = join(root, "plugins", name) } = {}) {
const result = await build({
entryPoints: [join(dir, "src", "index.ts")],
bundle: true,
write: false,
format: "cjs",
platform: "browser",
target: "es2022",
minify: false,
legalComments: "none",
plugins: [noNodeBuiltins],
});
return result.outputFiles[0].text;
}
async function main() {
const bundles = [];
for (const name of PLUGINS) {
const source = await bundlePlugin(name);
bundles.push({ name, source });
console.log(`${name}: ${(source.length / 1024).toFixed(1)} KB`);
}
const outFile = join(root, "packages", "platform", "src", "web", "sandboxPlugins.generated.ts");
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(
outFile,
[
"// Generated by scripts/bundle-sandbox-plugins.mjs. Do not edit.",
"//",
"// The plugins the browser tier loads into its sandbox, bundled for that",
"// target and inlined as source text.",
"",
"export interface SandboxPluginBundle {",
" name: string;",
" source: string;",
"}",
"",
"export const SANDBOX_PLUGINS: SandboxPluginBundle[] = [",
...bundles.map((b) => ` { name: ${JSON.stringify(b.name)}, source: ${JSON.stringify(b.source)} },`),
"];",
"",
].join("\n"),
);
console.log(`Wrote ${outFile}`);
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
/*
* What `yaakcli build --target sandbox` would need, beyond this:
*
* 1. `Platform::Browser` in the rolldown options (crates-cli/yaak-cli/src/
* commands/plugin.rs `bundler_options`), plus a resolver that fails on a
* Node built-in instead of shimming it — a silent shim turns a missing
* capability into a runtime error inside someone else's plugin.
* 2. A `runtime` field in the plugin manifest, so a plugin declares which
* target it is for and the registry can refuse to install a `node` plugin
* on a host that has no Node.
* 3. Both targets emitted for the same source where they both work, since a
* desktop with a sandbox and a desktop with Node are the same install.
* 4. Distribution as files, not as inlined strings. Inlining is what this
* script does because three small bundles cost less than an asset pipeline;
* the corpus does not, and a plugin the user installs at runtime cannot be
* inlined at build time at all.
*/