Generate the proxy wire types with ts-rs (@yaakapp-internal/send-proxy)

This commit is contained in:
Gregory Schier
2026-08-17 08:43:59 -07:00
parent 8e466fc1a0
commit d4007d3499
13 changed files with 166 additions and 62 deletions
Generated
+1
View File
@@ -11689,6 +11689,7 @@ dependencies = [
"serde_json",
"tokio",
"tower-http",
"ts-rs",
"url",
"uuid",
"yaak-http",
+1
View File
@@ -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 }
+5
View File
@@ -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
+48
View File
@@ -0,0 +1,48 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieSameSite = "Strict" | "Lax" | "None";
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseHeader = { name: string, value: string, };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the send proxy, and what the proxy reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
export type InheritedIntSetting = { enabled?: boolean, value: number, };
+64
View File
@@ -0,0 +1,64 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Cookie, HttpRequest, HttpResponseEventData, HttpResponseHeader, HttpSendSettings } from "./gen_models";
/**
* One line of the reply stream. Tags are snake_case like the timeline event tags; fields are
* camelCase like every model the tab stores.
*/
export type Frame = { "type": "event", event: HttpResponseEventData, } | { "type": "response", status: number, statusReason: string | null,
/**
* The URL that answered, after redirects.
*/
url: string, remoteAddr: string | null, version: string | null, headers: Array<HttpResponseHeader>,
/**
* The headers that were actually sent on the final hop, cookies and all.
*/
requestHeaders: Array<HttpResponseHeader>,
/**
* `Content-Length` as declared by the server, if it declared one.
*/
contentLength: number | null,
/**
* Milliseconds from the start of the send to the response head.
*/
elapsedHeaders: number,
/**
* Milliseconds spent in DNS on the last lookup, or zero.
*/
elapsedDns: number, } | { "type": "body", data: string, } | { "type": "done",
/**
* Milliseconds from the start of the send to the end of the body.
*/
elapsed: number,
/**
* Bytes of body relayed, after decompression.
*/
contentLength: number,
/**
* Bytes on the wire as declared by the server, or the relayed size when unknown.
*/
contentLengthCompressed: number,
/**
* The jar as the send left it, for the tab to persist. `None` when the tab sent none.
*/
cookies: Array<Cookie> | null, } | { "type": "error", message: string, cookies: Array<Cookie> | null, };
/**
* The body of `POST /v1/http/send`.
*/
export type SendRequest = {
/**
* The request to send, in the desktop's own model shape but with every template already
* rendered by the tab. The proxy builds the URL, headers and body from it exactly the way
* the desktop does after rendering.
*/
request: HttpRequest,
/**
* The resolved settings, values only. Where they came from is the tab's to record in
* its timeline; the proxy only needs to obey them.
*/
settings: HttpSendSettings,
/**
* The cookies to start with. `None` means no jar at all: nothing sent, nothing kept.
*/
cookies: Array<Cookie> | null, };
+4
View File
@@ -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";
@@ -0,0 +1,6 @@
{
"name": "@yaakapp-internal/send-proxy",
"version": "1.0.0",
"private": true,
"main": "index.ts"
}
+15 -2
View File
@@ -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<HttpResponseHeader>,
/// `Content-Length` as declared by the server, if it declared one.
#[ts(type = "number | null")]
content_length: Option<u64>,
/// Milliseconds from the start of the send to the response head.
#[ts(type = "number")]
elapsed_headers: u64,
/// Milliseconds spent in DNS on the last lookup, or zero.
#[ts(type = "number")]
elapsed_dns: u64,
},
/// A piece of the response body, decompressed, base64-encoded.
@@ -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<Vec<Cookie>>,
+10
View File
@@ -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"
}
},
+1
View File
@@ -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"
],
+1
View File
@@ -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",
+6 -56
View File
@@ -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<Uint8Array>): AsyncGenerator<ProxyFrame> {
export async function* readFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<Frame> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
@@ -93,7 +43,7 @@ export async function* readFrames(stream: ReadableStream<Uint8Array>): 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");
}
}
+4 -4
View File
@@ -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<Frame, { type: "response" }>): ResponsePatch {
return {
state: "connected",
status: frame.status,