Skip to content

Commit f4275f5

Browse files
authored
Grab bag of issue fixes! (#105)
* Add changeset for past PR #82. * Throw an error when attempting to access RpcTarget instance properties. This helps people learn why instance properties are not accessible over RPC, whereas returning `undefined` leaves them confused. Fixes #55 * Implement toString() for RpcStub and RpcPromise. They just return `[object RpcStub]` and `[object RpcPromise]`, but that's better than the previous `[object Function]` which was confusing. As suggested in #55. * Fix type signature of newHttpBatchRpcSession(). An earlier version of the function was designed to have the same signature as `fetch()`, but when I added `RpcSessionOptions` it made more sense to make that the second param... but I only updated the implementation and forgot to update the type. Fixes #67. * Support serializing Infinity, -Infinity, and NaN. Fixes #80 * Polyfill Promise.withResolvers(). This hopefully improves compatibility with old Safari versions and Hermes (React Native). Unfortunately it didn't seem easy to extend the tests to cover React Native. There is a vitest-react-native package, but it looks little-used and unmaintained, so I didn't want to install it. Might help with #91, though I won't declare that one "fixed" until we actually have tests proving it. * Document missing expression types in protocol.md. Fixes #48
1 parent b2fcb34 commit f4275f5

File tree

9 files changed

+107
-6
lines changed

9 files changed

+107
-6
lines changed

.changeset/fast-rules-thank.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"capnweb": minor
3+
---
4+
5+
Fixed incompatibility with bundlers that don't support top-level await. The top-level await was used for a conditional import; it has been replaced with an approach based on "exports" in package.json instead.

.changeset/frank-drinks-sleep.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"capnweb": patch
3+
---
4+
5+
Attempting to remotely access an instance property of an RpcTarget will now throw an exception rather than returning `undefined`, in order to help people understand what went wrong.

.changeset/salty-trams-stop.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"capnweb": minor
3+
---
4+
5+
Support serializing Infinity, -Infinity, and NaN.

.changeset/six-banks-pull.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"capnweb": patch
3+
---
4+
5+
Polyfilled Promise.withResolvers() to improve compatibility with old Safari versions and Hermes (React Native).

__tests__/index.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ let SERIALIZE_TEST_CASES: Record<string, unknown> = {
2929
'["error","Error","the message"]': new Error("the message"),
3030
'["error","TypeError","the message"]': new TypeError("the message"),
3131
'["error","RangeError","the message"]': new RangeError("the message"),
32+
33+
'["inf"]': Infinity,
34+
'["-inf"]': -Infinity,
35+
'["nan"]': NaN,
3236
};
3337

3438
class NotSerializable {
@@ -305,8 +309,16 @@ describe("local stub", () => {
305309

306310
expect(await stub.prototypeProp).toBe("prototype");
307311
expect(await stub.prototypeMethod()).toBe("method");
308-
expect(await (stub as any).instanceProp).toBe(undefined);
309-
expect(await (stub as any).dynamicProp).toBe(undefined);
312+
await expect(() => (stub as any).instanceProp).rejects.toThrow(new TypeError(
313+
"Attempted to access property 'instanceProp', which is an instance property of the " +
314+
"RpcTarget. To avoid leaking private internals, instance properties cannot be accessed " +
315+
"over RPC. If you want to make this property available over RPC, define it as a method " +
316+
"or getter on the class, instead of an instance property."));
317+
await expect(() => (stub as any).dynamicProp).rejects.toThrow(new TypeError(
318+
"Attempted to access property 'dynamicProp', which is an instance property of the " +
319+
"RpcTarget. To avoid leaking private internals, instance properties cannot be accessed " +
320+
"over RPC. If you want to make this property available over RPC, define it as a method " +
321+
"or getter on the class, instead of an instance property."));
310322
});
311323

312324
it("does not expose private methods starting with #", async () => {
@@ -387,6 +399,13 @@ describe("local stub", () => {
387399
[0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144]],
388400
]);
389401
});
402+
403+
it("overrides toString() to at least specify the type", async () => {
404+
let stub = new RpcStub(new TestTarget());
405+
expect(stub.toString()).toBe("[object RpcStub]");
406+
let promise = stub.square(3);
407+
expect(promise.toString()).toBe("[object RpcPromise]");
408+
});
390409
});
391410

392411
describe("stub disposal", () => {

protocol.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,22 @@ This expression will evaluate to the following object:
130130
}
131131
```
132132

133+
`["undefined"]`
134+
135+
The literal value `undefined`.
136+
137+
`["inf"]`, `["-inf"]`, `["nan"]`
138+
139+
The values Infinity, -Infinity, and NaN.
140+
141+
`["bytes", base64]`
142+
143+
A `Uint8Array`, represented as a base64-encoded string.
144+
145+
`["bigint", decimal]`
146+
147+
A bigint value, represented as a decimal string.
148+
133149
`["date", number]`
134150

135151
A JavaScript `Date` value. The number represents milliseconds since the Unix epoch.

src/core.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,20 @@ if (!Symbol.asyncDispose) {
1313
(Symbol as any).asyncDispose = Symbol.for('asyncDispose');
1414
}
1515

16+
// Polyfill Promise.withResolvers() for old Safari versions (ugh), Hermes (React Native), and
17+
// maybe others.
18+
if (!Promise.withResolvers) {
19+
Promise.withResolvers = function<T>(): PromiseWithResolvers<T> {
20+
let resolve: (value: T | PromiseLike<T>) => void;
21+
let reject: (reason?: any) => void;
22+
const promise = new Promise<T>((res, rej) => {
23+
resolve = res;
24+
reject = rej;
25+
});
26+
return { promise, resolve: resolve!, reject: reject! };
27+
};
28+
}
29+
1630
let workersModule: any = (globalThis as any)[WORKERS_MODULE_SYMBOL];
1731

1832
export interface RpcTarget {
@@ -426,6 +440,10 @@ export class RpcStub extends RpcTarget {
426440
let {hook, pathIfPromise} = this[RAW_STUB];
427441
return mapImpl.sendMap(hook, pathIfPromise || [], func);
428442
}
443+
444+
toString() {
445+
return "[object RpcStub]";
446+
}
429447
}
430448

431449
export class RpcPromise extends RpcStub {
@@ -447,6 +465,10 @@ export class RpcPromise extends RpcStub {
447465
finally(onfinally?: (() => void) | undefined | null): Promise<unknown> {
448466
return pullPromise(this).finally(...arguments);
449467
}
468+
469+
toString() {
470+
return "[object RpcPromise]";
471+
}
450472
}
451473

452474
// Given a stub (still wrapped in a Proxy), extract the underlying `StubHook`.
@@ -1224,7 +1246,15 @@ function followPath(value: unknown, parent: object | undefined,
12241246
case "rpc-thenable": {
12251247
// Must be prototype property, and must NOT be inherited from `Object`.
12261248
if (Object.hasOwn(<object>value, part)) {
1227-
value = undefined;
1249+
// We throw an error in this case, rather than return undefined, because otherwise
1250+
// people tend to get confused about this. If you don't want it to be possible to
1251+
// probe the existence of your instance properties, make them properly private (prefix
1252+
// with #).
1253+
throw new TypeError(
1254+
`Attempted to access property '${part}', which is an instance property of the ` +
1255+
`RpcTarget. To avoid leaking private internals, instance properties cannot be ` +
1256+
`accessed over RPC. If you want to make this property available over RPC, define ` +
1257+
`it as a method or getter on the class, instead of an instance property.`);
12281258
} else {
12291259
value = (<any>value)[part];
12301260
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ export let newWebSocketRpcSession:<T extends Serializable<T> = Empty>
118118
* (it will always be set to POST) and the body (which the RPC system will fill in).
119119
*/
120120
export let newHttpBatchRpcSession:<T extends Serializable<T>>
121-
(urlOrRequest: string | Request, init?: RequestInit) => RpcStub<T> =
121+
(urlOrRequest: string | Request, options?: RpcSessionOptions) => RpcStub<T> =
122122
<any>newHttpBatchRpcSessionImpl;
123123

124124
/**

src/serialize.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,18 @@ export class Devaluator {
114114
}
115115

116116
case "primitive":
117-
// Supported directly by JSON.
118-
return value;
117+
if (typeof value === "number" && !isFinite(value)) {
118+
if (value === Infinity) {
119+
return ["inf"];
120+
} else if (value === -Infinity) {
121+
return ["-inf"];
122+
} else {
123+
return ["nan"];
124+
}
125+
} else {
126+
// Supported directly by JSON.
127+
return value;
128+
}
119129

120130
case "object": {
121131
let object = <Record<string, unknown>>value;
@@ -347,6 +357,12 @@ export class Evaluator {
347357
return undefined;
348358
}
349359
break;
360+
case "inf":
361+
return Infinity;
362+
case "-inf":
363+
return -Infinity;
364+
case "nan":
365+
return NaN;
350366

351367
case "import":
352368
case "pipeline": {

0 commit comments

Comments
 (0)