mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-19 01:45:23 +02:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
252b151fcb | ||
|
|
85ba3e6852 | ||
|
|
20c0efc2a5 | ||
|
|
f8d6dfbdaa | ||
|
|
115615d994 | ||
|
|
538f782068 | ||
|
|
3f202ff664 | ||
|
|
131b7e5ab1 | ||
|
|
69083918f9 | ||
|
|
4b2dcf9a1a | ||
|
|
569f552d79 | ||
|
|
33f32cccf6 | ||
|
|
7ca772347f | ||
|
|
b89c448345 | ||
|
|
2021df112a |
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
dist
|
||||
**/dist
|
||||
target
|
||||
**/target
|
||||
.claude
|
||||
vendored
|
||||
**/vendored
|
||||
*.log
|
||||
.git
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y cmake ninja-build libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libnss3 patchelf xdg-utils
|
||||
# crates/yaak-web compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# crates/yaak-wasm compiles SQLite to wasm via sqlite-wasm-rs, whose C shim
|
||||
# uses C23 [[noreturn]] and expects a freestanding wasm32 target. Ubuntu
|
||||
# 22.04 ships only clang <=15: 14 rejects the attribute, and 15 falls
|
||||
# through to host glibc headers ("bits/libc-header-start.h" not found).
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Release web image
|
||||
|
||||
# Builds ghcr.io/mountain-loop/yaak-web: the browser client and the server that serves it.
|
||||
# One image per architecture on its own native runner (emulating a Rust release build is hours),
|
||||
# joined into one multi-arch tag at the end.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: [v*]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: Version to publish, without the v (e.g. 2026.2.0). Empty publishes main and sha tags only.
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE: ghcr.io/mountain-loop/yaak-web
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.repository == 'mountain-loop/yaak'
|
||||
name: Build ${{ matrix.platform }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-22.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
arch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.web
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p "${{ runner.temp }}/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
publish:
|
||||
name: Publish manifest
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digest-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# `latest` follows a release tag, and a manual run that names a version — the way to
|
||||
# publish before the first release. A prerelease (v2026.2.1-beta.1) never takes it.
|
||||
- name: Tags
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }}
|
||||
type=raw,value=latest,enable=${{ inputs.version != '' || (github.event_name == 'push' && !contains(github.ref_name, '-')) }}
|
||||
type=ref,event=branch
|
||||
type=sha,format=short
|
||||
|
||||
- name: Create and push the manifest
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect
|
||||
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
|
||||
Generated
+66
-2
@@ -619,6 +619,8 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"itoa",
|
||||
"matchit",
|
||||
"memchr",
|
||||
@@ -627,10 +629,15 @@ 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]]
|
||||
@@ -651,6 +658,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3276,6 +3284,12 @@ version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -9531,6 +9545,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9539,12 +9554,22 @@ version = "0.6.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||
dependencies = [
|
||||
"async-compression",
|
||||
"bitflags 2.11.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -9569,6 +9594,7 @@ 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",
|
||||
@@ -11277,6 +11303,7 @@ dependencies = [
|
||||
"yaak-grpc",
|
||||
"yaak-http",
|
||||
"yaak-license",
|
||||
"yaak-lifecycle",
|
||||
"yaak-mac-window",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
@@ -11342,6 +11369,7 @@ dependencies = [
|
||||
"yaak-core",
|
||||
"yaak-crypto",
|
||||
"yaak-http",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-plugins",
|
||||
"yaak-templates",
|
||||
@@ -11490,7 +11518,6 @@ dependencies = [
|
||||
"log 0.4.29",
|
||||
"mime_guess",
|
||||
"native-tls",
|
||||
"regex 1.11.1",
|
||||
"reqwest 0.12.20",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -11526,6 +11553,14 @@ 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"
|
||||
@@ -11559,8 +11594,10 @@ dependencies = [
|
||||
"sha2",
|
||||
"thiserror 2.0.17",
|
||||
"ts-rs",
|
||||
"urlencoding",
|
||||
"yaak-core",
|
||||
"yaak-database",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11732,12 +11769,13 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-web"
|
||||
name = "yaak-wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
"log 0.4.29",
|
||||
"md5 0.7.0",
|
||||
"serde",
|
||||
"serde-wasm-bindgen",
|
||||
"serde_json",
|
||||
@@ -11745,6 +11783,32 @@ dependencies = [
|
||||
"sqlite-wasm-vfs",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"yaak-lifecycle",
|
||||
"yaak-models",
|
||||
"yaak-templates",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaak-web"
|
||||
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",
|
||||
]
|
||||
|
||||
|
||||
+5
-1
@@ -14,6 +14,7 @@ members = [
|
||||
"crates/yaak-git",
|
||||
"crates/yaak-grpc",
|
||||
"crates/yaak-http",
|
||||
"crates/yaak-lifecycle",
|
||||
"crates/yaak-models",
|
||||
"crates/yaak-plugins",
|
||||
"crates/yaak-sse",
|
||||
@@ -21,11 +22,13 @@ members = [
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-tls",
|
||||
"crates/yaak-ws",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-wasm",
|
||||
"crates/yaak-api",
|
||||
"crates/yaak-proxy",
|
||||
# Proxy-specific crates
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# Server crates (the browser tier's hosted send executor)
|
||||
"crates-server/yaak-web",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
@@ -77,6 +80,7 @@ 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" }
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# Yaak in a browser, whole: the web client and the server that executes its sends, in one
|
||||
# image serving both from one origin.
|
||||
#
|
||||
# docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||
#
|
||||
# See crates-server/yaak-web/README.md for the knobs.
|
||||
|
||||
FROM node:22-slim AS web
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git python3 make g++ ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
# `npm ci` runs a prepare hook (`vp config`) that shells out to git, and there is no .git in
|
||||
# the build context — it is ignored, and in a worktree it is a pointer file anyway.
|
||||
RUN git init -q && git add -A \
|
||||
&& git -c user.email=build@yaak.app -c user.name=build commit -qm build
|
||||
# Empty means the tab posts sends to its own origin, which is what this image serves. Set it
|
||||
# only to build a bundle for a deployment whose server lives somewhere else.
|
||||
ARG VITE_YAAK_WEB_URL=""
|
||||
ENV VITE_YAAK_WEB_URL=$VITE_YAAK_WEB_URL
|
||||
ENV YAAK_TARGET=web
|
||||
# crates/yaak-wasm's wasm package is committed; rebuilding it needs a clang with a WebAssembly
|
||||
# backend, which this image has no reason to carry.
|
||||
ENV SKIP_WASM_BUILD=1
|
||||
RUN npm ci
|
||||
RUN node_modules/.bin/vp -C apps/yaak-client build
|
||||
|
||||
FROM rust:1-bookworm AS server
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config libssl-dev protobuf-compiler && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-web
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||
# docker run ghcr.io/mountain-loop/yaak-web yaak-web
|
||||
CMD ["yaak-web", "--serve", "/srv"]
|
||||
@@ -1,33 +1,42 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import type { SettingsTab } from "../components/Settings/Settings";
|
||||
import type { SettingsTab, SettingsTabWithSubtab } from "../components/Settings/Settings";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { createFastMutation } from "../hooks/useFastMutation";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { router } from "../lib/router";
|
||||
import { rpc } from "../lib/rpc";
|
||||
|
||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab | null>({
|
||||
mutationKey: ["open_settings"],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const to = "/workspaces/$workspaceId/settings" as const;
|
||||
const params = { workspaceId };
|
||||
const search = { tab: (tab ?? undefined) as SettingsTab | undefined };
|
||||
|
||||
// Settings is its own window where the host has windows to give. Where it
|
||||
// doesn't — a browser tab — the same route opens in place, which is the
|
||||
// whole difference: it is already a route, not a separate app.
|
||||
// doesn't — a browser tab — it's a dialog like any other, so opening it
|
||||
// doesn't take you away from the request you were working on.
|
||||
if (!platform.capabilities.multiWindow) {
|
||||
await router.navigate({ to, params, search });
|
||||
// Imported here so Settings stays out of the startup bundle, the way the
|
||||
// route that renders it on desktop already keeps it
|
||||
const { default: Settings } = await import("../components/Settings/Settings");
|
||||
showDialog({
|
||||
id: "settings",
|
||||
size: "md",
|
||||
className: "h-[calc(100vh-5rem)] max-h-150! overflow-hidden",
|
||||
noPadding: true,
|
||||
noScroll: true,
|
||||
// Keyed so opening a specific tab while the dialog is already up moves to it
|
||||
render: ({ hide }) => <Settings key={tab ?? "general"} tab={tab} hide={hide} />,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const location = router.buildLocation({ to, params, search });
|
||||
const location = router.buildLocation({
|
||||
to: "/workspaces/$workspaceId/settings",
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await rpc("cmd_new_child_window", {
|
||||
url: location.href,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useSearch } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { useLicense } from "@yaakapp-internal/license";
|
||||
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
|
||||
@@ -20,6 +19,8 @@ import { SettingsProxy } from "./SettingsProxy";
|
||||
import { SettingsTheme } from "./SettingsTheme";
|
||||
|
||||
interface Props {
|
||||
tab?: SettingsTabWithSubtab | null;
|
||||
/** Set when Settings is in a dialog rather than owning a window. */
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
@@ -42,25 +43,19 @@ const tabs = [
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
export type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}`;
|
||||
|
||||
export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: "/workspaces/$workspaceId/settings" });
|
||||
export default function Settings({ tab, hide }: Props) {
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(":") ?? [];
|
||||
const [mainTab, subtab] = tab?.split(":") ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape
|
||||
// Close settings window on escape. In a dialog, the dialog handles Escape itself.
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent("Escape", async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await platform.window.close();
|
||||
}
|
||||
if (hide == null) await platform.window.close();
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -90,7 +85,7 @@ export default function Settings({ hide }: Props) {
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
defaultValue={mainTab}
|
||||
addBorders
|
||||
tabListClassName="min-w-40 bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
|
||||
@@ -124,7 +124,7 @@ export function SettingsHotkeys() {
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
currentKeys={hotkeys[action] ?? []}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
|
||||
@@ -112,9 +112,12 @@ export const hotkeysAtom = atom((get) => {
|
||||
// Merge default hotkeys with custom hotkeys from settings
|
||||
// Custom hotkeys override defaults for the same action
|
||||
// An empty array means the hotkey is intentionally disabled
|
||||
const merged: Record<HotkeyAction, string[]> = { ...defaultHotkeys };
|
||||
const merged: Partial<Record<HotkeyAction, string[]>> = {};
|
||||
for (const action of hotkeyActions) {
|
||||
merged[action] = defaultHotkeys[action];
|
||||
}
|
||||
for (const [action, keys] of Object.entries(customHotkeys)) {
|
||||
if (action in defaultHotkeys && Array.isArray(keys)) {
|
||||
if (action in merged && Array.isArray(keys)) {
|
||||
merged[action as HotkeyAction] = keys;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +125,7 @@ export const hotkeysAtom = atom((get) => {
|
||||
});
|
||||
|
||||
/** Helper function to get current hotkeys from the store */
|
||||
function getHotkeys(): Record<HotkeyAction, string[]> {
|
||||
function getHotkeys(): Partial<Record<HotkeyAction, string[]>> {
|
||||
return jotaiStore.get(hotkeysAtom);
|
||||
}
|
||||
|
||||
@@ -165,16 +168,25 @@ const layoutInsensitiveKeys = [
|
||||
"Space",
|
||||
];
|
||||
|
||||
/** Zoom is the browser's own on these keys, so the app has no such action there. */
|
||||
const ZOOM_ACTIONS: HotkeyAction[] = ["app.zoom_in", "app.zoom_out", "app.zoom_reset"];
|
||||
|
||||
/**
|
||||
* The actions this host actually has. An action left out of here has no keys in
|
||||
* `hotkeysAtom`, so it never matches and never claims the keystroke.
|
||||
*/
|
||||
export const hotkeyActions: HotkeyAction[] = (
|
||||
Object.keys(defaultHotkeys) as (keyof typeof defaultHotkeys)[]
|
||||
).sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
)
|
||||
.filter((a) => platform.capabilities.interfaceZoom || !ZOOM_ACTIONS.includes(a))
|
||||
.sort((a, b) => {
|
||||
const scopeA = a.split(".")[0] || "";
|
||||
const scopeB = b.split(".")[0] || "";
|
||||
if (scopeA !== scopeB) {
|
||||
return scopeA.localeCompare(scopeB);
|
||||
}
|
||||
return hotkeyLabels[a].localeCompare(hotkeyLabels[b]);
|
||||
});
|
||||
|
||||
export type HotKeyOptions = {
|
||||
enable?: boolean | (() => boolean);
|
||||
|
||||
@@ -14,5 +14,6 @@ export const Route = createFileRoute("/workspaces/$workspaceId/settings")({
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <Settings />;
|
||||
const { tab } = Route.useSearch();
|
||||
return <Settings tab={tab} />;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ 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 }
|
||||
|
||||
@@ -435,15 +435,12 @@ 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 method = method.unwrap_or_else(|| "GET".to_string());
|
||||
|
||||
let request = HttpRequest {
|
||||
workspace_id,
|
||||
name,
|
||||
method: method.to_uppercase(),
|
||||
url,
|
||||
..Default::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 created = ctx
|
||||
.db()
|
||||
|
||||
@@ -49,6 +49,14 @@ 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 {
|
||||
|
||||
@@ -13,7 +13,7 @@ use tokio::task::JoinHandle;
|
||||
use yaak::plugin_events::{
|
||||
GroupedPluginEvent, HostRequest, SharedPluginEventContext, handle_shared_plugin_event,
|
||||
};
|
||||
use yaak::render::{render_grpc_request, render_http_request};
|
||||
use yaak_models::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;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "yaak-web"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "The server behind Yaak in the browser: executes sends, and can serve the app"
|
||||
|
||||
# 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-web"
|
||||
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 = ["compression-gzip", "compression-zstd", "cors", "fs"] }
|
||||
ts-rs = { workspace = true }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
@@ -0,0 +1,219 @@
|
||||
# yaak-web
|
||||
|
||||
The network half of Yaak in a browser — and, with `--serve`, the half that
|
||||
hands the browser the app in the first place.
|
||||
|
||||
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.
|
||||
|
||||
## Self-hosting it
|
||||
|
||||
One container, no configuration, nothing behind it:
|
||||
|
||||
```shell
|
||||
docker run -p 8080:8080 ghcr.io/mountain-loop/yaak-web
|
||||
```
|
||||
|
||||
Open <http://localhost:8080>. The image carries the built web client and this
|
||||
binary, which serves it — so the app and its sends are on one origin, and the
|
||||
tab's send URL is a path (`/v1/http/send`) rather than an address anyone has to
|
||||
configure. The image is `linux/amd64` and `linux/arm64`, built from
|
||||
`Dockerfile.web` at the repo root.
|
||||
|
||||
Your data lives in your browser (SQLite compiled to wasm, in IndexedDB), not in
|
||||
the container. The container is stateless: nothing is written to disk, so
|
||||
upgrading is `docker pull` and nothing else.
|
||||
|
||||
Two settings are worth knowing about:
|
||||
|
||||
```shell
|
||||
docker run -p 8080:8080 \
|
||||
-e YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true \
|
||||
-e YAAK_WEB_RATE_LIMIT_PER_MINUTE=0 \
|
||||
ghcr.io/mountain-loop/yaak-web
|
||||
```
|
||||
|
||||
- **`YAAK_WEB_ALLOW_PRIVATE_NETWORKS=true`** lets sends reach loopback,
|
||||
private and link-local addresses. Off by default, and it should stay off on
|
||||
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
|
||||
Turn it on for an instance on your own network, where calling the API on the
|
||||
next machine is the whole point. Note that "private" is relative to the
|
||||
*container*: `127.0.0.1` is the container itself, and reaching the Docker
|
||||
host means `host.docker.internal` (or `--network host`).
|
||||
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
|
||||
which suits a public instance and not a team of your own; `0` disables it.
|
||||
|
||||
Behind a reverse proxy, add `YAAK_WEB_TRUST_FORWARDED_FOR=true` so the rate
|
||||
limit sees real client addresses instead of its own — and only then, since
|
||||
otherwise anyone can spoof the header. If the reverse proxy buffers responses,
|
||||
tell it not to: sends are streamed, and the `X-Accel-Buffering: no` header this
|
||||
binary sets is honoured by nginx-shaped ones.
|
||||
|
||||
## Running it from source
|
||||
|
||||
```shell
|
||||
cargo run -p yaak-web -- --serve dist/apps/yaak-client
|
||||
```
|
||||
|
||||
after a `YAAK_TARGET=web SKIP_WASM_BUILD=1 npx vp -C apps/yaak-client build`.
|
||||
Without `--serve` it is the send executor alone, which is what the frontend
|
||||
dev server wants:
|
||||
|
||||
```shell
|
||||
cargo run -p yaak-web
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
|
||||
different origin and serves no `/v1`); a production build sends to its own
|
||||
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
|
||||
arguments; `--help` lists them all.
|
||||
|
||||
| Flag | Default | What |
|
||||
| --- | --- | --- |
|
||||
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
|
||||
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
|
||||
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
|
||||
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
|
||||
| `--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. |
|
||||
|
||||
## Serving the app
|
||||
|
||||
`--serve DIR` puts a file server behind the API routes: `/v1/*` is matched
|
||||
first, everything else comes from `DIR`, and a path with no file behind it gets
|
||||
`index.html` so the app's own routes survive a refresh. Responses are compressed
|
||||
(gzip or zstd) on the fly. `/assets/*` is cached forever — Vite content-hashes
|
||||
those names — and everything else is `no-cache`, so a new deploy arrives on the
|
||||
next reload.
|
||||
|
||||
Serving files changes nothing about sending: the same rendered request, the same
|
||||
destination policy, the same stateless executor. It exists so that a
|
||||
self-hosted Yaak is one thing to run rather than two.
|
||||
|
||||
## Split deployments
|
||||
|
||||
The app and the sender can still be separate services — one CDN-hosted bundle and
|
||||
one server elsewhere, or one server shared by several fronts. Then the bundle has
|
||||
to be told where to send, at build time:
|
||||
|
||||
```shell
|
||||
docker build -f Dockerfile.web \
|
||||
--build-arg VITE_YAAK_WEB_URL=https://send.example.com .
|
||||
```
|
||||
|
||||
and the server needs the CORS origins its callers use, since the requests are no
|
||||
longer same-origin:
|
||||
|
||||
```shell
|
||||
docker run -p 8080:8080 \
|
||||
-e YAAK_WEB_ALLOWED_ORIGINS=https://yaak.example.com \
|
||||
ghcr.io/mountain-loop/yaak-web \
|
||||
yaak-web
|
||||
```
|
||||
|
||||
The trailing `yaak-web` is a command override: the same image run without
|
||||
`--serve`, so it executes sends and serves no app.
|
||||
|
||||
## What it refuses, and why
|
||||
|
||||
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. So by default it refuses 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,
|
||||
IPv4 addresses carried inside IPv6 forms (`::ffff:a.b.c.d`, the well-known
|
||||
NAT64 prefix, 6to4), and the whole NAT64 local-use range;
|
||||
- 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 its own disk (`binary`, multipart file
|
||||
fields), since no browser tab could legitimately mean those.
|
||||
|
||||
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
|
||||
anything else strangers can reach) this must stay on: the machine's private
|
||||
network is the host's, not the user's, so a `localhost` or LAN API is not the
|
||||
user's to reach through it — the desktop app is what reaches those. On an
|
||||
instance you run for yourself, that reasoning is inverted, and
|
||||
`--allow-private-networks` inverts the policy with it. It allows every range
|
||||
above, including `169.254.169.254`, so use it only where the network on the
|
||||
other side is one the users are entitled to.
|
||||
|
||||
There is no authentication either way: an instance is anonymous, protected by
|
||||
the per-client rate limit and the destination policy. 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 a public instance.
|
||||
|
||||
## 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 server 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-web` after changing a frame)
|
||||
and published to the tab as `@yaakapp-internal/web`, 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
@@ -0,0 +1,48 @@
|
||||
// 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 Yaak server, and what the server 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
@@ -0,0 +1,64 @@
|
||||
// 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 server 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 server 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, };
|
||||
@@ -0,0 +1,4 @@
|
||||
// The server's wire contract, generated by ts-rs from src/wire.rs
|
||||
// (`cargo test -p yaak-web`). 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_web";
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The server behind Yaak running in a browser.
|
||||
///
|
||||
/// The tab renders the request and owns the data; this binary puts the bytes on the network
|
||||
/// and streams back what came back, and with `--serve` hands the browser the app as well.
|
||||
/// Nothing is written to disk or a database.
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "yaak-web", 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_WEB_BIND", default_value = "127.0.0.1:9227")]
|
||||
pub bind: SocketAddr,
|
||||
|
||||
/// Also serve a built web client from this directory, on the same origin as the API.
|
||||
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
|
||||
/// Without this the binary is only the send executor.
|
||||
#[arg(long, env = "YAAK_WEB_SERVE", value_name = "DIR")]
|
||||
pub serve: Option<PathBuf>,
|
||||
|
||||
/// Allow sends to loopback, private and link-local addresses. Off by default, because a
|
||||
/// server reachable by strangers is an open relay into the network it sits on. Turn it on
|
||||
/// only for an instance whose users are meant to reach that network — a self-hosted one
|
||||
/// on a LAN, where the point is to call the API on the next machine.
|
||||
#[arg(long, env = "YAAK_WEB_ALLOW_PRIVATE_NETWORKS", default_value_t = false)]
|
||||
pub allow_private_networks: bool,
|
||||
|
||||
/// Browser origins allowed to call this server (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_WEB_ALLOWED_ORIGINS",
|
||||
default_value = "*",
|
||||
value_delimiter = ','
|
||||
)]
|
||||
pub allowed_origins: Vec<String>,
|
||||
|
||||
/// Largest request the server accepts from the tab (the rendered request JSON, body included).
|
||||
#[arg(long, env = "YAAK_WEB_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
|
||||
pub max_request_bytes: usize,
|
||||
|
||||
/// Largest upstream response body the server will relay before cutting the send off.
|
||||
#[arg(long, env = "YAAK_WEB_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_WEB_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_WEB_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_WEB_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_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
|
||||
pub trust_forwarded_for: bool,
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! 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, unless the operator
|
||||
/// has said otherwise. A hosted server's "private network" is the cloud's, not the user's, so
|
||||
/// the default is public-only; a self-hosted instance on a LAN can be told that its private
|
||||
/// network *is* the user's, which is what `--allow-private-networks` means.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct DestinationPolicy {
|
||||
allow_private: bool,
|
||||
}
|
||||
|
||||
impl DestinationPolicy {
|
||||
pub fn new(allow_private: bool) -> Self {
|
||||
Self { allow_private }
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if self.allow_private {
|
||||
return Ok(());
|
||||
}
|
||||
match non_public_reason(ip) {
|
||||
Some(reason) => Err(format!(
|
||||
"Refusing to connect to {ip}: {reason}. This server 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 fixed-layout IPv6 forms — IPv4-mapped,
|
||||
/// the well-known NAT64 prefix, 6to4 — are unwrapped and judged as IPv4, since that is where
|
||||
/// the packets end up; the NAT64 local-use range is refused outright. This is the stable-Rust
|
||||
/// stand-in for `IpAddr::is_global`, which is still behind `#![feature(ip)]`; 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(v4) = embedded_v4(&v6) {
|
||||
return non_public_v4(v4);
|
||||
}
|
||||
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()[..3] == [0x64, 0xff9b, 1] {
|
||||
Some("NAT64 local-use (64:ff9b:1::/48)")
|
||||
} else if v6.segments()[..4] == [0x100, 0, 0, 0] {
|
||||
Some("discard-only (100::/64)")
|
||||
} 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 address an IPv6 address stands for, when it is one of the fixed-layout translation
|
||||
/// forms: the NAT64 well-known prefix (64:ff9b::/96) or 6to4 (2002::/16, IPv4 in the next 32
|
||||
/// bits). The NAT64 local-use range (64:ff9b:1::/48) is a pool operators carve their own
|
||||
/// prefix from, at a length only they know, so it is refused wholesale in [`non_public_reason`]
|
||||
/// rather than decoded — the same call `std`'s (still unstable) `Ipv6Addr::is_global` makes.
|
||||
fn embedded_v4(v6: &Ipv6Addr) -> Option<Ipv4Addr> {
|
||||
let s = v6.segments();
|
||||
let o = v6.octets();
|
||||
if s[0] == 0x64 && s[1] == 0xff9b && s[2..6].iter().all(|x| *x == 0) {
|
||||
return Some(Ipv4Addr::new(o[12], o[13], o[14], o[15]));
|
||||
}
|
||||
if s[0] == 0x2002 {
|
||||
return Some(Ipv4Addr::new(o[2], o[3], o[4], o[5]));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 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::new(false);
|
||||
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 allow_private_networks_opens_the_local_ranges_but_not_other_schemes() {
|
||||
let policy = DestinationPolicy::new(true);
|
||||
assert!(policy.check_url("http://127.0.0.1/").is_ok());
|
||||
assert!(policy.check_ip(ip("10.0.0.1")).is_ok());
|
||||
assert!(policy.check_ip(ip("169.254.169.254")).is_ok());
|
||||
assert!(policy.check_url("file:///etc/passwd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_http_schemes() {
|
||||
let policy = DestinationPolicy::new(false);
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! yaak-web: 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_WEB_*` 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, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
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::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
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::new(config.allow_private_networks);
|
||||
if config.allow_private_networks {
|
||||
warn!(
|
||||
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
|
||||
on an instance strangers cannot reach"
|
||||
);
|
||||
}
|
||||
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 api = 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 app = match &state.config.serve {
|
||||
Some(dir) => {
|
||||
info!("Serving the web client from {}", dir.display());
|
||||
api.merge(web_router(dir))
|
||||
}
|
||||
None => api,
|
||||
};
|
||||
|
||||
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-web 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");
|
||||
}
|
||||
|
||||
/// The built web client, served on the same origin as the API.
|
||||
///
|
||||
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
|
||||
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
|
||||
/// only a file server — a send behaves exactly as it does without this flag.
|
||||
///
|
||||
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
|
||||
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
|
||||
/// refresh).
|
||||
fn web_router(dir: &Path) -> Router {
|
||||
let index = ServeFile::new(dir.join("index.html"));
|
||||
Router::new()
|
||||
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
|
||||
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
|
||||
.fallback_service(ServeDir::new(dir).fallback(index))
|
||||
.layer(middleware::from_fn(cache_control))
|
||||
.layer(CompressionLayer::new())
|
||||
}
|
||||
|
||||
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
|
||||
/// Everything else — `index.html` above all, including the copy served for an unknown path —
|
||||
/// must be revalidated, or a browser keeps serving the deploy before last.
|
||||
async fn cache_control(req: Request, next: Next) -> Response {
|
||||
let hashed_name = req.uri().path().starts_with("/assets/");
|
||||
let mut res = next.run(req).await;
|
||||
if !res.status().is_success() {
|
||||
return res;
|
||||
}
|
||||
let is_html = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/html"));
|
||||
let value = if hashed_name && !is_html {
|
||||
"public, max-age=31536000, immutable"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
|
||||
res
|
||||
}
|
||||
|
||||
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 server 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))
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! 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 server 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 server.
|
||||
if request.body_type.as_deref() == Some("binary") {
|
||||
return Err(Refusal::Unsupported(
|
||||
"Binary file bodies can't be sent from the browser: the server 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 server 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 server'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 server'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 its 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());
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! What crosses the wire between a tab and this server.
|
||||
//!
|
||||
//! 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 server
|
||||
//! 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-web`) and published to the tab as
|
||||
//! `@yaakapp-internal/web`, 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_web.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 server 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 server 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_web.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>>,
|
||||
},
|
||||
}
|
||||
@@ -88,6 +88,7 @@ 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 }
|
||||
|
||||
@@ -68,6 +68,7 @@ mod notifications;
|
||||
mod plugin_events;
|
||||
mod plugins_ext;
|
||||
mod render;
|
||||
mod restart;
|
||||
mod rpc_ext;
|
||||
mod sync_ext;
|
||||
mod updates;
|
||||
@@ -904,7 +905,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
||||
}
|
||||
|
||||
async fn cmd_restart<R: Runtime>(app_handle: AppHandle<R>) -> YaakResult<()> {
|
||||
app_handle.request_restart();
|
||||
restart::request_restart(&app_handle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1258,6 +1259,14 @@ 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>());
|
||||
@@ -1357,15 +1366,6 @@ 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"))]
|
||||
@@ -1409,6 +1409,7 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
}
|
||||
RunEvent::Exit => restart::relaunch_if_requested(),
|
||||
_ => {}
|
||||
};
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ 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;
|
||||
|
||||
@@ -152,30 +151,11 @@ 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);
|
||||
|
||||
|
||||
@@ -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::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_models::render::{render_grpc_request, render_http_request};
|
||||
pub use yaak_commands::render::{render_json_value, render_template};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use log::{error, info};
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::{Command, Stdio};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tauri::{AppHandle, Runtime};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
static RELAUNCH_WITH_LAUNCH_SERVICES: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// Restart the app without directly spawning the executable on macOS.
|
||||
///
|
||||
/// Tauri's current macOS restart path starts the executable from the dying
|
||||
/// process. Besides inheriting stale process state, that bypasses
|
||||
/// LaunchServices and can leave the replacement app running without an active
|
||||
/// window. Defer the relaunch until `RunEvent::Exit`, when the event loop is
|
||||
/// already shutting down, and hand it to LaunchServices instead.
|
||||
pub fn request_restart<R: Runtime>(app_handle: &AppHandle<R>) {
|
||||
#[cfg(target_os = "macos")]
|
||||
if current_app_bundle().is_some() {
|
||||
info!("Requesting restart through macOS LaunchServices");
|
||||
RELAUNCH_WITH_LAUNCH_SERVICES.store(true, Ordering::SeqCst);
|
||||
app_handle.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
app_handle.request_restart();
|
||||
}
|
||||
|
||||
/// Complete a pending macOS restart after Tauri has emitted its exit events.
|
||||
pub fn relaunch_if_requested() {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if !RELAUNCH_WITH_LAUNCH_SERVICES.swap(false, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(bundle) = current_app_bundle() else {
|
||||
error!("Failed to resolve the app bundle for restart");
|
||||
return;
|
||||
};
|
||||
|
||||
match Command::new("/usr/bin/open")
|
||||
.arg("-n")
|
||||
.arg(&bundle)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => info!("Relaunching {} through LaunchServices", bundle.display()),
|
||||
Err(error) => error!("Failed to relaunch through LaunchServices: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn current_app_bundle() -> Option<PathBuf> {
|
||||
app_bundle_from_executable(&std::env::current_exe().ok()?)
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn app_bundle_from_executable(executable: &Path) -> Option<PathBuf> {
|
||||
let macos_dir = executable.parent()?;
|
||||
if macos_dir.file_name()? != "MacOS" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let contents_dir = macos_dir.parent()?;
|
||||
if contents_dir.file_name()? != "Contents" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let bundle = contents_dir.parent()?;
|
||||
if bundle.extension()? != "app" {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(bundle.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::app_bundle_from_executable;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[test]
|
||||
fn resolves_macos_app_bundle() {
|
||||
assert_eq!(
|
||||
app_bundle_from_executable(Path::new(
|
||||
"/Applications/Yaak.app/Contents/MacOS/yaak-app-client"
|
||||
)),
|
||||
Some(PathBuf::from("/Applications/Yaak.app"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unbundled_executable() {
|
||||
assert_eq!(
|
||||
app_bundle_from_executable(Path::new("/workspace/target/debug/yaak-app-client")),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models_ext::QueryManagerExt;
|
||||
use crate::restart;
|
||||
use log::{debug, error, info, warn};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{Emitter, Listener, Manager, Runtime, WebviewWindow};
|
||||
@@ -332,7 +333,7 @@ async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Upd
|
||||
))
|
||||
.blocking_show()
|
||||
{
|
||||
window.app_handle().request_restart();
|
||||
restart::request_restart(window.app_handle());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -70,6 +70,17 @@ 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 Yaak server, and what the server 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`
|
||||
|
||||
@@ -19,7 +19,6 @@ 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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::dns::LocalhostResolver;
|
||||
use crate::dns::{AddressFilter, LocalhostResolver};
|
||||
use crate::error::Result;
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::{Client, ClientBuilder, Proxy, redirect};
|
||||
@@ -103,13 +103,18 @@ 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(crate) fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
|
||||
pub fn build_client(&self) -> Result<(ConfiguredClient, Arc<LocalhostResolver>)> {
|
||||
let mut client = client_builder()
|
||||
.connection_verbose(true)
|
||||
.redirect(redirect::Policy::none())
|
||||
@@ -135,7 +140,10 @@ impl HttpConnectionOptions {
|
||||
}
|
||||
|
||||
// Configure DNS resolver - keep a reference to configure per-request
|
||||
let resolver = LocalhostResolver::new(self.dns_overrides.clone());
|
||||
let resolver = LocalhostResolver::with_address_filter(
|
||||
self.dns_overrides.clone(),
|
||||
self.address_filter.clone(),
|
||||
);
|
||||
client = client.dns_resolver(resolver.clone());
|
||||
|
||||
// Configure proxy
|
||||
|
||||
@@ -20,15 +20,32 @@ 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
|
||||
@@ -55,9 +72,25 @@ 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.
|
||||
@@ -72,6 +105,7 @@ 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);
|
||||
|
||||
@@ -94,6 +128,8 @@ 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() {
|
||||
@@ -125,6 +161,8 @@ 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() {
|
||||
@@ -161,6 +199,7 @@ 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();
|
||||
|
||||
|
||||
@@ -5,9 +5,12 @@ 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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "yaak-lifecycle"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
authors = ["Gregory Schier"]
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
@@ -0,0 +1,130 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@ 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
@@ -304,6 +304,22 @@ 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 Yaak server, and what the server 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;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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"]);
|
||||
});
|
||||
@@ -61,7 +61,9 @@ export function createOrderedModelAtom<M extends AnyModel["model"]>(
|
||||
const modelData = data[modelType] ?? {};
|
||||
return Object.values(modelData).sort(
|
||||
(a: ExtractModel<AnyModel, M>, b: ExtractModel<AnyModel, M>) => {
|
||||
const n = a[field] > b[field] ? 1 : -1;
|
||||
// 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;
|
||||
return order === "desc" ? n * -1 : n;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! 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")]);
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,13 @@ 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;
|
||||
|
||||
@@ -60,8 +60,22 @@ pub struct ProxySettingAuth {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct ClientCertificate {
|
||||
pub host: String,
|
||||
@@ -75,13 +89,18 @@ 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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
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")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
pub struct DnsOverride {
|
||||
pub hostname: String,
|
||||
@@ -89,7 +108,6 @@ 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,
|
||||
}
|
||||
@@ -140,6 +158,70 @@ 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 Yaak server, and what the server 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")]
|
||||
@@ -147,7 +229,6 @@ pub struct InheritedBoolSetting {
|
||||
#[serde(default)]
|
||||
#[ts(optional, as = "Option<bool>")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub value: bool,
|
||||
}
|
||||
|
||||
@@ -383,7 +464,31 @@ impl UpsertModelInfo for Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "workspaces")]
|
||||
@@ -403,18 +508,13 @@ 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,
|
||||
}
|
||||
|
||||
@@ -920,11 +1020,16 @@ impl UpsertModelInfo for Environment {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[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,
|
||||
@@ -949,7 +1054,35 @@ pub struct ParentHeaders {
|
||||
pub headers: Vec<HttpRequestHeader>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "folders")]
|
||||
@@ -974,7 +1107,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -1088,11 +1220,16 @@ impl UpsertModelInfo for Folder {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[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,
|
||||
@@ -1101,11 +1238,16 @@ pub struct HttpRequestHeader {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[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`
|
||||
@@ -1116,7 +1258,36 @@ pub struct HttpUrlParameter {
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "http_requests")]
|
||||
@@ -1137,7 +1308,6 @@ 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,
|
||||
@@ -1393,7 +1563,36 @@ impl Default for WebsocketMessageType {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "websocket_requests")]
|
||||
@@ -1420,7 +1619,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -2053,7 +2251,35 @@ impl UpsertModelInfo for GraphQlIntrospection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema, TS)]
|
||||
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)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_models.ts")]
|
||||
#[enum_def(table_name = "grpc_requests")]
|
||||
@@ -2079,7 +2305,6 @@ 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,
|
||||
}
|
||||
|
||||
@@ -2730,22 +2955,12 @@ impl<'s> TryFrom<&Row<'s>> for PluginKeyValue {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_request_message_size() -> i32 {
|
||||
DEFAULT_REQUEST_MESSAGE_SIZE
|
||||
}
|
||||
|
||||
/// 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_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),* $(,)?) => {
|
||||
@@ -2889,3 +3104,65 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
+54
-16
@@ -1,4 +1,4 @@
|
||||
use yaak_models::models::HttpUrlParameter;
|
||||
use crate::models::HttpUrlParameter;
|
||||
|
||||
pub fn apply_path_placeholders(
|
||||
url: &str,
|
||||
@@ -34,27 +34,41 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
|
||||
return url.to_string();
|
||||
}
|
||||
|
||||
// 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: ®ex::Captures| {
|
||||
format!(
|
||||
"{}{}{}",
|
||||
cap[1].to_string(),
|
||||
urlencoding::encode(p.value.as_str()),
|
||||
cap[2].to_string()
|
||||
)
|
||||
})
|
||||
.into_owned();
|
||||
// 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);
|
||||
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() {
|
||||
@@ -98,6 +112,30 @@ 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 {
|
||||
@@ -45,6 +45,31 @@ 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
|
||||
@@ -59,18 +84,7 @@ impl<'a> ClientDb<'a> {
|
||||
blobs: &BlobManager,
|
||||
responses_dir: &std::path::Path,
|
||||
) -> Result<usize> {
|
||||
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;
|
||||
}
|
||||
let mut deleted = self.delete_orphaned_response_body_blobs(blobs)?;
|
||||
|
||||
// Body files are stored as {responses_dir}/{response_id}
|
||||
if let Ok(entries) = fs::read_dir(responses_dir) {
|
||||
@@ -172,19 +186,20 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::blob_manager::BodyChunk;
|
||||
use crate::blob_manager::{BlobManager, BodyChunk};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::init_in_memory;
|
||||
use crate::models::{HttpRequest, HttpResponse, Workspace};
|
||||
use crate::util::UpdateSource;
|
||||
|
||||
#[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();
|
||||
|
||||
/// A workspace, a request, and one response that still exists.
|
||||
fn seed_live_response(db: &ClientDb, blob_manager: &BlobManager) -> HttpResponse {
|
||||
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(
|
||||
@@ -192,19 +207,57 @@ 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")
|
||||
}
|
||||
|
||||
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");
|
||||
/// 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 = 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
|
||||
|
||||
@@ -25,13 +25,7 @@ impl<'a> ClientDb<'a> {
|
||||
|
||||
if workspaces.is_empty() {
|
||||
workspaces.push(self.upsert_workspace(
|
||||
&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()
|
||||
},
|
||||
&Workspace { name: "Yaak".to_string(), ..Default::default() },
|
||||
&UpdateSource::Background,
|
||||
)?)
|
||||
}
|
||||
@@ -194,16 +188,40 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn default_headers() -> Vec<HttpRequestHeader> {
|
||||
vec![
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "User-Agent".to_string(),
|
||||
value: "yaak".to_string(),
|
||||
id: None,
|
||||
..Default::default()
|
||||
},
|
||||
HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "Accept".to_string(),
|
||||
value: "*/*".to_string(),
|
||||
id: None,
|
||||
..Default::default()
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,159 @@
|
||||
use crate::models::{Environment, EnvironmentVariable};
|
||||
use std::collections::HashMap;
|
||||
//! 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() })
|
||||
}
|
||||
|
||||
pub fn make_vars_hashmap(environment_chain: Vec<Environment>) -> HashMap<String, String> {
|
||||
let mut variables = HashMap::new();
|
||||
@@ -27,3 +181,70 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ 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"] }
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod format_json;
|
||||
pub mod parser;
|
||||
pub mod renderer;
|
||||
pub mod strip_json_comments;
|
||||
#[cfg(feature = "wasm")]
|
||||
pub mod wasm;
|
||||
|
||||
pub use parser::*;
|
||||
|
||||
@@ -8,12 +8,25 @@ use std::future::Future;
|
||||
|
||||
const MAX_DEPTH: usize = 50;
|
||||
|
||||
/// `Send`, except on wasm32, where a template function is a call into
|
||||
/// JavaScript: the future holds a `JsFuture` and the callback an `Rc` pool,
|
||||
/// neither of which can be `Send`. Every other host spawns rendering onto a
|
||||
/// thread pool and needs the bound.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub trait MaybeSend: Send {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<T: Send> MaybeSend for T {}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub trait MaybeSend {}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<T> MaybeSend for T {}
|
||||
|
||||
pub trait TemplateCallback {
|
||||
fn run(
|
||||
&self,
|
||||
fn_name: &str,
|
||||
args: HashMap<String, serde_json::Value>,
|
||||
) -> impl Future<Output = Result<String>> + Send;
|
||||
) -> impl Future<Output = Result<String>> + MaybeSend;
|
||||
|
||||
fn transform_arg(&self, fn_name: &str, arg_name: &str, arg_value: &str) -> Result<String>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "yaak-web"
|
||||
name = "yaak-wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
@@ -25,7 +25,11 @@ 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"
|
||||
@@ -35,3 +39,4 @@ 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"] }
|
||||
@@ -3,4 +3,12 @@
|
||||
// This is loaded by the SharedWorker in packages/platform/src/web/worker.ts and
|
||||
// nowhere else: it owns a SQLite database, and there must be exactly one of it
|
||||
// per origin.
|
||||
export { blob_delete, blob_get, blob_put, boot, rpc } from "./pkg";
|
||||
export {
|
||||
blob_delete,
|
||||
blob_get,
|
||||
blob_put,
|
||||
boot,
|
||||
prepare_http_send,
|
||||
render_template,
|
||||
rpc,
|
||||
} from "./pkg";
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/web",
|
||||
"name": "@yaakapp-internal/wasm",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "yaak-wasm",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"yaak_wasm_bg.wasm",
|
||||
"yaak_wasm.js",
|
||||
"yaak_wasm_bg.js",
|
||||
"yaak_wasm.d.ts"
|
||||
],
|
||||
"main": "yaak_wasm.js",
|
||||
"types": "yaak_wasm.d.ts",
|
||||
"sideEffects": [
|
||||
"./yaak_wasm.js",
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
@@ -25,6 +25,26 @@ 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).
|
||||
@@ -0,0 +1,9 @@
|
||||
/* @ts-self-types="./yaak_wasm.d.ts" */
|
||||
import * as wasm from "./yaak_wasm_bg.wasm";
|
||||
import { __wbg_set_wasm } from "./yaak_wasm_bg.js";
|
||||
|
||||
__wbg_set_wasm(wasm);
|
||||
wasm.__wbindgen_start();
|
||||
export {
|
||||
blob_delete, blob_get, blob_put, boot, prepare_http_send, render_template, rpc
|
||||
} from "./yaak_wasm_bg.js";
|
||||
@@ -62,6 +62,38 @@ 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).
|
||||
@@ -209,6 +241,10 @@ export function __wbg_call_dfde26266607c996() { return handleError(function (arg
|
||||
const ret = arg0.call(arg1, arg2);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_call_faa0a261f288f846() { return handleError(function (arg0, arg1, arg2, arg3) {
|
||||
const ret = arg0.call(arg1, arg2, arg3);
|
||||
return ret;
|
||||
}, arguments); }
|
||||
export function __wbg_clear_bb1b3ff877b62598() { return handleError(function (arg0) {
|
||||
const ret = arg0.clear();
|
||||
return ret;
|
||||
@@ -496,7 +532,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
|
||||
const a = state0.a;
|
||||
state0.a = 0;
|
||||
try {
|
||||
return wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(a, state0.b, arg0, arg1);
|
||||
return wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(a, state0.b, arg0, arg1);
|
||||
} finally {
|
||||
state0.a = a;
|
||||
}
|
||||
@@ -653,6 +689,10 @@ export function __wbg_then_837494e384b37459(arg0, arg1) {
|
||||
const ret = arg0.then(arg1);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_then_bd927500e8905df2(arg0, arg1, arg2) {
|
||||
const ret = arg0.then(arg1, arg2);
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_toString_1dda136fd8f30a5f(arg0) {
|
||||
const ret = arg0.toString();
|
||||
return ret;
|
||||
@@ -677,24 +717,27 @@ 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: 1104, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hf84d53817e0238b4);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1140, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 202, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e06bb925b918fbb);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 229, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 180, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 74, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 200, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc);
|
||||
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 227, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
|
||||
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000005(arg0) {
|
||||
@@ -731,30 +774,30 @@ export function __wbindgen_init_externref_table() {
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
}
|
||||
function wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h87640adb2bbfa2fc(arg0, arg1);
|
||||
function wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f(arg0, arg1);
|
||||
}
|
||||
|
||||
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__ha7903b6e296dd8f4(arg0, arg1, arg2) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4(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);
|
||||
function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h7e53e249a4dc4aa9(arg0, arg1, arg2);
|
||||
function wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2) {
|
||||
const ret = wasm.wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95(arg0, arg1, arg2);
|
||||
if (ret[1]) {
|
||||
throw takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2c72ca09e851b7f3(arg0, arg1, arg2, arg3);
|
||||
function wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3) {
|
||||
wasm.wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948(arg0, arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
+7
-5
@@ -5,6 +5,8 @@ export const blob_delete: (a: number, b: number) => [number, number];
|
||||
export const blob_get: (a: number, b: number) => [number, number, number, number];
|
||||
export const blob_put: (a: number, b: number, c: number, d: number) => [number, number];
|
||||
export const boot: () => any;
|
||||
export const prepare_http_send: (a: any, 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;
|
||||
@@ -16,11 +18,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__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 wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__he166673e9c1b4e95: (a: number, b: number, c: any) => [number, number];
|
||||
export const wasm_bindgen__convert__closures_____invoke__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 __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;
|
||||
@@ -12,8 +12,10 @@
|
||||
//! 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, and blob storage. Sending, plugins, git, sync and
|
||||
//! everything else with a socket or a filesystem behind it lives elsewhere.
|
||||
//! 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.
|
||||
|
||||
// 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
|
||||
@@ -24,12 +26,19 @@ 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::models::AnyModel;
|
||||
use yaak_models::cookies::apply_cookie_changes;
|
||||
use yaak_models::models::{
|
||||
AnyModel, Cookie, CookieJar, HttpRequest, HttpResponseEvent, HttpResponseEventData,
|
||||
HttpSendSettings,
|
||||
};
|
||||
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.
|
||||
@@ -43,6 +52,10 @@ 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) };
|
||||
}
|
||||
@@ -91,6 +104,10 @@ 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(())
|
||||
}
|
||||
@@ -201,6 +218,36 @@ 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,
|
||||
@@ -305,6 +352,48 @@ 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();
|
||||
@@ -312,10 +401,250 @@ 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,17 +0,0 @@
|
||||
{
|
||||
"name": "yaak-web",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"files": [
|
||||
"yaak_web_bg.wasm",
|
||||
"yaak_web.js",
|
||||
"yaak_web_bg.js",
|
||||
"yaak_web.d.ts"
|
||||
],
|
||||
"main": "yaak_web.js",
|
||||
"types": "yaak_web.d.ts",
|
||||
"sideEffects": [
|
||||
"./yaak_web.js",
|
||||
"./snippets/*"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import init from "./yaak_web_bg.wasm?init";
|
||||
export * from "./yaak_web_bg.js";
|
||||
import * as bg from "./yaak_web_bg.js";
|
||||
const instance = await init({ "./yaak_web_bg.js": bg });
|
||||
bg.__wbg_set_wasm(instance.exports);
|
||||
instance.exports.__wbindgen_start();
|
||||
Binary file not shown.
@@ -2,7 +2,6 @@ pub mod error;
|
||||
pub mod export;
|
||||
pub mod import;
|
||||
pub mod plugin_events;
|
||||
pub mod render;
|
||||
pub mod response_body;
|
||||
pub mod send;
|
||||
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+22
-55
@@ -1,4 +1,3 @@
|
||||
use crate::render::render_http_request;
|
||||
use async_trait::async_trait;
|
||||
use log::warn;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -25,10 +24,11 @@ use yaak_http::types::{
|
||||
use yaak_models::blob_manager::{BlobManager, BodyChunk};
|
||||
use yaak_models::models::{
|
||||
ClientCertificate, Cookie, CookieJar, DnsOverride, Environment, HttpRequest, HttpResponse,
|
||||
HttpResponseEvent, HttpResponseHeader, HttpResponseState, ProxySetting, ProxySettingAuth,
|
||||
ResolvedHttpRequestSettings, ResolvedSetting,
|
||||
HttpResponseEvent, HttpResponseEventData, HttpResponseHeader, HttpResponseState, ProxySetting,
|
||||
ProxySettingAuth, ResolvedHttpRequestSettings,
|
||||
};
|
||||
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,6 +193,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
|
||||
proxy: runtime_config.proxy.clone(),
|
||||
client_certificate,
|
||||
dns_overrides: runtime_config.dns_overrides.clone(),
|
||||
address_filter: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -715,36 +716,24 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
let started_at = Instant::now();
|
||||
let request_started_url = sendable_request.url.clone();
|
||||
|
||||
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,
|
||||
);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut http_response =
|
||||
match executor.send(sendable_request, event_tx, cookie_behavior.clone()).await {
|
||||
@@ -1130,28 +1119,6 @@ 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,
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"ignorePatterns": "crates/yaak-templates/pkg/**\ncrates/yaak-web/pkg/**\n**/bindings/gen_*.ts\npackage-lock.json\nCargo.lock"
|
||||
"ignorePatterns": "crates/yaak-templates/pkg/**\ncrates/yaak-wasm/pkg/**\n**/bindings/gen_*.ts\npackage-lock.json\nCargo.lock"
|
||||
}
|
||||
|
||||
Generated
+55
-4
@@ -16,6 +16,7 @@
|
||||
"packages/platform",
|
||||
"packages/plugin-runtime",
|
||||
"packages/plugin-runtime-types",
|
||||
"packages/plugin-sandbox",
|
||||
"plugins-external/mcp-server",
|
||||
"plugins-external/faker",
|
||||
"plugins-external/httpsnippet",
|
||||
@@ -67,9 +68,10 @@
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-wasm",
|
||||
"crates/yaak-ws",
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
"crates-server/yaak-web",
|
||||
"apps/yaak-client",
|
||||
"apps/yaak-proxy"
|
||||
],
|
||||
@@ -272,6 +274,10 @@
|
||||
"name": "@yaakapp-internal/proxy-lib",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"crates-server/yaak-web": {
|
||||
"name": "@yaakapp-internal/web",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"crates-tauri/yaak-app-client": {
|
||||
"name": "@yaakapp-internal/tauri-client",
|
||||
"version": "1.0.0"
|
||||
@@ -330,8 +336,8 @@
|
||||
"rimraf": "^6.1.2"
|
||||
}
|
||||
},
|
||||
"crates/yaak-web": {
|
||||
"name": "@yaakapp-internal/web",
|
||||
"crates/yaak-wasm": {
|
||||
"name": "@yaakapp-internal/wasm",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"rimraf": "^6.1.2"
|
||||
@@ -1465,6 +1471,21 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jitl/quickjs-ffi-types": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz",
|
||||
"integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@jitl/quickjs-ng-wasmfile-release-sync": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@jitl/quickjs-ng-wasmfile-release-sync/-/quickjs-ng-wasmfile-release-sync-0.32.0.tgz",
|
||||
"integrity": "sha512-XAX2jjZWWh3M0YaRqi82xMKNW/gkF6mo3MpW3UY2cmVxnQai1JuboVsJQVoLU629iEL4XWvHtO4h5lo7NRnAcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -5633,6 +5654,10 @@
|
||||
"resolved": "packages/plugin-runtime",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaakapp-internal/plugin-sandbox": {
|
||||
"resolved": "packages/plugin-sandbox",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaakapp-internal/plugins": {
|
||||
"resolved": "crates/yaak-plugins",
|
||||
"link": true
|
||||
@@ -5677,8 +5702,12 @@
|
||||
"resolved": "packages/ui",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaakapp-internal/wasm": {
|
||||
"resolved": "crates/yaak-wasm",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaakapp-internal/web": {
|
||||
"resolved": "crates/yaak-web",
|
||||
"resolved": "crates-server/yaak-web",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@yaakapp-internal/ws": {
|
||||
@@ -12790,6 +12819,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/quickjs-emscripten-core": {
|
||||
"version": "0.32.0",
|
||||
"resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz",
|
||||
"integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ffi-types": "0.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/railroad-diagrams": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz",
|
||||
@@ -15799,7 +15837,9 @@
|
||||
"@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/wasm": "^1.0.0",
|
||||
"@yaakapp-internal/web": "^1.0.0"
|
||||
}
|
||||
},
|
||||
@@ -15848,6 +15888,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"packages/plugin-sandbox": {
|
||||
"name": "@yaakapp-internal/plugin-sandbox",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
|
||||
"quickjs-emscripten-core": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
},
|
||||
"packages/tailwind-config": {
|
||||
"name": "@yaakapp-internal/tailwind-config",
|
||||
"version": "1.0.0"
|
||||
|
||||
+3
-1
@@ -15,6 +15,7 @@
|
||||
"packages/platform",
|
||||
"packages/plugin-runtime",
|
||||
"packages/plugin-runtime-types",
|
||||
"packages/plugin-sandbox",
|
||||
"plugins-external/mcp-server",
|
||||
"plugins-external/faker",
|
||||
"plugins-external/httpsnippet",
|
||||
@@ -66,9 +67,10 @@
|
||||
"crates/yaak-sse",
|
||||
"crates/yaak-sync",
|
||||
"crates/yaak-templates",
|
||||
"crates/yaak-web",
|
||||
"crates/yaak-wasm",
|
||||
"crates/yaak-ws",
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
"crates-server/yaak-web",
|
||||
"apps/yaak-client",
|
||||
"apps/yaak-proxy"
|
||||
],
|
||||
|
||||
@@ -2,3 +2,5 @@ export * from "./debounce";
|
||||
export * from "./eagerDebounceAsync";
|
||||
export * from "./formatSize";
|
||||
export * from "./templateFunction";
|
||||
export * from "./pluginForms";
|
||||
export * from "./responseBody";
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* `ctx`, built once for every runtime that has one. A runtime supplies only how
|
||||
* a payload reaches its host.
|
||||
*
|
||||
* `stream` and `form` are optional because they are the two places a host
|
||||
* genuinely differs: both need a conversation rather than one reply.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CallPromptFormDynamicArgs,
|
||||
Context,
|
||||
DynamicPromptFormArg,
|
||||
} from "@yaakapp/api";
|
||||
import type {
|
||||
DeleteKeyValueResponse,
|
||||
DeleteModelResponse,
|
||||
FindHttpResponsesResponse,
|
||||
Folder,
|
||||
FormInput,
|
||||
GetCookieValueRequest,
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdResponse,
|
||||
GetHttpResponseBodyInfoResponse,
|
||||
GetKeyValueResponse,
|
||||
HttpRequest,
|
||||
HttpResponse,
|
||||
InternalEventPayload,
|
||||
ListCookieNamesResponse,
|
||||
ListFoldersResponse,
|
||||
ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse,
|
||||
ListOpenWorkspacesResponse,
|
||||
PluginContext,
|
||||
PromptFormResponse,
|
||||
PromptTextResponse,
|
||||
ReadHttpResponseBodyChunkResponse,
|
||||
RenderGrpcRequestResponse,
|
||||
RenderHttpRequestResponse,
|
||||
SendHttpRequestResponse,
|
||||
TemplateRenderRequest,
|
||||
TemplateRenderResponse,
|
||||
UpsertModelResponse,
|
||||
WindowInfoResponse,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { applyDynamicFormInput, stripDynamicCallbacks } from "./pluginForms";
|
||||
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||
import { applyFormInputDefaults } from "./templateFunction";
|
||||
|
||||
export interface PluginTransport {
|
||||
request(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
notify(context: PluginContext, payload: InternalEventPayload): void;
|
||||
|
||||
/** Send once, keep receiving. Windows report navigation until they close. */
|
||||
stream?(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
onReply: (payload: InternalEventPayload) => void,
|
||||
): void;
|
||||
|
||||
/**
|
||||
* A form that may re-render before it settles: `onChange` answers with the
|
||||
* form to show next. Without it, a form is drawn once from its defaults.
|
||||
*/
|
||||
form?(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
onChange: (
|
||||
values: Record<string, unknown>,
|
||||
) => Promise<InternalEventPayload | null>,
|
||||
): Promise<PromptFormResponse>;
|
||||
}
|
||||
|
||||
/** `bodyPath` names a file on a host's disk; plugins address bodies by id. */
|
||||
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
||||
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
||||
bodyPath?: string | null;
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
export function createPluginContext(
|
||||
transport: PluginTransport,
|
||||
context: PluginContext,
|
||||
): Context {
|
||||
const send = <T>(payload: InternalEventPayload): Promise<T> =>
|
||||
transport.request(context, payload) as Promise<T>;
|
||||
|
||||
const storedBody = async (responseId: string) => {
|
||||
const bodyInfo = () =>
|
||||
send<GetHttpResponseBodyInfoResponse>({
|
||||
type: "get_http_response_body_info_request",
|
||||
responseId,
|
||||
});
|
||||
const info = await bodyInfo();
|
||||
|
||||
return createResponseBody(
|
||||
{
|
||||
responseId,
|
||||
contentLength: info.contentLength,
|
||||
contentType: info.contentType ?? null,
|
||||
complete: info.complete,
|
||||
},
|
||||
async (offset, length) => {
|
||||
const chunk = await send<ReadHttpResponseBodyChunkResponse>({
|
||||
type: "read_http_response_body_chunk_request",
|
||||
responseId,
|
||||
offset,
|
||||
length,
|
||||
});
|
||||
return decodeBase64Chunk(chunk.data);
|
||||
},
|
||||
{ refresh: bodyInfo },
|
||||
);
|
||||
};
|
||||
|
||||
const windowInfo = async () => {
|
||||
if (context.label == null) {
|
||||
throw new Error("Can't get window context without an active window");
|
||||
}
|
||||
return send<WindowInfoResponse>({ type: "window_info_request", label: context.label });
|
||||
};
|
||||
|
||||
const ctx: Context = {
|
||||
clipboard: {
|
||||
copyText: async (text) => {
|
||||
await send({ type: "copy_text_request", text });
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
show: async (args) => {
|
||||
await send({
|
||||
type: "show_toast_request",
|
||||
// Defaulted here because null and undefined both become None in Rust.
|
||||
timeout: args.timeout === undefined ? 5000 : args.timeout,
|
||||
...args,
|
||||
});
|
||||
},
|
||||
},
|
||||
window: {
|
||||
requestId: async () => (await windowInfo()).requestId,
|
||||
workspaceId: async () => (await windowInfo()).workspaceId,
|
||||
environmentId: async () => (await windowInfo()).environmentId,
|
||||
openUrl: async ({ onNavigate, onClose, ...args }) => {
|
||||
if (transport.stream == null) {
|
||||
throw new Error("ctx.window.openUrl is not available in this runtime");
|
||||
}
|
||||
args.label = args.label || `${Math.random()}`;
|
||||
transport.stream(context, { type: "open_window_request", ...args }, (event) => {
|
||||
if (event.type === "window_navigate_event") onNavigate?.(event);
|
||||
else if (event.type === "window_close_event") onClose?.();
|
||||
});
|
||||
return {
|
||||
close: () => {
|
||||
transport.notify(context, { type: "close_window_request", label: args.label });
|
||||
},
|
||||
};
|
||||
},
|
||||
openExternalUrl: async (url) => {
|
||||
await send({ type: "open_external_url_request", url });
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
text: async (args) => {
|
||||
const reply = await send<PromptTextResponse>({ type: "prompt_text_request", ...args });
|
||||
return reply.value;
|
||||
},
|
||||
form: async (args) => {
|
||||
// Inputs may compute from the values entered so far, and a function
|
||||
// cannot cross to a host.
|
||||
const resolve = async (values: Record<string, unknown>) => {
|
||||
const callArgs: CallPromptFormDynamicArgs = { values } as CallPromptFormDynamicArgs;
|
||||
const resolved = await applyDynamicFormInput(
|
||||
ctx,
|
||||
args.inputs as DynamicPromptFormArg[],
|
||||
callArgs,
|
||||
);
|
||||
return stripDynamicCallbacks(resolved) as FormInput[];
|
||||
};
|
||||
|
||||
const initial = await resolve(applyFormInputDefaults(args.inputs, {}));
|
||||
const payload: InternalEventPayload = {
|
||||
type: "prompt_form_request",
|
||||
...args,
|
||||
inputs: initial,
|
||||
};
|
||||
|
||||
if (transport.form == null) {
|
||||
const reply = await send<PromptFormResponse>(payload);
|
||||
return reply.values;
|
||||
}
|
||||
|
||||
const reply = await transport.form(context, payload, async (values) => {
|
||||
// Fired on mount, before there is anything to recompute from.
|
||||
if (values == null || Object.keys(values).length === 0) return null;
|
||||
return { type: "prompt_form_request", ...args, inputs: await resolve(values) };
|
||||
});
|
||||
return reply.values;
|
||||
},
|
||||
},
|
||||
httpResponse: {
|
||||
find: async (args) => {
|
||||
const { httpResponses } = await send<FindHttpResponsesResponse>({
|
||||
type: "find_http_responses_request",
|
||||
...args,
|
||||
});
|
||||
return httpResponses.map(forPlugin);
|
||||
},
|
||||
body: ({ responseId }) => storedBody(responseId),
|
||||
},
|
||||
grpcRequest: {
|
||||
render: async (args) => {
|
||||
const { grpcRequest } = await send<RenderGrpcRequestResponse>({
|
||||
type: "render_grpc_request_request",
|
||||
...args,
|
||||
});
|
||||
return grpcRequest;
|
||||
},
|
||||
},
|
||||
httpRequest: {
|
||||
getById: async (args) => {
|
||||
const { httpRequest } = await send<GetHttpRequestByIdResponse>({
|
||||
type: "get_http_request_by_id_request",
|
||||
...args,
|
||||
});
|
||||
return httpRequest;
|
||||
},
|
||||
send: async (args) => {
|
||||
const { httpResponse, body } = await send<SendHttpRequestResponse>({
|
||||
type: "send_http_request_request",
|
||||
...args,
|
||||
});
|
||||
|
||||
// A send with no request behind it saves nothing, so the reply carries
|
||||
// the only copy of its body.
|
||||
if (body == null) {
|
||||
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
||||
}
|
||||
|
||||
const bytes = decodeBase64Chunk(body);
|
||||
return {
|
||||
httpResponse: forPlugin(httpResponse),
|
||||
body: createResponseBody(
|
||||
{
|
||||
responseId: httpResponse.id,
|
||||
contentLength: bytes.byteLength,
|
||||
contentType:
|
||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")?.value ??
|
||||
null,
|
||||
// The host waited for the whole send before replying.
|
||||
complete: true,
|
||||
},
|
||||
async (offset, length) => bytes.slice(offset, offset + length),
|
||||
),
|
||||
};
|
||||
},
|
||||
render: async (args) => {
|
||||
const { httpRequest } = await send<RenderHttpRequestResponse>({
|
||||
type: "render_http_request_request",
|
||||
...args,
|
||||
});
|
||||
return httpRequest;
|
||||
},
|
||||
list: async (args?: { folderId?: string }) => {
|
||||
const payload: InternalEventPayload = {
|
||||
type: "list_http_requests_request",
|
||||
folderId: args?.folderId,
|
||||
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
|
||||
const { httpRequests } = await send<ListHttpRequestsResponse>(payload);
|
||||
return httpRequests;
|
||||
},
|
||||
create: async (args) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { name: "", method: "GET", ...args, id: "", model: "http_request" },
|
||||
} as InternalEventPayload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
update: async (args) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { model: "http_request", ...args },
|
||||
} as InternalEventPayload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
delete: async (args) => {
|
||||
const response = await send<DeleteModelResponse>({
|
||||
type: "delete_model_request",
|
||||
model: "http_request",
|
||||
id: args.id,
|
||||
} as InternalEventPayload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
},
|
||||
folder: {
|
||||
list: async () => {
|
||||
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
|
||||
return folders;
|
||||
},
|
||||
getById: async (args: { id: string }) => {
|
||||
const { folders } = await send<ListFoldersResponse>({ type: "list_folders_request" });
|
||||
return folders.find((f) => f.id === args.id) ?? null;
|
||||
},
|
||||
create: async ({ name, ...args }) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { ...args, name: name ?? "", id: "", model: "folder" },
|
||||
} as InternalEventPayload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
update: async (args) => {
|
||||
const response = await send<UpsertModelResponse>({
|
||||
type: "upsert_model_request",
|
||||
model: { model: "folder", ...args },
|
||||
} as InternalEventPayload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
delete: async (args: { id: string }) => {
|
||||
const response = await send<DeleteModelResponse>({
|
||||
type: "delete_model_request",
|
||||
model: "folder",
|
||||
id: args.id,
|
||||
} as InternalEventPayload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
getValue: async (args: GetCookieValueRequest) => {
|
||||
const { value } = await send<GetCookieValueResponse>({
|
||||
type: "get_cookie_value_request",
|
||||
...args,
|
||||
});
|
||||
return value;
|
||||
},
|
||||
listNames: async () => {
|
||||
const { names } = await send<ListCookieNamesResponse>({ type: "list_cookie_names_request" });
|
||||
return names;
|
||||
},
|
||||
},
|
||||
templates: {
|
||||
render: async (args: TemplateRenderRequest) => {
|
||||
const result = await send<TemplateRenderResponse>({
|
||||
type: "template_render_request",
|
||||
...args,
|
||||
});
|
||||
// oxlint-disable-next-line no-explicit-any -- the caller knows its own shape
|
||||
return result.data as any;
|
||||
},
|
||||
},
|
||||
store: {
|
||||
get: async <T>(key: string) => {
|
||||
const result = await send<GetKeyValueResponse>({ type: "get_key_value_request", key });
|
||||
return result.value ? (JSON.parse(result.value) as T) : undefined;
|
||||
},
|
||||
set: async <T>(key: string, value: T) => {
|
||||
await send<GetKeyValueResponse>({
|
||||
type: "set_key_value_request",
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
});
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
const result = await send<DeleteKeyValueResponse>({
|
||||
type: "delete_key_value_request",
|
||||
key,
|
||||
});
|
||||
return result.deleted;
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
reload: () => {
|
||||
transport.notify(context, { type: "reload_response", silent: true });
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list: async () => {
|
||||
const response = await send<ListOpenWorkspacesResponse>({
|
||||
type: "list_open_workspaces_request",
|
||||
});
|
||||
return response.workspaces.map((w) => {
|
||||
type WorkspaceInfoInternal = typeof w & { label?: string };
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
// Kept for routing, hidden from plugin authors.
|
||||
_label: (w as WorkspaceInfoInternal).label as string,
|
||||
};
|
||||
});
|
||||
},
|
||||
withContext: (handle: { id: string; name: string; _label?: string }) =>
|
||||
createPluginContext(transport, {
|
||||
...context,
|
||||
label: handle._label || null,
|
||||
workspaceId: handle.id,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
return ctx;
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import type {
|
||||
DynamicAuthenticationArg,
|
||||
DynamicPromptFormArg,
|
||||
DynamicTemplateFunctionArg,
|
||||
TemplateFunctionPlugin,
|
||||
} from "@yaakapp/api";
|
||||
import type {
|
||||
CallHttpAuthenticationActionArgs,
|
||||
CallTemplateFunctionArgs,
|
||||
FormInput,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
|
||||
type AnyDynamicArg = DynamicTemplateFunctionArg | DynamicAuthenticationArg | DynamicPromptFormArg;
|
||||
@@ -73,3 +75,33 @@ export async function applyDynamicFormInput(
|
||||
}
|
||||
return resolvedArgs;
|
||||
}
|
||||
|
||||
/** What a host receives has to be data all the way down. */
|
||||
export function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
|
||||
return inputs.map((input) => {
|
||||
// oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type
|
||||
const { dynamic: _dynamic, ...rest } = input as any;
|
||||
if ("inputs" in rest && Array.isArray(rest.inputs)) {
|
||||
rest.inputs = stripDynamicCallbacks(rest.inputs);
|
||||
}
|
||||
return rest as FormInput;
|
||||
});
|
||||
}
|
||||
|
||||
/** Select options used to carry `name` where they now carry `label`. */
|
||||
export function migrateTemplateFunctionSelectOptions(
|
||||
f: TemplateFunctionPlugin,
|
||||
): TemplateFunctionPlugin {
|
||||
const migratedArgs = f.args.map((a) => {
|
||||
if (a.type === "select") {
|
||||
type LegacyOption = { label?: string; value: string; name?: string };
|
||||
a.options = a.options.map((o) => {
|
||||
const legacy = o as LegacyOption;
|
||||
return { label: legacy.label ?? legacy.name ?? "", value: legacy.value };
|
||||
});
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
return { ...f, args: migratedArgs };
|
||||
}
|
||||
@@ -9,8 +9,10 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@yaakapp-internal/models": "^1.0.0",
|
||||
"@yaakapp-internal/rpc-schema": "^1.0.0",
|
||||
"@yaakapp-internal/web": "^1.0.0",
|
||||
"@yaakapp-internal/wasm": "^1.0.0",
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
|
||||
@@ -49,6 +49,7 @@ function toSyncUnsubscribe(pending: Promise<Unsubscribe>): Unsubscribe {
|
||||
}
|
||||
|
||||
const ALL_CAPABILITIES: PlatformCapabilities = {
|
||||
httpSending: true,
|
||||
grpc: true,
|
||||
websocket: true,
|
||||
git: true,
|
||||
@@ -59,6 +60,7 @@ const ALL_CAPABILITIES: PlatformCapabilities = {
|
||||
timeline: true,
|
||||
multiWindow: true,
|
||||
windowChrome: true,
|
||||
interfaceZoom: true,
|
||||
plugins: true,
|
||||
encryption: true,
|
||||
updater: true,
|
||||
|
||||
@@ -237,6 +237,8 @@ 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. */
|
||||
@@ -262,6 +264,11 @@ export interface PlatformCapabilities {
|
||||
* chrome should be reserved or drawn.
|
||||
*/
|
||||
windowChrome: boolean;
|
||||
/**
|
||||
* The app zooms its own interface, and so owns Cmd/Ctrl `+`, `-` and `0`.
|
||||
* False in a browser, where those keys are already the browser's.
|
||||
*/
|
||||
interfaceZoom: boolean;
|
||||
/** The plugin runtime. */
|
||||
plugins: boolean;
|
||||
/** Workspace encryption backed by a key the host keeps. */
|
||||
|
||||
@@ -24,8 +24,10 @@ 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
|
||||
│ ◀── model_writes ── crates/yaak-wasm → yaak-models → SQLite
|
||||
│ └─ pages in IndexedDB
|
||||
└── send.ts ──POST rendered request──▶ yaak-web (crates-server) ──▶ the internet
|
||||
◀── NDJSON: events, response, body, cookies ──
|
||||
```
|
||||
|
||||
| File | What it is |
|
||||
@@ -33,13 +35,19 @@ 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 server executes, this file stores what comes back where the desktop stores it. |
|
||||
| `server.ts` | Where the Yaak server is, and the wire shapes it speaks (generated from `crates-server/yaak-web/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 }`, and blob get/put.
|
||||
The Rust side is `crates/yaak-wasm` (`@yaakapp-internal/wasm`): `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.
|
||||
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.
|
||||
@@ -70,13 +78,14 @@ Behaviours worth knowing before changing anything:
|
||||
## Commands
|
||||
|
||||
109 commands are declared in `@yaakapp-internal/rpc-schema`. This host answers
|
||||
31, declines 44 by name with a reason, and refuses the remaining 34 generically.
|
||||
32, declines 43 by name with a reason, and refuses the remaining 34 generically.
|
||||
|
||||
### Implemented (31)
|
||||
### Implemented (32)
|
||||
|
||||
| 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 Yaak server; 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` |
|
||||
@@ -97,7 +106,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 (44)
|
||||
### Declined by name (43)
|
||||
|
||||
Each returns an `UnsupportedCommandError` carrying `cmd`, a user-facing
|
||||
`message`, and the `capability` a caller should have checked. The UI turns it
|
||||
@@ -105,7 +114,7 @@ into a toast.
|
||||
|
||||
| Reason | Commands |
|
||||
| --- | --- |
|
||||
| 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` |
|
||||
| Sending, the parts not wired yet | `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` |
|
||||
@@ -129,7 +138,10 @@ Reported honestly, so callers gate on the question rather than on the host:
|
||||
|
||||
| True | False |
|
||||
| --- | --- |
|
||||
| `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` |
|
||||
| `httpSending`, `timeline`, `cookieJar` | `grpc`, `websocket`, `git`, `sync`, `tlsOptions`, `localFiles`, `multiWindow`, `windowChrome`, `interfaceZoom`, `plugins`, `encryption`, `updater`, `clipboardRead`, `systemFonts`, `license` |
|
||||
|
||||
`interfaceZoom: false` leaves Cmd/Ctrl `+`, `-` and `0` to the browser instead
|
||||
of swallowing them, and drops those three rows from the hotkeys screen.
|
||||
|
||||
`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
|
||||
@@ -171,26 +183,45 @@ 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`.
|
||||
|
||||
## What slice 2 (the send proxy) will need from this layer
|
||||
## Sending
|
||||
|
||||
Sending becomes a stateless hosted service; this layer stays the only place data
|
||||
lives. Concretely:
|
||||
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 server,
|
||||
`crates-server/yaak-web`. This layer stays the only place data lives:
|
||||
|
||||
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.
|
||||
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
|
||||
server. 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 server (it has no access to
|
||||
your files, and must not read its own). And on a public instance a request to
|
||||
`localhost` or a LAN address can't work: the server runs elsewhere and refuses
|
||||
private ranges outright — that is what the desktop app is for. A self-hosted
|
||||
server on your own network can be started with `--allow-private-networks`, which
|
||||
is the one case where those addresses are the user's to reach.
|
||||
|
||||
**Where the tab sends** (`server.ts`): a production build posts to `/v1/http/send`
|
||||
on its own origin, because the server can serve the app itself
|
||||
(`yaak-web --serve dist/apps/yaak-client`, which is what the
|
||||
`ghcr.io/mountain-loop/yaak-web` image runs) — same origin, so no CORS and
|
||||
nothing to configure. A dev build falls back to `http://127.0.0.1:9227`, since
|
||||
the Vite server is a different origin and serves no `/v1`; run one with
|
||||
`cargo run -p yaak-web`. `VITE_YAAK_WEB_URL` overrides both, for a
|
||||
deployment that keeps the app and the server apart.
|
||||
|
||||
@@ -17,14 +17,22 @@
|
||||
* up here as a type error rather than as a runtime surprise.
|
||||
*/
|
||||
|
||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||
import type { JsonPrimitive } from "@yaakapp-internal/plugins";
|
||||
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
|
||||
import type { CapabilityName, RpcPayload } from "../types";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { unsupported } from "./errors";
|
||||
import type { WebPlugins } from "./plugins";
|
||||
import { sendHttpRequest } from "./send";
|
||||
|
||||
export type AppCmd = keyof RpcSchema;
|
||||
|
||||
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
|
||||
type Handler = (
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
) => Promise<unknown>;
|
||||
|
||||
/** Placeholder shown wherever the desktop would show a real filesystem path. */
|
||||
const NO_PATH = "";
|
||||
@@ -40,6 +48,31 @@ function text(payload: RpcPayload, key: string): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** Form values as the plugin protocol carries them. */
|
||||
function values(payload: RpcPayload, key = "values"): Record<string, JsonPrimitive> {
|
||||
const value = payload[key];
|
||||
return value != null && typeof value === "object"
|
||||
? (value as Record<string, JsonPrimitive>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The id a plugin keys its stored state on.
|
||||
*
|
||||
* The desktop hashes the id of whichever model the configuration was read from,
|
||||
* so two requests inheriting one folder's authentication share a token cache.
|
||||
* The preview paths here have no such model in hand and pass what they were
|
||||
* given, which is enough to be stable per form.
|
||||
*/
|
||||
function contextId(payload: RpcPayload): string {
|
||||
const model = payload.model;
|
||||
if (model != null && typeof model === "object" && "id" in model) {
|
||||
const id = (model as { id?: unknown }).id;
|
||||
return typeof id === "string" ? id : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands this host answers itself.
|
||||
*
|
||||
@@ -65,6 +98,24 @@ 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 server 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 ---------------------------------- */
|
||||
|
||||
@@ -133,22 +184,67 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
* Both of these are polled once a second until they answer with something, so
|
||||
* an empty list is not a quiet no — it is a poll that never stops.
|
||||
*
|
||||
* The auth list names what Yaak actually offers, so the picker tells the
|
||||
* truth about the product even though the form behind each entry stays empty
|
||||
* until plugins run here. Template functions get the opposite treatment: one
|
||||
* provider contributing no functions. That settles the poll while putting
|
||||
* nothing in the autocomplete, which is the honest answer — a function the
|
||||
* user could insert but nothing could evaluate would be worse than none.
|
||||
* Both now answer from the plugins actually loaded in the sandbox, which is
|
||||
* the only answer that stays true: an authentication method in the picker
|
||||
* that no loaded plugin can apply would be a promise this host cannot keep,
|
||||
* and a template function offered in the autocomplete that nothing can
|
||||
* evaluate would be worse than none.
|
||||
*/
|
||||
async cmd_get_http_authentication_summaries() {
|
||||
return HTTP_AUTHENTICATION_SUMMARIES;
|
||||
async cmd_get_http_authentication_summaries(_payload, _db, plugins) {
|
||||
return plugins.httpAuthenticationSummaries();
|
||||
},
|
||||
async cmd_template_function_summaries() {
|
||||
return [{ pluginRefId: "web", functions: [] }];
|
||||
async cmd_template_function_summaries(_payload, _db, plugins) {
|
||||
return plugins.templateFunctionSummaries();
|
||||
},
|
||||
|
||||
async cmd_get_http_authentication_config() {
|
||||
return { args: [], pluginRefId: "web" };
|
||||
async cmd_get_http_authentication_config(payload, _db, plugins) {
|
||||
const authName = str(payload, "authName");
|
||||
const config =
|
||||
authName == null
|
||||
? null
|
||||
: await plugins.httpAuthenticationConfig(authName, values(payload), contextId(payload));
|
||||
return config ?? { args: [], actions: [], pluginRefId: "web" };
|
||||
},
|
||||
|
||||
async cmd_template_function_config(payload, _db, plugins) {
|
||||
const name = str(payload, "functionName") ?? str(payload, "name");
|
||||
if (name == null) return null;
|
||||
return plugins.templateFunctionConfig(name, values(payload), contextId(payload));
|
||||
},
|
||||
|
||||
async cmd_call_http_authentication_action(payload, _db, plugins) {
|
||||
const authName = str(payload, "authName");
|
||||
if (authName == null) return null;
|
||||
const index = payload.actionIndex;
|
||||
await plugins.callHttpAuthenticationAction(
|
||||
authName,
|
||||
typeof index === "number" ? index : 0,
|
||||
values(payload),
|
||||
contextId(payload),
|
||||
);
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Turn a pasted cURL command into a request.
|
||||
*
|
||||
* Routed through the same importer the desktop uses, in the sandbox, which
|
||||
* is why this is a handler and no longer a refusal. The reshaping afterwards
|
||||
* matches `cmd_curl_to_request` in crates/yaak-commands: the importer names a
|
||||
* workspace of its own invention and mints an id, and both belong to the
|
||||
* caller instead.
|
||||
*/
|
||||
async cmd_curl_to_request(payload, _db, plugins) {
|
||||
const resources = await plugins.import(text(payload, "command"));
|
||||
const imported = resources?.httpRequests?.[0];
|
||||
if (imported == null) {
|
||||
throw new Error("Failed to import cURL command");
|
||||
}
|
||||
return {
|
||||
...imported,
|
||||
id: "",
|
||||
workspaceId: str(payload, "workspaceId") ?? imported.workspaceId,
|
||||
} as HttpRequest;
|
||||
},
|
||||
|
||||
async cmd_format_json(payload) {
|
||||
@@ -163,13 +259,19 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Rendering resolves variables and calls template functions, and the
|
||||
* functions live in plugins. Handing the template back unrendered is what the
|
||||
* preview then shows — the raw `${[...]}`, which is at least the thing the
|
||||
* user typed rather than a wrong value.
|
||||
* Resolve variables and call template functions, in the engine, exactly as
|
||||
* `cmd_render_template` does on the desktop. The functions come back out to
|
||||
* the sandbox as the render reaches them — see `templateBridge` in worker.ts.
|
||||
*/
|
||||
async cmd_render_template(payload) {
|
||||
return text(payload, "template");
|
||||
async cmd_render_template(payload, db) {
|
||||
const workspaceId = str(payload, "workspaceId");
|
||||
if (workspaceId == null) return text(payload, "template");
|
||||
return db.renderTemplate({
|
||||
template: text(payload, "template"),
|
||||
workspaceId,
|
||||
environmentId: str(payload, "environmentId"),
|
||||
ignoreError: payload.ignoreError === true,
|
||||
});
|
||||
},
|
||||
|
||||
/* ------------------------------- bodies -------------------------------- */
|
||||
@@ -203,30 +305,14 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
|
||||
return bytes == null ? null : Array.from(bytes);
|
||||
},
|
||||
|
||||
async cmd_get_http_response_events() {
|
||||
return [];
|
||||
},
|
||||
// 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_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.
|
||||
@@ -237,17 +323,10 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
|
||||
* while the first is a slice away.
|
||||
*/
|
||||
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | 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],
|
||||
// Saved requests send through the server (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],
|
||||
|
||||
// Protocols that need a real socket.
|
||||
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
|
||||
@@ -260,7 +339,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_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", 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"],
|
||||
@@ -289,18 +368,12 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
|
||||
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_template_function_config: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_template_tokens_to_string: ["Template functions come from plugins, which this host doesn't run", "plugins"],
|
||||
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
|
||||
|
||||
// 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],
|
||||
};
|
||||
@@ -327,9 +400,10 @@ export async function runCommand(
|
||||
cmd: string,
|
||||
payload: RpcPayload,
|
||||
db: WorkerConnection,
|
||||
plugins: WebPlugins,
|
||||
): Promise<unknown> {
|
||||
const handler = HANDLERS[cmd as AppCmd];
|
||||
if (handler != null) return handler(payload, db);
|
||||
if (handler != null) return handler(payload, db, plugins);
|
||||
|
||||
const declined = DECLINED[cmd as AppCmd];
|
||||
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
|
||||
|
||||
@@ -50,6 +50,9 @@ export class WorkerConnection {
|
||||
/** True once the worker has said anything at all. */
|
||||
private heard = false;
|
||||
|
||||
/** Unset until the sandbox is up; a render before then gets a refusal. */
|
||||
private templateFunctions: ((name: string, args: string) => Promise<string>) | null = null;
|
||||
|
||||
constructor() {
|
||||
// Both are required and neither is faked. Without a shared worker every
|
||||
// tab would need its own SQLite over the same pages; without Web Locks
|
||||
@@ -147,6 +150,9 @@ export class WorkerConnection {
|
||||
case "event":
|
||||
this.deliver(message.event, message.payload);
|
||||
return;
|
||||
case "template_function":
|
||||
void this.runTemplateFunction(message.id, message.name, message.args);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,10 +165,48 @@ 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-wasm: 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-wasm. */
|
||||
renderTemplate(payload: unknown): Promise<string> {
|
||||
return this.request<string>((id) => ({ type: "render_template", id, payload }));
|
||||
}
|
||||
|
||||
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
|
||||
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
|
||||
return buf == null ? null : new Uint8Array(buf);
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
* Sending goes through a small stateless server, 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.
|
||||
*/
|
||||
|
||||
import type {
|
||||
@@ -27,22 +28,27 @@ 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 Yaak server: the tab renders, the server 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. Nothing in the browser does yet.
|
||||
// wire, and the Yaak server uses its own.
|
||||
tlsOptions: false,
|
||||
// The jar can be edited and stored here; only filling it needs the sender.
|
||||
cookieJar: true,
|
||||
localFiles: false,
|
||||
timeline: false,
|
||||
// The server streams the engine's events back and the sender stores them.
|
||||
timeline: true,
|
||||
// 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
|
||||
@@ -52,7 +58,12 @@ function capabilitiesFor(): PlatformCapabilities {
|
||||
// The browser draws the frame around the page. There are no traffic lights
|
||||
// to leave room for and no window controls to draw.
|
||||
windowChrome: false,
|
||||
plugins: false,
|
||||
// The browser already zooms the page, on the same keys, and remembers it
|
||||
// per site. The app stays out of the way.
|
||||
interfaceZoom: false,
|
||||
// Plugins run in a QuickJS sandbox, but only the bundled set: there is no
|
||||
// installing them, so the plugin manager stays unavailable and says so.
|
||||
plugins: true,
|
||||
encryption: false,
|
||||
updater: false,
|
||||
// Reading needs a permission prompt at first paint, which is a bad ask for
|
||||
@@ -152,8 +163,12 @@ function createWindow(db: WorkerConnection): PlatformWindow {
|
||||
|
||||
export function createWebPlatform(): Platform {
|
||||
const db = new WorkerConnection();
|
||||
const plugins = new WebPlugins(db);
|
||||
const capabilities = capabilitiesFor();
|
||||
|
||||
// Registered before anything can render, not inside the first send.
|
||||
db.setTemplateFunctionHandler((name, args) => plugins.callTemplateFunction(name, args));
|
||||
|
||||
// Without this, IndexedDB is best-effort storage and a browser reclaiming
|
||||
// space may drop someone's workspaces. Asking is all we can do, and there is
|
||||
// nothing useful to do about a refusal.
|
||||
@@ -223,7 +238,7 @@ export function createWebPlatform(): Platform {
|
||||
// `plugin:` commands are Tauri host plugins, not engine commands, and
|
||||
// never reached the router even on the desktop.
|
||||
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
|
||||
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
|
||||
return runCommand(cmd, payload ?? {}, db, plugins) as Promise<T>;
|
||||
},
|
||||
|
||||
async rpcStream<T, M>(
|
||||
@@ -236,7 +251,7 @@ export function createWebPlatform(): Platform {
|
||||
const streamId = crypto.randomUUID();
|
||||
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
|
||||
try {
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
|
||||
const result = (await runCommand(cmd, { ...payload, streamId }, db, plugins)) as T;
|
||||
return { result, unlisten };
|
||||
} catch (err) {
|
||||
unlisten();
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Keeps a sandbox, loads the bundled plugins into it, routes by what each one
|
||||
* contributes, and answers the `ctx` calls they make. `hostRequest` below is
|
||||
* the whole of what a plugin can do to the world here.
|
||||
*/
|
||||
|
||||
import { PluginSandbox, type PluginSummary } from "@yaakapp-internal/plugin-sandbox";
|
||||
import type {
|
||||
GetHttpAuthenticationConfigResponse,
|
||||
GetHttpAuthenticationSummaryResponse,
|
||||
GetTemplateFunctionConfigResponse,
|
||||
GetTemplateFunctionSummaryResponse,
|
||||
ImportResources,
|
||||
InternalEventPayload,
|
||||
JsonPrimitive,
|
||||
PluginContext,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import { SANDBOX_PLUGINS } from "./sandboxPlugins.generated";
|
||||
|
||||
type KeyValueRequest = { key: string };
|
||||
|
||||
export interface AppliedAuthentication {
|
||||
setHeaders?: { name: string; value: string }[] | null;
|
||||
setQueryParameters?: { name: string; value: string }[] | null;
|
||||
}
|
||||
|
||||
export class WebPlugins {
|
||||
private readonly db: WorkerConnection;
|
||||
private sandbox: PluginSandbox | null = null;
|
||||
private loading: Promise<void> | null = null;
|
||||
|
||||
private readonly byTemplateFunction = new Map<string, string>();
|
||||
private readonly byAuthName = new Map<string, string>();
|
||||
private readonly importers: string[] = [];
|
||||
private readonly summaries = new Map<string, PluginSummary>();
|
||||
|
||||
constructor(db: WorkerConnection) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from every entry point rather than at construction, so a session
|
||||
* that never touches a plugin never pays for QuickJS.
|
||||
*/
|
||||
ready(): Promise<void> {
|
||||
this.loading ??= this.start();
|
||||
return this.loading;
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
const sandbox = new PluginSandbox({
|
||||
onHostRequest: (envelope) => this.hostRequest(envelope),
|
||||
onLog: ({ pluginRefId, level, message }) => {
|
||||
// Prefixed, or a plugin's console output blames the app's own code.
|
||||
const write = level === "error" ? console.error : console.log;
|
||||
write(`[plugin ${pluginRefId}] ${message}`);
|
||||
},
|
||||
});
|
||||
this.sandbox = sandbox;
|
||||
|
||||
await Promise.all(
|
||||
SANDBOX_PLUGINS.map(async ({ name, source }) => {
|
||||
try {
|
||||
const summary = await sandbox.load(name, source);
|
||||
this.summaries.set(name, summary);
|
||||
for (const fn of summary.templateFunctions) this.byTemplateFunction.set(fn, name);
|
||||
if (summary.authentication != null) this.byAuthName.set(summary.authentication, name);
|
||||
if (summary.importer) this.importers.push(name);
|
||||
} catch (err) {
|
||||
// One bad bundle should cost its own features and nothing else.
|
||||
console.error(`Failed to load plugin \`${name}\``, err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------ what exists ------------------------------ */
|
||||
|
||||
async templateFunctionSummaries(): Promise<GetTemplateFunctionSummaryResponse[]> {
|
||||
await this.ready();
|
||||
return this.gather("get_template_function_summary_request", this.summaries.keys());
|
||||
}
|
||||
|
||||
async httpAuthenticationSummaries(): Promise<GetHttpAuthenticationSummaryResponse[]> {
|
||||
await this.ready();
|
||||
return this.gather("get_http_authentication_summary_request", this.byAuthName.values());
|
||||
}
|
||||
|
||||
/** One broken plugin must not empty the picker for the others. */
|
||||
private async gather<T>(type: string, ids: Iterable<string>): Promise<T[]> {
|
||||
const replies = await Promise.all(
|
||||
Array.from(ids).map(async (id): Promise<{ type: string } | null> => {
|
||||
try {
|
||||
return await this.dispatch(id, { type } as InternalEventPayload);
|
||||
} catch (err) {
|
||||
console.error(`Plugin \`${id}\` failed to answer \`${type}\``, err);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return replies.filter((r) => r != null && r.type !== "empty_response") as T[];
|
||||
}
|
||||
|
||||
/* -------------------------------- calling -------------------------------- */
|
||||
|
||||
async templateFunctionConfig(
|
||||
name: string,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<GetTemplateFunctionConfigResponse | null> {
|
||||
await this.ready();
|
||||
const id = this.byTemplateFunction.get(name);
|
||||
if (id == null) return null;
|
||||
return this.dispatch(id, {
|
||||
type: "get_template_function_config_request",
|
||||
contextId,
|
||||
name,
|
||||
values,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the engine's render calls back into. A function nothing provides is a
|
||||
* throw naming it, not an empty string: a request sent with a silently blank
|
||||
* token is worse than one that refuses to be sent.
|
||||
*/
|
||||
async callTemplateFunction(name: string, argsJson: string): Promise<string> {
|
||||
await this.ready();
|
||||
const id = this.byTemplateFunction.get(name);
|
||||
if (id == null) {
|
||||
throw new Error(`No plugin provides the template function \`${name}\``);
|
||||
}
|
||||
|
||||
const values = JSON.parse(argsJson) as Record<string, JsonPrimitive>;
|
||||
const reply = await this.dispatch<{ value: string | null; error?: string | null }>(id, {
|
||||
type: "call_template_function_request",
|
||||
name,
|
||||
args: { purpose: "send", values },
|
||||
} as InternalEventPayload);
|
||||
|
||||
if (reply.error) throw new Error(reply.error);
|
||||
return reply.value ?? "";
|
||||
}
|
||||
|
||||
async httpAuthenticationConfig(
|
||||
authName: string,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<GetHttpAuthenticationConfigResponse | null> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) return null;
|
||||
return this.dispatch(id, {
|
||||
type: "get_http_authentication_config_request",
|
||||
contextId,
|
||||
values,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
async callHttpAuthenticationAction(
|
||||
authName: string,
|
||||
index: number,
|
||||
values: Record<string, JsonPrimitive>,
|
||||
contextId: string,
|
||||
): Promise<void> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) throw new Error(`No plugin provides \`${authName}\` authentication`);
|
||||
await this.dispatch(id, {
|
||||
type: "call_http_authentication_action_request",
|
||||
index,
|
||||
pluginRefId: id,
|
||||
args: { contextId, values },
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
|
||||
async applyHttpAuthentication(
|
||||
authName: string,
|
||||
request: {
|
||||
contextId: string;
|
||||
values: Record<string, JsonPrimitive>;
|
||||
method: string;
|
||||
url: string;
|
||||
headers: { name: string; value: string }[];
|
||||
body: string | null;
|
||||
},
|
||||
): Promise<AppliedAuthentication> {
|
||||
await this.ready();
|
||||
const id = this.byAuthName.get(authName);
|
||||
if (id == null) {
|
||||
throw new Error(
|
||||
`This request uses ${authName} authentication, which no plugin in the browser provides`,
|
||||
);
|
||||
}
|
||||
return this.dispatch<AppliedAuthentication>(id, {
|
||||
type: "call_http_authentication_request",
|
||||
...request,
|
||||
} as InternalEventPayload);
|
||||
}
|
||||
|
||||
/** First importer that recognizes the text wins, as `import_data` decides too. */
|
||||
async import(content: string): Promise<ImportResources | null> {
|
||||
await this.ready();
|
||||
for (const id of this.importers) {
|
||||
try {
|
||||
const reply = await this.dispatch<{ resources?: ImportResources }>(id, {
|
||||
type: "import_request",
|
||||
content,
|
||||
} as InternalEventPayload);
|
||||
if (reply.type === "import_response" && reply.resources != null) return reply.resources;
|
||||
} catch (err) {
|
||||
console.error(`Importer \`${id}\` failed`, err);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ------------------------------- internals ------------------------------- */
|
||||
|
||||
private async dispatch<T>(
|
||||
pluginRefId: string,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<T & { type: string }> {
|
||||
if (this.sandbox == null) throw new Error("The plugin sandbox is not running");
|
||||
return this.sandbox.dispatch<T>(pluginRefId, this.context(), payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* `label` names a desktop window, so it stays null and the calls needing one
|
||||
* refuse rather than guess which request the user is looking at.
|
||||
*/
|
||||
private context(): PluginContext {
|
||||
return { id: "web", label: null, workspaceId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every addition here is a capability decision, which is why they are written
|
||||
* out one at a time instead of forwarded wholesale.
|
||||
*/
|
||||
private async hostRequest(envelope: string): Promise<string> {
|
||||
const { pluginRefId, payload } = JSON.parse(envelope) as {
|
||||
pluginRefId: string;
|
||||
context: PluginContext;
|
||||
payload: InternalEventPayload;
|
||||
};
|
||||
|
||||
const reply = async (): Promise<InternalEventPayload> => {
|
||||
switch (payload.type) {
|
||||
case "get_key_value_request": {
|
||||
const value = await this.db.rpc<string | null>("web_plugin_kv_get", {
|
||||
pluginName: pluginRefId,
|
||||
key: (payload as unknown as KeyValueRequest).key,
|
||||
});
|
||||
return { type: "get_key_value_response", value } as InternalEventPayload;
|
||||
}
|
||||
case "set_key_value_request": {
|
||||
const { key, value } = payload as unknown as { key: string; value: string };
|
||||
await this.db.rpc("web_plugin_kv_set", {
|
||||
pluginName: pluginRefId,
|
||||
key,
|
||||
value,
|
||||
});
|
||||
return { type: "set_key_value_response" } as InternalEventPayload;
|
||||
}
|
||||
case "delete_key_value_request": {
|
||||
const deleted = await this.db.rpc<boolean>("web_plugin_kv_delete", {
|
||||
pluginName: pluginRefId,
|
||||
key: (payload as unknown as KeyValueRequest).key,
|
||||
});
|
||||
return { type: "delete_key_value_response", deleted } as InternalEventPayload;
|
||||
}
|
||||
|
||||
case "show_toast_request": {
|
||||
const { type: _type, ...toast } = payload;
|
||||
this.db.deliver("show_toast", toast);
|
||||
return { type: "empty_response" };
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`\`${payload.type}\` isn't something a plugin can do when Yaak runs in a browser yet`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
return JSON.stringify(await reply());
|
||||
} catch (err) {
|
||||
return JSON.stringify({
|
||||
type: "error_response",
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -10,6 +10,21 @@
|
||||
/** 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 }
|
||||
@@ -32,7 +47,9 @@ export type FromWorker =
|
||||
| { type: "result"; id: number; result: unknown }
|
||||
| { type: "error"; id: number; message: string }
|
||||
/** A backend event for the app — today only `model_writes`. Sent to every port. */
|
||||
| { type: "event"; event: string; payload: unknown };
|
||||
| { type: "event"; event: string; payload: unknown }
|
||||
/** The one message that runs the other way: the engine asking for a plugin. */
|
||||
| { type: "template_function"; id: number; name: string; args: string };
|
||||
|
||||
/** What the worker registers itself under. Tabs on one origin share it. */
|
||||
export const WORKER_NAME = "yaak-db";
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 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 server (`crates-server/yaak-web`).
|
||||
* 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 server 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 server's changes.
|
||||
*
|
||||
* The server 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/web";
|
||||
import type { WorkerConnection } from "./connection";
|
||||
import type { WebPlugins } from "./plugins";
|
||||
import { readFrames, serverIdentity, serverSendUrl } from "./server";
|
||||
|
||||
/* -------------------------------- 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-wasm) hands back. */
|
||||
interface PreparedHttpSend {
|
||||
request: HttpRequest;
|
||||
/** Hashed id of the model the auth came from; plugins key stored state on it. */
|
||||
authContextId: string;
|
||||
settings: HttpSendSettings;
|
||||
settingEvents: HttpResponseEventData[];
|
||||
cookieJar: CookieJar | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop applies auth to the sendable request; here the server builds that,
|
||||
* so the plugin's answer goes onto the model and the server folds it in. Same
|
||||
* bytes for a method that sets a header, which is every one that runs here.
|
||||
*
|
||||
* Not the same for one that *signs*, since the plugin sees the request before
|
||||
* the server assembles it. AWS SigV4 and OAuth 1.0 are refused rather than
|
||||
* mis-signed; see the sandbox README.
|
||||
*/
|
||||
async function applyAuthentication(
|
||||
plugins: WebPlugins,
|
||||
prepared: PreparedHttpSend,
|
||||
): Promise<HttpRequest> {
|
||||
const { request } = prepared;
|
||||
const authType = request.authenticationType;
|
||||
const disabled = request.authentication?.disabled === true;
|
||||
if (authType == null || authType === "none" || disabled) return request;
|
||||
|
||||
const applied = await plugins.applyHttpAuthentication(authType, {
|
||||
contextId: prepared.authContextId,
|
||||
values: request.authentication as Record<string, never>,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: request.headers.filter((h) => h.enabled !== false),
|
||||
// Only signing schemes hash the body, and those are already refused.
|
||||
body: null,
|
||||
});
|
||||
|
||||
const headers = [...request.headers];
|
||||
for (const header of applied.setHeaders ?? []) {
|
||||
// Replace-or-append, case-insensitively, matching `insert_header` in
|
||||
// crates/yaak-http.
|
||||
const at = headers.findIndex((h) => h.name.toLowerCase() === header.name.toLowerCase());
|
||||
const entry = { name: header.name, value: header.value, enabled: true };
|
||||
if (at >= 0) headers[at] = { ...headers[at], ...entry };
|
||||
else headers.push(entry);
|
||||
}
|
||||
|
||||
const urlParameters = [...request.urlParameters];
|
||||
for (const param of applied.setQueryParameters ?? []) {
|
||||
urlParameters.push({ name: param.name, value: param.value, enabled: true });
|
||||
}
|
||||
|
||||
return { ...request, headers, urlParameters };
|
||||
}
|
||||
|
||||
/** The desktop writes progress at most this often while a body streams in. */
|
||||
const PROGRESS_INTERVAL_MS = 100;
|
||||
|
||||
/* --------------------------------- 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 server 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 serverIdentity()}` }]);
|
||||
timeline.push(prepared.settingEvents);
|
||||
|
||||
const body: SendRequest = {
|
||||
request,
|
||||
settings: prepared.settings,
|
||||
cookies: prepared.cookieJar?.cookies ?? null,
|
||||
};
|
||||
const startedAt = performance.now();
|
||||
const res = await fetch(serverSendUrl(), {
|
||||
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 Yaak server at ${serverSendUrl()}: ${errorMessage(err)}`);
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// A refusal, not a failed send: bad destination, rate limit, a body the
|
||||
// server 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 Yaak server answered ${res.status}`);
|
||||
}
|
||||
if (res.body == null) throw new Error("The Yaak server 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 server 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 Yaak server 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 server'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;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* The wire to the Yaak server: where it is, and how to read what comes back.
|
||||
*
|
||||
* The shapes themselves are generated from `crates-server/yaak-web/src/wire.rs`
|
||||
* into `@yaakapp-internal/web`, so the two sides cannot drift silently.
|
||||
*/
|
||||
|
||||
import type { Frame } from "@yaakapp-internal/web";
|
||||
|
||||
/* ------------------------------- location -------------------------------- */
|
||||
|
||||
/**
|
||||
* Where the tab sends.
|
||||
*
|
||||
* Empty means "this origin": the server can serve the app itself
|
||||
* (`yaak-web --serve`), and then a send is a request to a path on the
|
||||
* page's own origin — no CORS, and nothing for a self-hoster to configure.
|
||||
*
|
||||
* `VITE_YAAK_WEB_URL` overrides it at build time, for a deployment that
|
||||
* keeps the two apart. The dev server is one of those: it serves the app on its
|
||||
* own origin and knows nothing about `/v1`, so a dev build falls back to a server
|
||||
* running locally (`cargo run -p yaak-web`).
|
||||
*/
|
||||
export function serverBaseUrl(): string {
|
||||
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
|
||||
const configured = env?.VITE_YAAK_WEB_URL?.trim();
|
||||
if (configured) return configured.replace(/\/+$/, "");
|
||||
return env?.DEV ? "http://127.0.0.1:9227" : "";
|
||||
}
|
||||
|
||||
export function serverSendUrl(): string {
|
||||
return `${serverBaseUrl()}/v1/http/send`;
|
||||
}
|
||||
|
||||
let identity: Promise<string> | null = null;
|
||||
|
||||
/** The server's location as a person reads it, since "" means "this origin". */
|
||||
function serverLocation(): string {
|
||||
return serverBaseUrl() || globalThis.location?.origin || "this origin";
|
||||
}
|
||||
|
||||
/**
|
||||
* Who does the sending, for the timeline: `yaak-web 0.1.0 at http://…`.
|
||||
* Asked of `/v1/health` once per page load; if the server can't be reached the
|
||||
* URL alone is the answer, and the send itself will say why shortly after.
|
||||
*/
|
||||
export function serverIdentity(): Promise<string> {
|
||||
identity ??= fetch(`${serverBaseUrl()}/v1/health`)
|
||||
.then((res) => res.json() as Promise<{ version?: string }>)
|
||||
.then((health) => `yaak-web ${health.version ?? ""} at ${serverLocation()}`.replace(" ", " "))
|
||||
.catch(() => {
|
||||
identity = null; // try again next send
|
||||
return `Yaak server at ${serverLocation()}`;
|
||||
});
|
||||
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 server 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();
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
|
||||
* download and compile — and the tab can therefore tell "this worker is dead"
|
||||
* from "this worker is busy" with a short timeout.
|
||||
*/
|
||||
type Engine = typeof import("@yaakapp-internal/web");
|
||||
type Engine = typeof import("@yaakapp-internal/wasm");
|
||||
let engine: Engine | null = null;
|
||||
|
||||
const ports = new Set<MessagePort>();
|
||||
@@ -96,7 +96,7 @@ function bootOnce(): Promise<void> {
|
||||
|
||||
booted = (async () => {
|
||||
await acquireDatabaseLock();
|
||||
const loaded = await import("@yaakapp-internal/web");
|
||||
const loaded = await import("@yaakapp-internal/wasm");
|
||||
await loaded.boot();
|
||||
engine = loaded;
|
||||
})();
|
||||
@@ -108,12 +108,36 @@ function bootOnce(): Promise<void> {
|
||||
return booted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendering happens here; the functions it calls live in a sandbox the tab
|
||||
* owns. Asked of the port that started the render, not every port, because
|
||||
* only that tab is waiting and only its sandbox has those plugins.
|
||||
*/
|
||||
const pendingTemplateFunctions = new Map<number, (result: string | Error) => void>();
|
||||
let nextTemplateFunctionId = 1;
|
||||
|
||||
function templateBridge(port: MessagePort): (name: string, args: string) => Promise<string> {
|
||||
return (name, args) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const id = nextTemplateFunctionId++;
|
||||
pendingTemplateFunctions.set(id, (r) => (r instanceof Error ? reject(r) : resolve(r)));
|
||||
send(port, { type: "template_function", id, name, args });
|
||||
});
|
||||
}
|
||||
|
||||
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
if (message.type === "goodbye") {
|
||||
ports.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "template_function_result") {
|
||||
const settle = pendingTemplateFunctions.get(message.id);
|
||||
pendingTemplateFunctions.delete(message.id);
|
||||
settle?.(message.error != null ? new Error(message.error) : (message.value ?? ""));
|
||||
return;
|
||||
}
|
||||
|
||||
// Every command waits for boot rather than the tab having to. Tabs post
|
||||
// the moment they load; the port queues; this drains once the DB is open.
|
||||
try {
|
||||
@@ -122,7 +146,8 @@ async function handle(port: MessagePort, message: ToWorker): Promise<void> {
|
||||
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
|
||||
return;
|
||||
}
|
||||
const { rpc, blob_get, blob_put, blob_delete } = engine!;
|
||||
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send, render_template } =
|
||||
engine!;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
@@ -142,6 +167,16 @@ 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) {
|
||||
|
||||
@@ -2,72 +2,36 @@ import console from "node:console";
|
||||
import { type Stats, statSync, watch } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
CallPromptFormDynamicArgs,
|
||||
Context,
|
||||
DynamicPromptFormArg,
|
||||
PluginDefinition,
|
||||
} from "@yaakapp/api";
|
||||
import {
|
||||
createPluginContext,
|
||||
type PluginTransport,
|
||||
} from "@yaakapp-internal/lib/pluginContext";
|
||||
import {
|
||||
applyDynamicFormInput,
|
||||
migrateTemplateFunctionSelectOptions,
|
||||
stripDynamicCallbacks,
|
||||
} from "@yaakapp-internal/lib/pluginForms";
|
||||
import {
|
||||
applyFormInputDefaults,
|
||||
validateTemplateFunctionArgs,
|
||||
} from "@yaakapp-internal/lib/templateFunction";
|
||||
import type {
|
||||
BootRequest,
|
||||
DeleteKeyValueResponse,
|
||||
DeleteModelResponse,
|
||||
FindHttpResponsesResponse,
|
||||
Folder,
|
||||
FormInput,
|
||||
GetCookieValueRequest,
|
||||
GetCookieValueResponse,
|
||||
GetHttpRequestByIdResponse,
|
||||
GetHttpResponseBodyInfoResponse,
|
||||
GetKeyValueResponse,
|
||||
GrpcRequestAction,
|
||||
HttpAuthenticationAction,
|
||||
HttpRequest,
|
||||
HttpRequestAction,
|
||||
HttpResponse,
|
||||
ImportResources,
|
||||
InternalEvent,
|
||||
InternalEventPayload,
|
||||
ListCookieNamesResponse,
|
||||
ListFoldersResponse,
|
||||
ListHttpRequestsRequest,
|
||||
ListHttpRequestsResponse,
|
||||
ListOpenWorkspacesResponse,
|
||||
PluginContext,
|
||||
PromptFormResponse,
|
||||
PromptTextResponse,
|
||||
ReadHttpResponseBodyChunkResponse,
|
||||
RenderGrpcRequestResponse,
|
||||
RenderHttpRequestResponse,
|
||||
SendHttpRequestResponse,
|
||||
TemplateFunction,
|
||||
TemplateRenderRequest,
|
||||
TemplateRenderResponse,
|
||||
UpsertModelResponse,
|
||||
WindowInfoResponse,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import { applyDynamicFormInput } from "./common";
|
||||
import { EventChannel } from "./EventChannel";
|
||||
import { migrateTemplateFunctionSelectOptions } from "./migrations";
|
||||
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
|
||||
|
||||
/**
|
||||
* A response as a plugin should see it.
|
||||
*
|
||||
* The host still puts `bodyPath` on the wire for its own callers, but it names
|
||||
* a file on the host's disk — meaningless to a plugin, absent once bodies move
|
||||
* off the filesystem, and impossible in a browser. Plugins address bodies by
|
||||
* response id, so drop it here rather than let one grow a dependency on it.
|
||||
*/
|
||||
function forPlugin(httpResponse: HttpResponse): HttpResponse {
|
||||
const { bodyPath: _bodyPath, ...rest } = httpResponse as HttpResponse & {
|
||||
bodyPath?: string | null;
|
||||
};
|
||||
return rest;
|
||||
}
|
||||
|
||||
export interface PluginWorkerData {
|
||||
bootRequest: BootRequest;
|
||||
@@ -629,444 +593,64 @@ export class PluginInstance {
|
||||
this.#sendEvent(eventToSend);
|
||||
}
|
||||
|
||||
#newCtx(context: PluginContext): Context {
|
||||
/** Read a body the host has stored, a chunk at a time, following it if it is still arriving. */
|
||||
const storedBody = async (responseId: string) => {
|
||||
const bodyInfo = () =>
|
||||
this.#sendForReply<GetHttpResponseBodyInfoResponse>(context, {
|
||||
type: "get_http_response_body_info_request",
|
||||
responseId,
|
||||
});
|
||||
const info = await bodyInfo();
|
||||
/**
|
||||
* How a plugin reaches the app from this runtime.
|
||||
*
|
||||
* Every request is an event whose reply is matched by id. This runtime can
|
||||
* hold a conversation open, so it supplies `stream` and `form`: a window
|
||||
* reports navigation until it closes, and a prompt form re-renders as values
|
||||
* change. `ctx` itself is built from these in @yaakapp-internal/lib, the same
|
||||
* way the sandbox runtime builds it.
|
||||
*/
|
||||
#transport: PluginTransport = {
|
||||
request: (context, payload) => this.#sendForReply(context, payload),
|
||||
|
||||
return createResponseBody(
|
||||
{
|
||||
responseId,
|
||||
contentLength: info.contentLength,
|
||||
contentType: info.contentType ?? null,
|
||||
complete: info.complete,
|
||||
},
|
||||
async (offset, length) => {
|
||||
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
|
||||
context,
|
||||
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
|
||||
);
|
||||
return decodeBase64Chunk(chunk.data);
|
||||
},
|
||||
{ refresh: bodyInfo },
|
||||
);
|
||||
};
|
||||
notify: (context, payload) => {
|
||||
this.#sendPayload(context, payload, null);
|
||||
},
|
||||
|
||||
const _windowInfo = async () => {
|
||||
if (context.label == null) {
|
||||
throw new Error("Can't get window context without an active window");
|
||||
}
|
||||
const payload: InternalEventPayload = {
|
||||
type: "window_info_request",
|
||||
label: context.label,
|
||||
};
|
||||
stream: (context, payload, onReply) => {
|
||||
this.#sendAndListenForEvents(context, payload, onReply);
|
||||
},
|
||||
|
||||
return this.#sendForReply<WindowInfoResponse>(context, payload);
|
||||
};
|
||||
form: (context, payload, onChange) => {
|
||||
// Built by hand so the event id is available: intermediate re-renders
|
||||
// reply to the original request rather than starting a new one.
|
||||
const eventToSend = this.#buildEventToSend(context, payload, null);
|
||||
|
||||
return {
|
||||
clipboard: {
|
||||
copyText: async (text) => {
|
||||
await this.#sendForReply(context, {
|
||||
type: "copy_text_request",
|
||||
text,
|
||||
});
|
||||
},
|
||||
},
|
||||
toast: {
|
||||
show: async (args) => {
|
||||
await this.#sendForReply(context, {
|
||||
type: "show_toast_request",
|
||||
// Handle default here because null/undefined both convert to None in Rust translation
|
||||
timeout: args.timeout === undefined ? 5000 : args.timeout,
|
||||
...args,
|
||||
});
|
||||
},
|
||||
},
|
||||
window: {
|
||||
requestId: async () => {
|
||||
return (await _windowInfo()).requestId;
|
||||
},
|
||||
async workspaceId(): Promise<string | null> {
|
||||
return (await _windowInfo()).workspaceId;
|
||||
},
|
||||
async environmentId(): Promise<string | null> {
|
||||
return (await _windowInfo()).environmentId;
|
||||
},
|
||||
openUrl: async ({ onNavigate, onClose, ...args }) => {
|
||||
args.label = args.label || `${Math.random()}`;
|
||||
const payload: InternalEventPayload = { type: "open_window_request", ...args };
|
||||
const onEvent = (event: InternalEventPayload) => {
|
||||
if (event.type === "window_navigate_event") {
|
||||
onNavigate?.(event);
|
||||
} else if (event.type === "window_close_event") {
|
||||
onClose?.();
|
||||
}
|
||||
};
|
||||
this.#sendAndListenForEvents(context, payload, onEvent);
|
||||
return {
|
||||
close: () => {
|
||||
const closePayload: InternalEventPayload = {
|
||||
type: "close_window_request",
|
||||
label: args.label,
|
||||
};
|
||||
this.#sendPayload(context, closePayload, null);
|
||||
},
|
||||
};
|
||||
},
|
||||
openExternalUrl: async (url) => {
|
||||
await this.#sendForReply(context, {
|
||||
type: "open_external_url_request",
|
||||
url,
|
||||
});
|
||||
},
|
||||
},
|
||||
prompt: {
|
||||
text: async (args) => {
|
||||
const reply: PromptTextResponse = await this.#sendForReply(context, {
|
||||
type: "prompt_text_request",
|
||||
...args,
|
||||
});
|
||||
return reply.value;
|
||||
},
|
||||
form: async (args) => {
|
||||
// Resolve dynamic callbacks on initial inputs using default values
|
||||
const defaults = applyFormInputDefaults(args.inputs, {});
|
||||
const callArgs: CallPromptFormDynamicArgs = { values: defaults };
|
||||
const resolvedInputs = await applyDynamicFormInput(
|
||||
this.#newCtx(context),
|
||||
args.inputs,
|
||||
callArgs,
|
||||
);
|
||||
const strippedInputs = stripDynamicCallbacks(resolvedInputs);
|
||||
return new Promise<PromptFormResponse>((resolve) => {
|
||||
const cb = (event: InternalEvent) => {
|
||||
if (event.replyId !== eventToSend.id) return;
|
||||
if (event.payload.type !== "prompt_form_response") return;
|
||||
|
||||
// Build the event manually so we can get the event ID for keying
|
||||
const eventToSend = this.#buildEventToSend(
|
||||
context,
|
||||
{ type: "prompt_form_request", ...args, inputs: strippedInputs },
|
||||
null,
|
||||
);
|
||||
|
||||
// Store original inputs (with dynamic callbacks) for later resolution
|
||||
this.#pendingDynamicForms.set(eventToSend.id, args.inputs);
|
||||
|
||||
const reply = await new Promise<PromptFormResponse>((resolve) => {
|
||||
const cb = (event: InternalEvent) => {
|
||||
if (event.replyId !== eventToSend.id) return;
|
||||
|
||||
if (event.payload.type === "prompt_form_response") {
|
||||
const { done, values } = event.payload as PromptFormResponse;
|
||||
if (done) {
|
||||
// Final response — resolve the promise and clean up
|
||||
this.#appToPluginEvents.unlisten(cb);
|
||||
this.#pendingDynamicForms.delete(eventToSend.id);
|
||||
resolve({ values } as PromptFormResponse);
|
||||
} else {
|
||||
// Intermediate value change — resolve dynamic inputs and send back
|
||||
// Skip empty values (fired on initial mount before user interaction)
|
||||
const storedInputs = this.#pendingDynamicForms.get(eventToSend.id);
|
||||
if (storedInputs && values && Object.keys(values).length > 0) {
|
||||
const ctx = this.#newCtx(context);
|
||||
const callArgs: CallPromptFormDynamicArgs = { values };
|
||||
applyDynamicFormInput(ctx, storedInputs, callArgs)
|
||||
.then((resolvedInputs) => {
|
||||
const stripped = stripDynamicCallbacks(resolvedInputs);
|
||||
this.#sendPayload(
|
||||
context,
|
||||
{ type: "prompt_form_request", ...args, inputs: stripped },
|
||||
eventToSend.id,
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to resolve dynamic form inputs", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
this.#appToPluginEvents.listen(cb);
|
||||
|
||||
// Send the initial event after we start listening (to prevent race)
|
||||
this.#sendEvent(eventToSend);
|
||||
});
|
||||
|
||||
return reply.values;
|
||||
},
|
||||
},
|
||||
httpResponse: {
|
||||
find: async (args) => {
|
||||
const payload = {
|
||||
type: "find_http_responses_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpResponses } = await this.#sendForReply<FindHttpResponsesResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpResponses.map(forPlugin);
|
||||
},
|
||||
body: ({ responseId }) => storedBody(responseId),
|
||||
},
|
||||
grpcRequest: {
|
||||
render: async (args) => {
|
||||
const payload = {
|
||||
type: "render_grpc_request_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { grpcRequest } = await this.#sendForReply<RenderGrpcRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return grpcRequest;
|
||||
},
|
||||
},
|
||||
httpRequest: {
|
||||
getById: async (args) => {
|
||||
const payload = {
|
||||
type: "get_http_request_by_id_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpRequest } = await this.#sendForReply<GetHttpRequestByIdResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpRequest;
|
||||
},
|
||||
send: async (args) => {
|
||||
const payload = {
|
||||
type: "send_http_request_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpResponse, body } = await this.#sendForReply<SendHttpRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
|
||||
// A send with no request behind it saves nothing, so the reply
|
||||
// carries the only copy of its body. A saved one is read back from
|
||||
// the host like any other. Callers get the same thing either way.
|
||||
if (body == null) {
|
||||
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
|
||||
const { done, values } = event.payload as PromptFormResponse;
|
||||
if (done) {
|
||||
this.#appToPluginEvents.unlisten(cb);
|
||||
resolve({ values } as PromptFormResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = decodeBase64Chunk(body);
|
||||
return {
|
||||
httpResponse: forPlugin(httpResponse),
|
||||
body: createResponseBody(
|
||||
{
|
||||
responseId: httpResponse.id,
|
||||
contentLength: bytes.byteLength,
|
||||
contentType:
|
||||
httpResponse.headers.find((h) => h.name.toLowerCase() === "content-type")
|
||||
?.value ?? null,
|
||||
// The host waited for the whole send before replying.
|
||||
complete: true,
|
||||
},
|
||||
async (offset, length) => bytes.slice(offset, offset + length),
|
||||
),
|
||||
};
|
||||
},
|
||||
render: async (args) => {
|
||||
const payload = {
|
||||
type: "render_http_request_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { httpRequest } = await this.#sendForReply<RenderHttpRequestResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpRequest;
|
||||
},
|
||||
list: async (args?: { folderId?: string }) => {
|
||||
const payload: InternalEventPayload = {
|
||||
type: "list_http_requests_request",
|
||||
folderId: args?.folderId,
|
||||
} satisfies ListHttpRequestsRequest & { type: "list_http_requests_request" };
|
||||
const { httpRequests } = await this.#sendForReply<ListHttpRequestsResponse>(
|
||||
context,
|
||||
payload,
|
||||
);
|
||||
return httpRequests;
|
||||
},
|
||||
create: async (args) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
name: "",
|
||||
method: "GET",
|
||||
...args,
|
||||
id: "",
|
||||
model: "http_request",
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
update: async (args) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
model: "http_request",
|
||||
...args,
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
delete: async (args) => {
|
||||
const payload = {
|
||||
type: "delete_model_request",
|
||||
model: "http_request",
|
||||
id: args.id,
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
|
||||
return response.model as HttpRequest;
|
||||
},
|
||||
},
|
||||
folder: {
|
||||
list: async () => {
|
||||
const payload = { type: "list_folders_request" } as const;
|
||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||
return folders;
|
||||
},
|
||||
getById: async (args: { id: string }) => {
|
||||
const payload = { type: "list_folders_request" } as const;
|
||||
const { folders } = await this.#sendForReply<ListFoldersResponse>(context, payload);
|
||||
return folders.find((f) => f.id === args.id) ?? null;
|
||||
},
|
||||
create: async ({ name, ...args }) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
...args,
|
||||
name: name ?? "",
|
||||
id: "",
|
||||
model: "folder",
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
update: async (args) => {
|
||||
const payload = {
|
||||
type: "upsert_model_request",
|
||||
model: {
|
||||
model: "folder",
|
||||
...args,
|
||||
},
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<UpsertModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
delete: async (args: { id: string }) => {
|
||||
const payload = {
|
||||
type: "delete_model_request",
|
||||
model: "folder",
|
||||
id: args.id,
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<DeleteModelResponse>(context, payload);
|
||||
return response.model as Folder;
|
||||
},
|
||||
},
|
||||
cookies: {
|
||||
getValue: async (args: GetCookieValueRequest) => {
|
||||
const payload = {
|
||||
type: "get_cookie_value_request",
|
||||
...args,
|
||||
} as const;
|
||||
const { value } = await this.#sendForReply<GetCookieValueResponse>(context, payload);
|
||||
return value;
|
||||
},
|
||||
listNames: async () => {
|
||||
const payload = { type: "list_cookie_names_request" } as const;
|
||||
const { names } = await this.#sendForReply<ListCookieNamesResponse>(context, payload);
|
||||
return names;
|
||||
},
|
||||
},
|
||||
templates: {
|
||||
/**
|
||||
* Invoke Yaak's template engine to render a value. If the value is a nested type
|
||||
* (eg. object), it will be recursively rendered.
|
||||
*/
|
||||
render: async (args: TemplateRenderRequest) => {
|
||||
const payload = { type: "template_render_request", ...args } as const;
|
||||
const result = await this.#sendForReply<TemplateRenderResponse>(context, payload);
|
||||
// oxlint-disable-next-line no-explicit-any -- That's okay
|
||||
return result.data as any;
|
||||
},
|
||||
},
|
||||
store: {
|
||||
get: async <T>(key: string) => {
|
||||
const payload = { type: "get_key_value_request", key } as const;
|
||||
const result = await this.#sendForReply<GetKeyValueResponse>(context, payload);
|
||||
return result.value ? (JSON.parse(result.value) as T) : undefined;
|
||||
},
|
||||
set: async <T>(key: string, value: T) => {
|
||||
const valueStr = JSON.stringify(value);
|
||||
const payload: InternalEventPayload = {
|
||||
type: "set_key_value_request",
|
||||
key,
|
||||
value: valueStr,
|
||||
};
|
||||
await this.#sendForReply<GetKeyValueResponse>(context, payload);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
const payload = { type: "delete_key_value_request", key } as const;
|
||||
const result = await this.#sendForReply<DeleteKeyValueResponse>(context, payload);
|
||||
return result.deleted;
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
reload: () => {
|
||||
this.#sendPayload(context, { type: "reload_response", silent: true }, null);
|
||||
},
|
||||
},
|
||||
workspace: {
|
||||
list: async () => {
|
||||
const payload = {
|
||||
type: "list_open_workspaces_request",
|
||||
} as InternalEventPayload;
|
||||
const response = await this.#sendForReply<ListOpenWorkspacesResponse>(context, payload);
|
||||
return response.workspaces.map((w) => {
|
||||
// Internal workspace info includes label field not in public API
|
||||
type WorkspaceInfoInternal = typeof w & { label?: string };
|
||||
return {
|
||||
id: w.id,
|
||||
name: w.name,
|
||||
// Hide label from plugin authors, but keep it for internal routing
|
||||
_label: (w as WorkspaceInfoInternal).label as string,
|
||||
};
|
||||
});
|
||||
},
|
||||
withContext: (workspaceHandle: { id: string; name: string; _label?: string }) => {
|
||||
// Create a new context with the workspace's window label
|
||||
const newContext: PluginContext = {
|
||||
...context,
|
||||
label: workspaceHandle._label || null,
|
||||
workspaceId: workspaceHandle.id,
|
||||
};
|
||||
return this.#newCtx(newContext);
|
||||
},
|
||||
},
|
||||
};
|
||||
onChange(values ?? {})
|
||||
.then((next) => {
|
||||
if (next != null) this.#sendPayload(context, next, eventToSend.id);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("Failed to resolve dynamic form inputs", err);
|
||||
});
|
||||
};
|
||||
this.#appToPluginEvents.listen(cb);
|
||||
|
||||
// Sent after the listener is attached, to prevent a race.
|
||||
this.#sendEvent(eventToSend);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
#newCtx(context: PluginContext): Context {
|
||||
return createPluginContext(this.#transport, context);
|
||||
}
|
||||
}
|
||||
|
||||
function stripDynamicCallbacks(inputs: { dynamic?: unknown }[]): FormInput[] {
|
||||
return inputs.map((input) => {
|
||||
// oxlint-disable-next-line no-explicit-any -- stripping dynamic from union type
|
||||
const { dynamic: _dynamic, ...rest } = input as any;
|
||||
if ("inputs" in rest && Array.isArray(rest.inputs)) {
|
||||
rest.inputs = stripDynamicCallbacks(rest.inputs);
|
||||
}
|
||||
return rest as FormInput;
|
||||
});
|
||||
}
|
||||
|
||||
function genId(len = 5): string {
|
||||
const alphabet = "01234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { TemplateFunctionPlugin } from "@yaakapp/api";
|
||||
|
||||
export function migrateTemplateFunctionSelectOptions(
|
||||
f: TemplateFunctionPlugin,
|
||||
): TemplateFunctionPlugin {
|
||||
const migratedArgs = f.args.map((a) => {
|
||||
if (a.type === "select") {
|
||||
// Migrate old options that had 'name' instead of 'label'
|
||||
type LegacyOption = { label?: string; value: string; name?: string };
|
||||
a.options = a.options.map((o) => {
|
||||
const legacy = o as LegacyOption;
|
||||
return {
|
||||
label: legacy.label ?? legacy.name ?? "",
|
||||
value: legacy.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
return a;
|
||||
});
|
||||
|
||||
return { ...f, args: migratedArgs };
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
# The Yaak plugin sandbox
|
||||
|
||||
A QuickJS interpreter, a small set of globals, and one function that calls the
|
||||
host. That is the whole runtime. Everything else a plugin does — read a request,
|
||||
send one, store a token, ask the user something — is a message the host chose to
|
||||
answer.
|
||||
|
||||
This document is the contract. It is written to be implementable twice: once
|
||||
here, in wasm, for the browser, and once in Rust with `rquickjs`, for the desktop
|
||||
and the CLI. **If the two hosts disagree about anything below, that is a bug in
|
||||
whichever one drifted, not a platform difference to work around.** The promise
|
||||
to plugin authors is that there is one sandbox and it behaves the same
|
||||
everywhere; a promise like that is only worth making if it is enforceable, which
|
||||
is why the browser runs QuickJS rather than the Worker's own JavaScript engine.
|
||||
|
||||
## The engine
|
||||
|
||||
**quickjs-ng**, and only quickjs-ng.
|
||||
|
||||
There is no real choice: `rquickjs` — the Rust binding the desktop host will use
|
||||
— vendors quickjs-ng as a git submodule and offers no alternative. Picking
|
||||
Bellard's upstream for the browser would mean the two hosts run different
|
||||
engines, which is exactly the thing this design exists to prevent.
|
||||
|
||||
| | Version | Notes |
|
||||
|---|---|---|
|
||||
| Browser (this package) | quickjs-ng **0.12.1** | via `@jitl/quickjs-ng-wasmfile-release-sync` 0.32.0 |
|
||||
| Desktop (planned) | quickjs-ng **0.15.1** | via `rquickjs` 0.12.2 |
|
||||
|
||||
**The version skew is a known gap, and closing it is slice-2 work.** Three minor
|
||||
versions is small — the differences are bug fixes and `Temporal` progress, not
|
||||
semantics anything here depends on — but "identical everywhere" is not a claim
|
||||
that survives being approximate indefinitely. Whoever builds the Rust host
|
||||
should pin both sides to the same tag and add a test that asserts the version
|
||||
string matches.
|
||||
|
||||
### Why the sync build, not ASYNCIFY
|
||||
|
||||
`quickjs-emscripten` ships an ASYNCIFY variant that lets guest code call an async
|
||||
host function *synchronously*. We use the plain sync build instead:
|
||||
|
||||
- ASYNCIFY is about twice the wasm size (1.08 MB vs 529 KB) and, measured,
|
||||
**2.2x slower**.
|
||||
- It can only suspend for one host call at a time. A runtime that runs several
|
||||
plugins would have to hold one wasm instance per in-flight call.
|
||||
- We do not need it. The guest gets real `await` anyway: a host function returns
|
||||
a QuickJS deferred promise, the host resolves it, and the host drains the job
|
||||
queue. `ctx.store.get(...)` is an ordinary `await` inside a plugin.
|
||||
|
||||
The only thing lost is a host call that *looks* synchronous to the guest, and no
|
||||
Yaak plugin wants one — the whole `ctx` API has been async since it existed.
|
||||
|
||||
## What exists inside the sandbox
|
||||
|
||||
QuickJS gives you the language and nothing else. Everything below is either
|
||||
installed by `src/guest/globals.ts` or absent. **Both hosts must install exactly
|
||||
this list.**
|
||||
|
||||
### From the engine
|
||||
|
||||
`Object`, `Array`, `Function`, `String`, `Number`, `Boolean`, `Symbol`, `Math`,
|
||||
`JSON`, `Date`, `RegExp`, `Error` and subclasses, `Map`, `Set`, `WeakMap`,
|
||||
`WeakSet`, `WeakRef`, `Promise`, `Proxy`, `Reflect`, `BigInt`, `ArrayBuffer`,
|
||||
`SharedArrayBuffer`, `DataView`, all `TypedArray`s, `globalThis`,
|
||||
`queueMicrotask`, `performance`.
|
||||
|
||||
Language level is ES2023 plus most of ES2024 — `Object.groupBy`,
|
||||
`Array.prototype.at`, `String.prototype.replaceAll`, async generators, private
|
||||
fields, `??=` all work.
|
||||
|
||||
### Installed by the runtime
|
||||
|
||||
| Global | Notes |
|
||||
|---|---|
|
||||
| `console` | `.log/.info/.warn/.error/.debug/.trace`. Arguments are formatted to a string **inside** the sandbox, so only strings cross out — a cycle or an exotic prototype is the guest's problem, not the host's. |
|
||||
| `setTimeout` / `clearTimeout` | The host holds the real timer; QuickJS has no clock to wake on. A sandbox torn down mid-wait takes its pending timers with it. |
|
||||
| `TextEncoder` / `TextDecoder` | UTF-8 only. Pure JavaScript, in-sandbox — a bridge would cost a copy each way. Lone surrogates encode to U+FFFD, matching the standard. |
|
||||
| `btoa` / `atob` | Latin-1, same narrow contract as the browser's. |
|
||||
|
||||
### Deliberately absent
|
||||
|
||||
`fetch`, `XMLHttpRequest`, `WebSocket`, `crypto`, `structuredClone`, `URL`,
|
||||
`URLSearchParams`, `setInterval`, `require`, `module`, `process`, `Buffer`,
|
||||
`std`, `os`, and every Node built-in.
|
||||
|
||||
- **Network and storage are absent because they are `ctx`'s job.** A plugin that
|
||||
could open its own socket would defeat the point of the sandbox and would not
|
||||
work in a browser anyway.
|
||||
- **`setInterval` is absent** because an interval is a timer that rearms and
|
||||
nothing in a plugin should be polling. Build one from `setTimeout`, visibly.
|
||||
- **`crypto` is absent, and this is the one real gap.** The decided direction is
|
||||
pure-JavaScript `@noble/*` inside the sandbox: audited, dependency-free,
|
||||
identical on both hosts, no host API to keep in sync. A `yaak.crypto` builtin
|
||||
is the escape hatch **if** a hot path is measured, not before. Concretely,
|
||||
`template-function-uuid` does not run in the sandbox today because its `uuid`
|
||||
dependency reaches for `node:crypto`; that is a slice-2 conversion, not a
|
||||
missing capability.
|
||||
- **`URL` is absent** only because nothing has needed it yet. It is a reasonable
|
||||
future addition; it must be added to both hosts together.
|
||||
|
||||
## The module contract
|
||||
|
||||
A module arrives as **source text**, not a file — there is no filesystem, and in
|
||||
a browser there could not be one.
|
||||
|
||||
It is evaluated as CommonJS, via `new Function("module", "exports", "require", source)`,
|
||||
and must assign `module.exports.plugin` (or `module.exports.default`). `new
|
||||
Function` rather than an ES module is deliberate: the bundle's top-level names
|
||||
cannot collide with the shell's, and the source needs no loader hook.
|
||||
|
||||
`require` exists **only to throw**, naming the specifier. A bundle that still
|
||||
calls it was not bundled for this target, and saying which module is missing
|
||||
beats an `undefined` that surfaces ten frames later.
|
||||
|
||||
Bundling requirements: CommonJS, no external modules, no Node built-ins, ES2022.
|
||||
`scripts/bundle-sandbox-plugins.mjs` does this today; what a real
|
||||
`yaakcli build --target sandbox` needs is listed at the bottom of that file.
|
||||
|
||||
## The host interface
|
||||
|
||||
Four functions, installed on `globalThis` before any plugin code runs. A Rust
|
||||
host must expose the same four with the same names and shapes.
|
||||
|
||||
| Function | Direction | Shape |
|
||||
|---|---|---|
|
||||
| `__yaak_call(envelopeJson)` | guest → host | Returns a **promise** of the reply JSON. The one door out. |
|
||||
| `__yaak_log(level, message)` | guest → host | Both strings. Fire and forget. |
|
||||
| `__yaak_timer_start(id, ms)` | guest → host | Host calls `__yaak_guest.fireTimer(id)` when due. |
|
||||
| `__yaak_timer_cancel(id)` | guest → host | |
|
||||
|
||||
And the guest exposes `globalThis.__yaak_guest`:
|
||||
|
||||
| Method | Shape |
|
||||
|---|---|
|
||||
| `load(source, pluginRefId)` | Evaluate a module. Throws if it exports no `plugin`. |
|
||||
| `summary()` | What the module contributes, as plain data. |
|
||||
| `dispatch(envelopeJson)` | Returns a promise of the reply payload JSON. |
|
||||
| `fireTimer(id)` | |
|
||||
|
||||
### Envelopes
|
||||
|
||||
Both directions carry `InternalEventPayload` from
|
||||
`crates/yaak-plugins/src/events.rs`, **unchanged**. That is what makes a plugin
|
||||
unable to tell which runtime it is in.
|
||||
|
||||
```jsonc
|
||||
// dispatch, host → guest
|
||||
{ "context": { "id": "...", "label": null, "workspaceId": "..." },
|
||||
"payload": { "type": "call_template_function_request", "name": "...", "args": { ... } } }
|
||||
|
||||
// __yaak_call, guest → host
|
||||
{ "pluginRefId": "auth-bearer",
|
||||
"context": { ... },
|
||||
"payload": { "type": "get_key_value_request", "key": "token" } }
|
||||
```
|
||||
|
||||
`pluginRefId` rides on outgoing calls because one host handler serves every
|
||||
loaded module, and a plugin's stored state is namespaced by which plugin it is —
|
||||
the same namespacing `build_shared_reply` does in `crates/yaak/src/plugin_events.rs`.
|
||||
|
||||
A throw inside a plugin becomes `{"type":"error_response","error":"..."}`, never
|
||||
a crash and never silence: whatever asked gets a message.
|
||||
|
||||
## The `ctx` API
|
||||
|
||||
Built entirely out of `__yaak_call`. See `src/guest/context.ts` — it is the same
|
||||
surface the Node runtime's `PluginInstance` builds, so it is not repeated here.
|
||||
|
||||
What differs is which calls a **host** answers. The browser host answers a
|
||||
deliberately short list (`packages/platform/src/web/plugins.ts`) and refuses the
|
||||
rest by name. Refusing by name matters: a plugin that needs something it cannot
|
||||
have should fail with a sentence someone can act on.
|
||||
|
||||
Answered in the browser today: `get_key_value`, `set_key_value`,
|
||||
`delete_key_value`, `show_toast`. Everything else — sends, model reads and
|
||||
writes, prompts, response bodies, window info — refuses. Those are capability
|
||||
decisions, not oversights, and each should be added one at a time.
|
||||
|
||||
`ctx.window.openUrl` throws in *every* sandbox host: a plugin-opened window is a
|
||||
desktop affordance with no browser equivalent, and handing back a handle whose
|
||||
`close()` does nothing would be worse.
|
||||
|
||||
## Isolation and limits
|
||||
|
||||
One runtime per worker, **one context per module**. A context is the isolation
|
||||
boundary — its own globals, its own `Object`, its own prototypes — so two plugins
|
||||
cannot see or patch each other. Sharing the runtime is deliberate: the engine and
|
||||
its wasm instance are the expensive part; contexts are not.
|
||||
|
||||
| Limit | Value | Why |
|
||||
|---|---|---|
|
||||
| Memory | 256 MB per runtime | Sized for an importer holding a large document and the objects it parses into. |
|
||||
| Stack | 2 MB | Deep recursion becomes a guest stack overflow, not a worker crash. |
|
||||
| Synchronous execution | 60 s | A watchdog for `while (true)`, **not** a limit on real work. |
|
||||
|
||||
The watchdog bounds *synchronous* execution only. A plugin awaiting the host is
|
||||
not looping, so the clock stops for the duration of a host call and restarts
|
||||
when the guest resumes. It is generous because it costs nothing to be: plugins
|
||||
run in their own worker, so one stuck there blocks no database command and no
|
||||
frame. It is sized off the slowest real work measured — GitHub's 12.3 MB OpenAPI
|
||||
description takes about 2.5 s (`bench/import.mjs`) — with room for a document
|
||||
several times larger before a legitimate import looks like a hang.
|
||||
|
||||
## Where the sandbox runs, and why not in the database worker
|
||||
|
||||
In the browser: a **dedicated worker owned by the tab**, separate from the
|
||||
SharedWorker that owns the database.
|
||||
|
||||
- Plugin work is slow by design, and the database worker answers every tab's
|
||||
commands synchronously. A large import in there would stall every other tab's
|
||||
reads.
|
||||
- A plugin that never returns can be ended with `terminate()`. You cannot do
|
||||
that to the worker holding the database.
|
||||
- The capabilities plugins actually ask for — a prompt, a toast, the active
|
||||
request — belong to a tab, not to a database. Routing through the tab is the
|
||||
shorter path, not a detour.
|
||||
|
||||
The cost is that `ctx.store` goes worker → tab → database worker. It is a message
|
||||
either way, and this is the direction where a stuck plugin costs nothing.
|
||||
|
||||
Template rendering is the one flow that runs backwards: rendering happens in the
|
||||
engine, in the database worker, but the functions it calls live here. So the
|
||||
engine is handed a callback that asks the tab, which asks the sandbox. See
|
||||
`templateBridge` in `packages/platform/src/web/worker.ts`.
|
||||
|
||||
## Plugins versus scripts
|
||||
|
||||
The shell is **not plugin-shaped underneath**. `load` takes source; `dispatch`
|
||||
takes an event. What a module *is* — a plugin today, a workspace script later —
|
||||
is decided by the payloads the host sends, not by the runtime.
|
||||
|
||||
That matters for one reason. A plugin is installed, so someone consented to it,
|
||||
and a plugin may one day escalate to a full Node runtime by asking. **A script
|
||||
arrives inside a workspace — as data, through an import, a git sync, a shared
|
||||
repository — with no consent moment at all.** So scripts get this sandbox and
|
||||
only this sandbox, forever, regardless of feature pressure. Any capability added
|
||||
below must be evaluated against the script case, which is the stricter one:
|
||||
"would I want this to run because someone opened a workspace a stranger sent
|
||||
them?"
|
||||
|
||||
Expected differences when scripts arrive, none of them built yet:
|
||||
|
||||
- A different payload set (`run_script_request` and friends) — same envelope.
|
||||
- A tighter host-call allowlist. A script should probably not reach `ctx.store`
|
||||
at all, and certainly not another plugin's namespace.
|
||||
- A much shorter watchdog. A pre-request script that runs for a minute is broken;
|
||||
an importer that does is working.
|
||||
|
||||
## Performance
|
||||
|
||||
QuickJS is an interpreter with no JIT. Measured on GitHub's 12.3 MB OpenAPI
|
||||
description (1220 requests imported, **identical output** in both engines):
|
||||
|
||||
| | First run | Best of 6 |
|
||||
|---|---|---|
|
||||
| Node (V8) | 304 ms | 164 ms |
|
||||
| QuickJS sandbox | 2503 ms | 2017 ms |
|
||||
|
||||
That is **8x on the first run** and about **12x once V8 has compiled** — well
|
||||
inside the 10–50x folklore, and the first-run number is the one a user waits for
|
||||
because an import happens once. Reproduce with:
|
||||
|
||||
```bash
|
||||
node packages/plugin-sandbox/bench/import.mjs <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 80–140 ms to instantiate the wasm and load a plugin,
|
||||
paid once and lazily, so a session that never touches a plugin never pays it.
|
||||
The wasm is 529 KB, next to the 4.3 MB SQLite one.
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* How much slower is an importer inside the sandbox? Yaak's OpenAPI importer is
|
||||
* first-party JavaScript, so a large spec is parsed by whatever engine the
|
||||
* runtime uses. Numbers are in the README.
|
||||
*
|
||||
* node packages/plugin-sandbox/bench/import.mjs <spec.json> [iterations]
|
||||
*/
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { bundlePlugin } from "../../../scripts/bundle-sandbox-plugins.mjs";
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
const PLUGIN = "importer-openapi";
|
||||
|
||||
const specPath = process.argv[2];
|
||||
const iterations = Number(process.argv[3] ?? 3);
|
||||
if (specPath == null) {
|
||||
console.error("usage: node bench/import.mjs <spec.json> [iterations]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const spec = readFileSync(specPath, "utf8");
|
||||
console.log(`Spec: ${specPath} (${(spec.length / 1024 / 1024).toFixed(1)} MB)`);
|
||||
console.log(`Iterations: ${iterations}\n`);
|
||||
|
||||
async function loadHost() {
|
||||
const outDir = join(root, "node_modules", ".cache", "yaak-plugin-sandbox");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outfile = join(outDir, "host.mjs");
|
||||
await build({
|
||||
entryPoints: [join(root, "packages/plugin-sandbox/src/host/sandbox.ts")],
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
outfile,
|
||||
// Resolved from the repo at run time, so the wasm variant is the real one.
|
||||
external: ["@jitl/*", "quickjs-emscripten-core"],
|
||||
});
|
||||
return import(pathToFileURL(outfile).href);
|
||||
}
|
||||
|
||||
const ctxStub = { id: "bench", label: null, workspaceId: "wk_bench" };
|
||||
|
||||
function stats(times) {
|
||||
const sorted = [...times].sort((a, b) => a - b);
|
||||
const mean = times.reduce((a, b) => a + b, 0) / times.length;
|
||||
return { min: sorted[0], median: sorted[Math.floor(sorted.length / 2)], mean };
|
||||
}
|
||||
|
||||
function report(label, times, resourceCount) {
|
||||
const { min, median } = stats(times);
|
||||
console.log(
|
||||
`${label.padEnd(20)} first ${times[0].toFixed(0).padStart(5)} ms ` +
|
||||
`best ${min.toFixed(0).padStart(5)} ms ` +
|
||||
`median ${median.toFixed(0).padStart(5)} ms (${resourceCount} requests)`,
|
||||
);
|
||||
// The spread is the point: V8 compiles this across the first few passes and
|
||||
// QuickJS does not compile at all.
|
||||
console.log(`${" ".repeat(20)} runs: ${times.map((t) => t.toFixed(0)).join(", ")} ms`);
|
||||
return { first: times[0], best: min };
|
||||
}
|
||||
|
||||
/* --------------------------------- Node ---------------------------------- */
|
||||
|
||||
const nodeTimes = [];
|
||||
let nodeCount = 0;
|
||||
{
|
||||
const { createRequire } = await import("node:module");
|
||||
const require = createRequire(join(root, "package.json"));
|
||||
const mod = require(join(root, "plugins", PLUGIN, "build", "index.js"));
|
||||
const plugin = mod.plugin ?? mod.default;
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const started = performance.now();
|
||||
const result = await plugin.importer.onImport(ctxStub, { text: spec });
|
||||
nodeTimes.push(performance.now() - started);
|
||||
nodeCount = result?.resources?.httpRequests?.length ?? 0;
|
||||
}
|
||||
}
|
||||
const node = report("Node (V8)", nodeTimes, nodeCount);
|
||||
|
||||
/* -------------------------------- QuickJS -------------------------------- */
|
||||
|
||||
const quickTimes = [];
|
||||
let quickCount = 0;
|
||||
{
|
||||
const { PluginSandboxHost } = await loadHost();
|
||||
const source = await bundlePlugin(PLUGIN);
|
||||
|
||||
const host = new PluginSandboxHost(
|
||||
async () => JSON.stringify({ type: "empty_response" }),
|
||||
(log) => console.error(`[${log.level}] ${log.message}`),
|
||||
);
|
||||
|
||||
const loadStarted = performance.now();
|
||||
await host.load(PLUGIN, source);
|
||||
console.log(`(sandbox boot + load: ${(performance.now() - loadStarted).toFixed(0)} ms)\n`);
|
||||
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
const started = performance.now();
|
||||
const reply = JSON.parse(
|
||||
await host.dispatch(
|
||||
PLUGIN,
|
||||
JSON.stringify({ context: ctxStub, payload: { type: "import_request", content: spec } }),
|
||||
),
|
||||
);
|
||||
quickTimes.push(performance.now() - started);
|
||||
if (reply.type === "error_response") throw new Error(reply.error);
|
||||
quickCount = reply.resources?.httpRequests?.length ?? 0;
|
||||
}
|
||||
host.dispose();
|
||||
}
|
||||
const quick = report("QuickJS (sandbox)", quickTimes, quickCount);
|
||||
|
||||
console.log(
|
||||
`\nFirst run (what a user waits for): ${(quick.first / 1000).toFixed(1)}s in the sandbox ` +
|
||||
`vs ${(node.first / 1000).toFixed(1)}s in Node — ${(quick.first / node.first).toFixed(1)}x.`,
|
||||
);
|
||||
console.log(
|
||||
`Best run (both warm): ${(quick.best / node.best).toFixed(1)}x, which is the ceiling once V8 has compiled.`,
|
||||
);
|
||||
if (nodeCount !== quickCount) {
|
||||
console.log(`WARNING: request counts differ (${nodeCount} vs ${quickCount}) — not the same work.`);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The shell has to reach QuickJS as source text. Emitted as a `.ts` module, not
|
||||
* a `.js` asset, so Vite and plain Node get at it the same way. Committed, like
|
||||
* the wasm packages, so a checkout builds without this having run.
|
||||
*/
|
||||
|
||||
import { build } from "esbuild";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = join(here, "src", "generated");
|
||||
|
||||
const result = await build({
|
||||
entryPoints: [join(here, "src", "guest", "index.ts")],
|
||||
bundle: true,
|
||||
write: false,
|
||||
format: "iife",
|
||||
platform: "browser",
|
||||
target: "es2022",
|
||||
minify: false,
|
||||
legalComments: "none",
|
||||
});
|
||||
|
||||
const source = result.outputFiles[0].text;
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(outDir, "guest.ts"),
|
||||
[
|
||||
"// Generated by build-guest.mjs. Do not edit.",
|
||||
"//",
|
||||
"// The runtime shell, as source text, for evaluation inside QuickJS.",
|
||||
"// Regenerate with `npm run build --workspace @yaakapp-internal/plugin-sandbox`.",
|
||||
"",
|
||||
`export const GUEST_SOURCE = ${JSON.stringify(source)};`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
console.log(`Bundled guest shell: ${(source.length / 1024).toFixed(1)} KB`);
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@yaakapp-internal/plugin-sandbox",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"bootstrap": "npm run build",
|
||||
"build": "node build-guest.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jitl/quickjs-ng-wasmfile-release-sync": "^0.32.0",
|
||||
"quickjs-emscripten-core": "^0.32.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Everything a plugin can reach that isn't the language itself. The Rust host
|
||||
* must install this same list; see the README.
|
||||
*/
|
||||
|
||||
declare const __yaak_log: (level: string, message: string) => void;
|
||||
declare const __yaak_timer_start: (id: number, ms: number) => void;
|
||||
declare const __yaak_timer_cancel: (id: number) => void;
|
||||
|
||||
/* -------------------------------- console -------------------------------- */
|
||||
|
||||
/** Formatted in here, so only strings cross the boundary. */
|
||||
function formatArgs(args: unknown[]): string {
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return arg.stack ?? `${arg.name}: ${arg.message}`;
|
||||
try {
|
||||
return JSON.stringify(arg, replacer()) ?? String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function replacer(): (key: string, value: unknown) => unknown {
|
||||
const seen = new WeakSet<object>();
|
||||
return (_key, value) => {
|
||||
if (typeof value === "bigint") return `${value}n`;
|
||||
if (typeof value === "function") return `[Function ${value.name || "anonymous"}]`;
|
||||
if (typeof value === "object" && value !== null) {
|
||||
if (seen.has(value)) return "[Circular]";
|
||||
seen.add(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
||||
function installConsole(): void {
|
||||
const log = (level: string) => (...args: unknown[]) => __yaak_log(level, formatArgs(args));
|
||||
(globalThis as Record<string, unknown>).console = {
|
||||
log: log("log"),
|
||||
info: log("info"),
|
||||
warn: log("warn"),
|
||||
error: log("error"),
|
||||
debug: log("debug"),
|
||||
trace: log("debug"),
|
||||
};
|
||||
}
|
||||
|
||||
/* --------------------------------- timers -------------------------------- */
|
||||
|
||||
/** QuickJS has no clock to wake on, so the host holds the real timer. */
|
||||
const timerCallbacks = new Map<number, () => void>();
|
||||
let nextTimerId = 1;
|
||||
|
||||
function installTimers(): void {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
g.setTimeout = (callback: (...a: unknown[]) => void, ms?: number, ...args: unknown[]) => {
|
||||
const id = nextTimerId++;
|
||||
timerCallbacks.set(id, () => callback(...args));
|
||||
__yaak_timer_start(id, Math.max(0, Number(ms) || 0));
|
||||
return id;
|
||||
};
|
||||
|
||||
g.clearTimeout = (id: number) => {
|
||||
if (!timerCallbacks.delete(id)) return;
|
||||
__yaak_timer_cancel(id);
|
||||
};
|
||||
|
||||
// An interval is a timer that rearms, and nothing in a plugin should poll.
|
||||
g.setInterval = undefined;
|
||||
g.clearInterval = undefined;
|
||||
}
|
||||
|
||||
/** Called by the host when a timer comes due. */
|
||||
function fireTimer(id: number): void {
|
||||
const callback = timerCallbacks.get(id);
|
||||
timerCallbacks.delete(id);
|
||||
callback?.();
|
||||
}
|
||||
|
||||
/* ------------------------------- text codecs ------------------------------ */
|
||||
|
||||
class SandboxTextEncoder {
|
||||
readonly encoding = "utf-8";
|
||||
|
||||
encode(input = ""): Uint8Array {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
let code = input.charCodeAt(i);
|
||||
// A lone surrogate becomes U+FFFD, as the standard encoder does.
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = input.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
code = (code - 0xd800) * 0x400 + (next - 0xdc00) + 0x10000;
|
||||
i++;
|
||||
} else {
|
||||
code = 0xfffd;
|
||||
}
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
code = 0xfffd;
|
||||
}
|
||||
|
||||
if (code < 0x80) out.push(code);
|
||||
else if (code < 0x800) out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f));
|
||||
else if (code < 0x10000)
|
||||
out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f));
|
||||
else
|
||||
out.push(
|
||||
0xf0 | (code >> 18),
|
||||
0x80 | ((code >> 12) & 0x3f),
|
||||
0x80 | ((code >> 6) & 0x3f),
|
||||
0x80 | (code & 0x3f),
|
||||
);
|
||||
}
|
||||
return new Uint8Array(out);
|
||||
}
|
||||
}
|
||||
|
||||
class SandboxTextDecoder {
|
||||
readonly encoding = "utf-8";
|
||||
|
||||
decode(input?: ArrayBuffer | ArrayBufferView): string {
|
||||
if (input == null) return "";
|
||||
const bytes =
|
||||
input instanceof Uint8Array
|
||||
? input
|
||||
: ArrayBuffer.isView(input)
|
||||
? new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
||||
: new Uint8Array(input);
|
||||
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; ) {
|
||||
const byte = bytes[i]!;
|
||||
let code: number;
|
||||
let size: number;
|
||||
if (byte < 0x80) {
|
||||
code = byte;
|
||||
size = 1;
|
||||
} else if ((byte & 0xe0) === 0xc0) {
|
||||
code = byte & 0x1f;
|
||||
size = 2;
|
||||
} else if ((byte & 0xf0) === 0xe0) {
|
||||
code = byte & 0x0f;
|
||||
size = 3;
|
||||
} else if ((byte & 0xf8) === 0xf0) {
|
||||
code = byte & 0x07;
|
||||
size = 4;
|
||||
} else {
|
||||
out += "�";
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (i + size > bytes.length) {
|
||||
out += "�";
|
||||
break;
|
||||
}
|
||||
for (let k = 1; k < size; k++) {
|
||||
const cont = bytes[i + k]!;
|
||||
if ((cont & 0xc0) !== 0x80) {
|
||||
code = -1;
|
||||
break;
|
||||
}
|
||||
code = (code << 6) | (cont & 0x3f);
|
||||
}
|
||||
i += size;
|
||||
|
||||
if (code < 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) out += "�";
|
||||
else if (code < 0x10000) out += String.fromCharCode(code);
|
||||
else {
|
||||
const c = code - 0x10000;
|
||||
out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
function installTextCodecs(): void {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
g.TextEncoder = SandboxTextEncoder;
|
||||
g.TextDecoder = SandboxTextDecoder;
|
||||
}
|
||||
|
||||
/* ------------------------------ base64 helpers ---------------------------- */
|
||||
|
||||
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
function installBase64(): void {
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
|
||||
g.btoa = (input: string): string => {
|
||||
let out = "";
|
||||
for (let i = 0; i < input.length; i += 3) {
|
||||
const a = input.charCodeAt(i);
|
||||
const b = input.charCodeAt(i + 1);
|
||||
const c = input.charCodeAt(i + 2);
|
||||
if (a > 0xff || b > 0xff || c > 0xff) {
|
||||
throw new Error("btoa: string contains characters outside of the Latin1 range");
|
||||
}
|
||||
const chunk = (a << 16) | ((Number.isNaN(b) ? 0 : b) << 8) | (Number.isNaN(c) ? 0 : c);
|
||||
out += B64[(chunk >> 18) & 63]! + B64[(chunk >> 12) & 63]!;
|
||||
out += Number.isNaN(b) ? "=" : B64[(chunk >> 6) & 63]!;
|
||||
out += Number.isNaN(c) ? "=" : B64[chunk & 63]!;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
g.atob = (input: string): string => {
|
||||
const clean = input.replace(/[\t\n\f\r ]/g, "").replace(/=+$/, "");
|
||||
let out = "";
|
||||
let bits = 0;
|
||||
let acc = 0;
|
||||
for (const ch of clean) {
|
||||
const value = B64.indexOf(ch);
|
||||
if (value < 0) throw new Error("atob: string contains invalid characters");
|
||||
acc = (acc << 6) | value;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out += String.fromCharCode((acc >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
export function installGlobals(): { fireTimer: (id: number) => void } {
|
||||
installConsole();
|
||||
installTimers();
|
||||
installTextCodecs();
|
||||
installBase64();
|
||||
return { fireTimer };
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* What QuickJS evaluates before any untrusted code does: install globals, load
|
||||
* one module, answer events against it.
|
||||
*
|
||||
* `load` takes source and `dispatch` takes an event, so what a module *is* — a
|
||||
* plugin today, a workspace script later — is the host's decision, not this
|
||||
* file's. See the README on why scripts never get a second runtime.
|
||||
*/
|
||||
|
||||
import type { PluginDefinition } from "@yaakapp/api";
|
||||
import {
|
||||
applyFormInputDefaults,
|
||||
validateTemplateFunctionArgs,
|
||||
} from "@yaakapp-internal/lib/templateFunction";
|
||||
import {
|
||||
applyDynamicFormInput,
|
||||
migrateTemplateFunctionSelectOptions,
|
||||
stripDynamicCallbacks,
|
||||
} from "@yaakapp-internal/lib/pluginForms";
|
||||
import type {
|
||||
GrpcRequestAction,
|
||||
HttpAuthenticationAction,
|
||||
HttpRequestAction,
|
||||
ImportResources,
|
||||
InternalEventPayload,
|
||||
PluginContext,
|
||||
TemplateFunction,
|
||||
} from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
createPluginContext,
|
||||
type PluginTransport,
|
||||
} from "@yaakapp-internal/lib/pluginContext";
|
||||
import { installGlobals } from "./globals";
|
||||
|
||||
declare const __yaak_call: (payloadJson: string) => Promise<string>;
|
||||
|
||||
const { fireTimer } = installGlobals();
|
||||
|
||||
let mod: PluginDefinition = {};
|
||||
let pluginRefId = "";
|
||||
|
||||
/**
|
||||
* `require` exists only to fail, by name: a bundle that still calls it was not
|
||||
* built for this target, and naming the specifier beats an undefined that
|
||||
* surfaces ten frames later.
|
||||
*/
|
||||
function load(source: string, refId: string): void {
|
||||
const module: { exports: Record<string, unknown> } = { exports: {} };
|
||||
const require = (specifier: string) => {
|
||||
throw new Error(
|
||||
`Module "${specifier}" is not available in the sandbox runtime. ` +
|
||||
`Plugins must be bundled with no external or built-in modules.`,
|
||||
);
|
||||
};
|
||||
|
||||
// Isolation is the QuickJS context around this, not a lint rule.
|
||||
// oxlint-disable-next-line no-implied-eval
|
||||
const factory = new Function("module", "exports", "require", source);
|
||||
factory(module, module.exports, require);
|
||||
|
||||
const loaded = (module.exports.plugin ?? module.exports.default) as PluginDefinition | undefined;
|
||||
if (loaded == null || typeof loaded !== "object") {
|
||||
throw new Error("Module did not export `plugin`");
|
||||
}
|
||||
mod = loaded;
|
||||
pluginRefId = refId;
|
||||
}
|
||||
|
||||
function summary(): Record<string, unknown> {
|
||||
return {
|
||||
templateFunctions: (mod.templateFunctions ?? []).map((f) => f.name),
|
||||
authentication: mod.authentication?.name ?? null,
|
||||
importer: mod.importer != null,
|
||||
filter: mod.filter != null,
|
||||
themes: (mod.themes ?? []).length,
|
||||
httpRequestActions: (mod.httpRequestActions ?? []).length,
|
||||
workspaceActions: (mod.workspaceActions ?? []).length,
|
||||
folderActions: (mod.folderActions ?? []).length,
|
||||
grpcRequestActions: (mod.grpcRequestActions ?? []).length,
|
||||
websocketRequestActions: (mod.websocketRequestActions ?? []).length,
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY: InternalEventPayload = { type: "empty_response" };
|
||||
|
||||
/**
|
||||
* Every branch mirrors the Node runtime's: same payloads, so a plugin cannot
|
||||
* tell which runtime it is in. An unmatched event gets `empty_response` rather
|
||||
* than silence, so no caller waits forever.
|
||||
*/
|
||||
/**
|
||||
* No `stream` and no `form`: both need the host to hold a conversation open,
|
||||
* which this protocol deliberately does not. `openUrl` refuses and a prompt
|
||||
* form is drawn once from its defaults, rather than quietly doing nothing.
|
||||
*/
|
||||
const transport: PluginTransport = {
|
||||
async request(context, payload) {
|
||||
// The id rides along because one host handler serves every loaded module,
|
||||
// and a plugin's storage is namespaced by which plugin it is.
|
||||
const replyJson = await __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
|
||||
const reply = JSON.parse(replyJson) as InternalEventPayload & { error?: string };
|
||||
if (reply.type === "error_response") {
|
||||
throw new Error(reply.error || `Host failed to handle ${payload.type}`);
|
||||
}
|
||||
const { type: _type, ...rest } = reply;
|
||||
return rest as Record<string, unknown>;
|
||||
},
|
||||
notify(context, payload) {
|
||||
void __yaak_call(JSON.stringify({ pluginRefId, context, payload }));
|
||||
},
|
||||
};
|
||||
|
||||
async function dispatch(
|
||||
context: PluginContext,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<InternalEventPayload> {
|
||||
const ctx = createPluginContext(transport, context);
|
||||
|
||||
if (payload.type === "boot_request") {
|
||||
await mod.init?.(ctx);
|
||||
return { type: "boot_response" };
|
||||
}
|
||||
|
||||
if (payload.type === "terminate_request") {
|
||||
await mod.dispose?.();
|
||||
return { type: "terminate_response" };
|
||||
}
|
||||
|
||||
if (payload.type === "import_request" && typeof mod.importer?.onImport === "function") {
|
||||
const reply = await mod.importer.onImport(ctx, { text: payload.content });
|
||||
if (reply != null) {
|
||||
return { type: "import_response", resources: reply.resources as ImportResources };
|
||||
}
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
if (payload.type === "filter_request" && typeof mod.filter?.onFilter === "function") {
|
||||
const reply = await mod.filter.onFilter(ctx, {
|
||||
filter: payload.filter,
|
||||
payload: payload.content,
|
||||
mimeType: payload.type,
|
||||
});
|
||||
return { type: "filter_response", ...reply };
|
||||
}
|
||||
|
||||
if (payload.type === "get_themes_request" && Array.isArray(mod.themes)) {
|
||||
return { type: "get_themes_response", themes: mod.themes };
|
||||
}
|
||||
|
||||
/* --------------------------- template functions -------------------------- */
|
||||
|
||||
if (
|
||||
payload.type === "get_template_function_summary_request" &&
|
||||
Array.isArray(mod.templateFunctions)
|
||||
) {
|
||||
const functions: TemplateFunction[] = mod.templateFunctions.map((f) => ({
|
||||
...migrateTemplateFunctionSelectOptions(f),
|
||||
onRender: undefined,
|
||||
}));
|
||||
return { type: "get_template_function_summary_response", pluginRefId, functions };
|
||||
}
|
||||
|
||||
if (
|
||||
payload.type === "get_template_function_config_request" &&
|
||||
Array.isArray(mod.templateFunctions)
|
||||
) {
|
||||
const found = mod.templateFunctions.find((f) => f.name === payload.name);
|
||||
if (found == null) return EMPTY;
|
||||
|
||||
const fn = { ...migrateTemplateFunctionSelectOptions(found), onRender: undefined };
|
||||
payload.values = applyFormInputDefaults(fn.args, payload.values);
|
||||
const resolved = await applyDynamicFormInput(ctx, fn.args, {
|
||||
...payload,
|
||||
purpose: "preview",
|
||||
} as const);
|
||||
|
||||
return {
|
||||
type: "get_template_function_config_response",
|
||||
pluginRefId,
|
||||
function: { ...fn, args: stripDynamicCallbacks(resolved) },
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === "call_template_function_request" && Array.isArray(mod.templateFunctions)) {
|
||||
const fn = mod.templateFunctions.find((f) => f.name === payload.name);
|
||||
|
||||
if (
|
||||
payload.args.purpose === "preview" &&
|
||||
(fn?.previewType === "click" || fn?.previewType === "none")
|
||||
) {
|
||||
return {
|
||||
type: "call_template_function_response",
|
||||
value: null,
|
||||
error: "Live preview disabled for this function",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof fn?.onRender === "function") {
|
||||
const resolved = await applyDynamicFormInput(ctx, fn.args, payload.args);
|
||||
const values = applyFormInputDefaults(resolved, payload.args.values);
|
||||
const error = validateTemplateFunctionArgs(fn.name, resolved, values);
|
||||
if (error && payload.args.purpose !== "preview") {
|
||||
return { type: "call_template_function_response", value: null, error };
|
||||
}
|
||||
|
||||
const result = await fn.onRender(ctx, { ...payload.args, values });
|
||||
return { type: "call_template_function_response", value: result ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------- http authentication ------------------------- */
|
||||
|
||||
if (payload.type === "get_http_authentication_summary_request" && mod.authentication) {
|
||||
return { type: "get_http_authentication_summary_response", ...mod.authentication };
|
||||
}
|
||||
|
||||
if (payload.type === "get_http_authentication_config_request" && mod.authentication) {
|
||||
const { args, actions } = mod.authentication;
|
||||
payload.values = applyFormInputDefaults(args, payload.values);
|
||||
const resolved = await applyDynamicFormInput(ctx, args, payload);
|
||||
const resolvedActions: HttpAuthenticationAction[] = [];
|
||||
// oxlint-disable-next-line unbound-method
|
||||
for (const { onSelect: _onSelect, ...action } of actions ?? []) resolvedActions.push(action);
|
||||
|
||||
return {
|
||||
type: "get_http_authentication_config_response",
|
||||
args: stripDynamicCallbacks(resolved),
|
||||
actions: resolvedActions,
|
||||
pluginRefId,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type === "call_http_authentication_request" && mod.authentication) {
|
||||
const auth = mod.authentication;
|
||||
if (typeof auth.onApply === "function") {
|
||||
const resolved = await applyDynamicFormInput(ctx, auth.args, payload);
|
||||
payload.values = applyFormInputDefaults(resolved, payload.values);
|
||||
return { type: "call_http_authentication_response", ...(await auth.onApply(ctx, payload)) };
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.type === "call_http_authentication_action_request" && mod.authentication != null) {
|
||||
const action = mod.authentication.actions?.[payload.index];
|
||||
if (typeof action?.onSelect === "function") {
|
||||
await action.onSelect(ctx, payload.args);
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------- actions ------------------------------- */
|
||||
|
||||
if (payload.type === "get_http_request_actions_request" && Array.isArray(mod.httpRequestActions)) {
|
||||
const actions: HttpRequestAction[] = mod.httpRequestActions.map((a) => ({
|
||||
...a,
|
||||
onSelect: undefined,
|
||||
}));
|
||||
return { type: "get_http_request_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (
|
||||
payload.type === "get_websocket_request_actions_request" &&
|
||||
Array.isArray(mod.websocketRequestActions)
|
||||
) {
|
||||
const actions = mod.websocketRequestActions.map((a) => ({ ...a, onSelect: undefined }));
|
||||
return { type: "get_websocket_request_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (payload.type === "get_grpc_request_actions_request" && Array.isArray(mod.grpcRequestActions)) {
|
||||
const actions: GrpcRequestAction[] = mod.grpcRequestActions.map((a) => ({
|
||||
...a,
|
||||
onSelect: undefined,
|
||||
}));
|
||||
return { type: "get_grpc_request_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (payload.type === "get_workspace_actions_request" && Array.isArray(mod.workspaceActions)) {
|
||||
const actions = mod.workspaceActions.map((a) => ({ ...a, onSelect: undefined }));
|
||||
return { type: "get_workspace_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
if (payload.type === "get_folder_actions_request" && Array.isArray(mod.folderActions)) {
|
||||
const actions = mod.folderActions.map((a) => ({ ...a, onSelect: undefined }));
|
||||
return { type: "get_folder_actions_response", pluginRefId, actions };
|
||||
}
|
||||
|
||||
const called = await callAction(ctx, payload);
|
||||
if (called) return EMPTY;
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
async function callAction(
|
||||
ctx: ReturnType<typeof createPluginContext>,
|
||||
payload: InternalEventPayload,
|
||||
): Promise<boolean> {
|
||||
const lists = {
|
||||
call_http_request_action_request: mod.httpRequestActions,
|
||||
call_websocket_request_action_request: mod.websocketRequestActions,
|
||||
call_grpc_request_action_request: mod.grpcRequestActions,
|
||||
call_workspace_action_request: mod.workspaceActions,
|
||||
call_folder_action_request: mod.folderActions,
|
||||
} as const;
|
||||
|
||||
const list = lists[payload.type as keyof typeof lists];
|
||||
if (!Array.isArray(list)) return false;
|
||||
|
||||
const action = list[(payload as { index: number }).index];
|
||||
if (typeof action?.onSelect !== "function") return false;
|
||||
|
||||
await action.onSelect(ctx, (payload as { args: never }).args);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
|
||||
(globalThis as Record<string, unknown>).__yaak_guest = {
|
||||
load,
|
||||
summary,
|
||||
fireTimer,
|
||||
dispatch: async (envelopeJson: string): Promise<string> => {
|
||||
const { context, payload } = JSON.parse(envelopeJson) as {
|
||||
context: PluginContext;
|
||||
payload: InternalEventPayload;
|
||||
};
|
||||
try {
|
||||
return JSON.stringify(await dispatch(context, payload));
|
||||
} catch (err) {
|
||||
// A throw from a plugin is an answer, not a crash.
|
||||
const error = (err instanceof Error ? err.message : String(err)).replace(/^Error:\s*/g, "");
|
||||
return JSON.stringify({ type: "error_response", error });
|
||||
}
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user