Skip to content

[PM-16908] Make WASM BitwardenClient API async #173

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/build-wasm-internal.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ jobs:
registry-url: "https://npm.pkg.github.com"
cache: "npm"

- name: NPM setup
run: npm ci

- name: Install dependencies
run: npm i -g binaryen

Expand Down
3 changes: 3 additions & 0 deletions crates/bitwarden-wasm-internal/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ wasm-opt -Os ./crates/bitwarden-wasm-internal/npm/node/bitwarden_wasm_internal_b
# Transpile to JS
wasm2js -Os ./crates/bitwarden-wasm-internal/npm/bitwarden_wasm_internal_bg.wasm -o ./crates/bitwarden-wasm-internal/npm/bitwarden_wasm_internal_bg.wasm.js
npx terser ./crates/bitwarden-wasm-internal/npm/bitwarden_wasm_internal_bg.wasm.js -o ./crates/bitwarden-wasm-internal/npm/bitwarden_wasm_internal_bg.wasm.js

# Rewrite the generated types to use async for the client functions and generate the subclient map
node ./crates/bitwarden-wasm-internal/rewrite_wasm_types.js
160 changes: 160 additions & 0 deletions crates/bitwarden-wasm-internal/rewrite_wasm_types.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// @ts-check
const fs = require("fs");
const path = require("path");
const ts = require("typescript");
const prettier = require("prettier");

// The root SDK client, and the main entry point for the remote IPC-based client
const ROOT_CLIENT = "BitwardenClient";

const SKIP_METHODS = [
// This methods is generated by the `wasm-bindgen` macro and is not async
"free",
];

// Read the types definition file and create an AST
const jsFilename = path.resolve(__dirname, "npm/bitwarden_wasm_internal_bg.js");
const tsFilename = path.resolve(__dirname, "npm/bitwarden_wasm_internal.d.ts");
const jsCode = fs.readFileSync(jsFilename, "utf-8");
const tsCode = fs.readFileSync(tsFilename, "utf-8");
const ast = ts.createSourceFile(tsFilename, tsCode, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);

// First collect all the classes, to later check if any methods return classes that we define

/** @type {Set<string>} */
const allClasses = new Set();

ast.forEachChild((child) => {
if (ts.isClassDeclaration(child) && child.name) {
allClasses.add(child.name.text);
}
});

// Then create the transitions table and validate that all functions are async.
// We use the transitions table to create the list of subclients used by the SDK,
// and we keep track of the functions that are sync to mark them as async later.

/** @type {Record<string, Record<string, string>>} */
const allTransitions = {};

/** @type {{className: string, methodName: string}[]} */
const syncMethods = [];

ast.forEachChild((child) => {
if (ts.isClassDeclaration(child) && child.name) {
const className = child.name.text;
child.members.forEach((member) => {
if (ts.isMethodDeclaration(member) && ts.isIdentifier(member.name)) {
const methodName = member.name.text;
if (SKIP_METHODS.includes(methodName)) {
return;
}

// Check if the return type is a reference type (class/promise)
if (
member.type &&
ts.isTypeReferenceNode(member.type) &&
ts.isIdentifier(member.type.typeName)
) {
const returnType = member.type.typeName.text;
// If it's a Promise, return early so it's not added to the syncMethods list.
if (returnType === "Promise") {
return;
}

// If it's a class that we define, add it to the transitions table.
if (allClasses.has(returnType)) {
allTransitions[className] ??= {};
allTransitions[className][returnType] = methodName;
return;
}
}

// Check if the method is using the async keyword
if (!member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)) {
syncMethods.push({ className, methodName });
}
}
});
}
});

// Generate the sub-clients table by following all the transitions from the root client.
// Also keep track of all the clients that are seen, as we don't want to mark all methods as async, only the ones in the sub-clients.
/**
* @param {string} clientName
* @param {Record<string, any>} output
* @param {Set<string>} seenClients
*/
function addSubClients(clientName, output, seenClients) {
seenClients.add(clientName);
for (const [subClient, func] of Object.entries(allTransitions[clientName] ?? {})) {
seenClients.add(subClient);
output[func] ??= {};
addSubClients(subClient, output[func], seenClients);
}
}
const subClients = {};
const seenClients = new Set();
addSubClients(ROOT_CLIENT, subClients, seenClients);

// Rewrite the .d.ts file to mark all the sync methods as async
const visitor = (/** @type {ts.Node} */ member) => {
if (ts.isMethodDeclaration(member) && ts.isIdentifier(member.name)) {
if (ts.isClassDeclaration(member.parent)) {
const methodName = member.name.text;
const className = member.parent.name?.text;
if (
member.type &&
seenClients.has(className) &&
syncMethods.some((m) => m.className === className && m.methodName === methodName)
) {
const promiseType = ts.factory.createTypeReferenceNode("Promise", [member.type]);
return ts.factory.updateMethodDeclaration(
member,
member.modifiers,
member.asteriskToken,
member.name,
member.questionToken,
member.typeParameters,
member.parameters,
promiseType,
member.body,
);
}
}
}
return ts.visitEachChild(member, visitor, undefined);
};

const modified = ts.visitNode(ast, visitor);
if (!ts.isSourceFile(modified)) {
throw new Error("Modified AST is not a source file");
}
const result = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }).printFile(modified);
prettier
.format(result, {
parser: "typescript",
tabWidth: 2,
useTabs: false,
})
.then((formatted) => {
fs.writeFileSync(tsFilename, formatted, "utf-8");
});

// Save the sub-clients table to the types file
const SEPARATOR = "/* The following code is generated by the rewrite_wasm_types.js script */";
fs.writeFileSync(
jsFilename,
`${jsCode.split(SEPARATOR)[0]}
${SEPARATOR}
export const SUB_CLIENT_METHODS = ${JSON.stringify(subClients, null, 2)};
`,
);
fs.writeFileSync(
tsFilename,
`${tsCode.split(SEPARATOR)[0]}
${SEPARATOR}
export declare const SUB_CLIENT_METHODS: Record<string, any>;
`,
);
16 changes: 15 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "2.15.3",
"prettier": "3.4.2"
"prettier": "3.4.2",
"typescript": "^5.7.3"
}
}
Loading