1
0
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:
Andreas Coroiu
2025-10-31 16:03:12 +01:00
parent fa4a264011
commit 2692023851
3 changed files with 269 additions and 0 deletions

View 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;
}
}

View 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,
},
};
}

View File

@@ -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);
}