diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dabeb75e57ed..b0fe2271190db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,6 +225,10 @@ jobs: - run: npx hereby test:tools - run: npx hereby test:api - run: npx hereby test:benchmarks:api + - run: npx playwright install --with-deps chromium + if: ${{ matrix.config.main }} + - run: npx hereby test:api:browser + if: ${{ matrix.config.main }} - run: git add . - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 if: ${{ always() && matrix.config.coverage && github.event_name != 'merge_group' }} diff --git a/.gitignore b/.gitignore index 198596e5aa56d..77f7d7dc8a66e 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ tsc/testdata/baselines/tmp /TEST-results.xml /*v8.log /lib/ +/packages/typescript-wasip1-wasm/lib/ /internal/ /scripts/eslint/built/ /scripts/debug.bat diff --git a/Herebyfile.mjs b/Herebyfile.mjs index d84265355b9a2..ed82cfdb7cf68 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1,6 +1,7 @@ // @ts-check import AdmZip from "adm-zip"; +import binaryen from "binaryen"; import chokidar from "chokidar"; import { task } from "hereby"; import assert from "node:assert"; @@ -1002,7 +1003,15 @@ async function runTestTools() { async function runTestAPI() { // Running the package script doesn't work on Windows; some path escaping isn't done correctly and the test runner runs no tests. - await run("node", ["--conditions", "@typescript/source", "--test", "./test/**/*.test.ts"], { cwd: "./packages/typescript" }); + await run( + "node", + ["--conditions", "@typescript/source", "--test", "./test/*.test.ts", "./test/async/**/*.test.ts", "./test/sync/**/*.test.ts"], + { cwd: "./packages/typescript" }, + ); +} + +async function runTestAPIBrowser() { + await run("node", ["--conditions", "@typescript/source", "--test", "./test/browser/**/*.test.ts"], { cwd: "./packages/typescript" }); } async function runTestAPIBenchmarks() { @@ -1036,13 +1045,97 @@ export const buildAPI = task({ async function runBuildAPITests(generateSources = true) { if (generateSources) await runGenerateSync(); + await runBuildWasi(); await run("npm", ["run", "-w", "@typescript/typescript", "build:test"]); } +/** + * @param {string} out + * @param {string[]} [extraFlags] + */ +async function buildWasiFile(out, extraFlags = []) { + out = path.resolve(out); + await fs.promises.mkdir(path.dirname(out), { recursive: true }); + await run("go", ["build", "-buildmode=c-shared", ...extraFlags, "-o", out, "./cmd/tsc"], { + cwd: "./tsc", + env: { GOOS: "wasip1", GOARCH: "wasm" }, + }); + patchWasiFile(out); +} + +/** + * @param {string} file + */ +function patchWasiFile(file) { + const module = binaryen.readBinary(fs.readFileSync(file)); + try { + module.setFeatures( + binaryen.Features.MutableGlobals + | binaryen.Features.NontrappingFPToInt + | binaryen.Features.BulkMemory + | binaryen.Features.BulkMemoryOpt + | binaryen.Features.SignExt, + ); + + const initialize = binaryen.getExportInfo(module.getExport("_initialize")).value; + const cliStart = binaryen.getExportInfo(module.getExport("__typescript_cli_start")).value; + // Go emits either a WASI command or reactor entrypoint, but the API binary needs both. + // Hide the standard reactor export from command hosts and synthesize a command entrypoint + // that initializes the runtime before entering the real Go CLI. + module.addFunction( + "__typescript_command_start", + binaryen.none, + binaryen.none, + [], + module.block(null, [ + module.call(initialize, [], binaryen.none), + module.call(cliStart, [], binaryen.none), + ]), + ); + module.removeExport("_initialize"); + module.removeExport("__typescript_cli_start"); + module.addFunctionExport(initialize, "typescript_initialize"); + module.addFunctionExport("__typescript_command_start", "_start"); + + if (!module.validate()) { + throw new Error("patched WebAssembly module is invalid"); + } + fs.writeFileSync(file, module.emitBinary()); + } + finally { + module.dispose(); + } +} + +/** + * @param {string[]} [extraFlags] + */ +async function runBuildWasi(extraFlags = []) { + const packageDir = "./packages/typescript-wasip1-wasm"; + await run("npm", ["run", "-w", "@typescript/typescript-wasip1-wasm", "build:js"]); + await buildWasiFile(path.join(packageDir, "lib", "tsc.wasm"), extraFlags); + const libDir = path.join(packageDir, "lib"); + await generateLibs(libDir); + const libFiles = (await fs.promises.readdir(libDir)) + .filter(file => file === "lib.d.ts" || file.startsWith("lib.") && file.endsWith(".d.ts")) + .sort(); + await fs.promises.writeFile( + path.join(libDir, "libFiles.json"), + JSON.stringify(libFiles, undefined, 4) + "\n", + ); +} + +export const buildWasi = task({ + name: "build:wasip1", + description: "Builds the @typescript/typescript-wasip1-wasm package.", + dependencies: [lib], + run: runBuildWasi, +}); + export const buildAPITests = task({ name: "build:api:test", description: "Builds the @typescript/typescript JS API tests.", - dependencies: [generateEnums, generateAPI], + dependencies: [generateEnums, generateAPI, lib], run: runBuildAPITests, }); @@ -1060,6 +1153,13 @@ export const testAPIBenchmarks = task({ run: runTestAPIBenchmarks, }); +export const testAPIBrowser = task({ + name: "test:api:browser", + description: "Runs the @typescript/typescript browser API tests.", + dependencies: [buildAPITests], + run: runTestAPIBrowser, +}); + export const testAll = task({ name: "test:all", description: "Runs compiler, extension, benchmark, tools, and API tests. Codegen tests are opt-in via test:codegen.", @@ -1072,6 +1172,7 @@ export const testAll = task({ await runTestTools(); await runTestAPI(); await runTestAPIBenchmarks(); + await runTestAPIBrowser(); }, }); @@ -1912,6 +2013,12 @@ const mainNativePreviewPackage = { npmTarball: path.join(builtNpm, publishAsTypescript ? "typescript.tgz" : "native-preview.tgz"), }; +const wasip1Package = { + npmPackageName: "@typescript/typescript-wasip1-wasm", + npmDir: path.join(builtNpm, "typescript-wasip1-wasm"), + npmTarball: path.join(builtNpm, "typescript-wasip1-wasm.tgz"), +}; + const typescriptMacEntitlements = [ "com.apple.security.cs.allow-dyld-environment-variables", "com.apple.security.cs.disable-library-validation", @@ -1935,7 +2042,7 @@ ${entries} * @typedef {"Microsoft400" | "LinuxSign" | "MacDeveloperHarden" | "8020" | "VSCodePublisher"} Cert * @typedef {`${OS | "alpine"}-${Exclude | "armhf"}`} VSCodeTarget * @typedef {{ name: string; sourceDir: string }} VsixExtensionPackage - * @typedef {{ nodeOs: string; vscodeTarget: string; sourceDir: string; extensionDir: string; vsixPath: string; vsixManifestPath: string; vsixSignaturePath: string }} VsixExtension + * @typedef {{ nodeOs?: string; vscodeTarget: VSCodeTarget | "web"; sourceDir: string; extensionDir: string; vsixPath: string; vsixManifestPath: string; vsixSignaturePath: string }} VsixExtension * @typedef {{ GOOS: string; GOARCH: string }} GoDistTarget * @typedef {{ os: OS; arch: Arch; cert?: Cert; vsix?: boolean; alpine?: boolean }} Platform */ @@ -1947,6 +2054,36 @@ const vsixExtensionPackages = [ ...(produceTypeScriptNightlyVsix ? [{ name: "vscode-typescript-nightly", sourceDir: nightlyExtensionDir }] : []), ]; +/** + * @param {string} packageName + * @param {string} sourceDir + * @param {VSCodeTarget | "web"} vscodeTarget + * @param {string} [nodeOs] + * @returns {VsixExtension} + */ +function createVsixExtension(packageName, sourceDir, vscodeTarget, nodeOs) { + const extensionDir = path.join(builtVsix, `${packageName}-${vscodeTarget}`); + return { + nodeOs, + vscodeTarget, + sourceDir, + extensionDir, + vsixPath: extensionDir + ".vsix", + vsixManifestPath: extensionDir + ".manifest", + vsixSignaturePath: extensionDir + ".signature.p7s", + }; +} + +function getLocalWebVsixExtensions() { + if (options.forRelease) { + return []; + } + return [ + createVsixExtension("native-preview", extensionDir, "web"), + createVsixExtension("vscode-typescript-nightly", nightlyExtensionDir, "web"), + ]; +} + /** * npm package platforms supported by the native release. * The native-preview package publishes only the entries with vsix: true; @@ -2070,29 +2207,13 @@ const getPlatforms = memoize(() => { /** @type {VsixExtension[]} */ let extensions = []; if (produceAnyVsix && vsix) { - /** @type {string[]} */ + /** @type {VSCodeTarget[]} */ const vscodeTargets = [`${os}-${arch === "arm" ? "armhf" : arch}`]; if (alpine) { vscodeTargets.push(`alpine-${arch === "arm" ? "armhf" : arch}`); } - extensions = vscodeTargets.flatMap(vscodeTarget => - vsixExtensionPackages.map(({ name: packageName, sourceDir }) => { - const extensionDir = path.join(builtVsix, `${packageName}-${vscodeTarget}`); - const vsixPath = extensionDir + ".vsix"; - const vsixManifestPath = extensionDir + ".manifest"; - const vsixSignaturePath = extensionDir + ".signature.p7s"; - return { - nodeOs: os, - vscodeTarget, - sourceDir, - extensionDir, - vsixPath, - vsixManifestPath, - vsixSignaturePath, - }; - }) - ); + extensions = vscodeTargets.flatMap(vscodeTarget => vsixExtensionPackages.map(({ name: packageName, sourceDir }) => createVsixExtension(packageName, sourceDir, vscodeTarget, os))); } return { @@ -2279,6 +2400,13 @@ async function runBuildNativePreviewPackages() { const platforms = getPlatforms(); const inputDir = "./packages/typescript"; + await Promise.all([ + fs.promises.rm(path.join(inputDir, "dist", "tsc.wasm"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.js"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.js.map"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.d.ts"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.d.ts.map"), { force: true }), + ]); const inputPackageJson = JSON.parse(fs.readFileSync(path.join(inputDir, "package.json"), "utf8")); inputPackageJson.version = getVersion(); @@ -2330,7 +2458,7 @@ async function runBuildNativePreviewPackages() { // Copy package contents excluding node_modules and dist (dist is copied separately after build). // The package.json "files" field controls what npm pack actually includes. - await cpRecursive(inputDir, mainPackageDir, p => !p.endsWith("/node_modules") && !p.includes("/dist")); + await cpRecursive(inputDir, mainPackageDir, p => !p.endsWith("/node_modules") && !p.split("/").includes("dist")); if (publishAsTypescript) { await fs.promises.writeFile(path.join(mainPackageDir, "bin", "tsc"), '#!/usr/bin/env node\nimport "../lib/tsc.js";\n'); await fs.promises.chmod(path.join(mainPackageDir, "bin", "tsc"), 0o755); @@ -2341,7 +2469,7 @@ async function runBuildNativePreviewPackages() { await fs.promises.copyFile("LICENSE.txt", path.join(mainPackageDir, "LICENSE")); await fs.promises.copyFile("NOTICE.txt", path.join(mainPackageDir, "NOTICE.txt")); - // Build JS API and copy dist into the package. + // Build the JS API and copy dist into the main package. await run("npm", ["run", "-w", "@typescript/typescript", "build"]); await cpRecursive(path.join(inputDir, "dist"), path.join(mainPackageDir, "dist")); @@ -2369,9 +2497,31 @@ async function runBuildNativePreviewPackages() { throw new Error(`Found external imports in .d.ts files:\n${importErrors.map(e => " " + e).join("\n")}`); } + const wasmInputDir = "./packages/typescript-wasip1-wasm"; const extraFlags = options.respectGoEnv ? [] : getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined); + const wasmPackageJson = JSON.parse(fs.readFileSync(path.join(wasmInputDir, "package.json"), "utf8")); + wasmPackageJson.version = getVersion(); + wasmPackageJson.gitHead = inputPackageJson.gitHead; + wasmPackageJson.publishConfig = { + access: "public", + tag: getPublishTag(), + }; + wasmPackageJson.files = [...new Set([...(wasmPackageJson.files ?? []), "NOTICE.txt"])]; + delete wasmPackageJson.private; + delete wasmPackageJson.scripts; + stripSourceConditions(wasmPackageJson); + + await runBuildWasi(extraFlags); + await cpRecursive(wasmInputDir, wasip1Package.npmDir, p => !p.endsWith("/node_modules") && !p.split("/").includes("dist")); + await cpRecursive(path.join(wasmInputDir, "dist"), path.join(wasip1Package.npmDir, "dist")); + await fs.promises.writeFile( + path.join(wasip1Package.npmDir, "package.json"), + JSON.stringify(wasmPackageJson, undefined, 4), + ); + await fs.promises.copyFile("LICENSE.txt", path.join(wasip1Package.npmDir, "LICENSE")); + await fs.promises.copyFile("NOTICE.txt", path.join(wasip1Package.npmDir, "NOTICE.txt")); const platformBuilders = platforms.map(({ npmDir, npmPackageName, nodeOs, nodeArch, goos, goarch }) => async () => { const packageJson = { @@ -2440,18 +2590,24 @@ async function testNativePreviewPackage(platforms) { const nodeModules = path.join(testRoot, "node_modules"); const mainPackageDir = path.join(nodeModules, ...mainNativePreviewPackage.npmPackageName.split("/")); const platformPackageDir = path.join(nodeModules, ...hostPlatform.npmPackageName.split("/")); + const wasmPackageDir = path.join(nodeModules, ...wasip1Package.npmPackageName.split("/")); const sourceFile = path.join(testRoot, "index.ts"); await rimraf(testRoot); try { await cpRecursive(mainNativePreviewPackage.npmDir, mainPackageDir); await cpRecursive(hostPlatform.npmDir, platformPackageDir); + await cpRecursive(wasip1Package.npmDir, wasmPackageDir); await fs.promises.writeFile(sourceFile, 'export const value: string = "value";\n'); const require = createRequire(sourceFile); const { stdout } = await runOutput("npm", ["pack", "--dry-run", "--json", mainPackageDir]); /** @type {{ files: { path: string }[] }[]} */ const packed = JSON.parse(stdout); + assert( + packed[0].files.some(file => file.path === "dist/wasm/index.js"), + "Main package is missing the WASM host entrypoint", + ); for (const name of ["tsconfig", "jsconfig"]) { const schemaPath = `schemas/${name}.schema.json`; assert(packed[0].files.some(file => file.path === schemaPath), `Package is missing ${schemaPath}`); @@ -2462,6 +2618,23 @@ async function testNativePreviewPackage(platforms) { assert.equal(require.resolve(`${mainNativePreviewPackage.npmPackageName}/${schemaPath}`), path.join(mainPackageDir, schemaPath)); } + const wasmPackageJson = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "package.json"), "utf8")); + assert.equal(wasmPackageJson.peerDependencies, undefined, "WASI artifact package must not depend on the main package"); + const libFiles = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "lib", "libFiles.json"), "utf8")); + const expectedLibFiles = (await fs.promises.readdir(path.join(wasmPackageDir, "lib"))) + .filter(file => file === "lib.d.ts" || file.startsWith("lib.") && file.endsWith(".d.ts")) + .sort(); + assert.deepEqual(libFiles, expectedLibFiles); + + const { stdout: wasmPackOutput } = await runOutput("npm", ["pack", "--dry-run", "--json", wasmPackageDir]); + /** @type {{ files: { path: string }[] }[]} */ + const wasmPacked = JSON.parse(wasmPackOutput); + const wasmFiles = new Set(wasmPacked[0].files.map(file => file.path)); + assert(wasmFiles.has("dist/index.js"), "WASI artifact package is missing its URL helper"); + assert(wasmFiles.has("lib/libFiles.json"), "WASI artifact package is missing its library file list"); + assert(!wasmFiles.has("dist/transport.js"), "WASI artifact package contains the reactor transport"); + assert(!wasmFiles.has("dist/wasi.js"), "WASI artifact package contains the reactor host"); + const binName = publishAsTypescript ? "tsc" : "tsgo"; const binPath = path.join(mainPackageDir, "bin", binName); const { stdout: versionOutput } = await runOutput(process.execPath, [binPath, "--version"]); @@ -2471,6 +2644,8 @@ async function testNativePreviewPackage(platforms) { assert(!listFilesOutput.includes("bundled:///"), "Packaged compiler listed an embedded library path"); const expectedLib = path.resolve(platformPackageDir, "lib", "lib.es5.d.ts"); + const expectedWasmLib = path.resolve(wasmPackageDir, "lib", "lib.es5.d.ts"); + assert(fs.existsSync(expectedWasmLib), `Expected WASM package to contain ${expectedWasmLib}`); const listedFiles = listFilesOutput .split(/\r?\n/) .filter(Boolean) @@ -2645,14 +2820,14 @@ async function runPackNativePreviewPackages() { } const platforms = getPlatforms(); - await Promise.all([mainNativePreviewPackage, ...platforms].map(async ({ npmDir, npmTarball }) => { + await Promise.all([mainNativePreviewPackage, wasip1Package, ...platforms].map(async ({ npmDir, npmTarball }) => { const { stdout } = await runOutput("npm", ["pack", "--json", npmDir]); const filename = JSON.parse(stdout)[0].filename.replace("@", "").replace("/", "-"); await fs.promises.rename(filename, npmTarball); })); - // npm packages need to be published in dependency order: platform packages - // first, then the main package that references them as optionalDependencies. + // Publish platform packages before the main package that references them as + // optionalDependencies. The independent WASI artifact package is published last. const publishManifest = { stages: [ platforms.map(p => ({ @@ -2663,6 +2838,11 @@ async function runPackNativePreviewPackages() { filename: path.basename(mainNativePreviewPackage.npmTarball), }, ], + [ + { + filename: path.basename(wasip1Package.npmTarball), + }, + ], ], }; @@ -2812,7 +2992,14 @@ async function runPackVsixExtensions() { } const platforms = getPlatforms(); - const extensions = platforms.flatMap(({ npmDir, npmPackageName, extensions }) => extensions.map(e => ({ npmDir, npmPackageName, ...e }))); + const extensions = [ + ...platforms.flatMap(({ npmDir, npmPackageName, extensions }) => extensions.map(e => ({ npmDir, npmPackageName, ...e }))), + ...getLocalWebVsixExtensions().map(e => ({ + npmDir: "./packages/typescript-wasip1-wasm", + npmPackageName: wasip1Package.npmPackageName, + ...e, + })), + ]; if (!extensions.length) { console.log("No VSIX targets configured; skipping extension packaging."); return; @@ -2820,6 +3007,9 @@ async function runPackVsixExtensions() { // We don't use vscode:prepublish, as that would run the build for each package below. await run("npm", ["run", "bundle:release"], { cwd: extensionDir, env: releasePackageEnv }); + if (extensions.some(extension => extension.vscodeTarget === "web")) { + await run("npm", ["run", "bundle:release:web"], { cwd: extensionDir, env: releasePackageEnv }); + } let version = "0.0.0"; if (options.forRelease) { @@ -2845,20 +3035,44 @@ async function runPackVsixExtensions() { console.log("Version:", version); await Promise.all(extensions.map(async ({ npmDir, npmPackageName, nodeOs, vscodeTarget, sourceDir, extensionDir: thisExtensionDir, vsixPath, vsixManifestPath, vsixSignaturePath }) => { - const npmLibDir = usePublishedPlatformPackagesForVsix - ? await getPublishedPlatformPackageLibDir(npmPackageName) - : path.join(npmDir, "lib"); + await cpWithoutNodeModulesOrTsconfig(sourceDir, thisExtensionDir); + assert(npmDir); + if (vscodeTarget !== "web") { + assert(npmPackageName); + assert(nodeOs); + } + let npmLibDir; + if (vscodeTarget !== "web" && usePublishedPlatformPackagesForVsix) { + assert(npmPackageName); + npmLibDir = await getPublishedPlatformPackageLibDir(npmPackageName); + } + else { + npmLibDir = path.join(npmDir, "lib"); + } const extensionLibDir = path.join(thisExtensionDir, "lib"); await fs.promises.mkdir(extensionLibDir, { recursive: true }); - - await cpWithoutNodeModulesOrTsconfig(sourceDir, thisExtensionDir); await cpWithoutNodeModulesOrTsconfig(npmLibDir, extensionLibDir); - await fs.promises.chmod(path.join(extensionLibDir, nativePreviewExeName(nodeOs)), 0o755); + if (vscodeTarget !== "web") { + assert(nodeOs); + await fs.promises.chmod(path.join(extensionLibDir, nativePreviewExeName(nodeOs)), 0o755); + } const packageJsonPath = path.join(thisExtensionDir, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); packageJson.version = version; packageJson.bundledTypeScriptVersion = usePublishedPlatformPackagesForVsix ? getPublishedTypeScriptVersion() : getVersion(); + if (vscodeTarget === "web") { + delete packageJson.main; + packageJson.files = /** @type {string[]} */ (packageJson.files ?? []).filter(file => file !== "dist/extension.bundle.js"); + if (sourceDir !== nightlyExtensionDir) { + packageJson.browser = "./dist/extension.web.bundle.js"; + packageJson.extensionDependencies = [ + ...(packageJson.extensionDependencies ?? []), + "ms-vscode.wasm-wasi-core", + ]; + packageJson.files.push("dist/extension.web.bundle.js"); + } + } fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 4)); await fs.promises.copyFile("NOTICE.txt", path.join(thisExtensionDir, "NOTICE.txt")); diff --git a/package-lock.json b/package-lock.json index d693c42e440d2..0bc82519eaca0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^22.20.1", "@unicode/unicode-15.1.0": "^1.6.17", "adm-zip": "^0.6.1", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.57.4", "dprint-plugin-yaml": "^0.6.0", @@ -1830,6 +1831,10 @@ "node": ">=16.20.0" } }, + "node_modules/@typescript/typescript-wasip1-wasm": { + "resolved": "packages/typescript-wasip1-wasm", + "link": true + }, "node_modules/@typescript/typescript-win32-arm64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", @@ -2088,6 +2093,24 @@ "win32" ] }, + "node_modules/@vscode/wasm-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vscode/wasm-wasi/-/wasm-wasi-1.0.2.tgz", + "integrity": "sha512-JLvtxHiLVIg5cPQBBueQWn7M4njfchKvh32Ujr844UCgFTU5myKL6LY5DrDai/oZfbAyx46T2VmJpF34d6vSWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.2", + "yargs": "^17.7.2" + }, + "bin": { + "dir-dump": "bin/dir-dump" + }, + "engines": { + "node": ">=20.9.0", + "vscode": "^1.88.0" + } + }, "node_modules/adm-zip": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", @@ -2108,6 +2131,62 @@ "node": ">= 14" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -2128,6 +2207,24 @@ "node": "18 || 20 || >=22" } }, + "node_modules/binaryen": { + "version": "132.0.0", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-132.0.0.tgz", + "integrity": "sha512-4Usg7yEvwO3FMso3Oov6qkOOHSod1IEv+boKkh77kTljM+eNBouqCTbqWswGzbG+vDNO/b/v2C4gfEh1WgB9Lg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-as": "bin/wasm-as", + "wasm-ctor-eval": "bin/wasm-ctor-eval", + "wasm-dis": "bin/wasm-dis", + "wasm-merge": "bin/wasm-merge", + "wasm-metadce": "bin/wasm-metadce", + "wasm-opt": "bin/wasm-opt", + "wasm-reduce": "bin/wasm-reduce", + "wasm-shell": "bin/wasm-shell", + "wasm2js": "bin/wasm2js" + } + }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -2196,6 +2293,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2253,6 +2369,21 @@ "node": ">=18" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/cockatiel": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz", @@ -2263,6 +2394,26 @@ "node": ">=16" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -2321,6 +2472,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -2334,6 +2503,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dprint": { "version": "0.57.4", "resolved": "https://registry.npmjs.org/dprint/-/dprint-0.57.4.tgz", @@ -2411,6 +2598,13 @@ "url": "https://bevry.me/fund" } }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", @@ -2499,6 +2693,16 @@ "@esbuild/win32-x64": "0.28.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2517,6 +2721,22 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2527,6 +2747,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -2586,6 +2826,19 @@ "dev": true, "license": "ISC" }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2599,6 +2852,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2666,6 +2935,43 @@ "node": ">= 14" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -2682,6 +2988,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -2701,6 +3037,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -2962,6 +3350,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -3014,6 +3450,55 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -3070,6 +3555,16 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -3114,6 +3609,24 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sax": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", @@ -3136,6 +3649,24 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -3219,6 +3750,34 @@ "dev": true, "license": "ISC" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/structured-source": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/structured-source/-/structured-source-4.0.0.tgz", @@ -3371,6 +3930,20 @@ "dev": true, "license": "MIT" }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -3446,6 +4019,46 @@ "resolved": "packages/vscode-typescript-nightly", "link": true }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -3486,6 +4099,16 @@ "node": ">=4.0" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -3496,6 +4119,35 @@ "node": ">=18" } }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yauzl": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", @@ -3528,6 +4180,9 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "assert": "^2.1.0", + "playwright": "^1.62.1", + "process": "^0.11.10", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" }, @@ -3535,6 +4190,11 @@ "node": ">=16.20.0" } }, + "packages/typescript-wasip1-wasm": { + "name": "@typescript/typescript-wasip1-wasm", + "version": "0.0.0", + "license": "Apache-2.0" + }, "packages/vscode-typescript": { "name": "native-preview", "version": "1.0.1", @@ -3548,6 +4208,7 @@ "@typescript/bundled-typescript": "npm:typescript@7.0.2", "@typescript/typescript": "0.0.0", "@vscode/vsce": "4.0.0", + "@vscode/wasm-wasi": "^1.0.2", "esbuild": "^0.28.2" }, "engines": { diff --git a/package.json b/package.json index 320354f8d467c..f6b4cf0bf6ab4 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/node": "^22.20.1", "@unicode/unicode-15.1.0": "^1.6.17", "adm-zip": "^0.6.1", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.57.4", "dprint-plugin-yaml": "^0.6.0", diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md new file mode 100644 index 0000000000000..5d1cf73ed7cea --- /dev/null +++ b/packages/typescript-wasip1-wasm/README.md @@ -0,0 +1,12 @@ +# `@typescript/typescript-wasip1-wasm` + +> [!WARNING] +> This package is experimental. Its APIs, behavior, and package layout may +> change without notice. + +This package contains the TypeScript compiler and language server artifacts +for `wasip1/wasm`, plus a list of the bundled library files. + +Install the same exact version of this package and the TypeScript package that +provides `typescript/unstable/wasm`. When loading both packages from a CDN, +select one version and use it for both package URLs. diff --git a/packages/typescript-wasip1-wasm/package.json b/packages/typescript-wasip1-wasm/package.json new file mode 100644 index 0000000000000..aa1755ac9b014 --- /dev/null +++ b/packages/typescript-wasip1-wasm/package.json @@ -0,0 +1,27 @@ +{ + "private": true, + "name": "@typescript/typescript-wasip1-wasm", + "version": "0.0.0", + "license": "Apache-2.0", + "author": "Microsoft Corp.", + "homepage": "https://www.typescriptlang.org/", + "description": "Experimental TypeScript support for wasip1/wasm", + "type": "module", + "files": [ + "dist", + "lib" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "@typescript/source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./lib/libFiles.json": "./lib/libFiles.json", + "./lib/tsc.wasm": "./lib/tsc.wasm" + }, + "scripts": { + "build": "hereby --herebyfile ../../Herebyfile.mjs build:wasip1", + "build:js": "tsc -b" + } +} diff --git a/packages/typescript-wasip1-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts new file mode 100644 index 0000000000000..33d40da10b77f --- /dev/null +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -0,0 +1 @@ +export const wasmURL: URL = new URL("../lib/tsc.wasm", import.meta.url); diff --git a/packages/typescript-wasip1-wasm/tsconfig.json b/packages/typescript-wasip1-wasm/tsconfig.json new file mode 100644 index 0000000000000..65c131d98313e --- /dev/null +++ b/packages/typescript-wasip1-wasm/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": [ + "es2022", + "dom" + ], + "strict": true, + "exactOptionalPropertyTypes": true, + "composite": true, + "forceConsistentCasingInFileNames": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "module": "node16", + "isolatedDeclarations": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src" + ] +} diff --git a/packages/typescript/lib/getExePath.d.ts b/packages/typescript/lib/getExePath.d.ts index bf33fb7cf6882..9efbab990f9b8 100644 --- a/packages/typescript/lib/getExePath.d.ts +++ b/packages/typescript/lib/getExePath.d.ts @@ -1,2 +1,3 @@ declare function getExePath(): string; export default getExePath; +export declare function getWasmPath(): string; diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index 0bb4ab39e13e7..cddaf18d160b1 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -5,10 +5,7 @@ import { fileURLToPath } from "node:url"; // NOTE: Keep VS Code extension's resolveTsdkPathToExe in sync with this function. export default function getExePath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const normalizedDirname = __dirname.replace(/\\/g, "/"); - - const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); + const { __dirname, normalizedDirname, pkg } = getPackageInfo(); const pkgName = pkg.name; const baseName = pkgName.startsWith("@") ? pkgName.split("/")[1] : pkgName; const expectedBinName = baseName === "typescript" ? "tsc" : "tsgo"; @@ -68,3 +65,57 @@ export default function getExePath() { return exe; } + +export function getWasmPath() { + const { __dirname, normalizedDirname, pkg } = getPackageInfo(); + + let wasmDir; + if (normalizedDirname.endsWith("/packages/typescript/lib")) { + wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "lib"); + } + else if (normalizedDirname.endsWith("/built/npm/typescript/lib") || normalizedDirname.endsWith("/built/npm/native-preview/lib")) { + wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "lib"); + } + else { + const require = module.createRequire(import.meta.url); + let packageJson; + try { + packageJson = require.resolve("@typescript/typescript-wasip1-wasm/package.json"); + } + catch (error) { + if (error?.code === "MODULE_NOT_FOUND") { + throw Object.assign( + new Error( + `WebAssembly compiler package not found. Install @typescript/typescript-wasip1-wasm@${pkg.version} to enable WASI fallback.`, + { cause: error }, + ), + { code: "ERR_TYPESCRIPT_WASM_PACKAGE" }, + ); + } + throw error; + } + const wasmPackage = JSON.parse(fs.readFileSync(packageJson, "utf8")); + if (wasmPackage.version !== pkg.version) { + throw Object.assign( + new Error( + `WebAssembly compiler package version ${wasmPackage.version} is incompatible with ${pkg.name}@${pkg.version}. Install @typescript/typescript-wasip1-wasm@${pkg.version}.`, + ), + { code: "ERR_TYPESCRIPT_WASM_PACKAGE" }, + ); + } + wasmDir = path.join(path.dirname(packageJson), "lib"); + } + + const wasmPath = path.join(wasmDir, "tsc.wasm"); + if (!fs.existsSync(wasmPath)) { + throw new Error("WebAssembly compiler not found: " + wasmPath); + } + return wasmPath; +} + +function getPackageInfo() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const normalizedDirname = __dirname.replace(/\\/g, "/"); + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); + return { __dirname, normalizedDirname, pkg }; +} diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index eb869b715737e..10a94cdafeca9 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -1,11 +1,35 @@ #!/usr/bin/env node -import getExePath from "#getExePath"; +import getExePath, { getWasmPath } from "#getExePath"; import { execFileSync } from "node:child_process"; +import { + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; -const exe = getExePath(); +let exe; +try { + exe = getExePath(); +} +catch (nativeError) { + let wasmPath; + try { + wasmPath = getWasmPath(); + } + catch (wasmError) { + if (wasmError?.code === "ERR_TYPESCRIPT_WASM_PACKAGE") { + throw wasmError; + } + throw nativeError; + } + await runWasi(wasmPath); +} -if (process.platform !== "win32" && typeof process.execve === "function") { +if (exe && process.platform !== "win32" && typeof process.execve === "function") { // > v22.15.0 try { process.execve(exe, [exe, ...process.argv.slice(2)]); @@ -15,14 +39,169 @@ if (process.platform !== "win32" && typeof process.execve === "function") { } } -try { - execFileSync(exe, process.argv.slice(2), { stdio: "inherit" }); +if (exe) { + try { + execFileSync(exe, process.argv.slice(2), { stdio: "inherit" }); + } + catch (e) { + if (e.status) { + process.exitCode = e.status; + } + else { + throw e; + } + } +} + +async function runWasi(wasmPath) { + const nodeMajor = Number(process.versions.node.split(".")[0]); + if (nodeMajor < 23) { + throw new Error("The WASI fallback requires Node.js 23 or newer."); + } + + const { WASI } = await import("node:wasi"); + + const { args, cwd, preopens, cleanup } = await getWasiPaths(wasmPath); + try { + const wasi = new WASI({ + version: "preview1", + args, + env: { ...process.env, PWD: cwd }, + preopens, + returnOnExit: true, + }); + const module = await WebAssembly.compile(await readFile(wasmPath)); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + process.exitCode = wasi.start(instance); + } + finally { + await cleanup?.(); + } } -catch (e) { - if (e.status) { - process.exitCode = e.status; + +async function getWasiPaths(wasmPath) { + const cwd = process.cwd(); + if (process.platform !== "win32") { + return { + args: [wasmPath, ...process.argv.slice(2)], + cwd, + preopens: { "/": "/" }, + }; + } + + const preopens = {}; + const guestRoots = new Map(); + const getGuestRoot = hostRoot => { + const key = hostRoot.toLowerCase(); + let guestRoot = guestRoots.get(key); + if (!guestRoot) { + const drive = /^([a-z]):[\\/]$/i.exec(hostRoot); + guestRoot = drive ? `/mnt/${drive[1].toLowerCase()}` : `/mnt/root-${guestRoots.size}`; + guestRoots.set(key, guestRoot); + preopens[guestRoot] = hostRoot; + } + return guestRoot; + }; + const toGuestPath = hostPath => { + const root = path.parse(hostPath).root; + const relative = path.relative(root, hostPath).replaceAll("\\", "/"); + return path.posix.join(getGuestRoot(root), relative); + }; + const translatePath = arg => { + return path.isAbsolute(arg) ? toGuestPath(arg) : arg; + }; + const responseFiles = new Map(); + let responseDirectory; + const translateResponseFile = async hostPath => { + const key = hostPath.toLowerCase(); + const existing = responseFiles.get(key); + if (existing) { + return existing; + } + responseDirectory ??= await mkdtemp(path.join(tmpdir(), "typescript-wasi-")); + const translatedPath = path.join(responseDirectory, `${responseFiles.size}.rsp`); + const guestPath = toGuestPath(translatedPath); + responseFiles.set(key, guestPath); + let contents; + try { + contents = await readFile(hostPath, "utf8"); + } + catch { + const fallbackPath = toGuestPath(hostPath); + responseFiles.set(key, fallbackPath); + return fallbackPath; + } + const tokens = parseResponseFile(contents); + if (tokens) { + const replacements = []; + for (const token of tokens) { + replacements.push({ + ...token, + value: await translateArgument(token.value), + }); + } + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i]; + contents = contents.slice(0, replacement.start) + replacement.value + contents.slice(replacement.end); + } + } + await writeFile(translatedPath, contents); + return guestPath; + }; + const translateArgument = async arg => { + if (arg.startsWith("@") && arg.length > 1) { + return `@${await translateResponseFile(path.resolve(cwd, arg.slice(1)))}`; + } + return translatePath(arg); + }; + const args = []; + for (const arg of process.argv.slice(2)) { + args.push(await translateArgument(arg)); } - else { - throw e; + + return { + args: [ + toGuestPath(wasmPath), + ...args, + ], + cwd: toGuestPath(cwd), + preopens, + cleanup: responseDirectory + ? () => rm(responseDirectory, { recursive: true, force: true }) + : undefined, + }; +} + +function parseResponseFile(contents) { + const tokens = []; + let position = 0; + while (position < contents.length) { + while (position < contents.length && contents.charCodeAt(position) <= 32) { + position++; + } + if (position >= contents.length) { + break; + } + if (contents[position] === '"') { + const start = ++position; + while (position < contents.length && contents[position] !== '"') { + position++; + } + if (position >= contents.length) { + return undefined; + } + tokens.push({ start, end: position, value: contents.slice(start, position) }); + position++; + } + else { + const start = position; + while (position < contents.length && contents.charCodeAt(position) > 32) { + position++; + } + tokens.push({ start, end: position, value: contents.slice(start, position) }); + } } + return tokens; } diff --git a/packages/typescript/package.json b/packages/typescript/package.json index b559b663e9766..77bb002ca207b 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -48,10 +48,18 @@ "@typescript/source": "./src/api/sync/api.ts", "default": "./dist/api/sync/api.js" }, + "./unstable/sync/transport": { + "@typescript/source": "./src/api/sync/transport.ts", + "default": "./dist/api/sync/transport.js" + }, "./unstable/async": { "@typescript/source": "./src/api/async/api.ts", "default": "./dist/api/async/api.js" }, + "./unstable/async/transport": { + "@typescript/source": "./src/api/async/transport.ts", + "default": "./dist/api/async/transport.js" + }, "./unstable/fs": { "@typescript/source": "./src/api/fs.ts", "default": "./dist/api/fs.js" @@ -64,6 +72,10 @@ "@typescript/source": "./src/api/proto.ts", "default": "./dist/api/proto.js" }, + "./unstable/wasm": { + "@typescript/source": "./src/wasm/index.ts", + "default": "./dist/wasm/index.js" + }, "./unstable/ast": { "@typescript/source": "./src/ast/index.ts", "default": "./dist/ast/index.js" @@ -95,6 +107,22 @@ }, "imports": { "#getExePath": "./lib/getExePath.js", + "#asyncClient": { + "@typescript/source": { + "browser": "./src/api/async/browserClient.ts", + "default": "./src/api/async/client.ts" + }, + "browser": "./dist/api/async/browserClient.js", + "default": "./dist/api/async/client.js" + }, + "#syncClient": { + "@typescript/source": { + "browser": "./src/api/sync/browserClient.ts", + "default": "./src/api/sync/client.ts" + }, + "browser": "./dist/api/sync/browserClient.js", + "default": "./dist/api/sync/client.js" + }, "#enums/*": { "@typescript/source": { "types": "./src/enums/*.enum.ts", @@ -113,11 +141,15 @@ "generate:sync": "npm run node -- scripts/generateSync.ts", "build": "tsc -b", "build:test": "tsc -b test", - "test:only": "npm run node -- --test './test/**/*.test.ts'", + "test:only": "npm run node -- --test './test/*.test.ts' './test/async/**/*.test.ts' './test/sync/**/*.test.ts'", + "test:browser:only": "npm run node -- --test './test/browser/**/*.test.ts'", "test": "npm run test:only" }, "devDependencies": { "@types/node": "^22.20.1", + "assert": "^2.1.0", + "playwright": "^1.62.1", + "process": "^0.11.10", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" } diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index cf2b483f0e9c6..404c137077779 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1,3 +1,8 @@ +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "#asyncClient"; // @sync: } from "#syncClient"; import { CheckFlags } from "#enums/checkFlags"; import { CompletionItemKind } from "#enums/completionItemKind"; import { DiagnosticCategory } from "#enums/diagnosticCategory"; @@ -56,7 +61,7 @@ import { } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; import type { - APIOptions, + APIOptions, // @sync: SyncAPIOptions as APIOptions, LSPConnectionOptions, } from "../options.ts"; import { @@ -137,11 +142,6 @@ import type { TimingCollector, TimingInfo, } from "../timing.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, @@ -763,6 +763,15 @@ export class API implements FormatDiagnosticsHo } async close(): Promise { + // @sync-skip-block-start + if (this.initializing && !this.initialized) { + const initializing = this.initializing; + await this.client.close(); + await initializing.catch(() => {}); + this.sourceFileCache.clear(); + return; + } + // @sync-skip-block-end await this.initializing?.catch(() => {}); // @sync-skip try { for (const retained of [...this.activeSourceFileLeases.values()]) { diff --git a/packages/typescript/src/api/async/browserClient.ts b/packages/typescript/src/api/async/browserClient.ts new file mode 100644 index 0000000000000..d70e67203af9d --- /dev/null +++ b/packages/typescript/src/api/async/browserClient.ts @@ -0,0 +1,22 @@ +import type { + AsyncClientOptions, + ClientSocketOptions, + ClientSpawnOptions, +} from "../options.ts"; +import { TransportClient } from "./transportClient.ts"; + +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions } from "../options.ts"; +export type { AsyncTransport } from "./transport.ts"; + +export class Client extends TransportClient { + constructor(options: AsyncClientOptions) { + if (!("transport" in options)) { + throw new Error("The browser async API requires an injected transport"); + } + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); + } +} diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 9c3319a13b499..08c7f7cf0b7d3 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -21,10 +21,12 @@ import { type FileSystemCallbackConfiguration, } from "../fsCallbacks.ts"; import { + type AsyncClientOptions, type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, getAPIProcessArgs, + isAsyncTransportOptions, isSpawnOptions, resolveExePath, } from "../options.ts"; @@ -43,8 +45,10 @@ import { TimingCollector, type TimingInfo, } from "../timing.ts"; +import { TransportClient } from "./transportClient.ts"; -export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions }; +export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; +export type { AsyncTransport } from "./transport.ts"; /** * Client handles communication with the TypeScript API server @@ -54,7 +58,8 @@ export class Client { private socket: Socket | undefined; private process: ChildProcess | undefined; private connection: MessageConnection | undefined; - private options: ClientOptions; + private options: ClientOptions | undefined; + private transportClient: TransportClient | undefined; private connected = false; private closed = false; private connecting: Promise | undefined; @@ -62,7 +67,18 @@ export class Client { private batchedRequests: { method: APIRequest["method"]; params: APIRequest["params"]; resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = []; private nextBatch: NodeJS.Immediate | "manual" | undefined; - constructor(options: ClientOptions) { + constructor(options: AsyncClientOptions) { + if (isAsyncTransportOptions(options)) { + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + this.transportClient = new TransportClient( + options.transport, + options.collectTiming ?? false, + options.maxResponseBytesPerPage, + ); + return; + } this.options = options; if (isSpawnOptions(options)) { configureFileSystemCallbacks(options.fs); @@ -73,6 +89,7 @@ export class Client { } connect(): Promise { + if (this.transportClient) return this.transportClient.connect(); if (this.closed) return Promise.reject(new Error("Client is closed")); if (this.connected) return Promise.resolve(); return this.connecting ??= this.connectWorker().finally(() => { @@ -81,6 +98,7 @@ export class Client { } private async connectWorker(): Promise { + if (!this.options) throw new Error("Client options are not available"); if (isSpawnOptions(this.options)) { await this.connectViaSpawn(this.options); } @@ -245,6 +263,9 @@ export class Client { } registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { + if (this.transportClient) { + return this.transportClient.registerCallback(name, callback); + } if (!this.connection) { throw new Error("Connection not established"); } @@ -275,8 +296,9 @@ export class Client { const requestType = new RequestType("batchRequests"); const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; - if (this.options.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + const maxResponseBytesPerPage = this.options?.maxResponseBytesPerPage; + if (maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = maxResponseBytesPerPage; } const response = await this.sendRequestWithTiming(requestType, params); let responses = response.responses; @@ -286,8 +308,8 @@ export class Client { requests: [], continuationToken, }; - if (this.options.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + if (maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = maxResponseBytesPerPage; } const page = await this.sendRequestWithTiming(requestType, pageParams); responses = responses.concat(page.responses); @@ -310,11 +332,12 @@ export class Client { } private scheduleImmediateBatch(): void { - if (this.nextBatch) return; + if (this.closed || this.nextBatch) return; this.nextBatch = setImmediate(this.doBatch.bind(this)); } batchContext(): { [Symbol.dispose](): void; } { + if (this.transportClient) return this.transportClient.batchContext(); if (this.nextBatch === "manual") { throw new Error("Already in a manual batch context"); } @@ -332,10 +355,12 @@ export class Client { } async apiRequest(method: K, params: APIMethodInfo[K]["params"]): Promise { + if (this.transportClient) return this.transportClient.apiRequest(method, params); if (this.closed) throw new Error("Client is closed"); if (!this.connected) { await this.connect(); } + if (this.closed) throw new Error("Client is closed"); if (!this.connection) { throw new Error("Connection not established"); } @@ -348,6 +373,7 @@ export class Client { } async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { + if (this.transportClient) return this.transportClient.apiRequestBinary(method, params); const response = await this.apiRequest(method, params); if (!response) return undefined; const buffer = Buffer.from(response.data, "base64"); @@ -361,6 +387,7 @@ export class Client { * materialization totals surface alongside request timings. */ getTimingCollector(): TimingCollector | undefined { + if (this.transportClient) return this.transportClient.getTimingCollector(); return this.timing; } @@ -370,6 +397,7 @@ export class Client { * (fetched via a getServerTiming request) and estimated transport overhead. */ async getTimingInfo(): Promise { + if (this.transportClient) return this.transportClient.getTimingInfo(); if (!this.timing) { return disabledTimingInfo(); } @@ -382,6 +410,7 @@ export class Client { } async resetTimingInfo(): Promise { + if (this.transportClient) return this.transportClient.resetTimingInfo(); if (!this.timing) return; this.timing.reset(); if (this.connected && this.connection) { @@ -402,8 +431,19 @@ export class Client { } async close(): Promise { - await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection + if (this.transportClient) return this.transportClient.close(); + if (this.closed) return; this.closed = true; + if (this.nextBatch && this.nextBatch !== "manual") { + clearImmediate(this.nextBatch); + } + this.nextBatch = undefined; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection if (this.connection) { this.connection.dispose(); this.connection = undefined; diff --git a/packages/typescript/src/api/async/transport.ts b/packages/typescript/src/api/async/transport.ts new file mode 100644 index 0000000000000..85596c76dd6c7 --- /dev/null +++ b/packages/typescript/src/api/async/transport.ts @@ -0,0 +1,26 @@ +/** + * An asynchronous request transport connected to a TypeScript API session. + */ +export interface AsyncTransportResponse { + readonly value: T; + readonly bytesSent: number; + readonly bytesReceived: number; +} + +export interface AsyncTransport { + request(method: string, payload: string): AsyncTransportResponse | PromiseLike>; + requestBinary( + method: string, + payload: Uint8Array, + ): AsyncTransportResponse | PromiseLike>; + /** + * Registers a callback invoked synchronously by the transport. + * The callback cannot return a Promise or make a nested request through a + * non-reentrant transport. + */ + registerCallback?(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback?(name: string): void; + setFileSystem?(fs: FileSystemCallbacks | undefined): void; + close(): void | PromiseLike; +} +import type { FileSystemCallbacks } from "../fs.ts"; diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts new file mode 100644 index 0000000000000..1a7490accae41 --- /dev/null +++ b/packages/typescript/src/api/async/transportClient.ts @@ -0,0 +1,308 @@ +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import type { AsyncTransport } from "./transport.ts"; + +interface PendingRequestBase { + reject: (reason?: unknown) => void; +} + +interface PendingTextRequest extends PendingRequestBase { + kind: "text"; + method: APIRequest["method"]; + params: APIRequest["params"]; + resolve: (value: unknown) => void; +} + +interface PendingBinaryRequest extends PendingRequestBase { + kind: "binary"; + method: SourceFileResponseMethod; + payload: Uint8Array; + resolve: (value: Uint8Array | undefined) => void; +} + +type PendingRequest = PendingTextRequest | PendingBinaryRequest; + +function isTextRequest(request: PendingRequest): request is PendingTextRequest { + return request.kind === "text"; +} + +/** Protocol client for an injected asynchronous transport. */ +export class TransportClient { + private readonly encoder = new TextEncoder(); + private readonly timing: TimingCollector | undefined; + private readonly transport: AsyncTransport; + private readonly maxResponseBytesPerPage: number | undefined; + private connected = false; + private closed = false; + private nextBatch: boolean | "manual" = false; + private batchGeneration = 0; + private batchedRequests: PendingRequest[] = []; + private readonly closedPromise: Promise; + private rejectClosed!: (reason: Error) => void; + + constructor(transport: AsyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + this.closedPromise = new Promise((_, reject) => { + this.rejectClosed = reject; + }); + void this.closedPromise.catch(() => {}); + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + connect(): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + this.connected = true; + return Promise.resolve(); + } + + batchContext(): { [Symbol.dispose](): void; } { + if (this.nextBatch === "manual") { + throw new Error("Already in a manual batch context"); + } + if (this.nextBatch) { + this.batchGeneration++; + this.nextBatch = false; + void this.doBatch(); + } + this.nextBatch = "manual"; + return { + [Symbol.dispose]: () => { + this.nextBatch = false; + this.scheduleBatch(); + }, + }; + } + + async apiRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + if (method === "initialize") { + return this.sendRequest(method, params); + } + const result = new Promise((resolve, reject) => { + this.batchedRequests.push({ kind: "text", method, params, resolve, reject }); + this.scheduleBatch(); + }); + return result; + } + + async apiRequestBinary( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + return new Promise((resolve, reject) => { + this.batchedRequests.push({ + kind: "binary", + method, + payload: this.encoder.encode(JSON.stringify(params)), + resolve, + reject, + }); + this.scheduleBatch(); + }); + } + + registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { + const register = this.transport.registerCallback; + const unregister = this.transport.unregisterCallback; + if (!register || !unregister) { + throw new Error("Callbacks are not supported by this transport"); + } + register.call(this.transport, name, (_, payload) => { + const result = callback(JSON.parse(payload)); + if (isPromiseLike(result)) { + throw new Error("Injected transport callbacks must complete synchronously"); + } + return JSON.stringify(result) ?? ""; + }); + return () => unregister.call(this.transport, name); + } + + getTimingCollector(): TimingCollector | undefined { + return this.timing; + } + + async getTimingInfo(): Promise { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + if (!this.connected) { + return local; + } + const response = await this.invokeTransport(() => this.transport.request("getServerTiming", "")); + return combineTimingInfo(local, JSON.parse(response.value) as ServerTimingInfo); + } + + async resetTimingInfo(): Promise { + if (!this.timing) return; + this.timing.reset(); + if (this.connected) { + await this.invokeTransport(() => this.transport.request("resetServerTiming", "")); + } + } + + async close(): Promise { + this.closed = true; + this.rejectClosed(new Error("Client is closed")); + this.batchGeneration++; + this.nextBatch = false; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.transport.close(); + this.connected = false; + } + + private scheduleBatch(): void { + if (this.closed || this.nextBatch) return; + this.nextBatch = true; + const generation = ++this.batchGeneration; + queueMicrotask(() => { + if (this.batchGeneration !== generation || this.nextBatch !== true) return; + this.nextBatch = false; + void this.doBatch(); + }); + } + + private async doBatch(): Promise { + if (!this.batchedRequests.length) return; + const requests = this.batchedRequests; + this.batchedRequests = []; + if (this.closed) { + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + return; + } + try { + if (requests.length === 1 && requests[0].kind === "text") { + const request = requests[0]; + request.resolve(await this.sendRequest(request.method, request.params)); + return; + } + + if (requests.every(isTextRequest)) { + const params: BatchRequestsParams = { + requests: requests.map(request => ({ method: request.method, params: request.params })), + }; + if (this.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const response = await this.sendRequest("batchRequests", params) as BatchRequestsResponse; + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const page = await this.sendRequest("batchRequests", pageParams) as BatchRequestsResponse; + responses = responses.concat(page.responses); + continuationToken = page.continuationToken; + } + for (let i = 0; i < requests.length; i++) { + const { resolve, reject } = requests[i]; + const item = responses[i]; + if (item.error !== undefined) { + reject(new Error(item.error)); + } + else { + resolve(item.result); + } + } + + return; + } + + for (const request of requests) { + if (request.kind === "text") { + request.resolve(await this.sendRequest(request.method, request.params)); + } + else { + request.resolve(await this.sendBinaryRequest(request.method, request.payload)); + } + } + } + catch (error) { + for (const { reject } of requests) reject(error); + } + } + + private async sendRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + const payload = JSON.stringify(params) ?? ""; + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.request(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length + ? JSON.parse(response.value) as APIMethodInfo[K]["result"] + : undefined as APIMethodInfo[K]["result"]; + } + + private async sendBinaryRequest(method: SourceFileResponseMethod, payload: Uint8Array): Promise { + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.requestBinary(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length === 0 ? undefined : response.value; + } + + private invokeTransport(operation: () => T | PromiseLike): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + return Promise.race([ + Promise.resolve().then(operation), + this.closedPromise, + ]); + } + + private recordTiming(method: string, start: number, bytesSent: number, bytesReceived: number): void { + if (!this.timing) return; + this.timing.record({ + method, + roundTripMs: performance.now() - start, + bytesSent, + bytesReceived, + }); + } +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" && value !== null || typeof value === "function") + && "then" in value + && typeof value.then === "function" + ); +} diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 0bfdcc2ff02e4..7302cfb2fc715 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -372,11 +372,52 @@ export function encodeNode(node: Node): Uint8Array { * Encode a Uint8Array to a base64 string. */ export function uint8ArrayToBase64(data: Uint8Array): string { - return Buffer.from(data).toString("base64"); + const nativeToBase64 = (data as Uint8Array & { toBase64?: () => string; }).toBase64; + if (nativeToBase64) { + return nativeToBase64.call(data); + } + + const bufferConstructor = (globalThis as { + Buffer?: { + from(buffer: ArrayBufferLike, byteOffset: number, length: number): { + toString(encoding: "base64"): string; + }; + }; + }).Buffer; + if (bufferConstructor) { + return bufferConstructor.from(data.buffer, data.byteOffset, data.byteLength).toString("base64"); + } + + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let result = ""; + for (let i = 0; i < data.length; i += 3) { + const first = data[i]; + const second = data[i + 1]; + const third = data[i + 2]; + result += alphabet[first >> 2]; + result += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)]; + result += second === undefined ? "=" : alphabet[((second & 0x0F) << 2) | ((third ?? 0) >> 6)]; + result += third === undefined ? "=" : alphabet[third & 0x3F]; + } + return result; } export function sourceFileResponseToUint8Array(response: { readonly data: string; } | null | undefined): Uint8Array | undefined { if (!response) return undefined; - const buffer = Buffer.from(response.data, "base64"); - return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const fromBase64 = (Uint8Array as { + fromBase64?: (data: string) => Uint8Array; + }).fromBase64; + if (fromBase64) { + return fromBase64(response.data); + } + const bufferConstructor = (globalThis as { + Buffer?: { + from(data: string, encoding: "base64"): Uint8Array; + }; + }).Buffer; + if (bufferConstructor) { + return new Uint8Array(bufferConstructor.from(response.data, "base64")); + } + const decoded = atob(response.data); + return Uint8Array.from(decoded, character => character.charCodeAt(0)); } diff --git a/packages/typescript/src/api/node/wtf8.ts b/packages/typescript/src/api/node/wtf8.ts index 30321e734b872..5159ec1546b8a 100644 --- a/packages/typescript/src/api/node/wtf8.ts +++ b/packages/typescript/src/api/node/wtf8.ts @@ -1,12 +1,10 @@ -import { Buffer } from "node:buffer"; - const surrogateLeadByte = 0xED; const surrogateSecondByteMin = 0xA0; const surrogateSecondByteMax = 0xBF; const continuationByteMin = 0x80; const continuationByteMax = 0xBF; const textEncoder = new TextEncoder(); -const replacementCharacterUtf8 = Buffer.from([0xEF, 0xBF, 0xBD]); +const replacementCharacterUtf8 = new Uint8Array([0xEF, 0xBF, 0xBD]); const loneSurrogateRegExp = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/; const shortStringLength = 64; type DecodeInput = ArrayBufferView | ArrayBufferLike | null; @@ -28,7 +26,20 @@ function getSurrogateCodeUnit(bytes: Uint8Array, index: number): number { } function hasSurrogateLeadByte(bytes: Uint8Array): boolean { - return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).indexOf(surrogateLeadByte) >= 0; + return bytes.includes(surrogateLeadByte); +} + +function hasReplacementCharacter(bytes: Uint8Array): boolean { + for (let i = 0; i <= bytes.length - replacementCharacterUtf8.length; i++) { + if ( + bytes[i] === replacementCharacterUtf8[0] + && bytes[i + 1] === replacementCharacterUtf8[1] + && bytes[i + 2] === replacementCharacterUtf8[2] + ) { + return true; + } + } + return false; } function toUint8Array(input: Exclude): Uint8Array { @@ -45,7 +56,7 @@ export function encodeWtf8(text: string): Uint8Array { if (text.length > shortStringLength) { const utf8 = textEncoder.encode(text); if ( - Buffer.from(utf8.buffer, utf8.byteOffset, utf8.byteLength).indexOf(replacementCharacterUtf8) < 0 + !hasReplacementCharacter(utf8) || !loneSurrogateRegExp.test(text) ) { return utf8; diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 2e8954c83a802..4784529492426 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -5,7 +5,9 @@ import getExePath from "#getExePath"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; +import type { AsyncTransport } from "./async/transport.ts"; import type { FileSystemCallbacks } from "./fs.ts"; +import type { SyncTransport } from "./sync/transport.ts"; export interface ClientSocketOptions { /** Path to the Unix domain socket or Windows named pipe for API communication */ @@ -43,6 +45,49 @@ export function isSpawnOptions(options: ClientOptions): options is ClientSpawnOp return !("pipe" in options); } +export interface ClientTransportOptions { + /** + * An existing synchronous transport connected to an API session. + * Custom module resolution callbacks must obey the transport's reentrancy + * constraints. + */ + transport: SyncTransport; + /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ + maxResponseBytesPerPage?: number | undefined; + /** Virtual filesystem callbacks used by transports that support them. */ + fs?: FileSystemCallbacks | undefined; + /** Collect timing information for requests made through the transport. */ + collectTiming?: boolean | undefined; +} + +export type SyncClientOptions = ClientSocketOptions | ClientSpawnOptions | ClientTransportOptions; + +export function isTransportOptions(options: SyncClientOptions): options is ClientTransportOptions { + return "transport" in options; +} + +export interface AsyncClientTransportOptions { + /** + * An existing asynchronous transport connected to an API session. + * Host callbacks are synchronous even when requests are asynchronous, so + * custom module resolution callbacks cannot return Promises and must obey + * the transport's reentrancy constraints. + */ + transport: AsyncTransport; + /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ + maxResponseBytesPerPage?: number | undefined; + /** Virtual filesystem callbacks used by transports that support them. */ + fs?: FileSystemCallbacks | undefined; + /** Collect timing information for requests made through the transport. */ + collectTiming?: boolean | undefined; +} + +export type AsyncClientOptions = ClientOptions | AsyncClientTransportOptions; + +export function isAsyncTransportOptions(options: AsyncClientOptions): options is AsyncClientTransportOptions { + return "transport" in options; +} + export function resolveExePath(options: ClientSpawnOptions): string { return options.tsserverPath ?? getExePath(); } @@ -68,5 +113,6 @@ function inferUseCaseSensitiveFileNames(): boolean { export interface LSPConnectionOptions extends ClientSocketOptions { } -export interface APIOptions extends ClientSpawnOptions { -} +export type APIOptions = ClientSpawnOptions | AsyncClientTransportOptions; + +export type SyncAPIOptions = ClientSpawnOptions | ClientTransportOptions; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index be67bd8ca2bc0..2a910129ab66f 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -35,6 +35,11 @@ import { SymbolOwnerKind } from "#enums/symbolOwnerKind"; import { TypeFlags } from "#enums/typeFlags"; import { TypeFormatFlags } from "#enums/typeFormatFlags"; import { TypePredicateKind } from "#enums/typePredicateKind"; +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "#syncClient"; import { type __String, type CallLikeExpression, @@ -73,8 +78,8 @@ import { } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; import type { - APIOptions, LSPConnectionOptions, + SyncAPIOptions as APIOptions, } from "../options.ts"; import { canonicalize, @@ -154,11 +159,6 @@ import type { TimingCollector, TimingInfo, } from "../timing.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, diff --git a/packages/typescript/src/api/sync/browserClient.ts b/packages/typescript/src/api/sync/browserClient.ts new file mode 100644 index 0000000000000..17c493d1b20ff --- /dev/null +++ b/packages/typescript/src/api/sync/browserClient.ts @@ -0,0 +1,22 @@ +import type { + ClientSocketOptions, + ClientSpawnOptions, + SyncClientOptions, +} from "../options.ts"; +import { TransportClient } from "./transportClient.ts"; + +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { ClientTransportOptions, SyncClientOptions as ClientOptions } from "../options.ts"; +export type { SyncTransport } from "./transport.ts"; + +export class Client extends TransportClient { + constructor(options: SyncClientOptions) { + if (!("transport" in options)) { + throw new Error("The browser sync API requires an injected transport"); + } + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); + } +} diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index 42255e9513a1b..5126ffbb0edfc 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -3,44 +3,35 @@ import { encodeFileSystemCallbackResult, } from "../fsCallbacks.ts"; import { - type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, getAPIProcessArgs, isSpawnOptions, + isTransportOptions, resolveExePath, + type SyncClientOptions, } from "../options.ts"; -import type { - APIMethodInfo, - APIRequest, - BatchRequestsParams, - BatchRequestsResponse, - SourceFileResponseMethod, -} from "../proto.ts"; import { SyncRpcChannel } from "../syncChannel.ts"; -import { - combineTimingInfo, - disabledTimingInfo, - type ServerTimingInfo, - TimingCollector, - type TimingInfo, -} from "../timing.ts"; - -export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions }; +import { TransportClient } from "./transportClient.ts"; -export class Client { - private channel: SyncRpcChannel; - private encoder = new TextEncoder(); - private timing: TimingCollector | undefined; - private maxResponseBytesPerPage: number | undefined; +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { ClientTransportOptions, SyncClientOptions as ClientOptions } from "../options.ts"; +export type { SyncTransport } from "./transport.ts"; - constructor(options: ClientOptions) { +export class Client extends TransportClient { + constructor(options: SyncClientOptions) { + if (isTransportOptions(options)) { + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); + return; + } if (!isSpawnOptions(options)) { throw new Error("Socket connections are not yet supported in the sync client"); } const args = getAPIProcessArgs(options, false); - this.maxResponseBytesPerPage = options.maxResponseBytesPerPage; const fsConfiguration = configureFileSystemCallbacks(options.fs); if (fsConfiguration.arguments.length > 0) { @@ -48,12 +39,8 @@ export class Client { } const collectTiming = options.collectTiming ?? false; - if (collectTiming) { - this.timing = new TimingCollector(); - } - const channel = new SyncRpcChannel(resolveExePath(options), args, collectTiming); - this.channel = channel; + super(channel, collectTiming, options.maxResponseBytesPerPage); if (options.fs) { for (const name of fsConfiguration.callbackNames) { @@ -77,113 +64,4 @@ export class Client { } } } - - apiRequest(method: K, params?: APIMethodInfo[K]["params"]): APIMethodInfo[K]["result"] { - const encodedPayload = JSON.stringify(params); - const start = performance.now(); - const result = this.channel.requestSync(method, encodedPayload); - this.recordTiming(method, start); - if (result.length) { - return JSON.parse(result) as APIMethodInfo[K]["result"]; - } - - return undefined as APIMethodInfo[K]["result"]; - } - - registerCallback(name: string, callback: (params: unknown) => unknown): () => void { - this.channel.registerCallback(name, (_, payload) => JSON.stringify(callback(JSON.parse(payload))) ?? ""); - return () => this.channel.unregisterCallback(name); - } - - batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { - const params: BatchRequestsParams = { requests }; - if (this.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const response = this.apiRequest("batchRequests", params); - let responses = response.responses; - let continuationToken = response.continuationToken; - while (continuationToken) { - const pageParams: BatchRequestsParams = { - requests: [], - continuationToken, - }; - if (this.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const page = this.apiRequest("batchRequests", pageParams); - if (page.responses.length < 200) { - responses.push(...page.responses); - } - else { - // If the number of responses is approaching the max argument length, we need to concat instead of push - responses = responses.concat(page.responses); - } - continuationToken = page.continuationToken; - } - return { responses }; - } - - apiRequestBinary(method: K, params?: APIMethodInfo[K]["params"]): Uint8Array | undefined { - const start = performance.now(); - const result = this.channel.requestBinarySync(method, this.encoder.encode(JSON.stringify(params))); - this.recordTiming(method, start); - if (result.length === 0) return undefined; - return result; - } - - echo(payload: string): string { - return this.channel.requestSync("echo", payload); - } - - echoBinary(payload: Uint8Array): Uint8Array { - return this.channel.requestBinarySync("echo", payload); - } - - /** - * Returns a combined timing snapshot: client-measured round-trip and byte - * counts folded together with the server's own per-request processing time - * (fetched via a getServerTiming request) and estimated transport overhead. - */ - getTimingInfo(): TimingInfo { - if (!this.timing) { - return disabledTimingInfo(); - } - const local = this.timing.getInfo(); - // requestSync bypasses recordTiming, so this query does not pollute the - // client-side collector. - const result = this.channel.requestSync("getServerTiming", ""); - return combineTimingInfo(local, JSON.parse(result) as ServerTimingInfo); - } - - resetTimingInfo(): void { - if (!this.timing) return; - this.timing.reset(); - // Keep the server's collection in sync so combined totals stay meaningful. - this.channel.requestSync("resetServerTiming", ""); - } - - /** - * Returns the timing collector that per-node materialization is reported - * into, or undefined when timing collection is disabled. The returned - * collector is the same one folded into {@link getTimingInfo}, so - * materialization totals surface alongside request timings. - */ - getTimingCollector(): TimingCollector | undefined { - return this.timing; - } - - private recordTiming(method: string, start: number): void { - if (!this.timing) return; - this.timing.record({ - method, - roundTripMs: performance.now() - start, - bytesSent: this.channel.lastBytesSent, - bytesReceived: this.channel.lastBytesReceived, - }); - } - - close(): void { - this.channel.close(); - } } diff --git a/packages/typescript/src/api/sync/transport.ts b/packages/typescript/src/api/sync/transport.ts new file mode 100644 index 0000000000000..be2bf1b475e0b --- /dev/null +++ b/packages/typescript/src/api/sync/transport.ts @@ -0,0 +1,21 @@ +/** + * A synchronous request transport connected to a TypeScript API session. + * + * Implementations may use a child process, an in-process WebAssembly reactor, + * or another embedding mechanism. Calls must run to completion before returning. + */ +export interface SyncTransport { + /** Payload bytes sent by the most recently completed request. */ + readonly lastBytesSent: number; + /** Payload bytes received by the most recently completed request. */ + readonly lastBytesReceived: number; + + requestSync(method: string, payload: string): string; + requestBinarySync(method: string, payload: Uint8Array): Uint8Array; + /** Registers a synchronous callback, subject to the transport's reentrancy constraints. */ + registerCallback?(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback?(name: string): void; + setFileSystem?(fs: FileSystemCallbacks | undefined): void; + close(): void; +} +import type { FileSystemCallbacks } from "../fs.ts"; diff --git a/packages/typescript/src/api/sync/transportClient.ts b/packages/typescript/src/api/sync/transportClient.ts new file mode 100644 index 0000000000000..461d4f44ae5b4 --- /dev/null +++ b/packages/typescript/src/api/sync/transportClient.ts @@ -0,0 +1,131 @@ +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import type { SyncTransport } from "./transport.ts"; + +/** Protocol client shared by the process and embedded synchronous transports. */ +export class TransportClient { + private readonly encoder = new TextEncoder(); + private readonly timing: TimingCollector | undefined; + private readonly transport: SyncTransport; + private readonly maxResponseBytesPerPage: number | undefined; + + constructor(transport: SyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + apiRequest(method: K, params?: APIMethodInfo[K]["params"]): APIMethodInfo[K]["result"] { + const encodedPayload = JSON.stringify(params); + const start = performance.now(); + const result = this.transport.requestSync(method, encodedPayload); + this.recordTiming(method, start); + if (result.length) { + return JSON.parse(result) as APIMethodInfo[K]["result"]; + } + return undefined as APIMethodInfo[K]["result"]; + } + + registerCallback(name: string, callback: (params: unknown) => unknown): () => void { + const register = this.transport.registerCallback; + const unregister = this.transport.unregisterCallback; + if (!register || !unregister) { + throw new Error("Callbacks are not supported by this transport"); + } + register.call(this.transport, name, (_, payload) => JSON.stringify(callback(JSON.parse(payload))) ?? ""); + return () => unregister.call(this.transport, name); + } + + batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { + const params: BatchRequestsParams = { requests }; + if (this.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const response = this.apiRequest("batchRequests", params); + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const page = this.apiRequest("batchRequests", pageParams); + if (page.responses.length < 200) { + responses.push(...page.responses); + } + else { + responses = responses.concat(page.responses); + } + continuationToken = page.continuationToken; + } + return { responses }; + } + + echo(payload: string): string { + return this.transport.requestSync("echo", payload); + } + + echoBinary(payload: Uint8Array): Uint8Array { + return this.transport.requestBinarySync("echo", payload); + } + + apiRequestBinary( + method: K, + params?: APIMethodInfo[K]["params"], + ): Uint8Array | undefined { + const start = performance.now(); + const result = this.transport.requestBinarySync(method, this.encoder.encode(JSON.stringify(params))); + this.recordTiming(method, start); + return result.length === 0 ? undefined : result; + } + + getTimingCollector(): TimingCollector | undefined { + return this.timing; + } + + getTimingInfo(): TimingInfo { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + const result = this.transport.requestSync("getServerTiming", ""); + return combineTimingInfo(local, JSON.parse(result) as ServerTimingInfo); + } + + resetTimingInfo(): void { + if (!this.timing) return; + this.timing.reset(); + this.transport.requestSync("resetServerTiming", ""); + } + + close(): void { + this.transport.close(); + } + + private recordTiming(method: string, start: number): void { + if (!this.timing) return; + this.timing.record({ + method, + roundTripMs: performance.now() - start, + bytesSent: this.transport.lastBytesSent, + bytesReceived: this.transport.lastBytesReceived, + }); + } +} diff --git a/packages/typescript/src/wasm/index.ts b/packages/typescript/src/wasm/index.ts new file mode 100644 index 0000000000000..c051713a83026 --- /dev/null +++ b/packages/typescript/src/wasm/index.ts @@ -0,0 +1 @@ +export * from "./transport.ts"; diff --git a/packages/typescript/src/wasm/transport.ts b/packages/typescript/src/wasm/transport.ts new file mode 100644 index 0000000000000..ad591fa7f1f2d --- /dev/null +++ b/packages/typescript/src/wasm/transport.ts @@ -0,0 +1,267 @@ +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import { + registerWasmCallback, + setWasmFileSystem, + unregisterWasmCallback, + type WasmFileSystem, +} from "./wasi.ts"; + +export interface WasmReactorExports { + memory: { readonly buffer: ArrayBufferLike; }; + create_session(optionsPointer: number, optionsLength: number): number; + close_session(): void; + get_request_buffer(size: number): number; + handle_request(methodLength: number, payloadLength: number): number; + set_file(pathLength: number, contentLength: number): number; + read_file(pathLength: number): number; + remove_file(pathLength: number): number; + response_ptr(): number; + response_len(): number; +} + +export interface WasmReactorInstance { + readonly exports: object; +} + +export interface WasmTransportOptions { + /** + * An instantiated reactor whose WASI host has already initialized it. + * For example, call `wasi.initialize(instance)` before constructing the transport. + */ + instance: WasmReactorInstance; + cwd?: string | undefined; + useCaseSensitiveFileNames?: boolean | undefined; + collectTiming?: boolean | undefined; + fs?: WasmFileSystem | undefined; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. + * + * Host callbacks must complete synchronously and cannot call this transport while + * an outer request is in progress. + */ +export class WasmTransport { + lastBytesSent = 0; + lastBytesReceived = 0; + + private readonly instance: WasmReactorInstance; + private readonly exports: WasmReactorExports; + private requestPointer = 0; + private closed = false; + private inCallback = false; + private readonly callbackNames = new Set(); + + constructor(options: WasmTransportOptions) { + this.instance = options.instance; + this.exports = getReactorExports(options.instance); + const sessionOptions = encoder.encode(JSON.stringify({ + cwd: options.cwd ?? "/", + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, + collectTiming: options.collectTiming, + })); + this.writeRequest(sessionOptions); + if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { + throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); + } + try { + this.configureFileSystem(options.fs); + } + catch (error) { + try { + this.exports.close_session(); + } + catch (closeError) { + throw new AggregateError( + [error, closeError], + "Failed to configure the TypeScript WASM filesystem and close its session", + ); + } + throw error; + } + } + + setFileSystem(fs: WasmFileSystem | undefined): void { + this.ensureOpen(); + this.configureFileSystem(fs); + } + + requestSync(method: string, payload: string): string { + return decoder.decode(this.call(method, encoder.encode(payload))); + } + + request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } { + const value = this.requestSync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + requestBinarySync(method: string, payload: Uint8Array): Uint8Array { + return this.call(method, payload); + } + + requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { + const value = this.requestBinarySync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + registerCallback(name: string, callback: (name: string, payload: string) => string): void { + this.ensureOpen(); + registerWasmCallback(this.instance, name, (callbackName, payload) => this.invokeCallback(() => callback(callbackName, payload))); + this.callbackNames.add(name); + } + + unregisterCallback(name: string): void { + if (!this.callbackNames.delete(name)) return; + unregisterWasmCallback(this.instance, name); + } + + setFile(path: string, content: string): void { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + const contentBytes = encoder.encode(content); + this.writeRequest(pathBytes, contentBytes); + if (this.exports.set_file(pathBytes.length, contentBytes.length) !== 0) { + throw new Error(`Failed to write ${path}: ${this.readResponseText()}`); + } + } + + /** Read a file from the reactor's in-memory filesystem. */ + readFile(path: string): string | undefined { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + this.writeRequest(pathBytes); + const status = this.exports.read_file(pathBytes.length); + if (status === 2) return undefined; + if (status !== 0) { + throw new Error(`Failed to read ${path}: ${this.readResponseText()}`); + } + return this.readResponseText(); + } + + removeFile(path: string): void { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + this.writeRequest(pathBytes); + if (this.exports.remove_file(pathBytes.length) !== 0) { + throw new Error(`Failed to remove ${path}: ${this.readResponseText()}`); + } + } + + close(): void { + if (this.closed) return; + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot close the same API transport"); + } + this.closed = true; + try { + this.exports.close_session(); + } + finally { + for (const name of this.callbackNames) { + unregisterWasmCallback(this.instance, name); + } + this.callbackNames.clear(); + setWasmFileSystem(this.instance, undefined); + } + } + + private call(method: string, payload: Uint8Array): Uint8Array { + this.ensureCanEnterWasm(); + const methodBytes = encoder.encode(method); + this.writeRequest(methodBytes, payload); + this.lastBytesSent = payload.length; + if (this.exports.handle_request(methodBytes.length, payload.length) !== 0) { + throw new Error(`TypeScript WASM request "${method}" failed: ${this.readResponseText()}`); + } + this.lastBytesReceived = this.exports.response_len(); + return this.readResponseBytes(); + } + + private writeRequest(first: Uint8Array, second?: Uint8Array): void { + const total = first.length + (second?.length ?? 0); + this.requestPointer = this.exports.get_request_buffer(total) >>> 0; + const memory = new Uint8Array(this.exports.memory.buffer); + memory.set(first, this.requestPointer); + if (second) { + memory.set(second, this.requestPointer + first.length); + } + } + + private readResponseText(): string { + return decoder.decode(this.readResponseBytes()); + } + + private readResponseBytes(): Uint8Array { + const length = this.exports.response_len(); + if (length === 0) return new Uint8Array(); + return new Uint8Array(this.exports.memory.buffer, this.exports.response_ptr() >>> 0, length).slice(); + } + + private ensureOpen(): void { + if (this.closed) { + throw new Error("The TypeScript WASM transport is closed"); + } + } + + private ensureCanEnterWasm(): void { + this.ensureOpen(); + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot call the same API transport"); + } + } + + private configureFileSystem(fs: WasmFileSystem | undefined): void { + const writeFile = fs?.writeFile; + setWasmFileSystem( + this.instance, + typeof writeFile === "function" + ? { writeFile: (path, data) => this.invokeCallback(() => writeFile(path, data)) } + : fs, + ); + } + + private invokeCallback(callback: () => T): T { + const previous = this.inCallback; + this.inCallback = true; + try { + return callback(); + } + finally { + this.inCallback = previous; + } + } +} + +function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { + const exports = instance.exports as unknown as Partial; + const required = [ + "create_session", + "close_session", + "get_request_buffer", + "handle_request", + "set_file", + "read_file", + "remove_file", + "response_ptr", + "response_len", + ] as const; + const missing: string[] = required.filter(name => typeof exports[name] !== "function"); + if (exports.memory == null || typeof exports.memory !== "object" || !("buffer" in exports.memory)) { + missing.push("memory"); + } + if (missing.length > 0) { + throw new Error(`Invalid TypeScript WASM reactor: missing ${missing.join(", ")}`); + } + return exports as WasmReactorExports; +} diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts new file mode 100644 index 0000000000000..62c5b576e2c79 --- /dev/null +++ b/packages/typescript/src/wasm/wasi.ts @@ -0,0 +1,447 @@ +import type { FileSystemCallbacks } from "../api/fs.ts"; +import type { RootedFilePath } from "../ast/index.ts"; +import type { WasmReactorInstance } from "./transport.ts"; + +declare namespace WebAssembly { + interface Module {} + + type Imports = Record>; + + interface Instance { + readonly exports: Record; + } + + const Instance: { + new (module: Module, imports?: Imports): Instance; + }; + + interface Memory { + readonly buffer: ArrayBufferLike; + } + + const Memory: { + readonly prototype: Memory; + new (descriptor: object): Memory; + }; + + function instantiate(module: Module, imports?: Imports): Promise; +} + +interface StreamingTextDecoder { + decode( + input?: ArrayBuffer | ArrayBufferView | null, + options?: { stream?: boolean | undefined; }, + ): string; +} + +const errnoSuccess = 0; +const errnoBadFileDescriptor = 8; +const errnoInvalidArgument = 28; +const errnoIo = 29; +const errnoNoSys = 52; +const fileTypeCharacterDevice = 2; +const eventTypeClock = 0; +const subscriptionClockAbstime = 1; +const hostCallbackFD = 0x7fff_fffd; +const hostWriteFileFD = 0x7fff_fffe; + +export interface InstantiateWasmOptions { + stdout?: ((text: string) => void) | undefined; + stderr?: ((text: string) => void) | undefined; +} + +export interface WasmFileSystem { + writeFile?: FileSystemCallbacks["writeFile"] | undefined; +} + +interface WasmHost { + setFileSystem(fs: WasmFileSystem | undefined): void; + registerCallback(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback(name: string): void; +} + +const wasmHosts = new WeakMap(); + +export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { + const host = wasmHosts.get(instance); + if (!host) { + if (fs === undefined) return; + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.setFileSystem(fs); +} + +export function registerWasmCallback( + instance: WasmReactorInstance, + name: string, + callback: (name: string, payload: string) => string, +): void { + const host = wasmHosts.get(instance); + if (!host) { + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.registerCallback(name, callback); +} + +export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { + wasmHosts.get(instance)?.unregisterCallback(name); +} + +/** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ +export async function instantiateWasm( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): Promise { + const host = createWasiHost(options); + const instance = await WebAssembly.instantiate(module, host.imports); + return host.initialize(instance); +} + +/** + * Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. + * + * Prefer `instantiateWasm` on browser main threads, where synchronous + * instantiation of large WebAssembly modules may be rejected. + */ +export function instantiateWasmSync( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): WasmReactorInstance { + const host = createWasiHost(options); + const instance = new WebAssembly.Instance(module, host.imports); + return host.initialize(instance); +} + +function createWasiHost(options: InstantiateWasmOptions): { + imports: WebAssembly.Imports; + initialize(instance: WebAssembly.Instance): WasmReactorInstance; +} { + let instance: WebAssembly.Instance | undefined; + const stdout = options.stdout ?? (text => console.log(text)); + const stderr = options.stderr ?? (text => console.error(text)); + const decoders = new Map(); + const encoder = new TextEncoder(); + const callbacks = new Map string>(); + const closedDescriptors = new Set(); + let fileSystem: WasmFileSystem | undefined; + + function getMemory(): WebAssembly.Memory { + const memory = instance?.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error("TypeScript WASM reactor did not export its memory"); + } + return memory; + } + + function getView(): DataView { + return new DataView(getMemory().buffer); + } + + function argsSizesGet(countPointer: number, sizePointer: number): number { + countPointer >>>= 0; + sizePointer >>>= 0; + const view = getView(); + view.setUint32(countPointer, 0, true); + view.setUint32(sizePointer, 0, true); + return errnoSuccess; + } + + function clockTimeGet(clockId: number, _precision: bigint, timePointer: number): number { + timePointer >>>= 0; + let nanoseconds: bigint; + switch (clockId) { + case 0: + nanoseconds = BigInt(Date.now()) * 1_000_000n; + break; + case 1: + nanoseconds = BigInt(Math.round(performance.now() * 1e6)); + break; + default: + return errnoInvalidArgument; + } + getView().setBigUint64(timePointer, nanoseconds, true); + return errnoSuccess; + } + + function fdFdstatGet(fd: number, statPointer: number): number { + statPointer >>>= 0; + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; + const memory = getMemory(); + new Uint8Array(memory.buffer, statPointer, 24).fill(0); + const view = new DataView(memory.buffer); + view.setUint8(statPointer, fileTypeCharacterDevice); + const rights = fd === 0 ? 1n << 1n : 1n << 6n; + view.setBigUint64(statPointer + 8, rights, true); + return errnoSuccess; + } + + function fdFdstatSetFlags(fd: number, _flags: number): number { + return isOpenStdioDescriptor(fd) ? errnoSuccess : errnoBadFileDescriptor; + } + + function fdRead(fd: number, _iovsPointer: number, _iovsLength: number, readPointer: number): number { + readPointer >>>= 0; + if (fd !== 0 || closedDescriptors.has(fd)) return errnoBadFileDescriptor; + getView().setUint32(readPointer, 0, true); + return errnoSuccess; + } + + function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { + iovsPointer >>>= 0; + writtenPointer >>>= 0; + if (fd === hostWriteFileFD) { + return hostWriteFile(iovsPointer, iovsLength, writtenPointer); + } + if (fd === hostCallbackFD) { + return hostCallback(iovsPointer, iovsLength, writtenPointer); + } + if ((fd !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const chunks: Uint8Array[] = []; + let length = 0; + for (let i = 0; i < iovsLength; i++) { + const iovPointer = iovsPointer + i * 8; + const chunkPointer = view.getUint32(iovPointer, true); + const chunkLength = view.getUint32(iovPointer + 4, true); + chunks.push(new Uint8Array(memory.buffer, chunkPointer, chunkLength)); + length += chunkLength; + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + view.setUint32(writtenPointer, length, true); + if (length === 0) return errnoSuccess; + const decoder = decoders.get(fd) ?? new TextDecoder(); + decoders.set(fd, decoder); + const text = decoder.decode(bytes, { stream: true }); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + return errnoSuccess; + } + + function fdClose(fd: number): number { + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; + closedDescriptors.add(fd); + const decoder = decoders.get(fd); + if (decoder) { + decoders.delete(fd); + const text = decoder.decode(); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + } + return errnoSuccess; + } + + function isOpenStdioDescriptor(fd: number): boolean { + return fd >= 0 && fd <= 2 && !closedDescriptors.has(fd); + } + + function hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { + if (iovsLength !== 1) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const bufferPointer = view.getUint32(iovsPointer, true); + const bufferLength = view.getUint32(iovsPointer + 4, true); + if (bufferLength < 16) return errnoInvalidArgument; + const methodLength = view.getUint32(bufferPointer, true); + const payloadLength = view.getUint32(bufferPointer + 4, true); + const resultCapacity = view.getUint32(bufferPointer + 8, true); + const errorCapacity = view.getUint32(bufferPointer + 12, true); + if (16 + methodLength + payloadLength + resultCapacity + errorCapacity > bufferLength) { + return errnoInvalidArgument; + } + const methodPointer = bufferPointer + 16; + const payloadPointer = methodPointer + methodLength; + const resultPointer = payloadPointer + payloadLength; + const errorPointer = resultPointer + resultCapacity; + const decoder = new TextDecoder(); + const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); + const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); + try { + const callback = callbacks.get(method); + if (!callback) { + throw new Error(`No callback is registered for ${method}`); + } + const result = encoder.encode(callback(method, payload)); + if (result.length > resultCapacity) { + throw new Error(`Callback result exceeds ${resultCapacity} bytes`); + } + const resultMemory = getMemory(); + const resultView = new DataView(resultMemory.buffer); + new Uint8Array(resultMemory.buffer, resultPointer, result.length).set(result); + resultView.setUint32(bufferPointer + 8, result.length, true); + resultView.setUint32(bufferPointer + 12, 0, true); + resultView.setUint32(writtenPointer, bufferLength, true); + return errnoSuccess; + } + catch (error) { + const errorBytes = encoder.encode(error instanceof Error ? error.message : String(error)); + const errorLength = Math.min(errorBytes.length, errorCapacity); + const errorMemory = getMemory(); + const errorView = new DataView(errorMemory.buffer); + new Uint8Array(errorMemory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); + errorView.setUint32(bufferPointer + 8, 0, true); + errorView.setUint32(bufferPointer + 12, errorLength, true); + errorView.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + function randomGet(bufferPointer: number, bufferLength: number): number { + bufferPointer >>>= 0; + const buffer = new Uint8Array(getMemory().buffer, bufferPointer, bufferLength); + for (let offset = 0; offset < buffer.length; offset += 65_536) { + crypto.getRandomValues(buffer.subarray(offset, Math.min(offset + 65_536, buffer.length))); + } + return errnoSuccess; + } + + function hostWriteFile(iovsPointer: number, iovsLength: number, writtenPointer: number): number { + iovsPointer >>>= 0; + writtenPointer >>>= 0; + if (typeof fileSystem?.writeFile !== "function") return errnoBadFileDescriptor; + if (iovsLength !== 1) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const bufferPointer = view.getUint32(iovsPointer, true); + const bufferLength = view.getUint32(iovsPointer + 4, true); + if (bufferLength < 12) return errnoInvalidArgument; + const pathLength = view.getUint32(bufferPointer, true); + const dataLength = view.getUint32(bufferPointer + 4, true); + const errorCapacity = view.getUint32(bufferPointer + 8, true); + if (12 + pathLength + dataLength + errorCapacity !== bufferLength) return errnoInvalidArgument; + const decoder = new TextDecoder(); + const pathPointer = bufferPointer + 12; + const dataPointer = pathPointer + pathLength; + const errorPointer = dataPointer + dataLength; + const path = decoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); + const data = decoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); + try { + fileSystem.writeFile(path as RootedFilePath, data); + new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); + return errnoSuccess; + } + catch (error) { + const errorBytes = encoder.encode(error instanceof Error ? error.message : String(error)); + const errorLength = Math.min(errorBytes.length, errorCapacity); + const errorMemory = getMemory(); + const errorView = new DataView(errorMemory.buffer); + new Uint8Array(errorMemory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); + errorView.setUint32(bufferPointer + 8, errorLength, true); + errorView.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + function pollOneoff( + subscriptionsPointer: number, + eventsPointer: number, + subscriptionsLength: number, + eventsLengthPointer: number, + ): number { + subscriptionsPointer >>>= 0; + eventsPointer >>>= 0; + eventsLengthPointer >>>= 0; + if (subscriptionsLength === 0) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + let selected = -1; + let shortestDelay = Number.POSITIVE_INFINITY; + for (let i = 0; i < subscriptionsLength; i++) { + const subscription = subscriptionsPointer + i * 48; + if (view.getUint8(subscription + 8) !== eventTypeClock) continue; + const clockId = view.getUint32(subscription + 16, true); + if (clockId !== 0 && clockId !== 1) return errnoInvalidArgument; + const timeout = view.getBigUint64(subscription + 24, true); + const flags = view.getUint16(subscription + 40, true); + const now = clockId === 0 ? Date.now() : performance.now(); + const delay = flags & subscriptionClockAbstime + ? Number(timeout) / 1e6 - now + : Number(timeout) / 1e6; + if (delay < shortestDelay) { + selected = subscription; + shortestDelay = delay; + } + } + if (selected < 0) return errnoNoSys; + + const deadline = performance.now() + Math.max(0, shortestDelay); + while (performance.now() < deadline) { + // WASI imports are synchronous, so a clock subscription must wait here. + } + + new Uint8Array(memory.buffer, eventsPointer, 32).fill(0); + view.setBigUint64(eventsPointer, view.getBigUint64(selected, true), true); + view.setUint8(eventsPointer + 10, eventTypeClock); + view.setUint32(eventsLengthPointer, 1, true); + return errnoSuccess; + } + + function unsupported(): number { + return errnoNoSys; + } + + const wasi = { + args_get: () => errnoSuccess, + args_sizes_get: argsSizesGet, + clock_time_get: clockTimeGet, + environ_get: () => errnoSuccess, + environ_sizes_get: argsSizesGet, + fd_close: fdClose, + fd_fdstat_get: fdFdstatGet, + fd_fdstat_set_flags: fdFdstatSetFlags, + fd_filestat_get: unsupported, + fd_pread: unsupported, + fd_prestat_dir_name: unsupported, + fd_prestat_get: () => errnoBadFileDescriptor, + fd_read: fdRead, + fd_readdir: unsupported, + fd_write: fdWrite, + path_create_directory: unsupported, + path_filestat_get: unsupported, + path_filestat_set_times: unsupported, + path_open: unsupported, + path_readlink: unsupported, + path_remove_directory: unsupported, + path_unlink_file: unsupported, + poll_oneoff: pollOneoff, + proc_exit: (code: number) => { + throw new Error(`TypeScript WASM runtime exited with code ${code}`); + }, + random_get: randomGet, + sched_yield: () => errnoSuccess, + sock_accept: unsupported, + }; + + return { + imports: { + wasi_snapshot_preview1: wasi, + }, + initialize(value) { + instance = value; + const initialize = instance.exports.typescript_initialize; + if (typeof initialize !== "function") { + throw new Error("TypeScript WASM reactor did not export typescript_initialize"); + } + initialize(); + wasmHosts.set(instance, { + setFileSystem(value) { + fileSystem = value; + }, + registerCallback(name, callback) { + callbacks.set(name, callback); + }, + unregisterCallback(name) { + callbacks.delete(name); + }, + }); + return instance; + }, + }; +} diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts new file mode 100644 index 0000000000000..50c13cc14f4f5 --- /dev/null +++ b/packages/typescript/test/async/transport.test.ts @@ -0,0 +1,388 @@ +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/unstable/async"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { TransportClient } from "../../src/api/async/transportClient.ts"; +import type { + BatchRequestsParams, + BatchRequestsResponse, + InferredProjectId, +} from "../../src/api/proto.ts"; + +describe("async transport", () => { + test("constructs an API over an injected transport", async () => { + const methods: string[] = []; + let batchParams: BatchRequestsParams | undefined; + let closed = false; + const api = new API({ + maxResponseBytesPerPage: 1, + transport: { + request(method, payload) { + methods.push(method); + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + case "batchRequests": { + batchParams = JSON.parse(payload) as BatchRequestsParams; + assert.ok(batchParams.requests); + const response: BatchRequestsResponse = { + responses: batchParams.requests.map(request => ({ + method: request.method, + result: { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + })), + }; + return { + value: JSON.stringify(response), + bytesSent: 0, + bytesReceived: 0, + }; + } + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() { + closed = true; + }, + }, + }); + + assert.deepStrictEqual(await api.parseCommandLine([]), { + options: {}, + fileNames: [], + errors: [], + }); + let batchRequests: Promise[]; + { + using _ = api.batchContext(); + batchRequests = [api.parseCommandLine([]), api.parseCommandLine([])]; + } + await Promise.all(batchRequests); + assert.deepStrictEqual(methods, ["initialize", "parseCommandLine", "batchRequests"]); + assert.strictEqual(batchParams?.maxResponseBytesPerPage, 1); + await api.close(); + assert.strictEqual(closed, true); + }); + + test("rejects requests queued when the transport closes", async () => { + const api = new API({ + transport: { + request(method) { + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + await api.parseCommandLine([]); + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("can close while initialization is manually batched", async () => { + const api = new API({ + transport: { + request(method) { + if (method !== "initialize") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("can close a process client while initialization is manually batched", async () => { + const api = new API({ cwd: process.cwd() }); + await api["client"].connect(); + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("does not flush a manual batch from a stale microtask", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + if (method !== "parseCommandLine") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const first = client.apiRequest("parseCommandLine", { commandLine: [] }); + const batch = client.batchContext(); + const second = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + assert.deepStrictEqual(methods, ["parseCommandLine"]); + batch[Symbol.dispose](); + await Promise.all([first, second]); + await client.close(); + }); + + test("keeps binary requests ordered inside a manual batch", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary(method, payload) { + methods.push(method); + return { + value: payload, + bytesSent: payload.length, + bytesReceived: payload.length, + }; + }, + close() {}, + }, false); + + await client.connect(); + const batch = client.batchContext(); + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project" as InferredProjectId, + file: "/index.ts", + }); + await Promise.resolve(); + assert.deepStrictEqual(methods, []); + batch[Symbol.dispose](); + await Promise.all([text, binary]); + assert.deepStrictEqual(methods, ["parseCommandLine", "getSourceFile"]); + await client.close(); + }); + + test("rejects requests that are still active when closed", async () => { + let resolveRequest: + | ((value: { + value: string; + bytesSent: number; + bytesReceived: number; + }) => void) + | undefined; + const client = new TransportClient({ + request() { + return new Promise(resolve => { + resolveRequest = resolve; + }); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const request = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + await client.close(); + await assert.rejects(request, /Client is closed/); + resolveRequest?.({ + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }); + }); + + test("can close while initialization is in flight", async () => { + const api = new API({ + transport: { + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await Promise.resolve(); + await api.close(); + await rejected; + }); + + test("rejects timing requests when closed", async () => { + const client = new TransportClient({ + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, true); + + await client.connect(); + const timing = client.getTimingInfo(); + const reset = client.resetTimingInfo(); + await client.close(); + await assert.rejects(timing, /Client is closed/); + await assert.rejects(reset, /Client is closed/); + }); + + test("does not queue requests after closing during connect", async () => { + const client = new TransportClient({ + request() { + throw new Error("Unexpected request"); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project" as InferredProjectId, + file: "/index.ts", + }); + await client.close(); + await assert.rejects(text, /Client is closed/); + await assert.rejects(binary, /Client is closed/); + }); + + test("rejects thenable callback results", () => { + let registeredCallback: ((name: string, payload: string) => string) | undefined; + const client = new TransportClient({ + request() { + throw new Error("Unexpected request"); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + registerCallback(_name, callback) { + registeredCallback = callback; + }, + unregisterCallback() {}, + }, false); + + client.registerCallback("callback", () => ({ then() {} })); + assert.throws( + () => registeredCallback?.("callback", "null"), + /Injected transport callbacks must complete synchronously/, + ); + }); +}); diff --git a/packages/typescript/test/browser/apiWrapper.ts b/packages/typescript/test/browser/apiWrapper.ts new file mode 100644 index 0000000000000..2ae04637d0e2f --- /dev/null +++ b/packages/typescript/test/browser/apiWrapper.ts @@ -0,0 +1,204 @@ +import type { FileSystemCallbacks } from "@typescript/typescript/unstable/fs"; +import { + toRootedDirectoryPath, + toRootedFilePath, +} from "@typescript/typescript/unstable/path"; +import { + instantiateWasm, + type WasmReactorInstance, + WasmTransport, +} from "@typescript/typescript/unstable/wasm"; +import { + type DocumentIdentifier, + type FileNotifications, + resolveFileName, +} from "../../src/api/proto.ts"; + +interface BrowserAPIOptions { + cwd?: string | undefined; + fs?: FileSystemCallbacks | undefined; + collectTiming?: boolean | undefined; + maxResponseBytesPerPage?: number | undefined; + transport?: object | undefined; +} + +interface BrowserSnapshotChanges { + fileNotifications?: FileNotifications | undefined; + openFiles?: readonly DocumentIdentifier[] | undefined; +} + +const availableInstances: WasmReactorInstance[] = []; +const mirroredFiles = new WeakMap>(); + +export async function initializeBrowserAPIInstances(module: WebAssembly.Module, count: number): Promise { + availableInstances.push( + ...await Promise.all( + Array.from({ length: count }, () => instantiateWasm(module)), + ), + ); +} + +class BrowserWasmTransport extends WasmTransport { + private returned = false; + + constructor( + private readonly reactorInstance: WasmReactorInstance, + options: ConstructorParameters[0], + ) { + super(options); + } + + override close(): void { + if (this.returned) return; + super.close(); + this.returned = true; + availableInstances.push(this.reactorInstance); + } +} + +export function createBrowserAPIOptions(options: BrowserAPIOptions): { + options: BrowserAPIOptions; + transport?: WasmTransport; +} { + if (options.transport) return { options }; + const instance = availableInstances.pop(); + if (!instance) { + throw new Error("No initialized TypeScript WASM reactor is available"); + } + const writeFile = typeof options.fs?.writeFile === "function" ? options.fs.writeFile : undefined; + const transport = new BrowserWasmTransport(instance, { + instance, + cwd: options.cwd ?? "/", + ...writeFile === undefined ? {} : { fs: { writeFile: writeFile.bind(options.fs) } }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + }); + if (options.fs) { + synchronizeFileSystem(options.fs, transport); + } + return { + options: { + transport, + ...options.fs === undefined ? {} : { fs: options.fs }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + ...options.maxResponseBytesPerPage === undefined ? {} : { maxResponseBytesPerPage: options.maxResponseBytesPerPage }, + }, + transport, + }; +} + +export function wrapFileUpdates(api: T, fs: FileSystemCallbacks | undefined, transport: WasmTransport | undefined): void { + if (!fs || !transport) return; + wrapMethod("createSnapshot", args => { + const params = args[0] as BrowserSnapshotChanges | undefined; + syncFileChanges(fs, transport, params?.fileNotifications); + syncOpenFiles(fs, transport, params); + }); + wrapMethod("updateSnapshot", args => { + const params = args[1] as BrowserSnapshotChanges | undefined; + syncFileChanges(fs, transport, params?.fileNotifications); + syncOpenFiles(fs, transport, params); + }); + + function wrapMethod(name: string, before: (args: unknown[]) => void): void { + const target = api as Record; + const original = target[name] as ((...args: unknown[]) => unknown) & { + gen?: (...args: unknown[]) => Generator; + }; + const bound = original.bind(api); + const wrapped = (...args: unknown[]) => { + before(args); + return bound(...args); + }; + if (original.gen) { + const boundGen = original.gen.bind(api); + wrapped.gen = (...args: unknown[]) => { + before(args); + return boundGen(...args); + }; + } + Object.defineProperty(api, name, { + configurable: true, + value: wrapped, + }); + } +} + +function syncOpenFiles( + fs: FileSystemCallbacks, + transport: WasmTransport, + changes: BrowserSnapshotChanges | undefined, +): void { + for (const file of changes?.openFiles ?? []) { + const fileName = resolveFileName(file); + const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(fileName, undefined)) : undefined; + if (typeof content === "string") { + transport.setFile(fileName, content); + mirroredFiles.get(transport)?.add(fileName); + } + else { + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); + } + } +} + +function synchronizeFileSystem(fs: FileSystemCallbacks, transport: WasmTransport): void { + const previous = mirroredFiles.get(transport) ?? new Set(); + const current = new Set(); + visit("/"); + for (const file of previous) { + if (!current.has(file)) { + transport.removeFile(file); + } + } + mirroredFiles.set(transport, current); + + function visit(directory: string): void { + const entries = typeof fs.getAccessibleEntries === "function" + ? fs.getAccessibleEntries(toRootedDirectoryPath(directory, undefined)) + : undefined; + if (!entries || typeof entries !== "object") return; + for (const file of entries.files) { + const path = join(directory, file); + current.add(path); + const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(path, undefined)) : undefined; + if (typeof content === "string") { + transport.setFile(path, content); + } + } + for (const child of entries.directories) { + visit(join(directory, child)); + } + } +} + +function syncFileChanges( + fs: FileSystemCallbacks, + transport: WasmTransport, + changes: FileNotifications | undefined, +): void { + if (changes?.invalidateAll) { + synchronizeFileSystem(fs, transport); + } + for (const file of [...changes?.changed ?? [], ...changes?.created ?? []]) { + const fileName = resolveFileName(file); + const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(fileName, undefined)) : undefined; + if (typeof content === "string") { + transport.setFile(fileName, content); + mirroredFiles.get(transport)?.add(fileName); + } + else { + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); + } + } + for (const file of changes?.deleted ?? []) { + const fileName = resolveFileName(file); + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); + } +} + +function join(directory: string, name: string): string { + return directory === "/" ? `/${name}` : `${directory}/${name}`; +} diff --git a/packages/typescript/test/browser/asyncAPI.ts b/packages/typescript/test/browser/asyncAPI.ts new file mode 100644 index 0000000000000..f13ace06de468 --- /dev/null +++ b/packages/typescript/test/browser/asyncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/async/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/async/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as unknown as Parameters[0]; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/browser/bundle.test.ts b/packages/typescript/test/browser/bundle.test.ts new file mode 100644 index 0000000000000..54b9db8adef44 --- /dev/null +++ b/packages/typescript/test/browser/bundle.test.ts @@ -0,0 +1,38 @@ +import { build } from "esbuild"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; + +describe("browser API bundle", () => { + test("uses browser clients without Node dependencies", async () => { + const result = await build({ + stdin: { + contents: ` + import { wasmURL } from "@typescript/typescript-wasip1-wasm"; + import { API as AsyncAPI } from "typescript/unstable/async"; + import { API as SyncAPI } from "typescript/unstable/sync"; + import { WasmTransport } from "typescript/unstable/wasm"; + globalThis.typescriptAPI = { AsyncAPI, SyncAPI, WasmTransport, wasmURL }; + `, + loader: "ts", + resolveDir: process.cwd(), + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source", "browser"], + format: "esm", + platform: "browser", + write: false, + }); + + assert.strictEqual(result.outputFiles.length, 1); + const output = result.outputFiles[0].text; + assert.doesNotMatch(output, /(?:from|import)\s*\(?["']node:/); + assert.match(output, /The browser async API requires an injected transport/); + assert.match(output, /The browser sync API requires an injected transport/); + }); +}); diff --git a/packages/typescript/test/browser/harness.test.ts b/packages/typescript/test/browser/harness.test.ts new file mode 100644 index 0000000000000..5a1194ab8a4cb --- /dev/null +++ b/packages/typescript/test/browser/harness.test.ts @@ -0,0 +1,40 @@ +import assert from "node:assert"; +import { test as nodeTest } from "node:test"; +import defaultTest, { + describe, + runRegisteredTests, + test, +} from "./harness.ts"; + +nodeTest("browser test harness preserves skip options", async () => { + assert.strictEqual(defaultTest, test); + test("skipped test", { skip: "test reason" }, () => assert.fail("skipped test ran")); + describe("skipped suite", { skip: "suite reason" }, () => { + test("nested test", () => assert.fail("skipped suite ran")); + }); + describe("nested suite", () => { + test("passing test", () => {}); + }); + test("mock method", context => { + const target = { + value: 0, + increment(amount: number) { + this.value += amount; + return this.value; + }, + }; + const method = context.mock.method(target, "increment"); + assert.strictEqual(target.increment(2), 2); + assert.strictEqual(method.mock.callCount(), 1); + }); + + assert.deepStrictEqual(await runRegisteredTests([]), { + total: 4, + passed: 2, + skipped: [ + { name: "skipped test", reason: "test reason" }, + { name: "skipped suite > nested test", reason: "suite reason" }, + ], + failures: [], + }); +}); diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts new file mode 100644 index 0000000000000..7c8ca6c5bce9c --- /dev/null +++ b/packages/typescript/test/browser/harness.ts @@ -0,0 +1,211 @@ +interface RegisteredTest { + name: string; + run: (context: BrowserTestContext) => unknown; + skip?: string; +} + +interface RegisteredSuite { + name: string; + skip?: string; +} + +interface BrowserTestContext { + after(callback: () => unknown): void; + mock: { + method( + target: T, + name: K, + implementation?: T[K] | undefined, + ): T[K] & { mock: { callCount(): number; }; }; + }; +} + +export interface BrowserTestFailure { + name: string; + message: string; + stack?: string; +} + +export interface BrowserTestSkip { + name: string; + reason: string; +} + +export interface BrowserTestResults { + total: number; + passed: number; + skipped: BrowserTestSkip[]; + failures: BrowserTestFailure[]; +} + +export interface BrowserTestExclusion { + pattern: RegExp; + reason: string; +} + +const tests: RegisteredTest[] = []; +const suites: RegisteredSuite[] = []; + +interface TestOptions { + skip?: boolean | string; +} + +export function describe(name: string, options: TestOptions, run: () => void): void; +export function describe(name: string, run: () => void): void; +export function describe(name: string, optionsOrRun: TestOptions | (() => void), run?: () => void): void { + const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; + const skip = currentSuiteSkip() ?? (typeof optionsOrRun === "function" ? undefined : skipReason(optionsOrRun.skip)); + suites.push({ + name, + ...skip === undefined ? {} : { skip }, + }); + try { + callback(); + } + finally { + suites.pop(); + } +} + +export function test(name: string, options: TestOptions, run: (context: BrowserTestContext) => unknown): void; +export function test(name: string, run: (context: BrowserTestContext) => unknown): void; +export function test( + name: string, + optionsOrRun: TestOptions | ((context: BrowserTestContext) => unknown), + run?: (context: BrowserTestContext) => unknown, +): void { + const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; + const skip = currentSuiteSkip() ?? (typeof optionsOrRun === "function" ? undefined : skipReason(optionsOrRun.skip)); + tests.push({ + name: [...suites.map(suite => suite.name), name].join(" > "), + run: callback, + ...skip === undefined ? {} : { skip }, + }); +} + +export default test; + +export async function runRegisteredTests(exclusions: readonly BrowserTestExclusion[], timeoutMs = 10_000): Promise { + const failures: BrowserTestFailure[] = []; + const skipped: BrowserTestSkip[] = []; + let passed = 0; + for (const registered of tests) { + if (registered.skip) { + skipped.push({ + name: registered.name, + reason: registered.skip, + }); + continue; + } + const exclusion = exclusions.find(entry => entry.pattern.test(registered.name)); + if (exclusion) { + skipped.push({ + name: registered.name, + reason: exclusion.reason, + }); + continue; + } + try { + Reflect.set(globalThis, "browserTestProgress", registered.name); + const restorations: (() => void)[] = []; + const context: BrowserTestContext = { + after(callback) { + restorations.push(callback); + }, + mock: { + method: ( + target: T, + name: K, + implementation?: T[K] | undefined, + ): T[K] & { mock: { callCount(): number; }; } => { + const descriptor = Object.getOwnPropertyDescriptor(target, name); + const original = target[name]; + const callback = implementation ?? original; + if (typeof callback !== "function") { + throw new TypeError(`Cannot mock non-function property ${String(name)}`); + } + let callCount = 0; + const wrapped = function (this: unknown, ...args: unknown[]) { + callCount++; + return callback.apply(this, args); + }; + wrapped.mock = { + callCount: () => callCount, + }; + Object.defineProperty(target, name, { + configurable: true, + value: wrapped, + writable: true, + }); + restorations.push(() => { + if (descriptor) { + Object.defineProperty(target, name, descriptor); + } + else { + delete target[name]; + } + }); + return wrapped as T[K] & { mock: { callCount(): number; }; }; + }, + }, + }; + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.resolve().then(() => registered.run(context)), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Test timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } + finally { + clearTimeout(timer); + for (let i = restorations.length - 1; i >= 0; i--) { + await restorations[i](); + } + } + passed++; + } + catch (error) { + const normalized = normalizeError(error); + failures.push({ + name: registered.name, + message: normalized.message, + ...normalized.stack === undefined ? {} : { stack: normalized.stack }, + }); + } + } + return { total: tests.length, passed, skipped, failures }; +} + +function currentSuiteSkip(): string | undefined { + for (let i = suites.length - 1; i >= 0; i--) { + if (suites[i].skip) { + return suites[i].skip; + } + } + return undefined; +} + +function skipReason(skip: boolean | string | undefined): string | undefined { + if (!skip) { + return undefined; + } + return typeof skip === "string" ? skip : "Skipped by test option"; +} + +function normalizeError(error: unknown): Error { + if ( + error instanceof Error + && error.name === "SuppressedError" + && "error" in error + && "suppressed" in error + ) { + const primary = normalizeError(error.error); + const suppressed = normalizeError(error.suppressed); + return new Error(`${primary.message}\nSuppressed during disposal: ${suppressed.message}`, { + cause: primary, + }); + } + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/typescript/test/browser/processShim.ts b/packages/typescript/test/browser/processShim.ts new file mode 100644 index 0000000000000..6332e4d8982af --- /dev/null +++ b/packages/typescript/test/browser/processShim.ts @@ -0,0 +1,13 @@ +import process from "process"; + +(process as { execArgv?: string[]; }).execArgv ??= []; + +export { process }; + +export function setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): ReturnType { + return setTimeout(callback, 0, ...args); +} + +export function clearImmediate(handle: ReturnType): void { + clearTimeout(handle); +} diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts new file mode 100644 index 0000000000000..2c6057a69cd55 --- /dev/null +++ b/packages/typescript/test/browser/suite.test.ts @@ -0,0 +1,318 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import { + build, + type Plugin, +} from "esbuild"; +import assert from "node:assert"; +import { globSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { + after, + before, + describe, + test, +} from "node:test"; +import { fileURLToPath } from "node:url"; +import { + type Browser, + chromium, +} from "playwright"; +import type { BrowserTestResults } from "./harness.ts"; + +const browserDir = fileURLToPath(new URL(".", import.meta.url)); +const packageDir = path.resolve(browserDir, "../.."); +const modes = ["async", "sync"] as const; +const fileExclusions = [ + { + mode: "async", + file: "version.test.ts", + tests: 2, + reason: "These tests exercise Node.js executable and package resolution behavior.", + }, + { + mode: "async", + file: "async/astnav.test.ts", + tests: 4, + reason: "These tests read compiler fixtures and Go baselines from the repository filesystem.", + }, + { + mode: "sync", + file: "sync/astnav.test.ts", + tests: 4, + reason: "These tests read compiler fixtures and Go baselines from the repository filesystem.", + }, + { + mode: "sync", + file: "sync/wasm.test.ts", + tests: 5, + reason: "These Node.js integration tests use node:fs and node:wasi; wasm.test.ts covers browser API loading.", + }, + { + mode: "sync", + file: "sync/shutdown.test.ts", + tests: 1, + reason: "This test launches a Node.js child process.", + }, +] as const; + +describe("API test suite in a browser", () => { + let browser: Browser; + let origin: string; + const bundles = new Map(); + const server = createServer(); + + before(async () => { + const allTestFiles = globSync(["*.test.ts", "async/**/*.test.ts", "sync/**/*.test.ts"], { + cwd: path.join(packageDir, "test"), + }); + for (const mode of modes) { + const testFiles = allTestFiles.filter(file => { + const belongsToMode = mode === "async" + ? !file.includes("/") || file.startsWith("async/") + : file.startsWith("sync/"); + return belongsToMode && !fileExclusions.some(exclusion => exclusion.file === file); + }); + const result = await build({ + stdin: { + contents: ` + ${testFiles.map(file => `import ${JSON.stringify(path.join(packageDir, "test", file))};`).join("\n")} + import { runRegisteredTests } from ${JSON.stringify(path.join(browserDir, "harness.ts"))}; + import { initializeBrowserAPIInstances } from ${JSON.stringify(path.join(browserDir, "apiWrapper.ts"))}; + try { + const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); + await initializeBrowserAPIInstances(module, 4); + globalThis.browserTestResults = await runRegisteredTests([ + { + pattern: /benchmarks$/i, + reason: "Benchmarks require Node.js process and filesystem APIs.", + }, + { + pattern: /updateSnapshot file systems > (?:full file system is total and does not invoke host callbacks|full file system accepts paths decoded from VS Code document URIs|file system layer bypasses callbacks on hits and falls back on misses)$/, + reason: "The browser WASM adapter mirrors callback files into reactor memory instead of exposing live host callback layering.", + }, + { + pattern: /module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)$/, + reason: "An in-thread WebAssembly reactor cannot service nested API requests while a host callback is in flight.", + }, + ...${JSON.stringify(mode)} === "async" + ? [{ + pattern: /source imports do not request module resolution$/, + reason: "The async resolver returns a Promise, which cannot complete inside a synchronous WebAssembly host callback.", + }] + : [], + { + pattern: /can close a process client while initialization is manually batched$/, + reason: "This test exercises the Node.js spawned-process client.", + }, + { + pattern: /Parse-clone-emit roundtrip$/, + reason: "The roundtrip fixture discovers source files through Node.js glob and filesystem APIs.", + }, + { + pattern: /parseJsonConfigFileContent accepts non-object JSON$|parseConfigFile$|project exposes parsedCommandLine$/, + reason: "These assertions depend on insertion-ordered callback filesystem directory listings.", + }, + { + pattern: /BuildOrchestrator > (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)$/, + reason: "The browser WASM adapter does not round-trip build output writes and removals through the mirrored host filesystem.", + }, + { + pattern: /readFile callback semantics > (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting|configured case sensitivity is used by the server and client)$/, + reason: "These assertions exercise spawned-process callback configuration that is not used by an injected WASM transport.", + }, + ]); + } + catch (error) { + globalThis.browserTestError = { + message: String(error), + stack: error instanceof Error ? error.stack : undefined, + }; + } + `, + loader: "ts", + resolveDir: packageDir, + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source", "browser"], + format: "esm", + inject: [path.join(browserDir, "processShim.ts")], + platform: "browser", + plugins: [browserTestPlugin(mode)], + write: false, + }); + assert.strictEqual(result.outputFiles.length, 1); + bundles.set(mode, result.outputFiles[0].contents); + } + const wasm = await readFile(wasmURL); + + server.on("request", (request, response) => { + if (request.url === "/tsc.wasm") { + response.setHeader("Content-Type", "application/wasm"); + response.end(wasm); + return; + } + const pageMode = request.url?.slice(1); + if (pageMode && bundles.has(pageMode)) { + response.setHeader("Content-Type", "text/html"); + response.end(``); + return; + } + const mode = request.url?.slice(1, -3); + const bundle = mode && bundles.get(mode); + if (bundle) { + response.setHeader("Content-Type", "text/javascript"); + response.end(bundle); + return; + } + response.statusCode = 404; + response.end(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + origin = `http://127.0.0.1:${address.port}`; + browser = await chromium.launch(); + }); + + after(async () => { + await browser?.close(); + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + }); + + for (const mode of modes) { + test(`runs the existing ${mode} API tests`, async t => { + const page = await browser.newPage(); + const pageErrors: string[] = []; + const pageConsole: string[] = []; + page.on("pageerror", error => pageErrors.push(error.stack ?? error.message)); + page.on("console", message => pageConsole.push(message.text())); + await page.goto(`${origin}/${mode}`, { waitUntil: "domcontentloaded" }); + try { + await page.waitForFunction( + () => Reflect.has(globalThis, "browserTestResults") || Reflect.has(globalThis, "browserTestError"), + undefined, + { timeout: 300_000 }, + ); + } + catch (error) { + const progress = await page.evaluate(() => Reflect.get(globalThis, "browserTestProgress")); + throw new Error(`Browser API tests stalled at: ${progress}\n${pageErrors.join("\n\n")}`, { cause: error }); + } + const error = await page.evaluate(() => Reflect.get(globalThis, "browserTestError")); + assert.strictEqual(error, undefined, error?.stack ?? error?.message); + const results = await page.evaluate(() => Reflect.get(globalThis, "browserTestResults")) as BrowserTestResults; + assert.strictEqual( + results.passed + results.skipped.length + results.failures.length, + results.total, + `Browser ${mode} API test registration accounting changed`, + ); + assert.strictEqual( + results.failures.length, + 0, + [ + ...results.failures.map(failure => `${failure.name}: ${failure.message}\n${failure.stack ?? ""}`), + ...pageConsole, + ].join("\n\n"), + ); + assert.ok(results.passed > 0); + const excludedFiles = fileExclusions.filter(exclusion => exclusion.mode === mode); + const excludedFileTests = excludedFiles.reduce((count, exclusion) => count + exclusion.tests, 0); + t.diagnostic(`${results.passed} passed, ${results.skipped.length + excludedFileTests} skipped`); + for (const skipped of results.skipped) { + t.diagnostic(`SKIP ${skipped.name}: ${skipped.reason}`); + } + for (const exclusion of excludedFiles) { + t.diagnostic(`SKIP ${exclusion.file} (${exclusion.tests} tests): ${exclusion.reason}`); + } + }); + } +}); + +function browserTestPlugin(mode: "async" | "sync"): Plugin { + const harness = path.join(browserDir, "harness.ts"); + const api = path.join(browserDir, `${mode}API.ts`); + return { + name: "browser-test", + setup(build) { + build.onResolve({ filter: /^node:test$/ }, () => ({ path: harness })); + build.onResolve({ filter: /^node:assert$/ }, () => ({ path: "node-assert", namespace: "browser-shim" })); + build.onResolve({ filter: /^\.\/ast\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:fs$/ }, () => ({ path: "node-fs", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:module$/ }, () => ({ path: "node-module", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:path$/ }, () => ({ path: "node-path", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:url$/ }, () => ({ path: "node-url", namespace: "browser-shim" })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/async$/ }, () => ({ + path: mode === "async" ? api : path.join(browserDir, "asyncAPI.ts"), + })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/sync$/ }, () => ({ + path: mode === "sync" ? api : path.join(browserDir, "syncAPI.ts"), + })); + build.onResolve({ filter: /^\.\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onResolve({ filter: /^(?:\.\.\/)+generators\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onLoad({ filter: /.*/, namespace: "browser-shim" }, args => { + switch (args.path) { + case "api-bench": + return { contents: "export function runBenchmarks() { throw new Error('Benchmarks are not available in a browser'); }" }; + case "node-assert": + return { + contents: ` + import assert from "assert"; + const equal = assert.equal; + assert.equal = (actual, expected, message) => { + if (Number.isNaN(actual) && Number.isNaN(expected)) return; + equal(actual, expected, message); + }; + export default assert; + `, + resolveDir: packageDir, + }; + case "node-fs": + return { + contents: ` + export function globSync() { throw new Error("globSync is not available in a browser"); } + export default { + existsSync() { return false; }, + readFileSync() { throw new Error("readFileSync is not available in a browser"); }, + }; + `, + }; + case "node-module": + return { contents: "export default {};" }; + case "node-path": + return { + contents: ` + export function dirname(path) { + const index = path.lastIndexOf("/"); + return index < 0 ? "." : path.slice(0, index) || "/"; + } + export function resolve(...parts) { + const path = parts.join("/"); + const normalized = []; + for (const part of path.split("/")) { + if (!part || part === ".") continue; + if (part === "..") normalized.pop(); + else normalized.push(part); + } + return "/" + normalized.join("/"); + } + export default { dirname, resolve }; + `, + }; + case "node-url": + return { contents: "export function fileURLToPath(url) { return new URL(url).pathname; }" }; + } + return undefined; + }); + }, + }; +} diff --git a/packages/typescript/test/browser/syncAPI.ts b/packages/typescript/test/browser/syncAPI.ts new file mode 100644 index 0000000000000..1c2ca8cff4279 --- /dev/null +++ b/packages/typescript/test/browser/syncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/sync/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/sync/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as unknown as Parameters[0]; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/browser/wasm.test.ts b/packages/typescript/test/browser/wasm.test.ts new file mode 100644 index 0000000000000..072d1a008dfcb --- /dev/null +++ b/packages/typescript/test/browser/wasm.test.ts @@ -0,0 +1,144 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import { build } from "esbuild"; +import assert from "node:assert"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { + after, + before, + describe, + test, +} from "node:test"; +import { + type Browser, + chromium, +} from "playwright"; + +describe("API over WebAssembly in a browser", () => { + let browser: Browser; + let origin: string; + const server = createServer(); + + before(async () => { + const index = await readFile(new URL("../../dist/wasm/index.js", import.meta.url)); + const transport = await readFile(new URL("../../dist/wasm/transport.js", import.meta.url)); + const wasi = await readFile(new URL("../../dist/wasm/wasi.js", import.meta.url)); + const result = await build({ + stdin: { + contents: ` + import { API as AsyncAPI } from "typescript/unstable/async"; + import { API as SyncAPI } from "typescript/unstable/sync"; + import { instantiateWasm, WasmTransport } from "typescript/unstable/wasm"; + + async function check(API, module) { + const instance = await instantiateWasm(module); + const transport = new WasmTransport({ instance, cwd: "/" }); + const api = new API({ transport }); + transport.setFile("/tsconfig.json", "{}"); + transport.setFile("/src/index.ts", "export const value = 42 as const;"); + try { + const snapshot = await api.createSnapshot({ + openFiles: ["/src/index.ts"], + fileNotifications: { + changed: ["/tsconfig.json", "/src/index.ts"], + deleted: [], + }, + }); + const project = await snapshot.getDefaultProjectForFile("/src/index.ts"); + const sourceFile = await project.program.getSourceFile("/src/index.ts"); + const name = sourceFile.statements[0].declarationList.declarations[0].name; + const type = await project.checker.getTypeAtLocation(name); + return project.checker.typeToString(type); + } + finally { + await api.close(); + } + } + + export async function run() { + const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); + return [ + await check(AsyncAPI, module), + await check(SyncAPI, module), + ]; + } + `, + loader: "ts", + resolveDir: process.cwd(), + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source", "browser"], + format: "esm", + platform: "browser", + write: false, + }); + assert.strictEqual(result.outputFiles.length, 1); + const bundle = result.outputFiles[0].contents; + const wasm = await readFile(wasmURL); + + server.on("request", (request, response) => { + switch (request.url) { + case "/": + response.setHeader("Content-Type", "text/html"); + response.end(''); + break; + case "/bundle.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(bundle); + break; + case "/tsc.wasm": + response.setHeader("Content-Type", "application/wasm"); + response.end(wasm); + break; + case "/transport.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(transport); + break; + case "/index.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(index); + break; + case "/wasi.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(wasi); + break; + default: + response.statusCode = 404; + response.end(); + break; + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + origin = `http://127.0.0.1:${address.port}`; + browser = await chromium.launch(); + }); + + after(async () => { + await browser?.close(); + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + }); + + test("runs the async and sync APIs in Chromium", async () => { + const page = await browser.newPage(); + await page.goto(origin); + const result = await page.evaluate(() => Reflect.get(globalThis, "result")); + assert.deepStrictEqual(result, ["42", "42"]); + }); + + test("loads the transport entry without package resolution", async () => { + const page = await browser.newPage(); + await page.goto(origin); + const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/index.js`)), origin); + assert.deepStrictEqual(exports, ["WasmTransport", "instantiateWasm", "instantiateWasmSync"]); + }); +}); diff --git a/packages/typescript/test/encoder.test.ts b/packages/typescript/test/encoder.test.ts index 8b48997dd9eaa..880d9adb4d184 100644 --- a/packages/typescript/test/encoder.test.ts +++ b/packages/typescript/test/encoder.test.ts @@ -39,6 +39,7 @@ import { import { encodeNode, encodeSourceFile, + uint8ArrayToBase64, } from "../src/api/node/encoder.ts"; import { RemoteNode, @@ -74,6 +75,16 @@ function decode(data: Uint8Array): RemoteSourceFile { } describe("Encoder", { concurrency }, () => { + test("encodes base64 using the available platform implementation", () => { + const data = new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4); + assert.strictEqual(uint8ArrayToBase64(data), "AQID"); + + Object.defineProperty(data, "toBase64", { + value: () => "native", + }); + assert.strictEqual(uint8ArrayToBase64(data), "native"); + }); + test("encodes empty source file", () => { const sf = makeSF("", "/test.ts", []); diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts new file mode 100644 index 0000000000000..e857d125f1cbc --- /dev/null +++ b/packages/typescript/test/sync/transport.test.ts @@ -0,0 +1,176 @@ +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/unstable/sync"; +import { + type WasmReactorExports, + WasmTransport, +} from "@typescript/typescript/unstable/wasm"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import type { + BatchRequestsParams, + BatchRequestsResponse, +} from "../../src/api/proto.ts"; + +describe("sync transport", () => { + test("constructs an API over an injected transport", () => { + const methods: string[] = []; + let batchParams: BatchRequestsParams | undefined; + let closed = false; + const api = new API({ + maxResponseBytesPerPage: 1, + transport: { + lastBytesSent: 0, + lastBytesReceived: 0, + requestSync(method, payload) { + methods.push(method); + switch (method) { + case "initialize": + return JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }); + case "parseCommandLine": + return JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ); + case "batchRequests": { + batchParams = JSON.parse(payload) as BatchRequestsParams; + assert.ok(batchParams.requests); + const response: BatchRequestsResponse = { + responses: batchParams.requests.map(request => ({ + method: request.method, + result: { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + })), + }; + return JSON.stringify(response); + } + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinarySync() { + throw new Error("Unexpected binary request"); + }, + close() { + closed = true; + }, + }, + }); + + assert.deepStrictEqual(api.parseCommandLine([]), { + options: {}, + fileNames: [], + errors: [], + }); + assert.deepStrictEqual( + api.batch(api.parseCommandLine.gen([]), api.parseCommandLine.gen([])), + [ + { options: {}, fileNames: [], errors: [] }, + { options: {}, fileNames: [], errors: [] }, + ], + ); + assert.deepStrictEqual(methods, ["initialize", "parseCommandLine", "batchRequests"]); + assert.strictEqual(batchParams?.maxResponseBytesPerPage, 1); + api.close(); + assert.strictEqual(closed, true); + }); + + test("drives the WASM reactor ABI", () => { + const memory = { buffer: new ArrayBuffer(65536) }; + const requestPointer = 0; + const responsePointer = 4096; + let responseLength = 0; + let closed = false; + const files = new Map(); + + function setResponse(value: string | Uint8Array) { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; + new Uint8Array(memory.buffer).set(bytes, responsePointer); + responseLength = bytes.length; + } + + const exports: WasmReactorExports = { + memory, + create_session() { + setResponse(""); + return 0; + }, + close_session() { + closed = true; + }, + get_request_buffer() { + return requestPointer; + }, + handle_request(methodLength, payloadLength) { + const bytes = new Uint8Array(memory.buffer); + const method = new TextDecoder().decode(bytes.subarray(requestPointer, requestPointer + methodLength)); + const payload = bytes.slice(requestPointer + methodLength, requestPointer + methodLength + payloadLength); + if (method === "text") { + setResponse(new TextDecoder().decode(payload).toUpperCase()); + } + else { + setResponse(payload.reverse()); + } + return 0; + }, + set_file(pathLength, contentLength) { + const bytes = new Uint8Array(memory.buffer); + const path = new TextDecoder().decode(bytes.subarray(requestPointer, requestPointer + pathLength)); + const content = new TextDecoder().decode( + bytes.subarray(requestPointer + pathLength, requestPointer + pathLength + contentLength), + ); + files.set(path, content); + return 0; + }, + read_file(pathLength) { + const path = new TextDecoder().decode(new Uint8Array(memory.buffer, requestPointer, pathLength)); + const content = files.get(path); + if (content === undefined) { + setResponse(""); + return 2; + } + setResponse(content); + return 0; + }, + remove_file(pathLength) { + const path = new TextDecoder().decode(new Uint8Array(memory.buffer, requestPointer, pathLength)); + files.delete(path); + return 0; + }, + response_ptr: () => responsePointer, + response_len: () => responseLength, + }; + + const transport = new WasmTransport({ + instance: { exports }, + cwd: "/workspace", + }); + transport.setFile("/workspace/index.ts", "const value = 1;"); + assert.strictEqual(files.get("/workspace/index.ts"), "const value = 1;"); + assert.strictEqual(transport.readFile("/workspace/index.ts"), "const value = 1;"); + assert.strictEqual(transport.readFile("/workspace/missing.ts"), undefined); + assert.strictEqual(transport.requestSync("text", "hello"), "HELLO"); + assert.deepStrictEqual(transport.requestBinarySync("binary", new Uint8Array([1, 2, 3])), new Uint8Array([3, 2, 1])); + transport.removeFile("/workspace/index.ts"); + assert.strictEqual(files.has("/workspace/index.ts"), false); + transport.close(); + assert.strictEqual(closed, true); + assert.throws( + () => transport.setFileSystem({ writeFile() {} }), + /TypeScript WASM transport is closed/, + ); + }); +}); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts new file mode 100644 index 0000000000000..0eba6f35f826e --- /dev/null +++ b/packages/typescript/test/sync/wasm.test.ts @@ -0,0 +1,381 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import { + isIdentifier, + isVariableStatement, +} from "@typescript/typescript/unstable/ast"; +import { API as AsyncAPI } from "@typescript/typescript/unstable/async"; +import { API as SyncAPI } from "@typescript/typescript/unstable/sync"; +import { + instantiateWasm, + instantiateWasmSync, + WasmTransport, +} from "@typescript/typescript/unstable/wasm"; +import binaryen from "binaryen"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { + mkdtemp, + open, + readFile, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + describe, + test, +} from "node:test"; +import { WASI } from "node:wasi"; + +describe("API over WebAssembly", () => { + test("publishes its library file list", async () => { + const libFilesURL = new URL("libFiles.json", wasmURL); + const libFiles = JSON.parse(await readFile(libFilesURL, "utf8")) as string[]; + assert.ok(libFiles.includes("lib.d.ts")); + assert.ok(libFiles.includes("lib.es5.d.ts")); + assert.ok(libFiles.every(file => !file.includes("/"))); + }); + + test("implements reactor stdio descriptor lifecycle", () => { + const WebAssembly = (globalThis as any).WebAssembly; + const output: string[] = []; + const module = binaryen.parseText(` + (module + (import "wasi_snapshot_preview1" "fd_close" (func $fd_close (param i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_fdstat_get" (func $fd_fdstat_get (param i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "typescript_initialize")) + (func (export "write_stdout") (param $pointer i32) (param $length i32) (result i32) + (i32.store (i32.const 0) (local.get $pointer)) + (i32.store (i32.const 4) (local.get $length)) + (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)) + ) + (func (export "close_stdout") (result i32) + (call $fd_close (i32.const 1)) + ) + (func (export "stat_stdout") (result i32) + (call $fd_fdstat_get (i32.const 1) (i32.const 16)) + ) + (data (i32.const 32) "\\e2\\82") + ) + `); + const instance = instantiateWasmSync( + new WebAssembly.Module(module.emitBinary()), + { stdout: (text: string) => output.push(text) }, + ); + const exports = instance.exports as { + write_stdout(pointer: number, length: number): number; + close_stdout(): number; + stat_stdout(): number; + }; + + assert.strictEqual(exports.write_stdout(32, 0), 0); + assert.deepStrictEqual(output, []); + assert.strictEqual(exports.write_stdout(32, 2), 0); + assert.deepStrictEqual(output, []); + assert.strictEqual(exports.close_stdout(), 0); + assert.deepStrictEqual(output, ["\uFFFD"]); + assert.strictEqual(exports.write_stdout(32, 0), 8); + assert.strictEqual(exports.stat_stdout(), 8); + assert.strictEqual(exports.close_stdout(), 8); + module.dispose(); + }); + + test("runs the compiler command from the same module", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); + try { + const stdoutPath = path.join(directory, "stdout"); + const stdout = await open(stdoutPath, "w+"); + try { + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--version"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + stdout: stdout.fd, + returnOnExit: true, + }); + const module = await WebAssembly.compile(await readFile(wasmURL)); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + assert.strictEqual(wasi.start(instance), 0); + } + finally { + await stdout.close(); + } + assert.match(await readFile(stdoutPath, "utf8"), /^Version \d+\.\d+\.\d+/); + } + finally { + await rm(directory, { recursive: true }); + } + }); + + test("exits successfully when LSP stdin closes", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); + try { + const stdin = await open(path.join(directory, "stdin"), "w+"); + const stdout = await open(path.join(directory, "stdout"), "w+"); + const stderr = await open(path.join(directory, "stderr"), "w+"); + try { + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--lsp", "--stdio"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + stdin: stdin.fd, + stdout: stdout.fd, + stderr: stderr.fd, + returnOnExit: true, + }); + const module = await WebAssembly.compile(await readFile(wasmURL)); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + assert.strictEqual(wasi.start(instance), 0); + } + finally { + await Promise.all([stdin.close(), stdout.close(), stderr.close()]); + } + } + finally { + await rm(directory, { recursive: true }); + } + }); + + test("waits for LSP stdin without repeatedly reading", { timeout: 10_000 }, async () => { + const childSource = ` + import { readFileSync } from "node:fs"; + import { WASI } from "node:wasi"; + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--lsp", "--stdio"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + returnOnExit: true, + }); + let reads = 0; + const imports = { + ...wasi.wasiImport, + fd_read(...args) { + if (++reads > 10) throw new Error("stdin was read repeatedly while idle"); + return wasi.wasiImport.fd_read(...args); + }, + }; + const module = await WebAssembly.compile(readFileSync(new URL(${JSON.stringify(wasmURL.toString())}))); + const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: imports }); + process.exitCode = wasi.start(instance); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", childSource], { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", chunk => stdout += chunk); + child.stderr.on("data", chunk => stderr += chunk); + + await new Promise(resolve => setTimeout(resolve, 250)); + const request = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + processId: null, + capabilities: {}, + rootUri: null, + workspaceFolders: null, + }, + }); + child.stdin.write(`Content-Length: ${Buffer.byteLength(request)}\r\n\r\n${request}`); + + while (!stdout.includes("Resolved client capabilities") && child.exitCode === null) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + child.stdin.end(); + const exitCode = child.exitCode ?? await new Promise(resolve => child.once("exit", resolve)); + assert.strictEqual(exitCode, 0, stderr); + assert.match(stdout, /Resolved client capabilities/); + }); + + test("waits for API stdin without repeatedly reading", { timeout: 10_000 }, async () => { + const childSource = ` + import { readFileSync } from "node:fs"; + import { WASI } from "node:wasi"; + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--api"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + returnOnExit: true, + }); + let reads = 0; + const imports = { + ...wasi.wasiImport, + fd_read(...args) { + if (++reads > 10) throw new Error("stdin was read repeatedly while idle"); + return wasi.wasiImport.fd_read(...args); + }, + }; + const module = await WebAssembly.compile(readFileSync(new URL(${JSON.stringify(wasmURL.toString())}))); + const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: imports }); + process.exitCode = wasi.start(instance); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", childSource], { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", chunk => stderr += chunk); + + await new Promise(resolve => setTimeout(resolve, 250)); + child.stdin.end(); + const exitCode = child.exitCode ?? await new Promise(resolve => child.once("exit", resolve)); + assert.strictEqual(exitCode, 0, stderr); + }); + + test("runs the compiler and checker through the reactor", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const module = await WebAssembly.compile( + await readFile(wasmURL), + ); + assert.deepStrictEqual( + [...new Set(WebAssembly.Module.imports(module).map((value: { module: string; }) => value.module))], + ["wasi_snapshot_preview1"], + ); + const exportNames = WebAssembly.Module.exports(module).map((value: { name: string; }) => value.name); + assert.ok(exportNames.includes("_start")); + assert.ok(exportNames.includes("typescript_initialize")); + assert.ok(!exportNames.includes("_initialize")); + const instance = await instantiateWasm(module, { stdout: undefined, stderr: undefined }); + + assert.throws( + () => + new WasmTransport({ + instance: { exports: instance.exports }, + cwd: undefined, + useCaseSensitiveFileNames: undefined, + collectTiming: undefined, + fs: { writeFile() {} }, + }), + /was not created by instantiateWasm/, + ); + + const emittedFiles = new Map(); + const memory = Reflect.get(instance.exports, "memory"); + assert.ok(memory instanceof WebAssembly.Memory); + let transport!: WasmTransport; + transport = new WasmTransport({ + instance, + cwd: "/", + collectTiming: true, + fs: { + writeFile(path, data) { + memory.grow(0); + assert.throws( + () => transport.close(), + /TypeScript WASM callbacks cannot close the same API transport/, + ); + assert.throws( + () => transport.readFile("/src/index.ts"), + /TypeScript WASM callbacks cannot call the same API transport/, + ); + emittedFiles.set(path, data); + }, + }, + }); + const api = new SyncAPI({ transport, collectTiming: true }); + assert.strictEqual(transport.requestSync("echo", "text"), "text"); + assert.deepStrictEqual( + transport.requestBinarySync("echo", new Uint8Array([1, 2, 3])), + new Uint8Array([1, 2, 3]), + ); + + transport.setFile("/tsconfig.json", "{}"); + transport.setFile("/src/index.ts", "export const value = 42 as const;"); + const snapshot = api.createSnapshot({ + openFiles: ["/src/index.ts"], + fileNotifications: { + changed: ["/tsconfig.json", "/src/index.ts"], + deleted: [], + }, + }); + const project = snapshot.getDefaultProjectForFile("/src/index.ts"); + assert.ok(project); + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const statement = sourceFile.statements[0]; + assert.ok(isVariableStatement(statement)); + const name = statement.declarationList.declarations[0].name; + assert.ok(isIdentifier(name)); + const type = project.checker.getTypeAtLocation(name); + assert.strictEqual(project.checker.typeToString(type), "42"); + assert.throws( + () => + new WasmTransport({ + instance, + fs: { writeFile() {} }, + }), + /session already created/, + ); + assert.deepStrictEqual(project.program.emit(), { + diagnostics: [], + emitSkipped: false, + emittedFiles: ["/src/index.js"], + fileSystem: undefined, + }); + assert.strictEqual(emittedFiles.get("/src/index.js"), "export const value = 42;\n"); + assert.strictEqual(transport.requestSync("echo", "after callback"), "after callback"); + + const timing = api.getTimingInfo(); + assert.strictEqual(timing.enabled, true); + assert.ok(timing.totals.requestCount > 0); + assert.ok(timing.totals.serverTimeMs >= 0); + Reflect.set(transport, "inCallback", true); + assert.throws( + () => transport.close(), + /TypeScript WASM callbacks cannot close the same API transport/, + ); + Reflect.set(transport, "inCallback", false); + transport.registerCallback("retained", () => ""); + api.close(); + assert.deepStrictEqual(Reflect.get(transport, "callbackNames"), new Set()); + + const secondTransport = new WasmTransport({ instance, cwd: "/" }); + const second = new SyncAPI({ transport: secondTransport }); + assert.strictEqual(secondTransport.requestSync("echo", "recreated"), "recreated"); + secondTransport.registerCallback("retained", () => ""); + transport.unregisterCallback("retained"); + assert.deepStrictEqual(Reflect.get(secondTransport, "callbackNames"), new Set(["retained"])); + second.close(); + + const asyncTransport = new WasmTransport({ instance, cwd: "/" }); + const asyncAPI = new AsyncAPI({ transport: asyncTransport }); + asyncTransport.setFile("/tsconfig.json", "{}"); + asyncTransport.setFile("/src/async.ts", "export const value = 42 as const;"); + const asyncSnapshot = await asyncAPI.createSnapshot({ + openFiles: ["/src/async.ts"], + fileNotifications: { + changed: ["/tsconfig.json", "/src/async.ts"], + deleted: [], + }, + }); + const asyncProject = await asyncSnapshot.getDefaultProjectForFile("/src/async.ts"); + assert.ok(asyncProject); + const asyncSourceFile = await asyncProject.program.getSourceFile("/src/async.ts"); + assert.ok(asyncSourceFile); + const asyncStatement = asyncSourceFile.statements[0]; + assert.ok(isVariableStatement(asyncStatement)); + const asyncName = asyncStatement.declarationList.declarations[0].name; + assert.ok(isIdentifier(asyncName)); + const asyncType = await asyncProject.checker.getTypeAtLocation(asyncName); + assert.strictEqual(await asyncProject.checker.typeToString(asyncType), "42"); + await asyncAPI.close(); + }); +}); diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index a2630f570a209..682bcc1ae107d 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -1,8 +1,21 @@ import getExePath from "#getExePath"; import { versionMajorMinor } from "@typescript/typescript"; import assert from "node:assert"; -import { execFileSync } from "node:child_process"; +import { + execFileSync, + spawnSync, +} from "node:child_process"; import { readFileSync } from "node:fs"; +import { + cp, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { test } from "node:test"; test("versionMajorMinor runtime and declaration match the compiler version", () => { @@ -17,3 +30,84 @@ test("versionMajorMinor runtime and declaration match the compiler version", () assert.ok(declarationMatch, "versionMajorMinor declaration not found"); assert.strictEqual(declarationMatch[1], majorMinor); }); + +test("the CLI falls back to the WASI package", { + skip: Number(process.versions.node.split(".")[0]) < 23 ? "Node.js 23 or newer is required" : undefined, +}, async () => { + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasi-fallback-")); + try { + const packageDirectory = path.join(directory, "node_modules", "typescript"); + const libraryDirectory = path.join(packageDirectory, "lib"); + await mkdir(libraryDirectory, { recursive: true }); + const responseFile = path.join(directory, "args.rsp"); + const sourceFile = path.join(directory, "input.ts"); + await Promise.all([ + cp(new URL("../lib/tsc.js", import.meta.url), path.join(libraryDirectory, "tsc.js")), + cp(new URL("../lib/getExePath.js", import.meta.url), path.join(libraryDirectory, "getExePath.js")), + writeFile(responseFile, "--version"), + writeFile(sourceFile, "export const value: string = 'value';\n"), + writeFile( + path.join(packageDirectory, "package.json"), + JSON.stringify({ + name: "typescript", + version: "0.0.0", + type: "module", + imports: { "#getExePath": "./lib/getExePath.js" }, + }), + ), + ]); + + const missingArtifact = spawnSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { + cwd: directory, + encoding: "utf8", + }); + assert.notStrictEqual(missingArtifact.status, 0); + assert.match( + missingArtifact.stderr, + /Install @typescript\/typescript-wasip1-wasm@0\.0\.0 to enable WASI fallback/, + ); + + const scopeDirectory = path.join(directory, "node_modules", "@typescript"); + await mkdir(scopeDirectory, { recursive: true }); + const artifactDirectory = path.join(scopeDirectory, "typescript-wasip1-wasm"); + await mkdir(artifactDirectory); + await symlink( + new URL("../../typescript-wasip1-wasm/lib", import.meta.url), + path.join(artifactDirectory, "lib"), + process.platform === "win32" ? "junction" : "dir", + ); + const artifactPackageJson = { + name: "@typescript/typescript-wasip1-wasm", + version: "1.0.0", + exports: { "./package.json": "./package.json" }, + }; + await writeFile(path.join(artifactDirectory, "package.json"), JSON.stringify(artifactPackageJson)); + + const mismatchedArtifact = spawnSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { + cwd: directory, + encoding: "utf8", + }); + assert.notStrictEqual(mismatchedArtifact.status, 0); + assert.match( + mismatchedArtifact.stderr, + /WebAssembly compiler package version 1\.0\.0 is incompatible with typescript@0\.0\.0/, + ); + artifactPackageJson.version = "0.0.0"; + await writeFile(path.join(artifactDirectory, "package.json"), JSON.stringify(artifactPackageJson)); + + const output = execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { + cwd: directory, + encoding: "utf8", + }); + assert.match(output, /^Version \d+\.\d+\.\d+/); + + await writeFile(responseFile, `--noEmit\n"${sourceFile}"\n`); + execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { + cwd: directory, + stdio: "inherit", + }); + } + finally { + await rm(directory, { recursive: true }); + } +}); diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 81aea93963b0a..3dc77af8e936b 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -369,13 +369,16 @@ ], "scripts": { "build": "tsc -b && npm run bundle", - "bundle": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", + "bundle": "npm run bundle:desktop && npm run bundle:web", + "bundle:desktop": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", + "bundle:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap", "test": "tsc -b test && esbuild test/index.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/index.test.cjs && node --test dist/test/index.test.cjs", - "watch": "npm run bundle -- --watch", + "watch": "npm run bundle:desktop -- --watch", "generateLocTest": "node ../../tools/scripts/gen/extensionLocalization.mts pseudo", "generateLocBundle": "node ../../tools/scripts/gen/extensionLocalization.mts bundle", "//vscode:prepublish": "we do not use this hook; the Herebyfile runs bundle:release once instead", - "bundle:release": "npm run bundle -- --minify" + "bundle:release": "npm run bundle:desktop -- --minify", + "bundle:release:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap --minify" }, "dependencies": { "@vscode/extension-telemetry": "^1.5.2", @@ -387,6 +390,7 @@ "@types/vscode": "~1.125.0", "@typescript/bundled-typescript": "npm:typescript@7.0.2", "@vscode/vsce": "4.0.0", + "@vscode/wasm-wasi": "^1.0.2", "esbuild": "^0.28.2" } } diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts new file mode 100644 index 0000000000000..bbf6c876232e4 --- /dev/null +++ b/packages/vscode-typescript/src/extension.web.ts @@ -0,0 +1,208 @@ +import { + Wasm, + type WasmProcess, + type Writable, +} from "@vscode/wasm-wasi/v1"; +import * as vscode from "vscode"; +import { + AbstractMessageReader, + AbstractMessageWriter, + type DataCallback, + LanguageClient, + type LanguageClientOptions, + type Message, + type MessageTransports, +} from "vscode-languageclient/browser"; +import { nightlyExtensionId } from "./extensionIds"; +import { selectWebModuleExtensionUri } from "./webModuleExtension"; +import { uriToWasmMountPoint } from "./webMountPoint"; + +declare global { + interface DedicatedWorkerGlobalScope { + readonly name: string; + } +} + +class WasmMessageReader extends AbstractMessageReader { + private readonly decoder = new TextDecoder(); + private buffer = new Uint8Array(); + private callback: DataCallback | undefined; + private closed = false; + private readonly dataSubscription: vscode.Disposable; + + constructor(process: WasmProcess) { + super(); + this.dataSubscription = process.stdout?.onData(chunk => this.accept(chunk)) ?? new vscode.Disposable(() => {}); + } + + listen(callback: DataCallback): vscode.Disposable { + this.callback = callback; + return new vscode.Disposable(() => this.callback = undefined); + } + + override dispose(): void { + this.dataSubscription.dispose(); + super.dispose(); + } + + close(): void { + if (!this.closed) { + this.closed = true; + this.fireClose(); + } + } + + private accept(chunk: Uint8Array): void { + const next = new Uint8Array(this.buffer.length + chunk.length); + next.set(this.buffer); + next.set(chunk, this.buffer.length); + this.buffer = next; + + while (true) { + const headerEnd = findHeaderEnd(this.buffer); + if (headerEnd < 0) return; + const header = this.decoder.decode(this.buffer.subarray(0, headerEnd)); + const match = /(?:^|\r\n)Content-Length: (\d+)(?:\r\n|$)/i.exec(header); + if (!match) { + this.fireError(new Error(`Language server response is missing Content-Length: ${header}`)); + return; + } + const contentLength = Number(match[1]); + const bodyStart = headerEnd + 4; + if (this.buffer.length < bodyStart + contentLength) return; + const body = this.decoder.decode(this.buffer.subarray(bodyStart, bodyStart + contentLength)); + this.buffer = this.buffer.slice(bodyStart + contentLength); + try { + this.callback?.(JSON.parse(body)); + } + catch (error) { + this.fireError(error); + } + } + } +} + +class WasmMessageWriter extends AbstractMessageWriter { + private readonly encoder = new TextEncoder(); + private writes = Promise.resolve(); + + constructor(private readonly stdin: Writable) { + super(); + } + + write(message: Message): Promise { + const body = this.encoder.encode(JSON.stringify(message)); + const header = this.encoder.encode(`Content-Length: ${body.length}\r\n\r\n`); + const framed = new Uint8Array(header.length + body.length); + framed.set(header); + framed.set(body, header.length); + this.writes = this.writes.then(() => this.stdin.write(framed)); + return this.writes; + } + + end(): void {} +} + +export async function activate(context: vscode.ExtensionContext): Promise { + const output = vscode.window.createOutputChannel("TypeScript 7", { log: true }); + context.subscriptions.push(output); + + const nightlyExtension = vscode.extensions.getExtension(nightlyExtensionId); + const moduleExtensionUri = await selectWebModuleExtensionUri( + context.extensionUri, + nightlyExtension?.extensionUri, + async extensionUri => { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, "lib", "tsc.wasm")); + return true; + } + catch { + return false; + } + }, + ); + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider("bundled", { + async provideTextDocumentContent(uri) { + const prefix = "/libs/"; + if (!uri.path.startsWith(prefix)) { + throw new Error(`Unsupported bundled TypeScript path: ${uri.toString()}`); + } + const fileName = uri.path.slice(prefix.length); + if (!fileName || fileName.includes("/")) { + throw new Error(`Unsupported bundled TypeScript path: ${uri.toString()}`); + } + const contents = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(moduleExtensionUri, "lib", fileName)); + return new TextDecoder().decode(contents); + }, + })); + + let process: WasmProcess | undefined; + const stderrDecoder = new TextDecoder(); + const serverOptions = async (): Promise => { + const wasm = await Wasm.load(); + const module = wasm.compile(vscode.Uri.joinPath(moduleExtensionUri, "lib", "tsc.wasm")); + const mountPoints = (vscode.workspace.workspaceFolders ?? []).map(folder => ({ + kind: "vscodeFileSystem" as const, + uri: folder.uri, + mountPoint: uriToWasmMountPoint(folder.uri), + })); + // `nonBlocking` is pending in https://github.com/microsoft/vscode-wasm/pull/298. + const stdin = { kind: "pipeIn" as const, nonBlocking: true }; + process = await wasm.createProcess("tsc", module, { + args: ["--lsp", "--stdio"], + env: { PWD: "/" }, + mountPoints, + stdio: { + in: stdin, + out: { kind: "pipeOut" }, + err: { kind: "pipeOut" }, + }, + }); + if (!process.stdin || !process.stdout) { + throw new Error("WASM WASI did not create language server stdio pipes"); + } + process.stderr?.onData(chunk => output.append(stderrDecoder.decode(chunk, { stream: true }))); + const reader = new WasmMessageReader(process); + void process.run().then( + code => { + if (code !== 0) output.error(`TypeScript language server exited with code ${code}`); + }, + error => output.error(`TypeScript language server failed: ${String(error)}`), + ).finally(() => reader.close()); + return { + reader, + writer: new WasmMessageWriter(process.stdin), + }; + }; + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { language: "javascript" }, + { language: "javascriptreact" }, + { language: "typescript" }, + { language: "typescriptreact" }, + ], + initializationOptions: { + enableTelemetry: false, + runExternalCode: false, + }, + outputChannel: output, + }; + const client = new LanguageClient("js/ts", "TypeScript Language Server", serverOptions, clientOptions); + context.subscriptions.push(client, { + dispose() { + void process?.terminate(); + }, + }); + await client.start(); +} + +export function deactivate(): void {} + +function findHeaderEnd(buffer: Uint8Array): number { + for (let i = 0; i + 3 < buffer.length; i++) { + if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) { + return i; + } + } + return -1; +} diff --git a/packages/vscode-typescript/src/extensionIds.ts b/packages/vscode-typescript/src/extensionIds.ts new file mode 100644 index 0000000000000..2aa3ff365939c --- /dev/null +++ b/packages/vscode-typescript/src/extensionIds.ts @@ -0,0 +1 @@ +export const nightlyExtensionId = "TypeScriptTeam.vscode-typescript-nightly"; diff --git a/packages/vscode-typescript/src/util.ts b/packages/vscode-typescript/src/util.ts index a299423f81948..208d90f3ce5af 100644 --- a/packages/vscode-typescript/src/util.ts +++ b/packages/vscode-typescript/src/util.ts @@ -1,12 +1,12 @@ import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; +import { nightlyExtensionId } from "./extensionIds"; import { resolvePackageExecutable } from "./tsdkPackage"; export const aiConnectionString = "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255"; export const languageClientName = "TypeScript Language Server"; -export const nightlyExtensionId = "TypeScriptTeam.vscode-typescript-nightly"; export const enableContributedNightlyVersion = true; export const jsTsLanguageModes = [ diff --git a/packages/vscode-typescript/src/webModuleExtension.ts b/packages/vscode-typescript/src/webModuleExtension.ts new file mode 100644 index 0000000000000..bcf426d089d88 --- /dev/null +++ b/packages/vscode-typescript/src/webModuleExtension.ts @@ -0,0 +1,7 @@ +export async function selectWebModuleExtensionUri( + builtinUri: T, + nightlyUri: T | undefined, + hasWasmPayload: (uri: T) => Promise, +): Promise { + return nightlyUri && await hasWasmPayload(nightlyUri) ? nightlyUri : builtinUri; +} diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts new file mode 100644 index 0000000000000..74e66a4da1905 --- /dev/null +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -0,0 +1,21 @@ +export interface WebFileSystemUri { + readonly scheme: string; + readonly authority: string; + readonly path: string; +} + +export function uriToWasmMountPoint(uri: WebFileSystemUri): string { + let fileName: string; + if (uri.scheme === "file") { + if (uri.authority) { + fileName = `//${uri.authority}${uri.path}`; + } + else { + fileName = /^\/[A-Za-z]:/.test(uri.path) ? uri.path.slice(1) : uri.path; + } + } + else { + fileName = `^/${uri.scheme}/${uri.authority || "ts-nul-authority"}${uri.path}`; + } + return fileName.startsWith("/") ? fileName : `/${fileName}`; +} diff --git a/packages/vscode-typescript/test/index.test.ts b/packages/vscode-typescript/test/index.test.ts index 76a3a8d1e641f..46b59aaa280c5 100644 --- a/packages/vscode-typescript/test/index.test.ts +++ b/packages/vscode-typescript/test/index.test.ts @@ -1,3 +1,5 @@ import "./contentMapperContributions.test"; import "./lspMiddleware.test"; import "./tsdkPackage.test"; +import "./webModuleExtension.test"; +import "./webMountPoint.test"; diff --git a/packages/vscode-typescript/test/webModuleExtension.test.ts b/packages/vscode-typescript/test/webModuleExtension.test.ts new file mode 100644 index 0000000000000..d83f8c6d8dbf6 --- /dev/null +++ b/packages/vscode-typescript/test/webModuleExtension.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { selectWebModuleExtensionUri } from "../src/webModuleExtension"; + +describe("web module extension", () => { + test("uses the nightly extension when it contains the WASM payload", async () => { + assert.strictEqual( + await selectWebModuleExtensionUri("builtin", "nightly", async uri => uri === "nightly"), + "nightly", + ); + }); + + test("uses the builtin extension when the nightly does not contain the WASM payload", async () => { + assert.strictEqual( + await selectWebModuleExtensionUri("builtin", "nightly", async () => false), + "builtin", + ); + }); + + test("uses the builtin extension when no nightly is installed", async () => { + assert.strictEqual( + await selectWebModuleExtensionUri("builtin", undefined, async () => true), + "builtin", + ); + }); +}); diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts new file mode 100644 index 0000000000000..a4634c7ed1e71 --- /dev/null +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { uriToWasmMountPoint } from "../src/webMountPoint"; + +describe("web WASI mount points", () => { + test("maps POSIX file workspaces to their absolute paths", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "", path: "/work/project" }), + "/work/project", + ); + }); + + test("maps Windows file workspaces below the WASI root", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "", path: "/c:/work/project" }), + "/c:/work/project", + ); + }); + + test("maps UNC workspaces to UNC paths", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "server", path: "/share/project" }), + "//server/share/project", + ); + }); + + test("maps virtual workspaces to the path used by DocumentUri.FileName", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "vscode-test-web", authority: "mount", path: "/" }), + "/^/vscode-test-web/mount/", + ); + }); +}); diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index 92a035cc3d927..97f7b2a0e673a 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -77,6 +77,10 @@ extends: targetPath: '$(Build.ArtifactStagingDirectory)/npm-main' artifactName: 'npm-main' displayName: 'Publish npm-main artifact' + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/npm-wasip1' + artifactName: 'npm-wasip1' + displayName: 'Publish npm-wasip1 artifact' steps: - checkout: none @@ -98,12 +102,13 @@ extends: src="$(Pipeline.Workspace)/npm" platformDst="$(Build.ArtifactStagingDirectory)/npm-platform" mainDst="$(Build.ArtifactStagingDirectory)/npm-main" - mkdir -p "$platformDst" "$mainDst" + wasmDst="$(Build.ArtifactStagingDirectory)/npm-wasip1" + mkdir -p "$platformDst" "$mainDst" "$wasmDst" manifest="$src/publish-manifest.json" stageCount="$(jq '.stages | length' "$manifest")" - if [ "$stageCount" -ne 2 ]; then - echo "Expected exactly 2 npm publish stages, found $stageCount." >&2 + if [ "$stageCount" -ne 3 ]; then + echo "Expected exactly 3 npm publish stages, found $stageCount." >&2 exit 1 fi @@ -168,14 +173,20 @@ extends: jq -r '.stages[0][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$platformDst/" jq -r '.stages[1][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$mainDst/" + jq -r '.stages[2][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$wasmDst/" platformCount="$(find "$platformDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" mainCount="$(find "$mainDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" - echo "##[section]Staged $platformCount platform package(s) and $mainCount main package(s)." + wasmCount="$(find "$wasmDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" + echo "##[section]Staged $platformCount platform package(s), $mainCount main package(s), and $wasmCount WASM package(s)." if [ "$mainCount" -ne 1 ]; then echo "Expected 1 main package, found $mainCount." >&2 exit 1 fi + if [ "$wasmCount" -ne 1 ]; then + echo "Expected 1 WASM package, found $wasmCount." >&2 + exit 1 + fi name: releaseMetadata displayName: 'Stage npm artifacts' env: @@ -200,6 +211,9 @@ extends: - input: pipelineArtifact artifactName: 'npm-main' targetPath: '$(Pipeline.Workspace)/npm-main' + - input: pipelineArtifact + artifactName: 'npm-wasip1' + targetPath: '$(Pipeline.Workspace)/npm-wasip1' steps: - checkout: none @@ -224,6 +238,16 @@ extends: owners: 'jabaile@microsoft.com' approvers: 'alexchi@microsoft.com' + - template: MicroBuild.Publish.yml@MicroBuildTemplate + parameters: + intent: 'PackageDistribution' + contentType: 'npm' + contentSource: 'Folder' + folderLocation: '$(Pipeline.Workspace)/npm-wasip1' + waitForReleaseCompletion: true + owners: 'jabaile@microsoft.com' + approvers: 'alexchi@microsoft.com' + - stage: Publish_vsix displayName: Publish VSIX dependsOn: Prepare diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index 71cfe9e944022..d3214f13472ff 100644 --- a/tsc/cmd/tsc/api.go +++ b/tsc/cmd/tsc/api.go @@ -5,7 +5,6 @@ import ( "flag" "fmt" "os" - "os/signal" "strings" "syscall" @@ -29,7 +28,7 @@ type apiFlags struct { func parseAPIFlags(args []string) (apiFlags, error) { flags := flag.NewFlagSet("api", flag.ContinueOnError) result := apiFlags{} - flags.StringVar(&result.cwd, "cwd", core.Must(os.Getwd()), "current working directory") + flags.StringVar(&result.cwd, "cwd", core.Must(getCurrentDirectory()), "current working directory") flags.StringVar(&result.pipePath, "pipe", "", "use named pipe or Unix domain socket for communication instead of stdio") flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks and defaults to enable") flags.BoolVar(&result.caseSensitive, "useCaseSensitiveFileNames", osvfs.FS().CaseSensitivity().IsCaseSensitive(), "treat filesystem paths as case-sensitive") @@ -71,13 +70,13 @@ func runAPI(args []string) int { if flags.pipePath != "" { options.PipePath = flags.pipePath } else { - options.In = os.Stdin + options.In = stdioStdin options.Out = os.Stdout } s := api.NewStdioServer(options) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := s.Run(ctx); err != nil { diff --git a/tsc/cmd/tsc/currentdirectory.go b/tsc/cmd/tsc/currentdirectory.go new file mode 100644 index 0000000000000..a132c34ae6837 --- /dev/null +++ b/tsc/cmd/tsc/currentdirectory.go @@ -0,0 +1,9 @@ +//go:build !wasip1 + +package main + +import "os" + +func getCurrentDirectory() (string, error) { + return os.Getwd() +} diff --git a/tsc/cmd/tsc/currentdirectory_wasip1.go b/tsc/cmd/tsc/currentdirectory_wasip1.go new file mode 100644 index 0000000000000..96f1adbd201d2 --- /dev/null +++ b/tsc/cmd/tsc/currentdirectory_wasip1.go @@ -0,0 +1,17 @@ +//go:build wasip1 + +package main + +import ( + "os" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +func getCurrentDirectory() (string, error) { + cwd := os.Getenv("PWD") + if !tspath.IsRootedDiskPath(cwd) { + cwd = "/" + } + return cwd, nil +} diff --git a/tsc/cmd/tsc/lsp.go b/tsc/cmd/tsc/lsp.go index b294ad5688416..1a7f1d4ceab3d 100644 --- a/tsc/cmd/tsc/lsp.go +++ b/tsc/cmd/tsc/lsp.go @@ -5,8 +5,6 @@ import ( "flag" "fmt" "os" - "os/exec" - "os/signal" "syscall" "time" @@ -45,25 +43,21 @@ func runLSP(args []string) int { fs := bundled.WrapFS(osvfs.FS()) defaultLibraryPath := bundled.LibPath() typingsLocation := osvfs.GetGlobalTypingsCacheLocation() - cwd := tspath.RootedDirectoryPathFromAbsolute(core.Must(os.Getwd())) + cwd := tspath.RootedDirectoryPathFromAbsolute(core.Must(getCurrentDirectory())) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() s := lsp.NewServer(&lsp.ServerOptions{ - In: lsp.ToReader(os.Stdin), + In: lsp.ToReader(stdioStdin), Out: lsp.ToWriter(os.Stdout), Err: os.Stderr, Cwd: cwd, FS: fs, DefaultLibraryPath: defaultLibraryPath, TypingsLocation: tspath.ToRootedDirectoryPath(typingsLocation, cwd), - NpmInstall: func(ctx context.Context, cwd string, args []string) ([]byte, error) { - cmd := exec.CommandContext(ctx, "npm", args...) - cmd.Dir = cwd - return cmd.Output() - }, - Spawn: spawnProcess, + NpmInstall: getNpmInstall(), + Spawn: getLSPSpawn(), ProgressDelay: 250 * time.Millisecond, SetParentProcessID: newParentProcessWatchdog(ctx, stop, *clientProcessID), }) diff --git a/tsc/cmd/tsc/lsp_process.go b/tsc/cmd/tsc/lsp_process.go new file mode 100644 index 0000000000000..79567dfb0f943 --- /dev/null +++ b/tsc/cmd/tsc/lsp_process.go @@ -0,0 +1,21 @@ +//go:build !wasip1 + +package main + +import ( + "context" + "io" + "os/exec" +) + +func getNpmInstall() func(ctx context.Context, cwd string, args []string) ([]byte, error) { + return func(ctx context.Context, cwd string, args []string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "npm", args...) + cmd.Dir = cwd + return cmd.Output() + } +} + +func getLSPSpawn() func(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + return spawnProcess +} diff --git a/tsc/cmd/tsc/lsp_process_wasip1.go b/tsc/cmd/tsc/lsp_process_wasip1.go new file mode 100644 index 0000000000000..ea9e45e4e3361 --- /dev/null +++ b/tsc/cmd/tsc/lsp_process_wasip1.go @@ -0,0 +1,19 @@ +//go:build wasip1 + +package main + +import ( + "context" + "errors" + "io" +) + +func getNpmInstall() func(context.Context, string, []string) ([]byte, error) { + return func(context.Context, string, []string) ([]byte, error) { + return nil, errors.New("installing npm packages is not supported in WebAssembly") + } +} + +func getLSPSpawn() func(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + return nil +} diff --git a/tsc/cmd/tsc/lsp_stdin.go b/tsc/cmd/tsc/lsp_stdin.go new file mode 100644 index 0000000000000..502a032ad5243 --- /dev/null +++ b/tsc/cmd/tsc/lsp_stdin.go @@ -0,0 +1,9 @@ +//go:build !wasip1 + +package main + +import ( + "os" +) + +var stdioStdin = os.Stdin diff --git a/tsc/cmd/tsc/lsp_stdin_wasip1.go b/tsc/cmd/tsc/lsp_stdin_wasip1.go new file mode 100644 index 0000000000000..1e32a0083187c --- /dev/null +++ b/tsc/cmd/tsc/lsp_stdin_wasip1.go @@ -0,0 +1,19 @@ +//go:build wasip1 + +package main + +import ( + "syscall" +) + +type wasiStdin struct{} + +func (wasiStdin) Read(buffer []byte) (int, error) { + return readWasiFD(syscall.Stdin, buffer) +} + +func (wasiStdin) Close() error { + return nil +} + +var stdioStdin wasiStdin diff --git a/tsc/cmd/tsc/main.go b/tsc/cmd/tsc/main.go index cbe3f0f4e567b..899113dc19fc9 100644 --- a/tsc/cmd/tsc/main.go +++ b/tsc/cmd/tsc/main.go @@ -3,7 +3,6 @@ package main import ( "context" "os" - "os/signal" "syscall" "github.com/microsoft/TypeScript/tsc/internal/core" @@ -26,7 +25,7 @@ func runMain() int { return runAPI(args[1:]) } } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + ctx, stop := notifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() result := execute.CommandLine(ctx, newSystem(), args, nil) return int(result.Status) diff --git a/tsc/cmd/tsc/notifycontext.go b/tsc/cmd/tsc/notifycontext.go new file mode 100644 index 0000000000000..0508bcd115e89 --- /dev/null +++ b/tsc/cmd/tsc/notifycontext.go @@ -0,0 +1,13 @@ +//go:build !wasip1 + +package main + +import ( + "context" + "os" + "os/signal" +) + +func notifyContext(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, signals...) +} diff --git a/tsc/cmd/tsc/notifycontext_wasip1.go b/tsc/cmd/tsc/notifycontext_wasip1.go new file mode 100644 index 0000000000000..807f7b1c9b261 --- /dev/null +++ b/tsc/cmd/tsc/notifycontext_wasip1.go @@ -0,0 +1,12 @@ +//go:build wasip1 + +package main + +import ( + "context" + "os" +) + +func notifyContext(parent context.Context, _ ...os.Signal) (context.Context, context.CancelFunc) { + return context.WithCancel(parent) +} diff --git a/tsc/cmd/tsc/sys.go b/tsc/cmd/tsc/sys.go index dc8198a564c9a..608cb46cac628 100644 --- a/tsc/cmd/tsc/sys.go +++ b/tsc/cmd/tsc/sys.go @@ -122,7 +122,7 @@ func (p *childProcess) Close() error { } func newSystem() *osSys { - cwd, err := os.Getwd() + cwd, err := getCurrentDirectory() if err != nil { fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err) os.Exit(int(tsc.ExitStatusInvalidProject_OutputsSkipped)) diff --git a/tsc/cmd/tsc/wasi_io_wasip1.go b/tsc/cmd/tsc/wasi_io_wasip1.go new file mode 100644 index 0000000000000..c26da883f6bf9 --- /dev/null +++ b/tsc/cmd/tsc/wasi_io_wasip1.go @@ -0,0 +1,30 @@ +//go:build wasip1 + +package main + +import ( + "io" + "syscall" + "time" +) + +const maxWasiPollDelay = 50 * time.Millisecond + +func readWasiFD(fd int, buffer []byte) (int, error) { + delay := time.Millisecond + for { + n, err := syscall.Read(fd, buffer) + if n > 0 { + return n, nil + } + if err == nil { + return 0, io.EOF + } + if err != syscall.EAGAIN { + return 0, err + } + // These descriptors are not registered with Go's WASI poller. + time.Sleep(delay) + delay = min(delay*2, maxWasiPollDelay) + } +} diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go new file mode 100644 index 0000000000000..ce680d8cb3ac2 --- /dev/null +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -0,0 +1,330 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "runtime/debug" + "unsafe" + + "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" + "github.com/microsoft/TypeScript/tsc/internal/ipc" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/wrapvfs" +) + +const ( + // API callbacks are tunneled through reserved descriptors so that + // the module only requires standard WASI imports and remains directly runnable. + hostWriteFileFD = 0x7fff_fffe + hostCallbackFD = 0x7fff_fffd + hostErrorCapacity = 16 * 1024 + hostResultCapacity = 16 * 1024 * 1024 + errnoBadFileNumber = 8 +) + +var ( + reactor *wasmreactor.Reactor + + requestBuffer []byte + + responseBuffer []byte + responsePtr uint32 + responseLen uint32 + + hostCallbackBuffer []byte + inCall bool +) + +//go:wasmexport create_session +func createSession(optionsPtr uint32, optionsLen uint32) (status uint32) { + if reactor != nil { + return fail("session already created") + } + defer func() { + if recovered := recover(); recovered != nil { + reactor = nil + status = fail(fmt.Sprintf("panic creating session: %v\n%s", recovered, debug.Stack())) + } + }() + + var options wasmreactor.Options + if err := json.Unmarshal(readMemory(optionsPtr, optionsLen), &options); err != nil { + return fail(fmt.Sprintf("invalid session options: %v", err)) + } + options.Conn = hostCallbackConn{} + options.WrapFS = func(files vfs.FS) vfs.FS { + return wrapvfs.Wrap(files, wrapvfs.Replacements{ + WriteFile: func(path tspath.RootedFilePath, data string) error { + handled, err := writeFileToHost(path.AsString(), data) + if !handled { + return files.WriteFile(path, data) + } + return err + }, + }) + } + reactor = wasmreactor.New(context.Background(), options) + setResponse(nil) + return 0 +} + +//go:wasmexport close_session +func closeSession() { + if reactor == nil { + return + } + current := reactor + reactor = nil + defer func() { + _ = recover() + }() + current.Close() +} + +//go:wasmexport get_request_buffer +func getRequestBuffer(size uint32) uint32 { + if uint32(cap(requestBuffer)) < size || (cap(requestBuffer) > 1<<20 && uint32(cap(requestBuffer)) > 4*size) { + requestBuffer = make([]byte, size) + } + requestBuffer = requestBuffer[:size] + return bytesPointer(requestBuffer) +} + +//go:wasmexport handle_request +func handleRequest(methodLen uint32, payloadLen uint32) (status uint32) { + if inCall { + return fail("re-entrant handle_request: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(methodLen, payloadLen) { + return fail("invalid request lengths: method + payload exceeds the request buffer") + } + inCall = true + defer func() { + inCall = false + }() + defer func() { + if recovered := recover(); recovered != nil { + status = fail(fmt.Sprintf("panic: %v\n%s", recovered, debug.Stack())) + } + }() + + method := string(requestBuffer[:methodLen]) + payload := append([]byte(nil), requestBuffer[methodLen:methodLen+payloadLen]...) + response, err := reactor.HandleRequest(method, payload) + if err != nil { + return fail(err.Error()) + } + setResponse(response.Data) + return 0 +} + +//go:wasmexport set_file +func setFile(pathLen uint32, contentLen uint32) uint32 { + if inCall { + return fail("re-entrant set_file: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(pathLen, contentLen) { + return fail("invalid set_file lengths: path + content exceeds the request buffer") + } + path := string(requestBuffer[:pathLen]) + content := string(requestBuffer[pathLen : pathLen+contentLen]) + if err := reactor.SetFile(path, content); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return 0 +} + +//go:wasmexport read_file +func readFile(pathLen uint32) (status uint32) { + if inCall { + return fail("re-entrant read_file: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(pathLen, 0) { + return fail("invalid read_file length: path exceeds the request buffer") + } + defer func() { + if recovered := recover(); recovered != nil { + status = fail(fmt.Sprintf("panic reading file: %v\n%s", recovered, debug.Stack())) + } + }() + content, ok := reactor.ReadFile(string(requestBuffer[:pathLen])) + if !ok { + setResponse(nil) + return 2 + } + setResponse([]byte(content)) + return 0 +} + +//go:wasmexport remove_file +func removeFile(pathLen uint32) uint32 { + if inCall { + return fail("re-entrant remove_file: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(pathLen, 0) { + return fail("invalid remove_file length: path exceeds the request buffer") + } + if err := reactor.RemoveFile(string(requestBuffer[:pathLen])); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return 0 +} + +//go:wasmexport response_ptr +func responsePointer() uint32 { + return responsePtr +} + +//go:wasmexport response_len +func responseLength() uint32 { + return responseLen +} + +//go:wasmexport __typescript_cli_start +func cliStart() { + os.Exit(runMain()) +} + +func fail(message string) uint32 { + setResponse([]byte(message)) + return 1 +} + +func setResponse(data []byte) { + responseBuffer = data + responsePtr = bytesPointer(responseBuffer) + responseLen = uint32(len(responseBuffer)) +} + +func withinRequest(first uint32, second uint32) bool { + return first+second >= first && first+second <= uint32(len(requestBuffer)) +} + +func readMemory(pointer uint32, length uint32) []byte { + if length == 0 { + return nil + } + return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(pointer))), length) +} + +func bytesPointer(bytes []byte) uint32 { + if len(bytes) == 0 { + return 0 + } + return uint32(uintptr(unsafe.Pointer(&bytes[0]))) +} + +func bytePointer(bytes []byte) *byte { + if len(bytes) == 0 { + return nil + } + return &bytes[0] +} + +type hostCallbackConn struct{} + +var _ ipc.Conn = hostCallbackConn{} + +func (hostCallbackConn) Run(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() +} + +func (hostCallbackConn) Call(_ context.Context, method string, params any) (json.Value, error) { + paramsBytes, err := json.Marshal(params) + if err != nil { + return nil, err + } + methodBytes := []byte(method) + bufferSize := 16 + len(methodBytes) + len(paramsBytes) + hostResultCapacity + hostErrorCapacity + if cap(hostCallbackBuffer) < bufferSize { + hostCallbackBuffer = make([]byte, bufferSize) + } + buffer := hostCallbackBuffer[:bufferSize] + binary.LittleEndian.PutUint32(buffer, uint32(len(methodBytes))) + binary.LittleEndian.PutUint32(buffer[4:], uint32(len(paramsBytes))) + binary.LittleEndian.PutUint32(buffer[8:], hostResultCapacity) + binary.LittleEndian.PutUint32(buffer[12:], hostErrorCapacity) + copy(buffer[16:], methodBytes) + copy(buffer[16+len(methodBytes):], paramsBytes) + + iov := [2]uint32{bytesPointer(buffer), uint32(len(buffer))} + var written uint32 + errno := hostFDWrite(hostCallbackFD, unsafe.Pointer(&iov[0]), 1, &written) + if errno == errnoBadFileNumber { + return nil, errors.New("host callbacks are not supported by this WebAssembly host") + } + resultLength := min(binary.LittleEndian.Uint32(buffer[8:]), uint32(hostResultCapacity)) + errorLength := min(binary.LittleEndian.Uint32(buffer[12:]), uint32(hostErrorCapacity)) + resultStart := 16 + len(methodBytes) + len(paramsBytes) + if errno != 0 { + errorStart := resultStart + hostResultCapacity + message := string(buffer[errorStart : errorStart+int(errorLength)]) + if message == "" { + message = "host callback failed" + } + return nil, errors.New(message) + } + return json.Value(buffer[resultStart : resultStart+int(resultLength)]), nil +} + +func (hostCallbackConn) Notify(context.Context, string, any) error { + return errors.New("host callback notifications are not supported") +} + +func writeFileToHost(path string, data string) (bool, error) { + pathBytes := []byte(path) + dataBytes := []byte(data) + buffer := make([]byte, 12+len(pathBytes)+len(dataBytes)+hostErrorCapacity) + binary.LittleEndian.PutUint32(buffer, uint32(len(pathBytes))) + binary.LittleEndian.PutUint32(buffer[4:], uint32(len(dataBytes))) + binary.LittleEndian.PutUint32(buffer[8:], hostErrorCapacity) + copy(buffer[12:], pathBytes) + copy(buffer[12+len(pathBytes):], dataBytes) + + iov := [2]uint32{bytesPointer(buffer), uint32(len(buffer))} + var written uint32 + errno := hostFDWrite(hostWriteFileFD, unsafe.Pointer(&iov[0]), 1, &written) + if errno == errnoBadFileNumber { + return false, nil + } + if errno == 0 { + return true, nil + } + + errorLength := binary.LittleEndian.Uint32(buffer[8:]) + if errorLength > hostErrorCapacity { + errorLength = hostErrorCapacity + } + errorStart := 12 + len(pathBytes) + len(dataBytes) + message := string(buffer[errorStart : errorStart+int(errorLength)]) + if message == "" { + message = "host filesystem callback failed" + } + return true, errors.New(message) +} + +//go:wasmimport wasi_snapshot_preview1 fd_write +//go:noescape +func hostFDWrite(fd int32, iovs unsafe.Pointer, iovsLen uint32, written *uint32) uint32 diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go new file mode 100644 index 0000000000000..84577febd9ba2 --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -0,0 +1,134 @@ +// Package wasmreactor hosts an API session without an IPC transport. +package wasmreactor + +import ( + "context" + "sync" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/api" + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/ipc" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" +) + +type Options struct { + Cwd string `json:"cwd"` + UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` + CollectTiming bool `json:"collectTiming"` + WrapFS func(vfs.FS) vfs.FS `json:"-"` + Conn ipc.Conn `json:"-"` +} + +type Response struct { + Data []byte +} + +type Reactor struct { + ctx context.Context + cancel context.CancelFunc + session *api.Session + files vfs.FS + cwd tspath.RootedDirectoryPath + timing *ipc.ServerTimingCollector + closeOnce sync.Once +} + +func New(ctx context.Context, options Options) *Reactor { + if options.Cwd == "" { + options.Cwd = "/" + } + cwd := tspath.RootedDirectoryPathFromAbsolute(options.Cwd) + caseSensitivity := tspath.CaseSensitive + if options.UseCaseSensitiveFileNames != nil && !*options.UseCaseSensitiveFileNames { + caseSensitivity = tspath.CaseInsensitive + } + files := vfstest.FromMap(map[string]string{}, caseSensitivity) + var projectFiles vfs.FS = files + if options.WrapFS != nil { + projectFiles = options.WrapFS(projectFiles) + } + ctx, cancel := context.WithCancel(ctx) + sessionInit := &project.SessionInit{ + BackgroundCtx: ctx, + FS: bundled.WrapFS(projectFiles), + Options: &project.SessionOptions{ + CurrentDirectory: cwd, + DefaultLibraryPath: bundled.LibPath(), + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoggingEnabled: false, + }, + } + session := api.NewStandaloneSession(sessionInit, &api.SessionOptions{UseBinaryResponses: true}) + if options.Conn != nil { + session.SetConnection(options.Conn) + } + reactor := &Reactor{ + ctx: ctx, + cancel: cancel, + session: session, + files: files, + cwd: cwd, + } + if options.CollectTiming { + reactor.timing = ipc.NewServerTimingCollector() + } + return reactor +} + +func (r *Reactor) HandleRequest(method string, payload []byte) (Response, error) { + switch method { + case ipc.MethodGetServerTiming: + return marshalResponse(ipc.ServerTimingSnapshot(r.timing)) + case ipc.MethodResetServerTiming: + if r.timing != nil { + r.timing.Reset() + } + return marshalResponse(nil) + } + + start := time.Now() + result, err := r.session.HandleRequest(r.ctx, method, json.Value(payload)) + if r.timing != nil { + r.timing.Record(method, time.Since(start)) + } + if err != nil { + return Response{}, err + } + if raw, ok := result.(api.RawBinary); ok { + return Response{Data: []byte(raw)}, nil + } + return marshalResponse(result) +} + +func marshalResponse(result any) (Response, error) { + encoded, err := json.Marshal(result) + if err != nil { + return Response{}, err + } + return Response{Data: encoded}, nil +} + +func (r *Reactor) SetFile(path string, content string) error { + return r.files.WriteFile(tspath.ToRootedFilePath(path, r.cwd), content) +} + +func (r *Reactor) ReadFile(path string) (string, bool) { + return r.files.ReadFile(tspath.ToRootedFilePath(path, r.cwd)) +} + +func (r *Reactor) RemoveFile(path string) error { + return r.files.Remove(tspath.ToRootedPath(path, r.cwd)) +} + +func (r *Reactor) Close() { + r.closeOnce.Do(func() { + r.cancel() + r.session.Close() + }) +} diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go new file mode 100644 index 0000000000000..a59eb8901ab14 --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -0,0 +1,56 @@ +package wasmreactor + +import ( + "bytes" + "context" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/json" +) + +func TestReactor(t *testing.T) { + t.Parallel() + + reactor := New(context.Background(), Options{Cwd: "/"}) + defer reactor.Close() + + if err := reactor.SetFile("/src/index.ts", "export const value = 1;"); err != nil { + t.Fatal(err) + } + if contents, ok := reactor.ReadFile("/src/index.ts"); !ok || contents != "export const value = 1;" { + t.Fatalf("ReadFile() = %q, %v", contents, ok) + } + if _, ok := reactor.ReadFile("/src/missing.ts"); ok { + t.Fatal("ReadFile() found a missing file") + } + + initialize, err := reactor.HandleRequest("initialize", nil) + if err != nil { + t.Fatal(err) + } + var initializeResponse struct { + CurrentDirectory string `json:"currentDirectory"` + } + if unmarshalErr := json.Unmarshal(initialize.Data, &initializeResponse); unmarshalErr != nil { + t.Fatal(unmarshalErr) + } + if initializeResponse.CurrentDirectory != "/" { + t.Fatalf("currentDirectory = %q, want /", initializeResponse.CurrentDirectory) + } + + payload := []byte("reactor") + echo, err := reactor.HandleRequest("echo", payload) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(echo.Data, payload) { + t.Fatalf("echo = %q", echo.Data) + } + + if err := reactor.RemoveFile("/src/index.ts"); err != nil { + t.Fatal(err) + } + if _, ok := reactor.files.ReadFile("/src/index.ts"); ok { + t.Fatal("removed file still exists") + } +} diff --git a/tsc/internal/ipc/timing.go b/tsc/internal/ipc/timing.go index 4114a548eae99..5a6148c5aea24 100644 --- a/tsc/internal/ipc/timing.go +++ b/tsc/internal/ipc/timing.go @@ -60,6 +60,35 @@ type timingCollector struct { head int } +// ServerTimingCollector records server-side request processing time for transports +// that dispatch directly to a Handler rather than through a connection. +type ServerTimingCollector struct { + collector *timingCollector +} + +// NewServerTimingCollector creates an empty server timing collector. +func NewServerTimingCollector() *ServerTimingCollector { + return &ServerTimingCollector{collector: newTimingCollector()} +} + +// Record adds a completed request to the collector. +func (c *ServerTimingCollector) Record(method string, duration time.Duration) { + c.collector.record(method, duration) +} + +// Reset clears all collected request timing. +func (c *ServerTimingCollector) Reset() { + c.collector.reset() +} + +// ServerTimingSnapshot returns the collector's current JSON-serializable snapshot. +func ServerTimingSnapshot(collector *ServerTimingCollector) any { + if collector == nil { + return disabledServerTimingInfo() + } + return collector.collector.snapshot() +} + func newTimingCollector() *timingCollector { return &timingCollector{} }