Compare commits

..
50 changed files with 2289 additions and 3173 deletions
Generated
-1
View File
@@ -11775,7 +11775,6 @@ dependencies = [
"console_error_panic_hook",
"js-sys",
"log 0.4.29",
"md5 0.7.0",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -0,0 +1,32 @@
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, test, vi } from "vite-plus/test";
import { CsvViewerInner } from "./CsvViewer";
vi.mock("@yaakapp-internal/ui", () => ({
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
}));
describe("CsvViewer", () => {
test("renders columns that extend beyond the first row", () => {
const markup = renderToStaticMarkup(
<CsvViewerInner
text={[
"startDate,2026-02-03T00:00-03:00",
"endDate,2026-02-03T23:59:59-03:00",
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
].join("\n")}
/>,
);
expect(markup).toContain("ID de usuario");
expect(markup).toContain("42041");
expect(markup.match(/<td>/g)).toHaveLength(20);
});
});
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
const parsed = useMemo(() => {
if (text == null) return null;
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
return Papa.parse<string[]>(text, { skipEmptyLines: true });
}, [text]);
if (parsed === null) return null;
const header = parsed.data[0] ?? [];
const rows = parsed.data.slice(1);
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
return (
<div className="overflow-auto h-full">
<Table className={classNames(className, "text-sm")}>
<TableHead>
<TableRow>
{parsed.meta.fields?.map((field) => (
<TableHeaderCell key={field}>{field}</TableHeaderCell>
{columnIndexes.map((columnIndex) => (
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{parsed.data.map((row, i) => (
{rows.map((row, i) => (
// oxlint-disable-next-line react/no-array-index-key
<TableRow key={i}>
{parsed.meta.fields?.map((key) => (
<TableCell key={key}>{row[key] ?? ""}</TableCell>
{row.map((cell, columnIndex) => (
// oxlint-disable-next-line react/no-array-index-key
<TableCell key={columnIndex}>{cell}</TableCell>
))}
</TableRow>
))}
+82 -2
View File
@@ -78,8 +78,19 @@ impl SendableHttpRequest {
}
pub fn insert_header(&mut self, header: (String, String)) {
if header.0.eq_ignore_ascii_case("cookie") {
if let Some(existing) =
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case("cookie"))
{
existing.1 = format!("{}; {}", existing.1, header.1);
} else {
self.headers.push(header);
}
return;
}
if let Some(existing) =
self.headers.iter_mut().find(|h| h.0.to_lowercase() == header.0.to_lowercase())
self.headers.iter_mut().find(|h| h.0.eq_ignore_ascii_case(&header.0))
{
existing.1 = header.1;
} else {
@@ -494,7 +505,76 @@ mod tests {
use bytes::Bytes;
use serde_json::json;
use std::collections::BTreeMap;
use yaak_models::models::{HttpRequest, HttpUrlParameter};
use yaak_models::models::{HttpRequest, HttpRequestHeader, HttpUrlParameter};
#[tokio::test]
async fn test_sendable_request_preserves_independent_cookie_enabled_states() {
let request = HttpRequest {
url: "https://example.com/api".to_string(),
headers: vec![
HttpRequestHeader {
enabled: true,
name: "Cookie".to_string(),
value: "session=abc".to_string(),
id: None,
},
HttpRequestHeader {
enabled: false,
name: "Cookie".to_string(),
value: "debug=verbose".to_string(),
id: None,
},
],
..Default::default()
};
let sendable =
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
.await
.unwrap();
assert_eq!(sendable.headers, vec![("Cookie".to_string(), "session=abc".to_string())]);
}
#[test]
fn test_insert_header_appends_authentication_cookie() {
let mut request = SendableHttpRequest {
headers: vec![
("Cookie".to_string(), "session=abc".to_string()),
("Cookie".to_string(), "theme=dark".to_string()),
],
..Default::default()
};
request.insert_header(("cookie".to_string(), "api_key=secret".to_string()));
assert_eq!(
request.headers,
vec![
("Cookie".to_string(), "session=abc; api_key=secret".to_string()),
("Cookie".to_string(), "theme=dark".to_string()),
],
);
}
#[tokio::test]
async fn test_sendable_request_preserves_serialized_path_delimiters() {
let request = HttpRequest {
url: "https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2"
.to_string(),
..Default::default()
};
let sendable =
SendableHttpRequest::from_http_request(&request, SendableHttpRequestOptions::default())
.await
.unwrap();
assert_eq!(
sendable.url,
"https://example.com/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2",
);
}
#[test]
fn test_build_url_no_params() {
+2 -4
View File
@@ -1,4 +1,4 @@
use super::conflict_free_name;
use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb;
use crate::connection_or_tx::ConnectionOrTx;
use crate::error::Result;
@@ -144,9 +144,7 @@ impl<'a> ClientDb<'a> {
headers.append(&mut workspace_headers);
}
headers.append(&mut folder.headers.clone());
Ok(headers)
Ok(merge_headers(headers, folder.headers.clone()))
}
pub fn resolve_settings_for_folder(
@@ -1,4 +1,4 @@
use super::{conflict_free_name, dedupe_headers};
use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
@@ -110,9 +110,7 @@ impl<'a> ClientDb<'a> {
metadata.append(&mut workspace_metadata);
}
metadata.append(&mut grpc_request.metadata.clone());
Ok(dedupe_headers(metadata))
Ok(merge_headers(metadata, grpc_request.metadata.clone()))
}
pub fn resolve_settings_for_grpc_request(
@@ -1,4 +1,4 @@
use super::{conflict_free_name, dedupe_headers};
use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
@@ -96,9 +96,7 @@ impl<'a> ClientDb<'a> {
headers.append(&mut workspace_headers);
}
headers.append(&mut http_request.headers.clone());
Ok(dedupe_headers(headers))
Ok(merge_headers(headers, http_request.headers.clone()))
}
pub fn resolve_settings_for_http_request(
@@ -172,3 +170,44 @@ impl<'a> ClientDb<'a> {
Ok(children)
}
}
#[cfg(test)]
mod tests {
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpRequestHeader};
#[test]
fn request_resolution_preserves_duplicate_request_headers() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let workspace = db.list_workspaces().expect("Failed to list workspaces").remove(0);
let request = HttpRequest {
workspace_id: workspace.id,
headers: vec![
HttpRequestHeader {
name: "Cookie".to_string(),
value: "required=1".to_string(),
..Default::default()
},
HttpRequestHeader {
enabled: false,
name: "Cookie".to_string(),
value: "optional=1".to_string(),
..Default::default()
},
],
..Default::default()
};
let resolved = db.resolve_headers_for_http_request(&request).expect("Failed to resolve");
let cookies = resolved
.iter()
.filter(|header| header.name.eq_ignore_ascii_case("cookie"))
.collect::<Vec<_>>();
assert_eq!(cookies.len(), 2);
assert_eq!(cookies[0].value, "required=1");
assert_eq!(cookies[1].value, "optional=1");
assert!(!cookies[1].enabled);
}
}
+54 -16
View File
@@ -28,21 +28,59 @@ pub(crate) use duplicate_name::conflict_free_name;
const MAX_HISTORY_ITEMS: usize = 20;
use crate::models::HttpRequestHeader;
use std::collections::HashMap;
use std::collections::HashSet;
/// Deduplicate headers by name (case-insensitive), keeping the latest (most specific) value.
/// Preserves the order of first occurrence for each header name.
pub(crate) fn dedupe_headers(headers: Vec<HttpRequestHeader>) -> Vec<HttpRequestHeader> {
let mut index_by_name: HashMap<String, usize> = HashMap::new();
let mut deduped: Vec<HttpRequestHeader> = Vec::new();
for header in headers {
let key = header.name.to_lowercase();
if let Some(&idx) = index_by_name.get(&key) {
deduped[idx] = header;
} else {
index_by_name.insert(key, deduped.len());
deduped.push(header);
}
}
deduped
/// Merge a more-specific header layer over its parent. Names in the child replace
/// inherited values case-insensitively, while duplicates declared together in
/// either layer remain independent entries.
pub(crate) fn merge_headers(
mut parent: Vec<HttpRequestHeader>,
child: Vec<HttpRequestHeader>,
) -> Vec<HttpRequestHeader> {
let child_names = child.iter().map(|header| header.name.to_lowercase()).collect::<HashSet<_>>();
parent.retain(|header| !child_names.contains(&header.name.to_lowercase()));
parent.extend(child);
parent
}
#[cfg(test)]
mod tests {
use super::merge_headers;
use crate::models::HttpRequestHeader;
fn header(name: &str, value: &str) -> HttpRequestHeader {
HttpRequestHeader { name: name.to_string(), value: value.to_string(), ..Default::default() }
}
#[test]
fn preserves_duplicate_headers_declared_in_one_layer() {
let merged = merge_headers(
vec![header("Cookie", "inherited=1")],
vec![
header("Cookie", "required=1"),
header("cookie", "optional=1"),
],
);
assert_eq!(
merged.iter().map(|header| header.value.as_str()).collect::<Vec<_>>(),
vec!["required=1", "optional=1"],
);
}
#[test]
fn child_names_override_parent_names_without_affecting_other_headers() {
let merged = merge_headers(
vec![header("Accept", "*/*"), header("X-Parent", "kept")],
vec![header("accept", "application/json")],
);
assert_eq!(
merged
.iter()
.map(|header| (header.name.as_str(), header.value.as_str()))
.collect::<Vec<_>>(),
vec![("X-Parent", "kept"), ("accept", "application/json")],
);
}
}
@@ -1,4 +1,4 @@
use super::{conflict_free_name, dedupe_headers};
use super::{conflict_free_name, merge_headers};
use crate::client_db::ClientDb;
use crate::error::Result;
use crate::models::{
@@ -103,13 +103,9 @@ impl<'a> ClientDb<'a> {
&self,
websocket_request: &WebsocketRequest,
) -> Result<Vec<HttpRequestHeader>> {
let workspace = self.get_workspace(&websocket_request.workspace_id)?;
// Resolved headers should be from furthest to closest ancestor, to override logically.
let mut headers = Vec::new();
headers.append(&mut workspace.headers.clone());
if let Some(folder_id) = websocket_request.folder_id.clone() {
let parent_folder = self.get_folder(&folder_id)?;
let mut folder_headers = self.resolve_headers_for_folder(&parent_folder)?;
@@ -120,9 +116,7 @@ impl<'a> ClientDb<'a> {
headers.append(&mut workspace_headers);
}
headers.append(&mut websocket_request.headers.clone());
Ok(dedupe_headers(headers))
Ok(merge_headers(headers, websocket_request.headers.clone()))
}
pub fn resolve_settings_for_websocket_request(
+2 -3
View File
@@ -1,3 +1,4 @@
use super::merge_headers;
use crate::blob_manager::BlobManager;
use crate::client_db::ClientDb;
use crate::error::Result;
@@ -144,9 +145,7 @@ impl<'a> ClientDb<'a> {
}
pub fn resolve_headers_for_workspace(&self, workspace: &Workspace) -> Vec<HttpRequestHeader> {
let mut headers = default_headers();
headers.extend(workspace.headers.clone());
headers
merge_headers(default_headers(), workspace.headers.clone())
}
pub fn resolve_settings_for_workspace(
+1 -14
View File
@@ -8,25 +8,12 @@ use std::future::Future;
const MAX_DEPTH: usize = 50;
/// `Send`, except on wasm32, where a template function is a call into
/// JavaScript: the future holds a `JsFuture` and the callback an `Rc` pool,
/// neither of which can be `Send`. Every other host spawns rendering onto a
/// thread pool and needs the bound.
#[cfg(not(target_arch = "wasm32"))]
pub trait MaybeSend: Send {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send> MaybeSend for T {}
#[cfg(target_arch = "wasm32")]
pub trait MaybeSend {}
#[cfg(target_arch = "wasm32")]
impl<T> MaybeSend for T {}
pub trait TemplateCallback {
fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> impl Future<Output = Result<String>> + MaybeSend;
) -> impl Future<Output = Result<String>> + Send;
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String>;
}
-1
View File
@@ -25,7 +25,6 @@ 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
+1 -9
View File
@@ -3,12 +3,4 @@
// 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,
render_template,
rpc,
} from "./pkg";
export { blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc } from "./pkg";
+7 -16
View File
@@ -26,24 +26,15 @@ export function blob_put(id: string, bytes: Uint8Array): void;
export function boot(): Promise<void>;
/**
* Resolve and render a request for sending, exactly as the desktop does: the environment
* chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
* a socket.
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the Yaak server.
*
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
* resolving to the rendered string. Passing nothing is allowed.
*
* Authentication is applied by the caller, not here, because the plugin that applies it
* needs to see the request as it will be sent.
* 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.
*/
export function prepare_http_send(payload: any, plugins: any): Promise<any>;
/**
* What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
* shows an empty string where a send would refuse, since a half-typed template is not yet a
* mistake.
*/
export function render_template(payload: any, plugins: any): Promise<any>;
export function prepare_http_send(payload: any): Promise<any>;
/**
* Run one command as `label` (the calling tab's identity, which stands in for
+1 -1
View File
@@ -5,5 +5,5 @@ import { __wbg_set_wasm } from "./yaak_wasm_bg.js";
__wbg_set_wasm(wasm);
wasm.__wbindgen_start();
export {
blob_delete, blob_get, blob_put, boot, prepare_http_send, render_template, rpc
blob_delete, blob_get, blob_put, boot, prepare_http_send, rpc
} from "./yaak_wasm_bg.js";
+15 -39
View File
@@ -63,34 +63,18 @@ export function boot() {
}
/**
* Resolve and render a request for sending, exactly as the desktop does: the environment
* chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
* a socket.
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the Yaak server.
*
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
* resolving to the rendered string. Passing nothing is allowed.
*
* Authentication is applied by the caller, not here, because the plugin that applies it
* needs to see the request as it will be sent.
* 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.
* @param {any} payload
* @param {any} plugins
* @returns {Promise<any>}
*/
export function prepare_http_send(payload, plugins) {
const ret = wasm.prepare_http_send(payload, plugins);
return ret;
}
/**
* What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
* shows an empty string where a send would refuse, since a half-typed template is not yet a
* mistake.
* @param {any} payload
* @param {any} plugins
* @returns {Promise<any>}
*/
export function render_template(payload, plugins) {
const ret = wasm.render_template(payload, plugins);
export function prepare_http_send(payload) {
const ret = wasm.prepare_http_send(payload);
return ret;
}
@@ -241,10 +225,6 @@ 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;
@@ -689,10 +669,6 @@ 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;
@@ -721,22 +697,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: 1140, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// 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`.
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: 229, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 212, 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: 74, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 227, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 210, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
return ret;
}
@@ -789,8 +765,8 @@ function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg
}
}
function wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
Binary file not shown.
+2 -3
View File
@@ -5,8 +5,7 @@ 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, b: any) => any;
export const render_template: (a: any, b: any) => any;
export const prepare_http_send: (a: 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;
@@ -19,7 +18,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__he166673e9c1b4e95: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__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;
+37 -138
View File
@@ -232,14 +232,6 @@ struct PersistSendCookiesReq {
after: Vec<Cookie>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PluginKeyValueReq {
plugin_name: String,
key: String,
value: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct InsertResponseEventsReq {
@@ -423,34 +415,6 @@ fn dispatch(
to_json(())
}
// Namespaced by plugin name exactly as `build_shared_reply` does in
// crates/yaak/src/plugin_events.rs, so a token is found under the same key on either host.
"web_plugin_kv_get" => {
let req: PluginKeyValueReq = from_js(payload)?;
let found = host.queries.connect().get_plugin_key_value(&req.plugin_name, &req.key);
to_json(found.map(|kv| kv.value))
}
"web_plugin_kv_set" => {
let req: PluginKeyValueReq = from_js(payload)?;
host.queries.connect().set_plugin_key_value(
&req.plugin_name,
&req.key,
&req.value.unwrap_or_default(),
);
to_json(())
}
"web_plugin_kv_delete" => {
let req: PluginKeyValueReq = from_js(payload)?;
let deleted = host
.queries
.connect()
.delete_plugin_key_value(&req.plugin_name, &req.key)
.map_err(js_error)?;
to_json(deleted)
}
other => Err(js_error(format!("yaak-web: `{other}` is not a command this host answers"))),
}
}
@@ -475,9 +439,6 @@ struct PreparedHttpSend {
/// The request with inherited headers and authentication applied and every template
/// rendered. What the proxy sends, and what the response records as its request.
request: HttpRequest,
/// Whichever model the auth was inherited from, hashed as the desktop hashes it. An
/// OAuth token cache belongs to the folder that declared the auth, not to each request.
auth_context_id: String,
settings: HttpSendSettings,
/// The `* Setting name=value` timeline lines the desktop writes at the top of a send,
/// sources and all. The tab records them before the proxy's own events.
@@ -486,44 +447,22 @@ struct PreparedHttpSend {
cookie_jar: Option<CookieJar>,
}
/// Reaches a template function through a JavaScript function the worker installed, which
/// forwards to the plugin sandbox. Without one, a template function is a refusal naming it
/// rather than an empty string sent in its place.
struct JsTemplateCallback {
call: Option<js_sys::Function>,
}
/// 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;
impl TemplateCallback for JsTemplateCallback {
impl TemplateCallback for NoPluginsCallback {
fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> {
let call = self.call.clone();
let fn_name = fn_name.to_string();
let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into());
async move {
use yaak_templates::error::Error::RenderError;
let Some(call) = call else {
return Err(RenderError(format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
No plugin provides it"
)));
};
let promise = call
.call2(&JsValue::NULL, &JsValue::from_str(&fn_name), &JsValue::from_str(&args))
.map_err(|e| RenderError(js_message(&e)))?;
let value = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise))
.await
.map_err(|e| RenderError(js_message(&e)))?;
value.as_string().ok_or_else(|| {
RenderError(format!("Template function \"{fn_name}\" did not return a string"))
})
}
_args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> + Send {
let message = format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
Plugins aren't available in the browser yet"
);
async move { Err(yaak_templates::error::Error::RenderError(message)) }
}
fn transform_arg(
@@ -536,35 +475,19 @@ impl TemplateCallback for JsTemplateCallback {
}
}
fn js_message(value: &JsValue) -> String {
if let Some(text) = value.as_string() {
return text;
}
let message = js_sys::Reflect::get(value, &JsValue::from_str("message"))
.ok()
.and_then(|m| m.as_string());
message.unwrap_or_else(|| format!("{value:?}"))
}
fn template_callback(plugins: JsValue) -> JsTemplateCallback {
JsTemplateCallback { call: plugins.dyn_into::<js_sys::Function>().ok() }
}
/// Resolve and render a request for sending, exactly as the desktop does: the environment
/// chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
/// a socket.
/// Resolve and render a request for sending, exactly as the desktop does before it puts the
/// request on the network: the environment chain, inherited headers and auth, request
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
/// posts to the Yaak server.
///
/// `plugins` is the template function bridge: a JS function taking a name and JSON args,
/// resolving to the rendered string. Passing nothing is allowed.
///
/// Authentication is applied by the caller, not here, because the plugin that applies it
/// needs to see the request as it will be sent.
/// 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.
#[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
pub async fn prepare_http_send(payload: JsValue) -> Result<JsValue> {
let req: PrepareHttpSendReq = from_js(payload)?;
// Everything from the database first, then release the host borrow before rendering.
let (request, environment_chain, settings, cookie_jar, auth_context_id) = with_host(|host| {
let (request, environment_chain, settings, cookie_jar) = 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
@@ -574,7 +497,7 @@ pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsV
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)?;
@@ -583,21 +506,34 @@ pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsV
None => None,
};
let request = HttpRequest { authentication_type, authentication, headers, ..request };
Ok((request, environment_chain, settings, cookie_jar, auth_context_id))
Ok((request, environment_chain, settings, cookie_jar))
})?;
let rendered = render_http_request(
&request,
environment_chain,
&template_callback(plugins),
&NoPluginsCallback,
&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,
@@ -608,43 +544,6 @@ pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsV
prepared.serialize(&serde_wasm_bindgen::Serializer::json_compatible()).map_err(js_error)
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RenderTemplateReq {
template: String,
workspace_id: String,
environment_id: Option<String>,
ignore_error: Option<bool>,
}
/// What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
/// shows an empty string where a send would refuse, since a half-typed template is not yet a
/// mistake.
#[wasm_bindgen]
pub async fn render_template(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
let req: RenderTemplateReq = from_js(payload)?;
let environment_chain = with_host(|host| {
host.queries
.connect()
.resolve_environments(&req.workspace_id, None, req.environment_id.as_deref())
.map_err(js_error)
})?;
let vars = yaak_models::render::make_vars_hashmap(environment_chain);
let options = if req.ignore_error == Some(true) {
RenderOptions::return_empty()
} else {
RenderOptions::throw()
};
let rendered =
yaak_templates::parse_and_render(&req.template, &vars, &template_callback(plugins), &options)
.await
.map_err(js_error)?;
to_json(rendered).map(|v| JsValue::from_str(v.as_str().unwrap_or_default()))
}
/* -------------------------------------------------------------------------- */
/* Blobs */
/* -------------------------------------------------------------------------- */
-40
View File
@@ -16,7 +16,6 @@
"packages/platform",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/plugin-sandbox",
"plugins-external/mcp-server",
"plugins-external/faker",
"plugins-external/httpsnippet",
@@ -1471,21 +1470,6 @@
"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",
@@ -5654,10 +5638,6 @@
"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
@@ -12819,15 +12799,6 @@
],
"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",
@@ -15888,17 +15859,6 @@
"dev": true,
"license": "MIT"
},
"packages/plugin-sandbox": {
"name": "@yaakapp-internal/plugin-sandbox",
"version": "1.0.0",
"dependencies": {
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
"quickjs-emscripten-core": "^0.32.0"
},
"devDependencies": {
"esbuild": "^0.28.0"
}
},
"packages/tailwind-config": {
"name": "@yaakapp-internal/tailwind-config",
"version": "1.0.0"
-1
View File
@@ -15,7 +15,6 @@
"packages/platform",
"packages/plugin-runtime",
"packages/plugin-runtime-types",
"packages/plugin-sandbox",
"plugins-external/mcp-server",
"plugins-external/faker",
"plugins-external/httpsnippet",
-2
View File
@@ -2,5 +2,3 @@ export * from "./debounce";
export * from "./eagerDebounceAsync";
export * from "./formatSize";
export * from "./templateFunction";
export * from "./pluginForms";
export * from "./responseBody";
-403
View File
@@ -1,403 +0,0 @@
/**
* `ctx`, built once for every runtime that has one. A runtime supplies only how
* a payload reaches its host.
*
* `stream` and `form` are optional because they are the two places a host
* genuinely differs: both need a conversation rather than one reply.
*/
import type {
CallPromptFormDynamicArgs,
Context,
DynamicPromptFormArg,
} from "@yaakapp/api";
import type {
DeleteKeyValueResponse,
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoResponse,
GetKeyValueResponse,
HttpRequest,
HttpResponse,
InternalEventPayload,
ListCookieNamesResponse,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
ListOpenWorkspacesResponse,
PluginContext,
PromptFormResponse,
PromptTextResponse,
ReadHttpResponseBodyChunkResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateRenderRequest,
TemplateRenderResponse,
UpsertModelResponse,
WindowInfoResponse,
} from "@yaakapp-internal/plugins";
import { applyDynamicFormInput, stripDynamicCallbacks } from "./pluginForms";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
import { applyFormInputDefaults } from "./templateFunction";
export interface PluginTransport {
request(
context: PluginContext,
payload: InternalEventPayload,
): Promise<Record<string, unknown>>;
notify(context: PluginContext, payload: InternalEventPayload): void;
/** Send once, keep receiving. Windows report navigation until they close. */
stream?(
context: PluginContext,
payload: InternalEventPayload,
onReply: (payload: InternalEventPayload) => void,
): void;
/**
* A form that may re-render before it settles: `onChange` answers with the
* form to show next. Without it, a form is drawn once from its defaults.
*/
form?(
context: PluginContext,
payload: InternalEventPayload,
onChange: (
values: Record<string, unknown>,
) => Promise<InternalEventPayload | null>,
): Promise<PromptFormResponse>;
}
/** `bodyPath` names a file on a host's disk; plugins address bodies by id. */
function forPlugin(httpResponse: HttpResponse): HttpResponse {
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
bodyPath?: string | null;
};
return rest;
}
export function createPluginContext(
transport: PluginTransport,
context: PluginContext,
): Context {
const send = <T>(payload: InternalEventPayload): Promise<T> =>
transport.request(context, payload) as Promise<T>;
const storedBody = async (responseId: string) => {
const bodyInfo = () =>
send<GetHttpResponseBodyInfoResponse>({
type: "get_http_response_body_info_request",
responseId,
});
const info = await bodyInfo();
return createResponseBody(
{
responseId,
contentLength: info.contentLength,
contentType: info.contentType ?? null,
complete: info.complete,
},
async (offset, length) => {
const chunk = await send<ReadHttpResponseBodyChunkResponse>({
type: "read_http_response_body_chunk_request",
responseId,
offset,
length,
});
return decodeBase64Chunk(chunk.data);
},
{ refresh: bodyInfo },
);
};
const windowInfo = async () => {
if (context.label == null) {
throw new Error("Can't get window context without an active window");
}
return send<WindowInfoResponse>({ type: "window_info_request", label: context.label });
};
const ctx: Context = {
clipboard: {
copyText: async (text) => {
await send({ type: "copy_text_request", text });
},
},
toast: {
show: async (args) => {
await send({
type: "show_toast_request",
// Defaulted here because null and undefined both become None in Rust.
timeout: args.timeout === undefined ? 5000 : args.timeout,
...args,
});
},
},
window: {
requestId: async () => (await windowInfo()).requestId,
workspaceId: async () => (await windowInfo()).workspaceId,
environmentId: async () => (await windowInfo()).environmentId,
openUrl: async ({ onNavigate, onClose, ...args }) => {
if (transport.stream == null) {
throw new Error("ctx.window.openUrl is not available in this runtime");
}
args.label = args.label || `${Math.random()}`;
transport.stream(context, { type: "open_window_request", ...args }, (event) => {
if (event.type === "window_navigate_event") onNavigate?.(event);
else if (event.type === "window_close_event") onClose?.();
});
return {
close: () => {
transport.notify(context, { type: "close_window_request", label: args.label });
},
};
},
openExternalUrl: async (url) => {
await send({ type: "open_external_url_request", url });
},
},
prompt: {
text: async (args) => {
const reply = await send<PromptTextResponse>({ type: "prompt_text_request", ...args });
return reply.value;
},
form: async (args) => {
// Inputs may compute from the values entered so far, and a function
// cannot cross to a host.
const resolve = async (values: Record<string, unknown>) => {
const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs;
const resolved = await applyDynamicFormInput(
ctx,
args.inputs as DynamicPromptFormArg[],
callArgs,
);
return stripDynamicCallbacks(resolved) as FormInput[];
};
const initial = await resolve(applyFormInputDefaults(args.inputs, {}));
const payload: InternalEventPayload = {
type: "prompt_form_request",
...args,
inputs: initial,
};
if (transport.form == null) {
const reply = await send<PromptFormResponse>(payload);
return reply.values;
}
const reply = await transport.form(context, payload, async (values) => {
// Fired on mount, before there is anything to recompute from.
if (values == null || Object.keys(values).length === 0) return null;
return { type: "prompt_form_request", ...args, inputs: await resolve(values) };
});
return reply.values;
},
},
httpResponse: {
find: async (args) => {
const { httpResponses } = await send<FindHttpResponsesResponse>({
type: "find_http_responses_request",
...args,
});
return httpResponses.map(forPlugin);
},
body: ({ responseId }) => storedBody(responseId),
},
grpcRequest: {
render: async (args) => {
const { grpcRequest } = await send<RenderGrpcRequestResponse>({
type: "render_grpc_request_request",
...args,
});
return grpcRequest;
},
},
httpRequest: {
getById: async (args) => {
const { httpRequest } = await send<GetHttpRequestByIdResponse>({
type: "get_http_request_by_id_request",
...args,
});
return httpRequest;
},
send: async (args) => {
const { httpResponse, body } = await send<SendHttpRequestResponse>({
type: "send_http_request_request",
...args,
});
// A send with no request behind it saves nothing, so the reply carries
// the only copy of its body.
if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
}
const bytes = decodeBase64Chunk(body);
return {
httpResponse: forPlugin(httpResponse),
body: createResponseBody(
{
responseId: httpResponse.id,
contentLength: bytes.byteLength,
contentType:
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")?.value ??
null,
// The host waited for the whole send before replying.
complete: true,
},
async (offset, length) => bytes.slice(offset, offset + length),
),
};
},
render: async (args) => {
const { httpRequest } = await send<RenderHttpRequestResponse>({
type: "render_http_request_request",
...args,
});
return httpRequest;
},
list: async (args?: { folderId?: string }) => {
const payload: InternalEventPayload = {
type: "list_http_requests_request",
folderId: args?.folderId,
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
const { httpRequests } = await send<ListHttpRequestsResponse>(payload);
return httpRequests;
},
create: async (args) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { name: "", method: "GET", ...args, id: "", model: "http_request" },
} as InternalEventPayload);
return response.model as HttpRequest;
},
update: async (args) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { model: "http_request", ...args },
} as InternalEventPayload);
return response.model as HttpRequest;
},
delete: async (args) => {
const response = await send<DeleteModelResponse>({
type: "delete_model_request",
model: "http_request",
id: args.id,
} as InternalEventPayload);
return response.model as HttpRequest;
},
},
folder: {
list: async () => {
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
return folders;
},
getById: async (args: { id: string }) => {
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
return folders.find((f) => f.id === args.id) ?? null;
},
create: async ({ name, ...args }) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { ...args, name: name ?? "", id: "", model: "folder" },
} as InternalEventPayload);
return response.model as Folder;
},
update: async (args) => {
const response = await send<UpsertModelResponse>({
type: "upsert_model_request",
model: { model: "folder", ...args },
} as InternalEventPayload);
return response.model as Folder;
},
delete: async (args: { id: string }) => {
const response = await send<DeleteModelResponse>({
type: "delete_model_request",
model: "folder",
id: args.id,
} as InternalEventPayload);
return response.model as Folder;
},
},
cookies: {
getValue: async (args: GetCookieValueRequest) => {
const { value } = await send<GetCookieValueResponse>({
type: "get_cookie_value_request",
...args,
});
return value;
},
listNames: async () => {
const { names } = await send<ListCookieNamesResponse>({ type: "list_cookie_names_request" });
return names;
},
},
templates: {
render: async (args: TemplateRenderRequest) => {
const result = await send<TemplateRenderResponse>({
type: "template_render_request",
...args,
});
// oxlint-disable-next-line no-explicit-any -- the caller knows its own shape
return result.data as any;
},
},
store: {
get: async <T>(key: string) => {
const result = await send<GetKeyValueResponse>({ type: "get_key_value_request", key });
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
set: async <T>(key: string, value: T) => {
await send<GetKeyValueResponse>({
type: "set_key_value_request",
key,
value: JSON.stringify(value),
});
},
delete: async (key: string) => {
const result = await send<DeleteKeyValueResponse>({
type: "delete_key_value_request",
key,
});
return result.deleted;
},
},
plugin: {
reload: () => {
transport.notify(context, { type: "reload_response", silent: true });
},
},
workspace: {
list: async () => {
const response = await send<ListOpenWorkspacesResponse>({
type: "list_open_workspaces_request",
});
return response.workspaces.map((w) => {
type WorkspaceInfoInternal = typeof w & { label?: string };
return {
id: w.id,
name: w.name,
// Kept for routing, hidden from plugin authors.
_label: (w as WorkspaceInfoInternal).label as string,
};
});
},
withContext: (handle: { id: string; name: string; _label?: string }) =>
createPluginContext(transport, {
...context,
label: handle._label || null,
workspaceId: handle.id,
}),
},
};
return ctx;
}
+40 -112
View File
@@ -17,22 +17,15 @@
* 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,
plugins: WebPlugins,
) => Promise<unknown>;
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
/** Placeholder shown wherever the desktop would show a real filesystem path. */
const NO_PATH = "";
@@ -48,31 +41,6 @@ function text(payload: RpcPayload, key: string): string {
return typeof value === "string" ? value : "";
}
/** Form values as the plugin protocol carries them. */
function values(payload: RpcPayload, key = "values"): Record<string, JsonPrimitive> {
const value = payload[key];
return value != null && typeof value === "object"
? (value as Record<string, JsonPrimitive>)
: {};
}
/**
* The id a plugin keys its stored state on.
*
* The desktop hashes the id of whichever model the configuration was read from,
* so two requests inheriting one folder's authentication share a token cache.
* The preview paths here have no such model in hand and pass what they were
* given, which is enough to be stable per form.
*/
function contextId(payload: RpcPayload): string {
const model = payload.model;
if (model != null && typeof model === "object" && "id" in model) {
const id = (model as { id?: unknown }).id;
return typeof id === "string" ? id : "";
}
return "";
}
/**
* Commands this host answers itself.
*
@@ -105,16 +73,10 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
// The tab renders and stores; a stateless server puts the bytes on the wire.
// See send.ts for the whole shape of it.
cmd_send_http_request: (payload, db, plugins) => {
cmd_send_http_request: (payload, db) => {
const requestId = str(payload, "requestId");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
return sendHttpRequest(
db,
plugins,
requestId,
str(payload, "environmentId"),
str(payload, "cookieJarId"),
);
return sendHttpRequest(db, requestId, str(payload, "environmentId"), str(payload, "cookieJarId"));
},
/* -------------------------------- app ---------------------------------- */
@@ -184,67 +146,22 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
* Both of these are polled once a second until they answer with something, so
* an empty list is not a quiet no — it is a poll that never stops.
*
* 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.
* 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.
*/
async cmd_get_http_authentication_summaries(_payload, _db, plugins) {
return plugins.httpAuthenticationSummaries();
async cmd_get_http_authentication_summaries() {
return HTTP_AUTHENTICATION_SUMMARIES;
},
async cmd_template_function_summaries(_payload, _db, plugins) {
return plugins.templateFunctionSummaries();
async cmd_template_function_summaries() {
return [{ pluginRefId: "web", functions: [] }];
},
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_get_http_authentication_config() {
return { args: [], pluginRefId: "web" };
},
async cmd_format_json(payload) {
@@ -259,19 +176,13 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
},
/**
* 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.
* 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.
*/
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,
});
async cmd_render_template(payload) {
return text(payload, "template");
},
/* ------------------------------- bodies -------------------------------- */
@@ -313,6 +224,21 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
},
};
/**
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
* picker is truthful about the product; choosing one currently yields an empty
* config form, because the plugin that defines the form isn't running.
*/
const HTTP_AUTHENTICATION_SUMMARIES = [
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
];
/**
* Commands this host declines, each with the reason a user would need.
@@ -327,6 +253,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// ones nothing stores, used for GraphQL introspection — take the same road but
// return the body inline; not wired yet.
cmd_send_ephemeral_request: ["Sending unsaved requests isn't available in the browser yet", null],
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
// Protocols that need a real socket.
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
@@ -368,12 +295,14 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
};
@@ -400,10 +329,9 @@ export async function runCommand(
cmd: string,
payload: RpcPayload,
db: WorkerConnection,
plugins: WebPlugins,
): Promise<unknown> {
const handler = HANDLERS[cmd as AppCmd];
if (handler != null) return handler(payload, db, plugins);
if (handler != null) return handler(payload, db);
const declined = DECLINED[cmd as AppCmd];
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
-39
View File
@@ -50,9 +50,6 @@ export class WorkerConnection {
/** True once the worker has said anything at all. */
private heard = false;
/** Unset until the sandbox is up; a render before then gets a refusal. */
private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null;
constructor() {
// Both are required and neither is faked. Without a shared worker every
// tab would need its own SQLite over the same pages; without Web Locks
@@ -150,9 +147,6 @@ 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;
}
}
@@ -165,34 +159,6 @@ export class WorkerConnection {
});
}
setTemplateFunctionHandler(handler: (name: string, args: string) => Promise<string>): void {
this.templateFunctions = handler;
}
private async runTemplateFunction(id: number, name: string, args: string): Promise<void> {
if (this.templateFunctions == null) {
this.post({
type: "template_function_result",
id,
error: `The template function \`${name}\` needs a plugin, and none are loaded yet`,
});
return;
}
try {
this.post({
type: "template_function_result",
id,
value: await this.templateFunctions(name, args),
});
} catch (err) {
this.post({
type: "template_function_result",
id,
error: err instanceof Error ? err.message : String(err),
});
}
}
rpc<T>(cmd: string, payload: unknown): Promise<T> {
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
}
@@ -202,11 +168,6 @@ export class WorkerConnection {
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
}
/** See `render_template` in crates/yaak-wasm. */
renderTemplate(payload: unknown): Promise<string> {
return this.request<string>((id) => ({ type: "render_template", id, payload }));
}
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
return buf == null ? null : new Uint8Array(buf);
+3 -10
View File
@@ -28,7 +28,6 @@ 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. */
@@ -61,9 +60,7 @@ function capabilitiesFor(): PlatformCapabilities {
// The browser already zooms the page, on the same keys, and remembers it
// per site. The app stays out of the way.
interfaceZoom: false,
// Plugins run in a QuickJS sandbox, but only the bundled set: there is no
// installing them, so the plugin manager stays unavailable and says so.
plugins: true,
plugins: false,
encryption: false,
updater: false,
// Reading needs a permission prompt at first paint, which is a bad ask for
@@ -163,12 +160,8 @@ function createWindow(db: WorkerConnection): PlatformWindow {
export function createWebPlatform(): Platform {
const db = new WorkerConnection();
const plugins = new WebPlugins(db);
const capabilities = capabilitiesFor();
// Registered before anything can render, not inside the first send.
db.setTemplateFunctionHandler((name, args) => plugins.callTemplateFunction(name, args));
// Without this, IndexedDB is best-effort storage and a browser reclaiming
// space may drop someone's workspaces. Asking is all we can do, and there is
// nothing useful to do about a refusal.
@@ -238,7 +231,7 @@ export function createWebPlatform(): Platform {
// `plugin:` commands are Tauri host plugins, not engine commands, and
// never reached the router even on the desktop.
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
return runCommand(cmd, payload ?? {}, db, plugins) as Promise<T>;
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
},
async rpcStream<T, M>(
@@ -251,7 +244,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, plugins)) as T;
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
return { result, unlisten };
} catch (err) {
unlisten();
-298
View File
@@ -1,298 +0,0 @@
/**
* Keeps a sandbox, loads the bundled plugins into it, routes by what each one
* contributes, and answers the `ctx` calls they make. `hostRequest` below is
* the whole of what a plugin can do to the world here.
*/
import { PluginSandbox, type PluginSummary } from "@yaakapp-internal/plugin-sandbox";
import type {
GetHttpAuthenticationConfigResponse,
GetHttpAuthenticationSummaryResponse,
GetTemplateFunctionConfigResponse,
GetTemplateFunctionSummaryResponse,
ImportResources,
InternalEventPayload,
JsonPrimitive,
PluginContext,
} from "@yaakapp-internal/plugins";
import type { WorkerConnection } from "./connection";
import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated";
type KeyValueRequest = { key: string };
export interface AppliedAuthentication {
setHeaders?: { name: string; value: string }[] | null;
setQueryParameters?: { name: string; value: string }[] | null;
}
export class WebPlugins {
private readonly db: WorkerConnection;
private sandbox: PluginSandbox | null = null;
private loading: Promise<void> | null = null;
private readonly byTemplateFunction = new Map<string, string>();
private readonly byAuthName = new Map<string, string>();
private readonly importers: string[] = [];
private readonly summaries = new Map<string, PluginSummary>();
constructor(db: WorkerConnection) {
this.db = db;
}
/**
* Called from every entry point rather than at construction, so a session
* that never touches a plugin never pays for QuickJS.
*/
ready(): Promise<void> {
this.loading ??= this.start();
return this.loading;
}
private async start(): Promise<void> {
const sandbox = new PluginSandbox({
onHostRequest: (envelope) => this.hostRequest(envelope),
onLog: ({ pluginRefId, level, message }) => {
// Prefixed, or a plugin's console output blames the app's own code.
const write = level === "error" ? console.error : console.log;
write(`[plugin ${pluginRefId}] ${message}`);
},
});
this.sandbox = sandbox;
await Promise.all(
SANDBOX_PLUGINS.map(async ({ name, source }) => {
try {
const summary = await sandbox.load(name, source);
this.summaries.set(name, summary);
for (const fn of summary.templateFunctions) this.byTemplateFunction.set(fn, name);
if (summary.authentication != null) this.byAuthName.set(summary.authentication, name);
if (summary.importer) this.importers.push(name);
} catch (err) {
// One bad bundle should cost its own features and nothing else.
console.error(`Failed to load plugin \`${name}\``, err);
}
}),
);
}
/* ------------------------------ what exists ------------------------------ */
async templateFunctionSummaries(): Promise<GetTemplateFunctionSummaryResponse[]> {
await this.ready();
return this.gather("get_template_function_summary_request", this.summaries.keys());
}
async httpAuthenticationSummaries(): Promise<GetHttpAuthenticationSummaryResponse[]> {
await this.ready();
return this.gather("get_http_authentication_summary_request", this.byAuthName.values());
}
/** One broken plugin must not empty the picker for the others. */
private async gather<T>(type: string, ids: Iterable<string>): Promise<T[]> {
const replies = await Promise.all(
Array.from(ids).map(async (id): Promise<{ type: string } | null> => {
try {
return await this.dispatch(id, { type } as InternalEventPayload);
} catch (err) {
console.error(`Plugin \`${id}\` failed to answer \`${type}\``, err);
return null;
}
}),
);
return replies.filter((r) => r != null && r.type !== "empty_response") as T[];
}
/* -------------------------------- calling -------------------------------- */
async templateFunctionConfig(
name: string,
values: Record<string, JsonPrimitive>,
contextId: string,
): Promise<GetTemplateFunctionConfigResponse | null> {
await this.ready();
const id = this.byTemplateFunction.get(name);
if (id == null) return null;
return this.dispatch(id, {
type: "get_template_function_config_request",
contextId,
name,
values,
} as InternalEventPayload);
}
/**
* What the engine's render calls back into. A function nothing provides is a
* throw naming it, not an empty string: a request sent with a silently blank
* token is worse than one that refuses to be sent.
*/
async callTemplateFunction(name: string, argsJson: string): Promise<string> {
await this.ready();
const id = this.byTemplateFunction.get(name);
if (id == null) {
throw new Error(`No plugin provides the template function \`${name}\``);
}
const values = JSON.parse(argsJson) as Record<string, JsonPrimitive>;
const reply = await this.dispatch<{ value: string | null; error?: string | null }>(id, {
type: "call_template_function_request",
name,
args: { purpose: "send", values },
} as InternalEventPayload);
if (reply.error) throw new Error(reply.error);
return reply.value ?? "";
}
async httpAuthenticationConfig(
authName: string,
values: Record<string, JsonPrimitive>,
contextId: string,
): Promise<GetHttpAuthenticationConfigResponse | null> {
await this.ready();
const id = this.byAuthName.get(authName);
if (id == null) return null;
return this.dispatch(id, {
type: "get_http_authentication_config_request",
contextId,
values,
} as InternalEventPayload);
}
async callHttpAuthenticationAction(
authName: string,
index: number,
values: Record<string, JsonPrimitive>,
contextId: string,
): Promise<void> {
await this.ready();
const id = this.byAuthName.get(authName);
if (id == null) throw new Error(`No plugin provides \`${authName}\` authentication`);
await this.dispatch(id, {
type: "call_http_authentication_action_request",
index,
pluginRefId: id,
args: { contextId, values },
} as InternalEventPayload);
}
async applyHttpAuthentication(
authName: string,
request: {
contextId: string;
values: Record<string, JsonPrimitive>;
method: string;
url: string;
headers: { name: string; value: string }[];
body: string | null;
},
): Promise<AppliedAuthentication> {
await this.ready();
const id = this.byAuthName.get(authName);
if (id == null) {
throw new Error(
`This request uses ${authName} authentication, which no plugin in the browser provides`,
);
}
return this.dispatch<AppliedAuthentication>(id, {
type: "call_http_authentication_request",
...request,
} as InternalEventPayload);
}
/** First importer that recognizes the text wins, as `import_data` decides too. */
async import(content: string): Promise<ImportResources | null> {
await this.ready();
for (const id of this.importers) {
try {
const reply = await this.dispatch<{ resources?: ImportResources }>(id, {
type: "import_request",
content,
} as InternalEventPayload);
if (reply.type === "import_response" && reply.resources != null) return reply.resources;
} catch (err) {
console.error(`Importer \`${id}\` failed`, err);
}
}
return null;
}
/* ------------------------------- internals ------------------------------- */
private async dispatch<T>(
pluginRefId: string,
payload: InternalEventPayload,
): Promise<T & { type: string }> {
if (this.sandbox == null) throw new Error("The plugin sandbox is not running");
return this.sandbox.dispatch<T>(pluginRefId, this.context(), payload);
}
/**
* `label` names a desktop window, so it stays null and the calls needing one
* refuse rather than guess which request the user is looking at.
*/
private context(): PluginContext {
return { id: "web", label: null, workspaceId: null };
}
/**
* Every addition here is a capability decision, which is why they are written
* out one at a time instead of forwarded wholesale.
*/
private async hostRequest(envelope: string): Promise<string> {
const { pluginRefId, payload } = JSON.parse(envelope) as {
pluginRefId: string;
context: PluginContext;
payload: InternalEventPayload;
};
const reply = async (): Promise<InternalEventPayload> => {
switch (payload.type) {
case "get_key_value_request": {
const value = await this.db.rpc<string | null>("web_plugin_kv_get", {
pluginName: pluginRefId,
key: (payload as unknown as KeyValueRequest).key,
});
return { type: "get_key_value_response", value } as InternalEventPayload;
}
case "set_key_value_request": {
const { key, value } = payload as unknown as { key: string; value: string };
await this.db.rpc("web_plugin_kv_set", {
pluginName: pluginRefId,
key,
value,
});
return { type: "set_key_value_response" } as InternalEventPayload;
}
case "delete_key_value_request": {
const deleted = await this.db.rpc<boolean>("web_plugin_kv_delete", {
pluginName: pluginRefId,
key: (payload as unknown as KeyValueRequest).key,
});
return { type: "delete_key_value_response", deleted } as InternalEventPayload;
}
case "show_toast_request": {
const { type: _type, ...toast } = payload;
this.db.deliver("show_toast", toast);
return { type: "empty_response" };
}
default:
throw new Error(
`\`${payload.type}\` isn't something a plugin can do when Yaak runs in a browser yet`,
);
}
};
try {
return JSON.stringify(await reply());
} catch (err) {
return JSON.stringify({
type: "error_response",
error: err instanceof Error ? err.message : String(err),
});
}
}
}
+1 -12
View File
@@ -16,15 +16,6 @@ export type ToWorker =
* async in the engine (rendering is), where every `rpc` command is not.
*/
| { type: "prepare_http_send"; id: number; payload: unknown }
/** Async for the same reason `prepare_http_send` is: it can call a plugin. */
| { type: "render_template"; id: number; payload: unknown }
/** The tab's answer to a `template_function` call. */
| {
type: "template_function_result";
id: number;
value?: string;
error?: string;
}
| { type: "blob_get"; id: number; blobId: string }
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
| { type: "blob_delete"; id: number; blobId: string }
@@ -47,9 +38,7 @@ 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 }
/** The one message that runs the other way: the engine asking for a plugin. */
| { type: "template_function"; id: number; name: string; args: string };
| { type: "event"; event: string; payload: unknown };
/** What the worker registers itself under. Tabs on one origin share it. */
export const WORKER_NAME = "yaak-db";
File diff suppressed because one or more lines are too long
+4 -56
View File
@@ -33,8 +33,7 @@ import type {
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection";
import type { WebPlugins } from "./plugins";
import { readFrames, serverIdentity, serverSendUrl } from "./server";
import { serverIdentity, serverSendUrl, readFrames } from "./server";
/* -------------------------------- shapes --------------------------------- */
@@ -52,59 +51,11 @@ type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
interface PreparedHttpSend {
request: HttpRequest;
/** Hashed id of the model the auth came from; plugins key stored state on it. */
authContextId: string;
settings: HttpSendSettings;
settingEvents: HttpResponseEventData[];
cookieJar: CookieJar | null;
}
/**
* The desktop applies auth to the sendable request; here the server builds that,
* so the plugin's answer goes onto the model and the server folds it in. Same
* bytes for a method that sets a header, which is every one that runs here.
*
* Not the same for one that *signs*, since the plugin sees the request before
* the server assembles it. AWS SigV4 and OAuth 1.0 are refused rather than
* mis-signed; see the sandbox README.
*/
async function applyAuthentication(
plugins: WebPlugins,
prepared: PreparedHttpSend,
): Promise<HttpRequest> {
const { request } = prepared;
const authType = request.authenticationType;
const disabled = request.authentication?.disabled === true;
if (authType == null || authType === "none" || disabled) return request;
const applied = await plugins.applyHttpAuthentication(authType, {
contextId: prepared.authContextId,
values: request.authentication as Record<string, never>,
method: request.method,
url: request.url,
headers: request.headers.filter((h) => h.enabled !== false),
// Only signing schemes hash the body, and those are already refused.
body: null,
});
const headers = [...request.headers];
for (const header of applied.setHeaders ?? []) {
// Replace-or-append, case-insensitively, matching `insert_header` in
// crates/yaak-http.
const at = headers.findIndex((h) => h.name.toLowerCase() === header.name.toLowerCase());
const entry = { name: header.name, value: header.value, enabled: true };
if (at >= 0) headers[at] = { ...headers[at], ...entry };
else headers.push(entry);
}
const urlParameters = [...request.urlParameters];
for (const param of applied.setQueryParameters ?? []) {
urlParameters.push({ name: param.name, value: param.value, enabled: true });
}
return { ...request, headers, urlParameters };
}
/** The desktop writes progress at most this often while a body streams in. */
const PROGRESS_INTERVAL_MS = 100;
@@ -112,7 +63,6 @@ const PROGRESS_INTERVAL_MS = 100;
export async function sendHttpRequest(
db: WorkerConnection,
plugins: WebPlugins,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
@@ -128,7 +78,7 @@ export async function sendHttpRequest(
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
try {
await runSend(db, plugins, response, requestId, environmentId, cookieJarId, cancel.signal);
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
} catch (err) {
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
await response.finish({ error: message });
@@ -140,7 +90,6 @@ export async function sendHttpRequest(
async function runSend(
db: WorkerConnection,
plugins: WebPlugins,
response: ResponseWriter,
requestId: string,
environmentId: string | null,
@@ -152,8 +101,7 @@ async function runSend(
environmentId,
cookieJarId,
});
const request = await applyAuthentication(plugins, prepared);
await response.patch({ url: request.url });
await response.patch({ url: prepared.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
@@ -163,7 +111,7 @@ async function runSend(
timeline.push(prepared.settingEvents);
const body: SendRequest = {
request,
request: prepared.request,
settings: prepared.settings,
cookies: prepared.cookieJar?.cookies ?? null,
};
+2 -32
View File
@@ -108,36 +108,12 @@ function bootOnce(): Promise<void> {
return booted;
}
/**
* Rendering happens here; the functions it calls live in a sandbox the tab
* owns. Asked of the port that started the render, not every port, because
* only that tab is waiting and only its sandbox has those plugins.
*/
const pendingTemplateFunctions = new Map<number, (result: string | Error) => void>();
let nextTemplateFunctionId = 1;
function templateBridge(port: MessagePort): (name: string, args: string) => Promise<string> {
return (name, args) =>
new Promise<string>((resolve, reject) => {
const id = nextTemplateFunctionId++;
pendingTemplateFunctions.set(id, (r) => (r instanceof Error ? reject(r) : resolve(r)));
send(port, { type: "template_function", id, name, args });
});
}
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
if (message.type === "goodbye") {
ports.delete(port);
return;
}
if (message.type === "template_function_result") {
const settle = pendingTemplateFunctions.get(message.id);
pendingTemplateFunctions.delete(message.id);
settle?.(message.error != null ? new Error(message.error) : (message.value ?? ""));
return;
}
// Every command waits for boot rather than the tab having to. Tabs post
// the moment they load; the port queues; this drains once the DB is open.
try {
@@ -146,8 +122,7 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
return;
}
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send, render_template } =
engine!;
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
try {
switch (message.type) {
@@ -168,15 +143,10 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
return;
}
case "prepare_http_send": {
const prepared = await prepare_http_send(message.payload, templateBridge(port));
const prepared = await prepare_http_send(message.payload);
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) {
+473 -57
View File
@@ -2,36 +2,72 @@ import console from "node:console";
import { type Stats, statSync, watch } from "node:fs";
import path from "node:path";
import type {
CallPromptFormDynamicArgs,
Context,
DynamicPromptFormArg,
PluginDefinition,
} from "@yaakapp/api";
import {
createPluginContext,
type PluginTransport,
} from "@yaakapp-internal/lib/pluginContext";
import {
applyDynamicFormInput,
migrateTemplateFunctionSelectOptions,
stripDynamicCallbacks,
} from "@yaakapp-internal/lib/pluginForms";
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
} from "@yaakapp-internal/lib/templateFunction";
import type {
BootRequest,
DeleteKeyValueResponse,
DeleteModelResponse,
FindHttpResponsesResponse,
Folder,
FormInput,
GetCookieValueRequest,
GetCookieValueResponse,
GetHttpRequestByIdResponse,
GetHttpResponseBodyInfoResponse,
GetKeyValueResponse,
GrpcRequestAction,
HttpAuthenticationAction,
HttpRequest,
HttpRequestAction,
HttpResponse,
ImportResources,
InternalEvent,
InternalEventPayload,
ListCookieNamesResponse,
ListFoldersResponse,
ListHttpRequestsRequest,
ListHttpRequestsResponse,
ListOpenWorkspacesResponse,
PluginContext,
PromptFormResponse,
PromptTextResponse,
ReadHttpResponseBodyChunkResponse,
RenderGrpcRequestResponse,
RenderHttpRequestResponse,
SendHttpRequestResponse,
TemplateFunction,
TemplateRenderRequest,
TemplateRenderResponse,
UpsertModelResponse,
WindowInfoResponse,
} from "@yaakapp-internal/plugins";
import { applyDynamicFormInput } from "./common";
import { EventChannel } from "./EventChannel";
import { migrateTemplateFunctionSelectOptions } from "./migrations";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
/**
* A response as a plugin should see it.
*
* The host still puts `bodyPath` on the wire for its own callers, but it names
* a file on the host's disk — meaningless to a plugin, absent once bodies move
* off the filesystem, and impossible in a browser. Plugins address bodies by
* response id, so drop it here rather than let one grow a dependency on it.
*/
function forPlugin(httpResponse: HttpResponse): HttpResponse {
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
bodyPath?: string | null;
};
return rest;
}
export interface PluginWorkerData {
bootRequest: BootRequest;
@@ -593,64 +629,444 @@ export class PluginInstance {
this.#sendEvent(eventToSend);
}
/**
* How a plugin reaches the app from this runtime.
*
* Every request is an event whose reply is matched by id. This runtime can
* hold a conversation open, so it supplies `stream` and `form`: a window
* reports navigation until it closes, and a prompt form re-renders as values
* change. `ctx` itself is built from these in @yaakapp-internal/lib, the same
* way the sandbox runtime builds it.
*/
#transport: PluginTransport = {
request: (context, payload) => this.#sendForReply(context, payload),
#newCtx(context: PluginContext): Context {
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
const storedBody = async (responseId: string) => {
const bodyInfo = () =>
this.#sendForReply<GetHttpResponseBodyInfoResponse>(context, {
type: "get_http_response_body_info_request",
responseId,
});
const info = await bodyInfo();
notify: (context, payload) => {
this.#sendPayload(context, payload, null);
},
return createResponseBody(
{
responseId,
contentLength: info.contentLength,
contentType: info.contentType ?? null,
complete: info.complete,
},
async (offset, length) => {
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
context,
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
);
return decodeBase64Chunk(chunk.data);
},
{ refresh: bodyInfo },
);
};
stream: (context, payload, onReply) => {
this.#sendAndListenForEvents(context, payload, onReply);
},
const _windowInfo = async () => {
if (context.label == null) {
throw new Error("Can't get window context without an active window");
}
const payload: InternalEventPayload = {
type: "window_info_request",
label: context.label,
};
form: (context, payload, onChange) => {
// Built by hand so the event id is available: intermediate re-renders
// reply to the original request rather than starting a new one.
const eventToSend = this.#buildEventToSend(context, payload, null);
return this.#sendForReply<WindowInfoResponse>(context, payload);
};
return new Promise<PromptFormResponse>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId !== eventToSend.id) return;
if (event.payload.type !== "prompt_form_response") return;
return {
clipboard: {
copyText: async (text) => {
await this.#sendForReply(context, {
type: "copy_text_request",
text,
});
},
},
toast: {
show: async (args) => {
await this.#sendForReply(context, {
type: "show_toast_request",
// Handle default here because null/undefined both convert to None in Rust translation
timeout: args.timeout === undefined ? 5000 : args.timeout,
...args,
});
},
},
window: {
requestId: async () => {
return (await _windowInfo()).requestId;
},
async workspaceId(): Promise<string | null> {
return (await _windowInfo()).workspaceId;
},
async environmentId(): Promise<string | null> {
return (await _windowInfo()).environmentId;
},
openUrl: async ({ onNavigate, onClose, ...args }) => {
args.label = args.label || `${Math.random()}`;
const payload: InternalEventPayload = { type: "open_window_request", ...args };
const onEvent = (event: InternalEventPayload) => {
if (event.type === "window_navigate_event") {
onNavigate?.(event);
} else if (event.type === "window_close_event") {
onClose?.();
}
};
this.#sendAndListenForEvents(context, payload, onEvent);
return {
close: () => {
const closePayload: InternalEventPayload = {
type: "close_window_request",
label: args.label,
};
this.#sendPayload(context, closePayload, null);
},
};
},
openExternalUrl: async (url) => {
await this.#sendForReply(context, {
type: "open_external_url_request",
url,
});
},
},
prompt: {
text: async (args) => {
const reply: PromptTextResponse = await this.#sendForReply(context, {
type: "prompt_text_request",
...args,
});
return reply.value;
},
form: async (args) => {
// Resolve dynamic callbacks on initial inputs using default values
const defaults = applyFormInputDefaults(args.inputs, {});
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
const resolvedInputs = await applyDynamicFormInput(
this.#newCtx(context),
args.inputs,
callArgs,
);
const strippedInputs = stripDynamicCallbacks(resolvedInputs);
const { done, values } = event.payload as PromptFormResponse;
if (done) {
this.#appToPluginEvents.unlisten(cb);
resolve({ values } as PromptFormResponse);
return;
// Build the event manually so we can get the event ID for keying
const eventToSend = this.#buildEventToSend(
context,
{ type: "prompt_form_request", ...args, inputs: strippedInputs },
null,
);
// Store original inputs (with dynamic callbacks) for later resolution
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
const reply = await new Promise<PromptFormResponse>((resolve) => {
const cb = (event: InternalEvent) => {
if (event.replyId !== eventToSend.id) return;
if (event.payload.type === "prompt_form_response") {
const { done, values } = event.payload as PromptFormResponse;
if (done) {
// Final response — resolve the promise and clean up
this.#appToPluginEvents.unlisten(cb);
this.#pendingDynamicForms.delete(eventToSend.id);
resolve({ values } as PromptFormResponse);
} else {
// Intermediate value change — resolve dynamic inputs and send back
// Skip empty values (fired on initial mount before user interaction)
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
if (storedInputs && values && Object.keys(values).length > 0) {
const ctx = this.#newCtx(context);
const callArgs: CallPromptFormDynamicArgs = { values };
applyDynamicFormInput(ctx, storedInputs, callArgs)
.then((resolvedInputs) => {
const stripped = stripDynamicCallbacks(resolvedInputs);
this.#sendPayload(
context,
{ type: "prompt_form_request", ...args, inputs: stripped },
eventToSend.id,
);
})
.catch((err) => {
console.error("Failed to resolve dynamic form inputs", err);
});
}
}
}
};
this.#appToPluginEvents.listen(cb);
// Send the initial event after we start listening (to prevent race)
this.#sendEvent(eventToSend);
});
return reply.values;
},
},
httpResponse: {
find: async (args) => {
const payload = {
type: "find_http_responses_request",
...args,
} as const;
const { httpResponses } = await this.#sendForReply<FindHttpResponsesResponse>(
context,
payload,
);
return httpResponses.map(forPlugin);
},
body: ({ responseId }) => storedBody(responseId),
},
grpcRequest: {
render: async (args) => {
const payload = {
type: "render_grpc_request_request",
...args,
} as const;
const { grpcRequest } = await this.#sendForReply<RenderGrpcRequestResponse>(
context,
payload,
);
return grpcRequest;
},
},
httpRequest: {
getById: async (args) => {
const payload = {
type: "get_http_request_by_id_request",
...args,
} as const;
const { httpRequest } = await this.#sendForReply<GetHttpRequestByIdResponse>(
context,
payload,
);
return httpRequest;
},
send: async (args) => {
const payload = {
type: "send_http_request_request",
...args,
} as const;
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
context,
payload,
);
// A send with no request behind it saves nothing, so the reply
// carries the only copy of its body. A saved one is read back from
// the host like any other. Callers get the same thing either way.
if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
}
onChange(values ?? {})
.then((next) => {
if (next != null) this.#sendPayload(context, next, eventToSend.id);
})
.catch((err: unknown) => {
console.error("Failed to resolve dynamic form inputs", err);
});
};
this.#appToPluginEvents.listen(cb);
// Sent after the listener is attached, to prevent a race.
this.#sendEvent(eventToSend);
});
},
};
#newCtx(context: PluginContext): Context {
return createPluginContext(this.#transport, context);
const bytes = decodeBase64Chunk(body);
return {
httpResponse: forPlugin(httpResponse),
body: createResponseBody(
{
responseId: httpResponse.id,
contentLength: bytes.byteLength,
contentType:
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
?.value ?? null,
// The host waited for the whole send before replying.
complete: true,
},
async (offset, length) => bytes.slice(offset, offset + length),
),
};
},
render: async (args) => {
const payload = {
type: "render_http_request_request",
...args,
} as const;
const { httpRequest } = await this.#sendForReply<RenderHttpRequestResponse>(
context,
payload,
);
return httpRequest;
},
list: async (args?: { folderId?: string }) => {
const payload: InternalEventPayload = {
type: "list_http_requests_request",
folderId: args?.folderId,
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
const { httpRequests } = await this.#sendForReply<ListHttpRequestsResponse>(
context,
payload,
);
return httpRequests;
},
create: async (args) => {
const payload = {
type: "upsert_model_request",
model: {
name: "",
method: "GET",
...args,
id: "",
model: "http_request",
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as HttpRequest;
},
update: async (args) => {
const payload = {
type: "upsert_model_request",
model: {
model: "http_request",
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as HttpRequest;
},
delete: async (args) => {
const payload = {
type: "delete_model_request",
model: "http_request",
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as HttpRequest;
},
},
folder: {
list: async () => {
const payload = { type: "list_folders_request" } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders;
},
getById: async (args: { id: string }) => {
const payload = { type: "list_folders_request" } as const;
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
return folders.find((f) => f.id === args.id) ?? null;
},
create: async ({ name, ...args }) => {
const payload = {
type: "upsert_model_request",
model: {
...args,
name: name ?? "",
id: "",
model: "folder",
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
update: async (args) => {
const payload = {
type: "upsert_model_request",
model: {
model: "folder",
...args,
},
} as InternalEventPayload;
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
return response.model as Folder;
},
delete: async (args: { id: string }) => {
const payload = {
type: "delete_model_request",
model: "folder",
id: args.id,
} as InternalEventPayload;
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
return response.model as Folder;
},
},
cookies: {
getValue: async (args: GetCookieValueRequest) => {
const payload = {
type: "get_cookie_value_request",
...args,
} as const;
const { value } = await this.#sendForReply<GetCookieValueResponse>(context, payload);
return value;
},
listNames: async () => {
const payload = { type: "list_cookie_names_request" } as const;
const { names } = await this.#sendForReply<ListCookieNamesResponse>(context, payload);
return names;
},
},
templates: {
/**
* Invoke Yaak's template engine to render a value. If the value is a nested type
* (eg. object), it will be recursively rendered.
*/
render: async (args: TemplateRenderRequest) => {
const payload = { type: "template_render_request", ...args } as const;
const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);
// oxlint-disable-next-line no-explicit-any -- That's okay
return result.data as any;
},
},
store: {
get: async <T>(key: string) => {
const payload = { type: "get_key_value_request", key } as const;
const result = await this.#sendForReply<GetKeyValueResponse>(context, payload);
return result.value ? (JSON.parse(result.value) as T) : undefined;
},
set: async <T>(key: string, value: T) => {
const valueStr = JSON.stringify(value);
const payload: InternalEventPayload = {
type: "set_key_value_request",
key,
value: valueStr,
};
await this.#sendForReply<GetKeyValueResponse>(context, payload);
},
delete: async (key: string) => {
const payload = { type: "delete_key_value_request", key } as const;
const result = await this.#sendForReply<DeleteKeyValueResponse>(context, payload);
return result.deleted;
},
},
plugin: {
reload: () => {
this.#sendPayload(context, { type: "reload_response", silent: true }, null);
},
},
workspace: {
list: async () => {
const payload = {
type: "list_open_workspaces_request",
} as InternalEventPayload;
const response = await this.#sendForReply<ListOpenWorkspacesResponse>(context, payload);
return response.workspaces.map((w) => {
// Internal workspace info includes label field not in public API
type WorkspaceInfoInternal = typeof w & { label?: string };
return {
id: w.id,
name: w.name,
// Hide label from plugin authors, but keep it for internal routing
_label: (w as WorkspaceInfoInternal).label as string,
};
});
},
withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {
// Create a new context with the workspace's window label
const newContext: PluginContext = {
...context,
label: workspaceHandle._label || null,
workspaceId: workspaceHandle.id,
};
return this.#newCtx(newContext);
},
},
};
}
}
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";
@@ -4,12 +4,10 @@ import type {
DynamicAuthenticationArg,
DynamicPromptFormArg,
DynamicTemplateFunctionArg,
TemplateFunctionPlugin,
} from "@yaakapp/api";
import type {
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
FormInput,
} from "@yaakapp-internal/plugins";
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
@@ -75,33 +73,3 @@ export async function applyDynamicFormInput(
}
return resolvedArgs;
}
/** What a host receives has to be data all the way down. */
export function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
return inputs.map((input) => {
// oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type
const { dynamic: _dynamic, ...rest } = input as any;
if ("inputs" in rest && Array.isArray(rest.inputs)) {
rest.inputs = stripDynamicCallbacks(rest.inputs);
}
return rest as FormInput;
});
}
/** Select options used to carry `name` where they now carry `label`. */
export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin,
): TemplateFunctionPlugin {
const migratedArgs = f.args.map((a) => {
if (a.type === "select") {
type LegacyOption = { label?: string; value: string; name?: string };
a.options = a.options.map((o) => {
const legacy = o as LegacyOption;
return { label: legacy.label ?? legacy.name ?? "", value: legacy.value };
});
}
return a;
});
return { ...f, args: migratedArgs };
}
+22
View File
@@ -0,0 +1,22 @@
import type { TemplateFunctionPlugin } from "@yaakapp/api";
export function migrateTemplateFunctionSelectOptions(
f: TemplateFunctionPlugin,
): TemplateFunctionPlugin {
const migratedArgs = f.args.map((a) => {
if (a.type === "select") {
// Migrate old options that had 'name' instead of 'label'
type LegacyOption = { label?: string; value: string; name?: string };
a.options = a.options.map((o) => {
const legacy = o as LegacyOption;
return {
label: legacy.label ?? legacy.name ?? "",
value: legacy.value,
};
});
}
return a;
});
return { ...f, args: migratedArgs };
}
-275
View File
@@ -1,275 +0,0 @@
# The Yaak plugin sandbox
A QuickJS interpreter, a small set of globals, and one function that calls the
host. That is the whole runtime. Everything else a plugin does — read a request,
send one, store a token, ask the user something — is a message the host chose to
answer.
This document is the contract. It is written to be implementable twice: once
here, in wasm, for the browser, and once in Rust with `rquickjs`, for the desktop
and the CLI. **If the two hosts disagree about anything below, that is a bug in
whichever one drifted, not a platform difference to work around.** The promise
to plugin authors is that there is one sandbox and it behaves the same
everywhere; a promise like that is only worth making if it is enforceable, which
is why the browser runs QuickJS rather than the Worker's own JavaScript engine.
## The engine
**quickjs-ng**, and only quickjs-ng.
There is no real choice: `rquickjs` — the Rust binding the desktop host will use
— vendors quickjs-ng as a git submodule and offers no alternative. Picking
Bellard's upstream for the browser would mean the two hosts run different
engines, which is exactly the thing this design exists to prevent.
| | Version | Notes |
|---|---|---|
| Browser (this package) | quickjs-ng **0.12.1** | via `@jitl/quickjs-ng-wasmfile-release-sync` 0.32.0 |
| Desktop (planned) | quickjs-ng **0.15.1** | via `rquickjs` 0.12.2 |
**The version skew is a known gap, and closing it is slice-2 work.** Three minor
versions is small — the differences are bug fixes and `Temporal` progress, not
semantics anything here depends on — but "identical everywhere" is not a claim
that survives being approximate indefinitely. Whoever builds the Rust host
should pin both sides to the same tag and add a test that asserts the version
string matches.
### Why the sync build, not ASYNCIFY
`quickjs-emscripten` ships an ASYNCIFY variant that lets guest code call an async
host function *synchronously*. We use the plain sync build instead:
- ASYNCIFY is about twice the wasm size (1.08 MB vs 529 KB) and, measured,
**2.2x slower**.
- It can only suspend for one host call at a time. A runtime that runs several
plugins would have to hold one wasm instance per in-flight call.
- We do not need it. The guest gets real `await` anyway: a host function returns
a QuickJS deferred promise, the host resolves it, and the host drains the job
queue. `ctx.store.get(...)` is an ordinary `await` inside a plugin.
The only thing lost is a host call that *looks* synchronous to the guest, and no
Yaak plugin wants one — the whole `ctx` API has been async since it existed.
## What exists inside the sandbox
QuickJS gives you the language and nothing else. Everything below is either
installed by `src/guest/globals.ts` or absent. **Both hosts must install exactly
this list.**
### From the engine
`Object`, `Array`, `Function`, `String`, `Number`, `Boolean`, `Symbol`, `Math`,
`JSON`, `Date`, `RegExp`, `Error` and subclasses, `Map`, `Set`, `WeakMap`,
`WeakSet`, `WeakRef`, `Promise`, `Proxy`, `Reflect`, `BigInt`, `ArrayBuffer`,
`SharedArrayBuffer`, `DataView`, all `TypedArray`s, `globalThis`,
`queueMicrotask`, `performance`.
Language level is ES2023 plus most of ES2024 — `Object.groupBy`,
`Array.prototype.at`, `String.prototype.replaceAll`, async generators, private
fields, `??=` all work.
### Installed by the runtime
| Global | Notes |
|---|---|
| `console` | `.log/.info/.warn/.error/.debug/.trace`. Arguments are formatted to a string **inside** the sandbox, so only strings cross out — a cycle or an exotic prototype is the guest's problem, not the host's. |
| `setTimeout` / `clearTimeout` | The host holds the real timer; QuickJS has no clock to wake on. A sandbox torn down mid-wait takes its pending timers with it. |
| `TextEncoder` / `TextDecoder` | UTF-8 only. Pure JavaScript, in-sandbox — a bridge would cost a copy each way. Lone surrogates encode to U+FFFD, matching the standard. |
| `btoa` / `atob` | Latin-1, same narrow contract as the browser's. |
### Deliberately absent
`fetch`, `XMLHttpRequest`, `WebSocket`, `crypto`, `structuredClone`, `URL`,
`URLSearchParams`, `setInterval`, `require`, `module`, `process`, `Buffer`,
`std`, `os`, and every Node built-in.
- **Network and storage are absent because they are `ctx`'s job.** A plugin that
could open its own socket would defeat the point of the sandbox and would not
work in a browser anyway.
- **`setInterval` is absent** because an interval is a timer that rearms and
nothing in a plugin should be polling. Build one from `setTimeout`, visibly.
- **`crypto` is absent, and this is the one real gap.** The decided direction is
pure-JavaScript `@noble/*` inside the sandbox: audited, dependency-free,
identical on both hosts, no host API to keep in sync. A `yaak.crypto` builtin
is the escape hatch **if** a hot path is measured, not before. Concretely,
`template-function-uuid` does not run in the sandbox today because its `uuid`
dependency reaches for `node:crypto`; that is a slice-2 conversion, not a
missing capability.
- **`URL` is absent** only because nothing has needed it yet. It is a reasonable
future addition; it must be added to both hosts together.
## The module contract
A module arrives as **source text**, not a file — there is no filesystem, and in
a browser there could not be one.
It is evaluated as CommonJS, via `new Function("module", "exports", "require", source)`,
and must assign `module.exports.plugin` (or `module.exports.default`). `new
Function` rather than an ES module is deliberate: the bundle's top-level names
cannot collide with the shell's, and the source needs no loader hook.
`require` exists **only to throw**, naming the specifier. A bundle that still
calls it was not bundled for this target, and saying which module is missing
beats an `undefined` that surfaces ten frames later.
Bundling requirements: CommonJS, no external modules, no Node built-ins, ES2022.
`scripts/bundle-sandbox-plugins.mjs` does this today; what a real
`yaakcli build --target sandbox` needs is listed at the bottom of that file.
## The host interface
Four functions, installed on `globalThis` before any plugin code runs. A Rust
host must expose the same four with the same names and shapes.
| Function | Direction | Shape |
|---|---|---|
| `__yaak_call(envelopeJson)` | guest → host | Returns a **promise** of the reply JSON. The one door out. |
| `__yaak_log(level, message)` | guest → host | Both strings. Fire and forget. |
| `__yaak_timer_start(id, ms)` | guest → host | Host calls `__yaak_guest.fireTimer(id)` when due. |
| `__yaak_timer_cancel(id)` | guest → host | |
And the guest exposes `globalThis.__yaak_guest`:
| Method | Shape |
|---|---|
| `load(source, pluginRefId)` | Evaluate a module. Throws if it exports no `plugin`. |
| `summary()` | What the module contributes, as plain data. |
| `dispatch(envelopeJson)` | Returns a promise of the reply payload JSON. |
| `fireTimer(id)` | |
### Envelopes
Both directions carry `InternalEventPayload` from
`crates/yaak-plugins/src/events.rs`, **unchanged**. That is what makes a plugin
unable to tell which runtime it is in.
```jsonc
// dispatch, host → guest
{ "context": { "id": "...", "label": null, "workspaceId": "..." },
"payload": { "type": "call_template_function_request", "name": "...", "args": { ... } } }
// __yaak_call, guest → host
{ "pluginRefId": "auth-bearer",
"context": { ... },
"payload": { "type": "get_key_value_request", "key": "token" } }
```
`pluginRefId` rides on outgoing calls because one host handler serves every
loaded module, and a plugin's stored state is namespaced by which plugin it is —
the same namespacing `build_shared_reply` does in `crates/yaak/src/plugin_events.rs`.
A throw inside a plugin becomes `{"type":"error_response","error":"..."}`, never
a crash and never silence: whatever asked gets a message.
## The `ctx` API
Built entirely out of `__yaak_call`. See `src/guest/context.ts` — it is the same
surface the Node runtime's `PluginInstance` builds, so it is not repeated here.
What differs is which calls a **host** answers. The browser host answers a
deliberately short list (`packages/platform/src/web/plugins.ts`) and refuses the
rest by name. Refusing by name matters: a plugin that needs something it cannot
have should fail with a sentence someone can act on.
Answered in the browser today: `get_key_value`, `set_key_value`,
`delete_key_value`, `show_toast`. Everything else — sends, model reads and
writes, prompts, response bodies, window info — refuses. Those are capability
decisions, not oversights, and each should be added one at a time.
`ctx.window.openUrl` throws in *every* sandbox host: a plugin-opened window is a
desktop affordance with no browser equivalent, and handing back a handle whose
`close()` does nothing would be worse.
## Isolation and limits
One runtime per worker, **one context per module**. A context is the isolation
boundary — its own globals, its own `Object`, its own prototypes — so two plugins
cannot see or patch each other. Sharing the runtime is deliberate: the engine and
its wasm instance are the expensive part; contexts are not.
| Limit | Value | Why |
|---|---|---|
| Memory | 256 MB per runtime | Sized for an importer holding a large document and the objects it parses into. |
| Stack | 2 MB | Deep recursion becomes a guest stack overflow, not a worker crash. |
| Synchronous execution | 60 s | A watchdog for `while (true)`, **not** a limit on real work. |
The watchdog bounds *synchronous* execution only. A plugin awaiting the host is
not looping, so the clock stops for the duration of a host call and restarts
when the guest resumes. It is generous because it costs nothing to be: plugins
run in their own worker, so one stuck there blocks no database command and no
frame. It is sized off the slowest real work measured — GitHub's 12.3 MB OpenAPI
description takes about 2.5 s (`bench/import.mjs`) — with room for a document
several times larger before a legitimate import looks like a hang.
## Where the sandbox runs, and why not in the database worker
In the browser: a **dedicated worker owned by the tab**, separate from the
SharedWorker that owns the database.
- Plugin work is slow by design, and the database worker answers every tab's
commands synchronously. A large import in there would stall every other tab's
reads.
- A plugin that never returns can be ended with `terminate()`. You cannot do
that to the worker holding the database.
- The capabilities plugins actually ask for — a prompt, a toast, the active
request — belong to a tab, not to a database. Routing through the tab is the
shorter path, not a detour.
The cost is that `ctx.store` goes worker → tab → database worker. It is a message
either way, and this is the direction where a stuck plugin costs nothing.
Template rendering is the one flow that runs backwards: rendering happens in the
engine, in the database worker, but the functions it calls live here. So the
engine is handed a callback that asks the tab, which asks the sandbox. See
`templateBridge` in `packages/platform/src/web/worker.ts`.
## Plugins versus scripts
The shell is **not plugin-shaped underneath**. `load` takes source; `dispatch`
takes an event. What a module *is* — a plugin today, a workspace script later —
is decided by the payloads the host sends, not by the runtime.
That matters for one reason. A plugin is installed, so someone consented to it,
and a plugin may one day escalate to a full Node runtime by asking. **A script
arrives inside a workspace — as data, through an import, a git sync, a shared
repository — with no consent moment at all.** So scripts get this sandbox and
only this sandbox, forever, regardless of feature pressure. Any capability added
below must be evaluated against the script case, which is the stricter one:
"would I want this to run because someone opened a workspace a stranger sent
them?"
Expected differences when scripts arrive, none of them built yet:
- A different payload set (`run_script_request` and friends) — same envelope.
- A tighter host-call allowlist. A script should probably not reach `ctx.store`
at all, and certainly not another plugin's namespace.
- A much shorter watchdog. A pre-request script that runs for a minute is broken;
an importer that does is working.
## Performance
QuickJS is an interpreter with no JIT. Measured on GitHub's 12.3 MB OpenAPI
description (1220 requests imported, **identical output** in both engines):
| | First run | Best of 6 |
|---|---|---|
| Node (V8) | 304 ms | 164 ms |
| QuickJS sandbox | 2503 ms | 2017 ms |
That is **8x on the first run** and about **12x once V8 has compiled** — well
inside the 1050x folklore, and the first-run number is the one a user waits for
because an import happens once. Reproduce with:
```bash
node packages/plugin-sandbox/bench/import.mjs <spec.json> 6
```
**Conclusion: importers stay in the sandbox.** 2.5 s in a worker, behind a
progress state, for the largest public API description that exists, is a fine
trade for one runtime everywhere. Revisit if a real document is measured
materially worse — the escape hatch is a host builtin for the hot path, not a
second runtime.
Boot cost is small: about 80140 ms to instantiate the wasm and load a plugin,
paid once and lazily, so a session that never touches a plugin never pays it.
The wasm is 529 KB, next to the 4.3 MB SQLite one.
-129
View File
@@ -1,129 +0,0 @@
/**
* How much slower is an importer inside the sandbox? Yaak's OpenAPI importer is
* first-party JavaScript, so a large spec is parsed by whatever engine the
* runtime uses. Numbers are in the README.
*
* node packages/plugin-sandbox/bench/import.mjs <spec.json> [iterations]
*/
import { build } from "esbuild";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { pathToFileURL } from "node:url";
import { bundlePlugin } from "../../../scripts/bundle-sandbox-plugins.mjs";
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const PLUGIN = "importer-openapi";
const specPath = process.argv[2];
const iterations = Number(process.argv[3] ?? 3);
if (specPath == null) {
console.error("usage: node bench/import.mjs <spec.json> [iterations]");
process.exit(1);
}
const spec = readFileSync(specPath, "utf8");
console.log(`Spec: ${specPath} (${(spec.length / 1024 / 1024).toFixed(1)} MB)`);
console.log(`Iterations: ${iterations}\n`);
async function loadHost() {
const outDir = join(root, "node_modules", ".cache", "yaak-plugin-sandbox");
mkdirSync(outDir, { recursive: true });
const outfile = join(outDir, "host.mjs");
await build({
entryPoints: [join(root, "packages/plugin-sandbox/src/host/sandbox.ts")],
bundle: true,
format: "esm",
platform: "node",
target: "node22",
outfile,
// Resolved from the repo at run time, so the wasm variant is the real one.
external: ["@jitl/*", "quickjs-emscripten-core"],
});
return import(pathToFileURL(outfile).href);
}
const ctxStub = { id: "bench", label: null, workspaceId: "wk_bench" };
function stats(times) {
const sorted = [...times].sort((a, b) => a - b);
const mean = times.reduce((a, b) => a + b, 0) / times.length;
return { min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], mean };
}
function report(label, times, resourceCount) {
const { min, median } = stats(times);
console.log(
`${label.padEnd(20)} first ${times[0].toFixed(0).padStart(5)} ms ` +
`best ${min.toFixed(0).padStart(5)} ms ` +
`median ${median.toFixed(0).padStart(5)} ms (${resourceCount} requests)`,
);
// The spread is the point: V8 compiles this across the first few passes and
// QuickJS does not compile at all.
console.log(`${" ".repeat(20)} runs: ${times.map((t) => t.toFixed(0)).join(", ")} ms`);
return { first: times[0], best: min };
}
/* --------------------------------- Node ---------------------------------- */
const nodeTimes = [];
let nodeCount = 0;
{
const { createRequire } = await import("node:module");
const require = createRequire(join(root, "package.json"));
const mod = require(join(root, "plugins", PLUGIN, "build", "index.js"));
const plugin = mod.plugin ?? mod.default;
for (let i = 0; i < iterations; i++) {
const started = performance.now();
const result = await plugin.importer.onImport(ctxStub, { text: spec });
nodeTimes.push(performance.now() - started);
nodeCount = result?.resources?.httpRequests?.length ?? 0;
}
}
const node = report("Node (V8)", nodeTimes, nodeCount);
/* -------------------------------- QuickJS -------------------------------- */
const quickTimes = [];
let quickCount = 0;
{
const { PluginSandboxHost } = await loadHost();
const source = await bundlePlugin(PLUGIN);
const host = new PluginSandboxHost(
async () => JSON.stringify({ type: "empty_response" }),
(log) => console.error(`[${log.level}] ${log.message}`),
);
const loadStarted = performance.now();
await host.load(PLUGIN, source);
console.log(`(sandbox boot + load: ${(performance.now() - loadStarted).toFixed(0)} ms)\n`);
for (let i = 0; i < iterations; i++) {
const started = performance.now();
const reply = JSON.parse(
await host.dispatch(
PLUGIN,
JSON.stringify({ context: ctxStub, payload: { type: "import_request", content: spec } }),
),
);
quickTimes.push(performance.now() - started);
if (reply.type === "error_response") throw new Error(reply.error);
quickCount = reply.resources?.httpRequests?.length ?? 0;
}
host.dispose();
}
const quick = report("QuickJS (sandbox)", quickTimes, quickCount);
console.log(
`\nFirst run (what a user waits for): ${(quick.first / 1000).toFixed(1)}s in the sandbox ` +
`vs ${(node.first / 1000).toFixed(1)}s in Node — ${(quick.first / node.first).toFixed(1)}x.`,
);
console.log(
`Best run (both warm): ${(quick.best / node.best).toFixed(1)}x, which is the ceiling once V8 has compiled.`,
);
if (nodeCount !== quickCount) {
console.log(`WARNING: request counts differ (${nodeCount} vs ${quickCount}) — not the same work.`);
}
-42
View File
@@ -1,42 +0,0 @@
/**
* The shell has to reach QuickJS as source text. Emitted as a `.ts` module, not
* a `.js` asset, so Vite and plain Node get at it the same way. Committed, like
* the wasm packages, so a checkout builds without this having run.
*/
import { build } from "esbuild";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const outDir = join(here, "src", "generated");
const result = await build({
entryPoints: [join(here, "src", "guest", "index.ts")],
bundle: true,
write: false,
format: "iife",
platform: "browser",
target: "es2022",
minify: false,
legalComments: "none",
});
const source = result.outputFiles[0].text;
mkdirSync(outDir, { recursive: true });
writeFileSync(
join(outDir, "guest.ts"),
[
"// Generated by build-guest.mjs. Do not edit.",
"//",
"// The runtime shell, as source text, for evaluation inside QuickJS.",
"// Regenerate with `npm run build --workspace @yaakapp-internal/plugin-sandbox`.",
"",
`export const GUEST_SOURCE = ${JSON.stringify(source)};`,
"",
].join("\n"),
);
console.log(`Bundled guest shell: ${(source.length / 1024).toFixed(1)} KB`);
-17
View File
@@ -1,17 +0,0 @@
{
"name": "@yaakapp-internal/plugin-sandbox",
"version": "1.0.0",
"private": true,
"main": "src/index.ts",
"scripts": {
"bootstrap": "npm run build",
"build": "node build-guest.mjs"
},
"dependencies": {
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
"quickjs-emscripten-core": "^0.32.0"
},
"devDependencies": {
"esbuild": "^0.28.0"
}
}
File diff suppressed because one or more lines are too long
@@ -1,238 +0,0 @@
/**
* Everything a plugin can reach that isn't the language itself. The Rust host
* must install this same list; see the README.
*/
declare const __yaak_log: (level: string, message: string) => void;
declare const __yaak_timer_start: (id: number, ms: number) => void;
declare const __yaak_timer_cancel: (id: number) => void;
/* -------------------------------- console -------------------------------- */
/** Formatted in here, so only strings cross the boundary. */
function formatArgs(args: unknown[]): string {
return args
.map((arg) => {
if (typeof arg === "string") return arg;
if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;
try {
return JSON.stringify(arg, replacer()) ?? String(arg);
} catch {
return String(arg);
}
})
.join(" ");
}
function replacer(): (key: string, value: unknown) => unknown {
const seen = new WeakSet<object>();
return (_key, value) => {
if (typeof value === "bigint") return `${value}n`;
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
if (typeof value === "object" && value !== null) {
if (seen.has(value)) return "[Circular]";
seen.add(value);
}
return value;
};
}
function installConsole(): void {
const log = (level: string) => (...args: unknown[]) => __yaak_log(level, formatArgs(args));
(globalThis as Record<string, unknown>).console = {
log: log("log"),
info: log("info"),
warn: log("warn"),
error: log("error"),
debug: log("debug"),
trace: log("debug"),
};
}
/* --------------------------------- timers -------------------------------- */
/** QuickJS has no clock to wake on, so the host holds the real timer. */
const timerCallbacks = new Map<number, () => void>();
let nextTimerId = 1;
function installTimers(): void {
const g = globalThis as Record<string, unknown>;
g.setTimeout = (callback: (...a: unknown[]) => void, ms?: number, ...args: unknown[]) => {
const id = nextTimerId++;
timerCallbacks.set(id, () => callback(...args));
__yaak_timer_start(id, Math.max(0, Number(ms) || 0));
return id;
};
g.clearTimeout = (id: number) => {
if (!timerCallbacks.delete(id)) return;
__yaak_timer_cancel(id);
};
// An interval is a timer that rearms, and nothing in a plugin should poll.
g.setInterval = undefined;
g.clearInterval = undefined;
}
/** Called by the host when a timer comes due. */
function fireTimer(id: number): void {
const callback = timerCallbacks.get(id);
timerCallbacks.delete(id);
callback?.();
}
/* ------------------------------- text codecs ------------------------------ */
class SandboxTextEncoder {
readonly encoding = "utf-8";
encode(input = ""): Uint8Array {
const out: number[] = [];
for (let i = 0; i < input.length; i++) {
let code = input.charCodeAt(i);
// A lone surrogate becomes U+FFFD, as the standard encoder does.
if (code >= 0xd800 && code <= 0xdbff) {
const next = input.charCodeAt(i + 1);
if (next >= 0xdc00 && next <= 0xdfff) {
code = (code - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000;
i++;
} else {
code = 0xfffd;
}
} else if (code >= 0xdc00 && code <= 0xdfff) {
code = 0xfffd;
}
if (code < 0x80) out.push(code);
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
else if (code < 0x10000)
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
else
out.push(
0xf0 | (code >> 18),
0x80 | ((code >> 12) & 0x3f),
0x80 | ((code >> 6) & 0x3f),
0x80 | (code & 0x3f),
);
}
return new Uint8Array(out);
}
}
class SandboxTextDecoder {
readonly encoding = "utf-8";
decode(input?: ArrayBuffer | ArrayBufferView): string {
if (input == null) return "";
const bytes =
input instanceof Uint8Array
? input
: ArrayBuffer.isView(input)
? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
: new Uint8Array(input);
let out = "";
for (let i = 0; i < bytes.length; ) {
const byte = bytes[i]!;
let code: number;
let size: number;
if (byte < 0x80) {
code = byte;
size = 1;
} else if ((byte & 0xe0) === 0xc0) {
code = byte & 0x1f;
size = 2;
} else if ((byte & 0xf0) === 0xe0) {
code = byte & 0x0f;
size = 3;
} else if ((byte & 0xf8) === 0xf0) {
code = byte & 0x07;
size = 4;
} else {
out += "";
i++;
continue;
}
if (i + size > bytes.length) {
out += "";
break;
}
for (let k = 1; k < size; k++) {
const cont = bytes[i + k]!;
if ((cont & 0xc0) !== 0x80) {
code = -1;
break;
}
code = (code << 6) | (cont & 0x3f);
}
i += size;
if (code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) out += "";
else if (code < 0x10000) out += String.fromCharCode(code);
else {
const c = code - 0x10000;
out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
}
}
return out;
}
}
function installTextCodecs(): void {
const g = globalThis as Record<string, unknown>;
g.TextEncoder = SandboxTextEncoder;
g.TextDecoder = SandboxTextDecoder;
}
/* ------------------------------ base64 helpers ---------------------------- */
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
function installBase64(): void {
const g = globalThis as Record<string, unknown>;
g.btoa = (input: string): string => {
let out = "";
for (let i = 0; i < input.length; i += 3) {
const a = input.charCodeAt(i);
const b = input.charCodeAt(i + 1);
const c = input.charCodeAt(i + 2);
if (a > 0xff || b > 0xff || c > 0xff) {
throw new Error("btoa: string contains characters outside of the Latin1 range");
}
const chunk = (a << 16) | ((Number.isNaN(b) ? 0 : b) << 8) | (Number.isNaN(c) ? 0 : c);
out += B64[(chunk >> 18) & 63]! + B64[(chunk >> 12) & 63]!;
out += Number.isNaN(b) ? "=" : B64[(chunk >> 6) & 63]!;
out += Number.isNaN(c) ? "=" : B64[chunk & 63]!;
}
return out;
};
g.atob = (input: string): string => {
const clean = input.replace(/[\t\n\f\r ]/g, "").replace(/=+$/, "");
let out = "";
let bits = 0;
let acc = 0;
for (const ch of clean) {
const value = B64.indexOf(ch);
if (value < 0) throw new Error("atob: string contains invalid characters");
acc = (acc << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
out += String.fromCharCode((acc >> bits) & 0xff);
}
}
return out;
};
}
export function installGlobals(): { fireTimer: (id: number) => void } {
installConsole();
installTimers();
installTextCodecs();
installBase64();
return { fireTimer };
}
-333
View File
@@ -1,333 +0,0 @@
/**
* What QuickJS evaluates before any untrusted code does: install globals, load
* one module, answer events against it.
*
* `load` takes source and `dispatch` takes an event, so what a module *is* — a
* plugin today, a workspace script later — is the host's decision, not this
* file's. See the README on why scripts never get a second runtime.
*/
import type { PluginDefinition } from "@yaakapp/api";
import {
applyFormInputDefaults,
validateTemplateFunctionArgs,
} from "@yaakapp-internal/lib/templateFunction";
import {
applyDynamicFormInput,
migrateTemplateFunctionSelectOptions,
stripDynamicCallbacks,
} from "@yaakapp-internal/lib/pluginForms";
import type {
GrpcRequestAction,
HttpAuthenticationAction,
HttpRequestAction,
ImportResources,
InternalEventPayload,
PluginContext,
TemplateFunction,
} from "@yaakapp-internal/plugins";
import {
createPluginContext,
type PluginTransport,
} from "@yaakapp-internal/lib/pluginContext";
import { installGlobals } from "./globals";
declare const __yaak_call: (payloadJson: string) => Promise<string>;
const { fireTimer } = installGlobals();
let mod: PluginDefinition = {};
let pluginRefId = "";
/**
* `require` exists only to fail, by name: a bundle that still calls it was not
* built for this target, and naming the specifier beats an undefined that
* surfaces ten frames later.
*/
function load(source: string, refId: string): void {
const module: { exports: Record<string, unknown> } = { exports: {} };
const require = (specifier: string) => {
throw new Error(
`Module "${specifier}" is not available in the sandbox runtime. ` +
`Plugins must be bundled with no external or built-in modules.`,
);
};
// Isolation is the QuickJS context around this, not a lint rule.
// oxlint-disable-next-line no-implied-eval
const factory = new Function("module", "exports", "require", source);
factory(module, module.exports, require);
const loaded = (module.exports.plugin ?? module.exports.default) as PluginDefinition | undefined;
if (loaded == null || typeof loaded !== "object") {
throw new Error("Module did not export `plugin`");
}
mod = loaded;
pluginRefId = refId;
}
function summary(): Record<string, unknown> {
return {
templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),
authentication: mod.authentication?.name ?? null,
importer: mod.importer != null,
filter: mod.filter != null,
themes: (mod.themes ?? []).length,
httpRequestActions: (mod.httpRequestActions ?? []).length,
workspaceActions: (mod.workspaceActions ?? []).length,
folderActions: (mod.folderActions ?? []).length,
grpcRequestActions: (mod.grpcRequestActions ?? []).length,
websocketRequestActions: (mod.websocketRequestActions ?? []).length,
};
}
const EMPTY: InternalEventPayload = { type: "empty_response" };
/**
* Every branch mirrors the Node runtime's: same payloads, so a plugin cannot
* tell which runtime it is in. An unmatched event gets `empty_response` rather
* than silence, so no caller waits forever.
*/
/**
* No `stream` and no `form`: both need the host to hold a conversation open,
* which this protocol deliberately does not. `openUrl` refuses and a prompt
* form is drawn once from its defaults, rather than quietly doing nothing.
*/
const transport: PluginTransport = {
async request(context, payload) {
// The id rides along because one host handler serves every loaded module,
// and a plugin's storage is namespaced by which plugin it is.
const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string };
if (reply.type === "error_response") {
throw new Error(reply.error || `Host failed to handle ${payload.type}`);
}
const { type: _type, ...rest } = reply;
return rest as Record<string, unknown>;
},
notify(context, payload) {
void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
},
};
async function dispatch(
context: PluginContext,
payload: InternalEventPayload,
): Promise<InternalEventPayload> {
const ctx = createPluginContext(transport, context);
if (payload.type === "boot_request") {
await mod.init?.(ctx);
return { type: "boot_response" };
}
if (payload.type === "terminate_request") {
await mod.dispose?.();
return { type: "terminate_response" };
}
if (payload.type === "import_request" && typeof mod.importer?.onImport === "function") {
const reply = await mod.importer.onImport(ctx, { text: payload.content });
if (reply != null) {
return { type: "import_response", resources: reply.resources as ImportResources };
}
return EMPTY;
}
if (payload.type === "filter_request" && typeof mod.filter?.onFilter === "function") {
const reply = await mod.filter.onFilter(ctx, {
filter: payload.filter,
payload: payload.content,
mimeType: payload.type,
});
return { type: "filter_response", ...reply };
}
if (payload.type === "get_themes_request" && Array.isArray(mod.themes)) {
return { type: "get_themes_response", themes: mod.themes };
}
/* --------------------------- template functions -------------------------- */
if (
payload.type === "get_template_function_summary_request" &&
Array.isArray(mod.templateFunctions)
) {
const functions: TemplateFunction[] = mod.templateFunctions.map((f) => ({
...migrateTemplateFunctionSelectOptions(f),
onRender: undefined,
}));
return { type: "get_template_function_summary_response", pluginRefId, functions };
}
if (
payload.type === "get_template_function_config_request" &&
Array.isArray(mod.templateFunctions)
) {
const found = mod.templateFunctions.find((f) => f.name === payload.name);
if (found == null) return EMPTY;
const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: undefined };
payload.values = applyFormInputDefaults(fn.args, payload.values);
const resolved = await applyDynamicFormInput(ctx, fn.args, {
...payload,
purpose: "preview",
} as const);
return {
type: "get_template_function_config_response",
pluginRefId,
function: { ...fn, args: stripDynamicCallbacks(resolved) },
};
}
if (payload.type === "call_template_function_request" && Array.isArray(mod.templateFunctions)) {
const fn = mod.templateFunctions.find((f) => f.name === payload.name);
if (
payload.args.purpose === "preview" &&
(fn?.previewType === "click" || fn?.previewType === "none")
) {
return {
type: "call_template_function_response",
value: null,
error: "Live preview disabled for this function",
};
}
if (typeof fn?.onRender === "function") {
const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);
const values = applyFormInputDefaults(resolved, payload.args.values);
const error = validateTemplateFunctionArgs(fn.name, resolved, values);
if (error && payload.args.purpose !== "preview") {
return { type: "call_template_function_response", value: null, error };
}
const result = await fn.onRender(ctx, { ...payload.args, values });
return { type: "call_template_function_response", value: result ?? null };
}
}
/* --------------------------- http authentication ------------------------- */
if (payload.type === "get_http_authentication_summary_request" && mod.authentication) {
return { type: "get_http_authentication_summary_response", ...mod.authentication };
}
if (payload.type === "get_http_authentication_config_request" && mod.authentication) {
const { args, actions } = mod.authentication;
payload.values = applyFormInputDefaults(args, payload.values);
const resolved = await applyDynamicFormInput(ctx, args, payload);
const resolvedActions: HttpAuthenticationAction[] = [];
// oxlint-disable-next-line unbound-method
for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);
return {
type: "get_http_authentication_config_response",
args: stripDynamicCallbacks(resolved),
actions: resolvedActions,
pluginRefId,
};
}
if (payload.type === "call_http_authentication_request" && mod.authentication) {
const auth = mod.authentication;
if (typeof auth.onApply === "function") {
const resolved = await applyDynamicFormInput(ctx, auth.args, payload);
payload.values = applyFormInputDefaults(resolved, payload.values);
return { type: "call_http_authentication_response", ...(await auth.onApply(ctx, payload)) };
}
}
if (payload.type === "call_http_authentication_action_request" && mod.authentication != null) {
const action = mod.authentication.actions?.[payload.index];
if (typeof action?.onSelect === "function") {
await action.onSelect(ctx, payload.args);
return EMPTY;
}
}
/* --------------------------------- actions ------------------------------- */
if (payload.type === "get_http_request_actions_request" && Array.isArray(mod.httpRequestActions)) {
const actions: HttpRequestAction[] = mod.httpRequestActions.map((a) => ({
...a,
onSelect: undefined,
}));
return { type: "get_http_request_actions_response", pluginRefId, actions };
}
if (
payload.type === "get_websocket_request_actions_request" &&
Array.isArray(mod.websocketRequestActions)
) {
const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: undefined }));
return { type: "get_websocket_request_actions_response", pluginRefId, actions };
}
if (payload.type === "get_grpc_request_actions_request" && Array.isArray(mod.grpcRequestActions)) {
const actions: GrpcRequestAction[] = mod.grpcRequestActions.map((a) => ({
...a,
onSelect: undefined,
}));
return { type: "get_grpc_request_actions_response", pluginRefId, actions };
}
if (payload.type === "get_workspace_actions_request" && Array.isArray(mod.workspaceActions)) {
const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: undefined }));
return { type: "get_workspace_actions_response", pluginRefId, actions };
}
if (payload.type === "get_folder_actions_request" && Array.isArray(mod.folderActions)) {
const actions = mod.folderActions.map((a) => ({ ...a, onSelect: undefined }));
return { type: "get_folder_actions_response", pluginRefId, actions };
}
const called = await callAction(ctx, payload);
if (called) return EMPTY;
return EMPTY;
}
async function callAction(
ctx: ReturnType<typeof createPluginContext>,
payload: InternalEventPayload,
): Promise<boolean> {
const lists = {
call_http_request_action_request: mod.httpRequestActions,
call_websocket_request_action_request: mod.websocketRequestActions,
call_grpc_request_action_request: mod.grpcRequestActions,
call_workspace_action_request: mod.workspaceActions,
call_folder_action_request: mod.folderActions,
} as const;
const list = lists[payload.type as keyof typeof lists];
if (!Array.isArray(list)) return false;
const action = list[(payload as { index: number }).index];
if (typeof action?.onSelect !== "function") return false;
await action.onSelect(ctx, (payload as { args: never }).args);
return true;
}
(globalThis as Record<string, unknown>).__yaak_guest = {
load,
summary,
fireTimer,
dispatch: async (envelopeJson: string): Promise<string> => {
const { context, payload } = JSON.parse(envelopeJson) as {
context: PluginContext;
payload: InternalEventPayload;
};
try {
return JSON.stringify(await dispatch(context, payload));
} catch (err) {
// A throw from a plugin is an answer, not a crash.
const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\s*/g, "");
return JSON.stringify({ type: "error_response", error });
}
},
};
-312
View File
@@ -1,312 +0,0 @@
/**
* One runtime, one context per module. The engine choice and the limits below
* are argued in this package's README, which is also the spec for the Rust host.
*/
import variant from "@jitl/quickjs-ng-wasmfile-release-sync";
import {
newQuickJSWASMModuleFromVariant,
type QuickJSContext,
type QuickJSRuntime,
type QuickJSWASMModule,
} from "quickjs-emscripten-core";
import { GUEST_SOURCE } from "../generated/guest";
const MEMORY_LIMIT_BYTES = 256 * 1024 * 1024;
const STACK_SIZE_BYTES = 2 * 1024 * 1024;
/** Bounds synchronous execution only: a plugin awaiting the host is not looping. */
const SYNC_BUDGET_MS = 60_000;
export type HostRequestHandler = (envelopeJson: string) => Promise<string>;
export interface SandboxLog {
pluginRefId: string;
level: string;
message: string;
}
let modulePromise: Promise<QuickJSWASMModule> | null = null;
function quickjs(): Promise<QuickJSWASMModule> {
modulePromise ??= newQuickJSWASMModuleFromVariant(variant);
return modulePromise;
}
class LoadedPlugin {
readonly pluginRefId: string;
readonly context: QuickJSContext;
/** Set while a dispatch is running; the interrupt handler reads it. */
deadline: number | null = null;
private nextTimer = new Map<number, ReturnType<typeof setTimeout>>();
private disposed = false;
constructor(pluginRefId: string, context: QuickJSContext) {
this.pluginRefId = pluginRefId;
this.context = context;
}
touch(): void {
if (this.deadline != null) this.deadline = Date.now() + SYNC_BUDGET_MS;
}
startTimer(id: number, ms: number, fire: () => void): void {
this.nextTimer.set(
id,
setTimeout(() => {
this.nextTimer.delete(id);
if (!this.disposed) fire();
}, ms),
);
}
cancelTimer(id: number): void {
const handle = this.nextTimer.get(id);
if (handle == null) return;
clearTimeout(handle);
this.nextTimer.delete(id);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
for (const handle of this.nextTimer.values()) clearTimeout(handle);
this.nextTimer.clear();
this.context.dispose();
}
}
export class PluginSandboxHost {
private runtime: QuickJSRuntime | null = null;
private readonly plugins = new Map<string, LoadedPlugin>();
constructor(
private readonly onHostRequest: HostRequestHandler,
private readonly onLog: (log: SandboxLog) => void,
) {}
async load(pluginRefId: string, source: string): Promise<Record<string, unknown>> {
const module = await quickjs();
if (this.runtime == null) {
this.runtime = module.newRuntime();
this.runtime.setMemoryLimit(MEMORY_LIMIT_BYTES);
this.runtime.setMaxStackSize(STACK_SIZE_BYTES);
this.runtime.setInterruptHandler(() => {
const now = Date.now();
for (const plugin of this.plugins.values()) {
if (plugin.deadline != null && now > plugin.deadline) return true;
}
return false;
});
}
this.plugins.get(pluginRefId)?.dispose();
const plugin = new LoadedPlugin(pluginRefId, this.runtime.newContext());
this.plugins.set(pluginRefId, plugin);
try {
this.installHostFunctions(plugin);
this.evalOrThrow(plugin, GUEST_SOURCE, "yaak:sandbox-shell");
await this.callGuest(plugin, "load", [source, pluginRefId]);
return await this.callGuest(plugin, "summary", []);
} catch (err) {
plugin.dispose();
this.plugins.delete(pluginRefId);
throw err;
}
}
loaded(): string[] {
return Array.from(this.plugins.keys());
}
unload(pluginRefId: string): void {
this.plugins.get(pluginRefId)?.dispose();
this.plugins.delete(pluginRefId);
}
async dispatch(pluginRefId: string, envelopeJson: string): Promise<string> {
const plugin = this.plugins.get(pluginRefId);
if (plugin == null) throw new Error(`No plugin loaded as \`${pluginRefId}\``);
const reply = await this.callGuest(plugin, "dispatch", [envelopeJson]);
return reply as unknown as string;
}
dispose(): void {
for (const plugin of this.plugins.values()) plugin.dispose();
this.plugins.clear();
this.runtime?.dispose();
this.runtime = null;
}
/* ------------------------------ internals ------------------------------- */
private installHostFunctions(plugin: LoadedPlugin): void {
const { context } = plugin;
const define = (name: string, fn: Parameters<QuickJSContext["newFunction"]>[1]) => {
const handle = context.newFunction(name, fn);
context.setProp(context.global, name, handle);
handle.dispose();
};
define("__yaak_log", (levelHandle, messageHandle) => {
this.onLog({
pluginRefId: plugin.pluginRefId,
level: context.getString(levelHandle),
message: context.getString(messageHandle),
});
});
define("__yaak_timer_start", (idHandle, msHandle) => {
const id = context.getNumber(idHandle);
plugin.startTimer(id, context.getNumber(msHandle), () => {
plugin.touch();
this.callGuestSync(plugin, "fireTimer", [id]);
this.pump(plugin);
});
});
define("__yaak_timer_cancel", (idHandle) => {
plugin.cancelTimer(context.getNumber(idHandle));
});
define("__yaak_call", (envelopeHandle) => {
const envelope = context.getString(envelopeHandle);
const wasWatching = plugin.deadline != null;
plugin.deadline = null;
const settle = this.onHostRequest(envelope).then(
(reply) => {
if (wasWatching) plugin.touch();
return context.newString(reply);
},
(err: unknown) => {
if (wasWatching) plugin.touch();
return context.newError(err instanceof Error ? err.message : String(err));
},
);
const deferred = context.newPromise(settle);
void deferred.settled.then(() => {
this.pump(plugin);
deferred.dispose();
});
return deferred.handle;
});
}
private pump(plugin: LoadedPlugin): void {
const result = this.runtime?.executePendingJobs();
if (result?.error != null) {
this.onLog({
pluginRefId: plugin.pluginRefId,
level: "error",
message: `Unhandled error in sandbox: ${result.error.consume(
plugin.context.dump.bind(plugin.context),
)}`,
});
}
}
private evalOrThrow(plugin: LoadedPlugin, source: string, filename: string): void {
plugin.deadline = Date.now() + SYNC_BUDGET_MS;
try {
const result = plugin.context.evalCode(source, filename);
if (result.error != null) {
throw this.toError(plugin, result.error.consume(plugin.context.dump.bind(plugin.context)));
}
result.value.dispose();
} finally {
plugin.deadline = null;
}
}
private async callGuest(
plugin: LoadedPlugin,
method: string,
args: (string | number)[],
// oxlint-disable-next-line no-explicit-any -- the caller knows the guest's shape
): Promise<any> {
const { context } = plugin;
plugin.deadline = Date.now() + SYNC_BUDGET_MS;
const guest = context.getProp(context.global, "__yaak_guest");
const fn = context.getProp(guest, method);
const argHandles = args.map((a) =>
typeof a === "string" ? context.newString(a) : context.newNumber(a),
);
try {
const called = context.callFunction(fn, guest, ...argHandles);
if (called.error != null) {
throw this.toError(plugin, called.error.consume(context.dump.bind(context)));
}
const value = called.value;
const state = context.getPromiseState(value);
if (state.type !== "fulfilled" || state.notAPromise !== true) {
const resolved = context.resolvePromise(value);
value.dispose();
this.pump(plugin);
const settled = await resolved;
if (settled.error != null) {
throw this.toError(plugin, settled.error.consume(context.dump.bind(context)));
}
return settled.value.consume(context.dump.bind(context));
}
return value.consume(context.dump.bind(context));
} finally {
plugin.deadline = null;
for (const handle of argHandles) handle.dispose();
fn.dispose();
guest.dispose();
}
}
private callGuestSync(plugin: LoadedPlugin, method: string, args: number[]): void {
const { context } = plugin;
const guest = context.getProp(context.global, "__yaak_guest");
const fn = context.getProp(guest, method);
const argHandles = args.map((a) => context.newNumber(a));
try {
const called = context.callFunction(fn, guest, ...argHandles);
if (called.error != null) {
this.onLog({
pluginRefId: plugin.pluginRefId,
level: "error",
message: String(this.toError(plugin, called.error.consume(context.dump.bind(context)))),
});
} else {
called.value.dispose();
}
} finally {
for (const handle of argHandles) handle.dispose();
fn.dispose();
guest.dispose();
}
}
private toError(plugin: LoadedPlugin, dumped: unknown): Error {
if (dumped != null && typeof dumped === "object") {
const { message, name, stack } = dumped as Record<string, string | undefined>;
const error = new Error(message ?? JSON.stringify(dumped));
if (name != null) error.name = name;
if (stack != null) error.stack = `${name ?? "Error"}: ${message ?? ""}\n${stack}`;
return error;
}
// An interrupted plugin surfaces as `null` with no error object.
if (dumped == null) {
return new Error(
`Plugin \`${plugin.pluginRefId}\` was stopped after running for ` +
`${SYNC_BUDGET_MS / 1000}s without yielding`,
);
}
return new Error(typeof dumped === "string" ? dumped : JSON.stringify(dumped));
}
}
-137
View File
@@ -1,137 +0,0 @@
/**
* A tab's handle on its sandbox. `onHostRequest` is the entire answer to "what
* can a plugin do here?", and this package deliberately has no opinion on it.
*/
import type { FromSandbox, ToSandbox } from "./protocol";
/** Answers one `ctx` call: JSON envelope in, JSON reply out. */
export type HostRequestHandler = (envelope: string) => Promise<string>;
export interface PluginSandboxOptions {
onHostRequest: HostRequestHandler;
onLog?: (log: { pluginRefId: string; level: string; message: string }) => void;
}
export interface PluginSummary {
templateFunctions: string[];
authentication: string | null;
importer: boolean;
filter: boolean;
themes: number;
httpRequestActions: number;
workspaceActions: number;
folderActions: number;
grpcRequestActions: number;
websocketRequestActions: number;
}
type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
export class PluginSandbox {
private readonly worker: Worker;
private readonly pending = new Map<number, Pending>();
private readonly options: PluginSandboxOptions;
private nextId = 1;
constructor(options: PluginSandboxOptions) {
this.options = options;
// Written inline because that exact syntax is what the bundler
// pattern-matches; hoisted into a variable it ships as raw TypeScript.
this.worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
name: "yaak-plugins",
});
this.worker.onmessage = (e: MessageEvent<FromSandbox>) => this.receive(e.data);
this.worker.onerror = () => this.failEverything("The plugin sandbox failed to start");
}
load(pluginRefId: string, source: string): Promise<PluginSummary> {
return this.request<PluginSummary>((id) => ({ type: "load", id, pluginRefId, source }));
}
unload(pluginRefId: string): Promise<void> {
return this.request<void>((id) => ({ type: "unload", id, pluginRefId }));
}
async dispatch<T>(
pluginRefId: string,
context: unknown,
payload: unknown,
): Promise<T & { type: string }> {
const envelope = JSON.stringify({ context, payload });
const reply = await this.request<string>((id) => ({
type: "dispatch",
id,
pluginRefId,
envelope,
}));
const parsed = JSON.parse(reply) as { type: string; error?: string };
if (parsed.type === "error_response") {
throw new Error(parsed.error || "Plugin failed");
}
return parsed as T & { type: string };
}
/** `terminate()`, not a polite shutdown: the reason to call this is a plugin that won't stop. */
dispose(): void {
this.worker.terminate();
this.failEverything("The plugin sandbox was shut down");
}
/* ------------------------------ internals ------------------------------- */
private request<T>(build: (id: number) => ToSandbox): Promise<T> {
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.worker.postMessage(build(id));
});
}
private receive(message: FromSandbox): void {
switch (message.type) {
case "result": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.resolve(message.result);
return;
}
case "error": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.reject(new Error(message.message));
return;
}
case "log":
this.options.onLog?.(message);
return;
case "host_call":
void this.answer(message.id, message.envelope);
return;
}
}
private async answer(id: number, envelope: string): Promise<void> {
let reply: ToSandbox;
try {
reply = { type: "host_result", id, reply: await this.options.onHostRequest(envelope) };
} catch (err) {
reply = {
type: "host_result",
id,
error: err instanceof Error ? err.message : String(err),
};
}
this.worker.postMessage(reply);
}
private failEverything(message: string): void {
for (const [id, p] of this.pending) {
this.pending.delete(id);
p.reject(new Error(message));
}
}
}
-21
View File
@@ -1,21 +0,0 @@
/**
* Two request/reply flows in opposite directions. Payloads are JSON strings
* rather than objects because they must be strings to cross into QuickJS
* anyway, so structured-cloning them first would only be undone.
*/
/** Tab → worker */
export type ToSandbox =
| { type: "load"; id: number; pluginRefId: string; source: string }
| { type: "unload"; id: number; pluginRefId: string }
| { type: "dispatch"; id: number; pluginRefId: string; envelope: string }
/** The tab's answer to a `host_call`. */
| { type: "host_result"; id: number; reply?: string; error?: string };
/** Worker → tab */
export type FromSandbox =
| { type: "result"; id: number; result: unknown }
| { type: "error"; id: number; message: string }
/** A plugin wants something only the tab can provide. */
| { type: "host_call"; id: number; envelope: string }
| { type: "log"; pluginRefId: string; level: string; message: string };
-68
View File
@@ -1,68 +0,0 @@
/// <reference lib="webworker" />
/**
* A dedicated worker owned by the tab, deliberately not the SharedWorker that
* owns the database. Reasons and the cost are in the README.
*/
import { PluginSandboxHost } from "./host/sandbox";
import type { FromSandbox, ToSandbox } from "./protocol";
const scope = self as unknown as DedicatedWorkerGlobalScope;
function send(message: FromSandbox): void {
scope.postMessage(message);
}
const pendingHostCalls = new Map<number, (reply: string | Error) => void>();
let nextHostCallId = 1;
const host = new PluginSandboxHost(
(envelope) =>
new Promise<string>((resolve, reject) => {
const id = nextHostCallId++;
pendingHostCalls.set(id, (reply) => (reply instanceof Error ? reject(reply) : resolve(reply)));
send({ type: "host_call", id, envelope });
}),
(log) => send({ type: "log", ...log }),
);
async function handle(message: ToSandbox): Promise<void> {
if (message.type === "host_result") {
const settle = pendingHostCalls.get(message.id);
pendingHostCalls.delete(message.id);
settle?.(message.error != null ? new Error(message.error) : (message.reply ?? "{}"));
return;
}
try {
switch (message.type) {
case "load":
send({
type: "result",
id: message.id,
result: await host.load(message.pluginRefId, message.source),
});
return;
case "unload":
host.unload(message.pluginRefId);
send({ type: "result", id: message.id, result: null });
return;
case "dispatch":
send({
type: "result",
id: message.id,
result: await host.dispatch(message.pluginRefId, message.envelope),
});
return;
}
} catch (err) {
send({
type: "error",
id: message.id,
message: err instanceof Error ? err.message : String(err),
});
}
}
scope.onmessage = (e: MessageEvent<ToSandbox>) => void handle(e.data);
File diff suppressed because it is too large Load Diff
@@ -11,6 +11,16 @@ exports[`importer-openapi > Snapshots real-world fixture apis-guru.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"model": "environment",
"name": "Server 1",
"parentId": null,
"parentModel": "environment",
"sortPriority": 9,
"variables": [
{
"name": "baseUrl",
@@ -153,18 +163,13 @@ Responses:
"model": "http_request",
"name": "Retrieve one version of a particular API",
"sortPriority": 5,
"url": "\${[baseUrl]}/specs/:provider/:api.json",
"url": "\${[baseUrl]}/specs/:provider/2.1.0.json",
"urlParameters": [
{
"enabled": true,
"name": ":provider",
"value": "apis.guru",
},
{
"enabled": true,
"name": ":api",
"value": "2.1.0",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -197,7 +202,7 @@ Responses:
"model": "http_request",
"name": "Retrieve one version of a particular API with a serviceName.",
"sortPriority": 6,
"url": "\${[baseUrl]}/specs/:provider/:service/:api.json",
"url": "\${[baseUrl]}/specs/:provider/:service/2.1.0.json",
"urlParameters": [
{
"enabled": true,
@@ -209,11 +214,6 @@ Responses:
"name": ":service",
"value": "graph",
},
{
"enabled": true,
"name": ":api",
"value": "2.1.0",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -246,14 +246,8 @@ Responses:
"model": "http_request",
"name": "List all APIs for a particular provider",
"sortPriority": 7,
"url": "\${[baseUrl]}/:provider.json",
"urlParameters": [
{
"enabled": true,
"name": ":provider",
"value": "apis.guru",
},
],
"url": "\${[baseUrl]}/apis.guru.json",
"urlParameters": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
@@ -326,6 +320,16 @@ exports[`importer-openapi > Snapshots real-world fixture httpbin.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"model": "environment",
"name": "Server 1",
"parentId": null,
"parentModel": "environment",
"sortPriority": 90,
"variables": [
{
"name": "baseUrl",
@@ -840,13 +844,7 @@ Responses:
- 200: Sucessful authentication.
- 401: Unsuccessful authentication.",
"folderId": "GENERATE_ID::FOLDER_1",
"headers": [
{
"enabled": false,
"name": "Authorization",
"value": "",
},
],
"headers": [],
"id": "GENERATE_ID::HTTP_REQUEST_15",
"method": "GET",
"model": "http_request",
@@ -2612,11 +2610,44 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"model": "environment",
"name": "Server 1",
"parentId": null,
"parentModel": "environment",
"sortPriority": 3,
"variables": [
{
"name": "baseUrl",
"value": "https://api.nasa.gov/planetary",
},
{
"name": "auth_api_key_key",
"value": "",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_2",
"model": "environment",
"name": "Server 2",
"parentId": null,
"parentModel": "environment",
"sortPriority": 4,
"variables": [
{
"name": "baseUrl",
"value": "http://api.nasa.gov/planetary",
},
{
"name": "auth_api_key_key",
"value": "",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -2640,7 +2671,7 @@ Here's a link: https://example.com",
"authentication": {
"key": "api_key",
"location": "query",
"value": "",
"value": "\${[auth_api_key_key]}",
},
"authenticationType": "apikey",
"body": {},
@@ -2711,6 +2742,16 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"model": "environment",
"name": "Server 1",
"parentId": null,
"parentModel": "environment",
"sortPriority": 3,
"variables": [
{
"name": "baseUrl",
+651 -11
View File
@@ -13,6 +13,36 @@ describe("importer-openapi", () => {
.readdirSync(realWorldFixturesPath)
.filter((fixture) => fixture.endsWith(".yaml"));
test("Imports OpenAPI 3.2 QUERY and additional operations", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.2.0",
info: { title: "OpenAPI 3.2 Operations", version: "1.0.0" },
paths: {
"/resources": {
query: { summary: "Query resources", responses: {} },
additionalOperations: {
COPY: { summary: "Copy resources", responses: {} },
},
},
},
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
method: "QUERY",
name: "Query resources",
url: "${[baseUrl]}/resources",
}),
expect.objectContaining({
method: "COPY",
name: "Copy resources",
url: "${[baseUrl]}/resources",
}),
]);
});
test("Maps operation description to request description", async () => {
const imported = await convertOpenApi(
JSON.stringify({
@@ -120,7 +150,15 @@ describe("importer-openapi", () => {
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
variables: [],
}),
expect.objectContaining({
name: "Server 1",
parentModel: "environment",
variables: [
{ name: "baseUrl", value: "https://api.example.com/v1" },
{ name: "auth_token_auth_token", value: "" },
],
}),
]);
expect(imported?.resources.httpRequests).toEqual([
@@ -129,7 +167,7 @@ describe("importer-openapi", () => {
method: "POST",
url: "${[baseUrl]}/accounts/:accountId/members",
authenticationType: "bearer",
authentication: { token: "", prefix: "Bearer" },
authentication: { token: "${[auth_token_auth_token]}", prefix: "Bearer" },
bodyType: "application/json",
body: {
text: JSON.stringify(
@@ -219,6 +257,11 @@ describe("importer-openapi", () => {
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [],
}),
expect.objectContaining({
name: "Server 1",
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
}),
]);
@@ -229,6 +272,31 @@ describe("importer-openapi", () => {
expect(imported).toBeUndefined();
});
test("Creates an editable baseUrl variable when OpenAPI omits servers", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Serverless OpenAPI Test", version: "1.0.0" },
paths: {
"/api/widgets": { get: { responses: {} } },
},
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [],
}),
expect.objectContaining({
name: "Default",
parentModel: "environment",
variables: [{ name: "baseUrl", value: "" }],
}),
]);
expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/api/widgets");
});
test("Prefers operation and path servers over the spec base URL", async () => {
const imported = await convertOpenApi(
JSON.stringify({
@@ -251,8 +319,119 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests.map((r) => r.url)).toEqual([
"${[baseUrl]}/root",
"https://path.example.com/path-level",
"https://operation.example.com/operation-level",
"${[serverUrl]}/path-level",
"${[serverUrl2]}/operation-level",
]);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [
{ name: "serverUrl", value: "https://path.example.com" },
{ name: "serverUrl2", value: "https://operation.example.com" },
],
}),
expect.objectContaining({
name: "Server 1",
variables: [{ name: "baseUrl", value: "https://root.example.com" }],
}),
]);
});
test("Creates selectable environments for multiple OpenAPI servers", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Server Environments Test", version: "1.0.0" },
servers: [
{ url: "https://api.example.com/v1", description: "Production" },
{ url: "https://sandbox.example.com/v1", description: "Sandbox" },
],
paths: {
"/oauth": { get: { security: [{ oauth: [] }], responses: {} } },
"/api-key": { get: { security: [{ apiKey: [] }], responses: {} } },
"/fixed": {
servers: [{ url: "https://fixed.example.com" }],
get: { responses: {} },
},
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "/oauth/authorize",
tokenUrl: "oauth/token",
scopes: {},
},
},
},
apiKey: { type: "apiKey", in: "header", name: "X-API-Key" },
},
},
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [{ name: "serverUrl", value: "https://fixed.example.com" }],
}),
expect.objectContaining({
name: "Production",
parentModel: "environment",
variables: [
{ name: "baseUrl", value: "https://api.example.com/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "baseUrlOrigin", value: "https://api.example.com" },
{ name: "auth_api_key_key", value: "" },
],
}),
expect.objectContaining({
name: "Sandbox",
parentModel: "environment",
variables: [
{ name: "baseUrl", value: "https://sandbox.example.com/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "baseUrlOrigin", value: "https://sandbox.example.com" },
{ name: "auth_api_key_key", value: "" },
],
}),
]);
expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
expect.objectContaining({
authorizationUrl: "${[baseUrlOrigin]}/oauth/authorize",
accessTokenUrl: "${[baseUrl]}/oauth/token",
}),
);
});
test("Creates variables for path servers without a top-level server", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Path Server Test", version: "1.0.0" },
paths: {
"/items": {
servers: [{ url: "https://path.example.com" }],
get: { responses: {} },
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.url).toBe("${[serverUrl]}/items");
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [{ name: "serverUrl", value: "https://path.example.com" }],
}),
expect.objectContaining({
name: "Default",
variables: [{ name: "baseUrl", value: "" }],
}),
]);
});
@@ -289,8 +468,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "client_credentials",
clientId: "",
clientSecret: "",
clientId: "${[oauth_oauth_client_id]}",
clientSecret: "${[oauth_oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "read write",
accessTokenUrl: "https://example.com/token",
@@ -302,12 +481,125 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "implicit",
clientId: "",
clientId: "${[oauth_implicitOauth_client_id]}",
headerPrefix: "Bearer",
authorizationUrl: "https://example.com/authorize",
},
}),
);
expect(imported?.resources.environments[0]?.variables).toEqual([]);
expect(imported?.resources.environments[1]).toEqual(
expect.objectContaining({
name: "Default",
variables: [
{ name: "baseUrl", value: "" },
{ name: "oauth_oauth_client_id", value: "" },
{ name: "oauth_oauth_client_secret", value: "" },
{ name: "oauth_implicitOauth_client_id", value: "" },
{ name: "oauth_implicitOauth_client_secret", value: "" },
],
}),
);
});
test("Uses server environment variables for OAuth2 client credentials", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "OAuth Environment Test", version: "1.0.0" },
servers: [{ url: "https://api.example.com" }],
paths: {
"/users": {
get: { security: [{ oauth: ["read"] }], responses: {} },
post: { security: [{ oauth: ["write"] }], responses: {} },
},
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "/oauth/authorize",
tokenUrl: "/oauth/token",
scopes: { read: "Read users", write: "Write users" },
},
},
},
},
},
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({ name: "Global Variables", variables: [] }),
expect.objectContaining({
name: "Server 1",
variables: [
{ name: "baseUrl", value: "https://api.example.com" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
],
}),
]);
expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([
expect.objectContaining({
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
authorizationUrl: "https://api.example.com/oauth/authorize",
accessTokenUrl: "https://api.example.com/oauth/token",
}),
expect.objectContaining({
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
}),
]);
});
test("Uses the server environment origin for OAuth endpoints with a path-only API base", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Path-only OAuth Test", version: "1.0.0" },
servers: [{ url: "/api/v1" }],
paths: {
"/users": { get: { security: [{ oauth: [] }], responses: {} } },
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "oauth/authorize",
tokenUrl: "/oauth/token",
scopes: {},
},
},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
expect.objectContaining({
authorizationUrl: "${[baseUrlOrigin]}/api/v1/oauth/authorize",
accessTokenUrl: "${[baseUrlOrigin]}/oauth/token",
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({ name: "Global Variables", variables: [] }),
expect.objectContaining({
name: "Server 1",
variables: [
{ name: "baseUrl", value: "/api/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "baseUrlOrigin", value: "" },
],
}),
]);
});
test("Imports Swagger 2 OAuth2 flows and produces", async () => {
@@ -335,8 +627,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "authorization_code",
clientId: "",
clientSecret: "",
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "admin",
authorizationUrl: "https://example.com/authorize",
@@ -474,6 +766,194 @@ describe("importer-openapi", () => {
]);
});
test("Imports cookie and content-based parameters", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Parameter Test", version: "1.0.0" },
paths: {
"/items": {
get: {
parameters: [
{
name: "session",
in: "cookie",
required: true,
schema: { type: "string", example: "abc" },
},
{
name: "debug",
in: "cookie",
schema: { type: "string", example: "verbose" },
},
{
name: "X-Filter",
in: "header",
required: true,
content: { "text/plain": { example: "active" } },
},
],
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
{ enabled: true, name: "X-Filter", value: "active" },
{ enabled: true, name: "Cookie", value: "session=abc" },
{ enabled: false, name: "Cookie", value: "debug=verbose" },
]);
});
test("Preserves parameter cookies alongside cookie API-key authentication", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Authenticated Cookie Test", version: "1.0.0" },
paths: {
"/items": {
get: {
security: [{ basicAuth: [], cookieKey: [] }],
parameters: [
{
name: "session",
in: "cookie",
required: true,
schema: { type: "string", example: "abc" },
},
{
name: "debug",
in: "cookie",
schema: { type: "string", example: "verbose" },
},
],
responses: {},
},
},
},
components: {
securitySchemes: {
basicAuth: { type: "http", scheme: "basic" },
cookieKey: { type: "apiKey", in: "cookie", name: "api_key" },
},
},
}),
);
expect(imported?.resources.httpRequests[0]).toEqual(
expect.objectContaining({
authenticationType: "basic",
headers: [
{ enabled: true, name: "Cookie", value: "api_key=${[auth_cookie_key_key]}" },
{ enabled: true, name: "Cookie", value: "session=abc" },
{ enabled: false, name: "Cookie", value: "debug=verbose" },
],
}),
);
});
test("Serializes structured query parameters according to style and explode", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Serialization Test", version: "1.0.0" },
paths: {
"/items": {
get: {
parameters: [
{
name: "filter",
in: "query",
required: true,
style: "deepObject",
explode: true,
schema: {
type: "object",
properties: {
role: { example: "admin" },
active: { example: true },
},
},
},
{
name: "tags",
in: "query",
style: "form",
explode: true,
schema: { type: "array", example: ["one", "two"] },
},
],
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
{ enabled: true, name: "filter[role]", value: "admin" },
{ enabled: true, name: "filter[active]", value: "true" },
{ enabled: false, name: "tags", value: "one" },
{ enabled: false, name: "tags", value: "two" },
]);
});
test("Emits executable label and matrix path serializations", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Path Serialization Test", version: "1.0.0" },
paths: {
"/labels/{labels}/matrix/{coordinates}/scalar/{color}/report.{format}": {
get: {
parameters: [
{
name: "labels",
in: "path",
required: true,
style: "label",
explode: true,
schema: { type: "array", example: ["one/two", "three"] },
},
{
name: "coordinates",
in: "path",
required: true,
style: "matrix",
explode: true,
schema: { type: "object", example: { x: "1;spoof=2", y: 2 } },
},
{
name: "format",
in: "path",
required: true,
schema: { type: "string", example: "json/evil" },
},
{
name: "color",
in: "path",
required: true,
style: "label",
schema: { type: "string", example: "blue" },
},
],
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]).toEqual(
expect.objectContaining({
url: "${[baseUrl]}/labels/.one%2Ftwo.three/matrix/;x=1%3Bspoof%3D2;y=2/scalar/.blue/report.json%2Fevil",
urlParameters: [],
}),
);
});
test("Prefers operation-level consumes for Swagger bodies", async () => {
const imported = await convertOpenApi(
JSON.stringify({
@@ -523,16 +1003,176 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests[0]).toEqual(
expect.objectContaining({
authenticationType: "basic",
authentication: { username: "", password: "" },
authentication: {
username: "${[auth_basic_auth_username]}",
password: "${[auth_basic_auth_password]}",
},
}),
);
// The auth plugin has no cookie location, so it becomes the Cookie header
expect(imported?.resources.httpRequests[1]).toEqual(
expect.objectContaining({
authenticationType: "apikey",
authentication: { location: "header", key: "Cookie", value: "session=" },
authentication: {
location: "header",
key: "Cookie",
value: "session=${[auth_cookie_key_key]}",
},
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [],
}),
expect.objectContaining({
name: "Server 1",
variables: [
{ name: "baseUrl", value: "https://example.com/" },
{ name: "auth_basic_auth_username", value: "" },
{ name: "auth_basic_auth_password", value: "" },
{ name: "auth_cookie_key_key", value: "" },
],
}),
]);
});
test("Preserves anonymous security alternatives and explicit auth overrides", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Optional Auth", version: "1.0.0" },
security: [{ bearerAuth: [] }],
paths: {
"/optional-auth-first": {
get: { security: [{ bearerAuth: [] }, {}], responses: {} },
},
"/optional-anonymous-first": {
get: { security: [{}, { bearerAuth: [] }], responses: {} },
},
"/public": { get: { security: [], responses: {} } },
},
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
},
},
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({ authenticationType: "none", authentication: {} }),
expect.objectContaining({ authenticationType: "none", authentication: {} }),
expect.objectContaining({ authenticationType: "none", authentication: {} }),
]);
});
test("Imports AND security requirements without dropping API keys", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Combined Auth", version: "1.0.0" },
paths: {
"/combined": {
get: {
security: [{ bearerAuth: [], tenantKey: [], queryKey: [] }],
parameters: [
{
in: "header",
name: "X-Tenant-Key",
example: "operation-value-must-not-replace-auth",
},
],
responses: {},
},
},
},
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
tenantKey: { type: "apiKey", in: "header", name: "X-Tenant-Key" },
queryKey: { type: "apiKey", in: "query", name: "api_key" },
},
},
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
authenticationType: "bearer",
authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
headers: [{ enabled: true, name: "X-Tenant-Key", value: "${[auth_tenant_key_key]}" }],
urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }],
}),
]);
});
test("Keeps distinct credentials for security scheme names that normalize alike", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Auth Variable Names", version: "1.0.0" },
paths: {
"/hyphen": { get: { security: [{ "api-key": [] }], responses: {} } },
"/underscore": { get: { security: [{ api_key: [] }], responses: {} } },
"/hyphen-again": { get: { security: [{ "api-key": [] }], responses: {} } },
},
components: {
securitySchemes: {
"api-key": { type: "apiKey", in: "header", name: "X-Hyphen-Key" },
api_key: { type: "apiKey", in: "header", name: "X-Underscore-Key" },
},
},
}),
);
expect(imported?.resources.environments[0]?.variables).toEqual([]);
expect(imported?.resources.environments[1]).toEqual(
expect.objectContaining({
name: "Default",
variables: [
{ name: "baseUrl", value: "" },
{ name: "auth_api_key_key", value: "" },
{ name: "auth_api_key_key_2", value: "" },
],
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
}),
expect.objectContaining({
authentication: expect.objectContaining({ value: "${[auth_api_key_key_2]}" }),
}),
expect.objectContaining({
authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
}),
]);
});
test("Imports OpenID Connect as bearer authentication", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "OpenID Connect", version: "1.0.0" },
paths: { "/me": { get: { security: [{ oidc: [] }], responses: {} } } },
components: {
securitySchemes: {
oidc: {
type: "openIdConnect",
openIdConnectUrl: "https://accounts.example.com/.well-known/openid-configuration",
},
},
},
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
authenticationType: "bearer",
authentication: { token: "${[auth_oidc_token]}", prefix: "Bearer" },
}),
]);
});
test("Reports references that point outside the document", async () => {
-96
View File
@@ -1,96 +0,0 @@
/**
* A stand-in for `yaakcli build --target sandbox`, which does not exist yet.
* What the CLI would need instead is at the bottom of this file.
*/
import { build } from "esbuild";
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
/** Three, not the corpus: one template function, one importer, one auth method. */
const PLUGINS = ["template-function-timestamp", "importer-curl", "auth-bearer"];
const noNodeBuiltins = {
name: "no-node-builtins",
setup(build) {
build.onResolve({ filter: /^(node:|fs$|path$|crypto$|buffer$|process$|os$|util$|stream$)/ }, (args) => ({
errors: [
{
text:
`\`${args.path}\` is not available in the sandbox runtime. ` +
`Replace it with a pure-JavaScript equivalent.`,
},
],
}));
},
};
export async function bundlePlugin(name, { dir = join(root, "plugins", name) } = {}) {
const result = await build({
entryPoints: [join(dir, "src", "index.ts")],
bundle: true,
write: false,
format: "cjs",
platform: "browser",
target: "es2022",
minify: false,
legalComments: "none",
plugins: [noNodeBuiltins],
});
return result.outputFiles[0].text;
}
async function main() {
const bundles = [];
for (const name of PLUGINS) {
const source = await bundlePlugin(name);
bundles.push({ name, source });
console.log(`${name}: ${(source.length / 1024).toFixed(1)} KB`);
}
const outFile = join(root, "packages", "platform", "src", "web", "sandboxPlugins.generated.ts");
mkdirSync(dirname(outFile), { recursive: true });
writeFileSync(
outFile,
[
"// Generated by scripts/bundle-sandbox-plugins.mjs. Do not edit.",
"//",
"// The plugins the browser tier loads into its sandbox, bundled for that",
"// target and inlined as source text.",
"",
"export interface SandboxPluginBundle {",
" name: string;",
" source: string;",
"}",
"",
"export const SANDBOX_PLUGINS: SandboxPluginBundle[] = [",
...bundles.map((b) => ` { name: ${JSON.stringify(b.name)}, source: ${JSON.stringify(b.source)} },`),
"];",
"",
].join("\n"),
);
console.log(`Wrote ${outFile}`);
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
/*
* What `yaakcli build --target sandbox` would need, beyond this:
*
* 1. `Platform::Browser` in the rolldown options (crates-cli/yaak-cli/src/
* commands/plugin.rs `bundler_options`), plus a resolver that fails on a
* Node built-in instead of shimming it — a silent shim turns a missing
* capability into a runtime error inside someone else's plugin.
* 2. A `runtime` field in the plugin manifest, so a plugin declares which
* target it is for and the registry can refuse to install a `node` plugin
* on a host that has no Node.
* 3. Both targets emitted for the same source where they both work, since a
* desktop with a sandbox and a desktop with Node are the same install.
* 4. Distribution as files, not as inlined strings. Inlining is what this
* script does because three small bundles cost less than an asset pipeline;
* the corpus does not, and a plugin the user installs at runtime cannot be
* inlined at build time at all.
*/