mirror of
https://github.com/bitwarden/browser
synced 2026-02-06 11:43:51 +00:00
feat: implement batch executor
This commit is contained in:
176
libs/common/src/platform/services/sdk/rpc/batch-executor.spec.ts
Normal file
176
libs/common/src/platform/services/sdk/rpc/batch-executor.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { executeBatchCommands } from "./batch-executor";
|
||||
import { RpcError } from "./error";
|
||||
import { BatchCommand, serializeSymbol } from "./protocol";
|
||||
import { ReferenceStore } from "./reference-store";
|
||||
|
||||
describe("Batch executor", () => {
|
||||
let target: TestTarget;
|
||||
let referenceStore: ReferenceStore;
|
||||
|
||||
beforeEach(() => {
|
||||
target = new TestTarget();
|
||||
referenceStore = new ReferenceStore();
|
||||
});
|
||||
|
||||
it("returns error when command list is empty", async () => {
|
||||
const commands: BatchCommand[] = [];
|
||||
|
||||
const response = await executeBatchCommands(target, commands, referenceStore);
|
||||
expect(response).toEqual({ status: "error", error: expect.any(RpcError) });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["propString", "test"],
|
||||
["propNumber", 42],
|
||||
["propBoolean", true],
|
||||
["propNull", null],
|
||||
["propUndefined", undefined],
|
||||
])("returns value of property when value is a primitive", async (propertyName, expectedValue) => {
|
||||
const commands: BatchCommand[] = [{ method: "get", propertyName }];
|
||||
|
||||
const response = await executeBatchCommands(target, commands, referenceStore);
|
||||
|
||||
expect(response).toEqual({
|
||||
status: "success",
|
||||
result: {
|
||||
type: "value",
|
||||
value: expectedValue,
|
||||
},
|
||||
});
|
||||
expect(referenceStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns reference of property when value is an object", async () => {
|
||||
const commands: BatchCommand[] = [{ method: "get", propertyName: "propObject" }];
|
||||
|
||||
const response = await executeBatchCommands(target, commands, referenceStore);
|
||||
expect(response).toEqual({
|
||||
status: "success",
|
||||
result: {
|
||||
type: "reference",
|
||||
referenceId: 1,
|
||||
objectType: "TestObject",
|
||||
},
|
||||
});
|
||||
expect(referenceStore.size).toBe(1);
|
||||
expect(referenceStore.get<object>(1)).toEqual(new TestObject("example"));
|
||||
});
|
||||
|
||||
it("returns value of property when transfer is explicitly requested", async () => {
|
||||
const commands: BatchCommand[] = [
|
||||
{ method: "get", propertyName: "propObject" },
|
||||
{ method: "transfer" },
|
||||
];
|
||||
|
||||
const response = await executeBatchCommands(target, commands, referenceStore);
|
||||
expect(response).toEqual({
|
||||
status: "success",
|
||||
result: {
|
||||
type: "value",
|
||||
value: new TestObject("example"),
|
||||
},
|
||||
});
|
||||
expect(referenceStore.size).toBe(0);
|
||||
});
|
||||
|
||||
it("returns function result when calling a method", async () => {
|
||||
const commands: BatchCommand[] = [
|
||||
{ method: "get", propertyName: "getTestObject" },
|
||||
{ method: "apply", args: ["arg"] },
|
||||
];
|
||||
|
||||
const response = await executeBatchCommands(target, commands, referenceStore);
|
||||
expect(response).toEqual({
|
||||
status: "success",
|
||||
result: {
|
||||
type: "reference",
|
||||
referenceId: 1,
|
||||
objectType: "TestObject",
|
||||
},
|
||||
});
|
||||
expect(referenceStore.size).toBe(1);
|
||||
expect(referenceStore.get<object>(1)).toEqual(new TestObject("arg"));
|
||||
});
|
||||
|
||||
it("returns function result when fetch and call is separate", async () => {
|
||||
const fetchCommands: BatchCommand[] = [{ method: "get", propertyName: "getPropString" }];
|
||||
const callCommands: BatchCommand[] = [{ method: "apply", args: [] }];
|
||||
|
||||
const response = await executeBatchCommands(target, fetchCommands, referenceStore);
|
||||
expect(response).toEqual({
|
||||
status: "success",
|
||||
result: {
|
||||
type: "reference",
|
||||
referenceId: 1,
|
||||
objectType: "Function",
|
||||
},
|
||||
});
|
||||
expect(referenceStore.size).toBe(1);
|
||||
|
||||
if (response.status === "error" || response.result.type !== "reference") {
|
||||
throw new Error("Unexpected response");
|
||||
}
|
||||
|
||||
const functionRefId = response.result.referenceId;
|
||||
const fun = referenceStore.get<TestTarget["getTestObject"]>(functionRefId);
|
||||
const callResponse = await executeBatchCommands(fun!, callCommands, referenceStore);
|
||||
|
||||
expect(callResponse).toEqual({
|
||||
status: "success",
|
||||
result: {
|
||||
type: "value",
|
||||
value: "test",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("calls method fetched using symbol property", async () => {
|
||||
const commands: BatchCommand[] = [
|
||||
{ method: "get", propertySymbol: serializeSymbol(Symbol.dispose) },
|
||||
{ method: "apply", args: [] },
|
||||
];
|
||||
|
||||
const response = await executeBatchCommands(target, commands, referenceStore);
|
||||
expect(response).toEqual({
|
||||
status: "success",
|
||||
result: {
|
||||
type: "value",
|
||||
value: undefined,
|
||||
},
|
||||
});
|
||||
expect(target.dispose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
class TestTarget {
|
||||
dispose = jest.fn();
|
||||
|
||||
propString: string = "test";
|
||||
propNumber: number = 42;
|
||||
propBoolean: boolean = true;
|
||||
propNull: null = null;
|
||||
propUndefined: undefined = undefined;
|
||||
propObject = new TestObject("example");
|
||||
|
||||
[Symbol.dispose] = this.dispose;
|
||||
|
||||
get child() {
|
||||
return new TestTarget();
|
||||
}
|
||||
|
||||
getTestObject(name: string) {
|
||||
return new TestObject(name);
|
||||
}
|
||||
|
||||
getPropString() {
|
||||
return this.propString;
|
||||
}
|
||||
}
|
||||
|
||||
class TestObject {
|
||||
constructor(public name: string) {}
|
||||
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
}
|
||||
89
libs/common/src/platform/services/sdk/rpc/batch-executor.ts
Normal file
89
libs/common/src/platform/services/sdk/rpc/batch-executor.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { RpcError } from "./error";
|
||||
import { BatchCommand, deserializeSymbol, Response } from "./protocol";
|
||||
import { ReferenceStore } from "./reference-store";
|
||||
|
||||
const PRIMITIVE_TYPES = ["string", "number", "boolean", "undefined"];
|
||||
|
||||
/**
|
||||
* Executes a batch of commands on the target object.
|
||||
*
|
||||
* The response depends on the return type of the last command in the batch.
|
||||
|
||||
* - Return-by-value will be used if:
|
||||
* - The last command returns a primitive value (string, number, boolean, null, undefined).
|
||||
* - The last command was explicitly a 'transfer' command.
|
||||
* - Return-by-reference will be used for everything else.
|
||||
*
|
||||
* @param target The target object to execute commands on.
|
||||
* @param commands The array of commands to execute.
|
||||
* @returns A promise that resolves to the response of the batch execution.
|
||||
*/
|
||||
export async function executeBatchCommands(
|
||||
target: any,
|
||||
commands: BatchCommand[],
|
||||
referenceStore: ReferenceStore,
|
||||
): Promise<Response> {
|
||||
if (commands.length === 0) {
|
||||
return {
|
||||
status: "error",
|
||||
error: new RpcError("Empty batch command list is not allowed."),
|
||||
};
|
||||
}
|
||||
|
||||
let currentTarget = target;
|
||||
let lastResult: any;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const command = commands[i];
|
||||
|
||||
if (command.method === "get" && "propertyName" in command) {
|
||||
lastResult = await currentTarget[command.propertyName];
|
||||
|
||||
if (typeof lastResult === "function") {
|
||||
lastResult = lastResult.bind(currentTarget);
|
||||
}
|
||||
|
||||
currentTarget = lastResult;
|
||||
} else if (command.method === "get" && "propertySymbol" in command) {
|
||||
const symbol = deserializeSymbol(command.propertySymbol);
|
||||
lastResult = await currentTarget[symbol];
|
||||
|
||||
if (typeof lastResult === "function") {
|
||||
lastResult = lastResult.bind(currentTarget);
|
||||
}
|
||||
|
||||
currentTarget = lastResult;
|
||||
} else if (command.method === "apply") {
|
||||
lastResult = await currentTarget(...command.args);
|
||||
currentTarget = lastResult;
|
||||
} else if (command.method === "transfer") {
|
||||
// For transfer, we just mark the last result as transferable.
|
||||
// Actual transfer logic would depend on the RPC implementation.
|
||||
lastResult = currentTarget;
|
||||
break;
|
||||
} else {
|
||||
throw new Error(`Unsupported command method: ${(command as any).method}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
return { status: "error", error };
|
||||
}
|
||||
|
||||
if (PRIMITIVE_TYPES.includes(typeof lastResult) || lastResult === null) {
|
||||
return { status: "success", result: { type: "value", value: lastResult } };
|
||||
}
|
||||
|
||||
if (commands[commands.length - 1].method === "transfer") {
|
||||
return { status: "success", result: { type: "value", value: lastResult } };
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
result: {
|
||||
type: "reference",
|
||||
referenceId: referenceStore.store(lastResult),
|
||||
objectType: lastResult?.constructor?.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ export class ReferenceStore {
|
||||
private _store = new Map<ReferenceId, any>();
|
||||
private _nextId = 1;
|
||||
|
||||
get size(): number {
|
||||
return this._store.size;
|
||||
}
|
||||
|
||||
get<T>(id: number): T | undefined {
|
||||
return this._store.get(id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user