diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..50e4b2fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +**/node_modules +dist +**/dist +target +**/target +.claude +vendored +**/vendored +*.log +.git diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml index c20901b5..6c519594 100644 --- a/.github/workflows/release-app.yml +++ b/.github/workflows/release-app.yml @@ -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). diff --git a/.github/workflows/release-web-image.yml b/.github/workflows/release-web-image.yml new file mode 100644 index 00000000..fc07b25c --- /dev/null +++ b/.github/workflows/release-web-image.yml @@ -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 }} diff --git a/Cargo.lock b/Cargo.lock index c8639ef0..c12ef146 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 69acaf2f..772e679d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 00000000..dba755cc --- /dev/null +++ b/Dockerfile.web @@ -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"] diff --git a/crates-server/yaak-send-proxy/README.md b/crates-server/yaak-send-proxy/README.md deleted file mode 100644 index 32474f11..00000000 --- a/crates-server/yaak-send-proxy/README.md +++ /dev/null @@ -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. diff --git a/crates-server/yaak-send-proxy/index.ts b/crates-server/yaak-send-proxy/index.ts deleted file mode 100644 index e9fdd9db..00000000 --- a/crates-server/yaak-send-proxy/index.ts +++ /dev/null @@ -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"; diff --git a/crates-server/yaak-send-proxy/src/config.rs b/crates-server/yaak-send-proxy/src/config.rs deleted file mode 100644 index 91ae2ccd..00000000 --- a/crates-server/yaak-send-proxy/src/config.rs +++ /dev/null @@ -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, - - /// 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, -} diff --git a/crates-server/yaak-send-proxy/Cargo.toml b/crates-server/yaak-web/Cargo.toml similarity index 82% rename from crates-server/yaak-send-proxy/Cargo.toml rename to crates-server/yaak-web/Cargo.toml index 0b525048..024b1674 100644 --- a/crates-server/yaak-send-proxy/Cargo.toml +++ b/crates-server/yaak-web/Cargo.toml @@ -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"] } diff --git a/crates-server/yaak-web/README.md b/crates-server/yaak-web/README.md new file mode 100644 index 00000000..c30e9beb --- /dev/null +++ b/crates-server/yaak-web/README.md @@ -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 . 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. diff --git a/crates-server/yaak-send-proxy/bindings/gen_models.ts b/crates-server/yaak-web/bindings/gen_models.ts similarity index 97% rename from crates-server/yaak-send-proxy/bindings/gen_models.ts rename to crates-server/yaak-web/bindings/gen_models.ts index 7c3f7a84..2263c04a 100644 --- a/crates-server/yaak-send-proxy/bindings/gen_models.ts +++ b/crates-server/yaak-web/bindings/gen_models.ts @@ -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, /** diff --git a/crates-server/yaak-send-proxy/bindings/gen_send_proxy.ts b/crates-server/yaak-web/bindings/gen_web.ts similarity index 93% rename from crates-server/yaak-send-proxy/bindings/gen_send_proxy.ts rename to crates-server/yaak-web/bindings/gen_web.ts index 3132405f..4361472b 100644 --- a/crates-server/yaak-send-proxy/bindings/gen_send_proxy.ts +++ b/crates-server/yaak-web/bindings/gen_web.ts @@ -49,13 +49,13 @@ cookies: Array | 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, /** diff --git a/crates-server/yaak-web/index.ts b/crates-server/yaak-web/index.ts new file mode 100644 index 00000000..62ebb54e --- /dev/null +++ b/crates-server/yaak-web/index.ts @@ -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"; diff --git a/crates-server/yaak-send-proxy/package.json b/crates-server/yaak-web/package.json similarity index 61% rename from crates-server/yaak-send-proxy/package.json rename to crates-server/yaak-web/package.json index cc290995..60cadb82 100644 --- a/crates-server/yaak-send-proxy/package.json +++ b/crates-server/yaak-web/package.json @@ -1,5 +1,5 @@ { - "name": "@yaakapp-internal/send-proxy", + "name": "@yaakapp-internal/web", "version": "1.0.0", "private": true, "main": "index.ts" diff --git a/crates-server/yaak-web/src/config.rs b/crates-server/yaak-web/src/config.rs new file mode 100644 index 00000000..bd86c82f --- /dev/null +++ b/crates-server/yaak-web/src/config.rs @@ -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, + + /// 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, + + /// 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, +} diff --git a/crates-server/yaak-send-proxy/src/guard.rs b/crates-server/yaak-web/src/guard.rs similarity index 89% rename from crates-server/yaak-send-proxy/src/guard.rs rename to crates-server/yaak-web/src/guard.rs index b94604d7..284fb7a1 100644 --- a/crates-server/yaak-send-proxy/src/guard.rs +++ b/crates-server/yaak-web/src/guard.rs @@ -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()); diff --git a/crates-server/yaak-send-proxy/src/limits.rs b/crates-server/yaak-web/src/limits.rs similarity index 100% rename from crates-server/yaak-send-proxy/src/limits.rs rename to crates-server/yaak-web/src/limits.rs diff --git a/crates-server/yaak-send-proxy/src/main.rs b/crates-server/yaak-web/src/main.rs similarity index 69% rename from crates-server/yaak-send-proxy/src/main.rs rename to crates-server/yaak-web/src/main.rs index 4db927f2..edf9c078 100644 --- a/crates-server/yaak-send-proxy/src/main.rs +++ b/crates-server/yaak-web/src/main.rs @@ -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 { diff --git a/crates-server/yaak-send-proxy/src/send.rs b/crates-server/yaak-web/src/send.rs similarity index 96% rename from crates-server/yaak-send-proxy/src/send.rs rename to crates-server/yaak-web/src/send.rs index a8c2c980..814b06d8 100644 --- a/crates-server/yaak-send-proxy/src/send.rs +++ b/crates-server/yaak-web/src/send.rs @@ -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, send: SendRequest) -> Result, send: SendRequest) -> Result 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) => { diff --git a/crates-server/yaak-send-proxy/src/wire.rs b/crates-server/yaak-web/src/wire.rs similarity index 89% rename from crates-server/yaak-send-proxy/src/wire.rs rename to crates-server/yaak-web/src/wire.rs index 30eeb584..25561731 100644 --- a/crates-server/yaak-send-proxy/src/wire.rs +++ b/crates-server/yaak-web/src/wire.rs @@ -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. diff --git a/crates/common/yaak-rpc-schema/bindings/gen_models.ts b/crates/common/yaak-rpc-schema/bindings/gen_models.ts index 78938ae1..9f73f1e7 100644 --- a/crates/common/yaak-rpc-schema/bindings/gen_models.ts +++ b/crates/common/yaak-rpc-schema/bindings/gen_models.ts @@ -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, /** diff --git a/crates/yaak-models/bindings/gen_models.ts b/crates/yaak-models/bindings/gen_models.ts index 76453e2f..21da46e7 100644 --- a/crates/yaak-models/bindings/gen_models.ts +++ b/crates/yaak-models/bindings/gen_models.ts @@ -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; diff --git a/crates/yaak-models/src/models.rs b/crates/yaak-models/src/models.rs index fdec37cc..98637b03 100644 --- a/crates/yaak-models/src/models.rs +++ b/crates/yaak-models/src/models.rs @@ -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")] diff --git a/crates/yaak-web/Cargo.toml b/crates/yaak-wasm/Cargo.toml similarity index 98% rename from crates/yaak-web/Cargo.toml rename to crates/yaak-wasm/Cargo.toml index 05a0cf1f..32f56137 100644 --- a/crates/yaak-web/Cargo.toml +++ b/crates/yaak-wasm/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "yaak-web" +name = "yaak-wasm" version = "0.1.0" edition = "2024" publish = false diff --git a/crates/yaak-web/build-wasm.cjs b/crates/yaak-wasm/build-wasm.cjs similarity index 100% rename from crates/yaak-web/build-wasm.cjs rename to crates/yaak-wasm/build-wasm.cjs diff --git a/crates/yaak-web/index.ts b/crates/yaak-wasm/index.ts similarity index 100% rename from crates/yaak-web/index.ts rename to crates/yaak-wasm/index.ts diff --git a/crates/yaak-web/package.json b/crates/yaak-wasm/package.json similarity index 88% rename from crates/yaak-web/package.json rename to crates/yaak-wasm/package.json index 30088d49..da4486a9 100644 --- a/crates/yaak-web/package.json +++ b/crates/yaak-wasm/package.json @@ -1,5 +1,5 @@ { - "name": "@yaakapp-internal/web", + "name": "@yaakapp-internal/wasm", "version": "1.0.0", "private": true, "main": "index.ts", diff --git a/crates/yaak-wasm/pkg/package.json b/crates/yaak-wasm/pkg/package.json new file mode 100644 index 00000000..00e0213a --- /dev/null +++ b/crates/yaak-wasm/pkg/package.json @@ -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/*" + ] +} \ No newline at end of file diff --git a/crates/yaak-web/pkg/yaak_web.d.ts b/crates/yaak-wasm/pkg/yaak_wasm.d.ts similarity index 98% rename from crates/yaak-web/pkg/yaak_web.d.ts rename to crates/yaak-wasm/pkg/yaak_wasm.d.ts index 42d31de3..19348e8f 100644 --- a/crates/yaak-web/pkg/yaak_web.d.ts +++ b/crates/yaak-wasm/pkg/yaak_wasm.d.ts @@ -29,7 +29,7 @@ export function boot(): Promise; * 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. diff --git a/crates/yaak-wasm/pkg/yaak_wasm.js b/crates/yaak-wasm/pkg/yaak_wasm.js new file mode 100644 index 00000000..76f0b2b1 --- /dev/null +++ b/crates/yaak-wasm/pkg/yaak_wasm.js @@ -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"; diff --git a/crates/yaak-web/pkg/yaak_web_bg.js b/crates/yaak-wasm/pkg/yaak_wasm_bg.js similarity index 99% rename from crates/yaak-web/pkg/yaak_web_bg.js rename to crates/yaak-wasm/pkg/yaak_wasm_bg.js index 2fcf3e7e..8450af8d 100644 --- a/crates/yaak-web/pkg/yaak_web_bg.js +++ b/crates/yaak-wasm/pkg/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]); } diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm similarity index 85% rename from crates/yaak-web/pkg/yaak_web_bg.wasm rename to crates/yaak-wasm/pkg/yaak_wasm_bg.wasm index c1266727..4828e9e8 100644 Binary files a/crates/yaak-web/pkg/yaak_web_bg.wasm and b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm differ diff --git a/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts similarity index 94% rename from crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts rename to crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts index 2db5c255..6f466387 100644 --- a/crates/yaak-web/pkg/yaak_web_bg.wasm.d.ts +++ b/crates/yaak-wasm/pkg/yaak_wasm_bg.wasm.d.ts @@ -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; diff --git a/crates/yaak-web/src/lib.rs b/crates/yaak-wasm/src/lib.rs similarity index 99% rename from crates/yaak-web/src/lib.rs rename to crates/yaak-wasm/src/lib.rs index d4afd43e..45704162 100644 --- a/crates/yaak-web/src/lib.rs +++ b/crates/yaak-wasm/src/lib.rs @@ -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. diff --git a/crates/yaak-web/pkg/package.json b/crates/yaak-web/pkg/package.json deleted file mode 100644 index cace5c1b..00000000 --- a/crates/yaak-web/pkg/package.json +++ /dev/null @@ -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/*" - ] -} \ No newline at end of file diff --git a/crates/yaak-web/pkg/yaak_web.js b/crates/yaak-web/pkg/yaak_web.js deleted file mode 100644 index d6309774..00000000 --- a/crates/yaak-web/pkg/yaak_web.js +++ /dev/null @@ -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(); diff --git a/greptile.json b/greptile.json index 60f15046..f99d7089 100644 --- a/greptile.json +++ b/greptile.json @@ -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" } diff --git a/package-lock.json b/package-lock.json index 44eb040d..f288fd6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } }, diff --git a/package.json b/package.json index 350d77fe..a00186a4 100644 --- a/package.json +++ b/package.json @@ -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" ], diff --git a/packages/platform/package.json b/packages/platform/package.json index 751cb0a5..2beea84e 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -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", diff --git a/packages/platform/src/web/README.md b/packages/platform/src/web/README.md index fdebbad8..6f6cf3d5 100644 --- a/packages/platform/src/web/README.md +++ b/packages/platform/src/web/README.md @@ -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. diff --git a/packages/platform/src/web/commands.ts b/packages/platform/src/web/commands.ts index a2f1dd71..88137d5c 100644 --- a/packages/platform/src/web/commands.ts +++ b/packages/platform/src/web/commands.ts @@ -71,7 +71,7 @@ const HANDLERS: Partial> = { /* ------------------------------- 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> = { - // 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((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(payload: unknown): Promise { return this.request((id) => ({ type: "prepare_http_send", id, payload })); } diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index 7e97f8a4..4835afaa 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -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 diff --git a/packages/platform/src/web/proxy.ts b/packages/platform/src/web/proxy.ts deleted file mode 100644 index e886c9f4..00000000 --- a/packages/platform/src/web/proxy.ts +++ /dev/null @@ -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 }).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 | 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 { - 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): AsyncGenerator { - 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(); - } -} diff --git a/packages/platform/src/web/send.ts b/packages/platform/src/web/send.ts index 7b970280..061abc27 100644 --- a/packages/platform/src/web/send.ts +++ b/packages/platform/src/web/send.ts @@ -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 & type ResponsePatch = Partial; -/** 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 { // 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, diff --git a/packages/platform/src/web/server.ts b/packages/platform/src/web/server.ts new file mode 100644 index 00000000..1ce57624 --- /dev/null +++ b/packages/platform/src/web/server.ts @@ -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 }).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 | 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 { + 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): AsyncGenerator { + 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(); + } +} diff --git a/packages/platform/src/web/worker.ts b/packages/platform/src/web/worker.ts index bd6d75e7..8998b541 100644 --- a/packages/platform/src/web/worker.ts +++ b/packages/platform/src/web/worker.ts @@ -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(); @@ -96,7 +96,7 @@ function bootOnce(): Promise { booted = (async () => { await acquireDatabaseLock(); - const loaded = await import("@yaakapp-internal/web"); + const loaded = await import("@yaakapp-internal/wasm"); await loaded.boot(); engine = loaded; })(); diff --git a/vite.config.ts b/vite.config.ts index fd992ad0..5042e3e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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, },