diff --git a/Cargo.lock b/Cargo.lock index c8639ef0..e8f63505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11782,6 +11782,7 @@ dependencies = [ "console_error_panic_hook", "js-sys", "log 0.4.29", + "md5 0.7.0", "serde", "serde-wasm-bindgen", "serde_json", diff --git a/crates/yaak-templates/src/renderer.rs b/crates/yaak-templates/src/renderer.rs index 72b73fe0..23cd5981 100644 --- a/crates/yaak-templates/src/renderer.rs +++ b/crates/yaak-templates/src/renderer.rs @@ -8,12 +8,29 @@ use std::future::Future; const MAX_DEPTH: usize = 50; +/// `Send`, except where nothing can be. +/// +/// Rendering a template function is a host call, and on a host with one thread +/// it is a call into JavaScript: the future holds a `JsFuture` and the callback +/// holds the `Rc` connection pool, neither of which is `Send` nor can be made +/// so. Every other host spawns rendering onto a thread pool and needs the bound. +/// So the bound belongs to the targets that can keep it, rather than to the +/// trait every host must implement. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSend for T {} +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} +#[cfg(target_arch = "wasm32")] +impl MaybeSend for T {} + pub trait TemplateCallback { fn run( &self, fn_name: &str, args: HashMap, - ) -> impl Future> + Send; + ) -> impl Future> + MaybeSend; fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result; } diff --git a/crates/yaak-web/Cargo.toml b/crates/yaak-web/Cargo.toml index 05a0cf1f..987d7d1a 100644 --- a/crates/yaak-web/Cargo.toml +++ b/crates/yaak-web/Cargo.toml @@ -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 diff --git a/crates/yaak-web/index.ts b/crates/yaak-web/index.ts index 78694a95..a813f3bd 100644 --- a/crates/yaak-web/index.ts +++ b/crates/yaak-web/index.ts @@ -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"; diff --git a/crates/yaak-web/pkg/yaak_web.d.ts b/crates/yaak-web/pkg/yaak_web.d.ts index 42d31de3..4db74597 100644 --- a/crates/yaak-web/pkg/yaak_web.d.ts +++ b/crates/yaak-web/pkg/yaak_web.d.ts @@ -31,10 +31,25 @@ export function boot(): Promise; * settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab * posts to the send proxy. * - * 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 JavaScript function taking a name and its + * JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes + * every template function a refusal naming it. + * + * Authentication is *not* applied here even though it is part of preparing a send. It is + * applied to the rendered request by the caller, because the plugin that applies it wants to + * see the request as it will be sent, and the caller is the side that knows that. */ -export function prepare_http_send(payload: any): Promise; +export function prepare_http_send(payload: any, plugins: any): Promise; + +/** + * Render one template string against an environment chain. + * + * What `cmd_render_template` does on the desktop, for the same callers: the value previews + * under an editor, and anywhere the app shows what a template will become. `ignore_error` + * picks the same behaviour it picks there — a preview shows an empty string where a send + * would refuse, because a half-typed template is not yet a mistake. + */ +export function render_template(payload: any, plugins: any): Promise; /** * Run one command as `label` (the calling tab's identity, which stands in for diff --git a/crates/yaak-web/pkg/yaak_web_bg.js b/crates/yaak-web/pkg/yaak_web_bg.js index 2fcf3e7e..bebabdf1 100644 --- a/crates/yaak-web/pkg/yaak_web_bg.js +++ b/crates/yaak-web/pkg/yaak_web_bg.js @@ -68,13 +68,35 @@ export function boot() { * settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab * posts to the send proxy. * - * 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 JavaScript function taking a name and its + * JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes + * every template function a refusal naming it. + * + * Authentication is *not* applied here even though it is part of preparing a send. It is + * applied to the rendered request by the caller, because the plugin that applies it wants to + * see the request as it will be sent, and the caller is the side that knows that. * @param {any} payload + * @param {any} plugins * @returns {Promise} */ -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; +} + +/** + * Render one template string against an environment chain. + * + * What `cmd_render_template` does on the desktop, for the same callers: the value previews + * under an editor, and anywhere the app shows what a template will become. `ignore_error` + * picks the same behaviour it picks there — a preview shows an empty string where a send + * would refuse, because a half-typed template is not yet a mistake. + * @param {any} payload + * @param {any} plugins + * @returns {Promise} + */ +export function render_template(payload, plugins) { + const ret = wasm.render_template(payload, plugins); return ret; } @@ -225,6 +247,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 +695,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 +727,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__h38d884a456ef1afe); + // 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__h20ab1db2d80221ce); 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 +795,8 @@ function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg } } -function wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2) { - const ret = wasm.wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2); +function wasm_bindgen__convert__closures_____invoke__h20ab1db2d80221ce(arg0, arg1, arg2) { + const ret = wasm.wasm_bindgen__convert__closures_____invoke__h20ab1db2d80221ce(arg0, arg1, arg2); if (ret[1]) { throw takeFromExternrefTable0(ret[0]); } diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm b/crates/yaak-web/pkg/yaak_web_bg.wasm index c1266727..c9213efb 100644 Binary files a/crates/yaak-web/pkg/yaak_web_bg.wasm and b/crates/yaak-web/pkg/yaak_web_bg.wasm differ diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts b/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts index 2db5c255..e0deaa2f 100644 --- a/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts +++ b/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts @@ -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__h38d884a456ef1afe: (a: number, b: number, c: any) => [number, number]; +export const wasm_bindgen__convert__closures_____invoke__h20ab1db2d80221ce: (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; diff --git a/crates/yaak-web/src/lib.rs b/crates/yaak-web/src/lib.rs index d4afd43e..64dc3218 100644 --- a/crates/yaak-web/src/lib.rs +++ b/crates/yaak-web/src/lib.rs @@ -232,6 +232,14 @@ struct PersistSendCookiesReq { after: Vec, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct PluginKeyValueReq { + plugin_name: String, + key: String, + value: Option, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct InsertResponseEventsReq { @@ -415,6 +423,35 @@ fn dispatch( to_json(()) } + // A plugin's own storage, namespaced by plugin name exactly as the desktop namespaces + // it (`build_shared_reply` in crates/yaak/src/plugin_events.rs), so a plugin that keeps + // a token here finds it under the same key on either host. + "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 +476,10 @@ struct PreparedHttpSend { /// The request with inherited headers and authentication applied and every template /// rendered. What the proxy sends, and what the response records as its request. request: HttpRequest, + /// Identifies whichever model the authentication was inherited from, hashed the way the + /// desktop hashes it. Plugins key their stored state on it — an OAuth token cache belongs + /// to the folder that declared the auth, not to each request under it. + 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 +488,51 @@ struct PreparedHttpSend { cookie_jar: Option, } -/// 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; +/// A template callback that calls a template function wherever the caller keeps them. +/// +/// The desktop's equivalent (`PluginTemplateCallback`) reaches a plugin process; this one +/// reaches a JavaScript function the worker installed, which forwards to the sandbox and back. +/// Both hand the renderer the same thing — a string, or an error naming what failed — so a +/// template renders identically on either host or fails for the same reason. +/// +/// Without a function to call, a template function is a clear refusal naming it, which tells +/// the user what the request needs rather than sending an empty string in its place. +struct JsTemplateCallback { + call: Option, +} -impl TemplateCallback for NoPluginsCallback { +impl TemplateCallback for JsTemplateCallback { fn run( &self, fn_name: &str, - _args: HashMap, - ) -> impl std::future::Future> + 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, + ) -> impl std::future::Future> { + // Built before the async block so the future holds only owned values. + let call = self.call.clone(); + let fn_name = fn_name.to_string(); + let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into()); + + 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 +545,40 @@ impl TemplateCallback for NoPluginsCallback { } } +/// The message out of a rejected promise or a thrown error, without the `Error:` wrapper. +fn js_message(value: &JsValue) -> String { + if let Some(text) = value.as_string() { + return text; + } + let message = js_sys::Reflect::get(value, &JsValue::from_str("message")) + .ok() + .and_then(|m| m.as_string()); + message.unwrap_or_else(|| format!("{value:?}")) +} + +/// The template function bridge, or none when the caller passed nothing. +fn template_callback(plugins: JsValue) -> JsTemplateCallback { + JsTemplateCallback { call: plugins.dyn_into::().ok() } +} + /// Resolve and render a request for sending, exactly as the desktop does before it puts the /// request on the network: the environment chain, inherited headers and auth, request /// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab /// posts to the send proxy. /// -/// 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 JavaScript function taking a name and its +/// JSON arguments and resolving to the rendered string. Passing nothing is allowed and makes +/// every template function a refusal naming it. +/// +/// Authentication is *not* applied here even though it is part of preparing a send. It is +/// applied to the rendered request by the caller, because the plugin that applies it wants to +/// see the request as it will be sent, and the caller is the side that knows that. #[wasm_bindgen] -pub async fn prepare_http_send(payload: JsValue) -> Result { +pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result { 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 +588,7 @@ pub async fn prepare_http_send(payload: JsValue) -> Result { 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 +597,21 @@ pub async fn prepare_http_send(payload: JsValue) -> Result { 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 +622,46 @@ pub async fn prepare_http_send(payload: JsValue) -> Result { 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, + ignore_error: Option, +} + +/// Render one template string against an environment chain. +/// +/// What `cmd_render_template` does on the desktop, for the same callers: the value previews +/// under an editor, and anywhere the app shows what a template will become. `ignore_error` +/// picks the same behaviour it picks there — a preview shows an empty string where a send +/// would refuse, because a half-typed template is not yet a mistake. +#[wasm_bindgen] +pub async fn render_template(payload: JsValue, plugins: JsValue) -> Result { + 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 */ /* -------------------------------------------------------------------------- */ diff --git a/package-lock.json b/package-lock.json index 44eb040d..b6abd75b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index 350d77fe..3794f5e3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/common-lib/index.ts b/packages/common-lib/index.ts index 245e43a4..42bd8070 100644 --- a/packages/common-lib/index.ts +++ b/packages/common-lib/index.ts @@ -2,3 +2,5 @@ export * from "./debounce"; export * from "./eagerDebounceAsync"; export * from "./formatSize"; export * from "./templateFunction"; +export * from "./pluginForms"; +export * from "./responseBody"; diff --git a/packages/plugin-runtime/src/common.ts b/packages/common-lib/pluginForms.ts similarity index 54% rename from packages/plugin-runtime/src/common.ts rename to packages/common-lib/pluginForms.ts index b37c60da..0b434b36 100644 --- a/packages/plugin-runtime/src/common.ts +++ b/packages/common-lib/pluginForms.ts @@ -1,13 +1,25 @@ +/** + * The form handling every plugin runtime does, wherever it runs. + * + * A plugin declares its inputs as data, but any of them may compute itself + * from the values entered so far — so a runtime has to resolve those callbacks + * before a host can draw the form, then strip them, because a function cannot + * cross a process, a worker, or a sandbox boundary. That is the same work for + * the Node runtime and the QuickJS one, so it lives here rather than in either. + */ + import type { CallPromptFormDynamicArgs, Context, DynamicAuthenticationArg, DynamicPromptFormArg, DynamicTemplateFunctionArg, + TemplateFunctionPlugin, } from "@yaakapp/api"; import type { CallHttpAuthenticationActionArgs, CallTemplateFunctionArgs, + FormInput, } from "@yaakapp-internal/plugins"; type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg; @@ -73,3 +85,38 @@ export async function applyDynamicFormInput( } return resolvedArgs; } + +/** + * Drop the `dynamic` callbacks, recursively, leaving inputs that serialize. + * + * Called on the way out of a runtime, after [`applyDynamicFormInput`] has run + * them: what a host receives has to be data all the way down. + */ +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 }; +} diff --git a/packages/plugin-runtime/src/responseBody.ts b/packages/common-lib/responseBody.ts similarity index 100% rename from packages/plugin-runtime/src/responseBody.ts rename to packages/common-lib/responseBody.ts diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts index a2f1dd71..466ae450 100644 --- a/packages/platform/src/web/commands.ts +++ b/packages/platform/src/web/commands.ts @@ -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; +type Handler = ( + payload: RpcPayload, + db: WorkerConnection, + plugins: WebPlugins, +) => Promise; /** 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 { + const value = payload[key]; + return value != null && typeof value === "object" + ? (value as Record) + : {}; +} + +/** + * 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> = { // The tab renders and stores; a stateless proxy 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> = { * 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> = { }, /** - * 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> = { }, }; -/** - * 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 { 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]); diff --git a/packages/platform/src/web/connection.ts b/packages/platform/src/web/connection.ts index 88940fb0..8e4afed6 100644 --- a/packages/platform/src/web/connection.ts +++ b/packages/platform/src/web/connection.ts @@ -50,6 +50,16 @@ export class WorkerConnection { /** True once the worker has said anything at all. */ private heard = false; + /** + * Who answers a template function, once something can. + * + * The engine renders in the worker but the functions come from plugins in + * this tab's sandbox, so the worker asks back through this. Unset until the + * sandbox is up, and a render that arrives before then gets the same refusal + * a host with no plugins gives — which is the truth at that moment. + */ + private templateFunctions: ((name: string, args: string) => Promise) | 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 +157,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 +172,35 @@ export class WorkerConnection { }); } + /** Hand the worker somewhere to send template functions. */ + setTemplateFunctionHandler(handler: (name: string, args: string) => Promise): void { + this.templateFunctions = handler; + } + + private async runTemplateFunction(id: number, name: string, args: string): Promise { + 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(cmd: string, payload: unknown): Promise { return this.request((id) => ({ type: "rpc", id, cmd, payload, label: this.label })); } @@ -168,6 +210,11 @@ export class WorkerConnection { return this.request((id) => ({ type: "prepare_http_send", id, payload })); } + /** See `render_template` in crates/yaak-web. */ + renderTemplate(payload: unknown): Promise { + return this.request((id) => ({ type: "render_template", id, payload })); + } + async blobGet(blobId: string): Promise | null> { const buf = await this.request((id) => ({ type: "blob_get", id, blobId })); return buf == null ? null : new Uint8Array(buf); diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index f664331b..ec81940b 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -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. */ @@ -57,7 +58,10 @@ function capabilitiesFor(): PlatformCapabilities { // The browser draws the frame around the page. There are no traffic lights // to leave room for and no window controls to draw. windowChrome: false, - plugins: false, + // Plugins run here, in a QuickJS sandbox — see packages/plugin-sandbox. + // What is missing is installing them: the set is the one bundled with the + // app, 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 @@ -157,8 +161,14 @@ function createWindow(db: WorkerConnection): PlatformWindow { export function createWebPlatform(): Platform { const db = new WorkerConnection(); + const plugins = new WebPlugins(db); const capabilities = capabilitiesFor(); + // Rendering happens in the worker and template functions live in the sandbox, + // so the worker needs a way back here to call one. Registered before anything + // can render, which is why it is here rather than inside the first send. + 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. @@ -228,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(cmd, payload); - return runCommand(cmd, payload ?? {}, db) as Promise; + return runCommand(cmd, payload ?? {}, db, plugins) as Promise; }, async rpcStream( @@ -241,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(); diff --git a/packages/platform/src/web/plugins.ts b/packages/platform/src/web/plugins.ts new file mode 100644 index 00000000..a0c4d3df --- /dev/null +++ b/packages/platform/src/web/plugins.ts @@ -0,0 +1,351 @@ +/** + * The plugins this host runs, and everything they are allowed to reach. + * + * Two jobs. Outward: keep a sandbox, load the bundled plugins into it, and know + * which of them answers what — the app asks for "the bearer auth config" and + * this decides that means `auth-bearer`. Inward: answer the `ctx` calls those + * plugins make, which is where the sandbox stops being a sealed box and starts + * being a host. Everything a plugin can do to the world is in `hostRequest` + * below, by name, with a refusal for anything not listed. + * + * The plugins are bundled into the app rather than installed, for now — see + * `scripts/bundle-sandbox-plugins.mjs`. Which three, and why only three, is a + * decision that belongs to this slice and not to the sandbox: the runtime does + * not know how many plugins exist. + */ + +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"; + +/** What a plugin's own storage is keyed under, matching the desktop's namespacing. */ +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 | null = null; + + /** Loaded plugin ids, by what they contribute. */ + private readonly byTemplateFunction = new Map(); + private readonly byAuthName = new Map(); + private readonly importers: string[] = []; + private readonly summaries = new Map(); + + constructor(db: WorkerConnection) { + this.db = db; + } + + /** + * Bring the sandbox up and load every bundled plugin, once. + * + * Called from every entry point rather than at construction, so a session + * that never touches a plugin never pays for QuickJS — and so a tab that + * cannot start the worker still boots the app, with plugin-shaped features + * failing individually instead of the page failing entirely. + */ + ready(): Promise { + this.loading ??= this.start(); + return this.loading; + } + + private async start(): Promise { + const sandbox = new PluginSandbox({ + onHostRequest: (envelope) => this.hostRequest(envelope), + onLog: ({ pluginRefId, level, message }) => { + // Prefixed, because otherwise a plugin's console output is + // indistinguishable from the app's own and blames the wrong code. + const write = level === "error" ? console.error : console.log; + write(`[plugin ${pluginRefId}] ${message}`); + }, + }); + this.sandbox = sandbox; + + // In parallel: each is an independent QuickJS context and none of them + // observes the others. + await Promise.all( + SANDBOX_PLUGINS.map(async ({ name, source }) => { + try { + 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 { + await this.ready(); + return this.gather("get_template_function_summary_request", this.summaries.keys()); + } + + async httpAuthenticationSummaries(): Promise { + await this.ready(); + return this.gather("get_http_authentication_summary_request", this.byAuthName.values()); + } + + /** + * Ask several plugins the same question and keep the answers that came back. + * + * A plugin that has nothing to say answers `empty_response`, which is not an + * answer and is dropped; one that throws is logged and dropped too, so a + * single broken plugin cannot empty the picker for all the others. + */ + private async gather(type: string, ids: Iterable): Promise { + 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, + contextId: string, + ): Promise { + 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); + } + + /** + * Run one template function. + * + * This is what the engine's render calls back into, so its contract is the + * engine's: a string, or a throw whose message says what went wrong. A + * function nothing provides is a throw naming it rather than an empty + * string, because a request sent with a silently blank token is worse than + * one that refuses to be sent. + */ + async callTemplateFunction(name: string, argsJson: string): Promise { + 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; + 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, + contextId: string, + ): Promise { + 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, + contextId: string, + ): Promise { + 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); + } + + /** + * Apply an authentication method to a request that is about to be sent. + * + * The plugin is shown the request as it stands and hands back headers and + * query parameters to add — the same exchange the desktop has, at the same + * point in the send. + */ + async applyHttpAuthentication( + authName: string, + request: { + contextId: string; + values: Record; + method: string; + url: string; + headers: { name: string; value: string }[]; + body: string | null; + }, + ): Promise { + 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(id, { + type: "call_http_authentication_request", + ...request, + } as InternalEventPayload); + } + + /** + * Import whatever this text turns out to be. + * + * Every importer is asked and the first one that recognizes it wins, which + * is how the desktop's `import_data` decides too — an importer that does not + * recognize its input returns nothing rather than guessing. + */ + async import(content: string): Promise { + 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( + pluginRefId: string, + payload: InternalEventPayload, + ): Promise { + if (this.sandbox == null) throw new Error("The plugin sandbox is not running"); + return this.sandbox.dispatch(pluginRefId, this.context(), payload); + } + + /** + * The context a plugin sees. + * + * `label` is null and stays null: it names a desktop window, and the calls + * that need one — `ctx.window.requestId()` and its neighbours — are refused + * rather than answered with a guess about which request the user is looking + * at. `workspaceId` is genuinely unknown here for the same reason; the + * commands that know it pass it themselves. + */ + private context(): PluginContext { + return { id: "web", label: null, workspaceId: null }; + } + + /** + * Answer one `ctx` call. + * + * The list is short on purpose. What is here is what a plugin can do in a + * browser tab today; what is missing refuses by name, so a plugin that needs + * it fails with a sentence someone can act on rather than a hang or an + * undefined. Every addition to this list is a capability decision, which is + * why they are written out one at a time instead of forwarded wholesale. + */ + private async hostRequest(envelope: string): Promise { + const { pluginRefId, payload } = JSON.parse(envelope) as { + pluginRefId: string; + context: PluginContext; + payload: InternalEventPayload; + }; + + const reply = async (): Promise => { + switch (payload.type) { + /* A plugin's own storage, namespaced by plugin in the database. */ + case "get_key_value_request": { + const value = await this.db.rpc("web_plugin_kv_get", { + pluginName: pluginRefId, + 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("web_plugin_kv_delete", { + pluginName: pluginRefId, + key: (payload as unknown as KeyValueRequest).key, + }); + return { type: "delete_key_value_response", deleted } as InternalEventPayload; + } + + /* A message for the user, delivered where every other one is. */ + case "show_toast_request": { + const { type: _type, ...toast } = payload; + this.db.deliver("show_toast", toast); + 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), + }); + } + } + +} diff --git a/packages/platform/src/web/protocol.ts b/packages/platform/src/web/protocol.ts index 5104463e..e3b7921d 100644 --- a/packages/platform/src/web/protocol.ts +++ b/packages/platform/src/web/protocol.ts @@ -16,6 +16,18 @@ export type ToWorker = * async in the engine (rendering is), where every `rpc` command is not. */ | { type: "prepare_http_send"; id: number; payload: unknown } + /** + * Render one template string. Async for the same reason `prepare_http_send` + * is: a template function is a call out to a plugin, and plugins are not here. + */ + | { 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 +50,16 @@ 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 } + /** + * Render a template function, please. + * + * The one message that runs the other way. Rendering happens in the engine, + * here, but the functions it calls live in a plugin sandbox the tab owns — + * so the engine asks, and it asks the port that started the render rather + * than broadcasting, because only that tab is waiting. + */ + | { type: "template_function"; id: number; name: string; args: string }; /** What the worker registers itself under. Tabs on one origin share it. */ export const WORKER_NAME = "yaak-db"; diff --git a/packages/platform/src/web/sandboxPlugins.generated.ts b/packages/platform/src/web/sandboxPlugins.generated.ts new file mode 100644 index 00000000..840de91f --- /dev/null +++ b/packages/platform/src/web/sandboxPlugins.generated.ts @@ -0,0 +1,15 @@ +// 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[] = [ + { name: "template-function-timestamp", source: "\"use strict\";\nvar __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __hasOwnProp = Object.prototype.hasOwnProperty;\nvar __export = (target, all) => {\n for (var name in all)\n __defProp(target, name, { get: all[name], enumerable: true });\n};\nvar __copyProps = (to, from, except, desc) => {\n if (from && typeof from === \"object\" || typeof from === \"function\") {\n for (let key of __getOwnPropNames(from))\n if (!__hasOwnProp.call(to, key) && key !== except)\n __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n }\n return to;\n};\nvar __toCommonJS = (mod) => __copyProps(__defProp({}, \"__esModule\", { value: true }), mod);\n\n// plugins/template-function-timestamp/src/index.ts\nvar index_exports = {};\n__export(index_exports, {\n calculateDatetime: () => calculateDatetime,\n formatDatetime: () => formatDatetime,\n plugin: () => plugin\n});\nmodule.exports = __toCommonJS(index_exports);\n\n// node_modules/date-fns/constants.js\nvar daysInYear = 365.2425;\nvar maxTime = Math.pow(10, 8) * 24 * 60 * 60 * 1e3;\nvar minTime = -maxTime;\nvar millisecondsInWeek = 6048e5;\nvar millisecondsInDay = 864e5;\nvar millisecondsInMinute = 6e4;\nvar millisecondsInHour = 36e5;\nvar secondsInHour = 3600;\nvar secondsInDay = secondsInHour * 24;\nvar secondsInWeek = secondsInDay * 7;\nvar secondsInYear = secondsInDay * daysInYear;\nvar secondsInMonth = secondsInYear / 12;\nvar secondsInQuarter = secondsInMonth * 3;\nvar constructFromSymbol = /* @__PURE__ */ Symbol.for(\"constructDateFrom\");\n\n// node_modules/date-fns/constructFrom.js\nfunction constructFrom(date, value) {\n if (typeof date === \"function\") return date(value);\n if (date && typeof date === \"object\" && constructFromSymbol in date)\n return date[constructFromSymbol](value);\n if (date instanceof Date) return new date.constructor(value);\n return new Date(value);\n}\n\n// node_modules/date-fns/toDate.js\nfunction toDate(argument, context) {\n return constructFrom(context || argument, argument);\n}\n\n// node_modules/date-fns/addDays.js\nfunction addDays(date, amount, options) {\n const _date = toDate(date, options?.in);\n if (isNaN(amount)) return constructFrom(options?.in || date, NaN);\n if (!amount) return _date;\n _date.setDate(_date.getDate() + amount);\n return _date;\n}\n\n// node_modules/date-fns/addMonths.js\nfunction addMonths(date, amount, options) {\n const _date = toDate(date, options?.in);\n if (isNaN(amount)) return constructFrom(options?.in || date, NaN);\n if (!amount) {\n return _date;\n }\n const dayOfMonth = _date.getDate();\n const endOfDesiredMonth = constructFrom(options?.in || date, _date.getTime());\n endOfDesiredMonth.setMonth(_date.getMonth() + amount + 1, 0);\n const daysInMonth = endOfDesiredMonth.getDate();\n if (dayOfMonth >= daysInMonth) {\n return endOfDesiredMonth;\n } else {\n _date.setFullYear(\n endOfDesiredMonth.getFullYear(),\n endOfDesiredMonth.getMonth(),\n dayOfMonth\n );\n return _date;\n }\n}\n\n// node_modules/date-fns/addMilliseconds.js\nfunction addMilliseconds(date, amount, options) {\n return constructFrom(options?.in || date, +toDate(date) + amount);\n}\n\n// node_modules/date-fns/addHours.js\nfunction addHours(date, amount, options) {\n return addMilliseconds(date, amount * millisecondsInHour, options);\n}\n\n// node_modules/date-fns/_lib/defaultOptions.js\nvar defaultOptions = {};\nfunction getDefaultOptions() {\n return defaultOptions;\n}\n\n// node_modules/date-fns/startOfWeek.js\nfunction startOfWeek(date, options) {\n const defaultOptions2 = getDefaultOptions();\n const weekStartsOn = options?.weekStartsOn ?? options?.locale?.options?.weekStartsOn ?? defaultOptions2.weekStartsOn ?? defaultOptions2.locale?.options?.weekStartsOn ?? 0;\n const _date = toDate(date, options?.in);\n const day = _date.getDay();\n const diff = (day < weekStartsOn ? 7 : 0) + day - weekStartsOn;\n _date.setDate(_date.getDate() - diff);\n _date.setHours(0, 0, 0, 0);\n return _date;\n}\n\n// node_modules/date-fns/startOfISOWeek.js\nfunction startOfISOWeek(date, options) {\n return startOfWeek(date, { ...options, weekStartsOn: 1 });\n}\n\n// node_modules/date-fns/getISOWeekYear.js\nfunction getISOWeekYear(date, options) {\n const _date = toDate(date, options?.in);\n const year = _date.getFullYear();\n const fourthOfJanuaryOfNextYear = constructFrom(_date, 0);\n fourthOfJanuaryOfNextYear.setFullYear(year + 1, 0, 4);\n fourthOfJanuaryOfNextYear.setHours(0, 0, 0, 0);\n const startOfNextYear = startOfISOWeek(fourthOfJanuaryOfNextYear);\n const fourthOfJanuaryOfThisYear = constructFrom(_date, 0);\n fourthOfJanuaryOfThisYear.setFullYear(year, 0, 4);\n fourthOfJanuaryOfThisYear.setHours(0, 0, 0, 0);\n const startOfThisYear = startOfISOWeek(fourthOfJanuaryOfThisYear);\n if (_date.getTime() >= startOfNextYear.getTime()) {\n return year + 1;\n } else if (_date.getTime() >= startOfThisYear.getTime()) {\n return year;\n } else {\n return year - 1;\n }\n}\n\n// node_modules/date-fns/_lib/getTimezoneOffsetInMilliseconds.js\nfunction getTimezoneOffsetInMilliseconds(date) {\n const _date = toDate(date);\n const utcDate = new Date(\n Date.UTC(\n _date.getFullYear(),\n _date.getMonth(),\n _date.getDate(),\n _date.getHours(),\n _date.getMinutes(),\n _date.getSeconds(),\n _date.getMilliseconds()\n )\n );\n utcDate.setUTCFullYear(_date.getFullYear());\n return +date - +utcDate;\n}\n\n// node_modules/date-fns/_lib/normalizeDates.js\nfunction normalizeDates(context, ...dates) {\n const normalize = constructFrom.bind(\n null,\n context || dates.find((date) => typeof date === \"object\")\n );\n return dates.map(normalize);\n}\n\n// node_modules/date-fns/startOfDay.js\nfunction startOfDay(date, options) {\n const _date = toDate(date, options?.in);\n _date.setHours(0, 0, 0, 0);\n return _date;\n}\n\n// node_modules/date-fns/differenceInCalendarDays.js\nfunction differenceInCalendarDays(laterDate, earlierDate, options) {\n const [laterDate_, earlierDate_] = normalizeDates(\n options?.in,\n laterDate,\n earlierDate\n );\n const laterStartOfDay = startOfDay(laterDate_);\n const earlierStartOfDay = startOfDay(earlierDate_);\n const laterTimestamp = +laterStartOfDay - getTimezoneOffsetInMilliseconds(laterStartOfDay);\n const earlierTimestamp = +earlierStartOfDay - getTimezoneOffsetInMilliseconds(earlierStartOfDay);\n return Math.round((laterTimestamp - earlierTimestamp) / millisecondsInDay);\n}\n\n// node_modules/date-fns/startOfISOWeekYear.js\nfunction startOfISOWeekYear(date, options) {\n const year = getISOWeekYear(date, options);\n const fourthOfJanuary = constructFrom(options?.in || date, 0);\n fourthOfJanuary.setFullYear(year, 0, 4);\n fourthOfJanuary.setHours(0, 0, 0, 0);\n return startOfISOWeek(fourthOfJanuary);\n}\n\n// node_modules/date-fns/addMinutes.js\nfunction addMinutes(date, amount, options) {\n const _date = toDate(date, options?.in);\n _date.setTime(_date.getTime() + amount * millisecondsInMinute);\n return _date;\n}\n\n// node_modules/date-fns/addSeconds.js\nfunction addSeconds(date, amount, options) {\n return addMilliseconds(date, amount * 1e3, options);\n}\n\n// node_modules/date-fns/addYears.js\nfunction addYears(date, amount, options) {\n return addMonths(date, amount * 12, options);\n}\n\n// node_modules/date-fns/isDate.js\nfunction isDate(value) {\n return value instanceof Date || typeof value === \"object\" && Object.prototype.toString.call(value) === \"[object Date]\";\n}\n\n// node_modules/date-fns/isValid.js\nfunction isValid(date) {\n return !(!isDate(date) && typeof date !== \"number\" || isNaN(+toDate(date)));\n}\n\n// node_modules/date-fns/startOfYear.js\nfunction startOfYear(date, options) {\n const date_ = toDate(date, options?.in);\n date_.setFullYear(date_.getFullYear(), 0, 1);\n date_.setHours(0, 0, 0, 0);\n return date_;\n}\n\n// node_modules/date-fns/locale/en-US/_lib/formatDistance.js\nvar formatDistanceLocale = {\n lessThanXSeconds: {\n one: \"less than a second\",\n other: \"less than {{count}} seconds\"\n },\n xSeconds: {\n one: \"1 second\",\n other: \"{{count}} seconds\"\n },\n halfAMinute: \"half a minute\",\n lessThanXMinutes: {\n one: \"less than a minute\",\n other: \"less than {{count}} minutes\"\n },\n xMinutes: {\n one: \"1 minute\",\n other: \"{{count}} minutes\"\n },\n aboutXHours: {\n one: \"about 1 hour\",\n other: \"about {{count}} hours\"\n },\n xHours: {\n one: \"1 hour\",\n other: \"{{count}} hours\"\n },\n xDays: {\n one: \"1 day\",\n other: \"{{count}} days\"\n },\n aboutXWeeks: {\n one: \"about 1 week\",\n other: \"about {{count}} weeks\"\n },\n xWeeks: {\n one: \"1 week\",\n other: \"{{count}} weeks\"\n },\n aboutXMonths: {\n one: \"about 1 month\",\n other: \"about {{count}} months\"\n },\n xMonths: {\n one: \"1 month\",\n other: \"{{count}} months\"\n },\n aboutXYears: {\n one: \"about 1 year\",\n other: \"about {{count}} years\"\n },\n xYears: {\n one: \"1 year\",\n other: \"{{count}} years\"\n },\n overXYears: {\n one: \"over 1 year\",\n other: \"over {{count}} years\"\n },\n almostXYears: {\n one: \"almost 1 year\",\n other: \"almost {{count}} years\"\n }\n};\nvar formatDistance = (token, count, options) => {\n let result;\n const tokenValue = formatDistanceLocale[token];\n if (typeof tokenValue === \"string\") {\n result = tokenValue;\n } else if (count === 1) {\n result = tokenValue.one;\n } else {\n result = tokenValue.other.replace(\"{{count}}\", count.toString());\n }\n if (options?.addSuffix) {\n if (options.comparison && options.comparison > 0) {\n return \"in \" + result;\n } else {\n return result + \" ago\";\n }\n }\n return result;\n};\n\n// node_modules/date-fns/locale/_lib/buildFormatLongFn.js\nfunction buildFormatLongFn(args) {\n return (options = {}) => {\n const width = options.width ? String(options.width) : args.defaultWidth;\n const format2 = args.formats[width] || args.formats[args.defaultWidth];\n return format2;\n };\n}\n\n// node_modules/date-fns/locale/en-US/_lib/formatLong.js\nvar dateFormats = {\n full: \"EEEE, MMMM do, y\",\n long: \"MMMM do, y\",\n medium: \"MMM d, y\",\n short: \"MM/dd/yyyy\"\n};\nvar timeFormats = {\n full: \"h:mm:ss a zzzz\",\n long: \"h:mm:ss a z\",\n medium: \"h:mm:ss a\",\n short: \"h:mm a\"\n};\nvar dateTimeFormats = {\n full: \"{{date}} 'at' {{time}}\",\n long: \"{{date}} 'at' {{time}}\",\n medium: \"{{date}}, {{time}}\",\n short: \"{{date}}, {{time}}\"\n};\nvar formatLong = {\n date: buildFormatLongFn({\n formats: dateFormats,\n defaultWidth: \"full\"\n }),\n time: buildFormatLongFn({\n formats: timeFormats,\n defaultWidth: \"full\"\n }),\n dateTime: buildFormatLongFn({\n formats: dateTimeFormats,\n defaultWidth: \"full\"\n })\n};\n\n// node_modules/date-fns/locale/en-US/_lib/formatRelative.js\nvar formatRelativeLocale = {\n lastWeek: \"'last' eeee 'at' p\",\n yesterday: \"'yesterday at' p\",\n today: \"'today at' p\",\n tomorrow: \"'tomorrow at' p\",\n nextWeek: \"eeee 'at' p\",\n other: \"P\"\n};\nvar formatRelative = (token, _date, _baseDate, _options) => formatRelativeLocale[token];\n\n// node_modules/date-fns/locale/_lib/buildLocalizeFn.js\nfunction buildLocalizeFn(args) {\n return (value, options) => {\n const context = options?.context ? String(options.context) : \"standalone\";\n let valuesArray;\n if (context === \"formatting\" && args.formattingValues) {\n const defaultWidth = args.defaultFormattingWidth || args.defaultWidth;\n const width = options?.width ? String(options.width) : defaultWidth;\n valuesArray = args.formattingValues[width] || args.formattingValues[defaultWidth];\n } else {\n const defaultWidth = args.defaultWidth;\n const width = options?.width ? String(options.width) : args.defaultWidth;\n valuesArray = args.values[width] || args.values[defaultWidth];\n }\n const index = args.argumentCallback ? args.argumentCallback(value) : value;\n return valuesArray[index];\n };\n}\n\n// node_modules/date-fns/locale/en-US/_lib/localize.js\nvar eraValues = {\n narrow: [\"B\", \"A\"],\n abbreviated: [\"BC\", \"AD\"],\n wide: [\"Before Christ\", \"Anno Domini\"]\n};\nvar quarterValues = {\n narrow: [\"1\", \"2\", \"3\", \"4\"],\n abbreviated: [\"Q1\", \"Q2\", \"Q3\", \"Q4\"],\n wide: [\"1st quarter\", \"2nd quarter\", \"3rd quarter\", \"4th quarter\"]\n};\nvar monthValues = {\n narrow: [\"J\", \"F\", \"M\", \"A\", \"M\", \"J\", \"J\", \"A\", \"S\", \"O\", \"N\", \"D\"],\n abbreviated: [\n \"Jan\",\n \"Feb\",\n \"Mar\",\n \"Apr\",\n \"May\",\n \"Jun\",\n \"Jul\",\n \"Aug\",\n \"Sep\",\n \"Oct\",\n \"Nov\",\n \"Dec\"\n ],\n wide: [\n \"January\",\n \"February\",\n \"March\",\n \"April\",\n \"May\",\n \"June\",\n \"July\",\n \"August\",\n \"September\",\n \"October\",\n \"November\",\n \"December\"\n ]\n};\nvar dayValues = {\n narrow: [\"S\", \"M\", \"T\", \"W\", \"T\", \"F\", \"S\"],\n short: [\"Su\", \"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\"],\n abbreviated: [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"],\n wide: [\n \"Sunday\",\n \"Monday\",\n \"Tuesday\",\n \"Wednesday\",\n \"Thursday\",\n \"Friday\",\n \"Saturday\"\n ]\n};\nvar dayPeriodValues = {\n narrow: {\n am: \"a\",\n pm: \"p\",\n midnight: \"mi\",\n noon: \"n\",\n morning: \"morning\",\n afternoon: \"afternoon\",\n evening: \"evening\",\n night: \"night\"\n },\n abbreviated: {\n am: \"AM\",\n pm: \"PM\",\n midnight: \"midnight\",\n noon: \"noon\",\n morning: \"morning\",\n afternoon: \"afternoon\",\n evening: \"evening\",\n night: \"night\"\n },\n wide: {\n am: \"a.m.\",\n pm: \"p.m.\",\n midnight: \"midnight\",\n noon: \"noon\",\n morning: \"morning\",\n afternoon: \"afternoon\",\n evening: \"evening\",\n night: \"night\"\n }\n};\nvar formattingDayPeriodValues = {\n narrow: {\n am: \"a\",\n pm: \"p\",\n midnight: \"mi\",\n noon: \"n\",\n morning: \"in the morning\",\n afternoon: \"in the afternoon\",\n evening: \"in the evening\",\n night: \"at night\"\n },\n abbreviated: {\n am: \"AM\",\n pm: \"PM\",\n midnight: \"midnight\",\n noon: \"noon\",\n morning: \"in the morning\",\n afternoon: \"in the afternoon\",\n evening: \"in the evening\",\n night: \"at night\"\n },\n wide: {\n am: \"a.m.\",\n pm: \"p.m.\",\n midnight: \"midnight\",\n noon: \"noon\",\n morning: \"in the morning\",\n afternoon: \"in the afternoon\",\n evening: \"in the evening\",\n night: \"at night\"\n }\n};\nvar ordinalNumber = (dirtyNumber, _options) => {\n const number = Number(dirtyNumber);\n const rem100 = number % 100;\n if (rem100 > 20 || rem100 < 10) {\n switch (rem100 % 10) {\n case 1:\n return number + \"st\";\n case 2:\n return number + \"nd\";\n case 3:\n return number + \"rd\";\n }\n }\n return number + \"th\";\n};\nvar localize = {\n ordinalNumber,\n era: buildLocalizeFn({\n values: eraValues,\n defaultWidth: \"wide\"\n }),\n quarter: buildLocalizeFn({\n values: quarterValues,\n defaultWidth: \"wide\",\n argumentCallback: (quarter) => quarter - 1\n }),\n month: buildLocalizeFn({\n values: monthValues,\n defaultWidth: \"wide\"\n }),\n day: buildLocalizeFn({\n values: dayValues,\n defaultWidth: \"wide\"\n }),\n dayPeriod: buildLocalizeFn({\n values: dayPeriodValues,\n defaultWidth: \"wide\",\n formattingValues: formattingDayPeriodValues,\n defaultFormattingWidth: \"wide\"\n })\n};\n\n// node_modules/date-fns/locale/_lib/buildMatchFn.js\nfunction buildMatchFn(args) {\n return (string, options = {}) => {\n const width = options.width;\n const matchPattern = width && args.matchPatterns[width] || args.matchPatterns[args.defaultMatchWidth];\n const matchResult = string.match(matchPattern);\n if (!matchResult) {\n return null;\n }\n const matchedString = matchResult[0];\n const parsePatterns = width && args.parsePatterns[width] || args.parsePatterns[args.defaultParseWidth];\n const key = Array.isArray(parsePatterns) ? findIndex(parsePatterns, (pattern) => pattern.test(matchedString)) : (\n // [TODO] -- I challenge you to fix the type\n findKey(parsePatterns, (pattern) => pattern.test(matchedString))\n );\n let value;\n value = args.valueCallback ? args.valueCallback(key) : key;\n value = options.valueCallback ? (\n // [TODO] -- I challenge you to fix the type\n options.valueCallback(value)\n ) : value;\n const rest = string.slice(matchedString.length);\n return { value, rest };\n };\n}\nfunction findKey(object, predicate) {\n for (const key in object) {\n if (Object.prototype.hasOwnProperty.call(object, key) && predicate(object[key])) {\n return key;\n }\n }\n return void 0;\n}\nfunction findIndex(array, predicate) {\n for (let key = 0; key < array.length; key++) {\n if (predicate(array[key])) {\n return key;\n }\n }\n return void 0;\n}\n\n// node_modules/date-fns/locale/_lib/buildMatchPatternFn.js\nfunction buildMatchPatternFn(args) {\n return (string, options = {}) => {\n const matchResult = string.match(args.matchPattern);\n if (!matchResult) return null;\n const matchedString = matchResult[0];\n const parseResult = string.match(args.parsePattern);\n if (!parseResult) return null;\n let value = args.valueCallback ? args.valueCallback(parseResult[0]) : parseResult[0];\n value = options.valueCallback ? options.valueCallback(value) : value;\n const rest = string.slice(matchedString.length);\n return { value, rest };\n };\n}\n\n// node_modules/date-fns/locale/en-US/_lib/match.js\nvar matchOrdinalNumberPattern = /^(\\d+)(th|st|nd|rd)?/i;\nvar parseOrdinalNumberPattern = /\\d+/i;\nvar matchEraPatterns = {\n narrow: /^(b|a)/i,\n abbreviated: /^(b\\.?\\s?c\\.?|b\\.?\\s?c\\.?\\s?e\\.?|a\\.?\\s?d\\.?|c\\.?\\s?e\\.?)/i,\n wide: /^(before christ|before common era|anno domini|common era)/i\n};\nvar parseEraPatterns = {\n any: [/^b/i, /^(a|c)/i]\n};\nvar matchQuarterPatterns = {\n narrow: /^[1234]/i,\n abbreviated: /^q[1234]/i,\n wide: /^[1234](th|st|nd|rd)? quarter/i\n};\nvar parseQuarterPatterns = {\n any: [/1/i, /2/i, /3/i, /4/i]\n};\nvar matchMonthPatterns = {\n narrow: /^[jfmasond]/i,\n abbreviated: /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i,\n wide: /^(january|february|march|april|may|june|july|august|september|october|november|december)/i\n};\nvar parseMonthPatterns = {\n narrow: [\n /^j/i,\n /^f/i,\n /^m/i,\n /^a/i,\n /^m/i,\n /^j/i,\n /^j/i,\n /^a/i,\n /^s/i,\n /^o/i,\n /^n/i,\n /^d/i\n ],\n any: [\n /^ja/i,\n /^f/i,\n /^mar/i,\n /^ap/i,\n /^may/i,\n /^jun/i,\n /^jul/i,\n /^au/i,\n /^s/i,\n /^o/i,\n /^n/i,\n /^d/i\n ]\n};\nvar matchDayPatterns = {\n narrow: /^[smtwf]/i,\n short: /^(su|mo|tu|we|th|fr|sa)/i,\n abbreviated: /^(sun|mon|tue|wed|thu|fri|sat)/i,\n wide: /^(sunday|monday|tuesday|wednesday|thursday|friday|saturday)/i\n};\nvar parseDayPatterns = {\n narrow: [/^s/i, /^m/i, /^t/i, /^w/i, /^t/i, /^f/i, /^s/i],\n any: [/^su/i, /^m/i, /^tu/i, /^w/i, /^th/i, /^f/i, /^sa/i]\n};\nvar matchDayPeriodPatterns = {\n narrow: /^(a|p|mi|n|(in the|at) (morning|afternoon|evening|night))/i,\n any: /^([ap]\\.?\\s?m\\.?|midnight|noon|(in the|at) (morning|afternoon|evening|night))/i\n};\nvar parseDayPeriodPatterns = {\n any: {\n am: /^a/i,\n pm: /^p/i,\n midnight: /^mi/i,\n noon: /^no/i,\n morning: /morning/i,\n afternoon: /afternoon/i,\n evening: /evening/i,\n night: /night/i\n }\n};\nvar match = {\n ordinalNumber: buildMatchPatternFn({\n matchPattern: matchOrdinalNumberPattern,\n parsePattern: parseOrdinalNumberPattern,\n valueCallback: (value) => parseInt(value, 10)\n }),\n era: buildMatchFn({\n matchPatterns: matchEraPatterns,\n defaultMatchWidth: \"wide\",\n parsePatterns: parseEraPatterns,\n defaultParseWidth: \"any\"\n }),\n quarter: buildMatchFn({\n matchPatterns: matchQuarterPatterns,\n defaultMatchWidth: \"wide\",\n parsePatterns: parseQuarterPatterns,\n defaultParseWidth: \"any\",\n valueCallback: (index) => index + 1\n }),\n month: buildMatchFn({\n matchPatterns: matchMonthPatterns,\n defaultMatchWidth: \"wide\",\n parsePatterns: parseMonthPatterns,\n defaultParseWidth: \"any\"\n }),\n day: buildMatchFn({\n matchPatterns: matchDayPatterns,\n defaultMatchWidth: \"wide\",\n parsePatterns: parseDayPatterns,\n defaultParseWidth: \"any\"\n }),\n dayPeriod: buildMatchFn({\n matchPatterns: matchDayPeriodPatterns,\n defaultMatchWidth: \"any\",\n parsePatterns: parseDayPeriodPatterns,\n defaultParseWidth: \"any\"\n })\n};\n\n// node_modules/date-fns/locale/en-US.js\nvar enUS = {\n code: \"en-US\",\n formatDistance,\n formatLong,\n formatRelative,\n localize,\n match,\n options: {\n weekStartsOn: 0,\n firstWeekContainsDate: 1\n }\n};\n\n// node_modules/date-fns/getDayOfYear.js\nfunction getDayOfYear(date, options) {\n const _date = toDate(date, options?.in);\n const diff = differenceInCalendarDays(_date, startOfYear(_date));\n const dayOfYear = diff + 1;\n return dayOfYear;\n}\n\n// node_modules/date-fns/getISOWeek.js\nfunction getISOWeek(date, options) {\n const _date = toDate(date, options?.in);\n const diff = +startOfISOWeek(_date) - +startOfISOWeekYear(_date);\n return Math.round(diff / millisecondsInWeek) + 1;\n}\n\n// node_modules/date-fns/getWeekYear.js\nfunction getWeekYear(date, options) {\n const _date = toDate(date, options?.in);\n const year = _date.getFullYear();\n const defaultOptions2 = getDefaultOptions();\n const firstWeekContainsDate = options?.firstWeekContainsDate ?? options?.locale?.options?.firstWeekContainsDate ?? defaultOptions2.firstWeekContainsDate ?? defaultOptions2.locale?.options?.firstWeekContainsDate ?? 1;\n const firstWeekOfNextYear = constructFrom(options?.in || date, 0);\n firstWeekOfNextYear.setFullYear(year + 1, 0, firstWeekContainsDate);\n firstWeekOfNextYear.setHours(0, 0, 0, 0);\n const startOfNextYear = startOfWeek(firstWeekOfNextYear, options);\n const firstWeekOfThisYear = constructFrom(options?.in || date, 0);\n firstWeekOfThisYear.setFullYear(year, 0, firstWeekContainsDate);\n firstWeekOfThisYear.setHours(0, 0, 0, 0);\n const startOfThisYear = startOfWeek(firstWeekOfThisYear, options);\n if (+_date >= +startOfNextYear) {\n return year + 1;\n } else if (+_date >= +startOfThisYear) {\n return year;\n } else {\n return year - 1;\n }\n}\n\n// node_modules/date-fns/startOfWeekYear.js\nfunction startOfWeekYear(date, options) {\n const defaultOptions2 = getDefaultOptions();\n const firstWeekContainsDate = options?.firstWeekContainsDate ?? options?.locale?.options?.firstWeekContainsDate ?? defaultOptions2.firstWeekContainsDate ?? defaultOptions2.locale?.options?.firstWeekContainsDate ?? 1;\n const year = getWeekYear(date, options);\n const firstWeek = constructFrom(options?.in || date, 0);\n firstWeek.setFullYear(year, 0, firstWeekContainsDate);\n firstWeek.setHours(0, 0, 0, 0);\n const _date = startOfWeek(firstWeek, options);\n return _date;\n}\n\n// node_modules/date-fns/getWeek.js\nfunction getWeek(date, options) {\n const _date = toDate(date, options?.in);\n const diff = +startOfWeek(_date, options) - +startOfWeekYear(_date, options);\n return Math.round(diff / millisecondsInWeek) + 1;\n}\n\n// node_modules/date-fns/_lib/addLeadingZeros.js\nfunction addLeadingZeros(number, targetLength) {\n const sign = number < 0 ? \"-\" : \"\";\n const output = Math.abs(number).toString().padStart(targetLength, \"0\");\n return sign + output;\n}\n\n// node_modules/date-fns/_lib/format/lightFormatters.js\nvar lightFormatters = {\n // Year\n y(date, token) {\n const signedYear = date.getFullYear();\n const year = signedYear > 0 ? signedYear : 1 - signedYear;\n return addLeadingZeros(token === \"yy\" ? year % 100 : year, token.length);\n },\n // Month\n M(date, token) {\n const month = date.getMonth();\n return token === \"M\" ? String(month + 1) : addLeadingZeros(month + 1, 2);\n },\n // Day of the month\n d(date, token) {\n return addLeadingZeros(date.getDate(), token.length);\n },\n // AM or PM\n a(date, token) {\n const dayPeriodEnumValue = date.getHours() / 12 >= 1 ? \"pm\" : \"am\";\n switch (token) {\n case \"a\":\n case \"aa\":\n return dayPeriodEnumValue.toUpperCase();\n case \"aaa\":\n return dayPeriodEnumValue;\n case \"aaaaa\":\n return dayPeriodEnumValue[0];\n case \"aaaa\":\n default:\n return dayPeriodEnumValue === \"am\" ? \"a.m.\" : \"p.m.\";\n }\n },\n // Hour [1-12]\n h(date, token) {\n return addLeadingZeros(date.getHours() % 12 || 12, token.length);\n },\n // Hour [0-23]\n H(date, token) {\n return addLeadingZeros(date.getHours(), token.length);\n },\n // Minute\n m(date, token) {\n return addLeadingZeros(date.getMinutes(), token.length);\n },\n // Second\n s(date, token) {\n return addLeadingZeros(date.getSeconds(), token.length);\n },\n // Fraction of second\n S(date, token) {\n const numberOfDigits = token.length;\n const milliseconds = date.getMilliseconds();\n const fractionalSeconds = Math.trunc(\n milliseconds * Math.pow(10, numberOfDigits - 3)\n );\n return addLeadingZeros(fractionalSeconds, token.length);\n }\n};\n\n// node_modules/date-fns/_lib/format/formatters.js\nvar dayPeriodEnum = {\n am: \"am\",\n pm: \"pm\",\n midnight: \"midnight\",\n noon: \"noon\",\n morning: \"morning\",\n afternoon: \"afternoon\",\n evening: \"evening\",\n night: \"night\"\n};\nvar formatters = {\n // Era\n G: function(date, token, localize2) {\n const era = date.getFullYear() > 0 ? 1 : 0;\n switch (token) {\n // AD, BC\n case \"G\":\n case \"GG\":\n case \"GGG\":\n return localize2.era(era, { width: \"abbreviated\" });\n // A, B\n case \"GGGGG\":\n return localize2.era(era, { width: \"narrow\" });\n // Anno Domini, Before Christ\n case \"GGGG\":\n default:\n return localize2.era(era, { width: \"wide\" });\n }\n },\n // Year\n y: function(date, token, localize2) {\n if (token === \"yo\") {\n const signedYear = date.getFullYear();\n const year = signedYear > 0 ? signedYear : 1 - signedYear;\n return localize2.ordinalNumber(year, { unit: \"year\" });\n }\n return lightFormatters.y(date, token);\n },\n // Local week-numbering year\n Y: function(date, token, localize2, options) {\n const signedWeekYear = getWeekYear(date, options);\n const weekYear = signedWeekYear > 0 ? signedWeekYear : 1 - signedWeekYear;\n if (token === \"YY\") {\n const twoDigitYear = weekYear % 100;\n return addLeadingZeros(twoDigitYear, 2);\n }\n if (token === \"Yo\") {\n return localize2.ordinalNumber(weekYear, { unit: \"year\" });\n }\n return addLeadingZeros(weekYear, token.length);\n },\n // ISO week-numbering year\n R: function(date, token) {\n const isoWeekYear = getISOWeekYear(date);\n return addLeadingZeros(isoWeekYear, token.length);\n },\n // Extended year. This is a single number designating the year of this calendar system.\n // The main difference between `y` and `u` localizers are B.C. years:\n // | Year | `y` | `u` |\n // |------|-----|-----|\n // | AC 1 | 1 | 1 |\n // | BC 1 | 1 | 0 |\n // | BC 2 | 2 | -1 |\n // Also `yy` always returns the last two digits of a year,\n // while `uu` pads single digit years to 2 characters and returns other years unchanged.\n u: function(date, token) {\n const year = date.getFullYear();\n return addLeadingZeros(year, token.length);\n },\n // Quarter\n Q: function(date, token, localize2) {\n const quarter = Math.ceil((date.getMonth() + 1) / 3);\n switch (token) {\n // 1, 2, 3, 4\n case \"Q\":\n return String(quarter);\n // 01, 02, 03, 04\n case \"QQ\":\n return addLeadingZeros(quarter, 2);\n // 1st, 2nd, 3rd, 4th\n case \"Qo\":\n return localize2.ordinalNumber(quarter, { unit: \"quarter\" });\n // Q1, Q2, Q3, Q4\n case \"QQQ\":\n return localize2.quarter(quarter, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n // 1, 2, 3, 4 (narrow quarter; could be not numerical)\n case \"QQQQQ\":\n return localize2.quarter(quarter, {\n width: \"narrow\",\n context: \"formatting\"\n });\n // 1st quarter, 2nd quarter, ...\n case \"QQQQ\":\n default:\n return localize2.quarter(quarter, {\n width: \"wide\",\n context: \"formatting\"\n });\n }\n },\n // Stand-alone quarter\n q: function(date, token, localize2) {\n const quarter = Math.ceil((date.getMonth() + 1) / 3);\n switch (token) {\n // 1, 2, 3, 4\n case \"q\":\n return String(quarter);\n // 01, 02, 03, 04\n case \"qq\":\n return addLeadingZeros(quarter, 2);\n // 1st, 2nd, 3rd, 4th\n case \"qo\":\n return localize2.ordinalNumber(quarter, { unit: \"quarter\" });\n // Q1, Q2, Q3, Q4\n case \"qqq\":\n return localize2.quarter(quarter, {\n width: \"abbreviated\",\n context: \"standalone\"\n });\n // 1, 2, 3, 4 (narrow quarter; could be not numerical)\n case \"qqqqq\":\n return localize2.quarter(quarter, {\n width: \"narrow\",\n context: \"standalone\"\n });\n // 1st quarter, 2nd quarter, ...\n case \"qqqq\":\n default:\n return localize2.quarter(quarter, {\n width: \"wide\",\n context: \"standalone\"\n });\n }\n },\n // Month\n M: function(date, token, localize2) {\n const month = date.getMonth();\n switch (token) {\n case \"M\":\n case \"MM\":\n return lightFormatters.M(date, token);\n // 1st, 2nd, ..., 12th\n case \"Mo\":\n return localize2.ordinalNumber(month + 1, { unit: \"month\" });\n // Jan, Feb, ..., Dec\n case \"MMM\":\n return localize2.month(month, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n // J, F, ..., D\n case \"MMMMM\":\n return localize2.month(month, {\n width: \"narrow\",\n context: \"formatting\"\n });\n // January, February, ..., December\n case \"MMMM\":\n default:\n return localize2.month(month, { width: \"wide\", context: \"formatting\" });\n }\n },\n // Stand-alone month\n L: function(date, token, localize2) {\n const month = date.getMonth();\n switch (token) {\n // 1, 2, ..., 12\n case \"L\":\n return String(month + 1);\n // 01, 02, ..., 12\n case \"LL\":\n return addLeadingZeros(month + 1, 2);\n // 1st, 2nd, ..., 12th\n case \"Lo\":\n return localize2.ordinalNumber(month + 1, { unit: \"month\" });\n // Jan, Feb, ..., Dec\n case \"LLL\":\n return localize2.month(month, {\n width: \"abbreviated\",\n context: \"standalone\"\n });\n // J, F, ..., D\n case \"LLLLL\":\n return localize2.month(month, {\n width: \"narrow\",\n context: \"standalone\"\n });\n // January, February, ..., December\n case \"LLLL\":\n default:\n return localize2.month(month, { width: \"wide\", context: \"standalone\" });\n }\n },\n // Local week of year\n w: function(date, token, localize2, options) {\n const week = getWeek(date, options);\n if (token === \"wo\") {\n return localize2.ordinalNumber(week, { unit: \"week\" });\n }\n return addLeadingZeros(week, token.length);\n },\n // ISO week of year\n I: function(date, token, localize2) {\n const isoWeek = getISOWeek(date);\n if (token === \"Io\") {\n return localize2.ordinalNumber(isoWeek, { unit: \"week\" });\n }\n return addLeadingZeros(isoWeek, token.length);\n },\n // Day of the month\n d: function(date, token, localize2) {\n if (token === \"do\") {\n return localize2.ordinalNumber(date.getDate(), { unit: \"date\" });\n }\n return lightFormatters.d(date, token);\n },\n // Day of year\n D: function(date, token, localize2) {\n const dayOfYear = getDayOfYear(date);\n if (token === \"Do\") {\n return localize2.ordinalNumber(dayOfYear, { unit: \"dayOfYear\" });\n }\n return addLeadingZeros(dayOfYear, token.length);\n },\n // Day of week\n E: function(date, token, localize2) {\n const dayOfWeek = date.getDay();\n switch (token) {\n // Tue\n case \"E\":\n case \"EE\":\n case \"EEE\":\n return localize2.day(dayOfWeek, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n // T\n case \"EEEEE\":\n return localize2.day(dayOfWeek, {\n width: \"narrow\",\n context: \"formatting\"\n });\n // Tu\n case \"EEEEEE\":\n return localize2.day(dayOfWeek, {\n width: \"short\",\n context: \"formatting\"\n });\n // Tuesday\n case \"EEEE\":\n default:\n return localize2.day(dayOfWeek, {\n width: \"wide\",\n context: \"formatting\"\n });\n }\n },\n // Local day of week\n e: function(date, token, localize2, options) {\n const dayOfWeek = date.getDay();\n const localDayOfWeek = (dayOfWeek - options.weekStartsOn + 8) % 7 || 7;\n switch (token) {\n // Numerical value (Nth day of week with current locale or weekStartsOn)\n case \"e\":\n return String(localDayOfWeek);\n // Padded numerical value\n case \"ee\":\n return addLeadingZeros(localDayOfWeek, 2);\n // 1st, 2nd, ..., 7th\n case \"eo\":\n return localize2.ordinalNumber(localDayOfWeek, { unit: \"day\" });\n case \"eee\":\n return localize2.day(dayOfWeek, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n // T\n case \"eeeee\":\n return localize2.day(dayOfWeek, {\n width: \"narrow\",\n context: \"formatting\"\n });\n // Tu\n case \"eeeeee\":\n return localize2.day(dayOfWeek, {\n width: \"short\",\n context: \"formatting\"\n });\n // Tuesday\n case \"eeee\":\n default:\n return localize2.day(dayOfWeek, {\n width: \"wide\",\n context: \"formatting\"\n });\n }\n },\n // Stand-alone local day of week\n c: function(date, token, localize2, options) {\n const dayOfWeek = date.getDay();\n const localDayOfWeek = (dayOfWeek - options.weekStartsOn + 8) % 7 || 7;\n switch (token) {\n // Numerical value (same as in `e`)\n case \"c\":\n return String(localDayOfWeek);\n // Padded numerical value\n case \"cc\":\n return addLeadingZeros(localDayOfWeek, token.length);\n // 1st, 2nd, ..., 7th\n case \"co\":\n return localize2.ordinalNumber(localDayOfWeek, { unit: \"day\" });\n case \"ccc\":\n return localize2.day(dayOfWeek, {\n width: \"abbreviated\",\n context: \"standalone\"\n });\n // T\n case \"ccccc\":\n return localize2.day(dayOfWeek, {\n width: \"narrow\",\n context: \"standalone\"\n });\n // Tu\n case \"cccccc\":\n return localize2.day(dayOfWeek, {\n width: \"short\",\n context: \"standalone\"\n });\n // Tuesday\n case \"cccc\":\n default:\n return localize2.day(dayOfWeek, {\n width: \"wide\",\n context: \"standalone\"\n });\n }\n },\n // ISO day of week\n i: function(date, token, localize2) {\n const dayOfWeek = date.getDay();\n const isoDayOfWeek = dayOfWeek === 0 ? 7 : dayOfWeek;\n switch (token) {\n // 2\n case \"i\":\n return String(isoDayOfWeek);\n // 02\n case \"ii\":\n return addLeadingZeros(isoDayOfWeek, token.length);\n // 2nd\n case \"io\":\n return localize2.ordinalNumber(isoDayOfWeek, { unit: \"day\" });\n // Tue\n case \"iii\":\n return localize2.day(dayOfWeek, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n // T\n case \"iiiii\":\n return localize2.day(dayOfWeek, {\n width: \"narrow\",\n context: \"formatting\"\n });\n // Tu\n case \"iiiiii\":\n return localize2.day(dayOfWeek, {\n width: \"short\",\n context: \"formatting\"\n });\n // Tuesday\n case \"iiii\":\n default:\n return localize2.day(dayOfWeek, {\n width: \"wide\",\n context: \"formatting\"\n });\n }\n },\n // AM or PM\n a: function(date, token, localize2) {\n const hours = date.getHours();\n const dayPeriodEnumValue = hours / 12 >= 1 ? \"pm\" : \"am\";\n switch (token) {\n case \"a\":\n case \"aa\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n case \"aaa\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"abbreviated\",\n context: \"formatting\"\n }).toLowerCase();\n case \"aaaaa\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"narrow\",\n context: \"formatting\"\n });\n case \"aaaa\":\n default:\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"wide\",\n context: \"formatting\"\n });\n }\n },\n // AM, PM, midnight, noon\n b: function(date, token, localize2) {\n const hours = date.getHours();\n let dayPeriodEnumValue;\n if (hours === 12) {\n dayPeriodEnumValue = dayPeriodEnum.noon;\n } else if (hours === 0) {\n dayPeriodEnumValue = dayPeriodEnum.midnight;\n } else {\n dayPeriodEnumValue = hours / 12 >= 1 ? \"pm\" : \"am\";\n }\n switch (token) {\n case \"b\":\n case \"bb\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n case \"bbb\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"abbreviated\",\n context: \"formatting\"\n }).toLowerCase();\n case \"bbbbb\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"narrow\",\n context: \"formatting\"\n });\n case \"bbbb\":\n default:\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"wide\",\n context: \"formatting\"\n });\n }\n },\n // in the morning, in the afternoon, in the evening, at night\n B: function(date, token, localize2) {\n const hours = date.getHours();\n let dayPeriodEnumValue;\n if (hours >= 17) {\n dayPeriodEnumValue = dayPeriodEnum.evening;\n } else if (hours >= 12) {\n dayPeriodEnumValue = dayPeriodEnum.afternoon;\n } else if (hours >= 4) {\n dayPeriodEnumValue = dayPeriodEnum.morning;\n } else {\n dayPeriodEnumValue = dayPeriodEnum.night;\n }\n switch (token) {\n case \"B\":\n case \"BB\":\n case \"BBB\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"abbreviated\",\n context: \"formatting\"\n });\n case \"BBBBB\":\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"narrow\",\n context: \"formatting\"\n });\n case \"BBBB\":\n default:\n return localize2.dayPeriod(dayPeriodEnumValue, {\n width: \"wide\",\n context: \"formatting\"\n });\n }\n },\n // Hour [1-12]\n h: function(date, token, localize2) {\n if (token === \"ho\") {\n let hours = date.getHours() % 12;\n if (hours === 0) hours = 12;\n return localize2.ordinalNumber(hours, { unit: \"hour\" });\n }\n return lightFormatters.h(date, token);\n },\n // Hour [0-23]\n H: function(date, token, localize2) {\n if (token === \"Ho\") {\n return localize2.ordinalNumber(date.getHours(), { unit: \"hour\" });\n }\n return lightFormatters.H(date, token);\n },\n // Hour [0-11]\n K: function(date, token, localize2) {\n const hours = date.getHours() % 12;\n if (token === \"Ko\") {\n return localize2.ordinalNumber(hours, { unit: \"hour\" });\n }\n return addLeadingZeros(hours, token.length);\n },\n // Hour [1-24]\n k: function(date, token, localize2) {\n let hours = date.getHours();\n if (hours === 0) hours = 24;\n if (token === \"ko\") {\n return localize2.ordinalNumber(hours, { unit: \"hour\" });\n }\n return addLeadingZeros(hours, token.length);\n },\n // Minute\n m: function(date, token, localize2) {\n if (token === \"mo\") {\n return localize2.ordinalNumber(date.getMinutes(), { unit: \"minute\" });\n }\n return lightFormatters.m(date, token);\n },\n // Second\n s: function(date, token, localize2) {\n if (token === \"so\") {\n return localize2.ordinalNumber(date.getSeconds(), { unit: \"second\" });\n }\n return lightFormatters.s(date, token);\n },\n // Fraction of second\n S: function(date, token) {\n return lightFormatters.S(date, token);\n },\n // Timezone (ISO-8601. If offset is 0, output is always `'Z'`)\n X: function(date, token, _localize) {\n const timezoneOffset = date.getTimezoneOffset();\n if (timezoneOffset === 0) {\n return \"Z\";\n }\n switch (token) {\n // Hours and optional minutes\n case \"X\":\n return formatTimezoneWithOptionalMinutes(timezoneOffset);\n // Hours, minutes and optional seconds without `:` delimiter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `XX`\n case \"XXXX\":\n case \"XX\":\n return formatTimezone(timezoneOffset);\n // Hours, minutes and optional seconds with `:` delimiter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `XXX`\n case \"XXXXX\":\n case \"XXX\":\n // Hours and minutes with `:` delimiter\n default:\n return formatTimezone(timezoneOffset, \":\");\n }\n },\n // Timezone (ISO-8601. If offset is 0, output is `'+00:00'` or equivalent)\n x: function(date, token, _localize) {\n const timezoneOffset = date.getTimezoneOffset();\n switch (token) {\n // Hours and optional minutes\n case \"x\":\n return formatTimezoneWithOptionalMinutes(timezoneOffset);\n // Hours, minutes and optional seconds without `:` delimiter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `xx`\n case \"xxxx\":\n case \"xx\":\n return formatTimezone(timezoneOffset);\n // Hours, minutes and optional seconds with `:` delimiter\n // Note: neither ISO-8601 nor JavaScript supports seconds in timezone offsets\n // so this token always has the same output as `xxx`\n case \"xxxxx\":\n case \"xxx\":\n // Hours and minutes with `:` delimiter\n default:\n return formatTimezone(timezoneOffset, \":\");\n }\n },\n // Timezone (GMT)\n O: function(date, token, _localize) {\n const timezoneOffset = date.getTimezoneOffset();\n switch (token) {\n // Short\n case \"O\":\n case \"OO\":\n case \"OOO\":\n return \"GMT\" + formatTimezoneShort(timezoneOffset, \":\");\n // Long\n case \"OOOO\":\n default:\n return \"GMT\" + formatTimezone(timezoneOffset, \":\");\n }\n },\n // Timezone (specific non-location)\n z: function(date, token, _localize) {\n const timezoneOffset = date.getTimezoneOffset();\n switch (token) {\n // Short\n case \"z\":\n case \"zz\":\n case \"zzz\":\n return \"GMT\" + formatTimezoneShort(timezoneOffset, \":\");\n // Long\n case \"zzzz\":\n default:\n return \"GMT\" + formatTimezone(timezoneOffset, \":\");\n }\n },\n // Seconds timestamp\n t: function(date, token, _localize) {\n const timestamp = Math.trunc(+date / 1e3);\n return addLeadingZeros(timestamp, token.length);\n },\n // Milliseconds timestamp\n T: function(date, token, _localize) {\n return addLeadingZeros(+date, token.length);\n }\n};\nfunction formatTimezoneShort(offset, delimiter = \"\") {\n const sign = offset > 0 ? \"-\" : \"+\";\n const absOffset = Math.abs(offset);\n const hours = Math.trunc(absOffset / 60);\n const minutes = absOffset % 60;\n if (minutes === 0) {\n return sign + String(hours);\n }\n return sign + String(hours) + delimiter + addLeadingZeros(minutes, 2);\n}\nfunction formatTimezoneWithOptionalMinutes(offset, delimiter) {\n if (offset % 60 === 0) {\n const sign = offset > 0 ? \"-\" : \"+\";\n return sign + addLeadingZeros(Math.abs(offset) / 60, 2);\n }\n return formatTimezone(offset, delimiter);\n}\nfunction formatTimezone(offset, delimiter = \"\") {\n const sign = offset > 0 ? \"-\" : \"+\";\n const absOffset = Math.abs(offset);\n const hours = addLeadingZeros(Math.trunc(absOffset / 60), 2);\n const minutes = addLeadingZeros(absOffset % 60, 2);\n return sign + hours + delimiter + minutes;\n}\n\n// node_modules/date-fns/_lib/format/longFormatters.js\nvar dateLongFormatter = (pattern, formatLong2) => {\n switch (pattern) {\n case \"P\":\n return formatLong2.date({ width: \"short\" });\n case \"PP\":\n return formatLong2.date({ width: \"medium\" });\n case \"PPP\":\n return formatLong2.date({ width: \"long\" });\n case \"PPPP\":\n default:\n return formatLong2.date({ width: \"full\" });\n }\n};\nvar timeLongFormatter = (pattern, formatLong2) => {\n switch (pattern) {\n case \"p\":\n return formatLong2.time({ width: \"short\" });\n case \"pp\":\n return formatLong2.time({ width: \"medium\" });\n case \"ppp\":\n return formatLong2.time({ width: \"long\" });\n case \"pppp\":\n default:\n return formatLong2.time({ width: \"full\" });\n }\n};\nvar dateTimeLongFormatter = (pattern, formatLong2) => {\n const matchResult = pattern.match(/(P+)(p+)?/) || [];\n const datePattern = matchResult[1];\n const timePattern = matchResult[2];\n if (!timePattern) {\n return dateLongFormatter(pattern, formatLong2);\n }\n let dateTimeFormat;\n switch (datePattern) {\n case \"P\":\n dateTimeFormat = formatLong2.dateTime({ width: \"short\" });\n break;\n case \"PP\":\n dateTimeFormat = formatLong2.dateTime({ width: \"medium\" });\n break;\n case \"PPP\":\n dateTimeFormat = formatLong2.dateTime({ width: \"long\" });\n break;\n case \"PPPP\":\n default:\n dateTimeFormat = formatLong2.dateTime({ width: \"full\" });\n break;\n }\n return dateTimeFormat.replace(\"{{date}}\", dateLongFormatter(datePattern, formatLong2)).replace(\"{{time}}\", timeLongFormatter(timePattern, formatLong2));\n};\nvar longFormatters = {\n p: timeLongFormatter,\n P: dateTimeLongFormatter\n};\n\n// node_modules/date-fns/_lib/protectedTokens.js\nvar dayOfYearTokenRE = /^D+$/;\nvar weekYearTokenRE = /^Y+$/;\nvar throwTokens = [\"D\", \"DD\", \"YY\", \"YYYY\"];\nfunction isProtectedDayOfYearToken(token) {\n return dayOfYearTokenRE.test(token);\n}\nfunction isProtectedWeekYearToken(token) {\n return weekYearTokenRE.test(token);\n}\nfunction warnOrThrowProtectedError(token, format2, input) {\n const _message = message(token, format2, input);\n console.warn(_message);\n if (throwTokens.includes(token)) throw new RangeError(_message);\n}\nfunction message(token, format2, input) {\n const subject = token[0] === \"Y\" ? \"years\" : \"days of the month\";\n return `Use \\`${token.toLowerCase()}\\` instead of \\`${token}\\` (in \\`${format2}\\`) for formatting ${subject} to the input \\`${input}\\`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md`;\n}\n\n// node_modules/date-fns/format.js\nvar formattingTokensRegExp = /[yYQqMLwIdDecihHKkms]o|(\\w)\\1*|''|'(''|[^'])+('|$)|./g;\nvar longFormattingTokensRegExp = /P+p+|P+|p+|''|'(''|[^'])+('|$)|./g;\nvar escapedStringRegExp = /^'([^]*?)'?$/;\nvar doubleQuoteRegExp = /''/g;\nvar unescapedLatinCharacterRegExp = /[a-zA-Z]/;\nfunction format(date, formatStr, options) {\n const defaultOptions2 = getDefaultOptions();\n const locale = options?.locale ?? defaultOptions2.locale ?? enUS;\n const firstWeekContainsDate = options?.firstWeekContainsDate ?? options?.locale?.options?.firstWeekContainsDate ?? defaultOptions2.firstWeekContainsDate ?? defaultOptions2.locale?.options?.firstWeekContainsDate ?? 1;\n const weekStartsOn = options?.weekStartsOn ?? options?.locale?.options?.weekStartsOn ?? defaultOptions2.weekStartsOn ?? defaultOptions2.locale?.options?.weekStartsOn ?? 0;\n const originalDate = toDate(date, options?.in);\n if (!isValid(originalDate)) {\n throw new RangeError(\"Invalid time value\");\n }\n let parts = formatStr.match(longFormattingTokensRegExp).map((substring) => {\n const firstCharacter = substring[0];\n if (firstCharacter === \"p\" || firstCharacter === \"P\") {\n const longFormatter = longFormatters[firstCharacter];\n return longFormatter(substring, locale.formatLong);\n }\n return substring;\n }).join(\"\").match(formattingTokensRegExp).map((substring) => {\n if (substring === \"''\") {\n return { isToken: false, value: \"'\" };\n }\n const firstCharacter = substring[0];\n if (firstCharacter === \"'\") {\n return { isToken: false, value: cleanEscapedString(substring) };\n }\n if (formatters[firstCharacter]) {\n return { isToken: true, value: substring };\n }\n if (firstCharacter.match(unescapedLatinCharacterRegExp)) {\n throw new RangeError(\n \"Format string contains an unescaped latin alphabet character `\" + firstCharacter + \"`\"\n );\n }\n return { isToken: false, value: substring };\n });\n if (locale.localize.preprocessor) {\n parts = locale.localize.preprocessor(originalDate, parts);\n }\n const formatterOptions = {\n firstWeekContainsDate,\n weekStartsOn,\n locale\n };\n return parts.map((part) => {\n if (!part.isToken) return part.value;\n const token = part.value;\n if (!options?.useAdditionalWeekYearTokens && isProtectedWeekYearToken(token) || !options?.useAdditionalDayOfYearTokens && isProtectedDayOfYearToken(token)) {\n warnOrThrowProtectedError(token, formatStr, String(date));\n }\n const formatter = formatters[token[0]];\n return formatter(originalDate, token, locale.localize, formatterOptions);\n }).join(\"\");\n}\nfunction cleanEscapedString(input) {\n const matched = input.match(escapedStringRegExp);\n if (!matched) {\n return input;\n }\n return matched[1].replace(doubleQuoteRegExp, \"'\");\n}\n\n// node_modules/date-fns/subDays.js\nfunction subDays(date, amount, options) {\n return addDays(date, -amount, options);\n}\n\n// node_modules/date-fns/parseISO.js\nfunction parseISO(argument, options) {\n const invalidDate = () => constructFrom(options?.in, NaN);\n const additionalDigits = options?.additionalDigits ?? 2;\n const dateStrings = splitDateString(argument);\n let date;\n if (dateStrings.date) {\n const parseYearResult = parseYear(dateStrings.date, additionalDigits);\n date = parseDate(parseYearResult.restDateString, parseYearResult.year);\n }\n if (!date || isNaN(+date)) return invalidDate();\n const timestamp = +date;\n let time = 0;\n let offset;\n if (dateStrings.time) {\n time = parseTime(dateStrings.time);\n if (isNaN(time)) return invalidDate();\n }\n if (dateStrings.timezone) {\n offset = parseTimezone(dateStrings.timezone);\n if (isNaN(offset)) return invalidDate();\n } else {\n const tmpDate = new Date(timestamp + time);\n const result = toDate(0, options?.in);\n result.setFullYear(\n tmpDate.getUTCFullYear(),\n tmpDate.getUTCMonth(),\n tmpDate.getUTCDate()\n );\n result.setHours(\n tmpDate.getUTCHours(),\n tmpDate.getUTCMinutes(),\n tmpDate.getUTCSeconds(),\n tmpDate.getUTCMilliseconds()\n );\n return result;\n }\n return toDate(timestamp + time + offset, options?.in);\n}\nvar patterns = {\n dateTimeDelimiter: /[T ]/,\n timeZoneDelimiter: /[Z ]/i,\n timezone: /([Z+-].*)$/\n};\nvar dateRegex = /^-?(?:(\\d{3})|(\\d{2})(?:-?(\\d{2}))?|W(\\d{2})(?:-?(\\d{1}))?|)$/;\nvar timeRegex = /^(\\d{2}(?:[.,]\\d*)?)(?::?(\\d{2}(?:[.,]\\d*)?))?(?::?(\\d{2}(?:[.,]\\d*)?))?$/;\nvar timezoneRegex = /^([+-])(\\d{2})(?::?(\\d{2}))?$/;\nfunction splitDateString(dateString) {\n const dateStrings = {};\n const array = dateString.split(patterns.dateTimeDelimiter);\n let timeString;\n if (array.length > 2) {\n return dateStrings;\n }\n if (/:/.test(array[0])) {\n timeString = array[0];\n } else {\n dateStrings.date = array[0];\n timeString = array[1];\n if (patterns.timeZoneDelimiter.test(dateStrings.date)) {\n dateStrings.date = dateString.split(patterns.timeZoneDelimiter)[0];\n timeString = dateString.substr(\n dateStrings.date.length,\n dateString.length\n );\n }\n }\n if (timeString) {\n const token = patterns.timezone.exec(timeString);\n if (token) {\n dateStrings.time = timeString.replace(token[1], \"\");\n dateStrings.timezone = token[1];\n } else {\n dateStrings.time = timeString;\n }\n }\n return dateStrings;\n}\nfunction parseYear(dateString, additionalDigits) {\n const regex = new RegExp(\n \"^(?:(\\\\d{4}|[+-]\\\\d{\" + (4 + additionalDigits) + \"})|(\\\\d{2}|[+-]\\\\d{\" + (2 + additionalDigits) + \"})$)\"\n );\n const captures = dateString.match(regex);\n if (!captures) return { year: NaN, restDateString: \"\" };\n const year = captures[1] ? parseInt(captures[1]) : null;\n const century = captures[2] ? parseInt(captures[2]) : null;\n return {\n year: century === null ? year : century * 100,\n restDateString: dateString.slice((captures[1] || captures[2]).length)\n };\n}\nfunction parseDate(dateString, year) {\n if (year === null) return /* @__PURE__ */ new Date(NaN);\n const captures = dateString.match(dateRegex);\n if (!captures) return /* @__PURE__ */ new Date(NaN);\n const isWeekDate = !!captures[4];\n const dayOfYear = parseDateUnit(captures[1]);\n const month = parseDateUnit(captures[2]) - 1;\n const day = parseDateUnit(captures[3]);\n const week = parseDateUnit(captures[4]);\n const dayOfWeek = parseDateUnit(captures[5]) - 1;\n if (isWeekDate) {\n if (!validateWeekDate(year, week, dayOfWeek)) {\n return /* @__PURE__ */ new Date(NaN);\n }\n return dayOfISOWeekYear(year, week, dayOfWeek);\n } else {\n const date = /* @__PURE__ */ new Date(0);\n if (!validateDate(year, month, day) || !validateDayOfYearDate(year, dayOfYear)) {\n return /* @__PURE__ */ new Date(NaN);\n }\n date.setUTCFullYear(year, month, Math.max(dayOfYear, day));\n return date;\n }\n}\nfunction parseDateUnit(value) {\n return value ? parseInt(value) : 1;\n}\nfunction parseTime(timeString) {\n const captures = timeString.match(timeRegex);\n if (!captures) return NaN;\n const hours = parseTimeUnit(captures[1]);\n const minutes = parseTimeUnit(captures[2]);\n const seconds = parseTimeUnit(captures[3]);\n if (!validateTime(hours, minutes, seconds)) {\n return NaN;\n }\n return hours * millisecondsInHour + minutes * millisecondsInMinute + seconds * 1e3;\n}\nfunction parseTimeUnit(value) {\n return value && parseFloat(value.replace(\",\", \".\")) || 0;\n}\nfunction parseTimezone(timezoneString) {\n if (timezoneString === \"Z\") return 0;\n const captures = timezoneString.match(timezoneRegex);\n if (!captures) return 0;\n const sign = captures[1] === \"+\" ? -1 : 1;\n const hours = parseInt(captures[2]);\n const minutes = captures[3] && parseInt(captures[3]) || 0;\n if (!validateTimezone(hours, minutes)) {\n return NaN;\n }\n return sign * (hours * millisecondsInHour + minutes * millisecondsInMinute);\n}\nfunction dayOfISOWeekYear(isoWeekYear, week, day) {\n const date = /* @__PURE__ */ new Date(0);\n date.setUTCFullYear(isoWeekYear, 0, 4);\n const fourthOfJanuaryDay = date.getUTCDay() || 7;\n const diff = (week - 1) * 7 + day + 1 - fourthOfJanuaryDay;\n date.setUTCDate(date.getUTCDate() + diff);\n return date;\n}\nvar daysInMonths = [31, null, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];\nfunction isLeapYearIndex(year) {\n return year % 400 === 0 || year % 4 === 0 && year % 100 !== 0;\n}\nfunction validateDate(year, month, date) {\n return month >= 0 && month <= 11 && date >= 1 && date <= (daysInMonths[month] || (isLeapYearIndex(year) ? 29 : 28));\n}\nfunction validateDayOfYearDate(year, dayOfYear) {\n return dayOfYear >= 1 && dayOfYear <= (isLeapYearIndex(year) ? 366 : 365);\n}\nfunction validateWeekDate(_year, week, day) {\n return week >= 1 && week <= 53 && day >= 0 && day <= 6;\n}\nfunction validateTime(hours, minutes, seconds) {\n if (hours === 24) {\n return minutes === 0 && seconds === 0;\n }\n return seconds >= 0 && seconds < 60 && minutes >= 0 && minutes < 60 && hours >= 0 && hours < 25;\n}\nfunction validateTimezone(_hours, minutes) {\n return minutes >= 0 && minutes <= 59;\n}\n\n// node_modules/date-fns/subMonths.js\nfunction subMonths(date, amount, options) {\n return addMonths(date, -amount, options);\n}\n\n// node_modules/date-fns/subHours.js\nfunction subHours(date, amount, options) {\n return addHours(date, -amount, options);\n}\n\n// node_modules/date-fns/subMinutes.js\nfunction subMinutes(date, amount, options) {\n return addMinutes(date, -amount, options);\n}\n\n// node_modules/date-fns/subSeconds.js\nfunction subSeconds(date, amount, options) {\n return addSeconds(date, -amount, options);\n}\n\n// node_modules/date-fns/subYears.js\nfunction subYears(date, amount, options) {\n return addYears(date, -amount, options);\n}\n\n// plugins/template-function-timestamp/src/index.ts\nvar dateArg = {\n type: \"text\",\n name: \"date\",\n label: \"Timestamp\",\n optional: true,\n description: \"Can be a timestamp in milliseconds, ISO string, or anything parseable by JS `new Date()`\",\n placeholder: (/* @__PURE__ */ new Date()).toISOString()\n};\nvar expressionArg = {\n type: \"text\",\n name: \"expression\",\n label: \"Expression\",\n description: \"Modification expression (eg. '-5d +2h 3m'). Available units: y, M, d, h, m, s\",\n optional: true,\n placeholder: \"-5d +2h 3m\"\n};\nvar formatArg = {\n name: \"format\",\n label: \"Format String\",\n description: `date-fns format string to describe the output (eg. \"yyyy-MM-dd 'at' HH:mm:ss\"). Wrap literal text in single quotes to escape it`,\n optional: true,\n placeholder: \"yyyy-MM-dd HH:mm:ss\",\n type: \"text\"\n};\nvar formatDocsBanner = {\n type: \"banner\",\n color: \"info\",\n inputs: [\n {\n type: \"markdown\",\n content: \"Uses [date-fns format tokens](https://date-fns.org/docs/format), not dayjs or Moment. Wrap literal text in single quotes to escape it.\"\n }\n ]\n};\nvar plugin = {\n templateFunctions: [\n {\n name: \"timestamp.unix\",\n description: \"Get the timestamp in seconds\",\n args: [dateArg],\n onRender: async (_ctx, args) => {\n const d = parseDateString(String(args.values.date ?? \"\"));\n return String(Math.floor(d.getTime() / 1e3));\n }\n },\n {\n name: \"timestamp.unixMillis\",\n description: \"Get the timestamp in milliseconds\",\n args: [dateArg],\n onRender: async (_ctx, args) => {\n const d = parseDateString(String(args.values.date ?? \"\"));\n return String(d.getTime());\n }\n },\n {\n name: \"timestamp.iso8601\",\n description: \"Get the date in ISO8601 format\",\n args: [dateArg],\n onRender: async (_ctx, args) => {\n const d = parseDateString(String(args.values.date ?? \"\"));\n return d.toISOString();\n }\n },\n {\n name: \"timestamp.format\",\n description: \"Format a date using a date-fns format string\",\n args: [formatDocsBanner, dateArg, formatArg],\n previewArgs: [formatArg.name],\n onRender: async (_ctx, args) => formatDatetime(args.values)\n },\n {\n name: \"timestamp.offset\",\n description: \"Get the offset of a date based on an expression\",\n args: [dateArg, expressionArg],\n previewArgs: [expressionArg.name],\n onRender: async (_ctx, args) => calculateDatetime(args.values)\n }\n ]\n};\nfunction applyDateOp(d, sign, amount, unit) {\n switch (unit) {\n case \"y\":\n return sign === \"-\" ? subYears(d, amount) : addYears(d, amount);\n case \"M\":\n return sign === \"-\" ? subMonths(d, amount) : addMonths(d, amount);\n case \"d\":\n return sign === \"-\" ? subDays(d, amount) : addDays(d, amount);\n case \"h\":\n return sign === \"-\" ? subHours(d, amount) : addHours(d, amount);\n case \"m\":\n return sign === \"-\" ? subMinutes(d, amount) : addMinutes(d, amount);\n case \"s\":\n return sign === \"-\" ? subSeconds(d, amount) : addSeconds(d, amount);\n default:\n throw new Error(`Invalid data calculation unit: ${unit}`);\n }\n}\nfunction parseOp(op) {\n const match2 = op.match(/^([+-]?)(\\d+)([yMdhms])$/);\n if (!match2) {\n throw new Error(`Invalid date expression: ${op}`);\n }\n const [, sign, amount, unit] = match2;\n if (!unit) return null;\n return { sign: sign ?? \"+\", amount: Number(amount ?? 0), unit };\n}\nfunction parseDateString(date) {\n if (!date.trim()) {\n return /* @__PURE__ */ new Date();\n }\n const isoDate = parseISO(date);\n if (isValid(isoDate)) {\n return isoDate;\n }\n const jsDate = /^\\d+(\\.\\d+)?$/.test(date) ? new Date(Number(date)) : new Date(date);\n if (isValid(jsDate)) {\n return jsDate;\n }\n throw new Error(`Invalid date: ${date}`);\n}\nfunction calculateDatetime(args) {\n const { date, expression } = args;\n let jsDate = parseDateString(date ?? \"\");\n if (expression) {\n const ops = String(expression).split(\" \").map((s) => s.trim()).filter(Boolean);\n for (const op of ops) {\n const parsed = parseOp(op);\n if (parsed) {\n jsDate = applyDateOp(jsDate, parsed.sign, parsed.amount, parsed.unit);\n }\n }\n }\n return jsDate.toISOString();\n}\nfunction formatDatetime(args) {\n const { date, format: format2 } = args;\n const d = parseDateString(date ?? \"\");\n return format(d, String(format2 || \"yyyy-MM-dd HH:mm:ss\"), { in: args.in });\n}\n" }, + { name: "importer-curl", source: "\"use strict\";\nvar __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __hasOwnProp = Object.prototype.hasOwnProperty;\nvar __export = (target, all) => {\n for (var name in all)\n __defProp(target, name, { get: all[name], enumerable: true });\n};\nvar __copyProps = (to, from, except, desc) => {\n if (from && typeof from === \"object\" || typeof from === \"function\") {\n for (let key of __getOwnPropNames(from))\n if (!__hasOwnProp.call(to, key) && key !== except)\n __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n }\n return to;\n};\nvar __toCommonJS = (mod) => __copyProps(__defProp({}, \"__esModule\", { value: true }), mod);\n\n// plugins/importer-curl/src/index.ts\nvar index_exports = {};\n__export(index_exports, {\n convertCurl: () => convertCurl,\n plugin: () => plugin\n});\nmodule.exports = __toCommonJS(index_exports);\n\n// node_modules/shlex/shlex.js\nvar Shlexer = class {\n constructor(string) {\n this.i = 0;\n this.string = string;\n this.whitespace = \" \t\\r\\n\";\n this.quotes = `'\"`;\n this.escapes = \"\\\\\";\n this.escapedQuotes = '\"';\n this.ansiCQuotes = true;\n this.localeQuotes = true;\n this.debug = false;\n }\n readChar() {\n return this.string.charAt(this.i++);\n }\n processEscapes(string, quote, isAnsiCQuote) {\n if (!isAnsiCQuote && !this.escapedQuotes.includes(quote)) {\n return string;\n }\n const anyEscape = \"[\" + this.escapes.replace(/(.)/g, \"\\\\$1\") + \"]\";\n if (!isAnsiCQuote && this.escapedQuotes.includes(quote)) {\n const re = new RegExp(\n anyEscape + \"(\" + anyEscape + \"|\\\\\" + quote + \")\",\n \"g\"\n );\n return string.replace(re, \"$1\");\n }\n if (isAnsiCQuote) {\n const patterns = {\n // Literal characters\n \"([\\\\\\\\'\\\"?])\": (x) => x,\n // Non-printable ASCII characters\n \"a\": () => \"\\x07\",\n \"b\": () => \"\\b\",\n \"e|E\": () => \"\\x1B\",\n \"f\": () => \"\\f\",\n \"n\": () => \"\\n\",\n \"r\": () => \"\\r\",\n \"t\": () => \"\t\",\n \"v\": () => \"\\v\",\n // Octal bytes\n \"([0-7]{1,3})\": (x) => String.fromCharCode(parseInt(x, 8)),\n // Hexadecimal bytes\n \"x([0-9a-fA-F]{1,2})\": (x) => String.fromCharCode(parseInt(x, 16)),\n // Unicode code units\n \"u([0-9a-fA-F]{1,4})\": (x) => String.fromCharCode(parseInt(x, 16)),\n \"U([0-9a-fA-F]{1,8})\": (x) => String.fromCharCode(parseInt(x, 16)),\n // Control characters\n // https://en.wikipedia.org/wiki/Control_character#How_control_characters_map_to_keyboards\n \"c(.)\": (x) => {\n if (x === \"?\") {\n return \"\\x7F\";\n } else if (x === \"@\") {\n return \"\\0\";\n } else {\n return String.fromCharCode(x.charCodeAt(0) & 31);\n }\n }\n };\n const re = new RegExp(\n anyEscape + \"(\" + Object.keys(patterns).join(\"|\") + \")\",\n \"g\"\n );\n return string.replace(re, function(m, p1) {\n for (const matched in patterns) {\n const mm = new RegExp(\"^\" + matched + \"$\").exec(p1);\n if (mm === null) {\n continue;\n }\n return patterns[matched].apply(null, mm.slice(1));\n }\n });\n }\n return void 0;\n }\n *[Symbol.iterator]() {\n let inQuote = false;\n let inDollarQuote = false;\n let escaped = false;\n let lastDollar = -2;\n let token;\n if (this.debug) {\n console.log(\"full input:\", \">\" + this.string + \"<\");\n }\n while (true) {\n const pos = this.i;\n const char = this.readChar();\n if (this.debug) {\n console.log(\n \"position:\",\n pos,\n \"input:\",\n \">\" + char + \"<\",\n \"accumulated:\",\n token,\n \"inQuote:\",\n inQuote,\n \"inDollarQuote:\",\n inDollarQuote,\n \"lastDollar:\",\n lastDollar,\n \"escaped:\",\n escaped\n );\n }\n if (char === \"\") {\n if (inQuote) {\n throw new Error(\"Got EOF while in a quoted string\");\n }\n if (escaped) {\n throw new Error(\"Got EOF while in an escape sequence\");\n }\n if (token !== void 0) {\n yield token;\n }\n return;\n }\n if (escaped) {\n if (char === \"\\n\") {\n } else if (inQuote) {\n token = (token || \"\") + escaped + char;\n } else {\n token = (token || \"\") + char;\n }\n escaped = false;\n continue;\n }\n if (this.escapes.includes(char)) {\n if (!inQuote || inDollarQuote !== false || this.escapedQuotes.includes(inQuote)) {\n escaped = char;\n continue;\n } else {\n }\n }\n if (inQuote !== false) {\n if (char === inQuote) {\n token = this.processEscapes(token, inQuote, inDollarQuote === \"'\");\n inQuote = false;\n inDollarQuote = false;\n continue;\n }\n token = (token || \"\") + char;\n continue;\n }\n if (this.quotes.includes(char)) {\n inQuote = char;\n if (lastDollar === pos - 1) {\n if (char === \"'\" && !this.ansiCQuotes) {\n } else if (char === '\"' && !this.localeQuotes) {\n } else {\n inDollarQuote = char;\n }\n }\n token = token || \"\";\n if (inDollarQuote !== false) {\n token = token.slice(0, -1);\n }\n continue;\n }\n if (inQuote === false && char === \"$\") {\n lastDollar = pos;\n }\n if (this.whitespace.includes(char)) {\n if (token !== void 0) {\n yield token;\n }\n token = void 0;\n continue;\n }\n token = (token || \"\") + char;\n }\n }\n};\nfunction split(s) {\n return Array.from(new Shlexer(s));\n}\n\n// plugins/importer-curl/src/graphql.ts\nfunction parseGraphQLJsonBody({\n mimeType,\n text,\n url\n}) {\n if (mimeType !== \"application/json\") {\n return null;\n }\n let parsed;\n try {\n parsed = JSON.parse(text);\n } catch {\n return null;\n }\n if (!parsed || typeof parsed !== \"object\" || Array.isArray(parsed)) {\n return null;\n }\n const body = parsed;\n if (typeof body.query !== \"string\") {\n return null;\n }\n if (hasExtraGraphQLEnvelopeFields(body)) {\n return null;\n }\n const signals = getGraphQLDetectionSignals(body, url);\n const score = signals.reduce((total, signal) => total + signal.score, 0);\n const hasGraphQLDocument = signals.some((signal) => signal.requiresGraphQLDocument);\n if (!hasGraphQLDocument || score < 4) {\n return null;\n }\n const result = { query: body.query };\n if (body.variables != null) {\n result.variables = typeof body.variables === \"string\" ? body.variables : JSON.stringify(body.variables, null, 2);\n }\n if (typeof body.operationName === \"string\") {\n result.operationName = body.operationName;\n }\n return result;\n}\nfunction hasExtraGraphQLEnvelopeFields(body) {\n const allowedKeys = /* @__PURE__ */ new Set([\"query\", \"variables\", \"operationName\"]);\n return Object.keys(body).some((key) => !allowedKeys.has(key));\n}\nfunction getGraphQLDetectionSignals(body, url) {\n const signals = [];\n const query = body.query;\n const urlPath = getUrlPath(url).toLowerCase();\n if (/\\b(graphql|gql)\\b/.test(urlPath)) {\n signals.push({ score: 2 });\n }\n if (/^(query|mutation|subscription|fragment)\\b/.test(query.trim())) {\n signals.push({ score: 3 });\n } else if (/^\\{[\\s\\S]*\\}$/.test(query.trim())) {\n signals.push({ score: 3, requiresGraphQLDocument: true });\n }\n if (/\\{[\\s\\S]*\\}/.test(query)) {\n signals.push({ score: 1, requiresGraphQLDocument: true });\n }\n if (typeof body.operationName === \"string\" && body.operationName.trim() !== \"\") {\n signals.push({ score: 1 });\n }\n if (body.variables != null && (typeof body.variables === \"object\" || typeof body.variables === \"string\")) {\n signals.push({ score: 1 });\n }\n return signals;\n}\nfunction getUrlPath(url) {\n try {\n return new URL(url).pathname;\n } catch {\n return url;\n }\n}\n\n// plugins/importer-curl/src/index.ts\nvar DATA_FLAGS = [\"d\", \"data\", \"data-raw\", \"data-urlencode\", \"data-binary\", \"data-ascii\"];\nvar SUPPORTED_FLAGS = [\n [\"cookie\", \"b\"],\n [\"d\", \"data\"],\n // Add url encoded data\n [\"data-ascii\"],\n [\"data-binary\"],\n [\"data-raw\"],\n [\"data-urlencode\"],\n [\"digest\"],\n // Apply auth as digest\n [\"form\", \"F\"],\n // Add multipart data\n [\"get\", \"G\"],\n // Put the post data in the URL\n [\"header\", \"H\"],\n [\"request\", \"X\"],\n // Request method\n [\"url\"],\n // Specify the URL explicitly\n [\"url-query\"],\n [\"user\", \"u\"],\n // Authentication\n DATA_FLAGS\n].flat();\nvar BOOLEAN_FLAGS = [\"G\", \"get\", \"digest\"];\nvar plugin = {\n importer: {\n name: \"cURL\",\n description: \"Import cURL commands\",\n onImport(_ctx, args) {\n return convertCurl(args.text);\n }\n }\n};\nfunction splitCommands(rawData) {\n const joined = rawData.replace(/\\\\\\r?\\n/g, \" \");\n function isEscaped(i) {\n let backslashes = 0;\n let j = i - 1;\n while (j >= 0 && joined[j] === \"\\\\\") {\n backslashes++;\n j--;\n }\n return backslashes % 2 !== 0;\n }\n const commands = [];\n let current = \"\";\n let inSingleQuote = false;\n let inDoubleQuote = false;\n let inDollarQuote = false;\n for (let i = 0; i < joined.length; i++) {\n if (joined[i] === void 0) break;\n const ch = joined[i];\n const next = joined[i + 1];\n if (!inDoubleQuote && !inDollarQuote && ch === \"'\" && !inSingleQuote) {\n inSingleQuote = true;\n current += ch;\n continue;\n }\n if (inSingleQuote && ch === \"'\") {\n inSingleQuote = false;\n current += ch;\n continue;\n }\n if (!inSingleQuote && !inDollarQuote && ch === '\"' && !inDoubleQuote) {\n inDoubleQuote = true;\n current += ch;\n continue;\n }\n if (inDoubleQuote && ch === '\"' && !isEscaped(i)) {\n inDoubleQuote = false;\n current += ch;\n continue;\n }\n if (!inSingleQuote && !inDoubleQuote && !inDollarQuote && ch === \"$\" && next === \"'\") {\n inDollarQuote = true;\n current += ch + next;\n i++;\n continue;\n }\n if (inDollarQuote && ch === \"'\" && !isEscaped(i)) {\n inDollarQuote = false;\n current += ch;\n continue;\n }\n const inQuote = inSingleQuote || inDoubleQuote || inDollarQuote;\n if (!inQuote && !isEscaped(i) && (ch === \";\" || ch === \"\\n\" || ch === \"\\r\" && next === \"\\n\")) {\n if (ch === \"\\r\") i++;\n if (current.trim()) {\n commands.push(current.trim());\n }\n current = \"\";\n continue;\n }\n current += ch;\n }\n if (current.trim()) {\n commands.push(current.trim());\n }\n return commands;\n}\nfunction convertCurl(rawData) {\n if (!rawData.match(/^\\s*curl /)) {\n return null;\n }\n const commands = splitCommands(rawData).map((cmd) => {\n const tokens = split(cmd);\n return tokens.flatMap((token) => {\n if (token.startsWith(\"-\") && !token.startsWith(\"--\") && token.length > 2) {\n return [token.slice(0, 2), token.slice(2)];\n }\n return token;\n });\n });\n const workspace = {\n model: \"workspace\",\n id: generateId(\"workspace\"),\n name: \"Curl Import\"\n };\n const requests = commands.filter((command) => command[0] === \"curl\").map((v) => importCommand(v, workspace.id));\n return {\n resources: {\n httpRequests: requests,\n workspaces: [workspace]\n }\n };\n}\nfunction extractAuthenticationFromHeaders(headers) {\n const authorizationHeaderIndex = headers.findIndex(\n (h) => h.name.toLowerCase() === \"authorization\"\n );\n const authorizationHeader = headers[authorizationHeaderIndex];\n if (authorizationHeader == null) {\n return {\n authenticationType: null,\n authentication: {},\n filteredHeaders: headers\n };\n }\n const value = authorizationHeader.value.trim();\n const spaceIndex = value.indexOf(\" \");\n if (spaceIndex <= 0) {\n return {\n authenticationType: null,\n authentication: {},\n filteredHeaders: headers\n };\n }\n const scheme = value.slice(0, spaceIndex).toLowerCase();\n const credentials = value.slice(spaceIndex + 1).trim();\n if (scheme === \"bearer\") {\n const filteredHeaders = headers.filter((_, i) => i !== authorizationHeaderIndex);\n return {\n authenticationType: \"bearer\",\n authentication: { token: credentials, prefix: \"Bearer\" },\n filteredHeaders\n };\n }\n if (scheme === \"basic\") {\n try {\n const decoded = Buffer.from(credentials, \"base64\").toString();\n const colonIndex = decoded.indexOf(\":\");\n if (colonIndex > 0) {\n const filteredHeaders = headers.filter((_, i) => i !== authorizationHeaderIndex);\n return {\n authenticationType: \"basic\",\n authentication: {\n username: decoded.slice(0, colonIndex),\n password: decoded.slice(colonIndex + 1)\n },\n filteredHeaders\n };\n }\n } catch {\n }\n }\n return {\n authenticationType: null,\n authentication: {},\n filteredHeaders: headers\n };\n}\nfunction importCommand(parseEntries, workspaceId) {\n const flagsByName = {};\n const singletons = [];\n for (let i = 1; i < parseEntries.length; i++) {\n let parseEntry = parseEntries[i];\n if (typeof parseEntry === \"string\") {\n parseEntry = parseEntry.trim();\n }\n if (typeof parseEntry === \"string\" && parseEntry.match(/^-{1,2}[\\w-]+/)) {\n const isSingleDash = parseEntry[0] === \"-\" && parseEntry[1] !== \"-\";\n let name = parseEntry.replace(/^-{1,2}/, \"\");\n if (!SUPPORTED_FLAGS.includes(name)) {\n continue;\n }\n let value;\n const nextEntry = parseEntries[i + 1];\n const hasValue = !BOOLEAN_FLAGS.includes(name);\n const nextEntryIsFlag = typeof nextEntry === \"string\" && (nextEntry.match(/^-[a-zA-Z]/) || nextEntry.match(/^--[a-zA-Z]/));\n if (isSingleDash && name.length > 1) {\n value = name.slice(1);\n name = name.slice(0, 1);\n } else if (typeof nextEntry === \"string\" && hasValue && !nextEntryIsFlag) {\n value = nextEntry;\n i++;\n } else {\n value = true;\n }\n flagsByName[name] = flagsByName[name] || [];\n flagsByName[name]?.push(value);\n } else if (parseEntry) {\n singletons.push(parseEntry);\n }\n }\n const urlArg = getPairValue(flagsByName, singletons[0] || \"\", [\"url\"]);\n const [baseUrl, search] = splitOnce(urlArg, \"?\");\n const urlParameters = search?.split(\"&\").map((p) => {\n const v = splitOnce(p, \"=\");\n return {\n name: decodeURIComponent(v[0] ?? \"\"),\n value: decodeURIComponent(v[1] ?? \"\"),\n enabled: true\n };\n }) ?? [];\n const url = baseUrl ?? urlArg;\n for (const p of flagsByName[\"url-query\"] ?? []) {\n if (typeof p !== \"string\") {\n continue;\n }\n const [name, value] = p.split(\"=\");\n urlParameters.push({\n name: name ?? \"\",\n value: value ?? \"\",\n enabled: true\n });\n }\n const [username, password] = getPairValue(flagsByName, \"\", [\"u\", \"user\"]).split(/:(.*)$/);\n const isDigest = getPairValue(flagsByName, false, [\"digest\"]);\n const authenticationType = username ? isDigest ? \"digest\" : \"basic\" : null;\n const authentication = username ? {\n username: username.trim(),\n password: (password ?? \"\").trim()\n } : {};\n const headers = [\n ...flagsByName.header || [],\n ...flagsByName.H || []\n ].map((header) => {\n const [name, value] = header.split(/:(.*)$/);\n if (!value) {\n return {\n name: (name ?? \"\").trim().replace(/;$/, \"\"),\n value: \"\",\n enabled: true\n };\n }\n return {\n name: (name ?? \"\").trim(),\n value: value.trim(),\n enabled: true\n };\n });\n const cookieHeaderValue = [\n ...flagsByName.cookie || [],\n ...flagsByName.b || []\n ].map((str) => {\n const name = str.split(\"=\", 1)[0];\n const value = str.replace(`${name}=`, \"\");\n return `${name}=${value}`;\n }).join(\"; \");\n const existingCookieHeader = headers.find((header) => header.name.toLowerCase() === \"cookie\");\n if (cookieHeaderValue && existingCookieHeader) {\n existingCookieHeader.value += `; ${cookieHeaderValue}`;\n } else if (cookieHeaderValue) {\n headers.push({\n name: \"Cookie\",\n value: cookieHeaderValue,\n enabled: true\n });\n }\n const {\n authenticationType: extractedAuthenticationType,\n authentication: extractedAuthentication,\n filteredHeaders\n } = extractAuthenticationFromHeaders(headers);\n const finalAuthenticationType = extractedAuthenticationType || authenticationType;\n const finalAuthentication = extractedAuthenticationType ? extractedAuthentication : authentication;\n const contentTypeHeader = filteredHeaders.find(\n (header) => header.name.toLowerCase() === \"content-type\"\n );\n const mimeType = contentTypeHeader ? contentTypeHeader.value.split(\";\")[0]?.trim() : null;\n const boundaryMatch = contentTypeHeader?.value.match(/boundary=([^\\s;]+)/i);\n const boundary = boundaryMatch?.[1];\n const rawDataValues = [\n ...flagsByName[\"data-raw\"] || [],\n ...flagsByName.d || [],\n ...flagsByName.data || [],\n ...flagsByName[\"data-binary\"] || [],\n ...flagsByName[\"data-ascii\"] || []\n ];\n let multipartFormDataFromRaw = null;\n if (mimeType === \"multipart/form-data\" && boundary && rawDataValues.length > 0) {\n const rawBody = rawDataValues.join(\"\");\n multipartFormDataFromRaw = parseMultipartFormData(rawBody, boundary);\n }\n const dataParameters = pairsToDataParameters(flagsByName);\n const formDataParams = [\n ...flagsByName.form || [],\n ...flagsByName.F || []\n ].map((str) => {\n const parts = str.split(\"=\");\n const name = parts[0] ?? \"\";\n const value = parts[1] ?? \"\";\n const item = {\n name,\n enabled: true\n };\n if (value.indexOf(\"@\") === 0) {\n item.file = value.slice(1);\n } else {\n item.value = value;\n }\n return item;\n });\n let body = {};\n let bodyType = null;\n const bodyAsGET = getPairValue(flagsByName, false, [\"G\", \"get\"]);\n const hasDataBody = dataParameters.length > 0 && !bodyAsGET;\n const hasFormBody = multipartFormDataFromRaw != null || formDataParams.length > 0;\n if (multipartFormDataFromRaw) {\n bodyType = \"multipart/form-data\";\n body = {\n form: multipartFormDataFromRaw\n };\n } else if (dataParameters.length > 0 && bodyAsGET) {\n urlParameters.push(...dataParameters);\n } else if (dataParameters.length > 0 && (mimeType == null || mimeType === \"application/x-www-form-urlencoded\")) {\n bodyType = mimeType ?? \"application/x-www-form-urlencoded\";\n body = {\n form: dataParameters.map((parameter) => ({\n ...parameter,\n name: decodeURIComponent(parameter.name || \"\"),\n value: decodeURIComponent(parameter.value || \"\")\n }))\n };\n filteredHeaders.push({\n name: \"Content-Type\",\n value: \"application/x-www-form-urlencoded\",\n enabled: true\n });\n } else if (dataParameters.length > 0) {\n const text = dataParameters.map(({ name, value }) => name && value ? `${name}=${value}` : name || value).join(\"&\");\n const graphqlBody = parseGraphQLJsonBody({ mimeType, text, url });\n if (graphqlBody != null) {\n bodyType = \"graphql\";\n body = graphqlBody;\n } else if (mimeType === \"application/json\" || mimeType === \"text/xml\" || mimeType === \"text/plain\") {\n bodyType = mimeType;\n body = { text };\n } else {\n bodyType = \"other\";\n body = { text };\n }\n } else if (formDataParams.length) {\n bodyType = mimeType ?? \"multipart/form-data\";\n body = {\n form: formDataParams\n };\n if (mimeType == null) {\n filteredHeaders.push({\n name: \"Content-Type\",\n value: \"multipart/form-data\",\n enabled: true\n });\n }\n }\n let method = getPairValue(flagsByName, \"\", [\"X\", \"request\"]).toUpperCase();\n if (method === \"\") {\n method = hasDataBody || hasFormBody ? \"POST\" : \"GET\";\n }\n const request = {\n id: generateId(\"http_request\"),\n model: \"http_request\",\n workspaceId,\n name: \"\",\n urlParameters,\n url,\n method,\n headers: filteredHeaders,\n authentication: finalAuthentication,\n authenticationType: finalAuthenticationType,\n body,\n bodyType,\n folderId: null,\n sortPriority: 0\n };\n return request;\n}\nfunction pairsToDataParameters(keyedPairs) {\n const dataParameters = [];\n for (const flagName of DATA_FLAGS) {\n const pairs = keyedPairs[flagName];\n if (!pairs || pairs.length === 0) {\n continue;\n }\n for (const p of pairs) {\n if (typeof p !== \"string\") continue;\n const params = p.split(\"&\");\n for (const param of params) {\n const [name, value] = splitOnce(param, \"=\");\n if (param.startsWith(\"@\")) {\n dataParameters.push({\n name: name ?? \"\",\n value: \"\",\n filePath: param.slice(1),\n enabled: true\n });\n } else {\n dataParameters.push({\n name: name ?? \"\",\n value: flagName === \"data-urlencode\" ? encodeURIComponent(value ?? \"\") : value ?? \"\",\n enabled: true\n });\n }\n }\n }\n }\n return dataParameters;\n}\nvar getPairValue = (pairsByName, defaultValue, names) => {\n for (const name of names) {\n if (pairsByName[name]?.length) {\n return pairsByName[name]?.[0];\n }\n }\n return defaultValue;\n};\nfunction splitOnce(str, sep) {\n const index = str.indexOf(sep);\n if (index > -1) {\n return [str.slice(0, index), str.slice(index + 1)];\n }\n return [str];\n}\nfunction parseMultipartFormData(rawBody, boundary) {\n const results = [];\n const boundaryMarker = `--${boundary}`;\n const parts = rawBody.split(boundaryMarker);\n for (const part of parts) {\n if (!part || part.trim() === \"--\" || part.trim() === \"--\\r\\n\") {\n continue;\n }\n const headerContentSplit = part.indexOf(\"\\r\\n\\r\\n\");\n if (headerContentSplit === -1) {\n continue;\n }\n const headerSection = part.slice(0, headerContentSplit);\n let content = part.slice(headerContentSplit + 4);\n if (content.endsWith(\"\\r\\n\")) {\n content = content.slice(0, -2);\n }\n const contentDispositionMatch = headerSection.match(\n /Content-Disposition:\\s*form-data;\\s*name=\"([^\"]+)\"(?:;\\s*filename=\"([^\"]+)\")?/i\n );\n if (!contentDispositionMatch) {\n continue;\n }\n const name = contentDispositionMatch[1] ?? \"\";\n const filename = contentDispositionMatch[2];\n const item = {\n name,\n enabled: true\n };\n if (filename) {\n item.file = filename;\n } else {\n item.value = content;\n }\n results.push(item);\n }\n return results.length > 0 ? results : null;\n}\nvar idCount = {};\nfunction generateId(model) {\n idCount[model] = (idCount[model] ?? -1) + 1;\n return `GENERATE_ID::${model.toUpperCase()}_${idCount[model]}`;\n}\n" }, + { name: "auth-bearer", source: "\"use strict\";\nvar __defProp = Object.defineProperty;\nvar __getOwnPropDesc = Object.getOwnPropertyDescriptor;\nvar __getOwnPropNames = Object.getOwnPropertyNames;\nvar __hasOwnProp = Object.prototype.hasOwnProperty;\nvar __export = (target, all) => {\n for (var name in all)\n __defProp(target, name, { get: all[name], enumerable: true });\n};\nvar __copyProps = (to, from, except, desc) => {\n if (from && typeof from === \"object\" || typeof from === \"function\") {\n for (let key of __getOwnPropNames(from))\n if (!__hasOwnProp.call(to, key) && key !== except)\n __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });\n }\n return to;\n};\nvar __toCommonJS = (mod) => __copyProps(__defProp({}, \"__esModule\", { value: true }), mod);\n\n// plugins/auth-bearer/src/index.ts\nvar index_exports = {};\n__export(index_exports, {\n plugin: () => plugin\n});\nmodule.exports = __toCommonJS(index_exports);\nvar plugin = {\n authentication: {\n name: \"bearer\",\n label: \"Bearer Token\",\n shortLabel: \"Bearer\",\n args: [\n {\n type: \"text\",\n name: \"token\",\n label: \"Token\",\n optional: true,\n password: true\n },\n {\n type: \"text\",\n name: \"prefix\",\n label: \"Prefix\",\n optional: true,\n placeholder: \"\",\n defaultValue: \"Bearer\",\n description: 'The prefix to use for the Authorization header, which will be of the format \" \".'\n }\n ],\n async onApply(_ctx, { values }) {\n return { setHeaders: [generateAuthorizationHeader(values)] };\n }\n }\n};\nfunction generateAuthorizationHeader(values) {\n const token = String(values.token || \"\").trim();\n const prefix = String(values.prefix || \"\").trim();\n const value = `${prefix} ${token}`.trim();\n return { name: \"Authorization\", value };\n}\n" }, +]; diff --git a/packages/platform/src/web/send.ts b/packages/platform/src/web/send.ts index 7b970280..9dcefd57 100644 --- a/packages/platform/src/web/send.ts +++ b/packages/platform/src/web/send.ts @@ -33,6 +33,7 @@ import type { } from "@yaakapp-internal/models"; import type { Frame, SendRequest } from "@yaakapp-internal/send-proxy"; import type { WorkerConnection } from "./connection"; +import type { WebPlugins } from "./plugins"; import { proxyIdentity, proxySendUrl, readFrames } from "./proxy"; /* -------------------------------- shapes --------------------------------- */ @@ -51,11 +52,69 @@ type ResponsePatch = Partial; /** What `prepare_http_send` (crates/yaak-web) hands back. */ interface PreparedHttpSend { request: HttpRequest; + /** Hashed id of the model the authentication came from; a plugin keys its state on it. */ + authContextId: string; settings: HttpSendSettings; settingEvents: HttpResponseEventData[]; cookieJar: CookieJar | null; } +/** + * Apply the request's authentication method, if it has one. + * + * The desktop does this to the request it is about to put on the wire, after + * rendering and after building the sendable form of it. Here the sendable form + * is built by the proxy, so the plugin's answer is applied to the model instead + * — headers onto `headers`, query parameters onto `urlParameters` — and the + * proxy folds both in exactly as it would any others. The result on the wire is + * the same for a method that sets a header, which is every method whose plugin + * runs in the browser today. + * + * It is not the same for a method that *signs* the request, because the plugin + * is shown the request before the proxy assembles it: AWS SigV4 and OAuth 1.0 + * would sign a URL and a header set slightly different from the ones sent. Both + * are refused rather than silently mis-signed — see the sandbox README. + */ +async function applyAuthentication( + plugins: WebPlugins, + prepared: PreparedHttpSend, +): Promise { + 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, + method: request.method, + url: request.url, + headers: request.headers.filter((h) => h.enabled !== false), + // The desktop passes the body so signing schemes can hash it. This host + // does not have it in bytes at this point, and the schemes that would use + // it are the ones already refused. + body: null, + }); + + const headers = [...request.headers]; + for (const header of applied.setHeaders ?? []) { + // Replace-or-append, case-insensitively, matching `insert_header` in + // crates/yaak-http: a plugin setting Authorization must not end up with the + // request's own Authorization also on the wire. + 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 +122,7 @@ const PROGRESS_INTERVAL_MS = 100; export async function sendHttpRequest( db: WorkerConnection, + plugins: WebPlugins, requestId: string, environmentId: string | null, cookieJarId: string | null, @@ -78,7 +138,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 +150,7 @@ export async function sendHttpRequest( async function runSend( db: WorkerConnection, + plugins: WebPlugins, response: ResponseWriter, requestId: string, environmentId: string | null, @@ -101,7 +162,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 +173,7 @@ async function runSend( timeline.push(prepared.settingEvents); const body: SendRequest = { - request: prepared.request, + request, settings: prepared.settings, cookies: prepared.cookieJar?.cookies ?? null, }; diff --git a/packages/platform/src/web/worker.ts b/packages/platform/src/web/worker.ts index bd6d75e7..ea1fe15b 100644 --- a/packages/platform/src/web/worker.ts +++ b/packages/platform/src/web/worker.ts @@ -108,12 +108,44 @@ function bootOnce(): Promise { return booted; } +/** + * Template functions, which live somewhere this worker cannot reach. + * + * Rendering is the engine's, and the engine is here. The functions it calls + * come from plugins, which run in a sandbox the tab owns — so the engine is + * handed a function that asks the tab. It asks the port that started the + * render, not every port, because only that tab is waiting on the answer and + * only its sandbox has the plugins the render was started against. + * + * A failure comes back as a rejection, which the engine turns into a render + * error naming the function. That matters: rendering `${[ uuid.v4() ]}` to an + * empty string and sending it would be worse than not sending at all. + */ +const pendingTemplateFunctions = new Map void>(); +let nextTemplateFunctionId = 1; + +function templateBridge(port: MessagePort): (name: string, args: string) => Promise { + return (name, args) => + new Promise((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 { 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 +154,8 @@ async function handle(port: MessagePort, message: ToWorker): Promise { 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 +176,15 @@ async function handle(port: MessagePort, message: ToWorker): Promise { 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) { diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index b61761d3..ddbf4320 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -7,6 +7,12 @@ import type { DynamicPromptFormArg, PluginDefinition, } from "@yaakapp/api"; +import { + applyDynamicFormInput, + migrateTemplateFunctionSelectOptions, + stripDynamicCallbacks, +} from "@yaakapp-internal/lib/pluginForms"; +import { createResponseBody, decodeBase64Chunk } from "@yaakapp-internal/lib/responseBody"; import { applyFormInputDefaults, validateTemplateFunctionArgs, @@ -17,7 +23,6 @@ import type { DeleteModelResponse, FindHttpResponsesResponse, Folder, - FormInput, GetCookieValueRequest, GetCookieValueResponse, GetHttpRequestByIdResponse, @@ -49,10 +54,7 @@ import type { 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. @@ -1057,16 +1059,6 @@ export class PluginInstance { } } -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"; diff --git a/packages/plugin-runtime/src/migrations.ts b/packages/plugin-runtime/src/migrations.ts deleted file mode 100644 index 45da919a..00000000 --- a/packages/plugin-runtime/src/migrations.ts +++ /dev/null @@ -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 }; -} diff --git a/packages/plugin-sandbox/README.md b/packages/plugin-sandbox/README.md new file mode 100644 index 00000000..cb4ab591 --- /dev/null +++ b/packages/plugin-sandbox/README.md @@ -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 10–50x 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 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 80–140 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. diff --git a/packages/plugin-sandbox/bench/import.mjs b/packages/plugin-sandbox/bench/import.mjs new file mode 100644 index 00000000..7f587996 --- /dev/null +++ b/packages/plugin-sandbox/bench/import.mjs @@ -0,0 +1,140 @@ +/** + * How much slower is an importer inside the sandbox? + * + * This is the number the tiered-runtime decision rests on. Template functions + * and auth signing are small enough that engine speed cannot matter; importing + * is not. Yaak's OpenAPI importer is first-party JavaScript, not Rust, so a + * large specification is parsed and walked by whatever engine the runtime uses + * — QuickJS in a browser tab, V8 on the desktop today. If the gap is large + * enough to be felt on a real document, importers need a different path before + * the corpus is ported. + * + * Usage: + * node packages/plugin-sandbox/bench/import.mjs [iterations] + */ + +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 [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`); + +/** The sandbox host, bundled for Node so this script can drive it directly. */ +async function loadHost() { + // Inside node_modules so the emitted bundle's own imports of the QuickJS + // variant resolve the way any other module's would. + const outDir = join(root, "node_modules", ".cache", "yaak-plugin-sandbox"); + mkdirSync(outDir, { recursive: true }); + const outfile = join(outDir, "host.mjs"); + 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)`, + ); + // Every run, because the spread is the point: V8 compiles this workload + // across the first few passes and QuickJS, which does not compile at all, + // does not. Quoting one ratio would pick a winner by choosing when to look. + 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.`); +} diff --git a/packages/plugin-sandbox/build-guest.mjs b/packages/plugin-sandbox/build-guest.mjs new file mode 100644 index 00000000..692807b4 --- /dev/null +++ b/packages/plugin-sandbox/build-guest.mjs @@ -0,0 +1,56 @@ +/** + * Bundle the guest shell into a string the host can evaluate. + * + * The shell runs inside QuickJS, which has no module loader and no filesystem, + * so it has to arrive as source text. Emitting it as a `.ts` module rather than + * a `.js` asset is what lets every consumer — Vite for the browser build, plain + * Node for the benchmarks — get at it the same way, with no loader plugin and + * no `?raw` import that only one bundler understands. + * + * The output is committed, like the wasm packages are, so a checkout builds + * without this step having run. + */ + +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, + // A script, not a module: the host evaluates it with `evalCode`, and it + // announces itself by assigning `globalThis.__yaak_guest`. + format: "iife", + // Nothing here may reach for a Node built-in, and "browser" is the closest + // description of a target with globals and no filesystem. QuickJS itself has + // fewer globals than any browser, which is what `guest/globals.ts` is for. + platform: "browser", + // QuickJS is ES2023-complete, so nothing needs downleveling. Keeping the + // source as written also keeps stack traces from the guest readable. + target: "es2022", + minify: false, + legalComments: "none", +}); + +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`); diff --git a/packages/plugin-sandbox/package.json b/packages/plugin-sandbox/package.json new file mode 100644 index 00000000..83ccfabf --- /dev/null +++ b/packages/plugin-sandbox/package.json @@ -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" + } +} diff --git a/packages/plugin-sandbox/src/generated/guest.ts b/packages/plugin-sandbox/src/generated/guest.ts new file mode 100644 index 00000000..3c592b75 --- /dev/null +++ b/packages/plugin-sandbox/src/generated/guest.ts @@ -0,0 +1,6 @@ +// 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 = "\"use strict\";\n(() => {\n // ../common-lib/templateFunction.ts\n function validateTemplateFunctionArgs(fnName, args, values) {\n for (const arg of args) {\n if (\"inputs\" in arg && arg.inputs) {\n const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);\n if (err) return err;\n }\n if (!(\"name\" in arg)) continue;\n if (arg.optional) continue;\n if (arg.defaultValue != null) continue;\n if (arg.hidden) continue;\n if (values[arg.name] != null) continue;\n return `Missing required argument \"${arg.label || arg.name}\" for template function ${fnName}()`;\n }\n return null;\n }\n function applyFormInputDefaults(inputs, values) {\n let newValues = { ...values };\n for (const input of inputs) {\n if (\"defaultValue\" in input && values[input.name] === void 0) {\n newValues[input.name] = input.defaultValue;\n }\n if (input.type === \"checkbox\" && values[input.name] === void 0) {\n newValues[input.name] = false;\n }\n if (\"inputs\" in input) {\n newValues = applyFormInputDefaults(input.inputs ?? [], newValues);\n }\n }\n return newValues;\n }\n\n // ../common-lib/pluginForms.ts\n async function applyDynamicFormInput(ctx, args, callArgs) {\n const resolvedArgs = [];\n for (const { dynamic, ...arg } of args) {\n const dynamicResult = typeof dynamic === \"function\" ? await dynamic(\n ctx,\n callArgs\n ) : void 0;\n const newArg = {\n ...arg,\n ...dynamicResult\n };\n if (\"inputs\" in newArg && Array.isArray(newArg.inputs)) {\n try {\n newArg.inputs = await applyDynamicFormInput(\n ctx,\n newArg.inputs,\n callArgs\n );\n } catch (e) {\n console.error(\"Failed to apply dynamic form input\", e);\n }\n }\n resolvedArgs.push(newArg);\n }\n return resolvedArgs;\n }\n function stripDynamicCallbacks(inputs) {\n return inputs.map((input) => {\n const { dynamic: _dynamic, ...rest } = input;\n if (\"inputs\" in rest && Array.isArray(rest.inputs)) {\n rest.inputs = stripDynamicCallbacks(rest.inputs);\n }\n return rest;\n });\n }\n function migrateTemplateFunctionSelectOptions(f) {\n const migratedArgs = f.args.map((a) => {\n if (a.type === \"select\") {\n a.options = a.options.map((o) => {\n const legacy = o;\n return { label: legacy.label ?? legacy.name ?? \"\", value: legacy.value };\n });\n }\n return a;\n });\n return { ...f, args: migratedArgs };\n }\n\n // ../common-lib/responseBody.ts\n var DEFAULT_CHUNK_SIZE = 1024 * 1024;\n var DEFAULT_MAX_BYTES = 32 * 1024 * 1024;\n var DEFAULT_POLL_INTERVAL_MS = 100;\n function createResponseBody(info, readChunk, { refresh, pollIntervalMs = DEFAULT_POLL_INTERVAL_MS } = {}) {\n const { responseId, contentLength, contentType, complete } = info;\n async function* chunks(options) {\n const chunkSize = Math.max(1, Math.floor(options?.chunkSize ?? DEFAULT_CHUNK_SIZE));\n let known = contentLength;\n let done = complete;\n let offset = 0;\n while (true) {\n if (done && offset >= known) return;\n const want = done ? Math.min(chunkSize, known - offset) : chunkSize;\n const chunk = await readChunk(offset, want);\n if (chunk.byteLength > 0) {\n yield chunk;\n offset += chunk.byteLength;\n continue;\n }\n if (done || refresh == null) return;\n ({ contentLength: known, complete: done } = await refresh());\n if (offset < known) continue;\n if (done) return;\n await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));\n }\n }\n async function readAll(accessor, options) {\n const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;\n refuseIfTooBig(accessor, contentLength, maxBytes);\n const parts = [];\n let total = 0;\n for await (const chunk of chunks(options)) {\n total += chunk.byteLength;\n refuseIfTooBig(accessor, total, maxBytes);\n parts.push(chunk);\n }\n const bytes = new Uint8Array(total);\n let offset = 0;\n for (const part of parts) {\n bytes.set(part, offset);\n offset += part.byteLength;\n }\n return bytes;\n }\n return {\n responseId,\n contentLength,\n contentType,\n complete,\n chunks,\n async arrayBuffer(options) {\n const bytes = await readAll(\"arrayBuffer\", options);\n return bytes.buffer;\n },\n async text(options) {\n return decodeBody(await readAll(\"text\", options), contentType);\n },\n async json(options) {\n return JSON.parse(decodeBody(await readAll(\"json\", options), contentType));\n }\n };\n }\n function refuseIfTooBig(accessor, bytes, maxBytes) {\n if (bytes <= maxBytes) return;\n throw new Error(\n `Response body is ${formatBytes(bytes)}, over the ${formatBytes(maxBytes)} limit for ${accessor}(). Read it with chunks() instead, or pass a larger maxBytes.`\n );\n }\n function decodeBody(bytes, contentType) {\n const charset = parseCharset(contentType);\n if (charset != null) {\n try {\n return new TextDecoder(charset).decode(bytes);\n } catch {\n }\n }\n return new TextDecoder(\"utf-8\").decode(bytes);\n }\n function parseCharset(contentType) {\n const match = contentType?.match(/;\\s*charset\\s*=\\s*\"?([^\";]+)\"?/i);\n return match?.[1]?.trim() || null;\n }\n function formatBytes(bytes) {\n if (bytes === Infinity) return \"unlimited\";\n if (bytes < 1024) return `${bytes} B`;\n const units = [\"KB\", \"MB\", \"GB\"];\n let value = bytes / 1024;\n let unit = 0;\n while (value >= 1024 && unit < units.length - 1) {\n value /= 1024;\n unit++;\n }\n return `${value.toFixed(1)} ${units[unit]}`;\n }\n function decodeBase64Chunk(data) {\n if (typeof Buffer !== \"undefined\") {\n const buf = Buffer.from(data, \"base64\");\n return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);\n }\n const binary = atob(data);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n }\n\n // src/guest/context.ts\n function forPlugin(httpResponse) {\n const { bodyPath: _bodyPath, ...rest } = httpResponse;\n return rest;\n }\n function newContext(call, context) {\n const send = (payload) => call(context, payload);\n const storedBody = async (responseId) => {\n const bodyInfo = () => send({\n type: \"get_http_response_body_info_request\",\n responseId\n });\n const info = await bodyInfo();\n return createResponseBody(\n {\n responseId,\n contentLength: info.contentLength,\n contentType: info.contentType ?? null,\n complete: info.complete\n },\n async (offset, length) => {\n const chunk = await send({\n type: \"read_http_response_body_chunk_request\",\n responseId,\n offset,\n length\n });\n return decodeBase64Chunk(chunk.data);\n },\n { refresh: bodyInfo }\n );\n };\n const windowInfo = async () => {\n if (context.label == null) {\n throw new Error(\"Can't get window context without an active window\");\n }\n return send({ type: \"window_info_request\", label: context.label });\n };\n const ctx = {\n clipboard: {\n copyText: async (text) => {\n await send({ type: \"copy_text_request\", text });\n }\n },\n toast: {\n show: async (args) => {\n await send({\n type: \"show_toast_request\",\n // Defaulted here because null and undefined both become None in Rust.\n timeout: args.timeout === void 0 ? 5e3 : args.timeout,\n ...args\n });\n }\n },\n window: {\n requestId: async () => (await windowInfo()).requestId,\n workspaceId: async () => (await windowInfo()).workspaceId,\n environmentId: async () => (await windowInfo()).environmentId,\n openUrl: async () => {\n throw new Error(\"ctx.window.openUrl is not available in the sandbox runtime\");\n },\n openExternalUrl: async (url) => {\n await send({ type: \"open_external_url_request\", url });\n }\n },\n prompt: {\n text: async (args) => {\n const reply = await send({ type: \"prompt_text_request\", ...args });\n return reply.value;\n },\n form: async (args) => {\n const defaults = applyFormInputDefaults(args.inputs, {});\n const callArgs = { values: defaults };\n const resolved = await applyDynamicFormInput(\n ctx,\n args.inputs,\n callArgs\n );\n const reply = await send({\n type: \"prompt_form_request\",\n ...args,\n inputs: stripDynamicCallbacks(resolved)\n });\n return reply.values;\n }\n },\n httpResponse: {\n find: async (args) => {\n const { httpResponses } = await send({\n type: \"find_http_responses_request\",\n ...args\n });\n return httpResponses.map(forPlugin);\n },\n body: ({ responseId }) => storedBody(responseId)\n },\n grpcRequest: {\n render: async (args) => {\n const { grpcRequest } = await send({\n type: \"render_grpc_request_request\",\n ...args\n });\n return grpcRequest;\n }\n },\n httpRequest: {\n getById: async (args) => {\n const { httpRequest } = await send({\n type: \"get_http_request_by_id_request\",\n ...args\n });\n return httpRequest;\n },\n send: async (args) => {\n const { httpResponse, body } = await send({\n type: \"send_http_request_request\",\n ...args\n });\n if (body == null) {\n return {\n httpResponse: forPlugin(httpResponse),\n body: await storedBody(httpResponse.id)\n };\n }\n const bytes = decodeBase64Chunk(body);\n return {\n httpResponse: forPlugin(httpResponse),\n body: createResponseBody(\n {\n responseId: httpResponse.id,\n contentLength: bytes.byteLength,\n contentType: httpResponse.headers.find((h) => h.name.toLowerCase() === \"content-type\")?.value ?? null,\n // The host waited for the whole send before replying.\n complete: true\n },\n async (offset, length) => bytes.slice(offset, offset + length)\n )\n };\n },\n render: async (args) => {\n const { httpRequest } = await send({\n type: \"render_http_request_request\",\n ...args\n });\n return httpRequest;\n },\n list: async (args) => {\n const payload = {\n type: \"list_http_requests_request\",\n folderId: args?.folderId\n };\n const { httpRequests } = await send(payload);\n return httpRequests;\n },\n create: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { name: \"\", method: \"GET\", ...args, id: \"\", model: \"http_request\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"http_request\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"http_request\",\n id: args.id\n });\n return response.model;\n }\n },\n folder: {\n list: async () => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders;\n },\n getById: async (args) => {\n const { folders } = await send({ type: \"list_folders_request\" });\n return folders.find((f) => f.id === args.id) ?? null;\n },\n create: async ({ name, ...args }) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { ...args, name: name ?? \"\", id: \"\", model: \"folder\" }\n });\n return response.model;\n },\n update: async (args) => {\n const response = await send({\n type: \"upsert_model_request\",\n model: { model: \"folder\", ...args }\n });\n return response.model;\n },\n delete: async (args) => {\n const response = await send({\n type: \"delete_model_request\",\n model: \"folder\",\n id: args.id\n });\n return response.model;\n }\n },\n cookies: {\n getValue: async (args) => {\n const { value } = await send({\n type: \"get_cookie_value_request\",\n ...args\n });\n return value;\n },\n listNames: async () => {\n const { names } = await send({ type: \"list_cookie_names_request\" });\n return names;\n }\n },\n templates: {\n render: async (args) => {\n const result = await send({\n type: \"template_render_request\",\n ...args\n });\n return result.data;\n }\n },\n store: {\n get: async (key) => {\n const result = await send({ type: \"get_key_value_request\", key });\n return result.value ? JSON.parse(result.value) : void 0;\n },\n set: async (key, value) => {\n await send({\n type: \"set_key_value_request\",\n key,\n value: JSON.stringify(value)\n });\n },\n delete: async (key) => {\n const result = await send({\n type: \"delete_key_value_request\",\n key\n });\n return result.deleted;\n }\n },\n plugin: {\n reload: () => {\n void send({ type: \"reload_response\", silent: true });\n }\n },\n workspace: {\n list: async () => {\n const response = await send({\n type: \"list_open_workspaces_request\"\n });\n return response.workspaces.map((w) => {\n return {\n id: w.id,\n name: w.name,\n // Kept for routing, hidden from plugin authors.\n _label: w.label\n };\n });\n },\n withContext: (handle) => newContext(call, { ...context, label: handle._label || null, workspaceId: handle.id })\n }\n };\n return ctx;\n }\n\n // src/guest/globals.ts\n function formatArgs(args) {\n return args.map((arg) => {\n if (typeof arg === \"string\") return arg;\n if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;\n try {\n return JSON.stringify(arg, replacer()) ?? String(arg);\n } catch {\n return String(arg);\n }\n }).join(\" \");\n }\n function replacer() {\n const seen = /* @__PURE__ */ new WeakSet();\n return (_key, value) => {\n if (typeof value === \"bigint\") return `${value}n`;\n if (typeof value === \"function\") return `[Function ${value.name || \"anonymous\"}]`;\n if (typeof value === \"object\" && value !== null) {\n if (seen.has(value)) return \"[Circular]\";\n seen.add(value);\n }\n return value;\n };\n }\n function installConsole() {\n const log = (level) => (...args) => __yaak_log(level, formatArgs(args));\n globalThis.console = {\n log: log(\"log\"),\n info: log(\"info\"),\n warn: log(\"warn\"),\n error: log(\"error\"),\n debug: log(\"debug\"),\n trace: log(\"debug\")\n };\n }\n var timerCallbacks = /* @__PURE__ */ new Map();\n var nextTimerId = 1;\n function installTimers() {\n const g = globalThis;\n g.setTimeout = (callback, ms, ...args) => {\n const id = nextTimerId++;\n timerCallbacks.set(id, () => callback(...args));\n __yaak_timer_start(id, Math.max(0, Number(ms) || 0));\n return id;\n };\n g.clearTimeout = (id) => {\n if (!timerCallbacks.delete(id)) return;\n __yaak_timer_cancel(id);\n };\n g.setInterval = void 0;\n g.clearInterval = void 0;\n }\n function fireTimer(id) {\n const callback = timerCallbacks.get(id);\n timerCallbacks.delete(id);\n callback?.();\n }\n var SandboxTextEncoder = class {\n encoding = \"utf-8\";\n encode(input = \"\") {\n const out = [];\n for (let i = 0; i < input.length; i++) {\n let code = input.charCodeAt(i);\n if (code >= 55296 && code <= 56319) {\n const next = input.charCodeAt(i + 1);\n if (next >= 56320 && next <= 57343) {\n code = (code - 55296) * 1024 + (next - 56320) + 65536;\n i++;\n } else {\n code = 65533;\n }\n } else if (code >= 56320 && code <= 57343) {\n code = 65533;\n }\n if (code < 128) out.push(code);\n else if (code < 2048) out.push(192 | code >> 6, 128 | code & 63);\n else if (code < 65536)\n out.push(224 | code >> 12, 128 | code >> 6 & 63, 128 | code & 63);\n else\n out.push(\n 240 | code >> 18,\n 128 | code >> 12 & 63,\n 128 | code >> 6 & 63,\n 128 | code & 63\n );\n }\n return new Uint8Array(out);\n }\n };\n var SandboxTextDecoder = class {\n encoding = \"utf-8\";\n decode(input) {\n if (input == null) return \"\";\n const bytes = input instanceof Uint8Array ? input : ArrayBuffer.isView(input) ? new Uint8Array(input.buffer, input.byteOffset, input.byteLength) : new Uint8Array(input);\n let out = \"\";\n for (let i = 0; i < bytes.length; ) {\n const byte = bytes[i];\n let code;\n let size;\n if (byte < 128) {\n code = byte;\n size = 1;\n } else if ((byte & 224) === 192) {\n code = byte & 31;\n size = 2;\n } else if ((byte & 240) === 224) {\n code = byte & 15;\n size = 3;\n } else if ((byte & 248) === 240) {\n code = byte & 7;\n size = 4;\n } else {\n out += \"\\uFFFD\";\n i++;\n continue;\n }\n if (i + size > bytes.length) {\n out += \"\\uFFFD\";\n break;\n }\n for (let k = 1; k < size; k++) {\n const cont = bytes[i + k];\n if ((cont & 192) !== 128) {\n code = -1;\n break;\n }\n code = code << 6 | cont & 63;\n }\n i += size;\n if (code < 0 || code > 1114111 || code >= 55296 && code <= 57343) out += \"\\uFFFD\";\n else if (code < 65536) out += String.fromCharCode(code);\n else {\n const c = code - 65536;\n out += String.fromCharCode(55296 + (c >> 10), 56320 + (c & 1023));\n }\n }\n return out;\n }\n };\n function installTextCodecs() {\n const g = globalThis;\n g.TextEncoder = SandboxTextEncoder;\n g.TextDecoder = SandboxTextDecoder;\n }\n var B64 = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/\";\n function installBase64() {\n const g = globalThis;\n g.btoa = (input) => {\n let out = \"\";\n for (let i = 0; i < input.length; i += 3) {\n const a = input.charCodeAt(i);\n const b = input.charCodeAt(i + 1);\n const c = input.charCodeAt(i + 2);\n if (a > 255 || b > 255 || c > 255) {\n throw new Error(\"btoa: string contains characters outside of the Latin1 range\");\n }\n const chunk = a << 16 | (Number.isNaN(b) ? 0 : b) << 8 | (Number.isNaN(c) ? 0 : c);\n out += B64[chunk >> 18 & 63] + B64[chunk >> 12 & 63];\n out += Number.isNaN(b) ? \"=\" : B64[chunk >> 6 & 63];\n out += Number.isNaN(c) ? \"=\" : B64[chunk & 63];\n }\n return out;\n };\n g.atob = (input) => {\n const clean = input.replace(/[\\t\\n\\f\\r ]/g, \"\").replace(/=+$/, \"\");\n let out = \"\";\n let bits = 0;\n let acc = 0;\n for (const ch of clean) {\n const value = B64.indexOf(ch);\n if (value < 0) throw new Error(\"atob: string contains invalid characters\");\n acc = acc << 6 | value;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out += String.fromCharCode(acc >> bits & 255);\n }\n }\n return out;\n };\n }\n function installGlobals() {\n installConsole();\n installTimers();\n installTextCodecs();\n installBase64();\n return { fireTimer };\n }\n\n // src/guest/index.ts\n var { fireTimer: fireTimer2 } = installGlobals();\n var mod = {};\n var pluginRefId = \"\";\n function load(source, refId) {\n const module = { exports: {} };\n const require2 = (specifier) => {\n throw new Error(\n `Module \"${specifier}\" is not available in the sandbox runtime. Plugins must be bundled with no external or built-in modules.`\n );\n };\n const factory = new Function(\"module\", \"exports\", \"require\", source);\n factory(module, module.exports, require2);\n const loaded = module.exports.plugin ?? module.exports.default;\n if (loaded == null || typeof loaded !== \"object\") {\n throw new Error(\"Module did not export `plugin`\");\n }\n mod = loaded;\n pluginRefId = refId;\n }\n function summary() {\n return {\n templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),\n authentication: mod.authentication?.name ?? null,\n importer: mod.importer != null,\n filter: mod.filter != null,\n themes: (mod.themes ?? []).length,\n httpRequestActions: (mod.httpRequestActions ?? []).length,\n workspaceActions: (mod.workspaceActions ?? []).length,\n folderActions: (mod.folderActions ?? []).length,\n grpcRequestActions: (mod.grpcRequestActions ?? []).length,\n websocketRequestActions: (mod.websocketRequestActions ?? []).length\n };\n }\n var EMPTY = { type: \"empty_response\" };\n async function dispatch(context, payload) {\n const ctx = newContext(hostCall, context);\n if (payload.type === \"boot_request\") {\n await mod.init?.(ctx);\n return { type: \"boot_response\" };\n }\n if (payload.type === \"terminate_request\") {\n await mod.dispose?.();\n return { type: \"terminate_response\" };\n }\n if (payload.type === \"import_request\" && typeof mod.importer?.onImport === \"function\") {\n const reply = await mod.importer.onImport(ctx, { text: payload.content });\n if (reply != null) {\n return { type: \"import_response\", resources: reply.resources };\n }\n return EMPTY;\n }\n if (payload.type === \"filter_request\" && typeof mod.filter?.onFilter === \"function\") {\n const reply = await mod.filter.onFilter(ctx, {\n filter: payload.filter,\n payload: payload.content,\n mimeType: payload.type\n });\n return { type: \"filter_response\", ...reply };\n }\n if (payload.type === \"get_themes_request\" && Array.isArray(mod.themes)) {\n return { type: \"get_themes_response\", themes: mod.themes };\n }\n if (payload.type === \"get_template_function_summary_request\" && Array.isArray(mod.templateFunctions)) {\n const functions = mod.templateFunctions.map((f) => ({\n ...migrateTemplateFunctionSelectOptions(f),\n onRender: void 0\n }));\n return { type: \"get_template_function_summary_response\", pluginRefId, functions };\n }\n if (payload.type === \"get_template_function_config_request\" && Array.isArray(mod.templateFunctions)) {\n const found = mod.templateFunctions.find((f) => f.name === payload.name);\n if (found == null) return EMPTY;\n const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: void 0 };\n payload.values = applyFormInputDefaults(fn.args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, fn.args, {\n ...payload,\n purpose: \"preview\"\n });\n return {\n type: \"get_template_function_config_response\",\n pluginRefId,\n function: { ...fn, args: stripDynamicCallbacks(resolved) }\n };\n }\n if (payload.type === \"call_template_function_request\" && Array.isArray(mod.templateFunctions)) {\n const fn = mod.templateFunctions.find((f) => f.name === payload.name);\n if (payload.args.purpose === \"preview\" && (fn?.previewType === \"click\" || fn?.previewType === \"none\")) {\n return {\n type: \"call_template_function_response\",\n value: null,\n error: \"Live preview disabled for this function\"\n };\n }\n if (typeof fn?.onRender === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);\n const values = applyFormInputDefaults(resolved, payload.args.values);\n const error = validateTemplateFunctionArgs(fn.name, resolved, values);\n if (error && payload.args.purpose !== \"preview\") {\n return { type: \"call_template_function_response\", value: null, error };\n }\n const result = await fn.onRender(ctx, { ...payload.args, values });\n return { type: \"call_template_function_response\", value: result ?? null };\n }\n }\n if (payload.type === \"get_http_authentication_summary_request\" && mod.authentication) {\n return { type: \"get_http_authentication_summary_response\", ...mod.authentication };\n }\n if (payload.type === \"get_http_authentication_config_request\" && mod.authentication) {\n const { args, actions } = mod.authentication;\n payload.values = applyFormInputDefaults(args, payload.values);\n const resolved = await applyDynamicFormInput(ctx, args, payload);\n const resolvedActions = [];\n for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);\n return {\n type: \"get_http_authentication_config_response\",\n args: stripDynamicCallbacks(resolved),\n actions: resolvedActions,\n pluginRefId\n };\n }\n if (payload.type === \"call_http_authentication_request\" && mod.authentication) {\n const auth = mod.authentication;\n if (typeof auth.onApply === \"function\") {\n const resolved = await applyDynamicFormInput(ctx, auth.args, payload);\n payload.values = applyFormInputDefaults(resolved, payload.values);\n return { type: \"call_http_authentication_response\", ...await auth.onApply(ctx, payload) };\n }\n }\n if (payload.type === \"call_http_authentication_action_request\" && mod.authentication != null) {\n const action = mod.authentication.actions?.[payload.index];\n if (typeof action?.onSelect === \"function\") {\n await action.onSelect(ctx, payload.args);\n return EMPTY;\n }\n }\n if (payload.type === \"get_http_request_actions_request\" && Array.isArray(mod.httpRequestActions)) {\n const actions = mod.httpRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_http_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_websocket_request_actions_request\" && Array.isArray(mod.websocketRequestActions)) {\n const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_websocket_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_grpc_request_actions_request\" && Array.isArray(mod.grpcRequestActions)) {\n const actions = mod.grpcRequestActions.map((a) => ({\n ...a,\n onSelect: void 0\n }));\n return { type: \"get_grpc_request_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_workspace_actions_request\" && Array.isArray(mod.workspaceActions)) {\n const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_workspace_actions_response\", pluginRefId, actions };\n }\n if (payload.type === \"get_folder_actions_request\" && Array.isArray(mod.folderActions)) {\n const actions = mod.folderActions.map((a) => ({ ...a, onSelect: void 0 }));\n return { type: \"get_folder_actions_response\", pluginRefId, actions };\n }\n const called = await callAction(ctx, payload);\n if (called) return EMPTY;\n return EMPTY;\n }\n async function callAction(ctx, payload) {\n const lists = {\n call_http_request_action_request: mod.httpRequestActions,\n call_websocket_request_action_request: mod.websocketRequestActions,\n call_grpc_request_action_request: mod.grpcRequestActions,\n call_workspace_action_request: mod.workspaceActions,\n call_folder_action_request: mod.folderActions\n };\n const list = lists[payload.type];\n if (!Array.isArray(list)) return false;\n const action = list[payload.index];\n if (typeof action?.onSelect !== \"function\") return false;\n await action.onSelect(ctx, payload.args);\n return true;\n }\n async function hostCall(context, payload) {\n const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));\n const reply = JSON.parse(replyJson);\n if (reply.type === \"error_response\") {\n throw new Error(reply.error || `Host failed to handle ${payload.type}`);\n }\n const { type: _type, ...rest } = reply;\n return rest;\n }\n globalThis.__yaak_guest = {\n load,\n summary,\n fireTimer: fireTimer2,\n dispatch: async (envelopeJson) => {\n const { context, payload } = JSON.parse(envelopeJson);\n try {\n return JSON.stringify(await dispatch(context, payload));\n } catch (err) {\n const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\\s*/g, \"\");\n return JSON.stringify({ type: \"error_response\", error });\n }\n }\n };\n})();\n"; diff --git a/packages/plugin-sandbox/src/guest/context.ts b/packages/plugin-sandbox/src/guest/context.ts new file mode 100644 index 00000000..7ce1655c --- /dev/null +++ b/packages/plugin-sandbox/src/guest/context.ts @@ -0,0 +1,370 @@ +/** + * `ctx`, as a plugin sees it, built entirely out of one call to the host. + * + * Every method here serializes a request payload, hands it out of the sandbox, + * and awaits a reply payload. That is the whole capability surface: the sandbox + * has no socket, no clock it owns, no storage and no DOM, so anything a plugin + * does to the world is a message the host chose to answer. The payload shapes + * are the ones in `crates/yaak-plugins/src/events.rs`, unchanged, so a plugin + * written for the Node runtime runs here without knowing which host it has. + */ + +import type { + CallPromptFormDynamicArgs, + Context, + DynamicPromptFormArg, +} from "@yaakapp/api"; +import { + applyDynamicFormInput, + stripDynamicCallbacks, +} from "@yaakapp-internal/lib/pluginForms"; +import { createResponseBody, decodeBase64Chunk } from "@yaakapp-internal/lib/responseBody"; +import { applyFormInputDefaults } from "@yaakapp-internal/lib/templateFunction"; +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"; + +/** What the host installs: one request out, one reply back. */ +export type HostCall = ( + context: PluginContext, + payload: InternalEventPayload, +) => Promise>; + +/** + * A response as a plugin should see it. + * + * `bodyPath` names a file on a host's disk. There is no disk here and there is + * none in a browser, and plugins address bodies by response id, so it is + * dropped rather than left for one to grow a dependency on. + */ +function forPlugin(httpResponse: HttpResponse): HttpResponse { + const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & { + bodyPath?: string | null; + }; + return rest; +} + +export function newContext(call: HostCall, context: PluginContext): Context { + const send = (payload: InternalEventPayload): Promise => + call(context, payload) as Promise; + + /** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */ + const storedBody = async (responseId: string) => { + const bodyInfo = () => + send({ + 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({ + 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({ 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 () => { + // A window is the host's to open, and the browser host has one tab. A + // plugin asking is told so rather than handed a handle that does + // nothing when it calls `close()`. + throw new Error("ctx.window.openUrl is not available in the sandbox runtime"); + }, + openExternalUrl: async (url) => { + await send({ type: "open_external_url_request", url }); + }, + }, + prompt: { + text: async (args) => { + const reply = await send({ type: "prompt_text_request", ...args }); + return reply.value; + }, + form: async (args) => { + // The inputs a plugin declares may compute themselves from the values + // entered so far. The host draws a static form, so they are resolved + // against the defaults before it is drawn and the callbacks stripped + // — a function cannot cross the boundary, and one left in would + // serialize to nothing and take its input's shape with it. + const defaults = applyFormInputDefaults(args.inputs, {}); + const callArgs: CallPromptFormDynamicArgs = { values: defaults }; + const resolved = await applyDynamicFormInput( + ctx, + args.inputs as DynamicPromptFormArg[], + callArgs, + ); + const reply = await send({ + type: "prompt_form_request", + ...args, + inputs: stripDynamicCallbacks(resolved) as FormInput[], + }); + return reply.values; + }, + }, + httpResponse: { + find: async (args) => { + const { httpResponses } = await send({ + type: "find_http_responses_request", + ...args, + }); + return httpResponses.map(forPlugin); + }, + body: ({ responseId }) => storedBody(responseId), + }, + grpcRequest: { + render: async (args) => { + const { grpcRequest } = await send({ + type: "render_grpc_request_request", + ...args, + }); + return grpcRequest; + }, + }, + httpRequest: { + getById: async (args) => { + const { httpRequest } = await send({ + type: "get_http_request_by_id_request", + ...args, + }); + return httpRequest; + }, + send: async (args) => { + const { httpResponse, body } = await send({ + 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. 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 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({ + 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(payload); + return httpRequests; + }, + create: async (args) => { + const response = await send({ + 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({ + type: "upsert_model_request", + model: { model: "http_request", ...args }, + } as InternalEventPayload); + return response.model as HttpRequest; + }, + delete: async (args) => { + const response = await send({ + type: "delete_model_request", + model: "http_request", + id: args.id, + } as InternalEventPayload); + return response.model as HttpRequest; + }, + }, + folder: { + list: async () => { + const { folders } = await send({ type: "list_folders_request" }); + return folders; + }, + getById: async (args: { id: string }) => { + const { folders } = await send({ type: "list_folders_request" }); + return folders.find((f) => f.id === args.id) ?? null; + }, + create: async ({ name, ...args }) => { + const response = await send({ + 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({ + type: "upsert_model_request", + model: { model: "folder", ...args }, + } as InternalEventPayload); + return response.model as Folder; + }, + delete: async (args: { id: string }) => { + const response = await send({ + 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({ + type: "get_cookie_value_request", + ...args, + }); + return value; + }, + listNames: async () => { + const { names } = await send({ type: "list_cookie_names_request" }); + return names; + }, + }, + templates: { + render: async (args: TemplateRenderRequest) => { + const result = await send({ + 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 (key: string) => { + const result = await send({ type: "get_key_value_request", key }); + return result.value ? (JSON.parse(result.value) as T) : undefined; + }, + set: async (key: string, value: T) => { + await send({ + type: "set_key_value_request", + key, + value: JSON.stringify(value), + }); + }, + delete: async (key: string) => { + const result = await send({ + type: "delete_key_value_request", + key, + }); + return result.deleted; + }, + }, + plugin: { + reload: () => { + void send({ type: "reload_response", silent: true }); + }, + }, + workspace: { + list: async () => { + const response = await send({ + 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 }) => + newContext(call, { ...context, label: handle._label || null, workspaceId: handle.id }), + }, + }; + + return ctx; +} diff --git a/packages/plugin-sandbox/src/guest/globals.ts b/packages/plugin-sandbox/src/guest/globals.ts new file mode 100644 index 00000000..99af62df --- /dev/null +++ b/packages/plugin-sandbox/src/guest/globals.ts @@ -0,0 +1,267 @@ +/** + * The globals that exist inside the sandbox. + * + * QuickJS is the language and nothing else: it has `Promise`, `BigInt` and the + * ES2024 built-ins, and no `console`, no `setTimeout`, no `TextEncoder`. The + * platform globals a browser or Node would supply are not there because there + * is no platform — which is the point. What a plugin can reach is what this + * file installs, and every one of these has to exist identically on the Rust + * host too, so the list is kept short and boring on purpose. + * + * Two of them are implemented here in pure JavaScript rather than bridged to + * the host: the text codecs are twenty lines and a bridge would cost a copy + * each way for no gain. Timers cannot be — the sandbox has no event loop of its + * own — so those are the host's. + */ + +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 -------------------------------- */ + +/** + * Arguments as a line of text, formatted here rather than at the host. + * + * Only strings cross the boundary, so a plugin logging an object gets it + * serialized inside the sandbox, where its own prototypes still exist and a + * cycle is this function's problem rather than the host's. + */ +function formatArgs(args: unknown[]): string { + 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(); + 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).console = { + log: log("log"), + info: log("info"), + warn: log("warn"), + error: log("error"), + debug: log("debug"), + trace: log("debug"), + }; +} + +/* --------------------------------- timers -------------------------------- */ + +/** + * Timers, owned by the host. + * + * QuickJS has no clock to wake on: `executePendingJobs` drains microtasks and + * returns, so a `setTimeout` implemented in here would either never fire or + * spin. The host holds the real timer and calls back in, which also means a + * sandbox torn down mid-wait takes its pending timers with it. + */ +const timerCallbacks = new Map void>(); +let nextTimerId = 1; + +function installTimers(): void { + const g = globalThis as Record; + + 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); + }; + + // Same contract, and deliberately not repeating: an interval is a timer that + // rearms, and nothing in a plugin should be polling anyway. A plugin that + // wants one can build it from `setTimeout`, visibly. + g.setInterval = undefined; + g.clearInterval = undefined; +} + +/** Called by the host when a timer it is holding 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 surrogate pair is one code point; a lone surrogate becomes U+FFFD, + // which is what the standard encoder does rather than erroring. + 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; + g.TextEncoder = SandboxTextEncoder; + g.TextDecoder = SandboxTextDecoder; +} + +/* ------------------------------ base64 helpers ---------------------------- */ + +const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +function installBase64(): void { + const g = globalThis as Record; + + // Latin-1 in, base64 out — the same narrow contract the browser's have, so a + // plugin that reaches for them behaves the same here as it does there. + g.btoa = (input: string): string => { + let out = ""; + for (let i = 0; i < input.length; i += 3) { + 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 }; +} diff --git a/packages/plugin-sandbox/src/guest/index.ts b/packages/plugin-sandbox/src/guest/index.ts new file mode 100644 index 00000000..0b0c305e --- /dev/null +++ b/packages/plugin-sandbox/src/guest/index.ts @@ -0,0 +1,355 @@ +/** + * The runtime shell, as it exists inside the sandbox. + * + * This is the whole of what QuickJS evaluates before any untrusted code does: + * it installs the globals, loads one module, and answers events against it. + * The Node runtime's `PluginInstance` does the same job on the other side of a + * WebSocket; the difference is that this one has no filesystem to load from and + * no host objects to reach for, so the module arrives as source text and every + * capability arrives as a reply. + * + * It is deliberately not plugin-shaped underneath. `load` takes source and + * `dispatch` takes an event: what a module *is* — a plugin today, a workspace + * script later — is decided by the payloads the host sends, not by this file. + * Scripts are the reason that matters. A plugin is installed, so someone + * consented to it; a script arrives inside a workspace, as data, with no such + * moment, which is why scripts will never get a runtime other than this one. + */ + +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 { newContext } from "./context"; +import { installGlobals } from "./globals"; + +declare const __yaak_call: (payloadJson: string) => Promise; + +const { fireTimer } = installGlobals(); + +/** The loaded module, and the id the host knows it by. */ +let mod: PluginDefinition = {}; +let pluginRefId = ""; + +/** + * Evaluate a module's source. + * + * The bundles are CommonJS, so they are handed the three names that implies and + * nothing else. `require` is the interesting one: it exists only to fail, by + * name, because a bundle that still calls it did not get bundled for this + * target and the honest outcome is a message saying which specifier is missing + * rather than an undefined that surfaces ten frames later. + */ +function load(source: string, refId: string): void { + const module: { exports: Record } = { 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.`, + ); + }; + + // `new Function` rather than an ES module so the bundle's own top-level names + // cannot collide with this shell's, and so the source can arrive as a string + // with no loader hook. Evaluating untrusted source is the entire job of this + // file; the isolation is the QuickJS context around it, not a lint rule. + // 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; +} + +/** Everything a module contributes, without the functions that implement it. */ +function summary(): Record { + 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" }; + +/** + * Answer one event against the loaded module. + * + * Every branch mirrors the Node runtime's, because the payloads are the same + * payloads — a plugin cannot tell which runtime it is in, and that is the + * promise the whole design exists to keep. An unmatched event gets + * `empty_response` rather than silence, so a caller never waits forever for a + * capability this module doesn't have. + */ +async function dispatch( + context: PluginContext, + payload: InternalEventPayload, +): Promise { + const ctx = newContext(hostCall, 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; +} + +/** The five action kinds, which differ only in which list they index into. */ +async function callAction( + ctx: ReturnType, + payload: InternalEventPayload, +): Promise { + 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; +} + +/** One outgoing request, JSON out and JSON back. */ +async function hostCall( + context: PluginContext, + payload: InternalEventPayload, +): Promise> { + // The id rides along because the host multiplexes every loaded module + // through one handler, and a plugin's storage is namespaced by which plugin + // it is. + 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; +} + +/** + * What the host can reach. + * + * Named on `globalThis` because the host calls them by evaluating an + * expression, and kept to four: load a module, ask what it has, send it an + * event, wake a timer. + */ +(globalThis as Record).__yaak_guest = { + load, + summary, + fireTimer, + dispatch: async (envelopeJson: string): Promise => { + const { context, payload } = JSON.parse(envelopeJson) as { + context: PluginContext; + payload: InternalEventPayload; + }; + try { + return JSON.stringify(await dispatch(context, payload)); + } catch (err) { + // A throw from inside a plugin is an answer, not a crash: the host turns + // it into the same `error_response` the Node runtime sends, and whatever + // asked for this gets a message instead of a hang. + const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\s*/g, ""); + return JSON.stringify({ type: "error_response", error }); + } + }, +}; diff --git a/packages/plugin-sandbox/src/host/sandbox.ts b/packages/plugin-sandbox/src/host/sandbox.ts new file mode 100644 index 00000000..d9efcdbc --- /dev/null +++ b/packages/plugin-sandbox/src/host/sandbox.ts @@ -0,0 +1,384 @@ +/** + * The sandbox host: QuickJS, and the four things that cross into it. + * + * One QuickJS runtime holds one context per loaded module. A context is the + * isolation boundary — its own globals, its own `Object`, its own prototypes — + * so two plugins cannot see or patch each other, and neither can reach the + * worker's own scope. Sharing a runtime between them is deliberate: the engine + * and its wasm instance are the expensive part, contexts are not. + * + * The engine is `quickjs-ng`, not Bellard's, and the sync variant rather than + * the ASYNCIFY one. Both choices are recorded in this package's README along + * with what they cost; the short version is that the Rust host has no choice + * (rquickjs vendors quickjs-ng and offers no alternative), and the sync build + * still gives the guest real `await` through a deferred promise, at half the + * size and twice the speed. + */ + +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"; + +/** + * What a module may allocate. + * + * Sized for the job rather than for comfort: an importer holding a large spec + * and the objects it parses into is the high-water mark, and a plugin that + * wants more than this is doing something a plugin should not. Hitting it + * throws inside the sandbox and unwinds as an ordinary error. + */ +const MEMORY_LIMIT_BYTES = 256 * 1024 * 1024; + +/** Deep recursion is a stack overflow inside the guest, not a crash of the worker. */ +const STACK_SIZE_BYTES = 2 * 1024 * 1024; + +/** + * How long a module may run without yielding. + * + * This bounds *synchronous* execution only, and it has to: a plugin awaiting + * the host is not looping, it is waiting for us. So the clock is set when a + * dispatch begins and pushed back whenever the guest hands control back, which + * makes it a watchdog for `while (true)` rather than a limit on how long real + * work may take. + * + * Generous, because it costs nothing to be: a plugin runs in its own worker, + * so one stuck here blocks no database command and no frame. It is sized off + * the slowest real work measured (`bench/import.mjs`: GitHub's 12 MB OpenAPI + * description takes about four seconds), with room for a document several + * times larger before a legitimate import looks like a hang. + */ +const SYNC_BUDGET_MS = 60_000; + +export type HostRequestHandler = (envelopeJson: string) => Promise; + +export interface SandboxLog { + pluginRefId: string; + level: string; + message: string; +} + +let modulePromise: Promise | null = null; + +function quickjs(): Promise { + // Loaded once per worker, on first use. The wasm is ~529 KB and there is no + // reason to pay for it in a session where nothing calls a plugin. + modulePromise ??= newQuickJSWASMModuleFromVariant(variant); + return modulePromise; +} + +/** One loaded module, and the context it lives in. */ +class LoadedPlugin { + readonly pluginRefId: string; + readonly context: QuickJSContext; + /** Set while a dispatch is running; the interrupt handler reads it. */ + deadline: number | null = null; + private nextTimer = new Map>(); + private disposed = false; + + constructor(pluginRefId: string, context: QuickJSContext) { + this.pluginRefId = pluginRefId; + this.context = context; + } + + /** Push the synchronous-execution deadline back; called whenever the guest yields. */ + touch(): void { + if (this.deadline != null) this.deadline = Date.now() + SYNC_BUDGET_MS; + } + + 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(); + + constructor( + private readonly onHostRequest: HostRequestHandler, + private readonly onLog: (log: SandboxLog) => void, + ) {} + + /** + * Load one module's source under an id. + * + * Replaces whatever was loaded under that id, disposing it first, so a + * reload is a fresh context rather than a re-evaluation on top of the old + * one's globals. + */ + async load(pluginRefId: string, source: string): Promise> { + const module = await quickjs(); + + if (this.runtime == null) { + this.runtime = module.newRuntime(); + this.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES); + this.runtime.setMaxStackSize(STACK_SIZE_BYTES); + // One handler for every context on the runtime. A plugin that is merely + // waiting has no deadline set, so it is never interrupted. + this.runtime.setInterruptHandler(() => { + const now = Date.now(); + for (const plugin of this.plugins.values()) { + 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); + } + + /** Send one event to one loaded module and wait for its reply payload. */ + async dispatch(pluginRefId: string, envelopeJson: string): Promise { + const plugin = this.plugins.get(pluginRefId); + if (plugin == null) throw new Error(`No plugin loaded as \`${pluginRefId}\``); + 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[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), () => { + // Waking a timer re-enters the guest, so it gets a fresh budget. + plugin.touch(); + this.callGuestSync(plugin, "fireTimer", [id]); + this.pump(plugin); + }); + }); + + define("__yaak_timer_cancel", (idHandle) => { + plugin.cancelTimer(context.getNumber(idHandle)); + }); + + // The one door out. Everything a plugin does to the world arrives here as + // a JSON envelope and leaves as a JSON reply; the guest's whole `ctx` is + // built from this single function. + define("__yaak_call", (envelopeHandle) => { + const envelope = context.getString(envelopeHandle); + + // While the host answers, the guest is suspended, not looping — so the + // watchdog stops until it comes back. + const wasWatching = plugin.deadline != null; + plugin.deadline = null; + + const settle = this.onHostRequest(envelope).then( + (reply) => { + if (wasWatching) plugin.touch(); + return context.newString(reply); + }, + (err: unknown) => { + if (wasWatching) plugin.touch(); + // Rejections come back as an error the guest can catch, which is + // what a host that cannot answer should look like from inside. + return context.newError(err instanceof Error ? err.message : String(err)); + }, + ); + + const deferred = context.newPromise(settle); + // Resolving a promise only queues its reactions; something has to run + // them, and inside a sandbox that something is us. + void deferred.settled.then(() => { + this.pump(plugin); + deferred.dispose(); + }); + return deferred.handle; + }); + } + + /** Drain the guest's microtask queue. */ + 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; + } + } + + /** Call `__yaak_guest.(...args)`, awaiting the result if it is a promise. */ + 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 { + 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) { + // A promise: hand control back so the guest can make progress, then + // wait for it on this side. + const resolved = context.resolvePromise(value); + value.dispose(); + this.pump(plugin); + 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(); + } + } + + /** The timer path: fire and forget, because nothing is waiting on it. */ + private callGuestSync(plugin: LoadedPlugin, method: string, args: number[]): void { + const { context } = plugin; + const guest = context.getProp(context.global, "__yaak_guest"); + 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(); + } + } + + /** + * A dumped QuickJS error as a host `Error`. + * + * The guest's stack is kept in the message: it names lines in the plugin's + * own bundle, which is the only stack that means anything to whoever wrote + * it — the worker's own stack would just say "sandbox.ts". + */ + private toError(plugin: LoadedPlugin, dumped: unknown): Error { + if (dumped != null && typeof dumped === "object") { + const { message, name, stack } = dumped as Record; + 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 at all, + // which would otherwise read as a mysterious empty failure. + 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)); + } +} diff --git a/packages/plugin-sandbox/src/index.ts b/packages/plugin-sandbox/src/index.ts new file mode 100644 index 00000000..4056b5f9 --- /dev/null +++ b/packages/plugin-sandbox/src/index.ts @@ -0,0 +1,153 @@ +/** + * A tab's handle on its sandbox. + * + * Owns the worker, keeps track of what is loaded in it, and turns the two + * message flows into promises. The interesting half is `onHostRequest`: the + * caller supplies it, and it is the entire answer to "what can a plugin do + * here?" — this package deliberately has no idea. A browser host answers those + * against a wasm database and a send proxy; something else could answer them + * differently; a host that answers nothing still runs plugins that only compute. + */ + +import type { FromSandbox, ToSandbox } from "./protocol"; + + +/** Answers one `ctx` call. Gets the JSON envelope, returns the JSON reply. */ +export type HostRequestHandler = (envelope: string) => Promise; + +export interface PluginSandboxOptions { + onHostRequest: HostRequestHandler; + onLog?: (log: { pluginRefId: string; level: string; message: string }) => void; +} + +/** What a module turned out to contribute, as reported after loading. */ +export interface PluginSummary { + templateFunctions: string[]; + authentication: string | null; + 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(); + private readonly options: PluginSandboxOptions; + private nextId = 1; + + constructor(options: PluginSandboxOptions) { + this.options = options; + + // `new URL("./worker.ts", import.meta.url)` is written inline because that + // exact syntax is what the bundler pattern-matches to know it must bundle a + // worker entry. Hoisted into a variable it ships as raw TypeScript. + this.worker = new Worker(new URL("./worker.ts", import.meta.url), { + type: "module", + name: "yaak-plugins", + }); + this.worker.onmessage = (e: MessageEvent) => this.receive(e.data); + this.worker.onerror = () => this.failEverything("The plugin sandbox failed to start"); + } + + /** Load a module's source under an id, replacing anything already there. */ + load(pluginRefId: string, source: string): Promise { + return this.request((id) => ({ type: "load", id, pluginRefId, source })); + } + + unload(pluginRefId: string): Promise { + return this.request((id) => ({ type: "unload", id, pluginRefId })); + } + + /** Send one event to one loaded module; resolves with its reply payload. */ + async dispatch( + pluginRefId: string, + context: unknown, + payload: unknown, + ): Promise { + const envelope = JSON.stringify({ context, payload }); + const reply = await this.request((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 }; + } + + /** + * End the sandbox now. + * + * `terminate()` rather than a polite shutdown, on purpose: the reason to + * reach for this is a plugin that will not stop, and asking it to stop is + * exactly what does not work then. + */ + dispose(): void { + this.worker.terminate(); + this.failEverything("The plugin sandbox was shut down"); + } + + /* ------------------------------ internals ------------------------------- */ + + private request(build: (id: number) => ToSandbox): Promise { + const id = this.nextId++; + return new Promise((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 { + 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)); + } + } +} diff --git a/packages/plugin-sandbox/src/protocol.ts b/packages/plugin-sandbox/src/protocol.ts new file mode 100644 index 00000000..e25630e7 --- /dev/null +++ b/packages/plugin-sandbox/src/protocol.ts @@ -0,0 +1,27 @@ +/** + * The messages between a tab and its sandbox worker. + * + * Two request/reply flows in opposite directions. The tab asks the worker to + * load a module or send it an event; the worker asks the tab to answer a + * plugin's `ctx` call, because the tab is the only side with a database, a + * network and a user. Both carry payloads as JSON strings rather than objects: + * they have to be strings to cross into QuickJS anyway, and serializing once at + * the edge is cheaper than structured-cloning an object the worker will only + * stringify again. + */ + +/** 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 }; diff --git a/packages/plugin-sandbox/src/worker.ts b/packages/plugin-sandbox/src/worker.ts new file mode 100644 index 00000000..87635c42 --- /dev/null +++ b/packages/plugin-sandbox/src/worker.ts @@ -0,0 +1,82 @@ +/// + +/** + * The worker plugins run in. + * + * A dedicated worker, owned by the tab that made it — deliberately not the + * SharedWorker that owns the database, for three reasons. Plugin work is slow + * by design (see `bench/import.mjs`) and the database worker answers every + * tab's commands synchronously, so a large import in there would stall every + * other tab's reads. A plugin that never returns can be ended with + * `terminate()`, which is not something you can do to the worker holding the + * database. And the capabilities a plugin actually asks for — a prompt, a + * toast, the active request — belong to a tab rather than to a database, so + * routing through the tab is the shorter path anyway, not a detour. + * + * That leaves the database one hop further away than it would otherwise be: + * `ctx.store` goes worker → tab → database worker. It is a message either way, + * and this direction is the one where a stuck plugin costs nothing. + */ + +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); +} + +/** Host calls waiting on the tab, by id. */ +const pendingHostCalls = new Map void>(); +let nextHostCallId = 1; + +const host = new PluginSandboxHost( + (envelope) => + new Promise((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 { + 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) => void handle(e.data); diff --git a/scripts/bundle-sandbox-plugins.mjs b/scripts/bundle-sandbox-plugins.mjs new file mode 100644 index 00000000..8c6c48e3 --- /dev/null +++ b/scripts/bundle-sandbox-plugins.mjs @@ -0,0 +1,115 @@ +/** + * Bundle plugins for the sandbox runtime. + * + * A stand-in for `yaakcli build --target sandbox`, which does not exist yet. + * The difference from the Node target is small and entirely in the resolver: + * nothing may resolve to a Node built-in, because the sandbox has none — see + * `packages/plugin-sandbox/README.md` for the full contract. Bundling here + * rather than in the CLI keeps the CLI out of this slice; what the CLI would + * need is written down at the bottom of this file. + * + * Output is a generated TypeScript module holding each bundle as a string, + * which is how the browser host ships them today. That is the part most + * obviously temporary: see the note at the bottom. + */ + +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)), ".."); + +/** + * The plugins the browser tier ships. + * + * Three, not the whole corpus: this slice is about the runtime existing and + * being proven, and each of these proves a different path through it — a + * template function, an importer, an authentication method. + */ +const PLUGINS = ["template-function-timestamp", "importer-curl", "auth-bearer"]; + +/** Refuse Node built-ins loudly at build time rather than at first call. */ +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, + // CommonJS because that is what the shell evaluates: a `new Function` with + // `module`, `exports` and a `require` that only throws. + format: "cjs", + platform: "browser", + target: "es2022", + 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. + */