From 16abb409456359f40eecd597d7c77239f2dc95de Mon Sep 17 00:00:00 2001 From: Kenton Varda Date: Fri, 13 Feb 2026 11:33:47 -0600 Subject: [PATCH] Implement support for serializing Headers, Request, and Response. There are some ugly hacks to work around Firefox not supporting `request.body`. Ugh. (I started out asking Claude to do this but it actually failed in a bunch of ways and I ended up rewriting most of it.) --- README.md | 2 +- __tests__/index.test.ts | 176 +++++++++++++++++++++++++++++- protocol.md | 16 +++ src/core.ts | 71 ++++++++++++- src/serialize.ts | 229 +++++++++++++++++++++++++++++++++++++++- 5 files changed, 490 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 91d6d67..3f436fc 100644 --- a/README.md +++ b/README.md @@ -202,12 +202,12 @@ The following types can be passed over RPC (in arguments or return values), and * `Uint8Array` * `Error` and its well-known subclasses * `ReadableStream` and `WritableStream`, with automatic flow control. +* `Headers`, `Request`, and `Response` from the Fetch API. The following types are not supported as of this writing, but may be added in the future: * `Map` and `Set` * `ArrayBuffer` and typed arrays other than `Uint8Array` * `RegExp` -* `Headers`, `Request`, and `Response` The following are intentionally NOT supported: * Application-defined classes that do not extend `RpcTarget`. diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index 6ca37b8..35c68c2 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -33,6 +33,26 @@ let SERIALIZE_TEST_CASES: Record = { '["inf"]': Infinity, '["-inf"]': -Infinity, '["nan"]': NaN, + + '["headers",[]]': new Headers(), + '["headers",[["content-type","text/plain"],["x-custom","hello"]]]': + new Headers({"Content-Type": "text/plain", "X-Custom": "hello"}), + + '["request","http://example.com/",{"method":"HEAD"}]': + new Request("http://example.com/", {method: "HEAD"}), + '["request","http://example.com/",{"method":"DELETE","headers":[["x-foo","bar"]]}]': + new Request("http://example.com/", {method: "DELETE", headers: {"X-Foo": "bar"}}), + '["request","http://example.com/",{"redirect":"manual"}]': + new Request("http://example.com/", {redirect: "manual"}), + + // Note: Cloudflare Workers atutomatically fills in `statusText` based on `status` while other + // platforms leave it as an empty string. So we can't actually test a totalyl empty init + // struct here, annoyingly. + '["response",null,{"statusText":"OK"}]': new Response(null, {statusText: "OK"}), + '["response",null,{"status":404,"statusText":"Not Found"}]': + new Response(null, {status: 404, statusText: "Not Found"}), + '["response",null,{"status":201,"statusText":"Hello","headers":[["x-custom","value"]]}]': + new Response(null, {status: 201, statusText: "Hello", headers: {"X-Custom": "value"}}), }; class NotSerializable { @@ -54,7 +74,14 @@ describe("simple serialization", () => { it("can deserialize", () => { for (let key in SERIALIZE_TEST_CASES) { - expect(deserialize(key)).toStrictEqual(SERIALIZE_TEST_CASES[key]); + let value = deserialize(key); + if (value instanceof Headers || value instanceof Request || value instanceof Response) { + // toStrictEqual() won't work, so test these by serializing them again and making sure + // they at least round-trip. + expect(serialize(value)).toBe(key); + } else { + expect(value).toStrictEqual(SERIALIZE_TEST_CASES[key]); + } } }) @@ -2228,3 +2255,150 @@ describe("ReadableStream over RPC", () => { expect(cancelCalled).toBe(true); }); }); + +// ======================================================================================= + +describe("Fetch API types over RPC", () => { + it("can send Headers over RPC", async () => { + class HeaderServer extends RpcTarget { + getHeaders() { + return new Headers({"Content-Type": "text/html", "X-Server": "test"}); + } + readHeader(headers: Headers, name: string) { + return headers.get(name); + } + } + + await using harness = new TestHarness(new HeaderServer()); + let stub = harness.stub as any; + + // Server -> Client + let headers: Headers = await stub.getHeaders(); + expect(headers).toBeInstanceOf(Headers); + expect(headers.get("content-type")).toBe("text/html"); + expect(headers.get("x-server")).toBe("test"); + + // Client -> Server + let result = await stub.readHeader(new Headers({"Authorization": "Bearer abc"}), "authorization"); + expect(result).toBe("Bearer abc"); + }); + + it("can send Request with body over RPC", async () => { + class RequestServer extends RpcTarget { + async receiveRequest(req: Request) { + return { + url: req.url, + method: req.method, + body: await req.text(), + customHeader: req.headers.get("x-custom"), + }; + } + getRequest() { + return new Request("http://example.com/api", { + method: "POST", + headers: {"X-Custom": "fromserver"}, + body: "server body", + }); + } + } + + await using harness = new TestHarness(new RequestServer()); + let stub = harness.stub as any; + + // Client -> Server: send request with body and headers + let result = await stub.receiveRequest(new Request("http://test.com/path", { + method: "PUT", + headers: {"X-Custom": "hello"}, + body: "request body", + })); + expect(result.url).toBe("http://test.com/path"); + expect(result.method).toBe("PUT"); + expect(result.body).toBe("request body"); + expect(result.customHeader).toBe("hello"); + + // Server -> Client: receive request with body + let req: Request = await stub.getRequest(); + expect(req).toBeInstanceOf(Request); + expect(req.url).toBe("http://example.com/api"); + expect(req.method).toBe("POST"); + expect(req.headers.get("x-custom")).toBe("fromserver"); + expect(await req.text()).toBe("server body"); + }); + + it("can send Response with body over RPC", async () => { + class ResponseServer extends RpcTarget { + async receiveResponse(resp: Response) { + return { + status: resp.status, + statusText: resp.statusText, + body: await resp.text(), + customHeader: resp.headers.get("x-custom"), + }; + } + getResponse() { + return new Response("hello from server", { + status: 201, + statusText: "Created", + headers: {"X-Custom": "fromserver"}, + }); + } + } + + await using harness = new TestHarness(new ResponseServer()); + let stub = harness.stub as any; + + // Client -> Server: send response with body and status + let result = await stub.receiveResponse(new Response("response body", { + status: 404, + statusText: "Not Found", + headers: {"X-Custom": "value"}, + })); + expect(result.status).toBe(404); + expect(result.statusText).toBe("Not Found"); + expect(result.body).toBe("response body"); + expect(result.customHeader).toBe("value"); + + // Server -> Client: receive response with body + let resp: Response = await stub.getResponse(); + expect(resp).toBeInstanceOf(Response); + expect(resp.status).toBe(201); + expect(resp.statusText).toBe("Created"); + expect(resp.headers.get("x-custom")).toBe("fromserver"); + expect(await resp.text()).toBe("hello from server"); + }); + + it("can send Request without body over RPC", async () => { + class RequestServer extends RpcTarget { + async receiveRequest(req: Request) { + let hasBody = req.body !== null; + if (req.body === undefined) { + // Ugh, Firefox doesn't support `request.body`, try a different approach. + hasBody = (await req.arrayBuffer()).byteLength > 0; + } + + return { url: req.url, method: req.method, hasBody }; + } + } + + await using harness = new TestHarness(new RequestServer()); + let stub = harness.stub as any; + let result = await stub.receiveRequest(new Request("http://example.com")); + expect(result.url).toBe("http://example.com/"); + expect(result.method).toBe("GET"); + expect(result.hasBody).toBe(false); + }); + + it("can send Response without body over RPC", async () => { + class ResponseServer extends RpcTarget { + receiveResponse(resp: Response) { + return { status: resp.status, hasBody: resp.body !== null }; + } + } + + await using harness = new TestHarness(new ResponseServer()); + let stub = harness.stub as any; + let result = await stub.receiveResponse(new Response(null, {status: 204})); + expect(result.status).toBe(204); + expect(result.hasBody).toBe(false); + }); +}); diff --git a/protocol.md b/protocol.md index a121941..e1508fd 100644 --- a/protocol.md +++ b/protocol.md @@ -176,6 +176,22 @@ A JavaScript `Error` value. `type` is the name of the specific well-known `Error _TODO: We should extend this to encode own properties that have been added to the error._ +`["headers", pairs]` + +A `Headers` object from the Fetch API. `pairs` is an array of `[name, value]` pairs, where both `name` and `value` are strings. For example: `["headers", [["content-type", "text/plain"], ["x-custom", "hello"]]]`. + +`["request", url, init]` + +A `Request` object from the Fetch API. `url` and `init` are the parameters to pass to `Request`'s constructor to create the desired `Request` instance. The sender should omit properties from `init` when their value would be the default value anyway. `init.headers`, if present, must contain an array of pairs, suitable to pass to the constructor of `Headers`. `init.body`, if present, is an expression for the response body, which must evaluate to `null`, a string, `Uint8Array`, or `ReadableStream`. Other properties of `init` must be plain values; they will not be evaluated as expressions before passing to the `Request` constructor. + +At this time, `init.signal` is not supported and must not be sent, though that will change when `AbortSignal` gains support for serialization. + +`["response", body, init]` + +A `Response` object from the Fetch API. `body` and `init` are the parameters to pass to `Response`'s constructor to create the desired `Response` instance. `body` is an expression which must evaluate to `null`, a string, `UInt8Array`, or `ReadableStream`. `init.headers`, if present, must contain an array of pairs, suitable to pass to the constructor of `Headers`. Other properties of `init` must be plain values; they will not be evaluated as expressions before passing to the `Response` constructor. + +At this time, `init.webSocket` (a Cloudflare Workers extension) is not supported and must not be sent, though that may change if `WebSocket` gains support for serialization. + `["import", importId, propertyPath, callArguments]` `["pipeline", importId, propertyPath, callArguments]` diff --git a/src/core.ts b/src/core.ts index 669ab90..e3cd1a9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -39,7 +39,7 @@ export type PropertyPath = (string | number)[]; type TypeForRpc = "unsupported" | "primitive" | "object" | "function" | "array" | "date" | "bigint" | "bytes" | "stub" | "rpc-promise" | "rpc-target" | "rpc-thenable" | "error" | - "undefined" | "writable" | "readable"; + "undefined" | "writable" | "readable" | "headers" | "request" | "response"; const AsyncFunction = (async function () {}).constructor; @@ -97,6 +97,15 @@ export function typeForRpc(value: unknown): TypeForRpc { case ReadableStream.prototype: return "readable"; + case Headers.prototype: + return "headers"; + + case Request.prototype: + return "request"; + + case Response.prototype: + return "response"; + // TODO: All other structured clone types. case RpcStub.prototype: @@ -1017,6 +1026,9 @@ export class RpcPayload { } case "readable": { + // Note that we don't use tee() here because we treat streams as reference types -- we + // actually want to share the same body. tee()ing the stream would force the runtime to + // buffer a copy of the whole body which would usually never be read. let stream = value; let hook: StubHook; if (owner) { @@ -1028,6 +1040,37 @@ export class RpcPayload { return stream; } + case "headers": + return new Headers(value); + + case "request": { + let req = value; + if (req.body) { + // Note "deep-copy" of a ReadableStream always returns the same stream, but we still + // need to run it in order to handle refcounting / disposal properly. + this.deepCopy(req.body, req, "body", req, dupStubs, owner); + } + + // Make an actual copy of the object, e.g. so the headers are copied. + // Note that it would be incorrect to use clone() here since that would tee() the body + // stream. + return new Request(req); + } + + case "response": { + let resp = value; + if (resp.body) { + // Note "deep-copy" of a ReadableStream always returns the same stream, but we still + // need to run it in order to handle refcounting / disposal properly. + this.deepCopy(resp.body, resp, "body", resp, dupStubs, owner); + } + + // Make an actual copy of the object, e.g. so the headers are copied. + // Note that it would be incorrect to use clone() here since that would tee() the body + // stream. + return new Response(resp.body, resp); + } + default: kind satisfies never; throw new Error("unreachable"); @@ -1278,6 +1321,26 @@ export class RpcPayload { // Since thenables are promises, we don't own them, so we don't dispose them. return; + case "headers": + // Headers have no owned resources to dispose. + return; + + case "request": { + // The body may be a ReadableStream that has an associated hook in rpcTargets. + let req = value; + if (req.body) this.disposeImpl(req.body, req); + // TODO: When we support AbortSignal, we may need to dispose request.signal here? + return; + } + + case "response": { + // The body may be a ReadableStream that has an associated hook in rpcTargets. + let resp = value; + if (resp.body) this.disposeImpl(resp.body, resp); + // TODO: When we support WebSocket, we may need to dispose response.webSocket here? + return; + } + case "writable": { let stream = value; let hook = this.rpcTargets?.get(stream); @@ -1347,6 +1410,9 @@ export class RpcPayload { case "rpc-target": case "writable": case "readable": + case "headers": + case "request": + case "response": return; case "array": { @@ -1496,6 +1562,9 @@ function followPath(value: unknown, parent: object | undefined, case "bytes": case "date": case "error": + case "headers": + case "request": + case "response": // These have no properties that can be accessed remotely. value = undefined; break; diff --git a/src/serialize.ts b/src/serialize.ts index 9cefc4d..6762a1e 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -2,7 +2,7 @@ // Licensed under the MIT license found in the LICENSE.txt file or at: // https://opensource.org/license/mit -import { StubHook, RpcPayload, typeForRpc, RpcStub, RpcPromise, LocatedPromise, RpcTarget, unwrapStubAndPath, streamImpl } from "./core.js"; +import { StubHook, RpcPayload, typeForRpc, RpcStub, RpcPromise, LocatedPromise, RpcTarget, unwrapStubAndPath, streamImpl, PromiseStubHook, PayloadStubHook } from "./core.js"; export type ImportId = number; export type ExportId = number; @@ -171,6 +171,132 @@ export class Devaluator { } } + case "headers": + // The `Headers` TS type apparently doesn't declare itself as being + // Iterable<[string, string]>, but it is. + return ["headers", [...>value]]; + + case "request": { + let req = value; + let init: Record = {}; + + // For many properties below, the official Fetch spec says they must always be present, + // but some platforms don't support them. So, we check both whether the property exists, + // and whether it is equal to the default, before bothering to add it to `init`. + + if (req.method !== "GET") init.method = req.method; + + let headers = [...>req.headers]; + if (headers.length > 0) { + // Note that we don't need to serialize this as ["headers", headers] because we are only + // trying to create a valid RequestInit object. + init.headers = headers; + } + + if (req.body) { + init.body = this.devaluateImpl(req.body, req, depth + 1); + + // Apparently the fetch spec technically requires that `duplex` be specified when a + // body is specified, and Chrome in fact requires this, and requires the value is "half". + // Workers hasn't implemented this (and actually supports full duplex by default, lol). + // The TS types for Request currently don't define this property, but it is there (on + // Chrome at least). + init.duplex = (req).duplex || "half"; + } else if (req.body === undefined && + !["GET", "HEAD", "OPTIONS", "TRACE", "DELETE"].includes(req.method)) { + // If the body is undefined rather than null, most likely we're on a platform that + // doesn't support request body streams (*cough*Firefox*cough*). We'll need to hack + // around this by using `req.arrayBuffer()` to get the body. Unfortunately this is async, + // so we can't just embed the resulting body into the message we are constructing. We + // will actually have to construct a ReadableStream. Ugh! + + let bodyPromise = req.arrayBuffer(); + + let readable = new ReadableStream({ + async start(controller) { + try { + // `as Uint8Array` is needed here to work around some sort of weird bug in the TS + // types where `new Uint8Array` somehow doesn't return a `Uint8Array`. Instead it + // somehow returns `Uint8Array` -- but `Uint8Array` is not a generic + // type! WTF? + controller.enqueue(new Uint8Array(await bodyPromise) as Uint8Array); + controller.close(); + } catch (err) { + controller.error(err); + } + } + }); + + // We can't recurse to devaluateImpl() to serialize the body because it'll call + // source.getHookForReadableStream(), adding a hook on the payload which isn't actually + // reachable by walking the payload, which will cause trouble later. So we have to + // inline it a bit here... + let hook = streamImpl.createReadableStreamHook(readable); + let importId = this.exporter.createPipe(readable, hook); + init.body = ["readable", importId]; + init.duplex = (req).duplex || "half"; + } + + if (req.cache && req.cache !== "default") init.cache = req.cache; + if (req.redirect !== "follow") init.redirect = req.redirect; + if (req.integrity) init.integrity = req.integrity; + + // These properties are only meaningful in browsers and not supported by most WinterCG + // (server-side) platforms. + if (req.mode && req.mode !== "cors") init.mode = req.mode; + if (req.credentials && req.credentials !== "same-origin") { + init.credentials = req.credentials; + } + if (req.referrer && req.referrer !== "about:client") init.referrer = req.referrer; + if (req.referrerPolicy) init.referrerPolicy = req.referrerPolicy; + if (req.keepalive) init.keepalive = req.keepalive; + + // These properties are specific to Cloudflare Workers. Cast the request to `any` to + // silence type errors on other platforms. + let cfReq = req as any; + if (cfReq.cf) init.cf = cfReq.cf; + if (cfReq.encodeResponseBody && cfReq.encodeResponseBody !== "automatic") { + init.encodeResponseBody = cfReq.encodeResponseBody; + } + + // TODO: Support request.signal. Annoyingly, all `Request`s have a `signal` property even + // if none was passed to the constructor, and there's no way to tell if it's a real + // signal. So for now, since we don't support AbortSignal yet, all we can do is ignore + // it; we can't throw an error if it's present. + + return ["request", req.url, init]; + } + + case "response": { + let resp = value; + let body = this.devaluateImpl(resp.body, resp, depth + 1); + let init: Record = {}; + + if (resp.status !== 200) init.status = resp.status; + if (resp.statusText) init.statusText = resp.statusText; + + let headers = [...>resp.headers]; + if (headers.length > 0) { + // Note that we don't need to serialize this as ["headers", headers] because we are only + // trying to create a valid ResponseInit object. + init.headers = headers; + } + + // These properties are specific to Cloudflare Workers. Cast the request to `any` to + // silence type errors on other platforms. + let cfResp = resp as any; + if (cfResp.cf) init.cf = cfResp.cf; + if (cfResp.encodeBody && cfResp.encodeBody !== "automatic") { + init.encodeBody = cfResp.encodeBody; + } + if (cfResp.webSocket) { + // As of this writing, we don't support WebSocket, but we might someday. + throw new TypeError("Can't serialize a Response containing a webSocket."); + } + + return ["response", body, init]; + } + case "error": { let e = value; @@ -319,6 +445,21 @@ class NullImporter implements Importer { const NULL_IMPORTER = new NullImporter(); +// Some runtimes (Firefox) don't support `request.body` as a stream, but we receive request bodies +// as streams. We'll need to read the body into an ArrayBuffer and recreate the request. This is +// asynchronous, so we'll have to swap in a promise here. This potentially breaks e-order but +// that's something people will just have to live with when sending a Request to a Firefox +// endpoint (probably rare). +function fixBrokenRequestBody(request: Request, body: ReadableStream): RpcPromise { + // Reuse built-in code to read the stream into an array. + let promise = new Response(body).arrayBuffer().then(arrayBuffer => { + let bytes = new Uint8Array(arrayBuffer); + let result = new Request(request, {body: bytes}); + return new PayloadStubHook(RpcPayload.fromAppReturn(result)); + }); + return new RpcPromise(new PromiseStubHook(promise), []); +} + // Takes object trees parse from JSON and converts them into fully-hydrated JavaScript objects for // delivery to the app. This is used to implement deserialization, except that it doesn't actually // start from a raw string. @@ -403,6 +544,92 @@ export class Evaluator { case "nan": return NaN; + case "headers": + // We only need to validate that the parameter is an array, so as not to invoke an + // unexpected variant of the Headers constructor. So long as it is an array then we can + // rely on the constructor to perform type checking. + if (value.length === 2 && value[1] instanceof Array) { + return new Headers(value[1] as [string, string][]); + } + break; + + case "request": { + if (value.length !== 3 || typeof value[1] !== "string") break; + let url = value[1] as string; + let init = value[2]; + if (typeof init !== "object" || init === null) break; + + // Evaluate specific properties which are expected to contain non-trivial types. + if (init.body) { + init.body = this.evaluateImpl(init.body, init, "body"); + if (init.body === null || + typeof init.body === "string" || + init.body instanceof Uint8Array || + init.body instanceof ReadableStream) { + // Acceptable types. + } else { + throw new TypeError("Request body must be of type ReadableStream."); + } + } + if (init.signal) { + init.signal = this.evaluateImpl(init.signal, init, "signal"); + if (!(init.signal instanceof AbortSignal)) { + throw new TypeError("Request siganl must be of type AbortSignal."); + } + } + + // Type-check `headers` is an array because the constructor allows multiple + // representations and we don't want to allow the others. + if (init.headers && !(init.headers instanceof Array)) { + throw new TypeError("Request headers must be serialized as an array of pairs."); + } + + // We assume the `Request` constructor can type-check the remaining properties. + let result = new Request(url, init as RequestInit); + + if (init.body instanceof ReadableStream && result.body === undefined) { + // Oh no! We must be on Firefox where request bodies are not supported, but we had a + // body. + let promise = fixBrokenRequestBody(result, init.body); + this.promises.push({promise, parent, property}); + return promise; + } else { + return result; + } + } + + case "response": { + if (value.length !== 3) break; + + let body = this.evaluateImpl(value[1], parent, property); + if (body === null || + typeof body === "string" || + body instanceof Uint8Array || + body instanceof ReadableStream) { + // Acceptable types. + } else { + throw new TypeError("Response body must be of type ReadableStream."); + } + + let init = value[2]; + if (typeof init !== "object" || init === null) break; + + // Evaluate specific properties which are expected to contain non-trivial types. + if (init.webSocket) { + // `response.webSocket` is a Cloudflare Workers extension. Not (yet?) supported for + // serialization. + throw new TypeError("Can't deserialize a Response containing a webSocket."); + } + + // Type-check `headers` is an array because the constructor allows multiple + // representations and we don't want to allow the others. + if (init.headers && !(init.headers instanceof Array)) { + throw new TypeError("Request headers must be serialized as an array of pairs."); + } + + return new Response(body as BodyInit | null, init as ResponseInit); + } + case "import": case "pipeline": { // It's an "import" from the perspective of the sender, so it's an export from our