diff --git a/Cargo.lock b/Cargo.lock index bb94f504..c36ef8f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11689,6 +11689,7 @@ dependencies = [ "serde_json", "tokio", "tower-http", + "ts-rs", "url", "uuid", "yaak-http", diff --git a/crates-server/yaak-send-proxy/Cargo.toml b/crates-server/yaak-send-proxy/Cargo.toml index 2d8f5c57..6d23dc90 100644 --- a/crates-server/yaak-send-proxy/Cargo.toml +++ b/crates-server/yaak-send-proxy/Cargo.toml @@ -29,6 +29,7 @@ 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", "trace"] } +ts-rs = { workspace = true } url = "2" uuid = { version = "1", features = ["v4"] } yaak-http = { workspace = true } diff --git a/crates-server/yaak-send-proxy/README.md b/crates-server/yaak-send-proxy/README.md index 62c3d454..f54fd1b9 100644 --- a/crates-server/yaak-send-proxy/README.md +++ b/crates-server/yaak-send-proxy/README.md @@ -121,6 +121,11 @@ 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 diff --git a/crates-server/yaak-send-proxy/bindings/gen_models.ts b/crates-server/yaak-send-proxy/bindings/gen_models.ts new file mode 100644 index 00000000..7c3f7a84 --- /dev/null +++ b/crates-server/yaak-send-proxy/bindings/gen_models.ts @@ -0,0 +1,48 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, }; + +export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty"; + +export type CookieExpires = { "AtUtc": string } | "SessionEnd"; + +export type CookieSameSite = "Strict" | "Lax" | "None"; + +export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record, authenticationType: string | null, body: Record, bodyType: string | null, description: string, headers: Array, method: string, name: string, sortPriority: number, url: string, +/** + * URL parameters used for both path placeholders (`:id`) and query string entries. + */ +urlParameters: Array, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, }; + +export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, }; + +/** + * Serializable representation of HTTP response events for DB storage. + * This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support. + * The `From` impl is in yaak-http to avoid circular dependencies. + */ +export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array, duration: bigint, overridden: boolean, }; + +export type HttpResponseHeader = { name: string, value: string, }; + +/** + * The resolved send settings, values only: what an executor has to obey, with the sources + * (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what + * crosses from a tab to the send proxy, and what the proxy reads. + */ +export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean, +/** + * Milliseconds. Zero or negative means no timeout. + */ +timeoutMs: number, sendCookies: boolean, storeCookies: boolean, }; + +export type HttpUrlParameter = { enabled?: boolean, +/** + * Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id` + * Other entries are appended as query parameters + */ +name: string, value: string, id?: string, }; + +export type InheritedBoolSetting = { enabled?: boolean, value: boolean, }; + +export type InheritedIntSetting = { enabled?: boolean, value: number, }; diff --git a/crates-server/yaak-send-proxy/bindings/gen_send_proxy.ts b/crates-server/yaak-send-proxy/bindings/gen_send_proxy.ts new file mode 100644 index 00000000..3132405f --- /dev/null +++ b/crates-server/yaak-send-proxy/bindings/gen_send_proxy.ts @@ -0,0 +1,64 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models"; + +/** + * One line of the reply stream. Tags are snake_case like the timeline event tags; fields are + * camelCase like every model the tab stores. + */ +export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null, +/** + * The URL that answered, after redirects. + */ +url: string, remoteAddr: string | null, version: string | null, headers: Array, +/** + * The headers that were actually sent on the final hop, cookies and all. + */ +requestHeaders: Array, +/** + * `Content-Length` as declared by the server, if it declared one. + */ +contentLength: number | null, +/** + * Milliseconds from the start of the send to the response head. + */ +elapsedHeaders: number, +/** + * Milliseconds spent in DNS on the last lookup, or zero. + */ +elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done", +/** + * Milliseconds from the start of the send to the end of the body. + */ +elapsed: number, +/** + * Bytes of body relayed, after decompression. + */ +contentLength: number, +/** + * Bytes on the wire as declared by the server, or the relayed size when unknown. + */ +contentLengthCompressed: number, +/** + * The jar as the send left it, for the tab to persist. `None` when the tab sent none. + */ +cookies: Array | null, } | { "type": "error", message: string, cookies: Array | null, }; + +/** + * The body of `POST /v1/http/send`. + */ +export type SendRequest = { +/** + * The request to send, in the desktop's own model shape but with every template already + * rendered by the tab. The proxy builds the URL, headers and body from it exactly the way + * the desktop does after rendering. + */ +request: HttpRequest, +/** + * The resolved settings, values only. Where they came from is the tab's to record in + * its timeline; the proxy only needs to obey them. + */ +settings: HttpSendSettings, +/** + * The cookies to start with. `None` means no jar at all: nothing sent, nothing kept. + */ +cookies: Array | null, }; diff --git a/crates-server/yaak-send-proxy/index.ts b/crates-server/yaak-send-proxy/index.ts new file mode 100644 index 00000000..e9fdd9db --- /dev/null +++ b/crates-server/yaak-send-proxy/index.ts @@ -0,0 +1,4 @@ +// 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/package.json b/crates-server/yaak-send-proxy/package.json new file mode 100644 index 00000000..cc290995 --- /dev/null +++ b/crates-server/yaak-send-proxy/package.json @@ -0,0 +1,6 @@ +{ + "name": "@yaakapp-internal/send-proxy", + "version": "1.0.0", + "private": true, + "main": "index.ts" +} diff --git a/crates-server/yaak-send-proxy/src/wire.rs b/crates-server/yaak-send-proxy/src/wire.rs index 85be562c..30eeb584 100644 --- a/crates-server/yaak-send-proxy/src/wire.rs +++ b/crates-server/yaak-send-proxy/src/wire.rs @@ -9,15 +9,21 @@ //! Nothing here names a workspace, a request id, or a response id. The proxy //! does not know what the tab will call this response; it only knows what came //! back. +//! +//! The TypeScript side of this contract is generated from these types into +//! `bindings/` (`cargo test -p yaak-send-proxy`) and published to the tab as +//! `@yaakapp-internal/send-proxy`, so a change here is a type error there. use serde::{Deserialize, Serialize}; +use ts_rs::TS; use yaak_models::models::{ Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings, }; /// The body of `POST /v1/http/send`. -#[derive(Deserialize, Debug)] +#[derive(Deserialize, Debug, TS)] #[serde(rename_all = "camelCase")] +#[ts(export, export_to = "gen_send_proxy.ts")] pub struct SendRequest { /// The request to send, in the desktop's own model shape but with every template already /// rendered by the tab. The proxy builds the URL, headers and body from it exactly the way @@ -33,12 +39,13 @@ pub struct SendRequest { /// One line of the reply stream. Tags are snake_case like the timeline event tags; fields are /// camelCase like every model the tab stores. -#[derive(Serialize, Debug)] +#[derive(Serialize, Debug, TS)] #[serde( tag = "type", rename_all = "snake_case", rename_all_fields = "camelCase" )] +#[ts(export, export_to = "gen_send_proxy.ts")] pub enum Frame { /// A timeline event, in the same shape the desktop stores. Interleaved with everything /// else in the order the engine produced it. @@ -56,10 +63,13 @@ pub enum Frame { /// The headers that were actually sent on the final hop, cookies and all. request_headers: Vec, /// `Content-Length` as declared by the server, if it declared one. + #[ts(type = "number | null")] content_length: Option, /// Milliseconds from the start of the send to the response head. + #[ts(type = "number")] elapsed_headers: u64, /// Milliseconds spent in DNS on the last lookup, or zero. + #[ts(type = "number")] elapsed_dns: u64, }, /// A piece of the response body, decompressed, base64-encoded. @@ -67,10 +77,13 @@ pub enum Frame { /// The send finished. The last frame on a successful stream. Done { /// Milliseconds from the start of the send to the end of the body. + #[ts(type = "number")] elapsed: u64, /// Bytes of body relayed, after decompression. + #[ts(type = "number")] content_length: u64, /// Bytes on the wire as declared by the server, or the relayed size when unknown. + #[ts(type = "number")] content_length_compressed: u64, /// The jar as the send left it, for the tab to persist. `None` when the tab sent none. cookies: Option>, diff --git a/package-lock.json b/package-lock.json index 13e4bc40..44eb040d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "crates/yaak-web", "crates/yaak-ws", "crates-proxy/yaak-proxy-lib", + "crates-server/yaak-send-proxy", "apps/yaak-client", "apps/yaak-proxy" ], @@ -272,6 +273,10 @@ "name": "@yaakapp-internal/proxy-lib", "version": "1.0.0" }, + "crates-server/yaak-send-proxy": { + "name": "@yaakapp-internal/send-proxy", + "version": "1.0.0" + }, "crates-tauri/yaak-app-client": { "name": "@yaakapp-internal/tauri-client", "version": "1.0.0" @@ -5645,6 +5650,10 @@ "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 @@ -15801,6 +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/web": "^1.0.0" } }, diff --git a/package.json b/package.json index 4035c0fe..350d77fe 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "crates/yaak-web", "crates/yaak-ws", "crates-proxy/yaak-proxy-lib", + "crates-server/yaak-send-proxy", "apps/yaak-client", "apps/yaak-proxy" ], diff --git a/packages/platform/package.json b/packages/platform/package.json index d0b3174b..751cb0a5 100644 --- a/packages/platform/package.json +++ b/packages/platform/package.json @@ -11,6 +11,7 @@ "dependencies": { "@yaakapp-internal/models": "^1.0.0", "@yaakapp-internal/rpc-schema": "^1.0.0", + "@yaakapp-internal/send-proxy": "^1.0.0", "@yaakapp-internal/web": "^1.0.0", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", diff --git a/packages/platform/src/web/proxy.ts b/packages/platform/src/web/proxy.ts index d57217c4..a3ecae49 100644 --- a/packages/platform/src/web/proxy.ts +++ b/packages/platform/src/web/proxy.ts @@ -1,19 +1,11 @@ /** - * The wire to the send proxy: where it is, what goes up, and what comes back. + * The wire to the send proxy: where it is, and how to read what comes back. * - * These shapes mirror `crates-server/yaak-send-proxy/src/wire.rs` by hand. The - * proxy is a separate binary with its own release cadence, so the contract is - * written down on both sides rather than generated across them; a change to one - * is a change to the other, and the frame `type` tags are the versioning. + * 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 { - Cookie, - HttpRequest, - HttpResponseEventData, - HttpResponseHeader, - HttpSendSettings, -} from "@yaakapp-internal/models"; +import type { Frame } from "@yaakapp-internal/send-proxy"; /* ------------------------------- location -------------------------------- */ @@ -33,54 +25,12 @@ export function proxySendUrl(): string { return `${proxyBaseUrl()}/v1/http/send`; } -/* --------------------------------- up ------------------------------------ */ - -/** The body of `POST /v1/http/send`. */ -export interface ProxyRequestBody { - /** The rendered request, in the model shape (see `wire.rs` `SendRequest.request`). */ - request: HttpRequest; - settings: HttpSendSettings; - /** The jar's cookies to start from, or `null` for no jar at all. */ - cookies: Cookie[] | null; -} - -/* -------------------------------- down ----------------------------------- */ - -export interface ProxySendResponse { - type: "response"; - status: number; - statusReason: string | null; - url: string; - remoteAddr: string | null; - version: string | null; - headers: HttpResponseHeader[]; - requestHeaders: HttpResponseHeader[]; - contentLength: number | null; - elapsedHeaders: number; - elapsedDns: number; -} - -export type ProxyFrame = - /** A timeline event in the `http_response_event.event` shape. */ - | { type: "event"; event: HttpResponseEventData } - | ProxySendResponse - /** A body chunk, decompressed, base64. */ - | { type: "body"; data: string } - | { - type: "done"; - elapsed: number; - contentLength: number; - contentLengthCompressed: number; - cookies: Cookie[] | null; - } - | { type: "error"; message: string; cookies: Cookie[] | null }; - /** * 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 { +export async function* readFrames(stream: ReadableStream): AsyncGenerator { const reader = stream.getReader(); const decoder = new TextDecoder(); let buffer = ""; @@ -93,7 +43,7 @@ export async function* readFrames(stream: ReadableStream): AsyncGene while (newline !== -1) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); - if (line.trim() !== "") yield JSON.parse(line) as ProxyFrame; + if (line.trim() !== "") yield JSON.parse(line) as Frame; newline = buffer.indexOf("\n"); } } diff --git a/packages/platform/src/web/send.ts b/packages/platform/src/web/send.ts index 4f570271..57e28a99 100644 --- a/packages/platform/src/web/send.ts +++ b/packages/platform/src/web/send.ts @@ -31,8 +31,8 @@ import type { HttpResponseEventData, HttpSendSettings, } from "@yaakapp-internal/models"; +import type { Frame, SendRequest } from "@yaakapp-internal/send-proxy"; import type { WorkerConnection } from "./connection"; -import type { ProxyFrame, ProxyRequestBody, ProxySendResponse } from "./proxy"; import { proxySendUrl, readFrames } from "./proxy"; /* -------------------------------- shapes --------------------------------- */ @@ -106,7 +106,7 @@ async function runSend( const timeline = new TimelineWriter(db, response.id, response.workspaceId); timeline.push(prepared.settingEvents); - const body: ProxyRequestBody = { + const body: SendRequest = { request: prepared.request, settings: prepared.settings, cookies: prepared.cookieJar?.cookies ?? null, @@ -139,7 +139,7 @@ async function runSend( const chunks: Uint8Array[] = []; let received = 0; let lastProgress = startedAt; - let terminal: ProxyFrame | null = null; + let terminal: Frame | null = null; for await (const frame of readFrames(res.body)) { switch (frame.type) { @@ -201,7 +201,7 @@ async function runSend( }); } -function headOf(frame: ProxySendResponse): ResponsePatch { +function headOf(frame: Extract): ResponsePatch { return { state: "connected", status: frame.status,