diff --git a/README.md b/README.md index a207561..9e112c4 100644 --- a/README.md +++ b/README.md @@ -1,91 +1,183 @@ # Cap'n Web: A JavaScript-native RPC system -This is a JavaScript/TypeScript RPC library that supports: - -* Usage from both browsers and servers. -* Object Capabilities (pass-by-reference objects and functions), to model complex stateful interactions. -* Promise Pipelining, so you can take the results of one call and send them in the arguments to the next call without actually waiting for the first call to return. -* Batch requests over HTTP *or* long-lived sessions over WebSocket. -* Absolutely minimal boilerplate. +Cap'n Web is a spiritual sibling to [Cap'n Proto](https://capnproto.org) (and is created by the same author), but designed to play nice in the web stack. That means: +* Like Cap'n Proto, it is an object-capability protocol. ("Cap'n" is short for "capabilities and".) We'll get into this more below, but it's incredibly powerful. +* Unlike Cap'n Proto, Cap'n Web has no schemas. In fact, it has almost no boilerplate whatsoever. This means it works more like the [JavaScript-native RPC system in Cloudflare Workers](https://blog.cloudflare.com/javascript-native-rpc/). +* That said, it integrates nicely with TypeScript. +* Also unlike Cap'n Proto, Cap'n Web's underlying serialization is human-readable. In fact, it's just JSON, with a little pre-/post-processing. +* It works over HTTP, WebSocket, and postMessage() out-of-the-box, with the ability to extend it to other transports easily. +* It works in all major browsers, Cloudflare Workers, Node.js, and other modern JavaScript runtimes. +The whole thing compresses (minify+gzip) to under 10kB with no dependencies. + +Cap'n Web is more expressive than almost every other RPC system, because it implements an object-capability RPC model. That means it: +* Supports bidirectional calling. The client can call the server, and the server can also call the client. +* Supports passing functions by reference: If you pass a function over RPC, the recipient receives a "stub". When they call the stub, they actually make an RPC back to you, invoking the function where it was created. This is how bidirectional calling happens: the client passes a callback to the server, and then the server can call it later. +* Similarly, supports passing objects by reference: If a class extends the special marker type `RpcTarget`, then instances of that class are passed by reference, with method calls calling back to the location where the object was created. +* Supports promise pipelining. When you start an RPC, you get back a promise. Instead of awaiting it, you can immediately use the promise in dependent RPCs, thus performing a chain of calls in a single network round trip. +* Supports capability-based security patterns. ## Example -_TODO: This is not a great example. Improve it!_ +A client looks like this: -Declare your RPC interface like this: +```js +import { newWebSocketRpcSession } from "capnweb"; -```ts -interface MyApi { - // Return who the user is logged in as. - whoami(): Promise<{name: string, id: string}>; +// One-line setup. +let api = newWebSocketRpcSession("wss://example.com/api"); - // Get the given user's public profile info. - getUserProfile(userId: string): Promise; -} +// Call a method on the server! +let result = await api.hello("World"); + +console.log(result); ``` -On the server, export it: +Here's the server: -```ts -import { receiveRpcOverHttp, RpcTarget } from "capnweb"; +```js +import { RpcTarget, newWorkersRpcResponse } from "capnweb"; -class MyApiImpl extends RpcTarget implements MyApi { - // ... implement api ... +// This is the server implementation. +class MyApiServer extends RpcTarget { + hello(name) { + return `Hello, ${name}!` + } } -// Cloudflare Workers fetch handler. (Node is also supported; see below.) +// Standard Cloudflare Workers HTTP handler. // -// Note this handles both batch and WebSocket-oriented RPCs. +// (Node and other runtimes are supported too; see below.) export default { - async fetch(req, env, ctx) { - let url = new URL(req.url); + fetch(request, env, ctx) { + // Parse URL for routing. + let url = new URL(request.url); - // Handle API endpoint. + // Serve API at `/api`. if (url.pathname === "/api") { - return receiveRpcOverHttp(request, new MyApiImpl(env, ctx)); + return newWorkersRpcResponse(request, new MyApiServer()); } - // ... handle other HTTP requests normally ... + // You could serve other endpoints here... + return new Response("Not found", {status: 404}); } } ``` -(If you are using Node, [see this test fixture](__tests__/test-server.ts) for exmaple code. Unfortunately, it's a bit more involved as Node handles normal HTTP and WebSocket via entirely separate paths.) +### More complicated example + +Here's an example that: +* Uses TypeScript +* Sends multiple calls, where the second call depends on the result of the first, in one round trip. + +We declare our interface in a shared types file: + +```ts +interface PublicApi { + // Authenticate the API token, and returned the authenticated API. + authenticate(apiToken: string): AuthedApi; + + // Get a given user's public profile info. (Doesn't require authentication.) + getUserProfile(userId: string): Promise; +} + +interface AuthedApi { + getUserId(): number; + + // Get the user IDs of all the user's friends. + getFriendIds(): number[]; +} + +type UserProfile = { + name: string; + photoUrl: string; +} +``` + +(Note: you don't _have to_ declare your interface separately. The client could just use `import("./server").ApiServer` as the type.) -On the client, use it in a batch request: +On the server, we implement the interface as an RpcTarget: ```ts -let batch = rpcOverHttp("https://example.com/api"); -let api = batch.getStub(); - -// Calling a function returns an RpcPromise for the result. -let whoamiPromise = api.whoami(); - -// `whoami()` will return the user ID. We haven't awaited the result yet, -// but we can pass the user ID to other calls in the batch. This creates a -// speculative, or "pipelined" call: on the server side, these calls will -// only be executed after `authenticate` finishes. -let profilePromise = api.getUserProfile(whoamiPromise.id); - -// Send the whole batch. Note that all calls must be initiated before this -// point, but promises will not resolve until after. -batch.send(); - -// Now we can actually await the results from earlier. Although we made -// two calls, both promises will resolve with only one round trip. -let user = await whoamiPromise; -let profile = await profilePromise; +import { newWorkersRpcResponse, RpcTarget } from "capnweb"; + +class ApiServer extends RpcTarget implements PublicApi { + // ... implement PublicApi ... +} + +export default { + async fetch(req, env, ctx) { + // ... same as previous example ... + } +} ``` -Alternatively, we can set up a persistent WebSocket connection: +On the client, we can use it in a batch request: ```ts -let session = rpcOverWebSocket("https://example.com/api"); -let api = session.getStub(); +import { newHttpBatchRpcSession } from "capnweb"; + +let api = newHttpBatchRpcSession("https://example.com/api"); + +// Call authenticate(), but don't await it. We can use the returned promise +// to make "pipelined" calls without waiting. +let authedApi: RpcPromise = api.authenticate(apiToken); + +// Make a pipelined call to get the user's ID. Again, don't await it. +let userIdPromise: RpcPromise = authedApi.getUserId(); -// Usage of `api` is the same, except there is no `sendBatch()` step. -// All calls are sent immediately. You can still send dependent calls -// without waiting for previous calls to return. +// Make another pipelined call to fetch the user's public profile, based on +// the user ID. Notice how we can use `RpcPromise` in the parameters of a +// call anywhere where T is expected. The promise will be replaced with its +// resolution before delivering the call. +let profilePromise = api.getUserProfile(userIdPromise); + +// Make another call to get the user's friends. +let friendsPromise = authedApi.getFriendIds(); + +// That only returns an array of user IDs, but we want all the profile info +// too, so use the magic .map() function to get them, too! Still one round +// trip. +let friendProfilesPromise = friendsPromise.map((id: RpcPromise) => { + return { id, profile: api.getUserProfile(id); }; +}); + +// Now await the promises. The batch is sent at this point. It's important +// to simultaneously await all promises for which you actually want the +// result. If you don't actually await a promise before the batch is sent, +// the system detects this and doesn't actually ask the server to send the +// return value back! +let [profile, friendProfiles] = + await Promise.all([profilePromise, friendProfilesPromise]); + +console.log(`Hello, ${profile.name}!`); + +// Note that at this point, the `api` and `authedApi` stubs no longer work, +// because the batch is done. You must start a new batch. +``` + +Alternatively, for a long-running interactive application, we can set up a persistent WebSocket connection: + +```ts +import { newWebSocketRpcSession } from "capnweb"; + +// We declare `api` with `using` so that it'll be disposed at the end of the +// scope, which closes the connection. `using` is a fairly new JavaScript +// feature, part of the "explicit resource management" spec. Alternatively, +// we could declare `api` with `let` or `const` and make sure to call +// `api[Symbol.dispose]()` to dispose it and close the connection later. +using api = newWebSocketRpcSession("https://example.com/api"); + +// Usage is exactly the same, except we don't have to await all the promises +// at once. + +// Authenticate and get the user ID in one round trip. Note we use `using` +// again so that `authedApi` will be disposed when we're done with it. In +// this case, it won't close the connection (since it's not the main stub), +// but disposing it does release the `AuthedApi` object on the server side. +using authedApi: RpcPromise = api.authenticate(apiToken); +let userId: number = await authedApi.getUserId(); + +// ... continue calling other methods, now or in the future ... ``` ## RPC Basics @@ -219,7 +311,7 @@ Instead, you may choose one of two strategies: 1. Explicitly dispose stubs when you are done with them. This notifies the remote end that it can release the associated resources. -2. Use short-lived sessions. When the session ends, all stubs are implicitly disposed. In particular, when using HTTP batch request, there's generally no need to dispose stubs. When using WebSocket sessions, however, disposal may be important. +2. Use short-lived sessions. When the session ends, all stubs are implicitly disposed. In particular, when using HTTP batch request, there's generally no need to dispose stubs. When using long-lived WebSocket sessions, however, disposal may be important. ### How to dispose @@ -237,14 +329,14 @@ The basic principle is: **The caller is responsible for disposing all stubs.** T * Stubs returned in the result of a call have their ownership transferred from the callee to the caller, and must be disposed by the caller. In practice, though, the callee and caller do not actually share the same stubs. When stubs are passed over RPC, they are _duplicated_, and the the target object is only disposed when all duplicates of the stub are disposed. Thus, to achieve the rule that only the caller needs to dispose stubs, the RPC system implicitly disposes the callee's duplicates of all stubs when the call completes. That is: -* Any stubs the callee receives in the parameters are implciitly disposed when the call completes. +* Any stubs the callee receives in the parameters are implicitly disposed when the call completes. * Any stubs returned in the results are implicitly disposed some time after the call completes. (Specifically, the RPC system will dispose them once it knows there will be no more pipelined calls.) Some additional wonky details: * Disposing an `RpcPromise` will automatically dispose the future result. (It may also cause the promise to be canceled and rejected, though this is not guaranteed.) If you don't intend to await an RPC promise, you should dispose it. * Passing an `RpcPromise` in params or the return value of a call has the same ownership / disposal rules as passing an `RpcStub`. * When you access a property of an `RpcStub` or `RpcPromise`, the result is itself an `RpcPromise`. However, this `RpcPromise` does not have its own disposer; you must dispose the stub or promise it came from. You can pass such properties in params or return values, but doing so will never lead to anything being implicitly disposed. -* The caller of an RPC may dispose any stubs used in the parameters immediately after initiating the RPC, without waiting for the RPC to copmlete. All stubs are duplicated at the moment of the call, so the callee is not responsible for keeping them alive. +* The caller of an RPC may dispose any stubs used in the parameters immediately after initiating the RPC, without waiting for the RPC to complete. All stubs are duplicated at the moment of the call, so the callee is not responsible for keeping them alive. * If the final result of an RPC returned to the caller is an object, it will always have a disposer. Disposing it will dispose all stubs found in that response. It's a good idea to always dispose return values even if you don't expect they contain any stubs, just in case the server changes the API in the future to add stubs to the result. WARNING: The ownership behavior of calls differs from the original behavior in the native RPC implementation built into the Cloudflare Workers Runtime. In the original Workers behavior, the callee loses ownership of stubs passed in a call's parameters. We plan to change the Workers Runtime to match Cap'n Web's behavior, as the original behavior has proven more problematic than helpful. @@ -275,6 +367,14 @@ If anything happens to the stub that would cause all further method calls and pr * The stub's underlying connection is lost. * The stub is a promise, and the promise rejects. +## Security Considerations + +* The WebSocket API in browsers always permits cross-site connections, and does not permit setting headers. Because of this, you generally cannot use cookies nor other headers for authentication. Instead, we highly recommend the pattern shown in the second example above, in which authentication happens in-band via an RPC method that returns the authenticated API. + +* Cap'n Web's pipelining can make it easy for a malicious client to enqueue a large amount of work to occur on a server. To mitigate this, we recommend implementing rate limits on expensive operations. If using Cloudflare Workers, you may also consider configuring [per-request CPU limits](https://developers.cloudflare.com/workers/wrangler/configuration/#limits) to be lower than the default 30s. Note that in stateless Workers (i.e. not Durable Objects), the system considers an entire WebSocket session to be one "request" for CPU limits purposes. + +* Cap'n Web currently does not provide any runtime type checking. When using TypeScript, keep in mind that types are checked only at compile time. A malicious client can send types you did not expect, and this could cause you application to behave in unexpected ways. For example, MongoDB uses special property names to express queries; placing attacker-provided values directly into queries can result in query injection vulnerabilities (similar to SQL injection). Of course, JSON has always had the same problem, and there exists tooling to solve it. You might consider using a runtime type-checking framework like Zod to check your inputs. In the future, we hope to explore auto-generating type-checking code based on TypeScript types. + ## Setting up a session ### HTTP batch client @@ -347,7 +447,7 @@ interface MyApi extends RpcTarget { // // (Note that disposing the root stub will close the connection. Here we declare it with `using` so // that the connection will be closed when the stub goes out of scope, but you can also call -// `stub[Symobl.dispose]()` directly.) +// `stub[Symbol.dispose]()` directly.) using stub: RpcStub = newWebSocketRpcSession("wss://example.com/api"); // With a WebSocket, we can freely make calls over time. @@ -396,14 +496,88 @@ export default { return newWorkersRpcResponse(request, new MyApiImpl(userInfo)); } - return new Respnose("Not found", {status: 404}); + return new Response("Not found", {status: 404}); } } ``` ### HTTP server on Node.js -_TODO: Not implemented yet_ +A server on Node.js is a bit more involved, due to the awkward handling of WebSockets in Node's HTTP library. + +```ts +import http from "node:http"; +import { WebSocketServer } from 'ws'; // npm package +import { RpcTarget, newWebSocketRpcSession, nodeHttpBatchRpcResponse } from "capnpweb"; + +class MyApiImpl extends RpcTarget implements MyApi { + // ... define API, same as above ... +} + +// Run standard HTTP server on a port. +httpServer = http.createServer(async (request, response) => { + if (request.headers.upgrade?.toLowerCase() === 'websocket') { + // Ignore, should be handled by WebSocketServer instead. + return; + } + + // Accept Cap'n Web requests at `/api`. + if (request.url === "/api") { + try { + nodeHttpBatchRpcResponse(request, response, new MyApiImpl(), { + // If you are accepting WebSockets, then you might as well accept cross-origin HTTP, since + // WebSockets always permit cross-origin request anyway. But, see security considerations + // for further discussion. + headers: { "Access-Control-Allow-Origin": "*" } + }); + } catch (err) { + response.writeHead(500, { 'content-type': 'text/plain' }); + response.end(String(err?.stack || err)); + } + return; + } + + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end("Not Found"); +}); + +// Arrange to handle WebSockets as well, using the `ws` package. You can skip this if you only +// want to handle HTTP batch requests. +wsServer = new WebSocketServer({ server: httpServer }) +wsServer.on('connection', (ws) => { + // The `as any` here is because the `ws` module seems to have its own `WebSocket` type + // declaration that's incompatible with the standard one. In practice, though, they are + // compatible enough for Cap'n Web! + newWebSocketRpcSession(ws as any, new MyApiImpl()); +}) + +// Accept requests on port 8080. +httpServer.listen(8080); +``` + +### HTTP server on other runtimes + +Every runtime does HTTP handling and WebSockets a little differently, although most modern runtimes use the standard `Request` and `Response` types from the Fetch API, as well as the standard `WebSocket` API. You should be able to use these two functions (exported by `capnweb`) to implement both HTTP batch and WebSocket handling on all platforms: + +```ts +// Run a single HTTP batch. +function newHttpBatchRpcResponse( + request: Request, yourApi: RpcTarget, options?: RpcSessionOptions) + : Promise; + +// Run a WebSocket session. +// +// This is actually the same function as is used on the client side! But on the +// server, you should pass in a `WebSocket` object representing the already-open +// connection, instead of a URL string, and you pass your API implementation as +// the second parameter. +// +// You can dispose the returned `Disposable` to close the connection, or just +// let it run until the client closes it. +function newWebSocketRpcSession( + webSocket: WebSocket, yourApi: RpcTarget, options?: RpcSessionOptions) + : Disposable; +``` ### MessagePort @@ -433,7 +607,7 @@ console.log(await stub.greet("Alice")); console.log(await stub.greet("Bob")); ``` -Of course, in a real-world scenario, you'd probably want to send one of the two ports to another context. A `MesasgePort` can itself be transferred to another context using `postMessage()`, e.g. `window.postMessage()`, `worker.postMessage()`, or even `port.postMessage()` on some other existing `MesasgePort`. +Of course, in a real-world scenario, you'd probably want to send one of the two ports to another context. A `MessagePort` can itself be transferred to another context using `postMessage()`, e.g. `window.postMessage()`, `worker.postMessage()`, or even `port.postMessage()` on some other existing `MessagePort`. Note that you should not use a `Window` object itself as a port for RPC -- you should always create a new `MessageChannel` and send one of the ports over. This is because anyone can `postMessage()` to a window, and the RPC system does not authenticate that messages came from the expected sender. You need to verify that you received the port itself from the expected sender first, then let the RPC system take over. diff --git a/src/batch.ts b/src/batch.ts index a6f766c..71945b7 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -122,6 +122,16 @@ class BatchServerTransport implements RpcTransport { } } +/** + * Implements the server end of an HTTP batch session, using standard Fetch API types to represent + * HTTP requests and responses. + * + * @param request The request received from the client initiating the session. + * @param localMain The main stub or RpcTarget which the server wishes to expose to the client. + * @param options Optional RPC sesison options. + * @returns The HTTP response to return to the client. Note that the returned object has mutable + * headers, so you can modify them using e.g. `response.headers.set("Foo", "bar")`. + */ export async function newHttpBatchRpcResponse( request: Request, localMain: any, options?: RpcSessionOptions): Promise { if (request.method !== "POST") { @@ -149,6 +159,14 @@ export async function newHttpBatchRpcResponse( return new Response(transport.getResponseBody()); } +/** + * Implements the server end of an HTTP batch session using traditional Node.js HTTP APIs. + * + * @param request The request received from the client initiating the session. + * @param response The response object, to which the response should be written. + * @param localMain The main stub or RpcTarget which the server wishes to expose to the client. + * @param options Optional RPC sesison options. You can also pass headers to set on the response. + */ export async function nodeHttpBatchRpcResponse( request: IncomingMessage, response: ServerResponse, localMain: any, diff --git a/src/core.ts b/src/core.ts index cd47f13..edb0f80 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1289,34 +1289,14 @@ function followPath(value: unknown, parent: object | undefined, }; } -// StubHook wrapping an RpcPayload in local memory. -// -// This is used for: -// - Resolution of a promise. -// - Initially on the server side, where it can be pull()ed and used in pipelining. -// - On the client side, after pull() has transmitted the payload. -// - Implementing RpcTargets, on the server side. -// - Since the payload's root is an RpcTarget, pull()ing it will just duplicate the stub. -export class PayloadStubHook extends StubHook { - constructor(payload: RpcPayload) { - super(); - this.payload = payload; - } - - private payload?: RpcPayload; // cleared when disposed - - private getPayload(): RpcPayload { - if (this.payload) { - return this.payload; - } else { - throw new Error("Attempted to use an RPC StubHook after it was disposed."); - } - } +// Shared base class for PayloadStubHook and TargetStubHook. +abstract class ValueStubHook extends StubHook { + protected abstract getValue(): {value: unknown, owner: RpcPayload | null}; call(path: PropertyPath, args: RpcPayload): StubHook { try { - let payload = this.getPayload(); - let followResult = followPath(payload.value, undefined, path, payload); + let {value, owner} = this.getValue(); + let followResult = followPath(value, undefined, path, owner); if (followResult.hook) { return followResult.hook.call(followResult.remainingPath, args); @@ -1339,8 +1319,8 @@ export class PayloadStubHook extends StubHook { try { let followResult: FollowPathResult; try { - let payload = this.getPayload(); - followResult = followPath(payload.value, undefined, path, payload); + let {value, owner} = this.getValue(); + followResult = followPath(value, undefined, path, owner);; } catch (err) { // Oops, we need to dispose the captures of which we took ownership. for (let cap of captures) { @@ -1362,19 +1342,67 @@ export class PayloadStubHook extends StubHook { get(path: PropertyPath): StubHook { try { - let payload = this.getPayload(); - let followResult = followPath(payload.value, undefined, path, payload); + let {value, owner} = this.getValue(); + + if (path.length === 0 && owner === null) { + // The only way this happens is if someone sends "pipeline" and references a + // TargetStubHook, but they shouldn't do that, because TargetStubHook never backs a + // promise, and a non-promise cannot be converted to a promise. + // TODO: Is this still correct for rpc-thenable? + throw new Error("Can't dup an RpcTarget stub as a promise."); + } + + let followResult = followPath(value, undefined, path, owner); if (followResult.hook) { return followResult.hook.get(followResult.remainingPath); } + // Note that if `followResult.owner` is null, then we've descended into the contents of an + // RpcTarget. In that case, if this deep copy discovers an RpcTarget embedded in the result, + // it will create a new stub for it. If that RpcTarget has a disposer, it'll be disposed when + // that stub is disposed. If the same RpcTarget is returned in *another* get(), it create + // *another* stub, which calls the disposer *another* time. This can be quite weird -- the + // disposer may be called any number of times, including zero if the property is never read + // at all. Unfortunately, that's just the way it is. The application can avoid this problem by + // wrapping the RpcTarget in an RpcStub itself, proactively, and using that as the property -- + // then, each time the property is get()ed, a dup() of that stub is returned. return new PayloadStubHook(RpcPayload.deepCopyFrom( followResult.value, followResult.parent, followResult.owner)); } catch (err) { return new ErrorStubHook(err); } } +} + +// StubHook wrapping an RpcPayload in local memory. +// +// This is used for: +// - Resolution of a promise. +// - Initially on the server side, where it can be pull()ed and used in pipelining. +// - On the client side, after pull() has transmitted the payload. +// - Implementing RpcTargets, on the server side. +// - Since the payload's root is an RpcTarget, pull()ing it will just duplicate the stub. +export class PayloadStubHook extends ValueStubHook { + constructor(payload: RpcPayload) { + super(); + this.payload = payload; + } + + private payload?: RpcPayload; // cleared when disposed + + private getPayload(): RpcPayload { + if (this.payload) { + return this.payload; + } else { + throw new Error("Attempted to use an RPC StubHook after it was disposed."); + } + } + + protected getValue() { + let payload = this.getPayload(); + return {value: payload.value, owner: payload}; + } dup(): StubHook { // Although dup() is documented as not copying the payload, what this really means is that @@ -1448,7 +1476,7 @@ type BoxedRefcount = { count: number }; // the root of the payload happens to be an RpcTarget), but there can only be one RpcPayload // pointing at an RpcTarget whereas there can be several TargetStubHooks pointing at it. Also, // TargetStubHook cannot be pull()ed, because it always backs an RpcStub, not an RpcPromise. -class TargetStubHook extends StubHook { +class TargetStubHook extends ValueStubHook { // Constructs a TargetStubHook that is not duplicated from an existing hook. // // If `value` is a function, `parent` is bound as its "this". @@ -1491,82 +1519,8 @@ class TargetStubHook extends StubHook { } } - call(path: PropertyPath, args: RpcPayload): StubHook { - try { - let target = this.getTarget(); - let followResult = followPath(target, this.parent, path, null); - - if (followResult.hook) { - return followResult.hook.call(followResult.remainingPath, args); - } - - // It's a local function. - if (typeof followResult.value != "function") { - throw new TypeError(`'${path.join('.')}' is not a function.`); - } - let promise = args.deliverCall(followResult.value, followResult.parent); - return new PromiseStubHook(promise.then(payload => { - return new PayloadStubHook(payload); - })); - } catch (err) { - return new ErrorStubHook(err); - } - } - - map(path: PropertyPath, captures: StubHook[], instructions: unknown[]): StubHook { - try { - let followResult: FollowPathResult; - try { - let target = this.getTarget(); - followResult = followPath(target, this.parent, path, null); - } catch (err) { - // Oops, we need to dispose the captures of which we took ownership. - for (let cap of captures) { - cap.dispose(); - } - throw err; - } - - if (followResult.hook) { - return followResult.hook.map(followResult.remainingPath, captures, instructions); - } - - return mapImpl.applyMap( - followResult.value, followResult.parent, followResult.owner, captures, instructions); - } catch (err) { - return new ErrorStubHook(err); - } - } - - get(path: PropertyPath): StubHook { - try { - if (path.length == 0) { - // The only way this happens is if someone sends "pipeline" and references a - // TargetStubHook, but they shouldn't do that, because TargetStubHook never backs a - // promise, and a non-promise cannot be converted to a promise. - throw new Error("Can't dup an RpcTarget stub as a promise."); - } - - let target = this.getTarget(); - let followResult = followPath(target, this.parent, path, null); - - if (followResult.hook) { - return followResult.hook.get(followResult.remainingPath); - } - - // Note that this deep copy, if it discovers an RpcTarget embedded in the result, will create - // a new stub for it. If the RpcTarget has a disposer, it'll be disposed when that stub is - // disposed. If the same RpcTarget is returned in *another* get(), it create *another* stub, - // which calls the disposer *another* time. This can be quite weird -- the disposer may be - // called any number of times, including zero if the property is never read at all. - // Unfortunately, that's just the way it is. The application can avoid this problem by - // wrapping the RpcTarget in an RpcStub itself, proactively, and using that as the property -- - // then, each time the property is get()ed, a dup() of that stub is returned. - return new PayloadStubHook(RpcPayload.deepCopyFrom( - followResult.value, followResult.parent, followResult.owner)); - } catch (err) { - return new ErrorStubHook(err); - } + protected getValue() { + return {value: this.getTarget(), owner: null}; } dup(): StubHook { diff --git a/src/index.ts b/src/index.ts index 91445f7..ccc0fd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,16 +17,49 @@ export { serialize, deserialize, newWorkersWebSocketRpcResponse, newHttpBatchRpc export type { RpcTransport, RpcSessionOptions }; // Hack the type system to make RpcStub's types work nicely! +/** + * Represents a reference to a remote object, on which methods may be remotely invoked via RPC. + * + * `RpcStub` can represent any interface (when using TypeScript, you pass the specific interface + * type as `T`, but this isn't known at runtime). The way this works is, `RpcStub` is actually a + * `Proxy`. It makes itself appear as if every possible method / property name is defined. You can + * invoke any method name, and the invocation will be sent to the server. If it turns out that no + * such method exists on the remote object, an exception is thrown back. But the client does not + * actually know, until that point, what methods exist. + */ export type RpcStub> = Stub; export const RpcStub: { new >(value: T): RpcStub; } = RpcStubImpl; +/** + * Represents the result of an RPC call. + * + * Also used to represent propreties. That is, `stub.foo` evaluates to an `RpcPromise` for the + * value of `foo`. + * + * This isn't actually a JavaScript `Promise`. It does, however, have `then()`, `catch()`, and + * `finally()` methods, like `Promise` does, and because it has a `then()` method, JavaScript will + * allow you to treat it like a promise, e.g. you can `await` it. + * + * An `RpcPromise` is also a proxy, just like `RpcStub`, where calling methods or awaiting + * properties will make a pipelined network request. + * + * Note that and `RpcPromise` is "lazy": the actual final result is not requested from the server + * until you actually `await` the promise (or call `then()`, etc. on it). This is an optimization: + * if you only intend to use the promise for pipelining and you never await it, then there's no + * need to transmit the resolution! + */ export type RpcPromise> = Stub & Promise>; export const RpcPromise: { // Note: Cannot construct directly! } = RpcPromiseImpl; +/** + * Use to construct an `RpcSession` on top of a custom `RpcTransport`. + * + * Most people won't use this. You only need it if you've implemented your own `RpcTransport`. + */ export interface RpcSession = undefined> { getRemoteMain(): RpcStub; getStats(): {imports: number, exports: number}; @@ -42,32 +75,77 @@ export const RpcSession: { // RpcTarget needs some hackage too to brand it properly and account for the implementation // conditionally being imported from "cloudflare:workers". +/** + * Classes which are intended to be passed by reference and called over RPC must extend + * `RpcTarget`. A class which does not extend `RpcTarget` (and which dosen't have built-in support + * from the RPC system) cannot be passed in an RPC message at all; an exception will be thrown. + * + * Note that on Cloudflare Workers, this `RpcTarget` is an alias for the one exported from the + * "cloudflare:workers" module, so they can be used interchangably. + */ export interface RpcTarget extends RpcTargetBranded {}; export const RpcTarget: { new(): RpcTarget; } = RpcTargetImpl; +/** + * Empty interface used as default type parameter for sessions where the other side doesn't + * necessarily export a main interface. + */ interface Empty {} -export let newWebSocketRpcSession: - = Empty> - (webSocket: WebSocket | string, localMain?: any, options?: RpcSessionOptions) => Stubify = +/** + * Start a WebSocket session given either an already-open WebSocket or a URL. + * + * @param webSocket Either the `wss://` URL to connect to, or an already-open WebSocket object to + * use. + * @param localMain The main RPC interface to expose to the peer. Returns a stub for the main + * interface exposed from the peer. + */ +export let newWebSocketRpcSession: = Empty> + (webSocket: WebSocket | string, localMain?: any, options?: RpcSessionOptions) => RpcStub = newWebSocketRpcSessionImpl; -export let newHttpBatchRpcSession: - = Empty> - (urlOrRequest: string | Request, init?: RequestInit) => Stubify = +/** + * Initiate an HTTP batch session from the client side. + * + * The parameters to this method have exactly the same signature as `fetch()`, but the return + * value is an RpcStub. You can customize anything about the request except for the method + * (it will always be set to POST) and the body (which the RPC system will fill in). + */ +export let newHttpBatchRpcSession:> + (urlOrRequest: string | Request, init?: RequestInit) => RpcStub = newHttpBatchRpcSessionImpl; -export let newMessagePortRpcSession: - = Empty> - (port: MessagePort, localMain?: any, options?: RpcSessionOptions) => Stubify = +/** + * Initiate an RPC session over a MessagePort, which is particularly useful for communicating + * between an iframe and its parent frame in a browser context. Each side should call this function + * on its own end of the MessageChannel. + */ +export let newMessagePortRpcSession: = Empty> + (port: MessagePort, localMain?: any, options?: RpcSessionOptions) => RpcStub = newMessagePortRpcSessionImpl; -// Implements inified handling of HTTP-batch and WebSocket responses for the Workers Runtime. -export function newWorkersRpcResponse(request: Request, localMain: any) { +/** + * Implements unified handling of HTTP-batch and WebSocket responses for the Cloudflare Workers + * Runtime. + * + * SECURITY WARNING: This function accepts cross-origin requests. If you do not want this, you + * should validate the `Origin` header before calling this, or use `newHttpBatchRpcSession()` and + * `newWebSocketRpcSession()` directly with appropriate security measures for each type of request. + * But if your API uses in-band authorization (i.e. it has an RPC method that takes the user's + * credentials as parameters and returns the authorized API), then cross-origin requests should + * be safe. + */ +export async function newWorkersRpcResponse(request: Request, localMain: any) { if (request.method === "POST") { - return newHttpBatchRpcResponse(request, localMain); + let response = await newHttpBatchRpcResponse(request, localMain); + // Since we're exposing the same API over WebSocket, too, and WebScoket always allows + // cross-origin requests, the API necessarily must be safe for cross-origin use (e.g. because + // it uses in-band authorization, as recommended in the readme). So, we might as well allow + // batch requests to be made cross-origin as well. + response.headers.set("Access-Control-Allow-Origin", "*"); + return response; } else if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") { return newWorkersWebSocketRpcResponse(request, localMain); } else { diff --git a/src/rpc.ts b/src/rpc.ts index 75354b0..8ad5d7d 100644 --- a/src/rpc.ts +++ b/src/rpc.ts @@ -1,24 +1,33 @@ import { StubHook, RpcPayload, RpcStub, PropertyPath, PayloadStubHook, ErrorStubHook, RpcTarget, unwrapStubAndPath } from "./core.js"; import { Devaluator, Evaluator, ExportId, ImportId, Exporter, Importer, serialize } from "./serialize.js"; -// Interface for an RPC transport, which is a simple bidirectional message stream. +/** + * Interface for an RPC transport, which is a simple bidirectional message stream. Implement this + * interface if the built-in transports (e.g. for HTTP batch and WebSocket) don't meet your needs. + */ export interface RpcTransport { - // Sends a message to the other end. + /** + * Sends a message to the other end. + */ send(message: string): Promise; - // Receives a message sent by the other end. - // - // If and when the transport becomes disconnected, this will reject. The thrown error will be - // propagated to all outstanding calls and future calls on any stubs associated with the session. - // If there are no outstanding calls (and none are made in the future), then the error does not - // propagate anywhere -- this is considered a "clean" shutdown. + /** + * Receives a message sent by the other end. + * + * If and when the transport becomes disconnected, this will reject. The thrown error will be + * propagated to all outstanding calls and future calls on any stubs associated with the session. + * If there are no outstanding calls (and none are made in the future), then the error does not + * propagate anywhere -- this is considered a "clean" shutdown. + */ receive(): Promise; - // Indicates that the RPC system has suffered an error that prevents the session from continuing. - // The transport should ideally try to send any queued messages if it can, and then close the - // connection. (It's not strictly necessary to deliver queued messages, but the last message sent - // before abort() is called is often an "abort" message, which communicates the error to the - // peer, so if that is dropped, the peer may have less information about what happened.) + /** + * Indicates that the RPC system has suffered an error that prevents the session from continuing. + * The transport should ideally try to send any queued messages if it can, and then close the + * connection. (It's not strictly necessary to deliver queued messages, but the last message sent + * before abort() is called is often an "abort" message, which communicates the error to the + * peer, so if that is dropped, the peer may have less information about what happened.) + */ abort?(reason: any): void; } @@ -268,16 +277,22 @@ class RpcMainHook extends RpcImportHook { } } +/** + * Options to customize behavior of an RPC session. All functions which start a session should + * optionally accept this. + */ export type RpcSessionOptions = { - // If provided, this function will be called whenever an `Error` object is serialized (for any - // resaon, not just because it was thrown). This can be used to log errors, and also to redact - // them. - // - // If `onSendError` returns an Error object, than object will be substituted in place of the - // original. If it has a stack property, the stack will be sent to the client. - // - // If `onSendError` doesn't return anything (or is not provided at all), the default behavior is - // to serialize the error with the stack omitted. + /** + * If provided, this function will be called whenever an `Error` object is serialized (for any + * resaon, not just because it was thrown). This can be used to log errors, and also to redact + * them. + * + * If `onSendError` returns an Error object, than object will be substituted in place of the + * original. If it has a stack property, the stack will be sent to the client. + * + * If `onSendError` doesn't return anything (or is not provided at all), the default behavior is + * to serialize the error with the stack omitted. + */ onSendError?: (error: Error) => Error | void; }; diff --git a/src/serialize.ts b/src/serialize.ts index 27f955d..5b8b4d3 100644 --- a/src/serialize.ts +++ b/src/serialize.ts @@ -236,6 +236,10 @@ export class Devaluator { } } +/** + * Serialize a value, using Cap'n Web's underlying serialization. This won't be able to serialize + * RPC stubs, but it will support basic data types. + */ export function serialize(value: unknown): string { return JSON.stringify(Devaluator.devaluate(value)); } @@ -522,6 +526,9 @@ export class Evaluator { } } +/** + * Deserialize a value serialized using serialize(). + */ export function deserialize(value: string): unknown { let payload = new Evaluator(NULL_IMPORTER).evaluate(JSON.parse(value)); payload.dispose(); // should be no-op but just in case diff --git a/src/types.d.ts b/src/types.d.ts index 67ad90e..c217d3b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -123,7 +123,7 @@ type MaybeDisposable = T extends object ? Disposable : unknown; // - Stubable types are replaced by stubs. // - Serializable types are passed by value, with stubable types replaced by stubs // and a top-level `Disposer`. -// Everything else can't be passed over PRC. +// Everything else can't be passed over RPC. // Technically, we use custom thenables here, but they quack like `Promise`s. // Intersecting with `(Maybe)Provider` allows pipelining. // prettier-ignore diff --git a/src/websocket.ts b/src/websocket.ts index c483218..459e887 100644 --- a/src/websocket.ts +++ b/src/websocket.ts @@ -3,10 +3,6 @@ import { RpcStub } from "./core.js"; import { RpcTransport, RpcSession, RpcSessionOptions } from "./rpc.js"; -// Start a WebSocket session given either an already-open WebSocket or a URL. -// -// `localMain` is the main RPC interface to expose to the peer. Returns a stub for the main -// interface exposed from the peer. export function newWebSocketRpcSession( webSocket: WebSocket | string, localMain?: any, options?: RpcSessionOptions): RpcStub { if (typeof webSocket === "string") { @@ -18,8 +14,10 @@ export function newWebSocketRpcSession( return rpc.getRemoteMain(); } -// For use in Cloudflare Workers: Construct an HTTP response that starts a WebSocket RPC session -// with the given `localMain`. +/** + * For use in Cloudflare Workers: Construct an HTTP response that starts a WebSocket RPC session + * with the given `localMain`. + */ export function newWorkersWebSocketRpcResponse( request: Request, localMain?: any, options?: RpcSessionOptions): Response { if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {