1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-31 00:33:33 +00:00

feat: add support for symbols

This commit is contained in:
Andreas Coroiu
2025-10-27 16:11:47 +01:00
parent eed8631730
commit 1bd0ceee1e
4 changed files with 156 additions and 63 deletions

View File

@@ -1,8 +1,18 @@
export type ReferenceId = number;
export type PropertySymbol = keyof typeof PropertySymbolMap;
export type SerializedPropertySymbol = (typeof PropertySymbolMap)[keyof typeof PropertySymbolMap];
export type Command =
| { method: "get"; referenceId: ReferenceId; propertyName: string }
| { method: "get"; referenceId: ReferenceId; propertySymbol: SerializedPropertySymbol }
| { method: "call"; referenceId: ReferenceId; propertyName: string; args: unknown[] }
| {
method: "call";
referenceId: ReferenceId;
propertySymbol: SerializedPropertySymbol;
args: unknown[];
}
| { method: "release"; referenceId: ReferenceId };
export type Response = { status: "success"; result: Result } | { status: "error"; error: unknown };
@@ -10,3 +20,33 @@ export type Response = { status: "success"; result: Result } | { status: "error"
export type Result =
| { type: "value"; value: unknown }
| { type: "reference"; referenceId: ReferenceId; objectType?: string };
// | { type: "rc", referenceId: ReferenceId, objectType?: string };
// A list of supported property symbols and their wire names
const PropertySymbolMap = {
[Symbol.dispose]: "dispose",
} as const;
// Build reverse lookup that includes symbol keys (Object.entries omits symbols)
const SymbolToString = new Map<symbol, SerializedPropertySymbol>([
[Symbol.dispose, PropertySymbolMap[Symbol.dispose]],
]);
const StringToSymbol = new Map<SerializedPropertySymbol, symbol>([
[PropertySymbolMap[Symbol.dispose], Symbol.dispose],
]);
export function deserializeSymbol(name: SerializedPropertySymbol): symbol {
const sym = StringToSymbol.get(name as SerializedPropertySymbol);
if (!sym) {
throw new Error(`Unsupported property symbol: ${name}. This property cannot be used over RPC.`);
}
return sym;
}
export function serializeSymbol(symbol: PropertySymbol): SerializedPropertySymbol {
const value = SymbolToString.get(symbol as unknown as symbol);
if (!value) {
throw new Error(`Unsupported serialized property symbol: ${String(symbol)}`);
}
return value;
}

View File

@@ -1,5 +1,5 @@
import { RpcRequestChannel } from "./client";
import { Command, ReferenceId } from "./protocol";
import { Command, PropertySymbol, ReferenceId, Response, serializeSymbol } from "./protocol";
/**
* A reference to a remote object.
@@ -24,14 +24,14 @@ function ProxiedReference(
reference: RpcObjectReference,
): RpcObjectReference {
return new Proxy(reference, {
get(target, propertyName: string) {
if (propertyName === "then") {
get(target, property: string | PropertySymbol) {
if (property === "then") {
// Allow awaiting the proxy itself
return undefined;
}
// console.log(`Accessing ${reference.objectType}.${propertyName}`);
return RpcPropertyReference(channel, { objectReference: target, propertyName });
// console.log(`Accessing ${reference.objectType}.${String(propertyName)}`);
return RpcPropertyReference(channel, { objectReference: target, property });
},
});
}
@@ -41,7 +41,7 @@ function ProxiedReference(
*/
type RpcPropertyReference = {
objectReference: RpcObjectReference;
propertyName: string;
property: string | PropertySymbol;
};
/**
@@ -77,8 +77,8 @@ type RpcPropertyReference = {
function RpcPropertyReference(channel: RpcRequestChannel, reference: RpcPropertyReference) {
const target = () => {};
Object.defineProperty(target, "name", { value: `RpcPropertyReference`, configurable: true });
target.objectReference = reference.objectReference;
target.propertyName = reference.propertyName;
(target as any).objectReference = reference.objectReference;
(target as any).property = reference.property;
return new Proxy(target, {
get(_target, propertyName: string) {
@@ -86,67 +86,92 @@ function RpcPropertyReference(channel: RpcRequestChannel, reference: RpcProperty
// `Accessing ${reference.objectReference.objectType}.${reference.propertyName}.${propertyName}`,
// );
// Allow Function.prototype.call to be used by TS helpers (e.g., disposables)
if (propertyName === "call") {
return undefined;
return Function.prototype.call;
}
if (propertyName !== "then") {
throw new Error(`Cannot access property '${propertyName}' on remote proxy synchronously`);
// Support chained call like: (await obj.prop).method() AND obj.prop.method()
// by lazily resolving obj.prop first, then invoking method/property on the resolved reference.
return (...argArray: unknown[]) => {
return (async () => {
// First resolve the original referenced property value via GET
const getResult = await sendAndUnwrap(channel, buildGetCommand(reference));
if (getResult.type !== "reference") {
throw new Error(
`Cannot access property '${propertyName}' on non-reference value returned by remote property`,
);
}
// Now perform the requested operation on the resolved reference
const callResult = await sendAndUnwrap(
channel,
buildCallCommand(getResult.referenceId, propertyName, argArray),
);
return unwrapResult(channel, callResult);
})();
};
}
return (onFulfilled: (value: any) => void, onRejected: (error: any) => void) => {
(async () => {
// Handle property access
const command: Command = {
method: "get",
referenceId: reference.objectReference.referenceId,
propertyName: reference.propertyName,
};
// Send the command over the channel
const result = await channel.sendCommand(command);
if (result.status === "error") {
throw new Error(`RPC Error: ${result.error}`);
}
if (result.result.type === "value") {
return result.result.value;
} else if (result.result.type === "reference") {
return RpcObjectReference.create(
channel,
result.result.referenceId,
result.result.objectType,
);
}
const result = await sendAndUnwrap(channel, buildGetCommand(reference));
return unwrapResult(channel, result);
})().then(onFulfilled, onRejected);
};
},
apply(_target, _thisArg, argArray: unknown[]) {
// console.log(`Calling ${reference.objectReference.objectType}.${reference.propertyName}`);
// Handle method call
const command: Command = {
method: "call",
referenceId: reference.objectReference.referenceId,
propertyName: reference.propertyName,
args: argArray,
};
return channel.sendCommand(command).then((result) => {
if (result.status === "error") {
throw new Error(`RPC Error: ${result.error}`);
}
if (result.result.type === "value") {
return result.result.value;
} else if (result.result.type === "reference") {
return RpcObjectReference.create(
channel,
result.result.referenceId,
result.result.objectType,
);
}
});
const command = buildCallCommand(
reference.objectReference.referenceId,
reference.property,
argArray,
);
return sendAndUnwrap(channel, command).then((result) => unwrapResult(channel, result));
},
});
}
// Helpers
function buildGetCommand(reference: RpcPropertyReference): Command {
if (typeof reference.property === "string") {
return {
method: "get",
referenceId: reference.objectReference.referenceId,
propertyName: reference.property,
};
}
return {
method: "get",
referenceId: reference.objectReference.referenceId,
propertySymbol: serializeSymbol(reference.property),
};
}
function buildCallCommand(
referenceId: ReferenceId,
property: string | PropertySymbol,
args: unknown[],
): Command {
if (typeof property === "string") {
return { method: "call", referenceId, propertyName: property, args };
}
return { method: "call", referenceId, propertySymbol: serializeSymbol(property), args };
}
async function sendAndUnwrap(channel: RpcRequestChannel, command: Command) {
const response: Response = await channel.sendCommand(command);
if (response.status === "error") {
throw new Error(`RPC Error: ${response.error}`);
}
return response.result;
}
function unwrapResult(channel: RpcRequestChannel, result: any) {
if (result.type === "value") {
return result.value;
}
return RpcObjectReference.create(channel, result.referenceId, result.objectType);
}

View File

@@ -1,5 +1,7 @@
import { firstValueFrom, map, Observable } from "rxjs";
import { Rc } from "../../../misc/reference-counting/rc";
import { RpcClient, RpcRequestChannel } from "./client";
import { Command, Response } from "./protocol";
import { RpcObjectReference } from "./proxies";
@@ -61,6 +63,27 @@ describe("RpcServer", () => {
expect(result).not.toBeInstanceOf(RpcObjectReference);
expect(result).toEqual({ message: "Hello, World!", array: [1, "2", null] });
});
it("handles RC objects correctly", async () => {
const wasmObj = new WasmLikeObject("RC");
const rcValue = new Rc(wasmObj);
const server = new RpcServer<Rc<WasmLikeObject>>();
const client = new RpcClient<Rc<WasmLikeObject>>(new InMemoryChannel(server));
server.setValue(rcValue);
const remoteRc = await firstValueFrom(client.getRoot());
{
using ref = await remoteRc.take();
const remoteWasmObj = ref.value;
const greeting = await remoteWasmObj.greet();
expect(greeting).toBe("Hello, RC!");
expect(wasmObj.freed).toBe(false);
}
await remoteRc.markForDisposal();
expect(wasmObj.freed).toBe(true);
});
});
class TestClass {
@@ -85,13 +108,14 @@ class TestClass {
class WasmLikeObject {
ptr: number;
freed = false;
constructor(private name: string) {
this.ptr = 0; // Simulated pointer
}
free() {
// Simulated free method
this.freed = true;
}
async greet(): Promise<string> {
@@ -99,8 +123,8 @@ class WasmLikeObject {
}
}
class InMemoryChannel implements RpcRequestChannel {
constructor(private server: RpcServer<TestClass>) {}
class InMemoryChannel<T> implements RpcRequestChannel {
constructor(private server: RpcServer<T>) {}
async sendCommand(command: Command): Promise<Response> {
// Simulate serialization/deserialization

View File

@@ -1,6 +1,6 @@
import { map, Observable, ReplaySubject } from "rxjs";
import { Command, Response, Result } from "./protocol";
import { Command, deserializeSymbol, Response, Result } from "./protocol";
import { ReferenceStore } from "./reference-store";
export class RpcServer<T> {
@@ -23,9 +23,11 @@ export class RpcServer<T> {
}
try {
const propertyValue = target[command.propertyName];
const propertyKey =
(command as any).propertyName ?? deserializeSymbol((command as any).propertySymbol);
const propertyValue = target[propertyKey];
if (typeof propertyValue === "function") {
return { status: "error", error: `[RPC] Property ${command.propertyName} is a function` };
return { status: "error", error: `[RPC] Property ${String(propertyKey)} is a function` };
} else {
return { status: "success", result: this.convertToReturnable(propertyValue) };
}
@@ -41,11 +43,13 @@ export class RpcServer<T> {
}
try {
const method = target[command.propertyName];
const propertyKey =
(command as any).propertyName ?? deserializeSymbol((command as any).propertySymbol);
const method = target[propertyKey];
if (typeof method !== "function") {
return {
status: "error",
error: `[RPC] Property ${command.propertyName} is not a function`,
error: `[RPC] Property ${String(propertyKey)} is not a function`,
};
}