Serve the web client from yaak-web (#582)

This commit is contained in:
Gregory Schier
2026-08-18 10:39:17 -07:00
committed by GitHub
parent 3f202ff664
commit 538f782068
50 changed files with 809 additions and 424 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
**/node_modules
dist
**/dist
target
**/target
.claude
vendored
**/vendored
*.log
.git
+1 -1
View File
@@ -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).
+115
View File
@@ -0,0 +1,115 @@
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:
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=auto` tags latest for a release tag and leaves it alone for a prerelease
# (v2026.2.1-beta.1) or a manual run off a branch.
- name: Tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=semver,pattern={{version}}
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
+40 -24
View File
@@ -3284,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"
@@ -9548,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",
@@ -11675,29 +11691,6 @@ dependencies = [
"yaak-ws",
]
[[package]]
name = "yaak-send-proxy"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"base64 0.22.1",
"bytes",
"clap",
"env_logger",
"futures-util",
"log 0.4.29",
"serde",
"serde_json",
"tokio",
"tower-http",
"ts-rs",
"url",
"uuid",
"yaak-http",
"yaak-models",
]
[[package]]
name = "yaak-sse"
version = "0.1.0"
@@ -11776,7 +11769,7 @@ dependencies = [
]
[[package]]
name = "yaak-web"
name = "yaak-wasm"
version = "0.1.0"
dependencies = [
"console_error_panic_hook",
@@ -11795,6 +11788,29 @@ dependencies = [
"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",
]
[[package]]
name = "yaak-window"
version = "0.1.0"
+2 -2
View File
@@ -22,13 +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-send-proxy",
"crates-server/yaak-web",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
+46
View File
@@ -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"]
-139
View File
@@ -1,139 +0,0 @@
# yaak-send-proxy
The network half of Yaak in a browser.
A tab can't see an HTTP response the way a desktop app can: CORS hides most
headers (2 of 8 in a typical response), redirects are followed silently, and
there is no timeline. So the tab renders the request and posts it here, and this
process puts it on the network with the desktop's own engine (`yaak-http`) and
streams back everything that happened — every header, every redirect hop, DNS
timing, the body — for the tab to store.
It is a **stateless executor**. It keeps nothing: no database, no files, no
sessions, no cookies between calls. Every byte it sees comes from the tab in the
request, and every byte it returns is stored by the tab. Restart it any time.
## Running it
```shell
cargo run -p yaak-send-proxy
```
Listens on `127.0.0.1:9227`. Then run the web build against it:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
```
The tab looks for the proxy at `http://127.0.0.1:9227` unless
`VITE_YAAK_SEND_PROXY_URL` says otherwise at build time.
Every flag has a `YAAK_PROXY_*` environment variable, so a container needs no
arguments; `--help` lists them all.
| Flag | Default | What |
| --- | --- | --- |
| `--bind` | `127.0.0.1:9227` | Listen address. `0.0.0.0:9227` inside a container. |
| `--allowed-origins` | `*` | CORS origins, comma-separated. A hosted instance should name its web origin. |
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
| `--max-concurrent` | 256 | Sends in flight at once. |
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
## What it refuses, and why
A hosted proxy is, by construction, a machine that makes HTTP requests on
behalf of strangers. Left alone that is an open relay into whatever network it
sits on. So it refuses, always, to connect to:
- loopback (`127/8`, `::1`), private (`10/8`, `172.16/12`, `192.168/16`,
`fc00::/7`), link-local (`169.254/16` — where cloud metadata lives — and
`fe80::/10`), carrier-grade NAT, multicast, reserved and unspecified ranges,
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 the proxy's disk (`binary`, multipart file
fields), since no browser tab could legitimately mean those.
Refusals are logged with the reason. There is no switch to turn this off: the
proxy's private network is the cloud's, not the user's, so a `localhost` or LAN
API can never be reached through it — that is what the desktop app is for.
## Deploying
One binary, no dependencies:
```shell
cargo build --release -p yaak-send-proxy
YAAK_PROXY_BIND=0.0.0.0:9227 \
YAAK_PROXY_ALLOWED_ORIGINS=https://yaak.example.com \
./target/release/yaak-send-proxy
```
There is no authentication: an instance is anonymous and protected by the
per-client rate limit and the destination policy, which is what the hosted
funnel wants. Anything more (a shared token, per-user quotas) is a later slice
and would sit in front of `send_http` in `main.rs`. Put TLS in front of it (a
reverse proxy). If the reverse proxy buffers responses, tell it not to: the reply
is a stream and the `X-Accel-Buffering: no` header it sets is honoured by
nginx-shaped ones.
## The wire
`POST /v1/http/send` with a JSON body:
```json
{
"request": { "url": "https://…", "method": "GET", "headers": [], "body": {}, "bodyType": null, "urlParameters": [] },
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
"cookies": [ ]
}
```
`request` is a Yaak `HttpRequest` in the desktop's own model shape with every
template already rendered by the tab; the proxy builds the URL, headers and
body from it exactly the way the desktop does after rendering. `cookies` is the
jar's contents (or `null` for no jar).
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
happened:
| `type` | When | Carries |
| --- | --- | --- |
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
| `body` | as the body is read | a decompressed chunk, base64 |
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
| `error` | last, on failure | the reason, and any cookies collected before the failure |
Refusals that happen before anything is sent (a blocked destination, a bad body,
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
`{"error": "…"}`, not streams.
Why a streamed HTTP response and not a WebSocket: one `POST` is stateless by
construction, cancellable by closing the connection, readable with `curl`, and
needs no upgrade handling on either side. A WebSocket only earns its keep when
traffic is bidirectional, which a single send is not.
The TypeScript side of this contract is generated from `src/wire.rs` by ts-rs
into `bindings/` (run `cargo test -p yaak-send-proxy` after changing a frame)
and published to the tab as `@yaakapp-internal/send-proxy`, so a change to the
wire on one side is a type error on the other.
`GET /v1/health` reports the version and the effective limits.
## What comes later
Not built, by design, but the router is shaped for it: a WebSocket relay
(`/v1/ws/relay`) and a gRPC relay (`/v1/grpc/relay`) would be long-lived,
bidirectional endpoints on the same binary, behind the same destination policy
and limits. They differ from this endpoint in holding per-connection
in-memory state while a connection is open (never persisted), which brings
connection limits and a larger abuse surface — the reason they are separate
work.
-4
View File
@@ -1,4 +0,0 @@
// The send proxy's wire contract, generated by ts-rs from src/wire.rs
// (`cargo test -p yaak-send-proxy`). The tab imports these so a change to a
// frame on the Rust side is a type error in packages/platform/src/web.
export type { Frame, SendRequest } from "./bindings/gen_send_proxy";
@@ -1,52 +0,0 @@
use clap::Parser;
use std::net::SocketAddr;
/// A stateless HTTP send executor for Yaak running in a browser.
///
/// The tab renders the request and owns the data; this binary only puts bytes on the
/// network and streams back what came back. Nothing is written to disk or a database.
#[derive(Parser, Debug, Clone)]
#[command(name = "yaak-send-proxy", version, about, long_about = None)]
pub struct Config {
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
#[arg(long, env = "YAAK_PROXY_BIND", default_value = "127.0.0.1:9227")]
pub bind: SocketAddr,
/// Browser origins allowed to call this proxy (CORS), comma-separated. `*` allows any.
/// A local dev instance wants the Vite origin; a hosted instance wants its own web origin.
#[arg(
long,
env = "YAAK_PROXY_ALLOWED_ORIGINS",
default_value = "*",
value_delimiter = ','
)]
pub allowed_origins: Vec<String>,
/// Largest request the proxy accepts from the tab (the rendered request JSON, body included).
#[arg(long, env = "YAAK_PROXY_MAX_REQUEST_BYTES", default_value_t = 16 * 1024 * 1024)]
pub max_request_bytes: usize,
/// Largest upstream response body the proxy will relay before cutting the send off.
#[arg(long, env = "YAAK_PROXY_MAX_RESPONSE_BYTES", default_value_t = 64 * 1024 * 1024)]
pub max_response_bytes: usize,
/// Ceiling on a send's timeout, in seconds. A request asking for longer (or for no timeout)
/// gets this instead.
#[arg(long, env = "YAAK_PROXY_MAX_TIMEOUT_SECS", default_value_t = 60)]
pub max_timeout_secs: u64,
/// Sends allowed per client IP per minute. 0 disables the limit. This and the concurrency
/// cap are the whole of what protects an instance: there is no authentication.
#[arg(long, env = "YAAK_PROXY_RATE_LIMIT_PER_MINUTE", default_value_t = 120)]
pub rate_limit_per_minute: u32,
/// Sends in flight at once across all clients.
#[arg(long, env = "YAAK_PROXY_MAX_CONCURRENT", default_value_t = 256)]
pub max_concurrent: usize,
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only turn
/// this on behind a load balancer that sets the header; otherwise anyone can spoof their way
/// past the rate limit.
#[arg(long, env = "YAAK_PROXY_TRUST_FORWARDED_FOR", default_value_t = false)]
pub trust_forwarded_for: bool,
}
@@ -1,9 +1,9 @@
[package]
name = "yaak-send-proxy"
name = "yaak-web"
version = "0.1.0"
edition = "2024"
publish = false
description = "Stateless HTTP send executor for Yaak in the browser"
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
@@ -13,7 +13,7 @@ description = "Stateless HTTP send executor for Yaak in the browser"
# its query layer.
[[bin]]
name = "yaak-send-proxy"
name = "yaak-web"
path = "src/main.rs"
[dependencies]
@@ -28,7 +28,7 @@ log = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "sync", "io-util", "time", "net"] }
tower-http = { version = "0.6", features = ["cors"] }
tower-http = { version = "0.6", features = ["compression-gzip", "compression-zstd", "cors", "fs"] }
ts-rs = { workspace = true }
url = "2"
uuid = { version = "1", features = ["v4"] }
+219
View File
@@ -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.
@@ -28,7 +28,7 @@ export type HttpResponseHeader = { name: string, value: string, };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
@@ -49,13 +49,13 @@ cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies:
export type SendRequest = {
/**
* The request to send, in the desktop's own model shape but with every template already
* rendered by the tab. The proxy builds the URL, headers and body from it exactly the way
* 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 proxy only needs to obey them.
* its timeline; the server only needs to obey them.
*/
settings: HttpSendSettings,
/**
+4
View File
@@ -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";
@@ -1,5 +1,5 @@
{
"name": "@yaakapp-internal/send-proxy",
"name": "@yaakapp-internal/web",
"version": "1.0.0",
"private": true,
"main": "index.ts"
+67
View File
@@ -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,
}
@@ -20,13 +20,20 @@ use yaak_http::dns::AddressFilter;
use yaak_http::sender::{HttpResponse, HttpResponseEvent, HttpSender};
use yaak_http::types::SendableHttpRequest;
/// The destination policy, shared by every send: public addresses only, always. A hosted
/// proxy's "private network" is the cloud's, not the user's, so there is no configuration
/// that makes reaching it right.
/// 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;
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> {
@@ -53,9 +60,12 @@ impl DestinationPolicy {
}
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 proxy only sends to public addresses"
"Refusing to connect to {ip}: {reason}. This server only sends to public addresses"
)),
None => Ok(()),
}
@@ -236,15 +246,24 @@ mod tests {
#[test]
fn literal_private_addresses_in_urls_are_refused() {
let policy = DestinationPolicy;
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;
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());
@@ -1,4 +1,4 @@
//! yaak-send-proxy: the network half of Yaak in a browser.
//! 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
@@ -6,7 +6,7 @@
//! with the desktop's own engine and streams back everything that happened,
//! for the tab to store. It keeps nothing: no database, no files, no session.
//!
//! One binary, configured by flags or `YAAK_PROXY_*` environment variables.
//! 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.
@@ -18,8 +18,9 @@ mod wire;
use axum::Router;
use axum::body::Body;
use axum::extract::{ConnectInfo, DefaultBodyLimit, State};
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;
@@ -30,10 +31,13 @@ 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)]
@@ -49,7 +53,13 @@ async fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let config = Config::parse();
let policy = DestinationPolicy;
let 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,
@@ -66,7 +76,7 @@ async fn main() {
.allow_headers([header::CONTENT_TYPE])
.allow_origin(allowed_origins(&state.config.allowed_origins));
let app = Router::new()
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.
@@ -75,13 +85,21 @@ async fn main() {
.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-send-proxy listening on http://{bind} (rate limit: {}/min)",
"yaak-web listening on http://{bind} (rate limit: {}/min)",
state.config.rate_limit_per_minute,
);
@@ -94,6 +112,48 @@ async fn main() {
.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();
@@ -149,7 +209,7 @@ async fn send_http(
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
warn!("At capacity; refusing {ip}");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This proxy is at capacity");
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
};
let prepared = match send::prepare(state.limits.clone(), body).await {
@@ -42,7 +42,7 @@ pub struct SendLimits {
pub enum Refusal {
/// The request asks for something a browser-originated send cannot mean.
Unsupported(String),
/// The destination is not one this proxy will talk to.
/// The destination is not one this server will talk to.
Destination(String),
/// The request could not be turned into something sendable.
Invalid(String),
@@ -57,10 +57,10 @@ pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<Prepa
// The engine reads files for these body types. There are no files here that a browser tab
// could legitimately mean, and letting a request name a path on this machine would be a
// local file read for anyone who can reach the proxy.
// 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 proxy has no access to your files"
"Binary file bodies can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
@@ -74,7 +74,7 @@ pub async fn prepare(limits: Arc<SendLimits>, send: SendRequest) -> Result<Prepa
});
if names_a_file {
return Err(Refusal::Unsupported(
"Multipart file fields can't be sent from the browser: the proxy has no access to your files"
"Multipart file fields can't be sent from the browser: the server has no access to your files"
.to_string(),
));
}
@@ -204,7 +204,7 @@ impl PreparedSend {
if self.timeout_capped {
let _ = event_tx.try_send(HttpResponseEvent::Info(format!(
"Timeout set to {:?} (this proxy's ceiling)",
"Timeout set to {:?} (this server's ceiling)",
self.timeout
)));
}
@@ -276,7 +276,7 @@ impl PreparedSend {
total += n;
if total > limits.max_response_bytes {
break Err(format!(
"Response body exceeds this proxy's limit of {} bytes",
"Response body exceeds this server's limit of {} bytes",
limits.max_response_bytes
));
}
@@ -309,7 +309,7 @@ impl PreparedSend {
///
/// A connection error from reqwest arrives wrapped several layers deep, and the layer that
/// says something useful — "Refusing to connect to ::1: loopback" — is the innermost. The
/// desktop shows the outer `Debug`; a stranger reading a proxy's reply deserves the reason.
/// 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) => {
@@ -1,4 +1,4 @@
//! What crosses the wire between a tab and this proxy.
//! 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
@@ -6,13 +6,13 @@
//! JSON frames: timeline events as they happen, the response head as soon as
//! headers arrive, body chunks as they are read, and one terminal frame.
//!
//! Nothing here names a workspace, a request id, or a response id. The proxy
//! 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-send-proxy`) and published to the tab as
//! `@yaakapp-internal/send-proxy`, so a change here is a type error there.
//! `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;
@@ -23,14 +23,14 @@ use yaak_models::models::{
/// The body of `POST /v1/http/send`.
#[derive(Deserialize, Debug, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_send_proxy.ts")]
#[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 proxy builds the URL, headers and body from it exactly the way
/// 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 proxy only needs to obey them.
/// 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)]
@@ -45,7 +45,7 @@ pub struct SendRequest {
rename_all = "snake_case",
rename_all_fields = "camelCase"
)]
#[ts(export, export_to = "gen_send_proxy.ts")]
#[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.
+1 -1
View File
@@ -73,7 +73,7 @@ export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
+1 -1
View File
@@ -307,7 +307,7 @@ export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = {
validateCertificates: boolean;
+1 -1
View File
@@ -197,7 +197,7 @@ impl ResolvedHttpRequestSettings {
/// The resolved send settings, values only: what an executor has to obey, with the sources
/// (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
/// crosses from a tab to the send proxy, and what the proxy reads.
/// 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")]
@@ -1,5 +1,5 @@
[package]
name = "yaak-web"
name = "yaak-wasm"
version = "0.1.0"
edition = "2024"
publish = false
@@ -1,5 +1,5 @@
{
"name": "@yaakapp-internal/web",
"name": "@yaakapp-internal/wasm",
"version": "1.0.0",
"private": true,
"main": "index.ts",
+17
View File
@@ -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/*"
]
}
@@ -29,7 +29,7 @@ export function boot(): Promise<void>;
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the send proxy.
* posts to the Yaak server.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
+9
View File
@@ -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, rpc
} from "./yaak_wasm_bg.js";
@@ -66,7 +66,7 @@ export function boot() {
* Resolve and render a request for sending, exactly as the desktop does before it puts the
* request on the network: the environment chain, inherited headers and auth, request
* settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
* posts to the send proxy.
* posts to the Yaak server.
*
* Refuses, with a message the user can act on, when the request needs something this host
* doesn't have: an authentication plugin, or a template function.
@@ -708,7 +708,7 @@ export function __wbindgen_cast_0000000000000002(arg0, arg1) {
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 83, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe);
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
@@ -765,8 +765,8 @@ function wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3(arg0, arg
}
}
function wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
@@ -18,7 +18,7 @@ export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
export const sqlite3_os_end: () => number;
export const sqlite3_os_init: () => number;
export const wasm_bindgen__convert__closures_____invoke__ha1c2fa93df0107f3: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h38d884a456ef1afe: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h4381d8e749fe46cf: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h2cf3f4cce3b29948: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha7903b6e296dd8f4: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__ha1b480b83daa641f: (a: number, b: number) => void;
@@ -478,7 +478,7 @@ impl TemplateCallback for NoPluginsCallback {
/// Resolve and render a request for sending, exactly as the desktop does before it puts the
/// request on the network: the environment chain, inherited headers and auth, request
/// settings, the cookie jar. Nothing here touches a socket. What comes back is what the tab
/// posts to the send proxy.
/// posts to the Yaak server.
///
/// Refuses, with a message the user can act on, when the request needs something this host
/// doesn't have: an authentication plugin, or a template function.
-17
View File
@@ -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/*"
]
}
-6
View File
@@ -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();
+1 -1
View File
@@ -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"
}
+12 -12
View File
@@ -67,10 +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-send-proxy",
"crates-server/yaak-web",
"apps/yaak-client",
"apps/yaak-proxy"
],
@@ -273,8 +273,8 @@
"name": "@yaakapp-internal/proxy-lib",
"version": "1.0.0"
},
"crates-server/yaak-send-proxy": {
"name": "@yaakapp-internal/send-proxy",
"crates-server/yaak-web": {
"name": "@yaakapp-internal/web",
"version": "1.0.0"
},
"crates-tauri/yaak-app-client": {
@@ -335,8 +335,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"
@@ -5650,10 +5650,6 @@
"resolved": "crates/common/yaak-rpc-schema",
"link": true
},
"node_modules/@yaakapp-internal/send-proxy": {
"resolved": "crates-server/yaak-send-proxy",
"link": true
},
"node_modules/@yaakapp-internal/sse": {
"resolved": "crates/yaak-sse",
"link": true
@@ -5686,8 +5682,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": {
@@ -15810,7 +15810,7 @@
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/models": "^1.0.0",
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/send-proxy": "^1.0.0",
"@yaakapp-internal/wasm": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0"
}
},
+2 -2
View File
@@ -66,10 +66,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-send-proxy",
"crates-server/yaak-web",
"apps/yaak-client",
"apps/yaak-proxy"
],
+1 -1
View File
@@ -11,8 +11,8 @@
"dependencies": {
"@yaakapp-internal/models": "^1.0.0",
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/send-proxy": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0",
"@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",
+23 -15
View File
@@ -24,9 +24,9 @@ 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
│ ◀── model_writes ── crates/yaak-wasm → yaak-models → SQLite
│ └─ pages in IndexedDB
└── send.ts ──POST rendered request──▶ yaak-send-proxy (crates-server) ──▶ the internet
└── send.ts ──POST rendered request──▶ yaak-web (crates-server) ──▶ the internet
◀── NDJSON: events, response, body, cookies ──
```
@@ -35,14 +35,14 @@ tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaak
| `index.ts` | The `Platform` implementation. |
| `commands.ts` | The command table: model commands forward to the worker; the rest is fixed answers and refusals-with-a-reason. |
| `connection.ts` | A tab's end of the wire: request/response over a `MessagePort`, event delivery, and the tab's identity (`label`). |
| `send.ts` | Sending: the worker renders (`prepare_http_send`), the proxy executes, this file stores what comes back where the desktop stores it. |
| `proxy.ts` | The proxy's location and wire shapes, mirrored by hand from `crates-server/yaak-send-proxy/src/wire.rs`. |
| `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()`,
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
@@ -85,7 +85,7 @@ Behaviours worth knowing before changing anything:
| Group | Commands |
| --- | --- |
| Models | `models_workspace_models`, `models_upsert`, `models_delete`, `models_duplicate`, `models_get_settings`, `models_get_graphql_introspection`, `models_upsert_graphql_introspection`, `models_grpc_events`, `models_websocket_events` |
| Sending | `cmd_send_http_request` (through the send proxy; see below) |
| 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` |
@@ -187,8 +187,8 @@ other's writes for an echo of their own and drop them.
A page cannot see a response the way a desktop app can — CORS exposes a handful
of headers, redirects are followed silently, there is no timeline — so the
network half of a send runs on a small stateless proxy,
`crates-server/yaak-send-proxy`. This layer stays the only place data lives:
network half of a send runs on a small stateless server,
`crates-server/yaak-web`. This layer stays the only place data lives:
1. `send.ts` creates the `http_response` row (state `initialized`), as the
desktop does, so anything that goes wrong lands in the response pane.
@@ -198,7 +198,7 @@ network half of a send runs on a small stateless proxy,
with `yaak_models::render::render_http_request`. Variables (`${[ name ]}`)
render here with no plugins involved.
3. The rendered request, the settings and the jar's cookies are POSTed to the
proxy. It streams back timeline events, the response head, body chunks and a
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
@@ -210,10 +210,18 @@ authentication is none, or an inline header. Sending a request that needs a
template *function* (`${[ timestamp() ]}`) or an authentication plugin (bearer,
basic, OAuth, …) is refused before anything leaves the tab, with a message naming
what it needs; those light up when plugins run in the browser. Requests with a
file body or multipart file fields are refused by the proxy (it has no access to
your files, and must not read its own). And a request to `localhost` or a LAN
address can't work from a browser: the proxy runs elsewhere and refuses private
ranges outright — reaching your own machine's APIs is what the desktop app is for.
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.
The proxy URL is `VITE_YAAK_SEND_PROXY_URL` at build time, defaulting to
`http://127.0.0.1:9227` (see `proxy.ts`). Run one with `cargo run -p yaak-send-proxy`.
**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.
+3 -3
View File
@@ -71,7 +71,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
/* ------------------------------- sending ------------------------------- */
// The tab renders and stores; a stateless proxy puts the bytes on the wire.
// 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) => {
const requestId = str(payload, "requestId");
@@ -249,7 +249,7 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
* while the first is a slice away.
*/
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
// Saved requests send through the proxy (see send.ts). Ephemeral sends — the
// 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],
@@ -266,7 +266,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"],
+1 -1
View File
@@ -163,7 +163,7 @@ export class WorkerConnection {
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
}
/** See `prepare_http_send` in crates/yaak-web: the database half of a send. */
/** 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 }));
}
+4 -4
View File
@@ -7,7 +7,7 @@
* the origin, so two tabs stay coherent for the same reason two desktop windows
* do: one process holds the data and pushes every write to all of them.
*
* Sending goes through a small stateless proxy, because a page cannot see a
* 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`
@@ -33,7 +33,7 @@ import { requestPersistence } from "./storage";
/** What this host can do, reported honestly. */
function capabilitiesFor(): PlatformCapabilities {
return {
// Through the send proxy: the tab renders, the proxy executes, the tab
// 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,
@@ -42,11 +42,11 @@ function capabilitiesFor(): PlatformCapabilities {
git: false,
sync: false,
// Certificates and proxies are decided by whoever puts the bytes on the
// wire, and the send proxy uses its own.
// wire, and the Yaak server uses its own.
tlsOptions: false,
cookieJar: true,
localFiles: false,
// The proxy streams the engine's events back and the sender stores them.
// 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
-71
View File
@@ -1,71 +0,0 @@
/**
* The wire to the send proxy: where it is, and how to read what comes back.
*
* The shapes themselves are generated from `crates-server/yaak-send-proxy/src/wire.rs`
* into `@yaakapp-internal/send-proxy`, so the two sides cannot drift silently.
*/
import type { Frame } from "@yaakapp-internal/send-proxy";
/* ------------------------------- location -------------------------------- */
/**
* Where the tab sends. Build-time configuration for now: `VITE_YAAK_SEND_PROXY_URL`
* (Vite exposes `VITE_*` to the bundle), defaulting to a proxy on this machine at
* its default port. A per-user setting can replace this later without touching
* the callers, which only ever ask for the URL.
*/
export function proxyBaseUrl(): string {
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
const configured = env?.VITE_YAAK_SEND_PROXY_URL?.trim();
return (configured || "http://127.0.0.1:9227").replace(/\/+$/, "");
}
export function proxySendUrl(): string {
return `${proxyBaseUrl()}/v1/http/send`;
}
let identity: Promise<string> | null = null;
/**
* Who does the sending, for the timeline: `yaak-send-proxy 0.1.0 at http://…`.
* Asked of `/v1/health` once per page load; if the proxy can't be reached the
* URL alone is the answer, and the send itself will say why shortly after.
*/
export function proxyIdentity(): Promise<string> {
identity ??= fetch(`${proxyBaseUrl()}/v1/health`)
.then((res) => res.json() as Promise<{ version?: string }>)
.then((health) => `yaak-send-proxy ${health.version ?? ""} at ${proxyBaseUrl()}`.replace(" ", " "))
.catch(() => {
identity = null; // try again next send
return `send proxy at ${proxyBaseUrl()}`;
});
return identity;
}
/**
* Yield frames from an NDJSON stream as they arrive. A partial trailing line is
* held until its newline comes; anything left when the stream ends is dropped,
* because a frame without its newline is a frame the proxy didn't finish writing.
*/
export async function* readFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<Frame> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim() !== "") yield JSON.parse(line) as Frame;
newline = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
+17 -17
View File
@@ -3,7 +3,7 @@
*
* A tab can't see a response the way the desktop can — CORS hides most headers,
* redirects are followed silently, there is no timeline — so the network half of
* a send happens on a small stateless proxy (`crates-server/yaak-send-proxy`).
* 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:
*
@@ -11,13 +11,13 @@
* 2. resolve and render the request in the worker (`prepare_http_send`: the
* environment chain, inherited headers and auth, request settings, cookie
* jar — the desktop's `HttpSendInputs`, in Rust, on the same model layer);
* 3. POST the rendered request to the proxy and consume its stream: timeline
* 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 proxy's changes.
* blob under the response id, the cookie jar with the server's changes.
*
* The proxy keeps nothing. Every byte it sees comes from this tab and every
* The server keeps nothing. Every byte it sees comes from this tab and every
* byte it returns is stored by this tab.
*/
@@ -31,9 +31,9 @@ import type {
HttpResponseEventData,
HttpSendSettings,
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/send-proxy";
import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection";
import { proxyIdentity, proxySendUrl, readFrames } from "./proxy";
import { serverIdentity, serverSendUrl, readFrames } from "./server";
/* -------------------------------- shapes --------------------------------- */
@@ -48,7 +48,7 @@ type ResponseRow = Pick<HttpResponse, "model" | "requestId" | "workspaceId"> &
type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-web) hands back. */
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
interface PreparedHttpSend {
request: HttpRequest;
settings: HttpSendSettings;
@@ -68,7 +68,7 @@ export async function sendHttpRequest(
cookieJarId: string | null,
): Promise<ResponseRow> {
// The response row exists before anything can go wrong, as on the desktop, so
// a failure to render or to reach the proxy lands in the response pane as
// 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 });
@@ -107,7 +107,7 @@ async function runSend(
// request through a proxy shows a different origin to the server than the
// user's machine, and this is where that should be visible.
const timeline = new TimelineWriter(db, response.id, response.workspaceId);
timeline.push([{ type: "info", message: `Executed by ${await proxyIdentity()}` }]);
timeline.push([{ type: "info", message: `Executed by ${await serverIdentity()}` }]);
timeline.push(prepared.settingEvents);
const body: SendRequest = {
@@ -116,19 +116,19 @@ async function runSend(
cookies: prepared.cookieJar?.cookies ?? null,
};
const startedAt = performance.now();
const res = await fetch(proxySendUrl(), {
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 send proxy at ${proxySendUrl()}: ${errorMessage(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
// proxy can't build. It comes as JSON with the reason.
// server can't build. It comes as JSON with the reason.
const text = await res.text();
let reason = text;
try {
@@ -136,9 +136,9 @@ async function runSend(
} catch {
/* not JSON; the text is the reason */
}
throw new Error(reason || `The send proxy answered ${res.status}`);
throw new Error(reason || `The Yaak server answered ${res.status}`);
}
if (res.body == null) throw new Error("The send proxy sent no body");
if (res.body == null) throw new Error("The Yaak server sent no body");
const chunks: Uint8Array[] = [];
let received = 0;
@@ -175,12 +175,12 @@ async function runSend(
if (terminal != null) break;
}
// Everything the proxy said about the timeline is in the database before the
// 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 send proxy closed the stream without finishing");
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
@@ -196,7 +196,7 @@ async function runSend(
// The body is written under the response id, which is how every reader —
// `cmd_http_response_body`, the image viewer, the download button — asks for
// it. One write, once the whole body is here: the worker's blob store has no
// append, and a body larger than memory is over the proxy's cap anyway.
// 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,
+83
View File
@@ -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();
}
}
+2 -2
View File
@@ -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;
})();
+1 -1
View File
@@ -5,7 +5,7 @@ export default defineConfig({
"*": "vp check --fix",
},
lint: {
ignorePatterns: ["npm/**", "crates/yaak-templates/pkg/**", "crates/yaak-web/pkg/**", "**/bindings/gen_*.ts"],
ignorePatterns: ["npm/**", "crates/yaak-templates/pkg/**", "crates/yaak-wasm/pkg/**", "**/bindings/gen_*.ts"],
options: {
typeAware: true,
},