diff --git a/.github/workflows/build-wasm-internal.yml b/.github/workflows/build-wasm-internal.yml index 2456b2c22..994ffb4ae 100644 --- a/.github/workflows/build-wasm-internal.yml +++ b/.github/workflows/build-wasm-internal.yml @@ -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 diff --git a/crates/bitwarden-wasm-internal/build.sh b/crates/bitwarden-wasm-internal/build.sh index 64f458431..08f099872 100755 --- a/crates/bitwarden-wasm-internal/build.sh +++ b/crates/bitwarden-wasm-internal/build.sh @@ -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 diff --git a/crates/bitwarden-wasm-internal/rewrite_wasm_types.js b/crates/bitwarden-wasm-internal/rewrite_wasm_types.js new file mode 100644 index 000000000..7530918a0 --- /dev/null +++ b/crates/bitwarden-wasm-internal/rewrite_wasm_types.js @@ -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} */ +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>} */ +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} output + * @param {Set} 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; +`, +); diff --git a/package-lock.json b/package-lock.json index 0ba861b41..c55f8a893 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "SEE LICENSE IN LICENSE", "devDependencies": { "@openapitools/openapi-generator-cli": "2.15.3", - "prettier": "3.4.2" + "prettier": "3.4.2", + "typescript": "^5.7.3" } }, "node_modules/@babel/runtime": { @@ -1879,6 +1880,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uid": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", diff --git a/package.json b/package.json index bc29366ce..4e58a7ae9 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ }, "devDependencies": { "@openapitools/openapi-generator-cli": "2.15.3", - "prettier": "3.4.2" + "prettier": "3.4.2", + "typescript": "^5.7.3" } }