Skip to content

Commit 62c50d4

Browse files
authored
Merge pull request #18 from cloudflare/kenton/more-tweaks
Improve docs, and some other tweaks
2 parents 813dc8f + 1c7c69b commit 62c50d4

File tree

8 files changed

+454
-210
lines changed

8 files changed

+454
-210
lines changed

README.md

Lines changed: 237 additions & 63 deletions
Large diffs are not rendered by default.

src/batch.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,16 @@ class BatchServerTransport implements RpcTransport {
122122
}
123123
}
124124

125+
/**
126+
* Implements the server end of an HTTP batch session, using standard Fetch API types to represent
127+
* HTTP requests and responses.
128+
*
129+
* @param request The request received from the client initiating the session.
130+
* @param localMain The main stub or RpcTarget which the server wishes to expose to the client.
131+
* @param options Optional RPC sesison options.
132+
* @returns The HTTP response to return to the client. Note that the returned object has mutable
133+
* headers, so you can modify them using e.g. `response.headers.set("Foo", "bar")`.
134+
*/
125135
export async function newHttpBatchRpcResponse(
126136
request: Request, localMain: any, options?: RpcSessionOptions): Promise<Response> {
127137
if (request.method !== "POST") {
@@ -149,6 +159,14 @@ export async function newHttpBatchRpcResponse(
149159
return new Response(transport.getResponseBody());
150160
}
151161

162+
/**
163+
* Implements the server end of an HTTP batch session using traditional Node.js HTTP APIs.
164+
*
165+
* @param request The request received from the client initiating the session.
166+
* @param response The response object, to which the response should be written.
167+
* @param localMain The main stub or RpcTarget which the server wishes to expose to the client.
168+
* @param options Optional RPC sesison options. You can also pass headers to set on the response.
169+
*/
152170
export async function nodeHttpBatchRpcResponse(
153171
request: IncomingMessage, response: ServerResponse,
154172
localMain: any,

src/core.ts

Lines changed: 60 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1289,34 +1289,14 @@ function followPath(value: unknown, parent: object | undefined,
12891289
};
12901290
}
12911291

1292-
// StubHook wrapping an RpcPayload in local memory.
1293-
//
1294-
// This is used for:
1295-
// - Resolution of a promise.
1296-
// - Initially on the server side, where it can be pull()ed and used in pipelining.
1297-
// - On the client side, after pull() has transmitted the payload.
1298-
// - Implementing RpcTargets, on the server side.
1299-
// - Since the payload's root is an RpcTarget, pull()ing it will just duplicate the stub.
1300-
export class PayloadStubHook extends StubHook {
1301-
constructor(payload: RpcPayload) {
1302-
super();
1303-
this.payload = payload;
1304-
}
1305-
1306-
private payload?: RpcPayload; // cleared when disposed
1307-
1308-
private getPayload(): RpcPayload {
1309-
if (this.payload) {
1310-
return this.payload;
1311-
} else {
1312-
throw new Error("Attempted to use an RPC StubHook after it was disposed.");
1313-
}
1314-
}
1292+
// Shared base class for PayloadStubHook and TargetStubHook.
1293+
abstract class ValueStubHook extends StubHook {
1294+
protected abstract getValue(): {value: unknown, owner: RpcPayload | null};
13151295

13161296
call(path: PropertyPath, args: RpcPayload): StubHook {
13171297
try {
1318-
let payload = this.getPayload();
1319-
let followResult = followPath(payload.value, undefined, path, payload);
1298+
let {value, owner} = this.getValue();
1299+
let followResult = followPath(value, undefined, path, owner);
13201300

13211301
if (followResult.hook) {
13221302
return followResult.hook.call(followResult.remainingPath, args);
@@ -1339,8 +1319,8 @@ export class PayloadStubHook extends StubHook {
13391319
try {
13401320
let followResult: FollowPathResult;
13411321
try {
1342-
let payload = this.getPayload();
1343-
followResult = followPath(payload.value, undefined, path, payload);
1322+
let {value, owner} = this.getValue();
1323+
followResult = followPath(value, undefined, path, owner);;
13441324
} catch (err) {
13451325
// Oops, we need to dispose the captures of which we took ownership.
13461326
for (let cap of captures) {
@@ -1362,19 +1342,67 @@ export class PayloadStubHook extends StubHook {
13621342

13631343
get(path: PropertyPath): StubHook {
13641344
try {
1365-
let payload = this.getPayload();
1366-
let followResult = followPath(payload.value, undefined, path, payload);
1345+
let {value, owner} = this.getValue();
1346+
1347+
if (path.length === 0 && owner === null) {
1348+
// The only way this happens is if someone sends "pipeline" and references a
1349+
// TargetStubHook, but they shouldn't do that, because TargetStubHook never backs a
1350+
// promise, and a non-promise cannot be converted to a promise.
1351+
// TODO: Is this still correct for rpc-thenable?
1352+
throw new Error("Can't dup an RpcTarget stub as a promise.");
1353+
}
1354+
1355+
let followResult = followPath(value, undefined, path, owner);
13671356

13681357
if (followResult.hook) {
13691358
return followResult.hook.get(followResult.remainingPath);
13701359
}
13711360

1361+
// Note that if `followResult.owner` is null, then we've descended into the contents of an
1362+
// RpcTarget. In that case, if this deep copy discovers an RpcTarget embedded in the result,
1363+
// it will create a new stub for it. If that RpcTarget has a disposer, it'll be disposed when
1364+
// that stub is disposed. If the same RpcTarget is returned in *another* get(), it create
1365+
// *another* stub, which calls the disposer *another* time. This can be quite weird -- the
1366+
// disposer may be called any number of times, including zero if the property is never read
1367+
// at all. Unfortunately, that's just the way it is. The application can avoid this problem by
1368+
// wrapping the RpcTarget in an RpcStub itself, proactively, and using that as the property --
1369+
// then, each time the property is get()ed, a dup() of that stub is returned.
13721370
return new PayloadStubHook(RpcPayload.deepCopyFrom(
13731371
followResult.value, followResult.parent, followResult.owner));
13741372
} catch (err) {
13751373
return new ErrorStubHook(err);
13761374
}
13771375
}
1376+
}
1377+
1378+
// StubHook wrapping an RpcPayload in local memory.
1379+
//
1380+
// This is used for:
1381+
// - Resolution of a promise.
1382+
// - Initially on the server side, where it can be pull()ed and used in pipelining.
1383+
// - On the client side, after pull() has transmitted the payload.
1384+
// - Implementing RpcTargets, on the server side.
1385+
// - Since the payload's root is an RpcTarget, pull()ing it will just duplicate the stub.
1386+
export class PayloadStubHook extends ValueStubHook {
1387+
constructor(payload: RpcPayload) {
1388+
super();
1389+
this.payload = payload;
1390+
}
1391+
1392+
private payload?: RpcPayload; // cleared when disposed
1393+
1394+
private getPayload(): RpcPayload {
1395+
if (this.payload) {
1396+
return this.payload;
1397+
} else {
1398+
throw new Error("Attempted to use an RPC StubHook after it was disposed.");
1399+
}
1400+
}
1401+
1402+
protected getValue() {
1403+
let payload = this.getPayload();
1404+
return {value: payload.value, owner: payload};
1405+
}
13781406

13791407
dup(): StubHook {
13801408
// Although dup() is documented as not copying the payload, what this really means is that
@@ -1448,7 +1476,7 @@ type BoxedRefcount = { count: number };
14481476
// the root of the payload happens to be an RpcTarget), but there can only be one RpcPayload
14491477
// pointing at an RpcTarget whereas there can be several TargetStubHooks pointing at it. Also,
14501478
// TargetStubHook cannot be pull()ed, because it always backs an RpcStub, not an RpcPromise.
1451-
class TargetStubHook extends StubHook {
1479+
class TargetStubHook extends ValueStubHook {
14521480
// Constructs a TargetStubHook that is not duplicated from an existing hook.
14531481
//
14541482
// If `value` is a function, `parent` is bound as its "this".
@@ -1491,82 +1519,8 @@ class TargetStubHook extends StubHook {
14911519
}
14921520
}
14931521

1494-
call(path: PropertyPath, args: RpcPayload): StubHook {
1495-
try {
1496-
let target = this.getTarget();
1497-
let followResult = followPath(target, this.parent, path, null);
1498-
1499-
if (followResult.hook) {
1500-
return followResult.hook.call(followResult.remainingPath, args);
1501-
}
1502-
1503-
// It's a local function.
1504-
if (typeof followResult.value != "function") {
1505-
throw new TypeError(`'${path.join('.')}' is not a function.`);
1506-
}
1507-
let promise = args.deliverCall(<Function>followResult.value, followResult.parent);
1508-
return new PromiseStubHook(promise.then(payload => {
1509-
return new PayloadStubHook(payload);
1510-
}));
1511-
} catch (err) {
1512-
return new ErrorStubHook(err);
1513-
}
1514-
}
1515-
1516-
map(path: PropertyPath, captures: StubHook[], instructions: unknown[]): StubHook {
1517-
try {
1518-
let followResult: FollowPathResult;
1519-
try {
1520-
let target = this.getTarget();
1521-
followResult = followPath(target, this.parent, path, null);
1522-
} catch (err) {
1523-
// Oops, we need to dispose the captures of which we took ownership.
1524-
for (let cap of captures) {
1525-
cap.dispose();
1526-
}
1527-
throw err;
1528-
}
1529-
1530-
if (followResult.hook) {
1531-
return followResult.hook.map(followResult.remainingPath, captures, instructions);
1532-
}
1533-
1534-
return mapImpl.applyMap(
1535-
followResult.value, followResult.parent, followResult.owner, captures, instructions);
1536-
} catch (err) {
1537-
return new ErrorStubHook(err);
1538-
}
1539-
}
1540-
1541-
get(path: PropertyPath): StubHook {
1542-
try {
1543-
if (path.length == 0) {
1544-
// The only way this happens is if someone sends "pipeline" and references a
1545-
// TargetStubHook, but they shouldn't do that, because TargetStubHook never backs a
1546-
// promise, and a non-promise cannot be converted to a promise.
1547-
throw new Error("Can't dup an RpcTarget stub as a promise.");
1548-
}
1549-
1550-
let target = this.getTarget();
1551-
let followResult = followPath(target, this.parent, path, null);
1552-
1553-
if (followResult.hook) {
1554-
return followResult.hook.get(followResult.remainingPath);
1555-
}
1556-
1557-
// Note that this deep copy, if it discovers an RpcTarget embedded in the result, will create
1558-
// a new stub for it. If the RpcTarget has a disposer, it'll be disposed when that stub is
1559-
// disposed. If the same RpcTarget is returned in *another* get(), it create *another* stub,
1560-
// which calls the disposer *another* time. This can be quite weird -- the disposer may be
1561-
// called any number of times, including zero if the property is never read at all.
1562-
// Unfortunately, that's just the way it is. The application can avoid this problem by
1563-
// wrapping the RpcTarget in an RpcStub itself, proactively, and using that as the property --
1564-
// then, each time the property is get()ed, a dup() of that stub is returned.
1565-
return new PayloadStubHook(RpcPayload.deepCopyFrom(
1566-
followResult.value, followResult.parent, followResult.owner));
1567-
} catch (err) {
1568-
return new ErrorStubHook(err);
1569-
}
1522+
protected getValue() {
1523+
return {value: this.getTarget(), owner: null};
15701524
}
15711525

15721526
dup(): StubHook {

src/index.ts

Lines changed: 90 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,49 @@ export { serialize, deserialize, newWorkersWebSocketRpcResponse, newHttpBatchRpc
1717
export type { RpcTransport, RpcSessionOptions };
1818

1919
// Hack the type system to make RpcStub's types work nicely!
20+
/**
21+
* Represents a reference to a remote object, on which methods may be remotely invoked via RPC.
22+
*
23+
* `RpcStub` can represent any interface (when using TypeScript, you pass the specific interface
24+
* type as `T`, but this isn't known at runtime). The way this works is, `RpcStub` is actually a
25+
* `Proxy`. It makes itself appear as if every possible method / property name is defined. You can
26+
* invoke any method name, and the invocation will be sent to the server. If it turns out that no
27+
* such method exists on the remote object, an exception is thrown back. But the client does not
28+
* actually know, until that point, what methods exist.
29+
*/
2030
export type RpcStub<T extends Serializable<T>> = Stub<T>;
2131
export const RpcStub: {
2232
new <T extends Serializable<T>>(value: T): RpcStub<T>;
2333
} = <any>RpcStubImpl;
2434

35+
/**
36+
* Represents the result of an RPC call.
37+
*
38+
* Also used to represent propreties. That is, `stub.foo` evaluates to an `RpcPromise` for the
39+
* value of `foo`.
40+
*
41+
* This isn't actually a JavaScript `Promise`. It does, however, have `then()`, `catch()`, and
42+
* `finally()` methods, like `Promise` does, and because it has a `then()` method, JavaScript will
43+
* allow you to treat it like a promise, e.g. you can `await` it.
44+
*
45+
* An `RpcPromise` is also a proxy, just like `RpcStub`, where calling methods or awaiting
46+
* properties will make a pipelined network request.
47+
*
48+
* Note that and `RpcPromise` is "lazy": the actual final result is not requested from the server
49+
* until you actually `await` the promise (or call `then()`, etc. on it). This is an optimization:
50+
* if you only intend to use the promise for pipelining and you never await it, then there's no
51+
* need to transmit the resolution!
52+
*/
2553
export type RpcPromise<T extends Serializable<T>> = Stub<T> & Promise<Stubify<T>>;
2654
export const RpcPromise: {
2755
// Note: Cannot construct directly!
2856
} = <any>RpcPromiseImpl;
2957

58+
/**
59+
* Use to construct an `RpcSession` on top of a custom `RpcTransport`.
60+
*
61+
* Most people won't use this. You only need it if you've implemented your own `RpcTransport`.
62+
*/
3063
export interface RpcSession<T extends Serializable<T> = undefined> {
3164
getRemoteMain(): RpcStub<T>;
3265
getStats(): {imports: number, exports: number};
@@ -42,32 +75,77 @@ export const RpcSession: {
4275

4376
// RpcTarget needs some hackage too to brand it properly and account for the implementation
4477
// conditionally being imported from "cloudflare:workers".
78+
/**
79+
* Classes which are intended to be passed by reference and called over RPC must extend
80+
* `RpcTarget`. A class which does not extend `RpcTarget` (and which dosen't have built-in support
81+
* from the RPC system) cannot be passed in an RPC message at all; an exception will be thrown.
82+
*
83+
* Note that on Cloudflare Workers, this `RpcTarget` is an alias for the one exported from the
84+
* "cloudflare:workers" module, so they can be used interchangably.
85+
*/
4586
export interface RpcTarget extends RpcTargetBranded {};
4687
export const RpcTarget: {
4788
new(): RpcTarget;
4889
} = RpcTargetImpl;
4990

91+
/**
92+
* Empty interface used as default type parameter for sessions where the other side doesn't
93+
* necessarily export a main interface.
94+
*/
5095
interface Empty {}
5196

52-
export let newWebSocketRpcSession:
53-
<T extends Serializable<T> = Empty>
54-
(webSocket: WebSocket | string, localMain?: any, options?: RpcSessionOptions) => Stubify<T> =
97+
/**
98+
* Start a WebSocket session given either an already-open WebSocket or a URL.
99+
*
100+
* @param webSocket Either the `wss://` URL to connect to, or an already-open WebSocket object to
101+
* use.
102+
* @param localMain The main RPC interface to expose to the peer. Returns a stub for the main
103+
* interface exposed from the peer.
104+
*/
105+
export let newWebSocketRpcSession:<T extends Serializable<T> = Empty>
106+
(webSocket: WebSocket | string, localMain?: any, options?: RpcSessionOptions) => RpcStub<T> =
55107
<any>newWebSocketRpcSessionImpl;
56108

57-
export let newHttpBatchRpcSession:
58-
<T extends Serializable<T> = Empty>
59-
(urlOrRequest: string | Request, init?: RequestInit) => Stubify<T> =
109+
/**
110+
* Initiate an HTTP batch session from the client side.
111+
*
112+
* The parameters to this method have exactly the same signature as `fetch()`, but the return
113+
* value is an RpcStub. You can customize anything about the request except for the method
114+
* (it will always be set to POST) and the body (which the RPC system will fill in).
115+
*/
116+
export let newHttpBatchRpcSession:<T extends Serializable<T>>
117+
(urlOrRequest: string | Request, init?: RequestInit) => RpcStub<T> =
60118
<any>newHttpBatchRpcSessionImpl;
61119

62-
export let newMessagePortRpcSession:
63-
<T extends Serializable<T> = Empty>
64-
(port: MessagePort, localMain?: any, options?: RpcSessionOptions) => Stubify<T> =
120+
/**
121+
* Initiate an RPC session over a MessagePort, which is particularly useful for communicating
122+
* between an iframe and its parent frame in a browser context. Each side should call this function
123+
* on its own end of the MessageChannel.
124+
*/
125+
export let newMessagePortRpcSession:<T extends Serializable<T> = Empty>
126+
(port: MessagePort, localMain?: any, options?: RpcSessionOptions) => RpcStub<T> =
65127
<any>newMessagePortRpcSessionImpl;
66128

67-
// Implements inified handling of HTTP-batch and WebSocket responses for the Workers Runtime.
68-
export function newWorkersRpcResponse(request: Request, localMain: any) {
129+
/**
130+
* Implements unified handling of HTTP-batch and WebSocket responses for the Cloudflare Workers
131+
* Runtime.
132+
*
133+
* SECURITY WARNING: This function accepts cross-origin requests. If you do not want this, you
134+
* should validate the `Origin` header before calling this, or use `newHttpBatchRpcSession()` and
135+
* `newWebSocketRpcSession()` directly with appropriate security measures for each type of request.
136+
* But if your API uses in-band authorization (i.e. it has an RPC method that takes the user's
137+
* credentials as parameters and returns the authorized API), then cross-origin requests should
138+
* be safe.
139+
*/
140+
export async function newWorkersRpcResponse(request: Request, localMain: any) {
69141
if (request.method === "POST") {
70-
return newHttpBatchRpcResponse(request, localMain);
142+
let response = await newHttpBatchRpcResponse(request, localMain);
143+
// Since we're exposing the same API over WebSocket, too, and WebScoket always allows
144+
// cross-origin requests, the API necessarily must be safe for cross-origin use (e.g. because
145+
// it uses in-band authorization, as recommended in the readme). So, we might as well allow
146+
// batch requests to be made cross-origin as well.
147+
response.headers.set("Access-Control-Allow-Origin", "*");
148+
return response;
71149
} else if (request.headers.get("Upgrade")?.toLowerCase() === "websocket") {
72150
return newWorkersWebSocketRpcResponse(request, localMain);
73151
} else {

0 commit comments

Comments
 (0)