Compare commits

..
108 changed files with 2250 additions and 6497 deletions
Generated
+2 -49
View File
@@ -619,8 +619,6 @@ dependencies = [
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
@@ -629,15 +627,10 @@ dependencies = [
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -658,7 +651,6 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9539,7 +9531,6 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9578,7 +9569,6 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log 0.4.29",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -11209,6 +11199,7 @@ dependencies = [
"base64 0.22.1",
"log 0.4.29",
"md5 0.8.0",
"rusqlite",
"serde_json",
"tempfile",
"thiserror 2.0.17",
@@ -11287,7 +11278,6 @@ dependencies = [
"yaak-grpc",
"yaak-http",
"yaak-license",
"yaak-lifecycle",
"yaak-mac-window",
"yaak-models",
"yaak-plugins",
@@ -11353,7 +11343,6 @@ dependencies = [
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-lifecycle",
"yaak-models",
"yaak-plugins",
"yaak-templates",
@@ -11502,6 +11491,7 @@ dependencies = [
"log 0.4.29",
"mime_guess",
"native-tls",
"regex 1.11.1",
"reqwest 0.12.20",
"serde",
"serde_json",
@@ -11537,14 +11527,6 @@ dependencies = [
"yaak-models",
]
[[package]]
name = "yaak-lifecycle"
version = "0.0.0"
dependencies = [
"log 0.4.29",
"yaak-models",
]
[[package]]
name = "yaak-mac-window"
version = "0.1.0"
@@ -11578,10 +11560,8 @@ dependencies = [
"sha2",
"thiserror 2.0.17",
"ts-rs",
"urlencoding",
"yaak-core",
"yaak-database",
"yaak-templates",
]
[[package]]
@@ -11675,29 +11655,6 @@ dependencies = [
"yaak-ws",
]
[[package]]
name = "yaak-send-proxy"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"clap",
"env_logger",
"futures-util",
"log 0.4.29",
"serde",
"serde_json",
"tokio",
"tower-http",
"ts-rs",
"url",
"uuid",
"yaak-http",
"yaak-models",
]
[[package]]
name = "yaak-sse"
version = "0.1.0"
@@ -11782,7 +11739,6 @@ dependencies = [
"console_error_panic_hook",
"js-sys",
"log 0.4.29",
"md5 0.7.0",
"serde",
"serde-wasm-bindgen",
"serde_json",
@@ -11790,10 +11746,7 @@ dependencies = [
"sqlite-wasm-vfs",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yaak-lifecycle",
"yaak-models",
"yaak-templates",
]
[[package]]
-4
View File
@@ -14,7 +14,6 @@ members = [
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -27,8 +26,6 @@ members = [
"crates/yaak-proxy",
# Proxy-specific crates
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-send-proxy",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
@@ -80,7 +77,6 @@ yaak-crypto = { path = "crates/yaak-crypto" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
+186 -21
View File
@@ -1,17 +1,34 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
modelTypeLabel,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { pluralizeCount } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { RadioCards } from "./core/RadioCards";
interface Props {
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
currentWorkspace: Workspace | null;
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
type DestinationChoice = "new_workspace" | "current_workspace";
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
@@ -31,8 +48,21 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({ importFile, importUrl }: Props) {
export function ImportDataDialog({
currentWorkspace,
selectedFolder,
planFile,
planUrl,
commit,
cancel,
onError,
}: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [destinationChoice, setDestinationChoice] = useState<DestinationChoice>(
currentWorkspace == null ? "new_workspace" : "current_workspace",
);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
@@ -71,19 +101,110 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
selectSource(selected);
};
const handleImport = async () => {
const destination = (): ImportDestination => {
if (destinationChoice === "current_workspace" && currentWorkspace != null) {
return {
type: "current_workspace",
workspaceId: currentWorkspace.id,
folderId: targetSelectedFolder ? selectedFolder?.id : undefined,
};
}
return { type: "new_workspace" };
};
const handlePreview = async () => {
setIsLoading(true);
try {
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit(plan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
if (plan != null) {
const counts = [
[plan.resources.workspaces[0]?.resource, plan.resources.workspaces.length],
[plan.resources.environments[0]?.resource, plan.resources.environments.length],
[plan.resources.folders[0]?.resource, plan.resources.folders.length],
[plan.resources.httpRequests[0]?.resource, plan.resources.httpRequests.length],
[plan.resources.grpcRequests[0]?.resource, plan.resources.grpcRequests.length],
[plan.resources.websocketRequests[0]?.resource, plan.resources.websocketRequests.length],
] as const;
const destinationLabel =
plan.destination.type === "new_workspace"
? "New workspace"
: selectedFolder != null && plan.destination.folderId === selectedFolder.id
? `${currentWorkspace?.name ?? "Current workspace"} / ${selectedFolder.name}`
: (currentWorkspace?.name ?? "Current workspace");
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div>
<div className="text-sm font-semibold mb-1">Resources</div>
<ul className="list-disc pl-6 text-sm text-text-subtle">
{counts.map(([model, count]) =>
model == null ? null : (
<li key={model.model}>{pluralizeCount(modelTypeLabel(model), count)}</li>
),
)}
</ul>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading ? "Importing" : "Confirm Import"}
</Button>
</HStack>
</VStack>
);
}
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -115,25 +236,69 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
<div className="text-sm font-semibold">Import destination</div>
<RadioCards
name="import-destination"
value={destinationChoice}
onChange={setDestinationChoice}
options={[
{
value: "new_workspace",
label: "New workspace",
description: "Create imported resources in a separate workspace.",
},
...(currentWorkspace == null
? []
: [
{
value: "current_workspace" as const,
label: currentWorkspace.name,
description: "Add resources without changing this workspace's settings.",
},
]),
]}
/>
{destinationChoice === "current_workspace" && selectedFolder != null && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
size="sm"
onClick={handleImport}
onClick={handlePreview}
>
{isLoading ? "Importing" : "Import"}
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</VStack>
</HStack>
</VStack>
);
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
+7 -5
View File
@@ -10,11 +10,13 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
disableBackdropClose?: boolean;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -27,7 +29,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableBackdropClose,
disableClose,
title,
description,
hideX,
@@ -42,7 +44,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -58,7 +60,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
onClose?.();
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -110,7 +112,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
+1 -3
View File
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableBackdropClose: true,
onClose: () => resolve("cancel"),
disableClose: true,
render: ({ hide }) =>
DivergedDialog({
remote,
+1 -2
View File
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({
id,
title,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
+1 -2
View File
@@ -18,9 +18,8 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({
...extraProps,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});
});
+29 -14
View File
@@ -1,10 +1,13 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import type { BatchUpsertResult, ImportDestination, ImportPlan } from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
@@ -21,29 +24,41 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "sm",
disableClose: true,
render: ({ hide }) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
};
return (
<ImportDataDialog
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
currentWorkspace={currentWorkspace}
selectedFolder={selectedFolder}
planFile={(filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
planUrl={(url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination })
}
commit={commit}
cancel={cancel}
onError={fail}
/>
);
},
+1 -6
View File
@@ -25,13 +25,8 @@ export async function showPromptForm({
id,
title,
description,
hideX: true,
size: size ?? "sm",
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
disableClose: true,
render: ({ hide }) =>
Prompt({
onCancel: () => {
-1
View File
@@ -45,7 +45,6 @@ yaak-api = { workspace = true }
yaak-core = { workspace = true }
yaak-crypto = { workspace = true }
yaak-http = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-templates = { workspace = true }
@@ -5,8 +5,7 @@ use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination};
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -51,6 +50,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -59,13 +59,13 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
let destination = match workspace_id {
Some(workspace_id) => ImportDestination::CurrentWorkspace { workspace_id, folder_id: None },
None => ImportDestination::NewWorkspace,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let imported = import::commit_import_plan(ctx.query_manager(), plan)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
+9 -6
View File
@@ -435,12 +435,15 @@ fn create(
let workspace_id = resolve_workspace_id(ctx, workspace_id_arg.as_deref(), "request create")?;
let name = name.unwrap_or_default();
let url = url.unwrap_or_default();
let mut request = HttpRequest { workspace_id, name, url, ..Default::default() };
// Only override the method when one was given; `HttpRequest::default()` is the
// single place the fallback ("GET") is defined.
if let Some(method) = method {
request.method = method.to_uppercase();
}
let method = method.unwrap_or_else(|| "GET".to_string());
let request = HttpRequest {
workspace_id,
name,
method: method.to_uppercase(),
url,
..Default::default()
};
let created = ctx
.db()
-8
View File
@@ -49,14 +49,6 @@ impl CliContext {
std::process::exit(1);
}
};
// Guest: the desktop may have this DB open, so only what's safe beside a live session
let _ = yaak_lifecycle::on_launch(
&yaak_lifecycle::Host::guest(),
&query_manager.connect(),
&blob_manager,
);
let encryption_manager = Arc::new(EncryptionManager::new(query_manager.clone(), app_id));
Self {
+1 -1
View File
@@ -13,7 +13,7 @@ use tokio::task::JoinHandle;
use yaak::plugin_events::{
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
};
use yaak_models::render::{render_grpc_request, render_http_request};
use yaak::render::{render_grpc_request, render_http_request};
use yaak::response_body::FileResponseBodyStore;
use yaak::send::{SendHttpRequestWithPluginsParams, send_http_request_with_plugins};
use yaak_crypto::manager::EncryptionManager;
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
.find(|workspace| workspace.name == "Imported Workspace")
.expect("workspace imported");
assert_ne!(workspace.id, "wrk_import");
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
}
fn write_postman_environment_fixture(path: &std::path::Path) {
-36
View File
@@ -1,36 +0,0 @@
[package]
name = "yaak-send-proxy"
version = "0.1.0"
edition = "2024"
publish = false
description = "Stateless HTTP send executor for Yaak in the browser"
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
# opens no database, runs no plugins, and renders nothing. yaak-models comes
# along only because yaak-http's types are its types; nothing here calls into
# its query layer.
[[bin]]
name = "yaak-send-proxy"
path = "src/main.rs"
[dependencies]
async-trait = "0.1"
axum = "0.7"
base64 = "0.22.1"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
env_logger = "0.11"
futures-util = "0.3"
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
tower-http = { version = "0.6", features = ["cors"] }
ts-rs = { workspace = true }
url = "2"
uuid = { version = "1", features = ["v4"] }
yaak-http = { workspace = true }
yaak-models = { workspace = true }
-139
View File
@@ -1,139 +0,0 @@
# yaak-send-proxy
The network half of Yaak in a browser.
A tab can't see an HTTP response the way a desktop app can: CORS hides most
headers (2 of 8 in a typical response), redirects are followed silently, and
there is no timeline. So the tab renders the request and posts it here, and this
process puts it on the network with the desktop's own engine (`yaak-http`) and
streams back everything that happened — every header, every redirect hop, DNS
timing, the body — for the tab to store.
It is a **stateless executor**. It keeps nothing: no database, no files, no
sessions, no cookies between calls. Every byte it sees comes from the tab in the
request, and every byte it returns is stored by the tab. Restart it any time.
## Running it
```shell
cargo run -p yaak-send-proxy
```
Listens on `127.0.0.1:9227`. Then run the web build against it:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
The tab looks for the proxy at `http://127.0.0.1:9227` unless
`VITE_YAAK_SEND_PROXY_URL` says otherwise at build time.
Every flag has a `YAAK_PROXY_*` environment variable, so a container needs no
arguments; `--help` lists them all.
| Flag | Default | What |
| --- | --- | --- |
| `--bind` | `127.0.0.1:9227` | Listen address. `0.0.0.0:9227` inside a container. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. A hosted instance should name its web origin. |
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
| `--max-concurrent` | 256 | Sends in flight at once. |
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
## What it refuses, and why
A hosted proxy is, by construction, a machine that makes HTTP requests on
behalf of strangers. Left alone that is an open relay into whatever network it
sits on. So it refuses, always, to connect to:
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
and IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the
well-known and local-use NAT64 prefixes, 6to4);
- anything not `http://` or `https://`.
The check runs **on the resolved addresses, after DNS**, for every hop of a
redirect chain, so a public hostname that points at an internal address is
caught, and so is a `Location:` header that points at one. It also refuses body
types that would read files on the proxy's disk (`binary`, multipart file
fields), since no browser tab could legitimately mean those.
Refusals are logged with the reason. There is no switch to turn this off: the
proxy's private network is the cloud's, not the user's, so a `localhost` or LAN
API can never be reached through it — that is what the desktop app is for.
## Deploying
One binary, no dependencies:
```shell
cargo build --release -p yaak-send-proxy
YAAK_PROXY_BIND=0.0.0.0:9227 \
YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \
./target/release/yaak-send-proxy
```
There is no authentication: an instance is anonymous and protected by the
per-client rate limit and the destination policy, which is what the hosted
funnel wants. Anything more (a shared token, per-user quotas) is a later slice
and would sit in front of `send_http` in `main.rs`. Put TLS in front of it (a
reverse proxy). If the reverse proxy buffers responses, tell it not to: the reply
is a stream and the `X-Accel-Buffering: no` header it sets is honoured by
nginx-shaped ones.
## The wire
`POST /v1/http/send` with a JSON body:
```json
{
"request": { "url": "https://…", "method": "GET", "headers": [], "body": {}, "bodyType": null, "urlParameters": [] },
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
"cookies": [ ]
}
```
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
template already rendered by the tab; the proxy builds the URL, headers and
body from it exactly the way the desktop does after rendering. `cookies` is the
jar's contents (or `null` for no jar).
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
happened:
| `type` | When | Carries |
| --- | --- | --- |
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
| `body` | as the body is read | a decompressed chunk, base64 |
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
| `error` | last, on failure | the reason, and any cookies collected before the failure |
Refusals that happen before anything is sent (a blocked destination, a bad body,
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
`{"error": "…"}`, not streams.
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
construction, cancellable by closing the connection, readable with `curl`, and
needs no upgrade handling on either side. A WebSocket only earns its keep when
traffic is bidirectional, which a single send is not.
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
into `bindings/` (run `cargo test -p yaak-send-proxy` after changing a frame)
and published to the tab as `@yaakapp-internal/send-proxy`, so a change to the
wire on one side is a type error on the other.
`GET /v1/health` reports the version and the effective limits.
## What comes later
Not built, by design, but the router is shaped for it: a WebSocket relay
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
bidirectional endpoints on the same binary, behind the same destination policy
and limits. They differ from this endpoint in holding per-connection
in-memory state while a connection is open (never persisted), which brings
connection limits and a larger abuse surface — the reason they are separate
work.
-48
View File
@@ -1,48 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieSameSite = "Strict" | "Lax" | "None";
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
export type InheritedIntSetting = { enabled?: boolean, value: number, };
-64
View File
@@ -1,64 +0,0 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
/**
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
* camelCase like every model the tab stores.
*/
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
/**
* The URL that answered, after redirects.
*/
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
/**
* The headers that were actually sent on the final hop, cookies and all.
*/
requestHeaders: Array<HttpResponseHeader>,
/**
* `Content-Length` as declared by the server, if it declared one.
*/
contentLength: number | null,
/**
* Milliseconds from the start of the send to the response head.
*/
elapsedHeaders: number,
/**
* Milliseconds spent in DNS on the last lookup, or zero.
*/
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
/**
* Milliseconds from the start of the send to the end of the body.
*/
elapsed: number,
/**
* Bytes of body relayed, after decompression.
*/
contentLength: number,
/**
* Bytes on the wire as declared by the server, or the relayed size when unknown.
*/
contentLengthCompressed: number,
/**
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
*/
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
/**
* The body of `POST /v1/http/send`.
*/
export type SendRequest = {
/**
* The request to send, in the desktop's own model shape but with every template already
* rendered by the tab. The proxy builds the URL, headers and body from it exactly the way
* the desktop does after rendering.
*/
request: HttpRequest,
/**
* The resolved settings, values only. Where they came from is the tab's to record in
* its timeline; the proxy only needs to obey them.
*/
settings: HttpSendSettings,
/**
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
*/
cookies: Array<Cookie> | null, };
-4
View File
@@ -1,4 +0,0 @@
// The send proxy's wire contract, generated by ts-rs from src/wire.rs
// (`cargo test -p yaak-send-proxy`). The tab imports these so a change to a
// frame on the Rust side is a type error in packages/platform/src/web.
export type { Frame, SendRequest } from "./bindings/gen_send_proxy";
@@ -1,6 +0,0 @@
{
"name": "@yaakapp-internal/send-proxy",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
@@ -1,52 +0,0 @@
use clap::Parser;
use std::net::SocketAddr;
/// A stateless HTTP send executor for Yaak running in a browser.
///
/// The tab renders the request and owns the data; this binary only puts bytes on the
/// network and streams back what came back. Nothing is written to disk or a database.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-send-proxy", version, about, long_about = None)]
pub struct Config {
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
#[arg(long, env = "YAAK_PROXY_BIND", default_value = "127.0.0.1:9227")]
pub bind: SocketAddr,
/// Browser origins allowed to call this proxy (CORS), comma-separated. `*` allows any.
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
#[arg(
long,
env = "YAAK_PROXY_ALLOWED_ORIGINS",
default_value = "*",
value_delimiter = ','
)]
pub allowed_origins: Vec<String>,
/// Largest request the proxy accepts from the tab (the rendered request JSON, body included).
#[arg(long, env = "YAAK_PROXY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize,
/// Largest upstream response body the proxy will relay before cutting the send off.
#[arg(long, env = "YAAK_PROXY_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
pub max_response_bytes: usize,
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
/// gets this instead.
#[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)]
pub max_timeout_secs: u64,
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
/// cap are the whole of what protects an instance: there is no authentication.
#[arg(long, env = "YAAK_PROXY_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
pub rate_limit_per_minute: u32,
/// Sends in flight at once across all clients.
#[arg(long, env = "YAAK_PROXY_MAX_CONCURRENT", default_value_t = 256)]
pub max_concurrent: usize,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
/// past the rate limit.
#[arg(long, env = "YAAK_PROXY_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool,
}
-260
View File
@@ -1,260 +0,0 @@
//! Where a send may go.
//!
//! A hosted sender is, by construction, a machine that makes HTTP requests on
//! behalf of strangers. Left alone that is an open relay into whatever network
//! it sits on: cloud metadata endpoints, internal admin panels, the database
//! next door. So every destination is checked twice — once on the URL before a
//! hop is attempted (literal IPs, host allow/deny lists) and once on the
//! addresses a hostname actually resolves to, right before the connection is
//! made. The second check is the one that matters for a hostname pointing at
//! an internal address, and it runs on every redirect hop because the engine
//! resolves every hop.
use async_trait::async_trait;
use log::warn;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use tokio::sync::mpsc;
use url::Url;
use yaak_http::dns::AddressFilter;
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
use yaak_http::types::SendableHttpRequest;
/// The destination policy, shared by every send: public addresses only, always. A hosted
/// proxy's "private network" is the cloud's, not the user's, so there is no configuration
/// that makes reaching it right.
#[derive(Clone, Default)]
pub struct DestinationPolicy;
impl DestinationPolicy {
/// Check a URL before a hop is attempted: scheme and literal IPs. A hostname that passes
/// here still has its resolved addresses checked by [`Self::address_filter`].
pub fn check_url(&self, raw: &str) -> Result<(), String> {
let url = Url::parse(raw).map_err(|e| format!("Invalid URL {raw:?}: {e}"))?;
match url.scheme() {
"http" | "https" => {}
other => return Err(format!("Refusing to send over {other:?}; only http and https")),
}
let host = url.host_str().ok_or_else(|| format!("URL {raw:?} has no host"))?;
let host = host.trim_matches(|c| c == '[' || c == ']');
// A literal IP never reaches the resolver, so it is checked here. Hostnames are checked
// where their addresses become known.
if let Ok(ip) = host.parse::<IpAddr>() {
self.check_ip(ip)?;
}
Ok(())
}
/// The veto the engine's resolver applies to every address a hostname resolves to.
pub fn address_filter(&self) -> AddressFilter {
let policy = self.clone();
Arc::new(move |ip| policy.check_ip(ip))
}
pub fn check_ip(&self, ip: IpAddr) -> Result<(), String> {
match non_public_reason(ip) {
Some(reason) => Err(format!(
"Refusing to connect to {ip}: {reason}. This proxy only sends to public addresses"
)),
None => Ok(()),
}
}
}
/// Why an address is not a public internet address, or `None` if it is one.
///
/// Every range here is one a hosted relay must never be talked into reaching: the machine
/// itself, the network it sits on, and the link-local range where cloud metadata services
/// (169.254.169.254) live. IPv4 addresses carried inside IPv6 forms — IPv4-mapped, the
/// well-known and local-use NAT64 prefixes, 6to4 — are unwrapped and judged as IPv4, since
/// that is where the packets end up. (A network-specific NAT64 prefix is not knowable here.)
pub fn non_public_reason(ip: IpAddr) -> Option<&'static str> {
match ip {
IpAddr::V4(v4) => non_public_v4(v4),
IpAddr::V6(v6) => {
if let Some(v4) = v6.to_ipv4_mapped() {
return non_public_v4(v4);
}
if let Some(reason) = embedded_v4(&v6).into_iter().find_map(non_public_v4) {
return Some(reason);
}
if v6.is_loopback() {
Some("loopback")
} else if v6.is_unspecified() {
Some("unspecified")
} else if v6.is_unique_local() {
Some("unique local (fc00::/7)")
} else if v6.is_unicast_link_local() {
Some("link-local (fe80::/10)")
} else if v6.is_multicast() {
Some("multicast")
} else if (v6.segments()[0] & 0xffc0) == 0xfec0 {
Some("site-local (fec0::/10)")
} else if v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8 {
Some("documentation (2001:db8::/32)")
} else {
None
}
}
}
}
fn non_public_v4(v4: Ipv4Addr) -> Option<&'static str> {
let o = v4.octets();
if v4.is_loopback() {
Some("loopback (127.0.0.0/8)")
} else if v4.is_private() {
Some("private (10/8, 172.16/12, 192.168/16)")
} else if v4.is_link_local() {
Some("link-local (169.254.0.0/16, where cloud metadata lives)")
} else if v4.is_unspecified() || o[0] == 0 {
Some("this network (0.0.0.0/8)")
} else if o[0] == 100 && (o[1] & 0xc0) == 64 {
Some("carrier-grade NAT (100.64.0.0/10)")
} else if v4.is_broadcast() {
Some("broadcast")
} else if v4.is_multicast() {
Some("multicast (224.0.0.0/4)")
} else if o[0] >= 240 {
Some("reserved (240.0.0.0/4)")
} else if v4.is_documentation() {
Some("documentation")
} else if o[0] == 192 && o[1] == 0 && o[2] == 0 {
Some("IETF protocol assignments (192.0.0.0/24)")
} else if o[0] == 198 && (o[1] & 0xfe) == 18 {
Some("benchmarking (198.18.0.0/15)")
} else {
None
}
}
/// The IPv4 addresses an IPv6 address may stand for, when it is one of the standard
/// translation forms: the NAT64 well-known prefix (64:ff9b::/96), the NAT64 local-use range
/// (64:ff9b:1::/48, RFC 8215), or 6to4 (2002::/16, IPv4 in the next 32 bits).
///
/// The local-use range is a pool an operator carves their own prefix from, at any of the
/// lengths RFC 6052 allows, so where the IPv4 sits inside it is not knowable here. Every
/// position it could occupy is returned, and the caller refuses if any of them is
/// non-public — a hosted relay would rather turn away an odd address than reach the wrong one.
fn embedded_v4(v6: &Ipv6Addr) -> Vec<Ipv4Addr> {
let s = v6.segments();
let o = v6.octets();
let v4 = |a: usize, b: usize, c: usize, d: usize| Ipv4Addr::new(o[a], o[b], o[c], o[d]);
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
return vec![v4(12, 13, 14, 15)];
}
if s[0] == 0x64 && s[1] == 0xff9b && s[2] == 1 {
// RFC 6052 layouts for a /48, /56, /64 and /96 prefix; octet 8 is the reserved `u`
// byte, skipped by the layouts that straddle it.
return vec![
v4(6, 7, 9, 10),
v4(7, 9, 10, 11),
v4(9, 10, 11, 12),
v4(12, 13, 14, 15),
];
}
if s[0] == 0x2002 {
return vec![v4(2, 3, 4, 5)];
}
Vec::new()
}
/// An [`HttpSender`] that checks each hop's URL against the policy before delegating.
///
/// The engine's redirect loop calls the sender once per hop with the hop's URL, so wrapping
/// the sender is what makes `Location:` headers subject to the same rules as the first URL —
/// including a redirect to a literal internal IP, which the resolver would never see.
pub struct GuardedSender<S> {
inner: S,
policy: DestinationPolicy,
}
impl<S: HttpSender> GuardedSender<S> {
pub fn new(inner: S, policy: DestinationPolicy) -> Self {
Self { inner, policy }
}
}
#[async_trait]
impl<S: HttpSender> HttpSender for GuardedSender<S> {
async fn send(
&self,
request: SendableHttpRequest,
event_tx: mpsc::Sender<HttpResponseEvent>,
) -> yaak_http::error::Result<HttpResponse> {
if let Err(reason) = self.policy.check_url(&request.url) {
warn!("Refused {} {}: {reason}", request.method, request.url);
return Err(yaak_http::error::Error::RequestError(reason));
}
self.inner.send(request, event_tx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ip(s: &str) -> IpAddr {
s.parse().unwrap()
}
#[test]
fn refuses_the_ranges_a_relay_must_never_reach() {
for addr in [
"127.0.0.1",
"127.9.9.9",
"10.0.0.1",
"172.16.0.1",
"172.31.255.255",
"192.168.1.1",
"169.254.169.254",
"169.254.0.1",
"0.0.0.0",
"100.64.0.1",
"255.255.255.255",
"224.0.0.1",
"240.0.0.1",
"::1",
"::",
"fc00::1",
"fd12::1",
"fe80::1",
"::ffff:127.0.0.1",
"::ffff:169.254.169.254",
"64:ff9b::7f00:1",
"ff02::1",
] {
assert!(non_public_reason(ip(addr)).is_some(), "{addr} should be refused");
}
}
#[test]
fn allows_public_addresses() {
for addr in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"172.32.0.1",
"2606:4700:4700::1111",
] {
assert!(non_public_reason(ip(addr)).is_none(), "{addr} should be allowed");
}
}
#[test]
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy;
assert!(policy.check_url("http://127.0.0.1/").is_err());
assert!(policy.check_url("http://[::1]/").is_err());
assert!(policy.check_url("http://169.254.169.254/latest/meta-data").is_err());
}
#[test]
fn only_http_schemes() {
let policy = DestinationPolicy;
assert!(policy.check_url("ftp://example.com/").is_err());
assert!(policy.check_url("file:///etc/passwd").is_err());
assert!(policy.check_url("https://example.com/").is_ok());
}
}
@@ -1,90 +0,0 @@
//! Per-client rate limiting, kept deliberately small.
//!
//! One token bucket per client IP, refilled continuously, in a mutex-guarded
//! map that is swept of idle entries as it goes. Good enough to keep one
//! caller from monopolising a hosted instance; not a substitute for whatever
//! sits in front of it in production.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub struct RateLimiter {
per_minute: u32,
buckets: Mutex<HashMap<IpAddr, Bucket>>,
}
struct Bucket {
tokens: f64,
last: Instant,
}
impl RateLimiter {
/// `per_minute == 0` disables limiting.
pub fn new(per_minute: u32) -> Self {
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
}
/// Take one token for `client`, or say how long until one is available.
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
if self.per_minute == 0 {
return Ok(());
}
let capacity = self.per_minute as f64;
let per_second = capacity / 60.0;
let now = Instant::now();
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
// Sweep buckets that have been idle long enough to be full again; there is nothing
// to remember about them.
if buckets.len() > 1024 {
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
}
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
let elapsed = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
Ok(())
} else {
let wait = (1.0 - bucket.tokens) / per_second;
Err(Duration::from_secs_f64(wait.max(0.001)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_full_bucket_then_a_wait() {
let limiter = RateLimiter::new(3);
let ip: IpAddr = "203.0.113.5".parse().unwrap();
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
assert!(limiter.check(ip).is_ok());
let wait = limiter.check(ip).expect_err("fourth call in a burst should wait");
assert!(wait > Duration::ZERO && wait <= Duration::from_secs(20));
}
#[test]
fn clients_are_independent_and_zero_disables() {
let limiter = RateLimiter::new(1);
let a: IpAddr = "203.0.113.5".parse().unwrap();
let b: IpAddr = "203.0.113.6".parse().unwrap();
assert!(limiter.check(a).is_ok());
assert!(limiter.check(a).is_err());
assert!(limiter.check(b).is_ok());
let unlimited = RateLimiter::new(0);
for _ in 0..1000 {
assert!(unlimited.check(a).is_ok());
}
}
}
-191
View File
@@ -1,191 +0,0 @@
//! yaak-send-proxy: the network half of Yaak in a browser.
//!
//! A tab can't see a response the way a desktop app can — CORS hides most
//! headers, redirects are followed silently, there is no timeline. So the tab
//! renders the request and hands it here; this process puts it on the network
//! with the desktop's own engine and streams back everything that happened,
//! for the tab to store. It keeps nothing: no database, no files, no session.
//!
//! One binary, configured by flags or `YAAK_PROXY_*` environment variables.
//! See README.md for running and deploying it, and `guard.rs` for what it
//! refuses to talk to.
mod config;
mod guard;
mod limits;
mod send;
mod wire;
use axum::Router;
use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, State};
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, post};
use clap::Parser;
use config::Config;
use guard::DestinationPolicy;
use limits::RateLimiter;
use log::{info, warn};
use send::{Refusal, SendLimits};
use serde_json::json;
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Semaphore;
use tower_http::cors::{AllowOrigin, CorsLayer};
use wire::SendRequest;
#[derive(Clone)]
struct AppState {
config: Arc<Config>,
limits: Arc<SendLimits>,
rate_limiter: Arc<RateLimiter>,
in_flight: Arc<Semaphore>,
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
let policy = DestinationPolicy;
let state = AppState {
limits: Arc::new(SendLimits {
policy,
max_response_bytes: config.max_response_bytes,
max_timeout: Duration::from_secs(config.max_timeout_secs),
}),
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
config: Arc::new(config),
};
let cors = CorsLayer::new()
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::CONTENT_TYPE])
.allow_origin(allowed_origins(&state.config.allowed_origins));
let app = Router::new()
.route("/v1/health", get(health))
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
// on the same router, behind the same policy, limits and auth. Not built; see README.
.route("/v1/http/send", post(send_http))
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
.layer(cors)
.with_state(state.clone());
let bind = state.config.bind;
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
eprintln!("Failed to bind {bind}: {e}");
std::process::exit(1);
});
info!(
"yaak-send-proxy listening on http://{bind} (rate limit: {}/min)",
state.config.rate_limit_per_minute,
);
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
let _ = tokio::signal::ctrl_c().await;
info!("Shutting down");
})
.await
.expect("server error");
}
fn allowed_origins(origins: &[String]) -> AllowOrigin {
if origins.iter().any(|o| o.trim() == "*") {
return AllowOrigin::any();
}
let parsed: Vec<HeaderValue> =
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
AllowOrigin::list(parsed)
}
async fn health(State(state): State<AppState>) -> impl IntoResponse {
Json(json!({
"ok": true,
"version": env!("CARGO_PKG_VERSION"),
"maxResponseBytes": state.config.max_response_bytes,
"maxTimeoutSecs": state.config.max_timeout_secs,
}))
}
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
let message = message.into();
(status, Json(json!({ "error": message }))).into_response()
}
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
/// hop when the operator has said the header can be trusted.
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
if config.trust_forwarded_for
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
&& let Some(first) = forwarded.split(',').next()
&& let Ok(ip) = first.trim().parse::<IpAddr>()
{
return ip;
}
peer.ip()
}
async fn send_http(
State(state): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(body): Json<SendRequest>,
) -> Response {
let ip = client_ip(&state.config, &headers, peer);
if let Err(wait) = state.rate_limiter.check(ip) {
warn!("Rate limited {ip}");
let mut res = error_response(
StatusCode::TOO_MANY_REQUESTS,
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
);
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
return res;
}
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
warn!("At capacity; refusing {ip}");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This proxy is at capacity");
};
let prepared = match send::prepare(state.limits.clone(), body).await {
Ok(p) => p,
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
Err(Refusal::Destination(m)) => {
warn!("Refused send from {ip}: {m}");
return error_response(StatusCode::FORBIDDEN, m);
}
};
let description = prepared.describe();
info!("{ip} -> {description}");
let started = Instant::now();
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
tokio::spawn(async move {
prepared.run(tx).await;
send::log_outcome(&description, started, "finished");
drop(permit);
});
let stream = tokio_stream_from(rx);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/x-ndjson")
.header(header::CACHE_CONTROL, "no-store")
// Some reverse proxies buffer streamed responses unless told not to
.header("x-accel-buffering", "no")
.body(Body::from_stream(stream))
.expect("valid response")
}
fn tokio_stream_from<T: Send + 'static>(
mut rx: tokio::sync::mpsc::Receiver<T>,
) -> impl futures_util::Stream<Item = T> + Send + 'static {
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
}
-357
View File
@@ -1,357 +0,0 @@
//! The one thing this binary does: execute a rendered request and stream back what happened.
//!
//! This is the "execute" half of the desktop's `send_http_request` — the part after rendering
//! and before storage — driven through the same `HttpTransaction` the desktop drives, with the
//! same redirect loop, cookie jar, decompression and timeline events. Everything the desktop
//! would write to its database is written to the reply stream instead, and the tab stores it.
use crate::guard::{DestinationPolicy, GuardedSender};
use crate::wire::{Frame, SendRequest};
use base64::Engine;
use bytes::Bytes;
use log::{info, warn};
use std::convert::Infallible;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tokio::io::AsyncReadExt;
use tokio::sync::{mpsc, watch};
use yaak_http::client::{HttpConnectionOptions, HttpConnectionProxySetting};
use yaak_http::cookies::CookieStore;
use yaak_http::sender::{HttpResponseEvent, ReqwestSender};
use yaak_http::transaction::HttpTransaction;
use yaak_http::types::{SendableHttpRequest, SendableHttpRequestOptions};
use yaak_models::models::HttpResponseHeader;
/// How many frames may sit unread by the client before body reading pauses. Backpressure, so a
/// slow tab slows the upstream read rather than filling memory.
pub const FRAME_CHANNEL_CAPACITY: usize = 64;
const EVENT_CHANNEL_CAPACITY: usize = 256;
const BODY_READ_CHUNK: usize = 64 * 1024;
/// What a send needs from the process, beyond the request itself.
pub struct SendLimits {
pub policy: DestinationPolicy,
pub max_response_bytes: usize,
pub max_timeout: Duration,
}
/// Why a send was refused before anything was put on the network. Distinct from a failure
/// mid-stream: these become a plain HTTP error, not a stream with an error frame.
#[derive(Debug)]
pub enum Refusal {
/// The request asks for something a browser-originated send cannot mean.
Unsupported(String),
/// The destination is not one this proxy will talk to.
Destination(String),
/// The request could not be turned into something sendable.
Invalid(String),
}
pub type FrameSender = mpsc::Sender<Result<Bytes, Infallible>>;
/// Check and prepare a send, then hand back the task that runs it. Refusals happen here, before
/// the caller has committed to a streaming response.
pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<PreparedSend, Refusal> {
let request = send.request;
// The engine reads files for these body types. There are no files here that a browser tab
// could legitimately mean, and letting a request name a path on this machine would be a
// local file read for anyone who can reach the proxy.
if request.body_type.as_deref() == Some("binary") {
return Err(Refusal::Unsupported(
"Binary file bodies can't be sent from the browser: the proxy has no access to your files"
.to_string(),
));
}
if request.body_type.as_deref() == Some("multipart/form-data") {
let names_a_file =
request.body.get("form").and_then(|f| f.as_array()).is_some_and(|entries| {
entries.iter().any(|e| {
e.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true)
&& e.get("file").and_then(|v| v.as_str()).is_some_and(|f| !f.is_empty())
})
});
if names_a_file {
return Err(Refusal::Unsupported(
"Multipart file fields can't be sent from the browser: the proxy has no access to your files"
.to_string(),
));
}
}
// The tab's requested timeout, capped. Zero means "none", which here means the cap.
let requested = if send.settings.timeout_ms > 0 {
Some(Duration::from_millis(send.settings.timeout_ms as u64))
} else {
None
};
let timeout = requested.map_or(limits.max_timeout, |t| t.min(limits.max_timeout));
let timeout_capped = requested.is_none_or(|t| t > limits.max_timeout);
let sendable = SendableHttpRequest::from_http_request(
&request,
SendableHttpRequestOptions {
timeout: Some(timeout),
follow_redirects: send.settings.follow_redirects,
},
)
.await
.map_err(|e| Refusal::Invalid(e.to_string()))?;
// The first hop, checked up front so a bad destination is a clean refusal rather than a
// stream that opens and immediately errors. Every later hop is checked by GuardedSender.
limits.policy.check_url(&sendable.url).map_err(Refusal::Destination)?;
Ok(PreparedSend {
limits,
sendable,
settings: send.settings,
cookies: send.cookies,
timeout,
timeout_capped,
})
}
pub struct PreparedSend {
limits: Arc<SendLimits>,
sendable: SendableHttpRequest,
settings: yaak_models::models::HttpSendSettings,
cookies: Option<Vec<yaak_models::models::Cookie>>,
timeout: Duration,
timeout_capped: bool,
}
impl PreparedSend {
pub fn describe(&self) -> String {
format!("{} {}", self.sendable.method, self.sendable.url)
}
/// Run the send, writing frames to `frames` until the terminal frame. Returns when the
/// stream is complete or the client has gone away.
pub async fn run(mut self, frames: FrameSender) {
let cookie_store = self.cookies.take().map(CookieStore::from_cookies);
let store_for_result = cookie_store.clone();
let outcome = self.execute(frames.clone(), cookie_store).await;
let cookies = store_for_result.as_ref().map(|s| s.get_all_cookies());
let terminal = match outcome {
Ok(done) => Frame::Done {
elapsed: done.elapsed,
content_length: done.content_length,
content_length_compressed: done.content_length_compressed,
cookies,
},
Err(message) => Frame::Error { message, cookies },
};
let _ = write_frame(&frames, &terminal).await;
}
async fn execute(
self,
frames: FrameSender,
cookie_store: Option<CookieStore>,
) -> Result<DoneStats, String> {
let limits = self.limits;
let (client, resolver) = HttpConnectionOptions {
id: uuid::Uuid::new_v4().to_string(),
validate_certificates: self.settings.validate_certificates,
// The proxy connects directly. Going through a system proxy would move DNS, and
// therefore the address check, somewhere this process can't see.
proxy: HttpConnectionProxySetting::Disabled,
client_certificate: None,
dns_overrides: Vec::new(),
address_filter: Some(limits.policy.address_filter()),
}
.build_client()
.map_err(|e| format!("Failed to build HTTP client: {e}"))?;
// Timeline events go into the same frame stream as everything else, as they happen.
// The desktop persists them from a task like this one; here the task serialises them.
let (event_tx, mut event_rx) = mpsc::channel::<HttpResponseEvent>(EVENT_CHANNEL_CAPACITY);
resolver.set_event_sender(Some(event_tx.clone())).await;
let dns_elapsed = Arc::new(AtomicU64::new(0));
let event_frames = frames.clone();
let event_dns = dns_elapsed.clone();
let event_task = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let HttpResponseEvent::DnsResolved { duration, .. } = &event {
event_dns.store(*duration, Ordering::Relaxed);
}
let frame = Frame::Event { event: event.into() };
if write_frame(&event_frames, &frame).await.is_err() {
break;
}
}
});
// Cancellation: the client hanging up, or the overall deadline. The deadline exists
// because a per-hop timeout times each hop separately; ten slow redirects must not add
// up to ten timeouts.
let (cancel_tx, cancel_rx) = watch::channel(false);
let deadline = self.timeout * 2 + Duration::from_secs(5);
let deadline_cancel = cancel_tx.clone();
let deadline_task = tokio::spawn(async move {
tokio::time::sleep(deadline).await;
let _ = deadline_cancel.send(true);
});
let hangup_frames = frames.clone();
let hangup_task = tokio::spawn(async move {
hangup_frames.closed().await;
let _ = cancel_tx.send(true);
});
if self.timeout_capped {
let _ = event_tx.try_send(HttpResponseEvent::Info(format!(
"Timeout set to {:?} (this proxy's ceiling)",
self.timeout
)));
}
let sender = GuardedSender::new(ReqwestSender::with_client(client), limits.policy.clone());
let transaction = match cookie_store {
Some(store) => HttpTransaction::with_cookie_behavior(
sender,
store,
self.settings.send_cookies,
self.settings.store_cookies,
),
None => HttpTransaction::new(sender),
};
let started_at = Instant::now();
let result = transaction
.execute_with_cancellation(self.sendable, cancel_rx.clone(), event_tx.clone())
.await;
resolver.set_event_sender(None).await;
let mut response = match result {
Ok(response) => response,
Err(err) => {
drop(event_tx);
let _ = event_task.await;
deadline_task.abort();
hangup_task.abort();
return Err(describe_error(&err));
}
};
let elapsed_headers = started_at.elapsed().as_millis() as u64;
let head = Frame::Response {
status: response.status,
status_reason: response.status_reason.clone(),
url: response.url.clone(),
remote_addr: response.remote_addr.clone(),
version: response.version.clone(),
headers: to_wire_headers(&response.headers),
request_headers: to_wire_headers(&response.request_headers),
content_length: response.content_length,
elapsed_headers,
elapsed_dns: dns_elapsed.load(Ordering::Relaxed),
};
write_frame(&frames, &head).await.map_err(|_| "Client went away".to_string())?;
let declared_length = response.content_length;
let mut body = response
.into_body_stream()
.map_err(|e| format!("Failed to read response body: {e}"))?;
let mut buf = vec![0u8; BODY_READ_CHUNK];
let mut total: usize = 0;
let mut cancel_rx = cancel_rx;
let base64 = base64::engine::general_purpose::STANDARD;
let read_result: Result<(), String> = loop {
if *cancel_rx.borrow() {
break Err("Request canceled".to_string());
}
let read = tokio::select! {
biased;
_ = cancel_rx.changed() => break Err("Request canceled".to_string()),
r = body.read(&mut buf) => r,
};
match read {
Ok(0) => break Ok(()),
Ok(n) => {
total += n;
if total > limits.max_response_bytes {
break Err(format!(
"Response body exceeds this proxy's limit of {} bytes",
limits.max_response_bytes
));
}
let frame = Frame::Body { data: base64.encode(&buf[..n]) };
if write_frame(&frames, &frame).await.is_err() {
break Err("Client went away".to_string());
}
}
Err(e) => break Err(format!("Failed to read response body: {e}")),
}
};
drop(body);
// Let the timeline drain before the terminal frame, so nothing arrives after "done".
drop(event_tx);
let _ = event_task.await;
deadline_task.abort();
hangup_task.abort();
read_result?;
Ok(DoneStats {
elapsed: started_at.elapsed().as_millis() as u64,
content_length: total as u64,
content_length_compressed: declared_length.unwrap_or(total as u64),
})
}
}
/// A send error as a sentence, not a debug dump.
///
/// A connection error from reqwest arrives wrapped several layers deep, and the layer that
/// says something useful — "Refusing to connect to ::1: loopback" — is the innermost. The
/// desktop shows the outer `Debug`; a stranger reading a proxy's reply deserves the reason.
fn describe_error(err: &yaak_http::error::Error) -> String {
match err {
yaak_http::error::Error::Client(e) => {
let mut leaf: &dyn std::error::Error = e;
while let Some(next) = leaf.source() {
leaf = next;
}
let outer = e.to_string();
let inner = leaf.to_string();
if inner == outer { outer } else { format!("{outer}: {inner}") }
}
yaak_http::error::Error::RequestError(message) => format!("Request failed: {message}"),
other => other.to_string(),
}
}
struct DoneStats {
elapsed: u64,
content_length: u64,
content_length_compressed: u64,
}
fn to_wire_headers(headers: &[(String, String)]) -> Vec<HttpResponseHeader> {
headers
.iter()
.map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() })
.collect()
}
async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> {
let mut line = match serde_json::to_vec(frame) {
Ok(v) => v,
Err(e) => {
warn!("Failed to serialize frame: {e}");
return Err(());
}
};
line.push(b'\n');
frames.send(Ok(Bytes::from(line))).await.map_err(|_| ())
}
/// Log a finished send at info: destination, outcome, and how long, never the content.
pub fn log_outcome(description: &str, started: Instant, outcome: &str) {
info!("{description} -> {outcome} in {:?}", started.elapsed());
}
-97
View File
@@ -1,97 +0,0 @@
//! What crosses the wire between a tab and this proxy.
//!
//! One `POST /v1/http/send` carries a request the tab has already rendered —
//! templates resolved, inheritance applied — plus the send settings and the
//! cookies the send starts with. The reply is a stream of newline-delimited
//! JSON frames: timeline events as they happen, the response head as soon as
//! headers arrive, body chunks as they are read, and one terminal frame.
//!
//! Nothing here names a workspace, a request id, or a response id. The proxy
//! does not know what the tab will call this response; it only knows what came
//! back.
//!
//! The TypeScript side of this contract is generated from these types into
//! `bindings/` (`cargo test -p yaak-send-proxy`) and published to the tab as
//! `@yaakapp-internal/send-proxy`, so a change here is a type error there.
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use yaak_models::models::{
Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings,
};
/// The body of `POST /v1/http/send`.
#[derive(Deserialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_send_proxy.ts")]
pub struct SendRequest {
/// The request to send, in the desktop's own model shape but with every template already
/// rendered by the tab. The proxy builds the URL, headers and body from it exactly the way
/// the desktop does after rendering.
pub request: HttpRequest,
/// The resolved settings, values only. Where they came from is the tab's to record in
/// its timeline; the proxy only needs to obey them.
pub settings: HttpSendSettings,
/// The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
#[serde(default)]
pub cookies: Option<Vec<Cookie>>,
}
/// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
/// camelCase like every model the tab stores.
#[derive(Serialize, Debug, TS)]
#[serde(
tag = "type",
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = "gen_send_proxy.ts")]
pub enum Frame {
/// A timeline event, in the same shape the desktop stores. Interleaved with everything
/// else in the order the engine produced it.
Event { event: HttpResponseEventData },
/// The response head. Sent once, as soon as the final hop's headers are in — before any of
/// the body — so the tab can show status and headers while the body streams.
Response {
status: u16,
status_reason: Option<String>,
/// The URL that answered, after redirects.
url: String,
remote_addr: Option<String>,
version: Option<String>,
headers: Vec<HttpResponseHeader>,
/// The headers that were actually sent on the final hop, cookies and all.
request_headers: Vec<HttpResponseHeader>,
/// `Content-Length` as declared by the server, if it declared one.
#[ts(type = "number | null")]
content_length: Option<u64>,
/// Milliseconds from the start of the send to the response head.
#[ts(type = "number")]
elapsed_headers: u64,
/// Milliseconds spent in DNS on the last lookup, or zero.
#[ts(type = "number")]
elapsed_dns: u64,
},
/// A piece of the response body, decompressed, base64-encoded.
Body { data: String },
/// The send finished. The last frame on a successful stream.
Done {
/// Milliseconds from the start of the send to the end of the body.
#[ts(type = "number")]
elapsed: u64,
/// Bytes of body relayed, after decompression.
#[ts(type = "number")]
content_length: u64,
/// Bytes on the wire as declared by the server, or the relayed size when unknown.
#[ts(type = "number")]
content_length_compressed: u64,
/// The jar as the send left it, for the tab to persist. `None` when the tab sent none.
cookies: Option<Vec<Cookie>>,
},
/// The send failed. The last frame on a failed stream. Cookies collected before the failure
/// still come back — the transaction may have set some before the hop that failed.
Error {
message: String,
cookies: Option<Vec<Cookie>>,
},
}
-1
View File
@@ -88,7 +88,6 @@ yaak-grpc = { workspace = true }
yaak-http = { workspace = true }
yaak-license = { workspace = true, optional = true }
yaak-mac-window = { workspace = true }
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
yaak-plugins = { workspace = true }
yaak-sse = { workspace = true }
+29 -19
View File
@@ -4,53 +4,63 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
import_contents(window, &contents).await
let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?;
commit_import(window, plan)
}
pub(crate) async fn import_url<R: Runtime>(
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
plan_import_contents(window, &contents, destination).await
}
async fn import_contents<R: Runtime>(
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::import_data(ImportDataParams {
Ok(import::plan_import_data(PlanImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
workspace_context,
destination,
contents,
})
.await?)
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
+23 -13
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{import_data, import_url};
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -40,7 +40,7 @@ use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
@@ -1013,15 +1013,24 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
) -> YaakResult<BatchUpsertResult> {
import_data(&window, file_path).await
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
}
async fn cmd_commit_import<R: Runtime>(
window: WebviewWindow<R>,
plan: ImportPlan,
) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await
commit_import(&window, plan)
}
@@ -1258,14 +1267,6 @@ pub fn run() {
builder
.setup(|app| {
let lifecycle_host = yaak_lifecycle::Host::owner()
.with_responses_dir(app.path().app_data_dir()?.join("responses"));
if let Err(e) =
yaak_lifecycle::on_launch(&lifecycle_host, &app.db(), &app.blob_manager())
{
error!("on_launch hook failed: {e:?}");
}
// The RPC command registry — every frontend command dispatches
// through this via the single `rpc` Tauri command
app.manage(rpc_ext::build_rpc_router::<TauriRuntime>());
@@ -1365,6 +1366,15 @@ pub fn run() {
let info = history::get_or_upsert_launch_info(&h);
debug!("Launched Yaak {:?}", info);
});
// Cancel pending requests
let h = app_handle.clone();
tauri::async_runtime::block_on(async move {
let db = h.db();
let _ = db.cancel_pending_http_responses();
let _ = db.cancel_pending_grpc_connections();
let _ = db.cancel_pending_websocket_connections();
});
}
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -15,6 +15,7 @@ use yaak_models::error::Result;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{ModelPayload, UpdateSource};
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
const MODEL_CHANGES_POLL_INTERVAL_MS: u64 = 1000;
const MODEL_CHANGES_POLL_BATCH_SIZE: usize = 200;
@@ -151,11 +152,30 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
}
};
let db = query_manager.connect();
if let Err(err) = db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)
{
error!("Failed to prune model_changes rows on startup: {err:?}");
}
// Only stream writes that happen after this app launch.
let cursor = ModelChangeCursor::from_launch_time();
let poll_query_manager = query_manager.clone();
// GC response bodies orphaned by cascade deletes, which historically
// didn't clean the blob DB or responses directory
let gc_query_manager = query_manager.clone();
let gc_blob_manager = blob_manager.clone();
let gc_responses_dir = app_path.join("responses");
tauri::async_runtime::spawn_blocking(move || {
let db = gc_query_manager.connect();
match db.delete_orphaned_response_bodies(&gc_blob_manager, &gc_responses_dir) {
Ok(0) => {}
Ok(n) => log::info!("Deleted {n} orphaned response bodies"),
Err(e) => error!("Failed to delete orphaned response bodies: {e:?}"),
}
});
app_handle.manage(query_manager);
app_handle.manage(blob_manager);
+1 -1
View File
@@ -4,5 +4,5 @@
//! `yaak-commands` when the template commands did. Callers in this crate do not
//! need to track which is which.
pub use yaak_models::render::{render_grpc_request, render_http_request};
pub use yaak::render::{render_grpc_request, render_http_request};
pub use yaak_commands::render::{render_json_value, render_template};
+9 -6
View File
@@ -40,7 +40,7 @@ use yaak_models::models::{
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
@@ -441,12 +441,16 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
@@ -843,4 +847,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
}
-11
View File
@@ -70,17 +70,6 @@ export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
File diff suppressed because one or more lines are too long
+10
View File
@@ -2,3 +2,13 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
+13 -3
View File
@@ -23,7 +23,7 @@ use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
@@ -236,6 +237,14 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
@@ -909,8 +918,9 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+1
View File
@@ -19,6 +19,7 @@ hyper-util = { version = "0.1.17", default-features = false, features = ["client
log = { workspace = true }
mime_guess = "2.0.5"
native-tls = { version = "0.2", features = ["alpn"] }
regex = "1.11.1"
reqwest = { workspace = true, features = [
"rustls-tls-manual-roots-no-provider",
"native-tls",
+3 -11
View File
@@ -1,4 +1,4 @@
use crate::dns::{AddressFilter, LocalhostResolver};
use crate::dns::LocalhostResolver;
use crate::error::Result;
use log::{debug, info, warn};
use reqwest::{Client, ClientBuilder, Proxy, redirect};
@@ -103,18 +103,13 @@ pub struct HttpConnectionOptions {
pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
/// Refuse connections to addresses a hostname resolves to. `None` means
/// every resolved address is connectable, which is what the desktop wants:
/// a user sending to their own machine or their own network is the point.
/// A hosted sender is the caller that supplies one.
pub address_filter: Option<AddressFilter>,
}
impl HttpConnectionOptions {
/// Build a reqwest Client and return it along with the DNS resolver.
/// The resolver is returned separately so it can be configured per-request
/// to emit DNS timing events to the appropriate channel.
pub fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
pub(crate) fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
let mut client = client_builder()
.connection_verbose(true)
.redirect(redirect::Policy::none())
@@ -140,10 +135,7 @@ impl HttpConnectionOptions {
}
// Configure DNS resolver - keep a reference to configure per-request
let resolver = LocalhostResolver::with_address_filter(
self.dns_overrides.clone(),
self.address_filter.clone(),
);
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
client = client.dns_resolver(resolver.clone());
// Configure proxy
-39
View File
@@ -20,32 +20,15 @@ pub struct ResolvedOverride {
pub ipv6: Vec<Ipv6Addr>,
}
/// A veto on the addresses a hostname resolves to, consulted after resolution
/// and before any connection is made. Returning `Err` refuses the whole lookup
/// with that message; a hostname is never partially allowed.
///
/// A hosted sender uses this to refuse private and metadata ranges no matter
/// what name they hide behind. Checking here rather than on the URL is what
/// catches a public hostname that resolves to an internal address.
pub type AddressFilter = Arc<dyn Fn(IpAddr) -> std::result::Result<(), String> + Send + Sync>;
#[derive(Clone)]
pub struct LocalhostResolver {
fallback: HyperGaiResolver,
event_tx: Arc<RwLock<Option<mpsc::Sender<HttpResponseEvent>>>>,
overrides: Arc<HashMap<String, ResolvedOverride>>,
address_filter: Option<AddressFilter>,
}
impl LocalhostResolver {
pub fn new(dns_overrides: Vec<DnsOverride>) -> Arc<Self> {
Self::with_address_filter(dns_overrides, None)
}
pub fn with_address_filter(
dns_overrides: Vec<DnsOverride>,
address_filter: Option<AddressFilter>,
) -> Arc<Self> {
let resolver = HyperGaiResolver::new();
// Pre-parse DNS overrides into a lookup map
@@ -72,25 +55,9 @@ impl LocalhostResolver {
fallback: resolver,
event_tx: Arc::new(RwLock::new(None)),
overrides: Arc::new(overrides),
address_filter,
})
}
/// Apply the address filter, if any, to a resolved address list.
fn filter_addrs(
filter: &Option<AddressFilter>,
addrs: &[SocketAddr],
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
if let Some(filter) = filter {
for addr in addrs {
if let Err(reason) = filter(addr.ip()) {
return Err(Box::new(std::io::Error::other(reason)));
}
}
}
Ok(())
}
/// Set the event sender for the current request.
/// This should be called before each request to direct DNS events
/// to the appropriate channel.
@@ -105,7 +72,6 @@ impl Resolve for LocalhostResolver {
let host = name.as_str().to_lowercase();
let event_tx = self.event_tx.clone();
let overrides = self.overrides.clone();
let address_filter = self.address_filter.clone();
info!("DNS resolve called for: {}", host);
@@ -128,8 +94,6 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for override
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -161,8 +125,6 @@ impl Resolve for LocalhostResolver {
let addresses: Vec<String> = addrs.iter().map(|a| a.ip().to_string()).collect();
return Box::pin(async move {
Self::filter_addrs(&address_filter, &addrs)?;
// Emit DNS event for localhost resolution
let guard = event_tx.read().await;
if let Some(tx) = guard.as_ref() {
@@ -199,7 +161,6 @@ impl Resolve for LocalhostResolver {
Ok(addrs) => {
// Collect addresses for event emission
let addr_vec: Vec<SocketAddr> = addrs.collect();
Self::filter_addrs(&address_filter, &addr_vec)?;
let addresses: Vec<String> =
addr_vec.iter().map(|a| a.ip().to_string()).collect();
+1 -4
View File
@@ -5,12 +5,9 @@ pub mod decompress;
pub mod dns;
pub mod error;
pub mod manager;
pub mod path_placeholders;
mod proto;
pub mod sender;
pub mod tee_reader;
pub mod transaction;
pub mod types;
// Moved to yaak-models so the browser's wasm host can render requests with the
// same code; re-exported here so existing callers keep their path.
pub use yaak_models::path_placeholders;
@@ -1,4 +1,4 @@
use crate::models::HttpUrlParameter;
use yaak_models::models::HttpUrlParameter;
pub fn apply_path_placeholders(
url: &str,
@@ -34,41 +34,27 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
return url.to_string();
}
// A placeholder is `/` followed by the parameter's name (which starts with `:`), and it
// ends at `/`, `?`, `#`, a literal `:`, or the end of the URL. The `:` boundary is what
// lets `/:id:increment-importance` substitute the `:id` placeholder while leaving
// `:increment-importance` as literal text. `/:foooo` is not a match for `:foo`.
//
// A plain scan rather than a regex: the name is matched literally, so a name containing
// `.` or `+` means exactly that, and nothing else in the model layer needs a regex engine.
let name = p.name.as_str();
let value = urlencoding::encode(p.value.as_str());
let mut result = String::with_capacity(url.len());
let mut rest = url;
while let Some(slash) = rest.find('/') {
let after_slash = &rest[slash + 1..];
let is_placeholder = after_slash.starts_with(name)
&& after_slash[name.len()..]
.chars()
.next()
.is_none_or(|c| matches!(c, '/' | '?' | '#' | ':'));
if is_placeholder {
result.push_str(&rest[..=slash]);
result.push_str(&value);
rest = &after_slash[name.len()..];
} else {
result.push_str(&rest[..=slash]);
rest = after_slash;
}
}
result.push_str(rest);
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
// placeholder while leaving `:increment-importance` as literal text.
let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap();
let result = re
.replace_all(url, |cap: &regex::Captures| {
format!(
"{}{}{}",
cap[1].to_string(),
urlencoding::encode(p.value.as_str()),
cap[2].to_string()
)
})
.into_owned();
result
}
#[cfg(test)]
mod placeholder_tests {
use crate::models::{HttpRequest, HttpUrlParameter};
use crate::path_placeholders::{apply_path_placeholders, replace_path_placeholder};
use yaak_models::models::{HttpRequest, HttpUrlParameter};
#[test]
fn placeholder_middle() {
@@ -112,30 +98,6 @@ mod placeholder_tests {
);
}
#[test]
fn placeholder_name_is_matched_literally() {
// `.` in a name is a dot, not "any character".
let p = HttpUrlParameter {
name: ":id.v2".into(),
value: "xxx".into(),
enabled: true,
id: None,
};
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id.v2/:idXv2"),
"https://example.com/xxx/:idXv2",
);
}
#[test]
fn placeholder_repeated() {
let p = HttpUrlParameter { name: ":id".into(), value: "7".into(), enabled: true, id: None };
assert_eq!(
replace_path_placeholder(&p, "https://example.com/:id/:id"),
"https://example.com/7/7",
);
}
#[test]
fn placeholder_missing() {
let p = HttpUrlParameter {
-10
View File
@@ -1,10 +0,0 @@
[package]
name = "yaak-lifecycle"
version = "0.0.0"
edition = "2024"
authors = ["Gregory Schier"]
publish = false
[dependencies]
log = { workspace = true }
yaak-models = { workspace = true }
-130
View File
@@ -1,130 +0,0 @@
//! Lifecycle hooks shared by every host (desktop, browser, CLI). The hooks say
//! what happens at each moment; the host decides when and on which thread.
//!
//! Builds for wasm32, so it can depend on `yaak-models` but not on the send
//! engine or plugin runtime.
use log::info;
use std::path::PathBuf;
use yaak_models::blob_manager::BlobManager;
use yaak_models::client_db::ClientDb;
use yaak_models::error::Result;
const MODEL_CHANGES_RETENTION_HOURS: i64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
/// Has the database for the life of the app (desktop, browser worker)
Owner,
/// Short-lived, and an owner may be using the database right now (CLI).
/// Must not touch anything in flight.
Guest,
}
/// Paths are `None` on hosts without a filesystem (the browser).
#[derive(Debug, Clone)]
pub struct Host {
pub role: Role,
pub responses_dir: Option<PathBuf>,
}
impl Host {
pub fn owner() -> Self {
Self { role: Role::Owner, responses_dir: None }
}
pub fn guest() -> Self {
Self { role: Role::Guest, responses_dir: None }
}
pub fn with_responses_dir(mut self, dir: impl Into<PathBuf>) -> Self {
self.responses_dir = Some(dir.into());
self
}
}
/// Run once after the database is open, before the host answers anything.
pub fn on_launch(host: &Host, db: &ClientDb, blobs: &BlobManager) -> Result<()> {
db.prune_model_changes_older_than_hours(MODEL_CHANGES_RETENTION_HOURS)?;
if host.role == Role::Owner {
// Anything still in flight was left by the last session
db.cancel_pending_http_responses()?;
db.cancel_pending_grpc_connections()?;
db.cancel_pending_websocket_connections()?;
// Cascaded deletes never cleaned up response bodies
let deleted = match host.responses_dir.as_deref() {
Some(dir) => db.delete_orphaned_response_bodies(blobs, dir)?,
None => db.delete_orphaned_response_body_blobs(blobs)?,
};
if deleted > 0 {
info!("Deleted {deleted} orphaned response bodies");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use yaak_models::blob_manager::BodyChunk;
use yaak_models::init_in_memory;
use yaak_models::models::{HttpRequest, HttpResponse, HttpResponseState, Workspace};
use yaak_models::util::UpdateSource;
#[test]
fn only_the_owner_closes_what_the_last_session_left_open() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let source = &UpdateSource::Background;
let workspace = db
.upsert_workspace(
&Workspace { name: "Hooks".to_string(), ..Default::default() },
source,
)
.unwrap();
let request = db
.upsert_http_request(
&HttpRequest { workspace_id: workspace.id.clone(), ..Default::default() },
source,
)
.unwrap();
let pending = db
.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
state: HttpResponseState::Connected,
..Default::default()
},
source,
&blob_manager,
)
.unwrap();
on_launch(&Host::guest(), &db, &blob_manager).unwrap();
let response = db.get_http_response(&pending.id).unwrap();
assert!(matches!(response.state, HttpResponseState::Connected));
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
let response = db.get_http_response(&pending.id).unwrap();
assert!(matches!(response.state, HttpResponseState::Closed));
}
#[test]
fn owner_without_a_filesystem_still_sweeps_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
{
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
}
on_launch(&Host::owner(), &db, &blob_manager).unwrap();
assert!(!blob_manager.connect().body_exists("rs_gone").unwrap());
}
}
-2
View File
@@ -19,10 +19,8 @@ serde_json = { workspace = true }
schemars = { workspace = true }
sha2 = { workspace = true }
thiserror = { workspace = true }
urlencoding = "2.1.3"
ts-rs = { workspace = true, features = ["chrono-impl", "serde-json-impl"] }
yaak-core = { workspace = true }
yaak-templates = { path = "../yaak-templates", default-features = false }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
r2d2 = "0.8.10"
-16
View File
@@ -304,22 +304,6 @@ export type HttpResponseHeader = { name: string; value: string };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = {
validateCertificates: boolean;
followRedirects: boolean;
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number;
sendCookies: boolean;
storeCookies: boolean;
};
export type HttpUrlParameter = {
enabled?: boolean;
/**
+10
View File
@@ -2,3 +2,13 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportDestination = { "type": "new_workspace" } | { "type": "current_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: ImportPlanResources, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
export type ImportPlanResources = { workspaces: Array<PlannedImportResource<Workspace>>, environments: Array<PlannedImportResource<Environment>>, folders: Array<PlannedImportResource<Folder>>, httpRequests: Array<PlannedImportResource<HttpRequest>>, grpcRequests: Array<PlannedImportResource<GrpcRequest>>, websocketRequests: Array<PlannedImportResource<WebsocketRequest>>, };
export type PlannedImportResource<T> = { sourceKey?: string, resource: T, };
-59
View File
@@ -1,59 +0,0 @@
import { createStore } from "jotai";
import { expect, test } from "vitest";
import type { HttpResponseEvent } from "../bindings/gen_models";
import { httpResponseEventsAtom, modelStoreDataAtom } from "./atoms";
import { newStoreData } from "./util";
// The five setting events that every send writes, all within the same millisecond
const SETTING_NAMES = [
"validate_certificates",
"redirects",
"timeout",
"send_cookies",
"store_cookies",
];
function settingEvent(id: string, name: string, createdAt: string): HttpResponseEvent {
return {
model: "http_response_event",
id,
createdAt,
updatedAt: createdAt,
workspaceId: "wk_1",
responseId: "rs_1",
event: { type: "setting", name, value: "true" },
};
}
test("events with equal createdAt keep store (DB) insertion order", () => {
const store = createStore();
const data = newStoreData();
SETTING_NAMES.forEach((name, i) => {
data.http_response_event[`hre_${i}`] = settingEvent(
`hre_${i}`,
name,
"2026-08-17T00:00:00.123",
);
});
store.set(modelStoreDataAtom, data);
const names = store.get(httpResponseEventsAtom).map((e) => {
return e.event.type === "setting" ? e.event.name : e.event.type;
});
expect(names).toEqual(SETTING_NAMES);
});
test("events with distinct createdAt sort ascending", () => {
const store = createStore();
const data = newStoreData();
for (const [id, createdAt] of [
["hre_b", "2026-08-17T00:00:00.456"],
["hre_a", "2026-08-17T00:00:00.123"],
["hre_c", "2026-08-17T00:00:00.789"],
]) {
data.http_response_event[id!] = settingEvent(id!, "timeout", createdAt!);
}
store.set(modelStoreDataAtom, data);
expect(store.get(httpResponseEventsAtom).map((e) => e.id)).toEqual(["hre_a", "hre_b", "hre_c"]);
});
+1 -3
View File
@@ -61,9 +61,7 @@ export function createOrderedModelAtom<M extends AnyModel["model"]>(
const modelData = data[modelType] ?? {};
return Object.values(modelData).sort(
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
// NOTE: ties must return 0, or the comparator is inconsistent and V8 reorders
// equal-keyed rows. Sort is stable, so 0 preserves store (DB) insertion order.
const n = a[field] === b[field] ? 0 : a[field] > b[field] ? 1 : -1;
const n = a[field] > b[field] ? 1 : -1;
return order === "desc" ? n * -1 : n;
},
);
-107
View File
@@ -1,107 +0,0 @@
//! Carrying a send's cookie changes back into a jar.
//!
//! A send starts from a snapshot of the jar and hands back the jar as the
//! transaction left it. Writing that whole result over the jar would also
//! write over anything the user changed *while* the send was in flight — a
//! cookie edited or deleted in the jar view, or set by another send. So the
//! send's contribution is taken as a difference (what it added, changed, or
//! removed relative to its snapshot) and applied to whatever the jar holds now.
use crate::models::{Cookie, CookieDomain};
/// The identity of a cookie in a jar: two cookies with the same name, domain
/// and path are the same cookie, whatever their value or attributes.
type CookieKey = (String, CookieDomain, String);
fn key(c: &Cookie) -> CookieKey {
(c.name.clone(), c.domain.clone(), c.path.clone())
}
/// Apply the changes between `before` (the snapshot a send started from) and
/// `after` (the jar as the send left it) to `current` (the jar as it is now).
///
/// Cookies the send removed are removed; cookies it added or changed replace
/// their counterpart in `current`, or are appended. Cookies the send did not
/// touch are left exactly as `current` has them.
pub fn apply_cookie_changes(
current: Vec<Cookie>,
before: &[Cookie],
after: &[Cookie],
) -> Vec<Cookie> {
let removed: Vec<CookieKey> =
before.iter().filter(|b| !after.iter().any(|a| key(a) == key(b))).map(key).collect();
let changed: Vec<&Cookie> = after.iter().filter(|a| !before.iter().any(|b| b == *a)).collect();
let mut result: Vec<Cookie> =
current.into_iter().filter(|c| !removed.contains(&key(c))).collect();
for cookie in changed {
match result.iter_mut().find(|c| key(c) == key(cookie)) {
Some(existing) => *existing = cookie.clone(),
None => result.push(cookie.clone()),
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::CookieExpires;
fn cookie(name: &str, value: &str) -> Cookie {
Cookie {
name: name.to_string(),
value: value.to_string(),
domain: CookieDomain::HostOnly("example.com".to_string()),
expires: CookieExpires::SessionEnd,
path: "/".to_string(),
secure: false,
http_only: false,
same_site: None,
}
}
#[test]
fn a_send_that_changed_nothing_leaves_the_jar_alone() {
let before = vec![cookie("a", "1")];
let current = vec![cookie("a", "edited"), cookie("b", "2")];
assert_eq!(apply_cookie_changes(current.clone(), &before, &before), current);
}
#[test]
fn additions_and_changes_land_without_touching_concurrent_edits() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("a", "1"), cookie("b", "3"), cookie("c", "4")];
// Meanwhile the user edited `a` and added `d`.
let current = vec![cookie("a", "edited"), cookie("b", "2"), cookie("d", "5")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![
cookie("a", "edited"),
cookie("b", "3"),
cookie("d", "5"),
cookie("c", "4")
]
);
}
#[test]
fn a_cookie_the_send_removed_is_removed() {
let before = vec![cookie("a", "1"), cookie("b", "2")];
let after = vec![cookie("b", "2")];
let current = vec![cookie("a", "1"), cookie("b", "2"), cookie("c", "3")];
assert_eq!(
apply_cookie_changes(current, &before, &after),
vec![cookie("b", "2"), cookie("c", "3")]
);
}
#[test]
fn a_cookie_the_user_deleted_mid_send_stays_deleted_unless_the_send_set_it() {
let before = vec![cookie("a", "1")];
let after = vec![cookie("a", "1")]; // untouched by the send
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![]);
let after = vec![cookie("a", "fresh")]; // the send set it again
assert_eq!(apply_cookie_changes(vec![], &before, &after), vec![cookie("a", "fresh")]);
}
}
-2
View File
@@ -10,13 +10,11 @@ use yaak_database::SqlitePool;
pub mod blob_manager;
pub mod client_db;
pub mod cookies;
mod connection_or_tx;
pub mod error;
pub mod migrate;
pub mod models;
pub mod models_ops;
pub mod path_placeholders;
pub mod queries;
pub mod query_manager;
pub mod render;
+39 -316
View File
@@ -60,22 +60,8 @@ pub struct ProxySettingAuth {
pub password: String,
}
impl Default for ClientCertificate {
fn default() -> Self {
Self {
host: String::new(),
port: None,
crt_file: None,
key_file: None,
pfx_file: None,
passphrase: None,
enabled: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct ClientCertificate {
pub host: String,
@@ -89,18 +75,13 @@ pub struct ClientCertificate {
pub pfx_file: Option<String>,
#[serde(default)]
pub passphrase: Option<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
impl Default for DnsOverride {
fn default() -> Self {
Self { hostname: String::new(), ipv4: Vec::new(), ipv6: Vec::new(), enabled: true }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct DnsOverride {
pub hostname: String,
@@ -108,6 +89,7 @@ pub struct DnsOverride {
pub ipv4: Vec<String>,
#[serde(default)]
pub ipv6: Vec<String>,
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
}
@@ -158,70 +140,6 @@ impl Default for ResolvedHttpRequestSettings {
}
}
impl ResolvedHttpRequestSettings {
/// The `* Setting name=value` lines a send writes at the top of its timeline, sources and
/// all. Built here, once, so every host that runs a send — the desktop, the CLI, the browser
/// tab handing off to a proxy — records the same lines the same way.
pub fn timeline_events(&self) -> Vec<HttpResponseEventData> {
fn event<T>(
name: &str,
value: String,
setting: &ResolvedSetting<T>,
) -> HttpResponseEventData {
HttpResponseEventData::Setting {
name: name.to_string(),
value,
source_model: Some(setting.source_model.clone()),
source_id: setting.source_id.clone(),
source_name: setting.source_name.clone(),
}
}
let timeout = if self.request_timeout.value > 0 {
format!("{:?}", std::time::Duration::from_millis(self.request_timeout.value as u64))
} else {
"Infinity".to_string()
};
vec![
event(
"validate_certificates",
self.validate_certificates.value.to_string(),
&self.validate_certificates,
),
event("redirects", self.follow_redirects.value.to_string(), &self.follow_redirects),
event("timeout", timeout, &self.request_timeout),
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
]
}
}
/// The resolved send settings, values only: what an executor has to obey, with the sources
/// (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
/// crosses from a tab to the send proxy, and what the proxy reads.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpSendSettings {
pub validate_certificates: bool,
pub follow_redirects: bool,
/// Milliseconds. Zero or negative means no timeout.
pub timeout_ms: i32,
pub send_cookies: bool,
pub store_cookies: bool,
}
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
fn from(s: &ResolvedHttpRequestSettings) -> Self {
Self {
validate_certificates: s.validate_certificates.value,
follow_redirects: s.follow_redirects.value,
timeout_ms: s.request_timeout.value,
send_cookies: s.send_cookies.value,
store_cookies: s.store_cookies.value,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
@@ -229,6 +147,7 @@ pub struct InheritedBoolSetting {
#[serde(default)]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
#[serde(default = "default_true")]
pub value: bool,
}
@@ -464,31 +383,7 @@ impl UpsertModelInfo for Settings {
}
}
impl Default for Workspace {
fn default() -> Self {
Self {
model: "workspace".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
authentication: BTreeMap::new(),
authentication_type: None,
description: String::new(),
headers: Vec::new(),
name: String::new(),
encryption_key_challenge: None,
setting_validate_certificates: true,
setting_follow_redirects: true,
setting_request_timeout: 0,
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
setting_dns_overrides: Vec::new(),
setting_send_cookies: true,
setting_store_cookies: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "workspaces")]
@@ -508,13 +403,18 @@ pub struct Workspace {
pub encryption_key_challenge: Option<String>,
// Settings
#[serde(default = "default_true")]
pub setting_validate_certificates: bool,
#[serde(default = "default_true")]
pub setting_follow_redirects: bool,
pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32,
#[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")]
pub setting_send_cookies: bool,
#[serde(default = "default_true")]
pub setting_store_cookies: bool,
}
@@ -1020,16 +920,11 @@ impl UpsertModelInfo for Environment {
}
}
impl Default for EnvironmentVariable {
fn default() -> Self {
Self { enabled: true, name: String::new(), value: String::new(), id: None }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct EnvironmentVariable {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
pub name: String,
@@ -1054,35 +949,7 @@ pub struct ParentHeaders {
pub headers: Vec<HttpRequestHeader>,
}
impl Default for Folder {
fn default() -> Self {
Self {
model: "folder".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication: BTreeMap::new(),
authentication_type: None,
description: String::new(),
headers: Vec::new(),
name: String::new(),
sort_priority: 0.0,
setting_send_cookies: InheritedBoolSetting::default(),
setting_store_cookies: InheritedBoolSetting::default(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_follow_redirects: InheritedBoolSetting::default(),
setting_request_timeout: InheritedIntSetting::default(),
setting_request_message_size: InheritedIntSetting {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "folders")]
@@ -1107,6 +974,7 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
@@ -1220,16 +1088,11 @@ impl UpsertModelInfo for Folder {
}
}
impl Default for HttpRequestHeader {
fn default() -> Self {
Self { enabled: true, name: String::new(), value: String::new(), id: None }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpRequestHeader {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
pub name: String,
@@ -1238,16 +1101,11 @@ pub struct HttpRequestHeader {
pub id: Option<String>,
}
impl Default for HttpUrlParameter {
fn default() -> Self {
Self { enabled: true, name: String::new(), value: String::new(), id: None }
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct HttpUrlParameter {
#[serde(default = "default_true")]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
/// Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
@@ -1258,36 +1116,7 @@ pub struct HttpUrlParameter {
pub id: Option<String>,
}
impl Default for HttpRequest {
fn default() -> Self {
Self {
model: "http_request".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication: BTreeMap::new(),
authentication_type: None,
body: BTreeMap::new(),
body_type: None,
description: String::new(),
headers: Vec::new(),
method: "GET".to_string(),
name: String::new(),
sort_priority: 0.0,
url: String::new(),
url_parameters: Vec::new(),
setting_send_cookies: InheritedBoolSetting::default(),
setting_store_cookies: InheritedBoolSetting::default(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_follow_redirects: InheritedBoolSetting::default(),
setting_request_timeout: InheritedIntSetting::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "http_requests")]
@@ -1308,6 +1137,7 @@ pub struct HttpRequest {
pub body_type: Option<String>,
pub description: String,
pub headers: Vec<HttpRequestHeader>,
#[serde(default = "default_http_method")]
pub method: String,
pub name: String,
pub sort_priority: f64,
@@ -1563,36 +1393,7 @@ impl Default for WebsocketMessageType {
}
}
impl Default for WebsocketRequest {
fn default() -> Self {
Self {
model: "websocket_request".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication: BTreeMap::new(),
authentication_type: None,
description: String::new(),
headers: Vec::new(),
message: String::new(),
name: String::new(),
sort_priority: 0.0,
url: String::new(),
url_parameters: Vec::new(),
setting_send_cookies: InheritedBoolSetting::default(),
setting_store_cookies: InheritedBoolSetting::default(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_request_message_size: InheritedIntSetting {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "websocket_requests")]
@@ -1619,6 +1420,7 @@ pub struct WebsocketRequest {
pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
@@ -2251,35 +2053,7 @@ impl UpsertModelInfo for GraphQlIntrospection {
}
}
impl Default for GrpcRequest {
fn default() -> Self {
Self {
model: "grpc_request".to_string(),
id: String::new(),
created_at: NaiveDateTime::default(),
updated_at: NaiveDateTime::default(),
workspace_id: String::new(),
folder_id: None,
authentication_type: None,
authentication: BTreeMap::new(),
description: String::new(),
message: String::new(),
metadata: Vec::new(),
method: None,
name: String::new(),
service: None,
sort_priority: 0.0,
url: String::new(),
setting_validate_certificates: InheritedBoolSetting::default(),
setting_request_message_size: InheritedIntSetting {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema, TS)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
#[enum_def(table_name = "grpc_requests")]
@@ -2305,6 +2079,7 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure)
pub url: String,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
@@ -2955,12 +2730,22 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
}
}
/// Only used as a `from_row` fallback for an unparseable settings column. The
/// value a *new* model gets comes from that model's `Default` impl.
fn default_true() -> bool {
true
}
fn default_request_message_size() -> i32 {
DEFAULT_REQUEST_MESSAGE_SIZE
}
fn default_request_message_size_setting() -> InheritedIntSetting {
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
}
fn default_http_method() -> String {
"GET".to_string()
}
#[macro_export]
macro_rules! define_any_model {
($($type:ident),* $(,)?) => {
@@ -3104,65 +2889,3 @@ impl AnyModel {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Every model below carries `#[serde(default)]` at the container level, so a
/// missing key is filled from `Default::default()`, which makes each `Default`
/// impl the single definition of that model's defaults.
///
/// Deserializing `{}` therefore equals `Default::default()` by construction
/// today. What this catches is the two ways that can come apart again, both of
/// which have already bitten us:
///
/// 1. A field-level `#[serde(default = "...")]` (or bare `#[serde(default)]`)
/// added back on a field whose `Default` says something else. That is exactly
/// the shape of the bug this replaced: `setting_send_cookies` deserialized as
/// true but a derived `Default` produced false, so the bootstrapped workspace
/// silently sent no cookies.
/// 2. The container-level `#[serde(default)]` being dropped, which turns every
/// missing key into a deserialization error instead.
macro_rules! assert_default_matches_serde {
($($t:ty),+ $(,)?) => {
$(
assert_eq!(
serde_json::from_str::<$t>("{}").expect(concat!(
stringify!($t),
" must deserialize from an empty object"
)),
<$t>::default(),
concat!(stringify!($t), ": Default::default() disagrees with its serde defaults"),
);
)+
};
}
#[test]
fn defaults_match_serde_defaults() {
assert_default_matches_serde!(
Workspace,
HttpRequest,
Folder,
GrpcRequest,
WebsocketRequest,
HttpRequestHeader,
HttpUrlParameter,
EnvironmentVariable,
DnsOverride,
ClientCertificate,
InheritedBoolSetting,
InheritedIntSetting,
);
}
#[test]
fn defaults_carry_their_model_name() {
assert_eq!(Workspace::default().model, "workspace");
assert_eq!(HttpRequest::default().model, "http_request");
assert_eq!(Folder::default().model, "folder");
assert_eq!(GrpcRequest::default().model, "grpc_request");
assert_eq!(WebsocketRequest::default().model, "websocket_request");
}
}
@@ -45,31 +45,6 @@ impl<'a> ClientDb<'a> {
Ok(count)
}
/// Delete blob-stored response bodies whose owning HTTP response row no
/// longer exists. Blob ids are keyed by the response that owns them —
/// "{response_id}" for a response body, "{response_id}.request" for the
/// request that produced it — so ownership is the id's first segment.
///
/// The blob half of [`Self::delete_orphaned_response_bodies`], on its own
/// for hosts with no filesystem to hold body files. See `crate::hooks`.
///
/// Returns the number of orphaned bodies deleted.
pub fn delete_orphaned_response_body_blobs(&self, blobs: &BlobManager) -> Result<usize> {
let mut deleted = 0;
let blob_ctx = blobs.connect();
for body_id in blob_ctx.list_body_ids()? {
let response_id = body_id.split('.').next().unwrap_or_default();
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
blob_ctx.delete_chunks(&body_id)?;
deleted += 1;
}
Ok(deleted)
}
/// Delete response body data (blob chunks and body files) whose owning HTTP
/// response row no longer exists. Cascaded deletes (request, folder,
/// workspace) historically never cleaned the blob DB or the responses
@@ -84,7 +59,18 @@ impl<'a> ClientDb<'a> {
blobs: &BlobManager,
responses_dir: &std::path::Path,
) -> Result<usize> {
let mut deleted = self.delete_orphaned_response_body_blobs(blobs)?;
let mut deleted = 0;
// Blob chunks are keyed "{response_id}.request"
let blob_ctx = blobs.connect();
for body_id in blob_ctx.list_body_ids()? {
let response_id = body_id.split('.').next().unwrap_or_default();
if self.find_optional::<HttpResponse>(HttpResponseIden::Id, response_id).is_some() {
continue;
}
blob_ctx.delete_chunks(&body_id)?;
deleted += 1;
}
// Body files are stored as {responses_dir}/{response_id}
if let Ok(entries) = fs::read_dir(responses_dir) {
@@ -186,20 +172,19 @@ impl<'a> ClientDb<'a> {
#[cfg(test)]
mod tests {
use crate::blob_manager::{BlobManager, BodyChunk};
use crate::client_db::ClientDb;
use crate::blob_manager::BodyChunk;
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpResponse, Workspace};
use crate::util::UpdateSource;
/// A workspace, a request, and one response that still exists.
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let source = &UpdateSource::Background;
let workspace = db
.upsert_workspace(
&Workspace { name: "GC Test".to_string(), ..Default::default() },
source,
)
.upsert_workspace(&Workspace { name: "GC Test".to_string(), ..Default::default() }, source)
.expect("Failed to upsert workspace");
let request = db
.upsert_http_request(
@@ -207,57 +192,19 @@ mod tests {
source,
)
.expect("Failed to upsert request");
db.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
blob_manager,
)
.expect("Failed to upsert response")
}
/// What a browser host runs: no filesystem, so bodies exist only as blob
/// chunks, under both id shapes the blob DB uses.
#[test]
fn deletes_orphaned_response_body_blobs() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let live = db
.upsert_http_response(
&HttpResponse {
request_id: request.id.clone(),
workspace_id: workspace.id.clone(),
..Default::default()
},
source,
&blob_manager,
)
.expect("Failed to upsert response");
let live = seed_live_response(&db, &blob_manager);
let live_request_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
// needs to take it
let blob_ctx = blob_manager.connect();
blob_ctx.insert_chunk(&BodyChunk::new(&live.id, 0, b"live".to_vec())).unwrap();
blob_ctx
.insert_chunk(&BodyChunk::new(&live_request_body_id, 0, b"live".to_vec()))
.unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone", 0, b"dead".to_vec())).unwrap();
blob_ctx.insert_chunk(&BodyChunk::new("rs_gone.request", 0, b"dead".to_vec())).unwrap();
}
let deleted = db
.delete_orphaned_response_body_blobs(&blob_manager)
.expect("Failed to GC response body blobs");
assert_eq!(deleted, 2);
let blob_ctx = blob_manager.connect();
assert!(blob_ctx.body_exists(&live.id).unwrap());
assert!(blob_ctx.body_exists(&live_request_body_id).unwrap());
assert!(!blob_ctx.body_exists("rs_gone").unwrap());
assert!(!blob_ctx.body_exists("rs_gone.request").unwrap());
}
#[test]
fn deletes_orphaned_response_bodies() {
let (query_manager, blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let live = seed_live_response(&db, &blob_manager);
let live_body_id = format!("{}.request", live.id);
{
// Scope the connection: the in-memory pool only has one, and the GC
+11 -29
View File
@@ -25,7 +25,13 @@ impl<'a> ClientDb<'a> {
if workspaces.is_empty() {
workspaces.push(self.upsert_workspace(
&Workspace { name: "Yaak".to_string(), ..Default::default() },
&Workspace {
name: "Yaak".to_string(),
setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
..Default::default()
},
&UpdateSource::Background,
)?)
}
@@ -188,40 +194,16 @@ impl<'a> ClientDb<'a> {
pub fn default_headers() -> Vec<HttpRequestHeader> {
vec![
HttpRequestHeader {
enabled: true,
name: "User-Agent".to_string(),
value: "yaak".to_string(),
..Default::default()
id: None,
},
HttpRequestHeader {
enabled: true,
name: "Accept".to_string(),
value: "*/*".to_string(),
..Default::default()
id: None,
},
]
}
#[cfg(test)]
mod tests {
use crate::init_in_memory;
#[test]
fn bootstraps_first_workspace_with_real_defaults() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let workspaces = db.list_workspaces().expect("Failed to list workspaces");
let workspace = workspaces.first().expect("No workspace was bootstrapped");
// This workspace is built in Rust and never deserialized, so it only gets
// these values if `Workspace::default()` carries them. Asserted through the
// DB round trip, since the column values are what a fresh install lives with.
assert!(workspace.setting_send_cookies, "setting_send_cookies");
assert!(workspace.setting_store_cookies, "setting_store_cookies");
assert!(workspace.setting_follow_redirects, "setting_follow_redirects");
assert!(workspace.setting_validate_certificates, "setting_validate_certificates");
assert_eq!(
workspace.setting_request_message_size,
crate::models::DEFAULT_REQUEST_MESSAGE_SIZE
);
}
}
+2 -223
View File
@@ -1,159 +1,5 @@
//! Rendering requests against an environment chain.
//!
//! Lives here rather than beside the send engine so that the browser's wasm
//! host, which has the model layer but no sockets, renders exactly what the
//! desktop renders.
use crate::models::{
Environment, EnvironmentVariable, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use crate::path_placeholders::apply_path_placeholders;
use log::info;
use serde_json::Value;
use std::collections::{BTreeMap, HashMap};
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
/// Render every template in an HTTP request against an environment chain.
pub async fn render_http_request<T: TemplateCallback>(
request: &HttpRequest,
environment_chain: Vec<Environment>,
callback: &T,
options: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for parameter in request.url_parameters.clone() {
if !parameter.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: parameter.enabled,
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
id: parameter.id,
})
}
let mut headers = Vec::new();
for header in request.headers.clone() {
if !header.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: header.enabled,
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
id: header.id,
})
}
let mut body = BTreeMap::new();
for (key, value) in request.body.clone() {
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match request.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(template)) => {
disabled = parse_and_render(template.as_str(), vars, callback, options)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (key, value) in request.authentication.clone() {
if key == "disabled" {
auth.insert(key, Value::Bool(false));
} else {
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
}
}
auth
};
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
use crate::models::{Environment, EnvironmentVariable};
use std::collections::HashMap;
pub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {
let mut variables = HashMap::new();
@@ -181,70 +27,3 @@ fn add_variable_to_map(
map
}
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}
+80
View File
@@ -85,6 +85,86 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>,
}
/// Where a staged import will be committed.
///
/// The current workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
CurrentWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
/// A model staged for import.
///
/// `source_key` is intentionally part of the plan boundary even though the first import slice does
/// not persist it. Future linked imports can populate it without changing how plans contain models.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct PlannedImportResource<T> {
#[ts(optional)]
pub source_key: Option<String>,
pub resource: T,
}
impl<T> PlannedImportResource<T> {
pub fn new(resource: T) -> Self {
Self { source_key: None, resource }
}
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanResources {
pub workspaces: Vec<PlannedImportResource<Workspace>>,
pub environments: Vec<PlannedImportResource<Environment>>,
pub folders: Vec<PlannedImportResource<Folder>>,
pub http_requests: Vec<PlannedImportResource<HttpRequest>>,
pub grpc_requests: Vec<PlannedImportResource<GrpcRequest>>,
pub websocket_requests: Vec<PlannedImportResource<WebsocketRequest>>,
}
impl ImportPlanResources {
pub fn into_batch(self) -> BatchUpsertResult {
BatchUpsertResult {
workspaces: self.workspaces.into_iter().map(|v| v.resource).collect(),
environments: self.environments.into_iter().map(|v| v.resource).collect(),
folders: self.folders.into_iter().map(|v| v.resource).collect(),
http_requests: self.http_requests.into_iter().map(|v| v.resource).collect(),
grpc_requests: self.grpc_requests.into_iter().map(|v| v.resource).collect(),
websocket_requests: self.websocket_requests.into_iter().map(|v| v.resource).collect(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: ImportPlanResources,
pub warnings: Vec<ImportPlanWarning>,
}
pub fn get_workspace_export_resources(
db: &ClientDb,
yaak_version: &str,
+1 -1
View File
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, };
export type ImportResponse = { importer: string, resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
+2
View File
@@ -247,6 +247,8 @@ pub struct ImportRequest {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportResponse {
/// Display name of the importer that recognized the input.
pub importer: String,
pub resources: ImportResources,
}
+13 -2
View File
@@ -1104,8 +1104,19 @@ impl PluginManager {
.await?;
// TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e.payload {
InternalEventPayload::ImportResponse(resp) => Some(resp),
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
_ => None,
});
-7
View File
@@ -10,13 +10,6 @@ wasm-opt = false # Causes errors in CI (haven't figured out why yet)
[lib]
crate-type = ["cdylib", "rlib"]
[features]
default = ["wasm"]
# The `#[wasm_bindgen]` exports (parse_template etc.) that make up the
# @yaakapp-internal/templates package. Off for crates that link this one into
# their own wasm module and do not want these re-exported from theirs.
wasm = []
[dependencies]
base64 = "0.22.1"
serde = { workspace = true, features = ["derive"] }
-1
View File
@@ -4,7 +4,6 @@ pub mod format_json;
pub mod parser;
pub mod renderer;
pub mod strip_json_comments;
#[cfg(feature = "wasm")]
pub mod wasm;
pub use parser::*;
+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>;
}
-5
View File
@@ -25,11 +25,7 @@ crate-type = ["cdylib", "rlib"]
log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
md5 = "0.7"
yaak-lifecycle = { workspace = true }
yaak-models = { workspace = true }
# No default features: the template exports belong to @yaakapp-internal/templates, not this module
yaak-templates = { path = "../yaak-templates", default-features = false }
[target.'cfg(target_arch = "wasm32")'.dependencies]
console_error_panic_hook = "0.1"
@@ -39,4 +35,3 @@ sqlite-wasm-rs = "0.5"
sqlite-wasm-vfs = "0.2"
wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["console"] }
+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, rpc } from "./pkg";
-20
View File
@@ -25,26 +25,6 @@ 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.
*
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
* resolving to the rendered string. Passing nothing is allowed.
*
* Authentication is applied by the caller, not here, because the plugin that applies it
* needs to see the request as it will be sent.
*/
export function prepare_http_send(payload: any, plugins: any): Promise<any>;
/**
* What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
* shows an empty string where a send would refuse, since a half-typed template is not yet a
* mistake.
*/
export function render_template(payload: any, plugins: any): Promise<any>;
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
+19 -62
View File
@@ -62,38 +62,6 @@ export function boot() {
return ret;
}
/**
* Resolve and render a request for sending, exactly as the desktop does: the environment
* chain, inherited headers and auth, request settings, the cookie jar. Nothing here touches
* a socket.
*
* `plugins` is the template function bridge: a JS function taking a name and JSON args,
* resolving to the rendered string. Passing nothing is allowed.
*
* Authentication is applied by the caller, not here, because the plugin that applies it
* needs to see the request as it will be sent.
* @param {any} payload
* @param {any} plugins
* @returns {Promise<any>}
*/
export function prepare_http_send(payload, plugins) {
const ret = wasm.prepare_http_send(payload, plugins);
return ret;
}
/**
* What `cmd_render_template` does on the desktop. `ignore_error` matches it too: a preview
* shows an empty string where a send would refuse, since a half-typed template is not yet a
* mistake.
* @param {any} payload
* @param {any} plugins
* @returns {Promise<any>}
*/
export function render_template(payload, plugins) {
const ret = wasm.render_template(payload, plugins);
return ret;
}
/**
* Run one command as `label` (the calling tab's identity, which stands in for
* the desktop's window label on every write it makes).
@@ -241,10 +209,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;
@@ -532,7 +496,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(a, state0.b, arg0, arg1);
return wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -689,10 +653,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;
@@ -717,27 +677,24 @@ export function __wbg_versions_215a3ab1c9d5745a(arg0) {
const ret = arg0.versions;
return ret;
}
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`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1104, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
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`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
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__h20ab1db2d80221ce);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9);
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`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
return ret;
}
export function __wbindgen_cast_0000000000000005(arg0) {
@@ -774,30 +731,30 @@ export function __wbindgen_init_externref_table() {
table.set(offset + 2, true);
table.set(offset + 3, false);
}
function wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1);
}
function wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb(arg0, arg1, arg2);
}
function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h20ab1db2d80221ce(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h20ab1db2d80221ce(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3);
}
Binary file not shown.
+5 -7
View File
@@ -5,8 +5,6 @@ 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 rpc: (a: number, b: number, c: any, d: number, e: number) => [number, number, number];
export const rust_sqlite_wasm_abort: () => void;
export const rust_sqlite_wasm_assert_fail: (a: number, b: number, c: number, d: number) => void;
@@ -18,11 +16,11 @@ export const rust_sqlite_wasm_malloc: (a: number) => number;
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__h20ab1db2d80221ce: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_exn_store: (a: number) => void;
+3 -332
View File
@@ -12,10 +12,8 @@
//! JavaScript side owns that; this crate assumes it is the only writer.
//!
//! The command surface is deliberately narrow: what the frontend needs to keep
//! its model store coherent, blob storage, and the "prepare" half of a send
//! (resolve, inherit, render — see [`prepare_http_send`]). Putting bytes on the
//! network, plugins, git, sync and everything else with a socket or a
//! filesystem behind it lives elsewhere.
//! its model store coherent, and blob storage. Sending, plugins, git, sync and
//! everything else with a socket or a filesystem behind it lives elsewhere.
// Nothing in here means anything off wasm32, and building it there would drag
// SQLite's wasm C shim into a native compile. So on any other target the crate
@@ -26,19 +24,12 @@ use std::cell::RefCell;
use std::sync::mpsc;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use wasm_bindgen::prelude::*;
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::cookies::apply_cookie_changes;
use yaak_models::models::{
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
HttpSendSettings,
};
use yaak_models::models::AnyModel;
use yaak_models::models_ops;
use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request;
use yaak_models::util::{ModelPayload, UpdateSource};
use yaak_templates::{RenderOptions, TemplateCallback};
/// Names inside the VFS, not paths on any disk. Two files because the desktop
/// keeps two: models in one, blobs in the other.
@@ -52,10 +43,6 @@ struct Host {
events: mpsc::Receiver<ModelPayload>,
}
fn lifecycle_host() -> yaak_lifecycle::Host {
yaak_lifecycle::Host::owner()
}
thread_local! {
static HOST: RefCell<Option<Host>> = const { RefCell::new(None) };
}
@@ -104,10 +91,6 @@ pub async fn boot() -> Result<()> {
let (queries, blobs, events) =
yaak_models::init_standalone(DB_NAME, BLOB_DB_NAME).map_err(js_error)?;
if let Err(e) = yaak_lifecycle::on_launch(&lifecycle_host(), &queries.connect(), &blobs) {
web_sys::console::warn_2(&"on_launch hook failed".into(), &js_error(e));
}
HOST.with(|h| *h.borrow_mut() = Some(Host { queries, blobs, events }));
Ok(())
}
@@ -218,36 +201,6 @@ struct UpsertIntrospectionReq {
content: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ResponseIdReq {
response_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PersistSendCookiesReq {
cookie_jar_id: String,
before: Vec<Cookie>,
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 {
response_id: String,
workspace_id: String,
events: Vec<HttpResponseEventData>,
}
fn dispatch(
host: &Host,
cmd: &str,
@@ -352,48 +305,6 @@ fn dispatch(
// Nothing here can open a socket, so no connection ever produced any.
"models_grpc_events" | "models_websocket_events" => to_json(Vec::<()>::new()),
"web_get_http_request" => {
let req: RequestIdReq = from_js(payload)?;
to_json(host.queries.connect().get_http_request(&req.request_id).map_err(js_error)?)
}
"cmd_get_http_response_events" => {
let req: ResponseIdReq = from_js(payload)?;
to_json(
host.queries
.connect()
.list_http_response_events(&req.response_id)
.map_err(js_error)?,
)
}
// The cookies a send set or cleared, applied to the jar as it is *now* rather than
// written over it, so an edit made while the send was in flight survives.
"web_persist_send_cookies" => {
let req: PersistSendCookiesReq = from_js(payload)?;
if req.before == req.after {
return to_json(());
}
let db = host.queries.connect();
let jar = db.get_cookie_jar(&req.cookie_jar_id).map_err(js_error)?;
let cookies = apply_cookie_changes(jar.cookies.clone(), &req.before, &req.after);
db.upsert_cookie_jar(&CookieJar { cookies, ..jar }, source).map_err(js_error)?;
to_json(())
}
// The tab's half of the send timeline: the events the proxy streamed back, recorded
// under the response they belong to. Same rows the desktop's send task writes, and the
// writes fan out to every tab as `model_writes` like any other.
"web_insert_http_response_events" => {
let req: InsertResponseEventsReq = from_js(payload)?;
let db = host.queries.connect();
for event in req.events {
let model = HttpResponseEvent::new(&req.response_id, &req.workspace_id, event);
db.upsert_http_response_event(&model, source).map_err(js_error)?;
}
to_json(())
}
"cmd_get_workspace_meta" => {
let req: WorkspaceIdReq = from_js(payload)?;
let db = host.queries.connect();
@@ -401,250 +312,10 @@ fn dispatch(
to_json(db.get_or_create_workspace_meta(&workspace.id).map_err(js_error)?)
}
"cmd_delete_all_http_responses" => {
let req: RequestIdReq = from_js(payload)?;
host.queries
.connect()
.delete_all_http_responses_for_request(&req.request_id, source)
.map_err(js_error)?;
to_json(())
}
"cmd_delete_send_history" => {
let req: WorkspaceIdReq = from_js(payload)?;
host.queries
.with_tx(|tx| {
tx.delete_all_http_responses_for_workspace(&req.workspace_id, source)?;
tx.delete_all_grpc_connections_for_workspace(&req.workspace_id, source)?;
tx.delete_all_websocket_connections_for_workspace(&req.workspace_id, source)?;
Ok::<(), yaak_models::error::Error>(())
})
.map_err(js_error)?;
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"))),
}
}
/* -------------------------------------------------------------------------- */
/* Preparing a send */
/* -------------------------------------------------------------------------- */
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PrepareHttpSendReq {
request_id: String,
environment_id: Option<String>,
cookie_jar_id: Option<String>,
}
/// Everything a send needs that lives in the database, resolved and rendered: the desktop's
/// `HttpSendInputs`, in the shape a tab hands to the proxy and keeps for itself.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
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.
setting_events: Vec<HttpResponseEventData>,
/// The jar the send starts with, so the tab can write it back with the proxy's changes.
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>,
}
impl TemplateCallback for JsTemplateCallback {
fn run(
&self,
fn_name: &str,
args: HashMap<String, serde_json::Value>,
) -> impl std::future::Future<Output = yaak_templates::error::Result<String>> {
let call = self.call.clone();
let fn_name = fn_name.to_string();
let args = serde_json::to_string(&args).unwrap_or_else(|_| "{}".into());
async move {
use yaak_templates::error::Error::RenderError;
let Some(call) = call else {
return Err(RenderError(format!(
"This request uses the template function \"{fn_name}\", which needs plugins. \
No plugin provides it"
)));
};
let promise = call
.call2(&JsValue::NULL, &JsValue::from_str(&fn_name), &JsValue::from_str(&args))
.map_err(|e| RenderError(js_message(&e)))?;
let value = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise))
.await
.map_err(|e| RenderError(js_message(&e)))?;
value.as_string().ok_or_else(|| {
RenderError(format!("Template function \"{fn_name}\" did not return a string"))
})
}
}
fn transform_arg(
&self,
_fn_name: &str,
_arg_name: &str,
arg_value: &str,
) -> yaak_templates::error::Result<String> {
Ok(arg_value.to_string())
}
}
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.
///
/// `plugins` is the template function bridge: a JS function taking a name and JSON args,
/// resolving to the rendered string. Passing nothing is allowed.
///
/// Authentication is applied by the caller, not here, because the plugin that applies it
/// needs to see the request as it will be sent.
#[wasm_bindgen]
pub async fn prepare_http_send(payload: JsValue, plugins: JsValue) -> Result<JsValue> {
let req: PrepareHttpSendReq = from_js(payload)?;
// Everything from the database first, then release the host borrow before rendering.
let (request, environment_chain, settings, cookie_jar, auth_context_id) = with_host(|host| {
let db = host.queries.connect();
let request = db.get_http_request(&req.request_id).map_err(js_error)?;
let environment_chain = db
.resolve_environments(
&request.workspace_id,
request.folder_id.as_deref(),
req.environment_id.as_deref(),
)
.map_err(js_error)?;
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)?;
let cookie_jar = match req.cookie_jar_id.as_deref() {
Some(id) => Some(db.get_cookie_jar(id).map_err(js_error)?),
None => None,
};
let request = HttpRequest { authentication_type, authentication, headers, ..request };
Ok((request, environment_chain, settings, cookie_jar, auth_context_id))
})?;
let rendered = render_http_request(
&request,
environment_chain,
&template_callback(plugins),
&RenderOptions::throw(),
)
.await
.map_err(js_error)?;
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,
};
// JSON-compatible, as `rpc` does: the tab posts this to the proxy with `JSON.stringify`,
// and the default serializer's `Map` for the request body would stringify to `{}`.
use serde::Serialize as _;
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 */
/* -------------------------------------------------------------------------- */
+1
View File
@@ -21,5 +21,6 @@ yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+770 -73
View File
@@ -1,129 +1,826 @@
use crate::Result;
use log::info;
use std::collections::BTreeMap;
use yaak_core::WorkspaceContext;
use std::collections::{BTreeMap, BTreeSet};
use yaak_models::client_db::ClientDb;
use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo,
WebsocketRequest, Workspace,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanResources, ImportPlanWarning,
PlannedImportResource, UpdateSource,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct ImportDataParams<'a> {
pub struct PlanImportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub workspace_context: WorkspaceContext,
pub destination: ImportDestination,
pub contents: &'a str,
}
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
let import_result =
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
import_resources(params.query_manager, params.workspace_context, import_result.resources)
plan_import_resources(
params.query_manager,
import_result.importer,
params.destination,
import_result.resources,
)
}
pub fn import_resources(
/// Remap parsed importer resources into their selected destination.
///
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
/// existing model and also makes the plan safe to inspect before it is committed.
pub fn plan_import_resources(
query_manager: &QueryManager,
workspace_context: WorkspaceContext,
importer: String,
destination: ImportDestination,
resources: ImportResources,
) -> Result<BatchUpsertResult> {
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
) -> Result<ImportPlan> {
let mut warnings = Vec::new();
validate_destination(query_manager, &destination)?;
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
v
})
.collect();
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
let mut folder_ids = BTreeMap::new();
for folder in &resources.folders {
folder_ids.insert(folder.id.clone(), Folder::generate_id());
}
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id =
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
}
("", _) => {
v.parent_model = "workspace".to_string();
}
_ => {
v.parent_id = None;
}
};
v
})
.collect();
let mut workspace_ids = BTreeMap::new();
let mut workspaces = Vec::new();
let (default_workspace_id, target_folder_id) = match &destination {
ImportDestination::NewWorkspace => {
for source in &resources.workspaces {
let mut workspace = source.clone();
workspace.id = Workspace::generate_id();
workspace_ids.insert(source.id.clone(), workspace.id.clone());
workspaces.push(PlannedImportResource::new(workspace));
}
let folders: Vec<Folder> = resources
if workspaces.is_empty() {
let workspace = Workspace {
id: Workspace::generate_id(),
model: "workspace".to_string(),
name: format!("{} Import", display_importer_name(&importer)),
setting_follow_redirects: true,
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
setting_send_cookies: true,
setting_store_cookies: true,
..Default::default()
};
workspaces.push(PlannedImportResource::new(workspace));
}
(workspaces[0].resource.id.clone(), None)
}
ImportDestination::CurrentWorkspace { workspace_id, folder_id } => {
for source in &resources.workspaces {
workspace_ids.insert(source.id.clone(), workspace_id.clone());
}
if !resources.workspaces.is_empty() {
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
let skipped_fields = resources
.workspaces
.iter()
.flat_map(|source| {
workspace_fields_not_imported(source, &destination_workspace)
})
.collect::<BTreeSet<_>>();
if !skipped_fields.is_empty() {
let source = if resources.workspaces.len() == 1 {
resources.workspaces[0].name.clone()
} else {
format!("{} imported workspaces", resources.workspaces.len())
};
warnings.push(ImportPlanWarning {
title: "Workspace settings skipped".to_string(),
detail: format!("{source} · {}", display_list(&skipped_fields)),
});
}
}
(workspace_id.clone(), folder_id.clone())
}
};
let resolve_workspace_id = |source_id: &str| {
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
};
let resolve_folder_id = |source_id: Option<String>| match source_id {
Some(source_id) if source_folder_ids.contains(&source_id) => {
folder_ids.get(&source_id).cloned()
}
_ => target_folder_id.clone(),
};
let folders = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut folder| {
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
folder.folder_id = resolve_folder_id(folder.folder_id);
PlannedImportResource::new(folder)
})
.collect();
let http_requests: Vec<HttpRequest> = resources
let http_requests = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = HttpRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
let grpc_requests = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = GrpcRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
let websocket_requests = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = WebsocketRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
PlannedImportResource::new(request)
})
.collect();
info!("Importing data");
let importing_into_current = matches!(destination, ImportDestination::CurrentWorkspace { .. });
let mut separated_base_environments = Vec::new();
let mut converted_duplicate_base_environment = false;
let mut converted_duplicate_folder_environment = false;
let mut base_environment_workspaces = BTreeSet::new();
let mut folder_environment_ids = BTreeSet::new();
let environments = resources
.environments
.into_iter()
.map(|mut environment| {
environment.id = Environment::generate_id();
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
query_manager.with_tx(|tx| {
tx.batch_upsert(
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
("workspace", _) if importing_into_current => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
let source_name = environment.name.clone();
environment.name = format!("{} (Imported)", environment.name);
separated_base_environments.push((
source_name,
environment.name.clone(),
environment.variables.len(),
));
}
("workspace", _) => {
environment.parent_id = None;
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
environment.parent_model = "environment".to_string();
environment.name = format!("{} (Imported)", environment.name);
converted_duplicate_base_environment = true;
}
}
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
environment.parent_id = folder_ids.get(&parent_id).cloned();
if let Some(parent_id) = &environment.parent_id
&& !folder_environment_ids.insert(parent_id.clone())
{
environment.parent_model = "environment".to_string();
environment.parent_id = None;
converted_duplicate_folder_environment = true;
}
}
("folder", _) => {
// Never attach an imported folder environment to an existing folder: the model
// layer permits only one and would otherwise delete the destination's value.
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
("environment", _) => {
environment.parent_id = None;
}
_ => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
}
PlannedImportResource::new(environment)
})
.collect();
for (source_name, imported_name, variable_count) in separated_base_environments {
let variables = if variable_count == 1 { "variable" } else { "variables" };
warnings.push(ImportPlanWarning {
title: "Base environment kept separate".to_string(),
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
});
}
if converted_duplicate_base_environment {
warnings.push(ImportPlanWarning {
title: "Base environments separated".to_string(),
detail: "Only the first remains the base environment".to_string(),
});
}
if converted_duplicate_folder_environment {
warnings.push(ImportPlanWarning {
title: "Folder environments separated".to_string(),
detail: "Only the first remains attached to each folder".to_string(),
});
}
Ok(ImportPlan {
importer,
destination,
resources: ImportPlanResources {
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
},
warnings,
})
}
/// Commit a previously prepared plan in one transaction.
pub fn commit_import_plan(
query_manager: &QueryManager,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
validate_plan(&plan)?;
let resources = plan.resources.into_batch();
info!("Committing staged import from {}", plan.importer);
query_manager.with_tx(|tx| {
validate_destination_db(tx, &plan.destination)?;
tx.batch_upsert(
resources.workspaces,
resources.environments,
resources.folders,
resources.http_requests,
resources.grpc_requests,
resources.websocket_requests,
&UpdateSource::Import,
)
.map_err(crate::Error::from)
})
}
fn validate_destination(
query_manager: &QueryManager,
destination: &ImportDestination,
) -> Result<()> {
let db = query_manager.connect();
validate_destination_db(&db, destination)
}
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
let ImportDestination::CurrentWorkspace { workspace_id, folder_id } = destination else {
return Ok(());
};
db.get_workspace(workspace_id)?;
if let Some(folder_id) = folder_id {
let folder = db.get_folder(folder_id)?;
if folder.workspace_id != *workspace_id {
return Err(yaak_models::error::Error::GenericError(format!(
"Folder {folder_id} does not belong to workspace {workspace_id}"
))
.into());
}
}
Ok(())
}
fn validate_plan(plan: &ImportPlan) -> Result<()> {
let invalid = |message: String| -> Result<()> {
Err(yaak_models::error::Error::GenericError(message).into())
};
match &plan.destination {
ImportDestination::CurrentWorkspace { workspace_id, .. } => {
if !plan.resources.workspaces.is_empty() {
return invalid(
"A current-workspace import plan must not contain workspace updates"
.to_string(),
);
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| &v.resource.workspace_id)
.chain(plan.resources.folders.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.http_requests.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.grpc_requests.iter().map(|v| &v.resource.workspace_id))
.chain(plan.resources.websocket_requests.iter().map(|v| &v.resource.workspace_id));
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
return invalid(
"A current-workspace import plan contains resources for another workspace"
.to_string(),
);
}
if plan.resources.environments.iter().any(|v| v.resource.parent_model == "workspace") {
return invalid(
"A current-workspace import plan must not replace the base environment"
.to_string(),
);
}
}
ImportDestination::NewWorkspace => {
let workspace_ids = plan
.resources
.workspaces
.iter()
.map(|v| v.resource.id.as_str())
.collect::<BTreeSet<_>>();
if workspace_ids.is_empty() {
return invalid("A new-workspace import plan has no workspace".to_string());
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| v.resource.workspace_id.as_str())
.chain(plan.resources.folders.iter().map(|v| v.resource.workspace_id.as_str()))
.chain(
plan.resources.http_requests.iter().map(|v| v.resource.workspace_id.as_str()),
)
.chain(
plan.resources.grpc_requests.iter().map(|v| v.resource.workspace_id.as_str()),
)
.chain(
plan.resources
.websocket_requests
.iter()
.map(|v| v.resource.workspace_id.as_str()),
);
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
return invalid(
"A new-workspace import plan contains resources outside its workspaces"
.to_string(),
);
}
let mut base_environment_workspaces = BTreeSet::new();
if plan.resources.environments.iter().any(|v| {
v.resource.parent_model == "workspace"
&& !base_environment_workspaces.insert(v.resource.workspace_id.as_str())
}) {
return invalid(
"A new-workspace import plan contains multiple base environments for one workspace"
.to_string(),
);
}
}
}
let planned_folder_ids =
plan.resources.folders.iter().map(|v| v.resource.id.as_str()).collect::<BTreeSet<_>>();
if plan.resources.environments.iter().any(|v| {
v.resource.parent_model == "folder"
&& v.resource.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
}) {
return invalid(
"An import plan must not replace an existing folder environment".to_string(),
);
}
Ok(())
}
fn display_importer_name(importer: &str) -> &str {
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
}
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
let mut fields = Vec::new();
if source.name != destination.name {
fields.push("workspace name");
}
if source.description != destination.description {
fields.push("description");
}
if source.authentication != destination.authentication
|| source.authentication_type != destination.authentication_type
{
fields.push("authentication");
}
if source.headers != destination.headers {
fields.push("default headers");
}
if source.encryption_key_challenge != destination.encryption_key_challenge {
fields.push("encryption configuration");
}
if source.setting_validate_certificates != destination.setting_validate_certificates {
fields.push("certificate validation");
}
if source.setting_follow_redirects != destination.setting_follow_redirects {
fields.push("redirect behavior");
}
if source.setting_request_timeout != destination.setting_request_timeout {
fields.push("request timeout");
}
if source.setting_request_message_size != destination.setting_request_message_size {
fields.push("request message size");
}
if source.setting_dns_overrides != destination.setting_dns_overrides {
fields.push("DNS overrides");
}
if source.setting_send_cookies != destination.setting_send_cookies
|| source.setting_store_cookies != destination.setting_store_cookies
{
fields.push("cookie behavior");
}
fields
}
fn display_list(items: &BTreeSet<&str>) -> String {
let items = items.iter().copied().collect::<Vec<_>>();
match items.as_slice() {
[] => String::new(),
[item] => (*item).to_string(),
[first, second] => format!("{first} and {second}"),
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
fn destination_workspace() -> Workspace {
Workspace {
id: "wk_destination".to_string(),
model: "workspace".to_string(),
name: "Destination".to_string(),
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
authentication_type: Some("bearer".to_string()),
headers: vec![HttpRequestHeader {
enabled: true,
name: "X-Destination".to_string(),
value: "preserved".to_string(),
id: None,
}],
setting_validate_certificates: false,
setting_follow_redirects: false,
setting_request_timeout: 1234,
..Default::default()
}
}
fn imported_resources() -> ImportResources {
ImportResources {
workspaces: vec![Workspace {
id: "wk_source".to_string(),
model: "workspace".to_string(),
name: "Imported".to_string(),
authentication_type: Some("basic".to_string()),
setting_validate_certificates: true,
..Default::default()
}],
environments: vec![Environment {
id: "ev_source_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "imported".to_string(),
value: "yes".to_string(),
id: None,
}],
..Default::default()
}],
folders: vec![Folder {
id: "fl_source".to_string(),
model: "folder".to_string(),
workspace_id: "wk_source".to_string(),
name: "Imported Folder".to_string(),
..Default::default()
}],
http_requests: vec![
HttpRequest {
id: "rq_root".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
name: "Root Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/root".to_string(),
..Default::default()
},
HttpRequest {
id: "rq_nested".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
folder_id: Some("fl_source".to_string()),
name: "Nested Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/nested".to_string(),
..Default::default()
},
],
..Default::default()
}
}
#[test]
fn current_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut destination = destination_workspace();
let selected_folder = Folder {
id: "fl_selected".to_string(),
model: "folder".to_string(),
workspace_id: destination.id.clone(),
name: "Selected Folder".to_string(),
..Default::default()
};
{
let db = query_manager.connect();
destination = db
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
db.upsert_folder(&selected_folder, &UpdateSource::Import)
.expect("create selected folder");
db.upsert_environment(
&Environment {
id: "ev_destination_base".to_string(),
model: "environment".to_string(),
workspace_id: destination.id.clone(),
name: "Destination Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "destination".to_string(),
value: "keep".to_string(),
id: None,
}],
..Default::default()
},
&UpdateSource::Import,
)
.expect("create base environment");
}
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::CurrentWorkspace {
workspace_id: destination.id.clone(),
folder_id: Some(selected_folder.id.clone()),
},
imported_resources(),
)
.expect("plan import");
// Planning performed only reads.
{
let db = query_manager.connect();
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
assert_eq!(
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
1
);
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
}
assert!(plan.resources.workspaces.is_empty());
assert_eq!(plan.resources.folders[0].resource.workspace_id, destination.id);
assert_eq!(
plan.resources.folders[0].resource.folder_id.as_deref(),
Some(selected_folder.id.as_str())
);
let root_request = plan
.resources
.http_requests
.iter()
.find(|v| v.resource.name == "Root Request")
.expect("root request");
assert_eq!(root_request.resource.folder_id.as_deref(), Some(selected_folder.id.as_str()));
let nested_request = plan
.resources
.http_requests
.iter()
.find(|v| v.resource.name == "Nested Request")
.expect("nested request");
assert_eq!(
nested_request.resource.folder_id,
Some(plan.resources.folders[0].resource.id.clone())
);
assert_eq!(plan.resources.environments[0].resource.parent_model, "environment");
assert!(plan.resources.environments[0].resource.name.ends_with("(Imported)"));
assert_eq!(plan.warnings.len(), 2);
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Workspace settings skipped"
&& warning.detail.starts_with("Imported ·")
&& warning.detail.contains("authentication")
&& warning.detail.contains("default headers")
}));
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Base environment kept separate"
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
}));
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
assert!(committed.workspaces.is_empty());
assert_eq!(committed.http_requests.len(), 2);
assert_eq!(
query_manager
.connect()
.get_workspace(&destination.id)
.expect("get destination after commit"),
destination
);
}
#[test]
fn environment_collisions_are_explicit_and_do_not_overwrite() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.environments.extend([
Environment {
id: "ev_second_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Second Base".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
Environment {
id: "ev_folder_one".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder One".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
Environment {
id: "ev_folder_two".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder Two".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
]);
let plan = plan_import_resources(
&query_manager,
"Yaak".to_string(),
ImportDestination::NewWorkspace,
resources,
)
.expect("plan import");
assert_eq!(
plan.resources
.environments
.iter()
.filter(|v| v.resource.parent_model == "workspace")
.count(),
1
);
assert_eq!(
plan.resources
.environments
.iter()
.filter(|v| v.resource.parent_model == "folder")
.count(),
1
);
assert_eq!(plan.warnings.len(), 2);
}
#[test]
fn importer_id_conventions_all_flow_through_the_same_planner() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let destination = destination_workspace();
query_manager
.connect()
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
let resources = ImportResources {
workspaces: vec![
Workspace {
id: "GENERATE_ID::WORKSPACE_0".to_string(),
model: "workspace".to_string(),
name: "Generated ID Importer".to_string(),
..Default::default()
},
Workspace {
id: "wk_exported".to_string(),
model: "workspace".to_string(),
name: "Stable ID Importer".to_string(),
..Default::default()
},
],
http_requests: [
"GENERATE_ID::WORKSPACE_0",
"wk_exported",
"CURRENT_WORKSPACE",
]
.into_iter()
.enumerate()
.map(|(index, workspace_id)| HttpRequest {
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
model: "http_request".to_string(),
workspace_id: workspace_id.to_string(),
name: format!("Request {index}"),
method: "GET".to_string(),
..Default::default()
})
.collect(),
..Default::default()
};
let plan = plan_import_resources(
&query_manager,
"Compatibility".to_string(),
ImportDestination::CurrentWorkspace {
workspace_id: destination.id.clone(),
folder_id: None,
},
resources,
)
.expect("plan import");
assert!(plan.resources.workspaces.is_empty());
assert!(
plan.resources.http_requests.iter().all(|v| v.resource.workspace_id == destination.id)
);
assert_eq!(
plan.resources
.http_requests
.iter()
.map(|v| v.resource.id.as_str())
.collect::<BTreeSet<_>>()
.len(),
3
);
}
#[test]
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
let dir = tempfile::tempdir().expect("create temp directory");
let db_path = dir.path().join("models.sqlite");
let blob_path = dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
)
.expect("plan import");
let workspace_id = plan.resources.workspaces[0].resource.id.clone();
let environment_id = plan.resources.environments[0].resource.id.clone();
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
connection
.execute_batch(&format!(
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
))
.expect("install failure trigger");
drop(connection);
assert!(commit_import_plan(&query_manager, plan).is_err());
let db = query_manager.connect();
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
}
}
+1
View File
@@ -2,6 +2,7 @@ pub mod error;
pub mod export;
pub mod import;
pub mod plugin_events;
pub mod render;
pub mod response_body;
pub mod send;
+217
View File
@@ -0,0 +1,217 @@
use log::info;
use serde_json::Value;
use std::collections::BTreeMap;
use yaak_http::path_placeholders::apply_path_placeholders;
use yaak_models::models::{
Environment, GrpcRequest, HttpRequest, HttpRequestHeader, HttpUrlParameter,
};
use yaak_models::render::make_vars_hashmap;
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
pub async fn render_http_request<T: TemplateCallback>(
request: &HttpRequest,
environment_chain: Vec<Environment>,
callback: &T,
options: &RenderOptions,
) -> yaak_templates::error::Result<HttpRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut url_parameters = Vec::new();
for parameter in request.url_parameters.clone() {
if !parameter.enabled {
continue;
}
url_parameters.push(HttpUrlParameter {
enabled: parameter.enabled,
name: parse_and_render(parameter.name.as_str(), vars, callback, options).await?,
value: parse_and_render(parameter.value.as_str(), vars, callback, options).await?,
id: parameter.id,
})
}
let mut headers = Vec::new();
for header in request.headers.clone() {
if !header.enabled {
continue;
}
headers.push(HttpRequestHeader {
enabled: header.enabled,
name: parse_and_render(header.name.as_str(), vars, callback, options).await?,
value: parse_and_render(header.value.as_str(), vars, callback, options).await?,
id: header.id,
})
}
let mut body = BTreeMap::new();
for (key, value) in request.body.clone() {
let value = if key == "form" { strip_disabled_form_entries(value) } else { value };
body.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match request.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(template)) => {
disabled = parse_and_render(template.as_str(), vars, callback, options)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{template}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (key, value) in request.authentication.clone() {
if key == "disabled" {
auth.insert(key, Value::Bool(false));
} else {
auth.insert(key, render_json_value_raw(value, vars, callback, options).await?);
}
}
}
auth
};
let url = parse_and_render(request.url.clone().as_str(), vars, callback, options).await?;
let (url, url_parameters) = apply_path_placeholders(&url, &url_parameters);
Ok(HttpRequest { url, url_parameters, headers, body, authentication, ..request.to_owned() })
}
pub async fn render_grpc_request<T: TemplateCallback>(
r: &GrpcRequest,
environment_chain: Vec<Environment>,
cb: &T,
opt: &RenderOptions,
) -> yaak_templates::error::Result<GrpcRequest> {
let vars = &make_vars_hashmap(environment_chain);
let mut metadata = Vec::new();
for p in r.metadata.clone() {
if !p.enabled {
continue;
}
metadata.push(HttpRequestHeader {
enabled: p.enabled,
name: parse_and_render(p.name.as_str(), vars, cb, opt).await?,
value: parse_and_render(p.value.as_str(), vars, cb, opt).await?,
id: p.id,
})
}
let authentication = {
let mut disabled = false;
let mut auth = BTreeMap::new();
match r.authentication.get("disabled") {
Some(Value::Bool(true)) => {
disabled = true;
}
Some(Value::String(tmpl)) => {
disabled = parse_and_render(tmpl.as_str(), vars, cb, opt)
.await
.unwrap_or_default()
.is_empty();
info!(
"Rendering authentication.disabled as a template: {disabled} from \"{tmpl}\""
);
}
_ => {}
}
if disabled {
auth.insert("disabled".to_string(), Value::Bool(true));
} else {
for (k, v) in r.authentication.clone() {
if k == "disabled" {
auth.insert(k, Value::Bool(false));
} else {
auth.insert(k, render_json_value_raw(v, vars, cb, opt).await?);
}
}
}
auth
};
let url = parse_and_render(r.url.as_str(), vars, cb, opt).await?;
Ok(GrpcRequest { url, metadata, authentication, ..r.to_owned() })
}
fn strip_disabled_form_entries(v: Value) -> Value {
match v {
Value::Array(items) => Value::Array(
items
.into_iter()
.filter(|item| item.get("enabled").and_then(|e| e.as_bool()).unwrap_or(true))
.collect(),
),
v => v,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_strip_disabled_form_entries() {
let input = json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": false, "name": "disabled", "value": "gone"},
{"enabled": true, "name": "baz", "value": "qux"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"enabled": true, "name": "foo", "value": "bar"},
{"enabled": true, "name": "baz", "value": "qux"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_all_disabled() {
let input = json!([
{"enabled": false, "name": "a", "value": "b"},
{"enabled": false, "name": "c", "value": "d"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(result, json!([]));
}
#[test]
fn test_strip_disabled_form_entries_missing_enabled_defaults_to_kept() {
let input = json!([
{"name": "no_enabled_field", "value": "kept"},
{"enabled": false, "name": "disabled", "value": "gone"},
]);
let result = strip_disabled_form_entries(input);
assert_eq!(
result,
json!([
{"name": "no_enabled_field", "value": "kept"},
])
);
}
#[test]
fn test_strip_disabled_form_entries_non_array_passthrough() {
let input = json!("just a string");
let result = strip_disabled_form_entries(input.clone());
assert_eq!(result, input);
}
}
+55 -22
View File
@@ -1,3 +1,4 @@
use crate::render::render_http_request;
use async_trait::async_trait;
use log::warn;
use std::path::{Path, PathBuf};
@@ -24,11 +25,10 @@ use yaak_http::types::{
use yaak_models::blob_manager::{BlobManager, BodyChunk};
use yaak_models::models::{
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
ProxySettingAuth, ResolvedHttpRequestSettings,
HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,
ResolvedHttpRequestSettings, ResolvedSetting,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::render::render_http_request;
use yaak_models::util::{UpdateSource, generate_prefixed_id};
use yaak_plugins::events::{
CallHttpAuthenticationRequest, HttpHeader, PluginContext, RenderPurpose,
@@ -193,7 +193,6 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
proxy: runtime_config.proxy.clone(),
client_certificate,
dns_overrides: runtime_config.dns_overrides.clone(),
address_filter: None,
})
.await?;
@@ -716,24 +715,36 @@ pub async fn send_http_request<T: TemplateCallback>(
let started_at = Instant::now();
let request_started_url = sendable_request.url.clone();
for event in resolved_settings.timeline_events() {
if let HttpResponseEventData::Setting {
name,
value,
source_model,
source_id,
source_name,
} = event
{
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
name,
value,
source_model,
source_id,
source_name,
});
}
}
send_setting_event(
&event_tx,
"validate_certificates",
resolved_settings.validate_certificates.value.to_string(),
&resolved_settings.validate_certificates,
);
send_setting_event(
&event_tx,
"redirects",
sendable_request.options.follow_redirects.to_string(),
&resolved_settings.follow_redirects,
);
send_setting_event(
&event_tx,
"timeout",
timeout_setting_value(sendable_request.options.timeout),
&resolved_settings.request_timeout,
);
send_setting_event(
&event_tx,
"send_cookies",
cookie_behavior.send_cookies.to_string(),
&resolved_settings.send_cookies,
);
send_setting_event(
&event_tx,
"store_cookies",
cookie_behavior.store_cookies.to_string(),
&resolved_settings.store_cookies,
);
let mut http_response =
match executor.send(sendable_request, event_tx, cookie_behavior.clone()).await {
@@ -1119,6 +1130,28 @@ pub fn persist_cookies_after_send(
Ok(())
}
fn send_setting_event<T>(
event_tx: &mpsc::Sender<SenderHttpResponseEvent>,
name: impl Into<String>,
value: impl Into<String>,
setting: &ResolvedSetting<T>,
) {
let _ = event_tx.try_send(SenderHttpResponseEvent::Setting {
name: name.into(),
value: value.into(),
source_model: Some(setting.source_model.clone()),
source_id: setting.source_id.clone(),
source_name: setting.source_name.clone(),
});
}
fn timeout_setting_value(timeout: Option<Duration>) -> String {
match timeout {
Some(timeout) if !timeout.is_zero() => format!("{timeout:?}"),
_ => "Infinity".to_string(),
}
}
fn proxy_setting_from_settings(proxy: Option<ProxySetting>) -> HttpConnectionProxySetting {
match proxy {
None => HttpConnectionProxySetting::System,
-51
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",
@@ -71,7 +70,6 @@
"crates/yaak-web",
"crates/yaak-ws",
"crates-proxy/yaak-proxy-lib",
"crates-server/yaak-send-proxy",
"apps/yaak-client",
"apps/yaak-proxy"
],
@@ -274,10 +272,6 @@
"name": "@yaakapp-internal/proxy-lib",
"version": "1.0.0"
},
"crates-server/yaak-send-proxy": {
"name": "@yaakapp-internal/send-proxy",
"version": "1.0.0"
},
"crates-tauri/yaak-app-client": {
"name": "@yaakapp-internal/tauri-client",
"version": "1.0.0"
@@ -1471,21 +1465,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 +5633,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
@@ -5670,10 +5645,6 @@
"resolved": "crates/common/yaak-rpc-schema",
"link": true
},
"node_modules/@yaakapp-internal/send-proxy": {
"resolved": "crates-server/yaak-send-proxy",
"link": true
},
"node_modules/@yaakapp-internal/sse": {
"resolved": "crates/yaak-sse",
"link": true
@@ -12819,15 +12790,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",
@@ -15837,9 +15799,7 @@
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/models": "^1.0.0",
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/send-proxy": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0"
}
},
@@ -15888,17 +15848,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"
-2
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",
@@ -70,7 +69,6 @@
"crates/yaak-web",
"crates/yaak-ws",
"crates-proxy/yaak-proxy-lib",
"crates-server/yaak-send-proxy",
"apps/yaak-client",
"apps/yaak-proxy"
],
-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;
}
-2
View File
@@ -9,9 +9,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@yaakapp-internal/models": "^1.0.0",
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/send-proxy": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
-1
View File
@@ -49,7 +49,6 @@ function toSyncUnsubscribe(pending: Promise<Unsubscribe>): Unsubscribe {
}
const ALL_CAPABILITIES: PlatformCapabilities = {
httpSending: true,
grpc: true,
websocket: true,
git: true,
-2
View File
@@ -237,8 +237,6 @@ export interface Platform {
* from the cargo features they were built with.
*/
export interface PlatformCapabilities {
/** Send HTTP requests and see the whole response: every header, the redirect chain, timing. */
httpSending: boolean;
/** Send gRPC requests. Needs HTTP/2 trailers, so it needs a real backend. */
grpc: boolean;
/** Send WebSocket requests with custom headers and auth. */
+29 -49
View File
@@ -24,10 +24,8 @@ installs the Tauri host exactly as before.
```
tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaakapp-internal/web (wasm)
◀── model_writes ── crates/yaak-web → yaak-models → SQLite
└─ pages in IndexedDB
└── send.ts ──POST rendered request──▶ yaak-send-proxy (crates-server) ──▶ the internet
◀── NDJSON: events, response, body, cookies ──
◀── model_writes ── crates/yaak-web → yaak-models → SQLite
└─ pages in IndexedDB
```
| File | What it is |
@@ -35,19 +33,13 @@ tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaak
| `index.ts` | The `Platform` implementation. |
| `commands.ts` | The command table: model commands forward to the worker; the rest is fixed answers and refusals-with-a-reason. |
| `connection.ts` | A tab's end of the wire: request/response over a `MessagePort`, event delivery, and the tab's identity (`label`). |
| `send.ts` | Sending: the worker renders (`prepare_http_send`), the proxy executes, this file stores what comes back where the desktop stores it. |
| `proxy.ts` | The proxy's location and wire shapes, mirrored by hand from `crates-server/yaak-send-proxy/src/wire.rs`. |
| `worker.ts` | The process that owns the database. Loads the wasm, opens the DB once, answers each port, fans `model_writes` out to every port. |
| `protocol.ts` | The message shapes both sides import. |
| `errors.ts` | `UnsupportedCommandError`, the structured refusal. |
| `storage.ts` | `navigator.storage.persist()`. |
The Rust side is `crates/yaak-web` (`@yaakapp-internal/web`): `boot()`,
`rpc(cmd, payload, label)` returning `{ result, events }`, blob get/put, and
`prepare_http_send(payload)` — the database half of a send (environment chain,
inherited headers and auth, request settings, cookie jar, rendering), which is
`yaak_models::render::render_http_request`, the same function the desktop
renders with.
`rpc(cmd, payload, label)` returning `{ result, events }`, and blob get/put.
Its `pkg/` is committed; rebuilding needs a clang with a WebAssembly backend
(`brew install llvm`), and `build-wasm.cjs` skips with a notice when there
isn't one, so a desktop `npm run bootstrap` never depends on it.
@@ -78,14 +70,13 @@ Behaviours worth knowing before changing anything:
## Commands
109 commands are declared in `@yaakapp-internal/rpc-schema`. This host answers
32, declines 43 by name with a reason, and refuses the remaining 34 generically.
31, declines 44 by name with a reason, and refuses the remaining 34 generically.
### Implemented (32)
### Implemented (31)
| Group | Commands |
| --- | --- |
| Models | `models_workspace_models`, `models_upsert`, `models_delete`, `models_duplicate`, `models_get_settings`, `models_get_graphql_introspection`, `models_upsert_graphql_introspection`, `models_grpc_events`, `models_websocket_events` |
| Sending | `cmd_send_http_request` (through the send proxy; see below) |
| App | `cmd_metadata`, `cmd_get_workspace_meta`, `cmd_default_headers`, `cmd_get_themes`, `cmd_check_for_updates`, `cmd_dismiss_notification`, `cmd_plugin_init_errors` |
| Bodies | `cmd_http_response_body`, `cmd_http_response_body_path`, `cmd_http_request_body`, `cmd_get_http_response_events`, `cmd_get_sse_events` |
| Plugin surfaces (empty results) | `cmd_http_request_actions`, `cmd_websocket_request_actions`, `cmd_grpc_request_actions`, `cmd_workspace_actions`, `cmd_folder_actions`, `cmd_template_function_summaries`, `cmd_get_http_authentication_summaries`, `cmd_get_http_authentication_config` |
@@ -106,7 +97,7 @@ Some of these answer honestly rather than fully, and the difference matters:
- `cmd_metadata` reports empty strings for the data, log, plugin and project
directories. There is no filesystem behind this host.
### Declined by name (43)
### Declined by name (44)
Each returns an `UnsupportedCommandError` carrying `cmd`, a user-facing
`message`, and the `capability` a caller should have checked. The UI turns it
@@ -114,7 +105,7 @@ into a toast.
| Reason | Commands |
| --- | --- |
| Sending, the parts not wired yet | `cmd_send_ephemeral_request`, `cmd_delete_send_history`, `cmd_delete_all_http_responses`, `cmd_import_url` |
| Sending isn't available yet (slice 2) | `cmd_send_http_request`, `cmd_send_ephemeral_request`, `cmd_delete_send_history`, `cmd_delete_all_http_responses`, `cmd_import_url` |
| No plugin runtime | `cmd_reload_plugins`, `cmd_plugin_info`, `cmd_plugins_search`, `cmd_plugins_install`, `cmd_plugins_install_from_directory`, `cmd_plugins_uninstall`, `cmd_plugins_updates`, `cmd_plugins_update_all`, `cmd_template_function_config`, `cmd_template_tokens_to_string`, `cmd_call_http_request_action`, `cmd_call_websocket_request_action`, `cmd_call_grpc_request_action`, `cmd_call_workspace_action`, `cmd_call_folder_action`, `cmd_call_http_authentication_action`, `cmd_curl_to_request`, `cmd_format_graphql` |
| No filesystem | `cmd_import_data`, `cmd_export_data`, `cmd_save_response`, `cmd_save_base64_to_binary` |
| Needs a real socket | `cmd_grpc_reflect`, `cmd_grpc_go`, `cmd_delete_all_grpc_connections`, `cmd_ws_connect`, `cmd_ws_send`, `cmd_ws_close`, `cmd_ws_delete_connections` |
@@ -138,7 +129,7 @@ Reported honestly, so callers gate on the question rather than on the host:
| True | False |
| --- | --- |
| `httpSending`, `timeline`, `cookieJar` | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `multiWindow`, `windowChrome`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
| `cookieJar` (the jar stores and edits here; only filling it needs the sender) | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `timeline`, `multiWindow`, `windowChrome`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
`multiWindow: false` means the host cannot open a *second window* on demand —
what `cmd_new_child_window` does for Settings and workspace switching. It is not
@@ -180,37 +171,26 @@ other's writes for an echo of their own and drop them.
crates for their types), so the crate declares the handful of request shapes
it needs locally, and `commands.ts` stays typed against `RpcSchema`.
## Sending
## What slice 2 (the send proxy) will need from this layer
A page cannot see a response the way a desktop app can — CORS exposes a handful
of headers, redirects are followed silently, there is no timeline — so the
network half of a send runs on a small stateless proxy,
`crates-server/yaak-send-proxy`. This layer stays the only place data lives:
Sending becomes a stateless hosted service; this layer stays the only place data
lives. Concretely:
1. `send.ts` creates the `http_response` row (state `initialized`), as the
desktop does, so anything that goes wrong lands in the response pane.
2. The worker resolves and renders the request (`prepare_http_send`): the
environment chain, inherited headers and auth, request settings, the cookie
jar. This is the desktop's `HttpSendInputs`, in Rust, on the same model layer,
with `yaak_models::render::render_http_request`. Variables (`${[ name ]}`)
render here with no plugins involved.
3. The rendered request, the settings and the jar's cookies are POSTed to the
proxy. It streams back timeline events, the response head, body chunks and a
terminal frame carrying the jar as the send left it.
4. Each frame is written where the desktop writes it: the response row as it
progresses, `http_response_event` rows for the timeline (which is why
`timeline` is true), the body under the response id via `blob_put`, and the
cookie jar through `models_upsert`. Every write fans out to every tab.
**What sends today:** any saved request whose templates are variables and whose
authentication is none, or an inline header. Sending a request that needs a
template *function* (`${[ timestamp() ]}`) or an authentication plugin (bearer,
basic, OAuth, …) is refused before anything leaves the tab, with a message naming
what it needs; those light up when plugins run in the browser. Requests with a
file body or multipart file fields are refused by the proxy (it has no access to
your files, and must not read its own). And a request to `localhost` or a LAN
address can't work from a browser: the proxy runs elsewhere and refuses private
ranges outright — reaching your own machine's APIs is what the desktop app is for.
The proxy URL is `VITE_YAAK_SEND_PROXY_URL` at build time, defaulting to
`http://127.0.0.1:9227` (see `proxy.ts`). Run one with `cargo run -p yaak-send-proxy`.
1. **A rendered request to send.** The client assembles `HttpSendInputs` and
posts it. Nothing about the workspace is uploaded except what this request
needs.
2. **Cookies out, cookies in.** The active `cookie_jar` model's `cookies` array
goes up with the request; the proxy returns the jar as the exchange left it,
and the client upserts it back through `models_upsert` like any other write.
The proxy keeps nothing.
3. **A response body sink.** `blob_put(responseId, bytes)` in the worker
writes through the desktop's `blob_manager`, chunked the way it chunks.
Streaming will want an append path rather than one whole-body write.
4. **A request body sink** under `${responseId}.request`, which
`cmd_http_request_body` already reads.
5. **Response and timeline models.** `cmd_send_http_request` currently declines;
it will instead upsert an `http_response` as the exchange progresses, plus
`http_response_event` rows once `timeline` becomes true. Both flow through
the same `write()` helper, so other tabs see a send land live.
6. **Blob cleanup is the desktop's.** `delete_http_response` and
`delete_workspace` in `yaak-models` already remove blob chunks.
+56 -129
View File
@@ -17,22 +17,14 @@
* 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 +40,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.
*
@@ -98,24 +65,6 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload),
cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload),
/* ------------------------------- sending ------------------------------- */
// The tab renders and stores; a stateless proxy puts the bytes on the wire.
// See send.ts for the whole shape of it.
cmd_send_http_request: (payload, db, plugins) => {
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"),
);
},
/* -------------------------------- app ---------------------------------- */
@@ -184,67 +133,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 +163,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 -------------------------------- */
@@ -305,14 +203,30 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
return bytes == null ? null : Array.from(bytes);
},
// The rows the sender wrote for that response, same table as the desktop.
cmd_get_http_response_events: (payload, db) => db.rpc("cmd_get_http_response_events", payload),
async cmd_get_http_response_events() {
return [];
},
async cmd_get_sse_events() {
return [];
},
};
/**
* 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.
@@ -323,10 +237,17 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
* while the first is a slice away.
*/
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
// Saved requests send through the proxy (see send.ts). Ephemeral sends — the
// 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],
// Sending — the next slice. Everything else about a request works today;
// only the part that puts bytes on the network is missing.
cmd_send_http_request: [
"Sending isn't available in the browser yet — everything else about this request is saved",
null,
],
cmd_send_ephemeral_request: [
"Sending isn't available in the browser yet — everything else about this request is saved",
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"],
@@ -340,6 +261,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the send proxy, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
@@ -368,12 +290,18 @@ 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"],
// Sending history and its bookkeeping belong to the send slice.
cmd_delete_send_history: ["Sending isn't available in the browser yet", null],
cmd_delete_all_http_responses: ["Sending isn't available in the browser yet", null],
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
};
@@ -400,10 +328,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]);
-44
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,48 +159,10 @@ 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 }));
}
/** See `prepare_http_send` in crates/yaak-web: the database half of a send. */
prepareHttpSend<T>(payload: unknown): Promise<T> {
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
}
/** See `render_template` in crates/yaak-web. */
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);
+11 -23
View File
@@ -7,12 +7,11 @@
* the origin, so two tabs stay coherent for the same reason two desktop windows
* do: one process holds the data and pushes every write to all of them.
*
* Sending goes through a small stateless proxy, because a page cannot see a
* response the way a desktop app can (see send.ts). What a page genuinely
* cannot do is not faked: there is no file dialog, no second window, no
* clipboard read without a prompt. Those report false through `capabilities`
* and refuse with a reason if called anyway, so a missing feature shows up as a
* disabled control or a toast that explains itself, never as a silent no-op.
* What a page genuinely cannot do is not faked. There is no file dialog, no
* second window, no clipboard read without a prompt, and in this slice no
* sending. Those report false through `capabilities` and refuse with a reason
* if called anyway, so a missing feature shows up as a disabled control or a
* toast that explains itself, never as a silent no-op.
*/
import type {
@@ -28,27 +27,22 @@ 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. */
function capabilitiesFor(): PlatformCapabilities {
return {
// Through the send proxy: the tab renders, the proxy executes, the tab
// stores. Requests needing plugin auth or template functions are refused
// with the reason until plugins run here.
httpSending: true,
grpc: false,
websocket: false,
git: false,
sync: false,
// Certificates and proxies are decided by whoever puts the bytes on the
// wire, and the send proxy uses its own.
// wire. Nothing in the browser does yet.
tlsOptions: false,
// The jar can be edited and stored here; only filling it needs the sender.
cookieJar: true,
localFiles: false,
// The proxy streams the engine's events back and the sender stores them.
timeline: true,
timeline: false,
// Whether the host can put a *second window* on this data on demand — what
// `cmd_new_child_window` does for Settings and workspace switching. A tab
// can't, so those open in place instead. This is not a claim that nothing
@@ -58,9 +52,7 @@ function capabilitiesFor(): PlatformCapabilities {
// The browser draws the frame around the page. There are no traffic lights
// to leave room for and no window controls to draw.
windowChrome: false,
// Plugins 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
@@ -160,12 +152,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.
@@ -235,7 +223,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>(
@@ -248,7 +236,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 -18
View File
@@ -10,21 +10,6 @@
/** Tab → worker */
export type ToWorker =
| { type: "rpc"; id: number; cmd: string; payload: unknown; label: string }
/**
* The prepare half of a send: resolve, inherit and render a request against
* the database. Its own message rather than an `rpc` command because it is
* 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 +32,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";
-71
View File
@@ -1,71 +0,0 @@
/**
* The wire to the send proxy: where it is, and how to read what comes back.
*
* The shapes themselves are generated from `crates-server/yaak-send-proxy/src/wire.rs`
* into `@yaakapp-internal/send-proxy`, so the two sides cannot drift silently.
*/
import type { Frame } from "@yaakapp-internal/send-proxy";
/* ------------------------------- location -------------------------------- */
/**
* Where the tab sends. Build-time configuration for now: `VITE_YAAK_SEND_PROXY_URL`
* (Vite exposes `VITE_*` to the bundle), defaulting to a proxy on this machine at
* its default port. A per-user setting can replace this later without touching
* the callers, which only ever ask for the URL.
*/
export function proxyBaseUrl(): string {
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
const configured = env?.VITE_YAAK_SEND_PROXY_URL?.trim();
return (configured || "http://127.0.0.1:9227").replace(/\/+$/, "");
}
export function proxySendUrl(): string {
return `${proxyBaseUrl()}/v1/http/send`;
}
let identity: Promise<string> | null = null;
/**
* Who does the sending, for the timeline: `yaak-send-proxy 0.1.0 at http://…`.
* Asked of `/v1/health` once per page load; if the proxy can't be reached the
* URL alone is the answer, and the send itself will say why shortly after.
*/
export function proxyIdentity(): Promise<string> {
identity ??= fetch(`${proxyBaseUrl()}/v1/health`)
.then((res) => res.json() as Promise<{ version?: string }>)
.then((health) => `yaak-send-proxy ${health.version ?? ""} at ${proxyBaseUrl()}`.replace(" ", " "))
.catch(() => {
identity = null; // try again next send
return `send proxy at ${proxyBaseUrl()}`;
});
return identity;
}
/**
* Yield frames from an NDJSON stream as they arrive. A partial trailing line is
* held until its newline comes; anything left when the stream ends is dropped,
* because a frame without its newline is a frame the proxy didn't finish writing.
*/
export async function* readFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<Frame> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim() !== "") yield JSON.parse(line) as Frame;
newline = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
File diff suppressed because one or more lines are too long
-404
View File
@@ -1,404 +0,0 @@
/**
* Sending an HTTP request from a tab.
*
* A tab can't see a response the way the desktop can CORS hides most headers,
* redirects are followed silently, there is no timeline so the network half of
* a send happens on a small stateless proxy (`crates-server/yaak-send-proxy`).
* Everything else happens here, against this tab's own database, in the same
* order the desktop does it:
*
* 1. create the `http_response` row (state: initialized);
* 2. resolve and render the request in the worker (`prepare_http_send`: the
* environment chain, inherited headers and auth, request settings, cookie
* jar the desktop's `HttpSendInputs`, in Rust, on the same model layer);
* 3. POST the rendered request to the proxy and consume its stream: timeline
* events, the response head, body chunks, and a terminal frame;
* 4. write what comes back where the desktop writes it the response row as
* it progresses, `http_response_event` rows for the timeline, the body
* blob under the response id, the cookie jar with the proxy's changes.
*
* The proxy keeps nothing. Every byte it sees comes from this tab and every
* byte it returns is stored by this tab.
*/
// Types only: the models package imports this one at runtime, and a type import
// is erased, so there is no cycle.
import type {
Cookie,
CookieJar,
HttpRequest,
HttpResponse,
HttpResponseEventData,
HttpSendSettings,
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/send-proxy";
import type { WorkerConnection } from "./connection";
import type { WebPlugins } from "./plugins";
import { proxyIdentity, proxySendUrl, readFrames } from "./proxy";
/* -------------------------------- shapes --------------------------------- */
/**
* The response row as this file knows it: what identifies it, plus whatever
* has been written so far. Every other field is optional and takes the model
* layer's default when absent, the same way the desktop's row does defaults
* live in Rust, once.
*/
type ResponseRow = Pick<HttpResponse, "model" | "requestId" | "workspaceId"> &
Partial<Omit<HttpResponse, "model" | "requestId" | "workspaceId">>;
type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-web) 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 proxy builds that,
* so the plugin's answer goes onto the model and the proxy folds it in. Same
* bytes for a method that sets a header, which is every one that runs here.
*
* Not the same for one that *signs*, since the plugin sees the request before
* the proxy assembles it. AWS SigV4 and OAuth 1.0 are refused rather than
* mis-signed; see the sandbox README.
*/
async function applyAuthentication(
plugins: WebPlugins,
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;
/* --------------------------------- send ---------------------------------- */
export async function sendHttpRequest(
db: WorkerConnection,
plugins: WebPlugins,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
): Promise<ResponseRow> {
// The response row exists before anything can go wrong, as on the desktop, so
// a failure to render or to reach the proxy lands in the response pane as
// that response's error rather than as a toast that names no request.
const workspaceId = await workspaceIdOfRequest(db, requestId);
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
await response.create();
const cancel = new AbortController();
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
try {
await runSend(db, plugins, response, requestId, environmentId, cookieJarId, cancel.signal);
} catch (err) {
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
await response.finish({ error: message });
} finally {
unlistenCancel();
}
return response.current();
}
async function runSend(
db: WorkerConnection,
plugins: WebPlugins,
response: ResponseWriter,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
signal: AbortSignal,
): Promise<void> {
const prepared = await db.prepareHttpSend<PreparedHttpSend>({
requestId,
environmentId,
cookieJarId,
});
const request = await applyAuthentication(plugins, prepared);
await response.patch({ url: request.url });
// The first line of the timeline says what did the sending and where. A
// request through a proxy shows a different origin to the server than the
// user's machine, and this is where that should be visible.
const timeline = new TimelineWriter(db, response.id, response.workspaceId);
timeline.push([{ type: "info", message: `Executed by ${await proxyIdentity()}` }]);
timeline.push(prepared.settingEvents);
const body: SendRequest = {
request,
settings: prepared.settings,
cookies: prepared.cookieJar?.cookies ?? null,
};
const startedAt = performance.now();
const res = await fetch(proxySendUrl(), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal,
}).catch((err: unknown) => {
if (signal.aborted) throw err;
throw new Error(`Couldn't reach the send proxy at ${proxySendUrl()}: ${errorMessage(err)}`);
});
if (!res.ok) {
// A refusal, not a failed send: bad destination, rate limit, a body the
// proxy can't build. It comes as JSON with the reason.
const text = await res.text();
let reason = text;
try {
reason = (JSON.parse(text) as { error?: string }).error ?? text;
} catch {
/* not JSON; the text is the reason */
}
throw new Error(reason || `The send proxy answered ${res.status}`);
}
if (res.body == null) throw new Error("The send proxy sent no body");
const chunks: Uint8Array[] = [];
let received = 0;
let lastProgress = startedAt;
let terminal: Frame | null = null;
for await (const frame of readFrames(res.body)) {
switch (frame.type) {
case "event":
timeline.push([frame.event]);
break;
case "response":
await response.patch(headOf(frame));
break;
case "body": {
const bytes = base64ToBytes(frame.data);
chunks.push(bytes);
received += bytes.byteLength;
const now = performance.now();
if (now - lastProgress >= PROGRESS_INTERVAL_MS) {
lastProgress = now;
await response.patch({
contentLength: received,
elapsed: Math.round(now - startedAt),
});
}
break;
}
case "done":
case "error":
terminal = frame;
break;
}
if (terminal != null) break;
}
// Everything the proxy said about the timeline is in the database before the
// response is marked closed, so a reader that wakes on "closed" sees all of it.
await timeline.flush();
if (terminal == null) {
throw new Error("The send proxy closed the stream without finishing");
}
// Cookies come back on both outcomes: a hop before the failing one may have
// set some, and the desktop keeps those too.
if (prepared.cookieJar != null && terminal.cookies != null) {
await persistCookies(db, prepared.cookieJar, terminal.cookies);
}
if (terminal.type === "error") {
throw new Error(terminal.message);
}
// The body is written under the response id, which is how every reader —
// `cmd_http_response_body`, the image viewer, the download button — asks for
// it. One write, once the whole body is here: the worker's blob store has no
// append, and a body larger than memory is over the proxy's cap anyway.
await db.blobPut(response.id, concat(chunks, received));
await response.finish({
contentLength: terminal.contentLength,
contentLengthCompressed: terminal.contentLengthCompressed,
elapsed: terminal.elapsed,
});
}
function headOf(frame: Extract<Frame, { type: "response" }>): ResponsePatch {
return {
state: "connected",
status: frame.status,
statusReason: frame.statusReason,
url: frame.url,
remoteAddr: frame.remoteAddr,
version: frame.version,
headers: frame.headers,
requestHeaders: frame.requestHeaders,
contentLength: frame.contentLength,
elapsedHeaders: frame.elapsedHeaders,
elapsedDns: frame.elapsedDns,
};
}
/* ------------------------------- helpers --------------------------------- */
/**
* The response row, written the way the desktop writes it: created empty,
* patched as the send progresses, closed at the end. Each write goes through
* `models_upsert`, so every tab on this database sees the response land.
*/
class ResponseWriter {
private state: ResponseRow;
constructor(
private readonly db: WorkerConnection,
initial: ResponseRow,
) {
this.state = initial;
}
get id(): string {
return this.state.id ?? "";
}
get workspaceId(): string {
return this.state.workspaceId;
}
current(): ResponseRow {
return this.state;
}
/** Create the row. Everything but its identity is the model layer's default. */
async create(): Promise<void> {
const id = await this.db.rpc<string>("models_upsert", { model: this.state });
this.state = { ...this.state, id };
}
async patch(patch: ResponsePatch): Promise<void> {
// Structured clone carries `undefined` across to the worker as a present
// key, and the model layer reads that as "wrong type" and refuses the whole
// model. Nothing here should produce one, but a missing wire field must
// not take the response row down with it.
const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
this.state = { ...this.state, ...defined };
await this.db.rpc("models_upsert", { model: this.state });
}
async finish(patch: ResponsePatch): Promise<void> {
await this.patch({ ...patch, state: "closed" });
}
}
/**
* Timeline events, written in the order they arrived. Writes are chained rather
* than awaited inline so a burst of `header_down` events doesn't serialise the
* body read behind a database round trip each, and `flush()` is the point at
* which the whole timeline is known to be in the database.
*/
class TimelineWriter {
private queue: HttpResponseEventData[] = [];
private inFlight: Promise<void> = Promise.resolve();
constructor(
private readonly db: WorkerConnection,
private readonly responseId: string,
private readonly workspaceId: string,
) {}
push(events: HttpResponseEventData[]): void {
if (events.length === 0) return;
this.queue.push(...events);
this.inFlight = this.inFlight.then(() => this.drain());
}
private async drain(): Promise<void> {
if (this.queue.length === 0) return;
const events = this.queue;
this.queue = [];
await this.db.rpc("web_insert_http_response_events", {
responseId: this.responseId,
workspaceId: this.workspaceId,
events,
});
}
flush(): Promise<void> {
return this.inFlight;
}
}
/**
* Carry the send's cookie changes into the jar. The worker applies them as a
* difference against the jar as it is now (see `apply_cookie_changes` in
* yaak-models), so an edit made while the send was in flight survives rather
* than being written over by the send's stale snapshot.
*/
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
}
/**
* The request's workspace, needed to create the response row before the worker
* has resolved the request (which is where a render refusal would land).
*/
async function workspaceIdOfRequest(db: WorkerConnection, requestId: string): Promise<string> {
const req = await db.rpc<{ workspaceId: string }>("web_get_http_request", { requestId });
return req.workspaceId;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function base64ToBytes(data: string): Uint8Array {
const bin = atob(data);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function concat(chunks: Uint8Array[], total: number): Uint8Array {
if (chunks.length === 1) return chunks[0]!;
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}
+1 -36
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 } = engine!;
try {
switch (message.type) {
@@ -167,16 +142,6 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
}
return;
}
case "prepare_http_send": {
const prepared = await prepare_http_send(message.payload, templateBridge(port));
send(port, { type: "result", id: message.id, result: prepared });
return;
}
case "render_template": {
const rendered = await render_template(message.payload, templateBridge(port));
send(port, { type: "result", id: message.id, result: rendered });
return;
}
case "blob_get": {
const bytes = blob_get(message.blobId);
if (bytes == null) {
+1 -1
View File
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, };
export type ImportResponse = { importer: string, resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
+474 -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;
@@ -131,6 +167,7 @@ export class PluginInstance {
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: "import_response",
importer: this.#mod.importer.name,
resources: reply.resources as ImportResources,
};
this.#sendPayload(context, replyPayload, replyId);
@@ -593,64 +630,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"
}
}

Some files were not shown because too many files have changed in this diff Show More