diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f61c5c914c24..cfcb2110b7c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,7 +224,12 @@ jobs: - run: npx hereby test:extension - run: npx hereby test:tools - run: npx hereby test:api + - run: npx hereby test:api:wasm - run: npx hereby test:benchmarks:api + - run: npx playwright install --with-deps chromium + if: ${{ matrix.config.main }} + - run: npx hereby test:api:browser-smoke + 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 bf3010588e80a..dfec56213cd8c 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"; @@ -1006,7 +1007,27 @@ 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 runTestAPIWasm() { + for (const mode of ["async", "sync"]) { + await run("node", [ + "--conditions", + "@typescript/source", + "--test", + `./test/${mode}/api.test.ts`, + ...mode === "sync" ? ["./test/sync/ast.test.ts", "./test/sync/api-generators.test.ts"] : [], + ], { cwd: "./packages/typescript", env: { TS_API_TEST_WASM: "1" } }); + } +} + +async function runTestAPIBrowserSmoke() { + await run("node", ["--conditions", "@typescript/source", "--test", "./test/browser/**/*.test.ts"], { cwd: "./packages/typescript" }); } async function runTestAPIBenchmarks() { @@ -1051,13 +1072,97 @@ export const checkAPIDeclarations = 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, }); @@ -1068,6 +1173,13 @@ export const testAPI = task({ run: runTestAPI, }); +export const testAPIWasm = task({ + name: "test:api:wasm", + description: "Runs the API suites against the WASM reactor in Node.js.", + dependencies: [buildAPITests], + run: runTestAPIWasm, +}); + export const testAPIBenchmarks = task({ name: "test:benchmarks:api", description: "Runs async, sync, and generator API benchmarks once; excluded from validate.", @@ -1075,6 +1187,13 @@ export const testAPIBenchmarks = task({ run: runTestAPIBenchmarks, }); +export const testAPIBrowserSmoke = task({ + name: "test:api:browser-smoke", + description: "Runs the @typescript/typescript browser API smoke tests.", + dependencies: [buildAPITests], + run: runTestAPIBrowserSmoke, +}); + export const testAll = task({ name: "test:all", description: "Runs compiler, extension, benchmark, tools, and API tests. Codegen tests are opt-in via test:codegen.", @@ -1086,7 +1205,9 @@ export const testAll = task({ await runTestBenchmarks(); await runTestTools(); await runTestAPI(); + await runTestAPIWasm(); await runTestAPIBenchmarks(); + await runTestAPIBrowserSmoke(); }, }); @@ -1241,8 +1362,10 @@ export const validate = task({ if (!options.all) await runGenerateEnums(); await runBuildAPITests(!options.all); await runValidation("test:api", runTestAPI); + await runValidation("test:api:wasm", runTestAPIWasm); } if (options.all) { + await runValidation("check:herebyfile", runCheckHerebyfile); await runValidation("test:tools", runTestTools); await runValidation("test:options", async () => { await run("node", ["--test", "./tools/scripts/tsc/options.test.ts"]); @@ -1284,29 +1407,32 @@ export const checkFormat = task({ export const checkHerebyfile = task({ name: "check:herebyfile", description: "Type-checks Herebyfile.mjs.", - run: () => - run("node", [ - "./node_modules/typescript/bin/tsc", - "--noEmit", - "--allowJs", - "--allowImportingTsExtensions", - "--checkJs", - "--target", - "es2022", - "--lib", - "es2024,esnext.array,esnext.collection,esnext.iterator", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - "--types", - "node", - "--strict", - "--esModuleInterop", - "--skipLibCheck", - "Herebyfile.mjs", - ]), -}); + run: runCheckHerebyfile, +}); + +async function runCheckHerebyfile() { + await run("node", [ + "./node_modules/typescript/bin/tsc", + "--noEmit", + "--allowJs", + "--allowImportingTsExtensions", + "--checkJs", + "--target", + "es2022", + "--lib", + "es2024,esnext.array,esnext.collection,esnext.iterator", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--types", + "node", + "--strict", + "--esModuleInterop", + "--skipLibCheck", + "Herebyfile.mjs", + ]); +} export const checkVsceVersion = task({ name: "check:vsce-version", @@ -1928,6 +2054,13 @@ const mainNativePreviewPackage = { npmTarball: path.join(builtNpm, publishAsTypescript ? "typescript.tgz" : "native-preview.tgz"), }; +const wasip1Package = { + npmPackageName: "@typescript/typescript-wasip1-wasm", + npmDirName: "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", @@ -1951,7 +2084,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 */ @@ -1963,6 +2096,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; @@ -2086,29 +2249,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 { @@ -2294,6 +2441,7 @@ async function runBuildNativePreviewPackages() { const platforms = getPlatforms(); await buildMainNativePreviewPackage(platforms); + await buildWasip1Package(); if (isCI) { for (const platform of platforms) { @@ -2335,6 +2483,12 @@ export const buildNativePreviewPlatformTask = task({ throw new Error("typescript:build-platform requires --platform."); } + if (options.platform === "wasip1-wasm") { + await rimraf(wasip1Package.npmDir); + await buildWasip1Package(); + return; + } + const platform = getPlatforms().find(({ nodeOs, nodeArch }) => `${nodeOs}-${nodeArch}` === options.platform); if (!platform) { throw new Error(`Unknown release platform: ${options.platform}`); @@ -2350,10 +2504,13 @@ export const buildNativePreviewPlatformMatrix = task({ hiddenFromTaskList: true, run: () => { const matrix = Object.fromEntries( - getPlatforms().map(({ nodeOs, nodeArch, npmDirName }) => { - const platform = `${nodeOs}-${nodeArch}`; - return [platform.replaceAll("-", "_"), { platform, npmDirName }]; - }), + [ + ...getPlatforms().map(({ nodeOs, nodeArch, npmDirName }) => { + const platform = `${nodeOs}-${nodeArch}`; + return [platform.replaceAll("-", "_"), { platform, npmDirName }]; + }), + ["wasip1_wasm", { platform: "wasip1-wasm", npmDirName: wasip1Package.npmDirName }], + ], ); console.log(`##vso[task.setvariable variable=matrix;isOutput=true]${JSON.stringify(matrix)}`); console.log(`##vso[task.setvariable variable=packageBaseName;isOutput=true]${path.basename(mainNativePreviewPackage.npmDir)}`); @@ -2390,7 +2547,7 @@ async function buildMainNativePreviewPackage(platforms) { 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 runCheckAPIDeclarations(); await cpRecursive(path.join(inputDir, "dist"), path.join(mainPackageDir, "dist")); @@ -2420,6 +2577,34 @@ async function buildMainNativePreviewPackage(platforms) { } } +async function buildWasip1Package() { + const inputPackageJson = await createNativePreviewInputPackageJson(); + 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")); + 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")); +} + /** * @param {ReturnType[number]} platform */ @@ -2523,18 +2708,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}`); @@ -2545,6 +2736,22 @@ 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.version, getVersion(), "WASI artifact package must match the main package version"); + 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("lib/tsc.wasm"), "WASI artifact package is missing its compiler"); + 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"); + const binName = publishAsTypescript ? "tsc" : "tsgo"; const binPath = path.join(mainPackageDir, "bin", binName); const { stdout: versionOutput } = await runOutput(process.execPath, [binPath, "--version"]); @@ -2728,7 +2935,7 @@ 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); @@ -2736,9 +2943,11 @@ async function runPackNativePreviewPackages() { // npm packages need to be published in dependency order: platform packages // first, then the main package that references them as optionalDependencies. + // The WASI package goes with the platform packages so that typescript/wasm + // never refers to a version without a matching tsc.wasm. const publishManifest = { stages: [ - platforms.map(p => ({ + [...platforms, wasip1Package].map(p => ({ filename: path.basename(p.npmTarball), })), [ @@ -2899,7 +3108,14 @@ async function runPackVsixExtensions() { } const platforms = getPlatforms(); - const extensions = platforms.flatMap(({ npmTarball, npmPackageName, extensions }) => extensions.map(e => ({ npmTarball, npmPackageName, ...e }))); + const extensions = [ + ...platforms.flatMap(({ npmTarball, npmPackageName, extensions }) => extensions.map(e => ({ npmTarball, npmPackageName, ...e }))), + ...getLocalWebVsixExtensions().map(e => ({ + npmTarball: wasip1Package.npmTarball, + npmPackageName: wasip1Package.npmPackageName, + ...e, + })), + ]; if (!extensions.length) { console.log("No VSIX targets configured; skipping extension packaging."); return; @@ -2908,6 +3124,9 @@ async function runPackVsixExtensions() { // We don't use vscode:prepublish, as that would run the build for each package below. await run("npm", ["run", "-w", "@typescript/typescript", "build"]); 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) { @@ -2933,33 +3152,61 @@ async function runPackVsixExtensions() { console.log("Version:", version); await Promise.all(extensions.map(async ({ npmTarball, npmPackageName, nodeOs, vscodeTarget, sourceDir, extensionDir: thisExtensionDir, vsixPath, vsixManifestPath, vsixSignaturePath }) => { - const nodeModules = path.join(thisExtensionDir, "node_modules"); - const embeddedPlatformPackageDir = path.join(nodeModules, ...npmPackageName.split("/")); - const embeddedTypeScriptDir = path.join(nodeModules, "typescript"); - await cpWithoutNodeModulesOrTsconfig(sourceDir, thisExtensionDir); - if (usePublishedPlatformPackagesForVsix) { - await cpRecursive(await getPublishedPlatformPackageDir(npmPackageName), embeddedPlatformPackageDir); - await cpRecursive(getPublishedTypeScriptPackageDir(), embeddedTypeScriptDir, p => !p.endsWith("/node_modules")); + assert(npmTarball); + assert(npmPackageName); + let packageJsonDependencies; + if (vscodeTarget === "web") { + const embeddedWasmPackageDir = path.join(thisExtensionDir, "node_modules", ...npmPackageName.split("/")); + await fs.promises.mkdir(embeddedWasmPackageDir, { recursive: true }); + await tar.x({ file: npmTarball, cwd: embeddedWasmPackageDir, strip: 1 }); + const embeddedWasmPackageJson = JSON.parse(await fs.promises.readFile(path.join(embeddedWasmPackageDir, "package.json"), "utf8")); + packageJsonDependencies = { + [npmPackageName]: embeddedWasmPackageJson.version, + }; } else { - await fs.promises.mkdir(embeddedPlatformPackageDir, { recursive: true }); - await fs.promises.mkdir(embeddedTypeScriptDir, { recursive: true }); - await tar.x({ file: npmTarball, cwd: embeddedPlatformPackageDir, strip: 1 }); - await tar.x({ file: mainNativePreviewPackage.npmTarball, cwd: embeddedTypeScriptDir, strip: 1 }); + assert(nodeOs); + const nodeModules = path.join(thisExtensionDir, "node_modules"); + const embeddedPlatformPackageDir = path.join(nodeModules, ...npmPackageName.split("/")); + const embeddedTypeScriptDir = path.join(nodeModules, "typescript"); + + if (usePublishedPlatformPackagesForVsix) { + await cpRecursive(await getPublishedPlatformPackageDir(npmPackageName), embeddedPlatformPackageDir); + await cpRecursive(getPublishedTypeScriptPackageDir(), embeddedTypeScriptDir, p => !p.endsWith("/node_modules")); + } + else { + await fs.promises.mkdir(embeddedPlatformPackageDir, { recursive: true }); + await fs.promises.mkdir(embeddedTypeScriptDir, { recursive: true }); + await tar.x({ file: npmTarball, cwd: embeddedPlatformPackageDir, strip: 1 }); + await tar.x({ file: mainNativePreviewPackage.npmTarball, cwd: embeddedTypeScriptDir, strip: 1 }); + } + await fs.promises.chmod(path.join(embeddedPlatformPackageDir, "lib", nativePreviewExeName(nodeOs)), 0o755); + const embeddedTypeScriptPackageJson = JSON.parse(await fs.promises.readFile(path.join(embeddedTypeScriptDir, "package.json"), "utf8")); + packageJsonDependencies = { + typescript: embeddedTypeScriptPackageJson.name === "typescript" + ? embeddedTypeScriptPackageJson.version + : `npm:${embeddedTypeScriptPackageJson.name}@${embeddedTypeScriptPackageJson.version}`, + }; } - await fs.promises.chmod(path.join(embeddedPlatformPackageDir, "lib", nativePreviewExeName(nodeOs)), 0o755); - const embeddedTypeScriptPackageJson = JSON.parse(await fs.promises.readFile(path.join(embeddedTypeScriptDir, "package.json"), "utf8")); const packageJsonPath = path.join(thisExtensionDir, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); packageJson.version = version; packageJson.bundledTypeScriptVersion = usePublishedPlatformPackagesForVsix ? getPublishedTypeScriptVersion() : getVersion(); - packageJson.dependencies = { - typescript: embeddedTypeScriptPackageJson.name === "typescript" - ? embeddedTypeScriptPackageJson.version - : `npm:${embeddedTypeScriptPackageJson.name}@${embeddedTypeScriptPackageJson.version}`, - }; + if (vscodeTarget === "web") { + delete packageJson.main; + packageJson.files = /** @type {string[]} */ (packageJson.files ?? []).filter(file => file !== "dist/extension.bundle.js" && file !== "node_modules/typescript"); + 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"); + } + } + packageJson.dependencies = packageJsonDependencies; fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 4)); await fs.promises.copyFile("NOTICE.txt", path.join(thisExtensionDir, "NOTICE.txt")); @@ -2984,12 +3231,13 @@ async function runPackVsixExtensions() { * @param {string} vsixPath * @param {string} extensionPath * @param {string} platformPackageName - * @param {string} nodeOs + * @param {string | undefined} nodeOs * @param {string} vscodeTarget */ async function testVsixPackage(vsixPath, extensionPath, platformPackageName, nodeOs, vscodeTarget) { const zip = new AdmZip(vsixPath); - for (const packageName of ["typescript", platformPackageName]) { + const packageNames = vscodeTarget === "web" ? [platformPackageName] : ["typescript", platformPackageName]; + for (const packageName of packageNames) { const packagePath = path.join(extensionPath, "node_modules", ...packageName.split("/")); const files = await fs.promises.readdir(packagePath, { recursive: true, withFileTypes: true }); for (const file of files) { @@ -3004,6 +3252,7 @@ async function testVsixPackage(vsixPath, extensionPath, platformPackageName, nod const hostTarget = `${process.platform}-${process.arch === "arm" ? "armhf" : process.arch}`; if (vscodeTarget !== hostTarget) return; + assert(nodeOs); const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "typescript-vsix-test-")); try { diff --git a/package-lock.json b/package-lock.json index d693c42e440d2..e3662ee7728c0 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,32 @@ "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/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 +2177,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", @@ -2253,6 +2320,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 +2345,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", @@ -2411,6 +2513,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 +2608,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", @@ -2527,6 +2646,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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", @@ -2682,6 +2811,16 @@ "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-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -3014,6 +3153,35 @@ "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/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -3070,6 +3238,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", @@ -3219,6 +3397,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", @@ -3446,6 +3652,24 @@ "resolved": "packages/vscode-typescript-nightly", "link": true }, + "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 +3710,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 +3730,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 +3791,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "playwright": "^1.62.1", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" }, @@ -3535,6 +3799,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 +3817,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..08f63e69e9d5e --- /dev/null +++ b/packages/typescript-wasip1-wasm/README.md @@ -0,0 +1,12 @@ +# `@typescript/typescript-wasip1-wasm` + +> [!WARNING] +> This package is experimental. Its contents and layout, and the +> `typescript/wasm` host that loads it, 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/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/README.md b/packages/typescript/README.md index c35985115a453..fcb15ad43dab7 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -16,6 +16,8 @@ For other builds, use the `tsgo` command just like you would use `tsc`: npx tsgo --help ``` +The JavaScript API requires Node.js 20.16 or later when it starts the compiler itself. In other environments, provide a transport. + ## Issues and Feedback The native port of TypeScript is still in progress. diff --git a/packages/typescript/lib/getExePath.d.ts b/packages/typescript/lib/getExePath.d.ts index bf33fb7cf6882..8cf164d03ed66 100644 --- a/packages/typescript/lib/getExePath.d.ts +++ b/packages/typescript/lib/getExePath.d.ts @@ -1,2 +1,8 @@ -declare function getExePath(): string; +export interface NodeModules { + fs: typeof import("node:fs"); + module: typeof import("node:module"); + path: typeof import("node:path"); + url: typeof import("node:url"); +} +declare function getExePath(modules: NodeModules): string; export default getExePath; diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index 0bb4ab39e13e7..98fee2cc0890f 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -1,11 +1,11 @@ -import fs from "node:fs"; -import module from "node:module"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +// Node modules are passed in rather than imported so that this file can be +// loaded in any environment; callers choose how to obtain them. +/** @typedef {import("./getExePath.d.ts").NodeModules} NodeModules */ // NOTE: Keep VS Code extension's resolveTsdkPathToExe in sync with this function. -export default function getExePath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** @param {NodeModules} modules */ +export default function getExePath({ fs, module, path, url }) { + const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); const normalizedDirname = __dirname.replace(/\\/g, "/"); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); @@ -45,7 +45,7 @@ export default function getExePath() { else { // v20.6.0, v18.19.0 const packageJson = import.meta.resolve(platformPackageName + "/package.json"); - const packageJsonPath = fileURLToPath(packageJson); + const packageJsonPath = url.fileURLToPath(packageJson); exeDir = path.join(path.dirname(packageJsonPath), "lib"); } } diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 506d783f70d69..c5cd47db2a06c 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -1,9 +1,13 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import module from "node:module"; +import path from "node:path"; +import url from "node:url"; import getExePath from "./getExePath.js"; -const exe = getExePath(); +const exe = getExePath({ fs, module, path, url }); if (process.platform !== "win32" && typeof process.execve === "function") { // > v22.15.0 diff --git a/packages/typescript/package.json b/packages/typescript/package.json index fbe0c3166e907..0b2aaaa79218d 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -50,10 +50,18 @@ "@typescript/source": "./src/api/sync/api.ts", "default": "./dist/api/sync/api.js" }, + "./sync/transport": { + "@typescript/source": "./src/api/sync/transport.ts", + "default": "./dist/api/sync/transport.js" + }, "./async": { "@typescript/source": "./src/api/async/api.ts", "default": "./dist/api/async/api.js" }, + "./async/transport": { + "@typescript/source": "./src/api/async/transport.ts", + "default": "./dist/api/async/transport.js" + }, "./fs": { "@typescript/source": "./src/api/fs.ts", "default": "./dist/api/fs.js" @@ -66,6 +74,10 @@ "@typescript/source": "./src/api/proto.ts", "default": "./dist/api/proto.js" }, + "./wasm": { + "@typescript/source": "./src/wasm/index.ts", + "default": "./dist/wasm/index.js" + }, "./ast": { "@typescript/source": "./src/ast/index.ts", "default": "./dist/ast/index.js" @@ -103,11 +115,13 @@ "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-smoke:only": "npm run node -- --test './test/browser/**/*.test.ts'", "test": "npm run test:only" }, "devDependencies": { "@types/node": "^22.20.1", + "playwright": "^1.62.1", "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 64dbd13a61a75..3a5f0d9d41819 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -57,7 +57,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 { @@ -138,9 +138,10 @@ import type { TimingInfo, } from "../timing.ts"; import { - Client, + type Client, // @sync: Client, type ClientSocketOptions, type ClientSpawnOptions, + createClient, // @sync-skip } from "./client.ts"; import type { AssertsIdentifierTypePredicate, @@ -364,7 +365,7 @@ export class API implements FormatDiagnosticsHo readonly debug: DebugHandlers; constructor(options: APIOptions | LSPConnectionOptions = {}) { - this.client = new Client(options); + this.client = createClient(options); // @sync: this.client = new Client(options); this.sourceFileCache = new SourceFileCache(); this.printer = new Printer(this.client); this.debug = new DebugHandlers(this.client, () => this.ensureInitialized()); // @sync: this.debug = new DebugHandlers(this.client, this.ensureInitialized); @@ -764,6 +765,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/client.ts b/packages/typescript/src/api/async/client.ts index 9d06cbdedfb27..bb5adf7030aa3 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -1,14 +1,5 @@ import type { ChildProcess } from "node:child_process"; import type { Socket } from "node:net"; -import { - createMessageConnection, - type MessageConnection, - RequestType, - SocketMessageReader, - SocketMessageWriter, - StreamMessageReader, - StreamMessageWriter, -} from "../../../vendor/vscode-jsonrpc/lib/node/main.js"; import type { RootedDirectoryPath, RootedFilePath, @@ -20,12 +11,15 @@ import { encodeFileSystemCallbackResult, type FileSystemCallbackConfiguration, } from "../fsCallbacks.ts"; +import { nodeProcess } from "../nodeProcess.ts"; import { + type AsyncClientOptions, type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, getAPIProcessArgs, isSpawnOptions, + isTransportOptions, resolveExePath, } from "../options.ts"; import type { @@ -44,17 +38,31 @@ import { type TimingInfo, } from "../timing.ts"; import { TimingDump } from "../timingDump.ts"; +import type * as JsonRpc from "./jsonrpc.ts"; +import { + createTransportClient, + type TransportClient, +} from "./transportClient.ts"; + +export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; +export type { AsyncTransport } from "./transport.ts"; + +export type Client = TransportClient | RpcClient; -export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions }; +export function createClient(options: AsyncClientOptions): Client { + return isTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); +} /** * Client handles communication with the TypeScript API server * over STDIO (spawned process) or a Unix domain socket using JSON-RPC. */ -export class Client { +class RpcClient { private socket: Socket | undefined; private process: ChildProcess | undefined; - private connection: MessageConnection | undefined; + private connection: JsonRpc.MessageConnection | undefined; + // Loaded on connection so that injected-transport clients never load it. + private jsonrpc: typeof JsonRpc | undefined; private options: ClientOptions; private connected = false; private closed = false; @@ -88,6 +96,7 @@ export class Client { } private async connectWorker(): Promise { + this.jsonrpc ??= await import("./jsonrpc.ts"); if (isSpawnOptions(this.options)) { await this.connectViaSpawn(this.options); } @@ -97,7 +106,7 @@ export class Client { } private async connectViaSpawn(options: ClientSpawnOptions): Promise { - const { spawn } = await import("node:child_process"); + const { spawn } = nodeProcess().getBuiltinModule("node:child_process"); return new Promise((resolve, reject) => { const args = getAPIProcessArgs(options, true); @@ -120,22 +129,18 @@ export class Client { resolve(); }); - const reader = new StreamMessageReader(this.process.stdout!); - const writer = new StreamMessageWriter(this.process.stdin!); - this.connection = createMessageConnection(reader, writer); + this.connection = this.jsonrpc!.createStreamConnection(this.process.stdout!, this.process.stdin!); this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); this.connection.listen(); }); } private async connectViaSocket(options: ClientSocketOptions): Promise { - const { createConnection } = await import("node:net"); + const { createConnection } = nodeProcess().getBuiltinModule("node:net"); return new Promise((resolve, reject) => { this.socket = createConnection(options.pipe, () => { - const reader = new SocketMessageReader(this.socket!); - const writer = new SocketMessageWriter(this.socket!); - this.connection = createMessageConnection(reader, writer); + this.connection = this.jsonrpc!.createStreamConnection(this.socket!, this.socket!); this.connection.listen(); this.connected = true; resolve(); @@ -148,7 +153,7 @@ export class Client { } private registerFSCallbacks( - connection: MessageConnection, + connection: JsonRpc.MessageConnection, fs: FileSystemCallbacks | undefined, configuration: FileSystemCallbackConfiguration, ): void { @@ -158,7 +163,7 @@ export class Client { case "readFile": { const callback = fs.readFile; if (typeof callback !== "function") throw new Error("Invalid readFile callback configuration"); - connection.onRequest(new RequestType(name), fileName => { + connection.onRequest(this.requestType(name), fileName => { return encodeFileSystemCallbackResult(name, callback(fileName)); }); break; @@ -166,7 +171,7 @@ export class Client { case "fileExists": { const callback = fs.fileExists; if (typeof callback !== "function") throw new Error("Invalid fileExists callback configuration"); - connection.onRequest(new RequestType(name), fileName => { + connection.onRequest(this.requestType(name), fileName => { return encodeFileSystemCallbackResult(name, callback(fileName)); }); break; @@ -174,7 +179,7 @@ export class Client { case "directoryExists": { const callback = fs.directoryExists; if (typeof callback !== "function") throw new Error("Invalid directoryExists callback configuration"); - connection.onRequest(new RequestType(name), directoryName => { + connection.onRequest(this.requestType(name), directoryName => { return encodeFileSystemCallbackResult(name, callback(directoryName)); }); break; @@ -182,7 +187,7 @@ export class Client { case "getAccessibleEntries": { const callback = fs.getAccessibleEntries; if (typeof callback !== "function") throw new Error("Invalid getAccessibleEntries callback configuration"); - connection.onRequest(new RequestType(name), directoryName => { + connection.onRequest(this.requestType(name), directoryName => { return encodeFileSystemCallbackResult(name, callback(directoryName)); }); break; @@ -190,7 +195,7 @@ export class Client { case "realpath": { const callback = fs.realpath; if (typeof callback !== "function") throw new Error("Invalid realpath callback configuration"); - connection.onRequest(new RequestType(name), path => { + connection.onRequest(this.requestType(name), path => { return encodeFileSystemCallbackResult(name, callback(path)); }); break; @@ -198,7 +203,7 @@ export class Client { case "stat": { const callback = fs.stat; if (typeof callback !== "function") throw new Error("Invalid stat callback configuration"); - connection.onRequest(new RequestType(name), path => { + connection.onRequest(this.requestType(name), path => { return encodeFileSystemCallbackResult(name, callback(path)); }); break; @@ -206,7 +211,7 @@ export class Client { case "writeFile": { const callback = fs.writeFile; if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); - connection.onRequest(new RequestType<{ path: RootedFilePath; data: string; }, unknown, void>(name), arg => { + connection.onRequest(this.requestType<{ path: RootedFilePath; data: string; }, unknown>(name), arg => { return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); }); break; @@ -214,7 +219,7 @@ export class Client { case "removeFile": { const callback = fs.removeFile; if (typeof callback !== "function") throw new Error("Invalid removeFile callback configuration"); - connection.onRequest(new RequestType(name), path => { + connection.onRequest(this.requestType(name), path => { return encodeFileSystemCallbackResult(name, callback(path)); }); break; @@ -223,7 +228,12 @@ export class Client { } } - private async sendRequestWithTiming(requestType: RequestType, params: unknown): Promise { + private requestType(method: string): JsonRpc.RequestType { + if (!this.jsonrpc) throw new Error("Connection not established"); + return new this.jsonrpc.RequestType(method); + } + + private async sendRequestWithTiming(requestType: JsonRpc.RequestType, params: unknown): Promise { if (!this.connection) { throw new Error("Connection not established"); } @@ -255,7 +265,7 @@ export class Client { if (!this.connection) { throw new Error("Connection not established"); } - const disposable = this.connection.onRequest(new RequestType(name), callback); + const disposable = this.connection.onRequest(this.requestType(name), callback); return () => disposable.dispose(); } @@ -274,13 +284,13 @@ export class Client { if (requests.length === 1) { // send single queued requests directly instead of as a batched request - const requestType = new RequestType(requests[0].method); + const requestType = this.requestType(requests[0].method); const response = await this.sendRequestWithTiming(requestType, requests[0].params); requests[0].resolve(response); return; } - const requestType = new RequestType("batchRequests"); + const requestType = this.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; @@ -317,7 +327,7 @@ export class Client { } private scheduleImmediateBatch(): void { - if (this.nextBatch) return; + if (this.closed || this.nextBatch) return; this.nextBatch = setImmediate(this.doBatch.bind(this)); } @@ -343,6 +353,7 @@ export class Client { if (!this.connected) { await this.connect(); } + if (this.closed) throw new Error("Client is closed"); if (!this.connection) { throw new Error("Connection not established"); } @@ -393,7 +404,7 @@ export class Client { this.timing.reset(); if (this.connected && this.connection) { // Keep the server's collection in sync so combined totals stay meaningful. - const requestType = new RequestType("resetServerTiming"); + const requestType = this.requestType("resetServerTiming"); await this.connection.sendRequest(requestType, undefined); } } @@ -404,7 +415,7 @@ export class Client { } // Fetch the server's own timing collection via a dedicated request. This // bypasses the client-side collector so the query does not pollute it. - const requestType = new RequestType("getServerTiming"); + const requestType = this.requestType("getServerTiming"); return this.connection.sendRequest(requestType, undefined); } @@ -413,8 +424,17 @@ export class Client { } private async closeWorker(): Promise { - await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection 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 try { if (this.timingDump && this.timing) { const local = this.timing.getInfo(); diff --git a/packages/typescript/src/api/async/jsonrpc.ts b/packages/typescript/src/api/async/jsonrpc.ts new file mode 100644 index 0000000000000..fec7364099f04 --- /dev/null +++ b/packages/typescript/src/api/async/jsonrpc.ts @@ -0,0 +1,125 @@ +// Only vscode-jsonrpc's environment-neutral core is imported here. Its Node +// entry point loads Node modules at import time, which would prevent this +// package from being loaded or bundled for other environments, so the Node +// runtime layer it would install is provided below instead. +import { + AbstractMessageBuffer, + createMessageConnection, + Disposable, + type MessageConnection, + NullLogger, + RAL, + ReadableStreamMessageReader, + WriteableStreamMessageWriter, +} from "../../../vendor/vscode-jsonrpc/lib/common/api.js"; + +export { type MessageConnection, RequestType } from "../../../vendor/vscode-jsonrpc/lib/common/api.js"; + +class MessageBuffer extends AbstractMessageBuffer { + protected emptyBuffer(): Uint8Array { + return Buffer.allocUnsafe(0); + } + + protected fromString(value: string, encoding: RAL.MessageBufferEncoding): Uint8Array { + return Buffer.from(value, encoding); + } + + protected toString(value: Uint8Array, encoding: RAL.MessageBufferEncoding): string { + return value instanceof Buffer ? value.toString(encoding) : new TextDecoder(encoding).decode(value); + } + + protected asNative(buffer: Uint8Array, length?: number): Uint8Array { + if (length === undefined) { + return buffer instanceof Buffer ? buffer : Buffer.from(buffer); + } + return buffer instanceof Buffer ? buffer.subarray(0, length) : Buffer.from(buffer.buffer, buffer.byteOffset, length); + } + + protected allocNative(length: number): Uint8Array { + return Buffer.allocUnsafe(length); + } +} + +interface EventSource { + on(event: string, listener: (...args: any[]) => void): unknown; + off(event: string, listener: (...args: any[]) => void): unknown; +} + +interface WritableStream extends EventSource { + write(data: Uint8Array, callback: (error: Error | null | undefined) => void): unknown; + write(data: string, encoding: RAL.MessageBufferEncoding, callback: (error: Error | null | undefined) => void): unknown; + end(): unknown; +} + +function listen(stream: EventSource, event: string, listener: (...args: any[]) => void): Disposable { + stream.on(event, listener); + return Disposable.create(() => stream.off(event, listener)); +} + +let installed = false; + +function installRuntime(): void { + if (installed) return; + installed = true; + RAL.install({ + messageBuffer: { + create: encoding => new MessageBuffer(encoding), + }, + applicationJson: { + encoder: { + name: "application/json", + encode: async (message, options) => Buffer.from(JSON.stringify(message, undefined, 0), options.charset), + }, + decoder: { + name: "application/json", + decode: async (buffer, options) => + JSON.parse( + buffer instanceof Buffer ? buffer.toString(options.charset) : new TextDecoder(options.charset).decode(buffer), + ), + }, + }, + console, + timer: { + setTimeout(callback, ms, ...args) { + const handle = setTimeout(callback, ms, ...args); + return { dispose: () => clearTimeout(handle) }; + }, + setImmediate(callback, ...args) { + const handle = setImmediate(callback, ...args); + return { dispose: () => clearImmediate(handle) }; + }, + setInterval(callback, ms, ...args) { + const handle = setInterval(callback, ms, ...args); + return { dispose: () => clearInterval(handle) }; + }, + }, + }); +} + +/** Creates a JSON-RPC connection over Node.js streams. */ +export function createStreamConnection(readable: EventSource, writable: WritableStream): MessageConnection { + installRuntime(); + const reader = new ReadableStreamMessageReader({ + onClose: listener => listen(readable, "close", listener), + onError: listener => listen(readable, "error", listener), + onEnd: listener => listen(readable, "end", listener), + onData: listener => listen(readable, "data", listener), + }); + const writer = new WriteableStreamMessageWriter({ + onClose: listener => listen(writable, "close", listener), + onError: listener => listen(writable, "error", listener), + onEnd: listener => listen(writable, "end", listener), + write: (data: Uint8Array | string, encoding?: RAL.MessageBufferEncoding) => + new Promise((resolve, reject) => { + const callback = (error: Error | null | undefined) => error ? reject(error) : resolve(); + if (typeof data === "string") { + writable.write(data, encoding ?? "utf-8", callback); + } + else { + writable.write(data, callback); + } + }), + end: () => writable.end(), + }); + return createMessageConnection(reader, writer, NullLogger); +} diff --git a/packages/typescript/src/api/async/transport.ts b/packages/typescript/src/api/async/transport.ts new file mode 100644 index 0000000000000..e968da013e91a --- /dev/null +++ b/packages/typescript/src/api/async/transport.ts @@ -0,0 +1,27 @@ +import type { FileSystemCallbacks } from "../fs.ts"; + +/** + * 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 the server invokes during a request. Transports + * that cannot wait for a Promise or accept nested requests from the + * callback must reject them. + */ + registerCallback?(name: string, callback: (name: string, payload: string) => string | PromiseLike): void; + unregisterCallback?(name: string): void; + setFileSystem?(fs: FileSystemCallbacks | undefined): void; + close(): void | PromiseLike; +} diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts new file mode 100644 index 0000000000000..95f0d1a9e1673 --- /dev/null +++ b/packages/typescript/src/api/async/transportClient.ts @@ -0,0 +1,316 @@ +import type { AsyncClientTransportOptions } from "../options.ts"; +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; + +export function createTransportClient(options: AsyncClientTransportOptions): TransportClient { + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + return new TransportClient(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); +} + +/** 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 closed = false; + private closing: Promise | undefined; + private nextBatch: boolean | "manual" = false; + private batchGeneration = 0; + private batchedRequests: PendingRequest[] = []; + private readonly pendingOperations = new Set<(reason: Error) => void>(); + + constructor(transport: AsyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + 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 (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"); + 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)) { + return result.then(value => JSON.stringify(value) ?? ""); + } + 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.closed) { + 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.closed) { + await this.invokeTransport(() => this.transport.request("resetServerTiming", "")); + } + } + + close(): Promise { + return this.closing ??= this.closeWorker(); + } + + private async closeWorker(): Promise { + this.closed = true; + for (const reject of this.pendingOperations) { + reject(new Error("Client is closed")); + } + this.pendingOperations.clear(); + 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(); + } + + 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((request): request is PendingTextRequest => request.kind === "text")) { + 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) { + try { + 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) { + request.reject(error); + } + } + } + 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 new Promise((resolve, reject) => { + this.pendingOperations.add(reject); + Promise.resolve().then(() => { + if (this.closed) throw new Error("Client is closed"); + return operation(); + }).then( + value => { + this.pendingOperations.delete(reject); + resolve(value); + }, + error => { + this.pendingOperations.delete(reject); + reject(error); + }, + ); + }); + } + + 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/fs.ts b/packages/typescript/src/api/fs.ts index 25bfe9f044309..095c53a81df1b 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -1,10 +1,13 @@ -import { dirname } from "node:path"; -import getExePath from "../../lib/getExePath.js"; import type { RootedDirectoryPath, RootedFilePath, RootedPath, } from "../ast/index.ts"; +import { serverFS } from "./fsCallbacks.ts"; +import { + getDefaultExePath, + nodeProcess, +} from "./nodeProcess.ts"; import { normalizePath } from "./path.ts"; import type { RequestDirectoryEntries, @@ -32,30 +35,7 @@ export interface FileSystemStat { mtime: Date; } -const useOS: unique symbol = Symbol("useOS"); -const identity: unique symbol = Symbol("identity"); -const fakeStat: unique symbol = Symbol("fakeStat"); -const noop: unique symbol = Symbol("noop"); -const error: unique symbol = Symbol("error"); - -export const serverFS: { - /** Delegate the configured operation, or the current callback invocation, to the server's operating-system filesystem. */ - readonly useOS: typeof useOS; - /** Use the input path as its own real path without consulting a filesystem. Valid only for `realpath`. */ - readonly identity: typeof identity; - /** Synthesize stat information from `directoryExists` and `fileExists`. Valid only for `stat`. */ - readonly fakeStat: typeof fakeStat; - /** Ignore writes without invoking a callback or writing to the server's operating-system filesystem. Valid only for `writeFile`. */ - readonly noop: typeof noop; - /** Panic if the configured operation, or current callback invocation, reaches the server filesystem. */ - readonly error: typeof error; -} = { - useOS: useOS, - identity: identity, - fakeStat: fakeStat, - noop: noop, - error: error, -}; +export { serverFS }; export interface FileSystemCallbacks { directoryExists: @@ -143,11 +123,11 @@ export function createFileSystemWithLib( : [normalizePath("bundled:///libs")]; if (!options.defaultLibraryPath) { try { - defaultLibraryPaths.push(normalizePath(dirname(getExePath()))); + defaultLibraryPaths.push(normalizePath(nodeProcess().getBuiltinModule("node:path").dirname(getDefaultExePath()))); } catch { - // A socket-connected embedded server can provide bundled libs without - // a locally installed compiler executable. + // A socket-connected embedded server or a WebAssembly transport can + // provide bundled libs without a locally installed compiler executable. } } const symlinks = { ...options.symlinks }; diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts index bba5efa4155e5..474864bc7c0d9 100644 --- a/packages/typescript/src/api/fsCallbacks.ts +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -1,7 +1,29 @@ -import { - type FileSystemCallbacks, - serverFS, -} from "./fs.ts"; +import type { FileSystemCallbacks } from "./fs.ts"; + +const useOS: unique symbol = Symbol("useOS"); +const identity: unique symbol = Symbol("identity"); +const fakeStat: unique symbol = Symbol("fakeStat"); +const noop: unique symbol = Symbol("noop"); +const error: unique symbol = Symbol("error"); + +export const serverFS: { + /** Delegate the configured operation, or the current callback invocation, to the server's operating-system filesystem. */ + readonly useOS: typeof useOS; + /** Use the input path as its own real path without consulting a filesystem. Valid only for `realpath`. */ + readonly identity: typeof identity; + /** Synthesize stat information from `directoryExists` and `fileExists`. Valid only for `stat`. */ + readonly fakeStat: typeof fakeStat; + /** Ignore writes without invoking a callback or writing to the server's operating-system filesystem. Valid only for `writeFile`. */ + readonly noop: typeof noop; + /** Panic if the configured operation, or current callback invocation, reaches the server filesystem. */ + readonly error: typeof error; +} = { + useOS: useOS, + identity: identity, + fakeStat: fakeStat, + noop: noop, + error: error, +}; type ServerFSSentinelName = keyof typeof serverFS; type ServerFSSentinel = typeof serverFS[ServerFSSentinelName]; diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 0bfdcc2ff02e4..4c93c5357df38 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -368,15 +368,41 @@ export function encodeNode(node: Node): Uint8Array { return result; } +interface NodeBuffer extends Uint8Array { + toString(encoding?: "base64"): string; +} + +const nodeBuffer = (globalThis as { + Buffer?: { + from(buffer: ArrayBufferLike, byteOffset: number, length: number): NodeBuffer; + from(data: string, encoding: "base64"): NodeBuffer; + }; +}).Buffer; + /** * Encode a Uint8Array to a base64 string. */ -export function uint8ArrayToBase64(data: Uint8Array): string { - return Buffer.from(data).toString("base64"); -} +export const uint8ArrayToBase64: (data: Uint8Array) => string = "toBase64" in Uint8Array.prototype + ? data => (data as Uint8Array & { toBase64(): string; }).toBase64() + : nodeBuffer + ? data => nodeBuffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64") + : data => { + let binary = ""; + for (let i = 0; i < data.length; i += 0x8000) { + binary += String.fromCharCode(...data.subarray(i, i + 0x8000)); + } + return btoa(binary); + }; + +const base64ToUint8Array: (data: string) => Uint8Array = "fromBase64" in Uint8Array + ? (Uint8Array as unknown as { fromBase64(data: string): Uint8Array; }).fromBase64 + : nodeBuffer + ? data => { + const buffer = nodeBuffer.from(data, "base64"); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } + : data => Uint8Array.from(atob(data), character => character.charCodeAt(0)); 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); + return response ? base64ToUint8Array(response.data) : undefined; } diff --git a/packages/typescript/src/api/node/wtf8.ts b/packages/typescript/src/api/node/wtf8.ts index 30321e734b872..bfaebcb8c1a8f 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,16 @@ 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 = bytes.indexOf(replacementCharacterUtf8[0]); i !== -1 && i + 2 < bytes.length; i = bytes.indexOf(replacementCharacterUtf8[0], i + 1)) { + if (bytes[i + 1] === replacementCharacterUtf8[1] && bytes[i + 2] === replacementCharacterUtf8[2]) { + return true; + } + } + return false; } function toUint8Array(input: Exclude): Uint8Array { @@ -45,7 +52,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/nodeProcess.ts b/packages/typescript/src/api/nodeProcess.ts new file mode 100644 index 0000000000000..f958866a333af --- /dev/null +++ b/packages/typescript/src/api/nodeProcess.ts @@ -0,0 +1,24 @@ +import getExePath from "../../lib/getExePath.js"; + +/** + * Returns `process` when running in Node.js with `process.getBuiltinModule`. + * Node modules are loaded through it so that the API can also be loaded in + * environments without them, where a transport must be provided instead. + */ +export function nodeProcess(): NodeJS.Process { + const process = (globalThis as { process?: Partial; }).process; + if (typeof process?.getBuiltinModule !== "function") { + throw new Error("Starting or connecting to a TypeScript API server requires Node.js 20.16 or later; in other environments, provide a transport."); + } + return process as NodeJS.Process; +} + +export function getDefaultExePath(): string { + const process = nodeProcess(); + return getExePath({ + fs: process.getBuiltinModule("node:fs"), + module: process.getBuiltinModule("node:module"), + path: process.getBuiltinModule("node:path"), + url: process.getBuiltinModule("node:url"), + }); +} diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 1cc066f5e18f0..bb66b498bb926 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -2,10 +2,13 @@ * Shared utilities for the TypeScript API client. */ -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import getExePath from "../../lib/getExePath.js"; +import type { AsyncTransport } from "./async/transport.ts"; import type { FileSystemCallbacks } from "./fs.ts"; +import { + getDefaultExePath, + nodeProcess, +} from "./nodeProcess.ts"; +import type { SyncTransport } from "./sync/transport.ts"; export interface ClientSocketOptions { /** Path to the Unix domain socket or Windows named pipe for API communication */ @@ -46,14 +49,49 @@ export function isSpawnOptions(options: ClientOptions): options is ClientSpawnOp return !("pipe" in options); } +interface TransportOptionsBase { + /** 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 interface ClientTransportOptions extends TransportOptionsBase { + /** + * An existing synchronous transport connected to an API session. + * Custom module resolution callbacks must obey the transport's reentrancy + * constraints. + */ + transport: SyncTransport; +} + +export type SyncClientOptions = ClientSocketOptions | ClientSpawnOptions | ClientTransportOptions; + +export function isTransportOptions(options: T): options is Extract { + return "transport" in options; +} + +export interface AsyncClientTransportOptions extends TransportOptionsBase { + /** + * An existing asynchronous transport connected to an API session. + * Custom module resolution callbacks must obey the transport's support + * for Promise results and reentrancy. + */ + transport: AsyncTransport; +} + +export type AsyncClientOptions = ClientOptions | AsyncClientTransportOptions; + export function resolveExePath(options: ClientSpawnOptions): string { - return options.tsserverPath ?? getExePath(); + return options.tsserverPath ?? getDefaultExePath(); } export function getAPIProcessArgs(options: ClientSpawnOptions, async: boolean): string[] { const args = ["--api"]; if (async) args.push("--async"); - args.push("--cwd", options.cwd ?? process.cwd()); + args.push("--cwd", options.cwd ?? nodeProcess().cwd()); args.push(`--useCaseSensitiveFileNames=${options.useCaseSensitiveFileNames ?? inferUseCaseSensitiveFileNames()}`); if (options.runExternalCode) args.push("--runExternalCode"); if (options.collectTiming) args.push("--timing"); @@ -61,15 +99,17 @@ export function getAPIProcessArgs(options: ClientSpawnOptions, async: boolean): } function inferUseCaseSensitiveFileNames(): boolean { + const process = nodeProcess(); if (process.platform === "win32") { return false; } - const fileName = fileURLToPath(import.meta.url); - return !existsSync(fileName.replace(/\w/g, char => char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase())); + const fileName = process.getBuiltinModule("node:url").fileURLToPath(import.meta.url); + return !process.getBuiltinModule("node:fs").existsSync(fileName.replace(/\w/g, char => char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase())); } 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 f08ef255288b9..59e6be31400ac 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -74,8 +74,8 @@ import { } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; import type { - APIOptions, LSPConnectionOptions, + SyncAPIOptions as APIOptions, } from "../options.ts"; import { canonicalize, diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index 045d8663e6691..701ced0115d6e 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -3,76 +3,65 @@ 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"; +import type { ServerTimingInfo } from "../timing.ts"; import { TimingDump } from "../timingDump.ts"; +import { TransportClient } from "./transportClient.ts"; -export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions }; +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { ClientTransportOptions, SyncClientOptions as ClientOptions } from "../options.ts"; +export type { SyncTransport } from "./transport.ts"; -export class Client { - private channel: SyncRpcChannel; - private encoder = new TextEncoder(); - private timing: TimingCollector | undefined; - private timingDump: TimingDump | undefined; +export class Client extends TransportClient { + private readonly channel: SyncRpcChannel | undefined; + private readonly timingDump: TimingDump | undefined; private closed = false; - private maxResponseBytesPerPage: number | undefined; - constructor(options: ClientOptions) { + 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"); } - this.timingDump = TimingDump.fromEnvironment("sync", "spawn"); - if (this.timingDump) { + const timingDump = TimingDump.fromEnvironment("sync", "spawn"); + if (timingDump) { options = { ...options, collectTiming: true }; } const args = getAPIProcessArgs(options, false); - this.maxResponseBytesPerPage = options.maxResponseBytesPerPage; - const fsConfiguration = configureFileSystemCallbacks(options.fs); if (fsConfiguration.arguments.length > 0) { args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); } const collectTiming = options.collectTiming ?? false; - if (collectTiming) { - this.timing = new TimingCollector(); - } - const channel = new SyncRpcChannel(resolveExePath(options), args, collectTiming); + super(channel, collectTiming, options.maxResponseBytesPerPage); this.channel = channel; + this.timingDump = timingDump; if (options.fs) { for (const name of fsConfiguration.callbackNames) { if (name === "writeFile") { const callback = options.fs.writeFile; if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); - channel.registerCallback(name, (_, arg) => { const { path, data } = JSON.parse(arg); return JSON.stringify(encodeFileSystemCallbackResult(name, callback(path, data))); }); - continue; } @@ -85,117 +74,13 @@ 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 { + override close(): void { if (this.closed) return; this.closed = true; try { - if (this.timingDump && this.timing) { - const local = this.timing.getInfo(); + const timing = this.getTimingCollector(); + if (this.timingDump && timing && this.channel) { + const local = timing.getInfo(); let server: ServerTimingInfo | undefined; let serverTimingError: string | undefined; try { @@ -208,7 +93,7 @@ export class Client { } } finally { - this.channel.close(); + super.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..b48eb470b643b --- /dev/null +++ b/packages/typescript/src/api/sync/transport.ts @@ -0,0 +1,22 @@ +import type { FileSystemCallbacks } from "../fs.ts"; + +/** + * 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; +} 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/api/syncChannel.ts b/packages/typescript/src/api/syncChannel.ts index 4076d907578b2..880c963a66f48 100644 --- a/packages/typescript/src/api/syncChannel.ts +++ b/packages/typescript/src/api/syncChannel.ts @@ -10,17 +10,7 @@ * ChildProcess. */ -import { - type ChildProcess, - spawn, -} from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { - closeSync, - openSync, - readSync, - writeSync, -} from "node:fs"; +import type { ChildProcess } from "node:child_process"; import type { Readable, Writable, @@ -50,6 +40,7 @@ import { MSGPACK_UINT8, writeBinHeader, } from "./node/msgpack.ts"; +import { nodeProcess } from "./nodeProcess.ts"; // ── MessageType constants ──────────────────────────────────────────── // Sent by channel (parent → child) @@ -61,34 +52,44 @@ const MSG_RESPONSE = 4; const MSG_ERROR = 5; const MSG_CALL = 6; -// Pre-allocated buffer used by Atomics.wait for tiny sleeps when a -// non-blocking fd returns EAGAIN. -const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); +// Module state is created on first use so that this module can be loaded +// outside Node.js. -// Shared empty buffer – avoids allocating Buffer.alloc(0) on every -// zero-length bin field. -const EMPTY_BUF = Buffer.alloc(0); +// Buffer used by Atomics.wait for tiny sleeps when a non-blocking fd +// returns EAGAIN. +let sleepBuf: Int32Array | undefined; + +function sleep(ms: number): void { + Atomics.wait(sleepBuf ??= new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} // ── Global cleanup tracking ───────────────────────────────────────── // Track all live child processes so they can be killed on process exit. // This mimics the auto-cleanup behavior of the native libsyncrpc module, // whose Rust/C++ destructors would kill children automatically. -const liveChildren = new Set(); +let liveChildren: Set | undefined; -process.on("exit", () => { - for (const child of liveChildren) { - try { - child.kill(); - } - catch { - // swallow – process may already be dead - } +function trackChild(process: NodeJS.Process, child: ChildProcess): void { + if (!liveChildren) { + const children = liveChildren = new Set(); + process.on("exit", () => { + for (const child of children) { + try { + child.kill(); + } + catch { + // swallow – process may already be dead + } + } + children.clear(); + }); } - liveChildren.clear(); -}); + liveChildren.add(child); +} export function createPipePath(): string { - return `\\\\.\\pipe\\tsgo-sync-${process.pid}-${randomBytes(16).toString("hex")}`; + const process = nodeProcess(); + return `\\\\.\\pipe\\tsgo-sync-${process.pid}-${process.getBuiltinModule("node:crypto").randomBytes(16).toString("hex")}`; } /** @@ -129,9 +130,15 @@ export class SyncRpcChannel { lastBytesSent = 0; lastBytesReceived = 0; + private readonly fs = nodeProcess().getBuiltinModule("node:fs"); + + // Shared empty buffer – avoids allocating Buffer.alloc(0) on every + // zero-length bin field. + private readonly emptyBuf = Buffer.alloc(0); + private _msgType = 0; - private _msgName: Buffer = EMPTY_BUF; - private _msgPayload: Buffer = EMPTY_BUF; + private _msgName: Buffer = this.emptyBuf; + private _msgPayload: Buffer = this.emptyBuf; private headerBuf = Buffer.allocUnsafe(4); @@ -145,6 +152,8 @@ export class SyncRpcChannel { constructor(exe: string, args: string[], collectTiming = false) { this.collectTiming = collectTiming; + const process = nodeProcess(); + const { spawn } = process.getBuiltinModule("node:child_process"); const isWindows = process.platform === "win32"; if (isWindows) { @@ -162,7 +171,7 @@ export class SyncRpcChannel { let fd: number | undefined; for (let i = 0; i < 500; i++) { try { - fd = openSync(pipePath, "r+"); + fd = this.fs.openSync(pipePath, "r+"); break; } catch { @@ -171,7 +180,7 @@ export class SyncRpcChannel { `Child process exited with code ${this.child.exitCode} before pipe was ready`, ); } - Atomics.wait(sleepBuf, 0, 0, 10); + sleep(10); } } if (fd === undefined) { @@ -220,7 +229,7 @@ export class SyncRpcChannel { } // Track for auto-cleanup on process exit. - liveChildren.add(this.child); + trackChild(process, this.child); this.child.unref(); } @@ -257,9 +266,9 @@ export class SyncRpcChannel { /** Kill the child process and release resources. */ close(): void { try { - liveChildren.delete(this.child); + liveChildren?.delete(this.child); if (this.pipeFd !== undefined) { - closeSync(this.pipeFd); + this.fs.closeSync(this.pipeFd); this.pipeFd = undefined; } // Destroy the stdio streams so that their pipe handles are closed @@ -509,7 +518,7 @@ export class SyncRpcChannel { `Expected binary data (0xc4-0xc6), received: 0x${marker.toString(16)}`, ); } - if (size === 0) return EMPTY_BUF; + if (size === 0) return this.emptyBuf; return this.readExact(size); } @@ -557,7 +566,7 @@ export class SyncRpcChannel { this.readBufLen = 0; for (;;) { try { - const n = readSync(this.readFd, this.readBuf, 0, this.readBuf.length, null); + const n = this.fs.readSync(this.readFd, this.readBuf, 0, this.readBuf.length, null); if (n === 0) { throw this.eofError(); } @@ -566,7 +575,7 @@ export class SyncRpcChannel { } catch (e: unknown) { if (e instanceof Error && ("code" in e) && ((e as NodeJS.ErrnoException).code === "EAGAIN" || (e as NodeJS.ErrnoException).code === "EWOULDBLOCK")) { - Atomics.wait(sleepBuf, 0, 0, 1); + sleep(1); continue; } throw e; @@ -595,7 +604,7 @@ export class SyncRpcChannel { // Remaining data is larger than read buffer; read directly // into the target to avoid unnecessary copying. try { - const n = readSync(this.readFd, buffer, pos, length - pos, null); + const n = this.fs.readSync(this.readFd, buffer, pos, length - pos, null); if (n === 0) { throw this.eofError(); } @@ -603,7 +612,7 @@ export class SyncRpcChannel { } catch (e: unknown) { if (e instanceof Error && ("code" in e) && ((e as NodeJS.ErrnoException).code === "EAGAIN" || (e as NodeJS.ErrnoException).code === "EWOULDBLOCK")) { - Atomics.wait(sleepBuf, 0, 0, 1); + sleep(1); continue; } throw e; @@ -625,12 +634,12 @@ export class SyncRpcChannel { let pos = 0; while (pos < total) { try { - const n = writeSync(this.writeFd, data, pos, total - pos); + const n = this.fs.writeSync(this.writeFd, data, pos, total - pos); pos += n; } catch (e: unknown) { if (e instanceof Error && ("code" in e) && ((e as NodeJS.ErrnoException).code === "EAGAIN" || (e as NodeJS.ErrnoException).code === "EWOULDBLOCK")) { - Atomics.wait(sleepBuf, 0, 0, 1); + sleep(1); continue; } throw e; diff --git a/packages/typescript/src/api/timingDump.ts b/packages/typescript/src/api/timingDump.ts index 6bb17ce13c413..597c4fa4d1795 100644 --- a/packages/typescript/src/api/timingDump.ts +++ b/packages/typescript/src/api/timingDump.ts @@ -1,12 +1,4 @@ -import { randomUUID } from "node:crypto"; -import { - mkdirSync, - writeFileSync, -} from "node:fs"; -import { - join, - resolve, -} from "node:path"; +import { nodeProcess } from "./nodeProcess.ts"; import { combineTimingInfo, type ServerTimingInfo, @@ -15,15 +7,16 @@ import { /** One final snapshot per client, written before its connection is closed. */ export class TimingDump { - private readonly instanceId = randomUUID(); + private readonly instanceId = nodeProcess().getBuiltinModule("node:crypto").randomUUID(); private readonly startedAt = new Date().toISOString(); private readonly directory: string; private readonly mode: "sync" | "async"; private readonly transport: "spawn" | "socket"; static fromEnvironment(mode: "sync" | "async", transport: "spawn" | "socket"): TimingDump | undefined { + const process = nodeProcess(); const directory = process.env.TS_API_TIMING_DIR; - return directory ? new TimingDump(resolve(directory), mode, transport) : undefined; + return directory ? new TimingDump(process.getBuiltinModule("node:path").resolve(directory), mode, transport) : undefined; } private constructor(directory: string, mode: "sync" | "async", transport: "spawn" | "socket") { @@ -33,6 +26,9 @@ export class TimingDump { } write(client: TimingInfo, server: ServerTimingInfo | undefined, serverTimingError?: string): void { + const process = nodeProcess(); + const { mkdirSync, writeFileSync } = process.getBuiltinModule("node:fs"); + const { join } = process.getBuiltinModule("node:path"); const snapshot = { schemaVersion: 1, instanceId: this.instanceId, diff --git a/packages/typescript/src/wasm/index.ts b/packages/typescript/src/wasm/index.ts new file mode 100644 index 0000000000000..6636a58156297 --- /dev/null +++ b/packages/typescript/src/wasm/index.ts @@ -0,0 +1,9 @@ +/** + * Runs the TypeScript API on the `wasip1/wasm` build of the compiler. + * + * @experimental This module and the WebAssembly build it loads may change + * without notice. + * @packageDocumentation + */ + +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..e4721bd1950f0 --- /dev/null +++ b/packages/typescript/src/wasm/transport.ts @@ -0,0 +1,427 @@ +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import type { FileSystemCallbacks } from "../api/fs.ts"; +import { + configureFileSystemCallbacks, + encodeFileSystemCallbackResult, +} from "../api/fsCallbacks.ts"; +import { + encodeWtf8, + Wtf8Decoder, +} from "../api/node/wtf8.ts"; + +export interface WasmReactorExports { + memory: { readonly buffer: ArrayBufferLike; }; + create_session(optionsLength: number): number; + start_session(callbacksLength: number): number; + close_session(): void; + get_request_buffer(size: number): number; + send_message(type: number, methodLength: number, payloadLength: number): number; + receive_message(): number; + set_file(pathLength: number, contentLength: number): number; + read_file(pathLength: number): number; + remove_file(pathLength: number): number; + response_ptr(): number; + response_len(): number; + response_method_len(): number; +} + +export interface WasmReactorInstance { + readonly exports: object; +} + +export interface WasmTransportOptions { + /** + * An initialized reactor, normally created by `instantiateWasm` or + * `instantiateWasmSync`. Other WASI hosts must call the module's + * `typescript_initialize` export instead of `_start`, which runs the + * command line compiler. + */ + instance: WasmReactorInstance; + cwd?: string | undefined; + useCaseSensitiveFileNames?: boolean | undefined; + collectTiming?: boolean | undefined; + /** + * Callbacks that replace operations on the reactor's in-memory file + * system, which `serverFS.useOS` refers to. + */ + fs?: FileSystemCallbacks | undefined; +} + +type TransportCallback = (name: string, payload: string) => string | PromiseLike; + +const MessageType = { + Request: 1, + CallResponse: 2, + CallError: 3, + Response: 4, + Error: 5, + Call: 6, +} as const; +type MessageType = typeof MessageType[keyof typeof MessageType]; + +const receiveClosed = 0; +const receiveFailed = 1; + +type Settlement = + | { ok: true; value: Uint8Array; } + | { ok: false; error: Error; }; + +interface RequestFrame { + kind: "request"; + method: string; + bytesSent: number; + settlement?: Settlement; + onSettle?: (settlement: Settlement) => void; +} + +interface CallFrame { + kind: "call"; + method: string; + reply?: { type: typeof MessageType.CallResponse | typeof MessageType.CallError; payload: Uint8Array; }; +} + +type Frame = RequestFrame | CallFrame; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); + +/** + * API transport backed by an in-process TypeScript WebAssembly reactor. + * + * The reactor runs the same server as a separate process would, so callbacks + * may return promises and may make nested requests through this transport. + * Requests made while callbacks are outstanding are nested within the + * innermost one, which must complete before outer callbacks can reply. + */ +export class WasmTransport { + lastBytesSent = 0; + lastBytesReceived = 0; + + private readonly exports: WasmReactorExports; + private readonly callbacks = new Map(); + private readonly fileSystemCallbacks = new Map(); + private fs: FileSystemCallbacks | undefined; + private readonly stack: Frame[] = []; + private started = false; + private closed = false; + + constructor(options: WasmTransportOptions) { + 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(sessionOptions.length) !== 0) { + throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); + } + try { + this.setFileSystem(options.fs); + } + catch (error) { + this.exports.close_session(); + throw error; + } + } + + /** + * Configures file system callbacks. They are validated immediately and + * read again when the first request starts the session. + */ + setFileSystem(fs: FileSystemCallbacks | undefined): void { + this.ensureOpen(); + if (this.started) { + throw new Error("The TypeScript WASM file system must be configured before the first request"); + } + configureFileSystemCallbacks(fs); + this.fs = fs; + } + + requestSync(method: string, payload: string): string { + return decoder.decode(this.requestBinarySync(method, encoder.encode(payload))); + } + + requestBinarySync(method: string, payload: Uint8Array): Uint8Array { + const frame = this.startRequest(method, payload); + const settlement = frame.settlement; + if (!settlement) { + throw new Error(`TypeScript WASM request "${method}" is waiting for an asynchronous callback`); + } + return this.unwrap(frame, settlement); + } + + request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } | Promise<{ value: string; bytesSent: number; bytesReceived: number; }> { + const response = this.requestBinary(method, encoder.encode(payload)); + const decode = (response: { value: Uint8Array; bytesSent: number; bytesReceived: number; }) => ({ ...response, value: decoder.decode(response.value) }); + return response instanceof Promise ? response.then(decode) : decode(response); + } + + requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } | Promise<{ value: Uint8Array; bytesSent: number; bytesReceived: number; }> { + const frame = this.startRequest(method, payload); + const toResponse = (settlement: Settlement) => { + const value = this.unwrap(frame, settlement); + return { value, bytesSent: frame.bytesSent, bytesReceived: value.length }; + }; + if (frame.settlement) { + return toResponse(frame.settlement); + } + return new Promise((resolve, reject) => { + frame.onSettle = settlement => { + try { + resolve(toResponse(settlement)); + } + catch (error) { + reject(error); + } + }; + }); + } + + registerCallback(name: string, callback: TransportCallback): void { + this.ensureOpen(); + this.callbacks.set(name, callback); + } + + unregisterCallback(name: string): void { + this.callbacks.delete(name); + } + + setFile(path: string, content: string): void { + this.ensureOpen(); + const pathBytes = encoder.encode(path); + const contentBytes = encodeWtf8(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.ensureOpen(); + 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 fileContentDecoder.decode(this.readResponseBytes()); + } + + removeFile(path: string): void { + this.ensureOpen(); + 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; + this.closed = true; + try { + this.exports.close_session(); + } + finally { + this.callbacks.clear(); + this.fileSystemCallbacks.clear(); + this.fs = undefined; + this.abort(new Error("The TypeScript WASM transport is closed")); + } + } + + private startRequest(method: string, payload: Uint8Array): RequestFrame { + this.ensureOpen(); + this.ensureStarted(); + const frame: RequestFrame = { kind: "request", method, bytesSent: payload.length }; + this.stack.push(frame); + this.send(MessageType.Request, method, payload); + this.pump(); + return frame; + } + + private unwrap(frame: RequestFrame, settlement: Settlement): Uint8Array { + if (!settlement.ok) throw settlement.error; + this.lastBytesSent = frame.bytesSent; + this.lastBytesReceived = settlement.value.length; + return settlement.value; + } + + /** + * Exchanges messages with the server until the innermost frame is a + * callback that has not yet produced its reply, or no frames remain. + */ + private pump(): void { + while (this.stack.length > 0) { + const top = this.stack[this.stack.length - 1]; + if (top.kind === "call") { + if (!top.reply) return; + this.stack.pop(); + this.send(top.reply.type, top.method, top.reply.payload); + continue; + } + + const type = this.exports.receive_message(); + switch (type) { + case MessageType.Response: + this.settle(top, { ok: true, value: this.readResponsePayload() }); + break; + case MessageType.Error: + this.settle(top, { ok: false, error: new Error(`TypeScript WASM request "${top.method}" failed: ${this.readResponsePayloadText()}`) }); + break; + case MessageType.Call: + this.invokeCallback(this.readResponseMethod(), this.readResponsePayloadText()); + break; + case receiveClosed: + this.abort(new Error("The TypeScript WASM server stopped")); + return; + case receiveFailed: + default: + this.abort(new Error(`TypeScript WASM transport failed: ${type === receiveFailed ? this.readResponseText() : `unexpected message type ${type}`}`)); + return; + } + } + } + + private settle(frame: RequestFrame, settlement: Settlement): void { + this.stack.splice(this.stack.indexOf(frame), 1); + frame.settlement = settlement; + frame.onSettle?.(settlement); + } + + private invokeCallback(method: string, payload: string): void { + const frame: CallFrame = { kind: "call", method }; + this.stack.push(frame); + const succeed = (result: string) => { + frame.reply = { type: MessageType.CallResponse, payload: encoder.encode(result) }; + }; + const fail = (error: unknown) => { + frame.reply = { type: MessageType.CallError, payload: encoder.encode(error instanceof Error ? error.message : String(error)) }; + }; + let result: string | PromiseLike; + try { + const callback = this.fileSystemCallbacks.get(method) ?? this.callbacks.get(method); + if (!callback) { + throw new Error(`No callback is registered for ${method}`); + } + result = callback(method, payload); + } + catch (error) { + fail(error); + return; + } + if (typeof result === "string") { + succeed(result); + return; + } + result.then(succeed, fail).then(() => { + if (this.stack.includes(frame)) this.pump(); + }); + } + + private abort(error: Error): void { + const frames = this.stack.splice(0); + for (const frame of frames) { + if (frame.kind === "request" && !frame.settlement) { + frame.settlement = { ok: false, error }; + frame.onSettle?.(frame.settlement); + } + } + } + + private ensureStarted(): void { + if (this.started) return; + const fs = this.fs; + const configuration = configureFileSystemCallbacks(fs); + for (const name of configuration.callbackNames) { + const callback = fs![name] as (...args: any[]) => unknown; + this.fileSystemCallbacks.set(name, (_, payload) => { + const arg = JSON.parse(payload); + const result = name === "writeFile" ? callback.call(fs, arg.path, arg.data) : callback.call(fs, arg); + return JSON.stringify(encodeFileSystemCallbackResult(name, result)); + }); + } + const callbacks = encoder.encode(JSON.stringify(configuration.arguments)); + this.writeRequest(callbacks); + if (this.exports.start_session(callbacks.length) !== 0) { + throw new Error(`Failed to start TypeScript WASM session: ${this.readResponseText()}`); + } + this.started = true; + } + + private send(type: MessageType, method: string, payload: Uint8Array): void { + const methodBytes = encoder.encode(method); + this.writeRequest(methodBytes, payload); + if (this.exports.send_message(type, methodBytes.length, payload.length) !== 0) { + this.abort(new Error(`TypeScript WASM transport failed: ${this.readResponseText()}`)); + } + } + + private writeRequest(first: Uint8Array, second?: Uint8Array): void { + const total = first.length + (second?.length ?? 0); + const requestPointer = this.exports.get_request_buffer(total) >>> 0; + const memory = new Uint8Array(this.exports.memory.buffer); + memory.set(first, requestPointer); + if (second) { + memory.set(second, requestPointer + first.length); + } + } + + private readResponseText(): string { + return decoder.decode(this.readResponseBytes()); + } + + private readResponseMethod(): string { + return decoder.decode(this.readResponseBytes().subarray(0, this.exports.response_method_len())); + } + + private readResponsePayloadText(): string { + return decoder.decode(this.readResponsePayload()); + } + + private readResponsePayload(): Uint8Array { + return this.readResponseBytes().subarray(this.exports.response_method_len()); + } + + 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"); + } + } +} + +function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { + const exports = instance.exports as unknown as Partial; + const required = [ + "create_session", + "start_session", + "close_session", + "get_request_buffer", + "send_message", + "receive_message", + "set_file", + "read_file", + "remove_file", + "response_ptr", + "response_len", + "response_method_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..24838fe703051 --- /dev/null +++ b/packages/typescript/src/wasm/wasi.ts @@ -0,0 +1,290 @@ +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 errnoNoSys = 52; +const fileTypeCharacterDevice = 2; +const eventTypeClock = 0; +const subscriptionClockAbstime = 1; + +export interface InstantiateWasmOptions { + stdout?: ((text: string) => void) | undefined; + stderr?: ((text: string) => void) | undefined; +} + +/** 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 closedDescriptors = new Set(); + + 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 !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; + const memory = getMemory(); + const view = new DataView(memory.buffer); + let decoder = decoders.get(fd); + if (!decoder) decoders.set(fd, decoder = new TextDecoder()); + let text = ""; + let length = 0; + for (let i = 0; i < iovsLength; i++) { + const iovPointer = iovsPointer + i * 8; + const chunkLength = view.getUint32(iovPointer + 4, true); + text += decoder.decode(new Uint8Array(memory.buffer, view.getUint32(iovPointer, true), chunkLength), { stream: true }); + length += chunkLength; + } + view.setUint32(writtenPointer, length, 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 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 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 spin here. + // The Go scheduler only polls when every goroutine is idle with a + // pending timer, which the reactor does not hit during API requests. + } + + 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(); + return instance; + }, + }; +} diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 379f07b6b68f7..c925852d2aecd 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -61,7 +61,6 @@ import { } from "@typescript/typescript/ast/factory"; import { visitEachChild } from "@typescript/typescript/ast/visitor"; import { - API, type BigIntLiteralType, CheckFlags, type CompilerOptions, @@ -146,6 +145,7 @@ import { createVirtualFileSystem, } from "../testUtils.ts"; import { + API, defaultFiles, spawnAPI, } from "./api.testUtils.ts"; @@ -396,7 +396,7 @@ describe("API", { concurrency }, () => { await using api = spawnAPI(); const config = await api.parseJsonConfigFileContent(null, { configDirectory: "/src" }); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.errors, []); }); @@ -1443,7 +1443,7 @@ declare module "augmentation" {}`, await using api = spawnAPI(); const config = await api.parseConfigFile("/tsconfig.json"); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.options, { configFilePath: "/tsconfig.json" }); assert.equal(config.compileOnSave, false); assert.equal(config.typeAcquisition, undefined); @@ -2109,7 +2109,7 @@ describe("Snapshot", { concurrency }, () => { const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); const project = snapshot.getConfiguredProject("/tsconfig.json")!; - assert.deepEqual(project.parsedCommandLine.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...project.parsedCommandLine.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(project.parsedCommandLine.options, { configFilePath: "/tsconfig.json" }); assert.equal(project.parsedCommandLine.compileOnSave, true); }); @@ -8075,6 +8075,21 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "(name: string) => string"); }); + test("typeToTypeNode returns undefined when the type cannot be named", async () => { + await using api = spawnAPI({ + "/tsconfig.json": "{}", + "/main.ts": "export const x = new (class { a = 1; })();\n", + }); + + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const { checker } = project; + const symbol = await checker.getSymbolAtPosition("/main.ts", 13); + assert.strictEqual(symbol?.name, "x"); + const type = await checker.getTypeOfSymbol(symbol); + assert.strictEqual(await checker.typeToTypeNode(type, await project.program.getSourceFile("/main.ts")), undefined); + }); + test("visitEachChild on typeToTypeNode result with keyword types", async () => { await using api = spawnAPI(emitterFiles); diff --git a/packages/typescript/test/async/api.testUtils.ts b/packages/typescript/test/async/api.testUtils.ts index 04e45ff6489e8..9eb05df5d103d 100644 --- a/packages/typescript/test/async/api.testUtils.ts +++ b/packages/typescript/test/async/api.testUtils.ts @@ -1,9 +1,13 @@ import { - API, + API as NativeAPI, type APIOptions, } from "@typescript/typescript/async"; import { fileURLToPath } from "node:url"; import { createVirtualFileSystem } from "../testUtils.ts"; +import { withWasm } from "../wasm.ts"; + +export const API = process.env.TS_API_TEST_WASM ? withWasm(NativeAPI) : NativeAPI; +export type API = NativeAPI; export const defaultFiles = { "/tsconfig.json": "{}", diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts new file mode 100644 index 0000000000000..1a4e8980621b6 --- /dev/null +++ b/packages/typescript/test/async/transport.test.ts @@ -0,0 +1,285 @@ +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/async"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { TransportClient } from "../../src/api/async/transportClient.ts"; +import type { + BatchRequestsParams, + BatchRequestsResponse, +} 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 closes = 0; + 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() { + closes++; + }, + }, + }); + + 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 Promise.all([api.close(), api.close()]); + await api.close(); + assert.strictEqual(closes, 1); + }); + + test("rejects queued requests when closed", async () => { + const emptyCommandLine: ParsedCommandLine = { options: {}, fileNames: [], errors: [] }; + const respond = (value: unknown) => ({ value: JSON.stringify(value), bytesSent: 0, bytesReceived: 0 }); + // Requests held in a manual batch, both before and after initialization. + for (const initializeFirst of [false, true]) { + const api = new API({ + transport: { + request(method) { + switch (method) { + case "initialize": + return respond({ currentDirectory: "/", useCaseSensitiveFileNames: true }); + case "parseCommandLine": + return respond(emptyCommandLine); + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + if (initializeFirst) { + await api.parseCommandLine([]); + } + const batch = api.batchContext(); + const rejected = assert.rejects(api.parseCommandLine([]), /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + } + + // Text and binary requests closed before their microtask sends them. + 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: 1, + file: "/index.ts", + }); + await client.close(); + await assert.rejects(text, /Client is closed/); + await assert.rejects(binary, /Client is closed/); + }); + + test("can close a process client while initialization is manually batched", async () => { + const api = new API({ cwd: process.cwd() }); + // @ts-ignore -- Internal client transport is exercised by source tests. + 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); + + 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 and isolated 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) { + methods.push(method); + throw new Error("could not read file"); + }, + close() {}, + }, false); + + const batch = client.batchContext(); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: 1, + file: "/index.ts", + }); + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + assert.deepStrictEqual(methods, []); + batch[Symbol.dispose](); + await assert.rejects(binary, /could not read file/); + assert.deepStrictEqual(await text, { options: {}, fileNames: [], errors: [] }); + assert.deepStrictEqual(methods, ["getSourceFile", "parseCommandLine"]); + await client.close(); + }); + + test("rejects in-flight requests when closed", async () => { + const neverResolves = { + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }; + + const client = new TransportClient(neverResolves, false); + const request = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + await client.close(); + await assert.rejects(request, /Client is closed/); + + // The API's own initialize request is in flight here. + const api = new API({ transport: neverResolves }); + const rejected = assert.rejects(api.parseCommandLine([]), /Client is closed/); + await Promise.resolve(); + await api.close(); + await rejected; + }); + + test("rejects timing requests when closed", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, true); + + 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/); + assert.deepStrictEqual(methods, []); + }); +}); diff --git a/packages/typescript/test/browser/smoke.test.ts b/packages/typescript/test/browser/smoke.test.ts new file mode 100644 index 0000000000000..9a4a74fb8fc25 --- /dev/null +++ b/packages/typescript/test/browser/smoke.test.ts @@ -0,0 +1,170 @@ +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"; + +/** Returns a module whose `run` export exercises the API entries at the given specifiers. */ +function testModule(entries: { async: string; sync: string; wasm: string; fs: string; }): string { + return ` +import { API as AsyncAPI } from "${entries.async}"; +import { API as SyncAPI } from "${entries.sync}"; +import { instantiateWasm, WasmTransport } from "${entries.wasm}"; +import { createFileSystemWithLib } from "${entries.fs}"; + +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), + Object.keys(createFileSystemWithLib([]).symlinks).length, + ]; +} +`; +} + +describe("API over WebAssembly in a browser", () => { + let browser: Browser; + let origin: string; + const server = createServer(); + + before(async () => { + const result = await build({ + stdin: { + contents: testModule({ + async: "typescript/async", + sync: "typescript/sync", + wasm: "typescript/wasm", + fs: "typescript/fs", + }), + loader: "ts", + resolveDir: process.cwd(), + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source"], + 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:/); + const bundle = result.outputFiles[0].contents; + const wasm = await readFile(wasmURL); + + const unbundled = testModule({ + async: "/dist/api/async/api.js", + sync: "/dist/api/sync/api.js", + wasm: "/dist/wasm/index.js", + fs: "/dist/api/fs.js", + }); + + // Serve the package root so the built entries' relative imports resolve as published. + const root = new URL("../../", import.meta.url); + server.on("request", async (request, response) => { + switch (request.url) { + case "/": + response.setHeader("Content-Type", "text/html"); + response.end(''); + break; + case "/unbundled": + response.setHeader("Content-Type", "text/html"); + response.end(''); + break; + case "/bundle.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(bundle); + break; + case "/unbundled.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(unbundled); + break; + case "/tsc.wasm": + response.setHeader("Content-Type", "application/wasm"); + response.end(wasm); + break; + default: { + const file = new URL(`.${request.url}`, root); + const contents = file.href.startsWith(root.href) && file.pathname.endsWith(".js") + ? await readFile(file).catch(() => undefined) + : undefined; + if (contents === undefined) { + response.statusCode = 404; + response.end(); + break; + } + response.setHeader("Content-Type", "text/javascript"); + response.end(contents); + 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", 1]); + }); + + test("runs the built async and sync API entries without bundling", async () => { + const page = await browser.newPage(); + await page.goto(`${origin}/unbundled`); + const result = await page.evaluate(() => Reflect.get(globalThis, "result")); + assert.deepStrictEqual(result, ["42", "42", 1]); + }); +}); diff --git a/packages/typescript/test/encoder.test.ts b/packages/typescript/test/encoder.test.ts index a3d09ad64ea22..2d95145cda8eb 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,10 @@ function decode(data: Uint8Array): RemoteSourceFile { } describe("Encoder", { concurrency }, () => { + test("encodes base64 from a subarray", () => { + assert.strictEqual(uint8ArrayToBase64(new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4)), "AQID"); + }); + test("encodes empty source file", () => { const sf = makeSF("", "/test.ts", []); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index f048e8ede87e6..51ae9043f1974 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -82,7 +82,6 @@ import { toRootedFilePath, } from "@typescript/typescript/path"; import { - API, type BigIntLiteralType, CheckFlags, type CompilerOptions, @@ -154,6 +153,7 @@ import { createVirtualFileSystem, } from "../testUtils.ts"; import { + API, defaultFiles, spawnAPI, } from "./api.testUtils.ts"; @@ -385,7 +385,7 @@ describe("API", { concurrency }, () => { using api = spawnAPI(); const config = api.parseJsonConfigFileContent(null, { configDirectory: "/src" }); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.errors, []); }); @@ -1430,7 +1430,7 @@ declare module "augmentation" {}`, using api = spawnAPI(); const config = api.parseConfigFile("/tsconfig.json"); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.options, { configFilePath: "/tsconfig.json" }); assert.equal(config.compileOnSave, false); assert.equal(config.typeAcquisition, undefined); @@ -1975,7 +1975,7 @@ describe("Snapshot", { concurrency }, () => { const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); const project = snapshot.getConfiguredProject("/tsconfig.json")!; - assert.deepEqual(project.parsedCommandLine.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...project.parsedCommandLine.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(project.parsedCommandLine.options, { configFilePath: "/tsconfig.json" }); assert.equal(project.parsedCommandLine.compileOnSave, true); }); @@ -7876,6 +7876,21 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "(name: string) => string"); }); + test("typeToTypeNode returns undefined when the type cannot be named", () => { + using api = spawnAPI({ + "/tsconfig.json": "{}", + "/main.ts": "export const x = new (class { a = 1; })();\n", + }); + + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const { checker } = project; + const symbol = checker.getSymbolAtPosition("/main.ts", 13); + assert.strictEqual(symbol?.name, "x"); + const type = checker.getTypeOfSymbol(symbol); + assert.strictEqual(checker.typeToTypeNode(type, project.program.getSourceFile("/main.ts")), undefined); + }); + test("visitEachChild on typeToTypeNode result with keyword types", () => { using api = spawnAPI(emitterFiles); diff --git a/packages/typescript/test/sync/api.testUtils.ts b/packages/typescript/test/sync/api.testUtils.ts index c9d282d4a261e..36fccb5df0429 100644 --- a/packages/typescript/test/sync/api.testUtils.ts +++ b/packages/typescript/test/sync/api.testUtils.ts @@ -1,9 +1,13 @@ import { - API, + API as NativeAPI, type APIOptions, } from "@typescript/typescript/sync"; import { fileURLToPath } from "node:url"; import { createVirtualFileSystem } from "../testUtils.ts"; +import { withWasm } from "../wasm.ts"; + +export const API = process.env.TS_API_TEST_WASM ? withWasm(NativeAPI) : NativeAPI; +export type API = NativeAPI; export const defaultFiles = { "/tsconfig.json": "{}", diff --git a/packages/typescript/test/sync/ast.bench.ts b/packages/typescript/test/sync/ast.bench.ts index 5ea08b311cf16..74b2e2567734d 100644 --- a/packages/typescript/test/sync/ast.bench.ts +++ b/packages/typescript/test/sync/ast.bench.ts @@ -20,12 +20,12 @@ import { createIdentifier, createIfStatement, } from "@typescript/typescript/ast/factory"; -import { API } from "@typescript/typescript/sync"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { Bench } from "tinybench"; import { createVirtualFileSystem } from "../testUtils.ts"; +import { API } from "./api.testUtils.ts"; const treeDepth = 5; const expressionsPerBlock = 6; diff --git a/packages/typescript/test/sync/ast.test.ts b/packages/typescript/test/sync/ast.test.ts index 1c0cab8becb8c..eff8e6eb3b000 100644 --- a/packages/typescript/test/sync/ast.test.ts +++ b/packages/typescript/test/sync/ast.test.ts @@ -67,7 +67,6 @@ import { visitNodes, } from "@typescript/typescript/ast/visitor"; import { - API, Checker, isRemoteNode, TypeFlags, @@ -82,6 +81,7 @@ import { areTestsFiltered, createVirtualFileSystem, } from "../testUtils.ts"; +import { API } from "./api.testUtils.ts"; import { runBenchmarks } from "./ast.bench.ts"; const concurrency = areTestsFiltered(); diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts new file mode 100644 index 0000000000000..8b5aa482a4130 --- /dev/null +++ b/packages/typescript/test/sync/transport.test.ts @@ -0,0 +1,86 @@ +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/sync"; +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); + }); +}); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts new file mode 100644 index 0000000000000..1fa8eb19b6663 --- /dev/null +++ b/packages/typescript/test/sync/wasm.test.ts @@ -0,0 +1,346 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import { API as SyncAPI } from "@typescript/typescript/sync"; +import { + instantiateWasm, + instantiateWasmSync, + WasmTransport, +} from "@typescript/typescript/wasm"; +import binaryen from "binaryen"; +import assert from "node:assert"; +import { spawn } from "node:child_process"; +import { + mkdir, + mkdtemp, + open, + readFile, + rm, + writeFile, +} 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"; +import { serverFS } from "../../src/api/fs.ts"; + +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("keeps paths resolved below the working directory in the compiler namespace", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); + try { + const root = path.join(directory, "root"); + // Windows cannot create a directory named like a drive. + const projects = ["^/~ts-uri~/memfs/host/project", ...process.platform === "win32" ? [] : ["c:/project"]]; + for (const project of projects) { + const projectDir = path.join(root, project); + await mkdir(path.join(projectDir, "node_modules", "pkg"), { recursive: true }); + // Node's WASI cannot list directories on Windows. + await writeFile(path.join(projectDir, "tsconfig.json"), `{ "compilerOptions": { "noEmit": true, "noLib": true, "types": [] }, "files": ["index.ts"] }`); + await writeFile(path.join(projectDir, "index.ts"), `import { x } from "pkg";`); + await writeFile(path.join(projectDir, "node_modules", "pkg", "package.json"), `{ "name": "pkg", "types": "index.d.ts" }`); + await writeFile(path.join(projectDir, "node_modules", "pkg", "index.d.ts"), `export declare const x: number;`); + } + const module = await WebAssembly.compile(await readFile(wasmURL)); + // The same directories named by absolute paths stay POSIX paths. + for (const project of [...projects, ...projects.map(project => `/${project}`)]) { + const stdoutPath = path.join(directory, "stdout"); + const stdout = await open(stdoutPath, "w+"); + let exitCode: number | undefined; + try { + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "-p", project, "--listFilesOnly"], + env: { PWD: "/" }, + preopens: { "/": root }, + stdout: stdout.fd, + returnOnExit: true, + }); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + exitCode = wasi.start(instance); + } + finally { + await stdout.close(); + } + const output = await readFile(stdoutPath, "utf8"); + assert.strictEqual(exitCode, 0, `tsc -p ${project} failed:\n${output}`); + assert.deepStrictEqual( + output.trim().split("\n").sort(), + [`${project}/index.ts`, `${project}/node_modules/pkg/index.d.ts`], + ); + } + } + finally { + await rm(directory, { recursive: true }); + } + }); + + test("waits for LSP stdin without repeatedly reading", { timeout: 10_000 }, async t => { + 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 > 50) 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"], + }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }); + 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 && !t.signal.aborted) { + 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 t => { + 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 > 50) 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"], + }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }); + 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("enforces reactor transport invariants", 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"], + ); + // Hosts call _initialize automatically, which would break the command entry point. + assert.ok(!WebAssembly.Module.exports(module).some((value: { name: string; }) => value.name === "_initialize")); + const instance = await instantiateWasm(module, { stdout: undefined, stderr: undefined }); + + const emittedFiles = new Map(); + const transport: WasmTransport = new WasmTransport({ + instance, + cwd: "/", + fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.useOS, + stat: serverFS.useOS, + removeFile: serverFS.useOS, + writeFile(path, data) { + // Callbacks may make nested requests, as with a separate server process. + assert.strictEqual(transport.requestSync("echo", "nested"), "nested"); + emittedFiles.set(path, data); + }, + }, + }); + const api = new SyncAPI({ transport }); + assert.strictEqual(transport.requestSync("echo", "text"), "text"); + assert.deepStrictEqual( + transport.requestBinarySync("echo", new Uint8Array([1, 2, 3])), + new Uint8Array([1, 2, 3]), + ); + + const rawContent = '\uFEFFconst lone = "\uD800";'; + transport.setFile("/raw.ts", rawContent); + assert.strictEqual(transport.readFile("/raw.ts"), rawContent); + transport.removeFile("/raw.ts"); + + transport.setFile("/tsconfig.json", `{ "compilerOptions": { "emitBOM": true } }`); + 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); + assert.throws( + () => new WasmTransport({ instance }), + /session already created/, + ); + assert.deepStrictEqual(project.program.emit(), { + diagnostics: [], + emitSkipped: false, + emittedFiles: ["/src/index.js"], + fileSystem: undefined, + }); + assert.strictEqual(emittedFiles.get("/src/index.js"), "\uFEFFexport const value = 42;\n"); + assert.strictEqual(transport.requestSync("echo", "after callback"), "after callback"); + + assert.throws( + () => transport.setFileSystem(undefined), + /must be configured before the first request/, + ); + + api.close(); + + const secondTransport = new WasmTransport({ instance, cwd: "/" }); + const second = new SyncAPI({ transport: secondTransport }); + assert.strictEqual(secondTransport.requestSync("echo", "recreated"), "recreated"); + second.close(); + }); +}); diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index e7f9d5a2955a7..0b650ca81a8a3 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -1,12 +1,15 @@ import { versionMajorMinor } from "@typescript/typescript"; import assert from "node:assert"; import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import fs, { readFileSync } from "node:fs"; +import module from "node:module"; +import path from "node:path"; import { test } from "node:test"; +import url from "node:url"; import getExePath from "../lib/getExePath.js"; test("versionMajorMinor runtime and declaration match the compiler version", () => { - const compilerVersion = execFileSync(getExePath(), ["--version"], { encoding: "utf8" }) + const compilerVersion = execFileSync(getExePath({ fs, module, path, url }), ["--version"], { encoding: "utf8" }) .trim() .replace(/^Version /, ""); const majorMinor = compilerVersion.split(".", 2).join("."); diff --git a/packages/typescript/test/wasm.ts b/packages/typescript/test/wasm.ts new file mode 100644 index 0000000000000..291ad8f1da319 --- /dev/null +++ b/packages/typescript/test/wasm.ts @@ -0,0 +1,158 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import type { API as AsyncAPI } from "@typescript/typescript/async"; +import { + type FileSystemCallbacks, + serverFS, +} from "@typescript/typescript/fs"; +import type { API as SyncAPI } from "@typescript/typescript/sync"; +import { + instantiateWasmSync, + type WasmReactorInstance, + WasmTransport, +} from "@typescript/typescript/wasm"; +import { + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; + +declare const WebAssembly: { + Module: new (bytes: Uint8Array) => WebAssembly.Module; +}; + +interface WasmAPIOptions { + cwd?: string | undefined; + fs?: FileSystemCallbacks | undefined; + useCaseSensitiveFileNames?: boolean | undefined; + collectTiming?: boolean | undefined; + maxResponseBytesPerPage?: number | undefined; + transport?: object | undefined; +} + +const availableInstances: WasmReactorInstance[] = []; +let module: WebAssembly.Module | undefined; + +class TestWasmTransport extends WasmTransport { + private returned = false; + private readonly reactorInstance: WasmReactorInstance; + + constructor( + reactorInstance: WasmReactorInstance, + options: ConstructorParameters[0], + ) { + super(options); + this.reactorInstance = reactorInstance; + } + + override close(): void { + if (this.returned) return; + super.close(); + this.returned = true; + availableInstances.push(this.reactorInstance); + } +} + +/** The disk a native server process would use for `serverFS.useOS`. */ +const diskFileSystem: { [K in keyof FileSystemCallbacks]: Extract; } = { + directoryExists: path => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false, + fileExists: path => statSync(path, { throwIfNoEntry: false })?.isFile() ?? false, + getAccessibleEntries: path => { + const files: string[] = []; + const directories: string[] = []; + const symlinks: string[] = []; + try { + for (const entry of readdirSync(path, { withFileTypes: true })) { + let stat: { isFile(): boolean; isDirectory(): boolean; } | undefined = entry; + if (entry.isSymbolicLink()) { + symlinks.push(entry.name); + stat = statSync(`${path}/${entry.name}`, { throwIfNoEntry: false }); + } + if (stat?.isFile()) files.push(entry.name); + else if (stat?.isDirectory()) directories.push(entry.name); + } + } + catch { + // Unreadable directories have no accessible entries. + } + return { files, directories, symlinks }; + }, + readFile: path => { + try { + return readFileSync(path, "utf8"); + } + catch { + return undefined; + } + }, + realpath: path => { + try { + return realpathSync.native(path); + } + catch { + return path; + } + }, + stat: path => statSync(path, { throwIfNoEntry: false }), + writeFile: (path, content) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + }, + removeFile: path => rmSync(path, { recursive: true, force: true }), +}; + +/** + * Sends operations that a native server would perform on its own disk to + * the disk, since the reactor's base file system is in memory. Members are + * read on access so that the client sees the caller's current configuration. + */ +function withDiskFallback(fs: FileSystemCallbacks | undefined): FileSystemCallbacks { + const result = {} as FileSystemCallbacks; + for (const name of Object.keys(diskFileSystem) as (keyof FileSystemCallbacks)[]) { + const diskOperation = diskFileSystem[name] as (...args: unknown[]) => unknown; + // The reactor's own base file system serves the embedded libraries. + const disk = (...args: unknown[]) => (args[0] as string).startsWith("bundled:///") ? serverFS.useOS : diskOperation(...args); + Object.defineProperty(result, name, { + enumerable: true, + get() { + const value = fs ? fs[name] : serverFS.useOS; + if (value === serverFS.useOS) return disk; + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + const result = (value as (...args: unknown[]) => unknown).apply(fs, args); + return result === serverFS.useOS ? disk(...args) : result; + }; + }, + }); + } + return result; +} + +function createWasmAPIOptions(options: WasmAPIOptions): WasmAPIOptions { + if (options.transport) return options; + const instance = availableInstances.pop() ?? instantiateWasmSync(module ??= new WebAssembly.Module(readFileSync(wasmURL))); + const transport = new TestWasmTransport(instance, { + instance, + cwd: options.cwd ?? "/", + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, + collectTiming: options.collectTiming, + }); + return { + transport, + fs: withDiskFallback(options.fs), + collectTiming: options.collectTiming, + maxResponseBytesPerPage: options.maxResponseBytesPerPage, + }; +} + +export function withWasm(API: T): T { + return new Proxy(API, { + construct(target, args) { + return Reflect.construct(target, [createWasmAPIOptions(args[0] ?? {})]); + }, + }); +} diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index ead9099414b94..f94f9fc026838 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -333,7 +333,35 @@ }, { "command": "typescript.native-preview.selectVersion", - "when": "typescript.native-preview.serverRunning" + "when": "typescript.native-preview.serverRunning && !isWeb" + }, + { + "command": "typescript.native-preview.reportIssue", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.runGC", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.saveHeapProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.saveAllocProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.startCPUProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.stopCPUProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.initializeAPIConnection.ui", + "when": "!isWeb" }, { "command": "typescript.native-preview.goToSourceDefinition", @@ -370,13 +398,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": "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 bundle:web -- --minify" }, "dependencies": { "@vscode/extension-telemetry": "^1.5.2", @@ -388,6 +419,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/commands.ts b/packages/vscode-typescript/src/commands.ts index de015f116bd64..19272cef0b508 100644 --- a/packages/vscode-typescript/src/commands.ts +++ b/packages/vscode-typescript/src/commands.ts @@ -6,21 +6,21 @@ import type { Position, } from "vscode-languageclient"; -import type * as tr from "./telemetryReporting"; import { getExplicitConfigTarget, restartExtHostOnChangeIfNeeded, -} from "./util"; +} from "./settings"; +import type * as tr from "./telemetryReporting"; -export function registerEnablementCommands(context: vscode.ExtensionContext, telemetryReporter: tr.TelemetryReporter, stopSession: () => Promise): void { +export function registerEnablementCommands(context: vscode.ExtensionContext, telemetryReporter: tr.TelemetryReporter | undefined, stopSession: () => Promise): void { context.subscriptions.push(vscode.commands.registerCommand("typescript.native-preview.enable", () => { // Fire and forget, because this will restart the extension host and cause an error if we await - telemetryReporter.sendTelemetryEvent("command.enableNativePreview"); + telemetryReporter?.sendTelemetryEvent("command.enableNativePreview"); updateUseTsgoSetting(true); })); context.subscriptions.push(vscode.commands.registerCommand("typescript.native-preview.disable", async () => { - telemetryReporter.sendTelemetryEvent("command.disableNativePreview"); + telemetryReporter?.sendTelemetryEvent("command.disableNativePreview"); await stopSession(); await updateUseTsgoSetting(false); })); @@ -70,8 +70,27 @@ export async function updateWorkspaceUseTsgoSetting(enable: boolean): Promise { + outputChannel.show(); + }), + vscode.commands.registerCommand("typescript.native-preview.sortImports", () => { + return vscode.commands.executeCommand("editor.action.sourceAction", { + kind: "source.sortImports", + apply: "first", + }); + }), + vscode.commands.registerCommand("typescript.native-preview.removeUnusedImports", () => { + return vscode.commands.executeCommand("editor.action.sourceAction", { + kind: "source.removeUnusedImports", + apply: "first", + }); + }), + ); function showCodeLensLocations(...args: unknown[]): void { if (args.length !== 3) { diff --git a/packages/vscode-typescript/src/configurationMiddleware.ts b/packages/vscode-typescript/src/configurationMiddleware.ts index 643d300be04ce..7d73b145c645b 100644 --- a/packages/vscode-typescript/src/configurationMiddleware.ts +++ b/packages/vscode-typescript/src/configurationMiddleware.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import type { ConfigurationMiddleware } from "vscode-languageclient/node"; +import type { ConfigurationMiddleware } from "vscode-languageclient"; import type { MessageSignature } from "vscode-languageserver-protocol"; /** diff --git a/packages/vscode-typescript/src/extension.ts b/packages/vscode-typescript/src/extension.ts index 7edd489ec2bfc..1608d6a3aa30e 100644 --- a/packages/vscode-typescript/src/extension.ts +++ b/packages/vscode-typescript/src/extension.ts @@ -7,12 +7,13 @@ import { updateUseTsgoSetting, } from "./commands"; import { - aiConnectionString, getExplicitConfigTarget, getUseTsgo, getWinningTsgoConfigKey, needsExtHostRestartOnChange, -} from "./util"; + shouldStartTsgo, +} from "./settings"; +import { aiConnectionString } from "./util"; import { TelemetryReporter as VSCodeTelemetryReporter } from "@vscode/extension-telemetry"; import { @@ -88,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { - const useTsgo = getUseTsgo(); - if (context.extensionMode === vscode.ExtensionMode.Development && !vscode.extensions.getExtension("vscode.typescript-language-features")) { - return useTsgo !== false; - } - return useTsgo === true; - } - await startNativeServer(); return api; diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts new file mode 100644 index 0000000000000..d9fb84b7bd759 --- /dev/null +++ b/packages/vscode-typescript/src/extension.web.ts @@ -0,0 +1,442 @@ +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 { + codeLensShowLocationsCommandName, + registerEnablementCommands, + registerServerCommands, + updateUseTsgoSetting, +} from "./commands"; +import { + configurationMiddleware, + sendNotificationMiddleware, +} from "./configurationMiddleware"; +import { nightlyExtensionId } from "./extensionIds"; +import { registerSourceDefinitionFeature } from "./languageFeatures/sourceDefinition"; +import { + getUseTsgo, + needsExtHostRestartOnChange, + shouldStartTsgo, +} from "./settings"; +import { + outermostMountPoints, + uriSegmentToWasmName, + uriToWasmMountPoint, + wasmPathToUriText, +} from "./webMountPoint"; + +declare global { + interface DedicatedWorkerGlobalScope { + readonly name: string; + } +} + +const wasmPackagePath = ["node_modules", "@typescript", "typescript-wasip1-wasm", "lib"] as const; + +const wasmFileSystemScheme = "ts-wasi"; + +/** + * Serves virtual workspace files under the names the server derives from + * their document URIs, since WASI joins those names onto the folder URI + * literally and would otherwise miss files whose URIs contain escapes. + */ +class WasmFileSystemProvider implements vscode.FileSystemProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeFile = this.changeEmitter.event; + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + stat(uri: vscode.Uri): Thenable { + return vscode.workspace.fs.stat(toWorkspaceUri(uri)); + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const directory = toWorkspaceUri(uri); + const entries = await vscode.workspace.fs.readDirectory(directory); + return entries.map(([name, type]) => { + const text = vscode.Uri.joinPath(directory, name).toString(); + const segment = text.slice(text.lastIndexOf("/") + 1); + return [uriSegmentToWasmName(segment, (type & vscode.FileType.Directory) === 0), type]; + }); + } + + createDirectory(uri: vscode.Uri): Thenable { + return vscode.workspace.fs.createDirectory(toWorkspaceUri(uri)); + } + + readFile(uri: vscode.Uri): Thenable { + return vscode.workspace.fs.readFile(toWorkspaceUri(uri)); + } + + writeFile(uri: vscode.Uri, content: Uint8Array): Thenable { + return vscode.workspace.fs.writeFile(toWorkspaceUri(uri), content); + } + + delete(uri: vscode.Uri, options: { readonly recursive: boolean; }): Thenable { + return vscode.workspace.fs.delete(toWorkspaceUri(uri), options); + } + + rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { readonly overwrite: boolean; }): Thenable { + return vscode.workspace.fs.rename(toWorkspaceUri(oldUri), toWorkspaceUri(newUri), options); + } +} + +function toWorkspaceUri(uri: vscode.Uri): vscode.Uri { + const text = wasmPathToUriText(uri.path); + if (text === undefined) throw vscode.FileSystemError.FileNotFound(uri); + return vscode.Uri.parse(text, true); +} + +class WasmMessageReader extends AbstractMessageReader { + private readonly decoder = new TextDecoder(); + private buffer = new Uint8Array(4096); + private length = 0; + 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 { + if (this.length + chunk.length > this.buffer.length) { + const next = new Uint8Array(Math.max(this.buffer.length * 2, this.length + chunk.length)); + next.set(this.buffer.subarray(0, this.length)); + this.buffer = next; + } + this.buffer.set(chunk, this.length); + this.length += chunk.length; + + let offset = 0; + while (true) { + const pending = this.buffer.subarray(offset, this.length); + const headerEnd = findHeaderEnd(pending); + if (headerEnd < 0) break; + const header = this.decoder.decode(pending.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}`)); + break; + } + const contentLength = Number(match[1]); + const bodyStart = headerEnd + 4; + if (pending.length < bodyStart + contentLength) break; + const body = this.decoder.decode(pending.subarray(bodyStart, bodyStart + contentLength)); + offset += bodyStart + contentLength; + try { + this.callback?.(JSON.parse(body)); + } + catch (error) { + this.fireError(error); + } + } + if (offset > 0) { + this.buffer.copyWithin(0, offset, this.length); + this.length -= offset; + } + } +} + +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 { + await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", false); + const output = vscode.window.createOutputChannel("TypeScript 7", { log: true }); + context.subscriptions.push(output); + + const nightlyExtension = vscode.extensions.getExtension(nightlyExtensionId); + const moduleExtensionUri = nightlyExtension && await hasWasmPayload(nightlyExtension.extensionUri) + ? nightlyExtension.extensionUri + : context.extensionUri; + const modulePackageUri = vscode.Uri.joinPath(moduleExtensionUri, ...wasmPackagePath); + 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(modulePackageUri, fileName)); + return new TextDecoder().decode(contents); + }, + })); + context.subscriptions.push(vscode.workspace.registerFileSystemProvider(wasmFileSystemScheme, new WasmFileSystemProvider(), { isCaseSensitive: true })); + + let process: WasmProcess | undefined; + let disposed = false; + // Bumped whenever the current client is replaced, so starts by older clients give up. + let generation = 0; + const stderrDecoder = new TextDecoder(); + const serverOptions = async (owner: number): Promise => { + const isStale = () => disposed || owner !== generation; + const wasm = await Wasm.load(); + if (!supportsNonBlockingStdin(wasm.versions.extension)) { + throw new Error( + `TypeScript requires WebAssembly Execution Engine ${minWasiCoreVersion.join(".")} or newer; found ${wasm.versions.extension}. Update the extension and reload.`, + ); + } + const module = wasm.compile(vscode.Uri.joinPath(modulePackageUri, "tsc.wasm")); + const mountPoints = outermostMountPoints((vscode.workspace.workspaceFolders ?? []).map(folder => { + const mountPoint = uriToWasmMountPoint(folder.uri); + return { + kind: "vscodeFileSystem" as const, + uri: folder.uri.scheme === "file" ? folder.uri.with({ query: "", fragment: "" }) : vscode.Uri.from({ scheme: wasmFileSystemScheme, path: mountPoint }), + mountPoint, + }; + })); + // Blocking stdin reads stall the Go scheduler, so the server cannot answer requests + // while waiting for input. `@vscode/wasm-wasi` does not declare this option yet. + const stdin = { kind: "pipeIn" as const, nonBlocking: true }; + if (isStale()) throw new Error("TypeScript language server was stopped"); + await process?.terminate(); + if (isStale()) throw new Error("TypeScript language server was stopped"); + const newProcess = await wasm.createProcess("tsc", module, { + args: ["--lsp", "--stdio"], + env: { PWD: "/" }, + mountPoints, + stdio: { + in: stdin, + out: { kind: "pipeOut" }, + err: { kind: "pipeOut" }, + }, + }); + // The server may have been stopped while the process was being created. + if (isStale()) { + await newProcess.terminate(); + throw new Error("TypeScript language server was stopped"); + } + process = newProcess; + 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: { + codeLensShowLocationsCommandName, + enableTelemetry: false, + runExternalCode: false, + }, + outputChannel: output, + middleware: { + workspace: configurationMiddleware, + sendNotification: sendNotificationMiddleware, + }, + }; + // A client whose start failed cannot start again, so each start gets a new one. + let client: LanguageClient | undefined; + context.subscriptions.push({ + dispose() { + disposed = true; + void client?.dispose().catch(() => {}); + void process?.terminate(); + }, + }); + + let running = false; + let serverCommands: vscode.Disposable | undefined; + let pending = Promise.resolve(); + const enqueue = (action: () => Promise): Promise => pending = pending.then(action).catch(error => output.error(`TypeScript language server failed: ${String(error)}`)); + const start = async () => { + const owner = ++generation; + const newClient = new LanguageClient("js/ts", "TypeScript Language Server", () => serverOptions(owner), clientOptions); + client = newClient; + try { + await newClient.start(); + } + catch (error) { + await disposeClient(); + throw error; + } + running = true; + serverCommands = vscode.Disposable.from( + registerServerCommands(output), + registerSourceDefinitionFeature(newClient), + ); + await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", true); + }; + const stop = async () => { + if (!running) return; + running = false; + serverCommands?.dispose(); + serverCommands = undefined; + await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", false); + await disposeClient(); + }; + const disposeClient = async () => { + const oldClient = client; + client = undefined; + generation++; + // Stopping fails while the client is starting or after its start failed; + // disposing still prevents further restarts, and terminating the process + // ends any start in progress. + await oldClient?.dispose().catch(() => {}); + await process?.terminate(); + process = undefined; + }; + + registerEnablementCommands(context, undefined, () => enqueue(stop)); + context.subscriptions.push( + vscode.commands.registerCommand("typescript.native-preview.restart", () => + enqueue(async () => { + await stop(); + await start(); + })), + { dispose: () => serverCommands?.dispose() }, + ); + + // WASI mounts are fixed when the process starts, so new folders need a new process. + context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(() => { + void enqueue(async () => { + if (!running) return; + await stop(); + await start(); + }); + })); + + let configChangeTimeout: ReturnType | undefined; + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => { + if ( + event.affectsConfiguration("typescript.experimental.useTsgo") + || event.affectsConfiguration("js/ts.experimental.useTsgo") + ) { + // Debounce to coalesce rapid events when both settings are updated together. + clearTimeout(configChangeTimeout); + configChangeTimeout = setTimeout(async () => { + if (needsExtHostRestartOnChange()) { + const selected = await vscode.window.showInformationMessage(vscode.l10n.t("TypeScript 7 setting has changed. Restart extensions to apply changes."), vscode.l10n.t("Restart Extensions")); + if (selected) { + vscode.commands.executeCommand("workbench.action.restartExtensionHost"); + } + return; + } + void enqueue(async () => { + await stop(); + if (shouldStartTsgo(context)) { + await start(); + } + }); + }, 100); + } + })); + context.subscriptions.push({ dispose: () => clearTimeout(configChangeTimeout) }); + + const hasOnboardedTsgoStateKey = "hasOnboardedTsgo"; + const shouldOnboardTsgo = !context.globalState.get(hasOnboardedTsgoStateKey); + if (shouldOnboardTsgo) { + await context.globalState.update(hasOnboardedTsgoStateKey, true); + } + if (getUseTsgo() === undefined && shouldOnboardTsgo) { + void updateUseTsgoSetting(true); + return; + } + + if (!shouldStartTsgo(context)) { + output.appendLine(vscode.l10n.t("TypeScript 7 is disabled. Select 'TypeScript: Enable TypeScript 7' in the command palette to enable it.")); + return; + } + await enqueue(start); +} + +export function deactivate(): void {} + +const minWasiCoreVersion = [1, 0, 4]; + +function supportsNonBlockingStdin(version: string): boolean { + const parts = version.split(/[.-]/, 3).map(Number); + for (let i = 0; i < minWasiCoreVersion.length; i++) { + if (parts[i] !== minWasiCoreVersion[i]) return parts[i] > minWasiCoreVersion[i]; + } + return true; +} + +async function hasWasmPayload(extensionUri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, ...wasmPackagePath, "tsc.wasm")); + return true; + } + catch { + return false; + } +} + +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/languageFeatures/sourceDefinition.ts b/packages/vscode-typescript/src/languageFeatures/sourceDefinition.ts index e719e1b2f4411..34818fd0c3068 100644 --- a/packages/vscode-typescript/src/languageFeatures/sourceDefinition.ts +++ b/packages/vscode-typescript/src/languageFeatures/sourceDefinition.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode"; import { - LanguageClient, - Location, + type BaseLanguageClient, + type Location, LocationLink, -} from "vscode-languageclient/node"; +} from "vscode-languageclient"; const sourceDefinitionMethod = "custom/textDocument/sourceDefinition"; const sourceDefinitionCommand = "typescript.native-preview.goToSourceDefinition"; @@ -11,7 +11,7 @@ const sourceDefinitionContext = "tsSupportsSourceDefinition"; type SourceDefinitionResponse = Location | Location[] | LocationLink[] | null; -export function registerSourceDefinitionFeature(client: LanguageClient): vscode.Disposable { +export function registerSourceDefinitionFeature(client: BaseLanguageClient): vscode.Disposable { const experimental = client.initializeResult?.capabilities?.experimental as { customSourceDefinitionProvider?: boolean; } | undefined; const enabled = !!experimental?.customSourceDefinitionProvider; void vscode.commands.executeCommand("setContext", sourceDefinitionContext, enabled); diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 943f2af6817f3..d5aa0d53d58eb 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -8,7 +8,7 @@ import * as vscode from "vscode"; import { ActiveJsTsEditorTracker } from "./activeJsTsEditorTracker"; import { Client } from "./client"; import { - registerCodeLensShowLocationsCommand, + registerServerCommands, updateWorkspaceUseTsgoSetting, } from "./commands"; import { @@ -317,11 +317,7 @@ class Session implements vscode.Disposable { } registerCommands(): void { - this.disposables.push(registerCodeLensShowLocationsCommand()); - - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.output.focus", () => { - this.outputChannel.show(); - })); + this.disposables.push(registerServerCommands(this.outputChannel)); // TODO: Support the standard reload/go-to-project-config commands while // Strada leaves their command palette entries visible in native mode: @@ -332,20 +328,6 @@ class Session implements vscode.Disposable { showCommands(this.client); })); - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.sortImports", () => { - return vscode.commands.executeCommand("editor.action.sourceAction", { - kind: "source.sortImports", - apply: "first", - }); - })); - - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.removeUnusedImports", () => { - return vscode.commands.executeCommand("editor.action.sourceAction", { - kind: "source.removeUnusedImports", - apply: "first", - }); - })); - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.reportIssue", () => { this.telemetryReporter.sendTelemetryEvent("command.reportIssue"); vscode.commands.executeCommand("workbench.action.openIssueReporter", { diff --git a/packages/vscode-typescript/src/settings.ts b/packages/vscode-typescript/src/settings.ts new file mode 100644 index 0000000000000..7c79c655eb785 --- /dev/null +++ b/packages/vscode-typescript/src/settings.ts @@ -0,0 +1,108 @@ +import * as vscode from "vscode"; + +export function needsExtHostRestartOnChange() { + const majorVersion = parseInt(vscode.version.split(".")[0]); + const minorVersion = parseInt(vscode.version.split(".")[1]); + return majorVersion <= 1 && minorVersion < 105; +} + +export async function restartExtHostOnChangeIfNeeded(): Promise { + if (needsExtHostRestartOnChange()) { + await vscode.commands.executeCommand("workbench.action.restartExtensionHost"); + } +} + +/** + * Read the useTsgo setting from both `js/ts.experimental.useTsgo` and + * `typescript.experimental.useTsgo`, using `inspect()` to only consider + * explicitly set values (ignoring VS Code defaults). + * + * Each setting key is resolved using standard VS Code precedence (workspace + * folder > workspace > global, with language-specific overrides taking + * priority within each scope). When both keys are explicitly configured, the + * value set at the most specific scope wins, and `js/ts` wins over + * `typescript` at the same scope. Returns `undefined` if neither setting has + * been explicitly configured. + */ +export function getUseTsgo(): boolean | undefined { + const tsValue = getExplicitUseTsgo("typescript"); + const jsTsValue = getExplicitUseTsgo("js/ts"); + + if (tsValue !== undefined || jsTsValue !== undefined) { + const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); + const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); + const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); + return jsTsTarget === mostSpecific ? jsTsValue : tsValue; + } + + return undefined; +} + +export function getExplicitConfigTarget( + config: vscode.WorkspaceConfiguration, + key: string, +): vscode.ConfigurationTarget | undefined { + const inspection = config.inspect(key); + if (!inspection) return undefined; + if (inspection.workspaceFolderValue !== undefined) return vscode.ConfigurationTarget.WorkspaceFolder; + if (inspection.workspaceValue !== undefined) return vscode.ConfigurationTarget.Workspace; + if (inspection.globalValue !== undefined) return vscode.ConfigurationTarget.Global; + return undefined; +} + +export enum ConfigName { + JsTsConfigName = "js/ts.experimental.useTsgo", + DeprecatedTypeScriptConfigName = "typescript.experimental.useTsgo", +} + +/** + * Returns the name of the setting with the appropriate precedence that is explicitly set, + * preferring `js/ts` over `typescript`. + * Returns `undefined` if neither is explicitly specified. + */ +export function getWinningTsgoConfigKey(): ConfigName | undefined { + const jsTsValue = getExplicitUseTsgo("js/ts"); + const tsValue = getExplicitUseTsgo("typescript"); + + if (jsTsValue !== undefined || tsValue !== undefined) { + const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); + const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); + const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); + return jsTsTarget === mostSpecific ? ConfigName.JsTsConfigName : ConfigName.DeprecatedTypeScriptConfigName; + } + + return undefined; +} + +function getExplicitUseTsgo(section: string): boolean | undefined { + const config = vscode.workspace.getConfiguration(section); + const inspected = config.inspect("experimental.useTsgo"); + if (!inspected) return undefined; + + const explicitValues: (boolean | undefined)[] = [ + inspected.workspaceFolderLanguageValue, + inspected.workspaceLanguageValue, + inspected.globalLanguageValue, + inspected.workspaceFolderValue, + inspected.workspaceValue, + inspected.globalValue, + ]; + + for (const v of explicitValues) { + if (v !== undefined) return v; + } + return undefined; +} + +/** + * Whether the native language server should run with the current settings. + * In development, it also runs when the built-in TypeScript extension is + * disabled unless the setting explicitly turns it off. + */ +export function shouldStartTsgo(context: vscode.ExtensionContext): boolean { + const useTsgo = getUseTsgo(); + if (context.extensionMode === vscode.ExtensionMode.Development && !vscode.extensions.getExtension("vscode.typescript-language-features")) { + return useTsgo !== false; + } + return useTsgo === true; +} diff --git a/packages/vscode-typescript/src/util.ts b/packages/vscode-typescript/src/util.ts index 665453286c80e..05a7b943eeb64 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 = [ @@ -379,100 +379,6 @@ export function getLanguageForUri(uri: vscode.Uri): string | undefined { } } -export function needsExtHostRestartOnChange() { - const majorVersion = parseInt(vscode.version.split(".")[0]); - const minorVersion = parseInt(vscode.version.split(".")[1]); - return majorVersion <= 1 && minorVersion < 105; -} - -export async function restartExtHostOnChangeIfNeeded(): Promise { - if (needsExtHostRestartOnChange()) { - await vscode.commands.executeCommand("workbench.action.restartExtensionHost"); - } -} - -/** - * Read the useTsgo setting from both `js/ts.experimental.useTsgo` and - * `typescript.experimental.useTsgo`, using `inspect()` to only consider - * explicitly set values (ignoring VS Code defaults). - * - * Each setting key is resolved using standard VS Code precedence (workspace - * folder > workspace > global, with language-specific overrides taking - * priority within each scope). When both keys are explicitly configured, the - * value set at the most specific scope wins, and `js/ts` wins over - * `typescript` at the same scope. Returns `undefined` if neither setting has - * been explicitly configured. - */ -export function getUseTsgo(): boolean | undefined { - const tsValue = getExplicitUseTsgo("typescript"); - const jsTsValue = getExplicitUseTsgo("js/ts"); - - if (tsValue !== undefined || jsTsValue !== undefined) { - const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); - const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); - const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); - return jsTsTarget === mostSpecific ? jsTsValue : tsValue; - } - - return undefined; -} - -export function getExplicitConfigTarget( - config: vscode.WorkspaceConfiguration, - key: string, -): vscode.ConfigurationTarget | undefined { - const inspection = config.inspect(key); - if (!inspection) return undefined; - if (inspection.workspaceFolderValue !== undefined) return vscode.ConfigurationTarget.WorkspaceFolder; - if (inspection.workspaceValue !== undefined) return vscode.ConfigurationTarget.Workspace; - if (inspection.globalValue !== undefined) return vscode.ConfigurationTarget.Global; - return undefined; -} - -export enum ConfigName { - JsTsConfigName = "js/ts.experimental.useTsgo", - DeprecatedTypeScriptConfigName = "typescript.experimental.useTsgo", -} - -/** - * Returns the name of the setting with the appropriate precedence that is explicitly set, - * preferring `js/ts` over `typescript`. - * Returns `undefined` if neither is explicitly specified. - */ -export function getWinningTsgoConfigKey(): ConfigName | undefined { - const jsTsValue = getExplicitUseTsgo("js/ts"); - const tsValue = getExplicitUseTsgo("typescript"); - - if (jsTsValue !== undefined || tsValue !== undefined) { - const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); - const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); - const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); - return jsTsTarget === mostSpecific ? ConfigName.JsTsConfigName : ConfigName.DeprecatedTypeScriptConfigName; - } - - return undefined; -} - -function getExplicitUseTsgo(section: string): boolean | undefined { - const config = vscode.workspace.getConfiguration(section); - const inspected = config.inspect("experimental.useTsgo"); - if (!inspected) return undefined; - - const explicitValues: (boolean | undefined)[] = [ - inspected.workspaceFolderLanguageValue, - inspected.workspaceLanguageValue, - inspected.globalLanguageValue, - inspected.workspaceFolderValue, - inspected.workspaceValue, - inspected.globalValue, - ]; - - for (const v of explicitValues) { - if (v !== undefined) return v; - } - return undefined; -} - /** * Read a setting following the standard `js/ts > [typescript|javascript]` precedence: * the unified `js/ts.` setting wins if explicitly set at any scope, otherwise diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts new file mode 100644 index 0000000000000..5b9adaa267d1b --- /dev/null +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -0,0 +1,149 @@ +export interface WebFileSystemUri { + readonly scheme: string; + readonly authority: string; + readonly path: string; + toString(): string; +} + +const dynamicURIFileNamePrefix = "^/~ts-uri~/"; +const escapePrefixes = ["~ts-uri-escape~", "~ts-uri-spec~", "~ts-uri-no-path~"]; +const nulAuthority = "ts-nul-authority"; + +export function uriToWasmMountPoint(uri: WebFileSystemUri): string { + if (uri.scheme === "file") { + // Document URIs are serialized with a lowercase authority and drive + // letter, and the server names files from that serialized form. + let fileName: string; + if (uri.authority) { + fileName = `//${uri.authority.toLowerCase()}${uri.path}`; + } + else { + fileName = /^\/[A-Za-z]:/.test(uri.path) ? uri.path[1].toLowerCase() + uri.path.slice(2) : uri.path; + } + return fileName.startsWith("/") ? fileName : `/${fileName}`; + } + + // Files below the folder must land inside this mount, so this follows the + // server's mapping of non-file document URIs onto the file names it reads. + const text = uri.toString(); + const colon = text.indexOf(":"); + const scheme = text.slice(0, colon); + let path = text.slice(colon + 1); + // Only the final segment of a document name carries a query or fragment, + // so directories below the folder are named as if it had none. + const suffixStart = path.search(/[?#]/); + if (suffixStart !== -1) path = path.slice(0, suffixStart); + let authority = nulAuthority; + if (path.startsWith("//")) { + const slash = path.indexOf("/", 2); + const rawAuthority = slash === -1 ? path.slice(2) : path.slice(2, slash); + authority = rawAuthority === nulAuthority || isDriveRoot(rawAuthority) ? forceEncodeSegment(rawAuthority) : needsEncoding(rawAuthority) ? forceEncodeSegment(rawAuthority, /* preserveExtension */ true) : rawAuthority; + path = slash === -1 ? "" : path.slice(slash + 1); + } + const segments = path === "" ? [] : (path.endsWith("/") ? path : `${path}/`).split("/").slice(0, -1).map(encodeSegment); + if (segments.length && isDriveRoot(segments[0])) { + segments[0] = forceEncodeSegment(segments[0]); + } + return `/${dynamicURIFileNamePrefix}${[scheme, authority, ...segments].join("/")}`; +} + +/** + * Drops mounts that are the same as, or nested below, another mount on the + * same backing file system, since the WASI host cannot mount a folder over an + * existing mount. + */ +export function outermostMountPoints(mounts: readonly T[]): T[] { + const withSlash = (path: string) => path.endsWith("/") ? path : `${path}/`; + const sorted = [...mounts].sort((a, b) => a.mountPoint.length - b.mountPoint.length); + const kept: T[] = []; + for (const mount of sorted) { + if (!kept.some(outer => outer.uri.scheme === mount.uri.scheme && withSlash(mount.mountPoint).startsWith(withSlash(outer.mountPoint)))) { + kept.push(mount); + } + } + return kept; +} + +/** + * Maps a file name below a virtual workspace mount back to the URI the server + * derived it from, or returns undefined for names no document URI produces. + */ +export function wasmPathToUriText(path: string): string | undefined { + const prefix = `/${dynamicURIFileNamePrefix}`; + if (!path.startsWith(prefix)) return undefined; + const [scheme, authority, ...segments] = path.slice(prefix.length).split("/"); + if (!scheme || authority === undefined) return undefined; + let uriPath = ""; + for (let i = 0; i < segments.length; i++) { + const segment = decodeSegment(segments[i]); + if (segment === undefined) return undefined; + uriPath += i === 0 ? segment : `/${segment}`; + } + // The root path "/" is a single empty segment. + if (authority === nulAuthority) return `${scheme}:${uriPath || (segments.length ? "/" : "")}`; + const decodedAuthority = decodeSegment(authority); + return decodedAuthority === undefined ? undefined : `${scheme}://${decodedAuthority}/${uriPath}`; +} + +/** + * Names a directory entry as the server would name it within a document URI, + * given the entry's segment in the escaped URI text. + */ +export function uriSegmentToWasmName(segment: string, isFile: boolean): string { + return needsEncoding(segment) ? forceEncodeSegment(segment, isFile) : segment; +} + +function isDriveRoot(segment: string): boolean { + return /^[A-Za-z]:$/.test(segment); +} + +function needsEncoding(segment: string): boolean { + return segment === "" + || segment === "." + || segment === ".." + || segment.includes("\\") + || escapePrefixes.some(prefix => segment.startsWith(prefix)); +} + +function encodeSegment(segment: string): string { + return needsEncoding(segment) ? forceEncodeSegment(segment) : segment; +} + +function forceEncodeSegment(segment: string, preserveExtension = false): string { + let extension = ""; + if (preserveExtension && segment !== "." && segment !== "..") { + extension = getExtension(segment.slice(segment.lastIndexOf("\\") + 1)); + segment = segment.slice(0, segment.length - extension.length); + } + const hex = Array.from(new TextEncoder().encode(segment), byte => byte.toString(16).padStart(2, "0")).join(""); + return `${escapePrefixes[0]}${hex}~${extension}`; +} + +function getExtension(baseName: string): string { + const declaration = [".d.ts", ".d.cts", ".d.mts"].find(ext => baseName.endsWith(ext)); + if (declaration) return declaration; + if (baseName.endsWith(".ts") && baseName.includes(".d.")) return baseName.slice(baseName.indexOf(".d.")); + const dot = baseName.lastIndexOf("."); + return dot === -1 ? "" : baseName.slice(dot); +} + +const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +function decodeSegment(segment: string): string | undefined { + if (!segment.startsWith(escapePrefixes[0])) { + return needsEncoding(segment) ? undefined : segment; + } + const encoded = segment.slice(escapePrefixes[0].length); + const end = encoded.indexOf("~"); + const hex = encoded.slice(0, end); + if (end === -1 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return undefined; + let decoded: string; + try { + decoded = strictUtf8Decoder.decode(new Uint8Array(hex.match(/../g)?.map(byte => parseInt(byte, 16)) ?? [])); + } + catch { + return undefined; + } + // A NUL marks a query or fragment, which only open documents carry. + return decoded.includes("\0") ? undefined : decoded + encoded.slice(end + 1); +} diff --git a/packages/vscode-typescript/test/index.test.ts b/packages/vscode-typescript/test/index.test.ts index 76a3a8d1e641f..94cb06471e0ae 100644 --- a/packages/vscode-typescript/test/index.test.ts +++ b/packages/vscode-typescript/test/index.test.ts @@ -1,3 +1,4 @@ import "./contentMapperContributions.test"; import "./lspMiddleware.test"; import "./tsdkPackage.test"; +import "./webMountPoint.test"; diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts new file mode 100644 index 0000000000000..85a1fda813ff5 --- /dev/null +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { + outermostMountPoints, + uriSegmentToWasmName, + uriToWasmMountPoint, + wasmPathToUriText, +} 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", + ); + 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", + ); + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "Server.Name", path: "/Share/Project" }), + "//server.name/Share/Project", + ); + }); + + test("maps virtual workspaces to the paths the server derives from document URIs", () => { + const virtual = (text: string) => uriToWasmMountPoint({ scheme: text.slice(0, text.indexOf(":")), authority: "", path: "", toString: () => text }); + assert.strictEqual(virtual("vscode-test-web://mount/"), "/^/~ts-uri~/vscode-test-web/mount"); + assert.strictEqual(virtual("vscode-vfs://github/microsoft/TypeScript"), "/^/~ts-uri~/vscode-vfs/github/microsoft/TypeScript"); + assert.strictEqual(virtual("memfs:/work/a%5Cb"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/a%5Cb"); + assert.strictEqual(virtual("memfs:/work/a\\b/"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~"); + assert.strictEqual(virtual("memfs:/"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~"); + assert.strictEqual(virtual("memfs:///x"), "/^/~ts-uri~/memfs/~ts-uri-escape~~/x"); + assert.strictEqual(virtual("memfs://c:/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~633a~/work"); + assert.strictEqual(virtual("memfs://x:8080/work"), "/^/~ts-uri~/memfs/x:8080/work"); + assert.strictEqual(virtual("memfs://~ts-uri-escape~fs.example/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~7e74732d7572692d6573636170657e6673~.example/work"); + assert.strictEqual(virtual("memfs://mount/work?ref=main"), "/^/~ts-uri~/memfs/mount/work"); + assert.strictEqual(virtual("memfs://mount/work/#x"), "/^/~ts-uri~/memfs/mount/work"); + assert.strictEqual(virtual("memfs://mount?ref=a/b"), "/^/~ts-uri~/memfs/mount"); + assert.strictEqual(virtual("memfs:/work#a/b"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work"); + }); + + test("maps virtual workspace file names back to their escaped URIs", () => { + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/vscode-vfs/github/o/r/a%20b.ts"), "vscode-vfs://github/o/r/a%20b.ts"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/vscode-test-web/mount"), "vscode-test-web://mount/"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~.ts"), "memfs:/work/a\\b.ts"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/~ts-uri-escape~~/x"), "memfs:///x"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~"), "memfs:/"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/a\\b"), undefined); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/~ts-uri-escape~6100713d31~.ts"), undefined); + assert.strictEqual(wasmPathToUriText("/work/a.ts"), undefined); + }); + + test("drops mounts nested below or equal to another mount", () => { + const mounts = (...paths: string[]) => outermostMountPoints(paths.map(mountPoint => ({ mountPoint, uri: { scheme: "ts-wasi" } }))).map(mount => mount.mountPoint); + assert.deepStrictEqual(mounts("/^/~ts-uri~/memfs/host/project/src", "/^/~ts-uri~/memfs/host/project"), ["/^/~ts-uri~/memfs/host/project"]); + assert.deepStrictEqual(mounts("/^/~ts-uri~/memfs/host/project", "/^/~ts-uri~/memfs/host/project"), ["/^/~ts-uri~/memfs/host/project"]); + assert.deepStrictEqual(mounts("/work/a", "/work/ab"), ["/work/a", "/work/ab"]); + assert.deepStrictEqual(mounts("/work", "/"), ["/"]); + const mixed = outermostMountPoints([{ mountPoint: "/", uri: { scheme: "file" } }, { mountPoint: "/^/~ts-uri~/memfs/host", uri: { scheme: "ts-wasi" } }]); + assert.strictEqual(mixed.length, 2); + }); + + test("names directory entries as the server names them in document URIs", () => { + assert.strictEqual(uriSegmentToWasmName("a%20b.ts", true), "a%20b.ts"); + assert.strictEqual(uriSegmentToWasmName("a\\b.d.ts", true), "~ts-uri-escape~615c62~.d.ts"); + assert.strictEqual(uriSegmentToWasmName("a\\b.d", false), "~ts-uri-escape~615c622e64~"); + }); +}); diff --git a/tools/pipelines/typescript-build.yml b/tools/pipelines/typescript-build.yml index 5c69e542b6e1d..e755d27d510ab 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -199,12 +199,17 @@ extends: inputs: buildType: current targetPath: $(Pipeline.Workspace)/platform-packages - patterns: '$(packageBaseName)-*/**' + patterns: | + $(packageBaseName)-*/** + typescript-wasip1-wasm/** - bash: | set -euo pipefail shopt -s nullglob platformArtifacts=("$(Pipeline.Workspace)"/platform-packages/"$PACKAGE_BASE_NAME"-*) + if [[ "$PACKAGE_BASE_NAME" != "typescript" ]]; then + platformArtifacts+=("$(Pipeline.Workspace)"/platform-packages/typescript-wasip1-wasm) + fi if (( ${#platformArtifacts[@]} == 0 )); then echo "No platform package artifacts were downloaded." >&2 exit 1 @@ -212,12 +217,14 @@ extends: for artifact in "${platformArtifacts[@]}"; do packageDir="built/npm/$(basename "$artifact")" cp -a "$artifact" "$packageDir" - executable="$(find "$packageDir/lib" -maxdepth 1 -type f \( -name tsc -o -name tsc.exe -o -name tsgo -o -name tsgo.exe \) -print -quit)" + executable="$(find "$packageDir/lib" -maxdepth 1 -type f \( -name tsc -o -name tsc.exe -o -name tsgo -o -name tsgo.exe -o -name tsc.wasm \) -print -quit)" if [[ -z "$executable" ]]; then echo "Missing platform executable in $packageDir." >&2 exit 1 fi - chmod +x "$executable" + if [[ "$executable" != *.wasm ]]; then + chmod +x "$executable" + fi done displayName: 'Assemble platform packages' env: diff --git a/tools/scripts/gen/generatedFile.test.mts b/tools/scripts/gen/generatedFile.test.mts index eaf918e759fc3..60edad3e3439d 100644 --- a/tools/scripts/gen/generatedFile.test.mts +++ b/tools/scripts/gen/generatedFile.test.mts @@ -58,7 +58,9 @@ test("validate generates before building and selects the generation scope", asyn runTests: action("test:tsc"), runTestExtension: action("test:extension"), runCheckAPIDeclarations: action("check:api-declarations"), + runCheckHerebyfile: action("check:herebyfile"), runTestAPI: action("test:api"), + runTestAPIWasm: action("test:api:wasm"), runTestBenchmarks: action("test:benchmarks"), runTestTools: action("test:tools"), run: async (command: string, args: string[]) => { @@ -77,6 +79,8 @@ test("validate generates before building and selects the generation scope", asyn assert.ok(calls.indexOf("build") < calls.indexOf("test:tsc")); assert.ok(calls.indexOf("check:api-declarations") > calls.indexOf("test:extension")); assert.equal(calls.includes("test:api"), "api" in options || "all" in options); + assert.equal(calls.includes("test:api:wasm"), "api" in options || "all" in options); + assert.equal(calls.includes("check:herebyfile"), "all" in options); assert.equal(calls.includes("test:tools"), "all" in options); assert.equal(calls.includes("test:options"), "all" in options); if ("all" in options) { diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index fc5934e0eefcb..104343b494650 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 a named pipe (Windows) or create a private Unix socket at /socket, where must not exist") 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/lsp.go b/tsc/cmd/tsc/lsp.go index 3bb3affd2b160..d25f743d8eda9 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: typingsLocation, - 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/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/platform.go b/tsc/cmd/tsc/platform.go new file mode 100644 index 0000000000000..f452a211b31d1 --- /dev/null +++ b/tsc/cmd/tsc/platform.go @@ -0,0 +1,33 @@ +//go:build !wasip1 + +package main + +import ( + "context" + "io" + "os" + "os/exec" + "os/signal" +) + +var stdioStdin = os.Stdin + +func getCurrentDirectory() (string, error) { + return os.Getwd() +} + +func notifyContext(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, signals...) +} + +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/platform_wasip1.go b/tsc/cmd/tsc/platform_wasip1.go new file mode 100644 index 0000000000000..59496a132c0e0 --- /dev/null +++ b/tsc/cmd/tsc/platform_wasip1.go @@ -0,0 +1,65 @@ +//go:build wasip1 + +package main + +import ( + "context" + "errors" + "io" + "os" + "syscall" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +const maxStdinPollDelay = 50 * time.Millisecond + +type wasiStdin struct{} + +func (wasiStdin) Read(buffer []byte) (int, error) { + delay := time.Millisecond + for { + n, err := syscall.Read(syscall.Stdin, buffer) + if n > 0 { + return n, nil + } + if err == nil { + return 0, io.EOF + } + if err != syscall.EAGAIN { + return 0, err + } + // Stdin is not registered with Go's WASI poller. + time.Sleep(delay) + delay = min(delay*2, maxStdinPollDelay) + } +} + +func (wasiStdin) Close() error { + return nil +} + +var stdioStdin wasiStdin + +func getCurrentDirectory() (string, error) { + cwd := os.Getenv("PWD") + if !tspath.IsRootedDiskPath(cwd) { + cwd = "/" + } + return cwd, nil +} + +func notifyContext(parent context.Context, _ ...os.Signal) (context.Context, context.CancelFunc) { + return context.WithCancel(parent) +} + +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/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/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go new file mode 100644 index 0000000000000..6bc9207004cfb --- /dev/null +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -0,0 +1,231 @@ +//go:build wasip1 + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "runtime/debug" + "unsafe" + + "github.com/microsoft/TypeScript/tsc/internal/api" + "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" + "github.com/microsoft/TypeScript/tsc/internal/json" +) + +// Statuses returned by exports. receive_message otherwise returns the +// api.MessageType of the message it received. +const ( + statusOK = 0 + statusFailed = 1 + statusAbsent = 2 + statusClosed = 0 +) + +var ( + reactor *wasmreactor.Reactor + + requestBuffer []byte + + responseBuffer []byte + responsePtr uint32 + responseLen uint32 + responseMethodLen uint32 +) + +//go:wasmexport create_session +func createSession(optionsLen uint32) (status uint32) { + if reactor != nil { + return fail("session already created") + } + if !withinRequest(optionsLen, 0) { + return fail("invalid session options length: exceeds the request buffer") + } + 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(requestBuffer[:optionsLen], &options); err != nil { + return fail(fmt.Sprintf("invalid session options: %v", err)) + } + reactor = wasmreactor.New(context.Background(), options) + setResponse(nil) + return statusOK +} + +//go:wasmexport start_session +func startSession(callbacksLen uint32) uint32 { + return guarded("starting session", callbacksLen, 0, func() uint32 { + var callbacks []string + if err := json.Unmarshal(requestBuffer[:callbacksLen], &callbacks); err != nil { + return fail(fmt.Sprintf("invalid callbacks: %v", err)) + } + if err := reactor.Start(callbacks); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return statusOK + }) +} + +//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 send_message +func sendMessage(messageType uint32, methodLen uint32, payloadLen uint32) uint32 { + return guarded("sending message", methodLen, payloadLen, func() uint32 { + message := wasmreactor.Message{ + Type: api.MessageType(messageType), + Method: string(requestBuffer[:methodLen]), + Payload: append([]byte(nil), requestBuffer[methodLen:methodLen+payloadLen]...), + } + if err := reactor.Send(message); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return statusOK + }) +} + +//go:wasmexport receive_message +func receiveMessage() uint32 { + return guarded("receiving message", 0, 0, func() uint32 { + message, err := reactor.Receive() + if errors.Is(err, io.EOF) { + setResponse(nil) + return statusClosed + } + if err != nil { + return fail(err.Error()) + } + buffer := make([]byte, 0, len(message.Method)+len(message.Payload)) + buffer = append(buffer, message.Method...) + buffer = append(buffer, message.Payload...) + setResponse(buffer) + responseMethodLen = uint32(len(message.Method)) + return uint32(message.Type) + }) +} + +//go:wasmexport set_file +func setFile(pathLen uint32, contentLen uint32) uint32 { + return guarded("setting file", pathLen, contentLen, func() uint32 { + 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 statusOK + }) +} + +//go:wasmexport read_file +func readFile(pathLen uint32) uint32 { + return guarded("reading file", pathLen, 0, func() uint32 { + content, ok := reactor.ReadFile(string(requestBuffer[:pathLen])) + if !ok { + setResponse(nil) + return statusAbsent + } + setResponse([]byte(content)) + return statusOK + }) +} + +//go:wasmexport remove_file +func removeFile(pathLen uint32) uint32 { + return guarded("removing file", pathLen, 0, func() uint32 { + if err := reactor.RemoveFile(string(requestBuffer[:pathLen])); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return statusOK + }) +} + +// guarded runs an export that operates on the session and the first+second +// bytes of the request buffer, reporting panics as failures. +func guarded(operation string, first uint32, second uint32, run func() uint32) (status uint32) { + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(first, second) { + return fail(operation + ": lengths exceed the request buffer") + } + defer func() { + if recovered := recover(); recovered != nil { + status = fail(fmt.Sprintf("panic %s: %v\n%s", operation, recovered, debug.Stack())) + } + }() + return run() +} + +//go:wasmexport response_ptr +func responsePointer() uint32 { + return responsePtr +} + +//go:wasmexport response_len +func responseLength() uint32 { + return responseLen +} + +//go:wasmexport response_method_len +func responseMethodLength() uint32 { + return responseMethodLen +} + +//go:wasmexport __typescript_cli_start +func cliStart() { + os.Exit(runMain()) +} + +func fail(message string) uint32 { + setResponse([]byte(message)) + return statusFailed +} + +func setResponse(data []byte) { + responseBuffer = data + responsePtr = bytesPointer(responseBuffer) + responseLen = uint32(len(responseBuffer)) + responseMethodLen = 0 +} + +func withinRequest(first uint32, second uint32) bool { + return first+second >= first && first+second <= uint32(len(requestBuffer)) +} + +func bytesPointer(bytes []byte) uint32 { + if len(bytes) == 0 { + return 0 + } + return uint32(uintptr(unsafe.Pointer(&bytes[0]))) +} diff --git a/tsc/internal/api/protocol_msgpack.go b/tsc/internal/api/protocol_msgpack.go index 848bb9a6e71b6..0167f22f24e9d 100644 --- a/tsc/internal/api/protocol_msgpack.go +++ b/tsc/internal/api/protocol_msgpack.go @@ -37,26 +37,44 @@ const ( msgpackU8 byte = 0xCC ) -// MessagePackProtocol implements the Protocol interface using a custom -// msgpack-based tuple format: [MessageType, method, payload]. -type MessagePackProtocol struct { - r *bufio.Reader - w *bufio.Writer +// TupleTransport carries [MessageType, method, payload] tuples between the +// server and a client. +type TupleTransport interface { + ReadTuple() (MessageType, string, []byte, error) + WriteTuple(msgType MessageType, method string, payload []byte) error +} + +// TupleProtocol implements the Protocol interface over [MessageType, method, +// payload] tuples. Requests and responses are matched by method name. +type TupleProtocol struct { + t TupleTransport } -var _ ipc.Protocol = (*MessagePackProtocol)(nil) +var _ ipc.Protocol = (*TupleProtocol)(nil) + +// NewTupleProtocol creates a protocol handler that exchanges tuples over t. +func NewTupleProtocol(t TupleTransport) *TupleProtocol { + return &TupleProtocol{t: t} +} -// NewMessagePackProtocol creates a new msgpack protocol handler. -func NewMessagePackProtocol(rw io.ReadWriter) *MessagePackProtocol { - return &MessagePackProtocol{ +// NewMessagePackProtocol creates a protocol handler that frames tuples as +// MessagePack arrays on rw. +func NewMessagePackProtocol(rw io.ReadWriter) *TupleProtocol { + return NewTupleProtocol(&messagePackTransport{ r: bufio.NewReader(rw), w: bufio.NewWriter(rw), - } + }) +} + +// messagePackTransport frames tuples as fixed three-element MessagePack arrays. +type messagePackTransport struct { + r *bufio.Reader + w *bufio.Writer } // ReadMessage implements Protocol. -func (p *MessagePackProtocol) ReadMessage() (*ipc.Message, error) { - msgType, method, payload, err := p.readTuple() +func (p *TupleProtocol) ReadMessage() (*ipc.Message, error) { + msgType, method, payload, err := p.t.ReadTuple() if err != nil { return nil, err } @@ -94,7 +112,7 @@ func (p *MessagePackProtocol) ReadMessage() (*ipc.Message, error) { return msg, nil } -func (p *MessagePackProtocol) readTuple() (MessageType, string, []byte, error) { +func (p *messagePackTransport) ReadTuple() (MessageType, string, []byte, error) { // Read fixed array marker (0x93 = 3-element array) t, err := p.r.ReadByte() if err != nil { @@ -143,7 +161,7 @@ func (p *MessagePackProtocol) readTuple() (MessageType, string, []byte, error) { return msgType, method, payload, nil } -func (p *MessagePackProtocol) readBin() ([]byte, error) { +func (p *messagePackTransport) readBin() ([]byte, error) { t, err := p.r.ReadByte() if err != nil { return nil, err @@ -181,23 +199,23 @@ func (p *MessagePackProtocol) readBin() ([]byte, error) { } // WriteRequest implements Protocol. -func (p *MessagePackProtocol) WriteRequest(id *jsonrpc.ID, method string, params any) error { +func (p *TupleProtocol) WriteRequest(id *jsonrpc.ID, method string, params any) error { // For msgpack protocol, requests from server are "Call" type payload, err := json.Marshal(params) if err != nil { return err } - return p.writeTuple(MessageTypeCall, method, payload) + return p.t.WriteTuple(MessageTypeCall, method, payload) } // WriteNotification implements Protocol. -func (p *MessagePackProtocol) WriteNotification(method string, params any) error { +func (p *TupleProtocol) WriteNotification(method string, params any) error { // Msgpack protocol doesn't distinguish notifications from calls return p.WriteRequest(nil, method, params) } // WriteResponse implements Protocol. -func (p *MessagePackProtocol) WriteResponse(id *jsonrpc.ID, result any) error { +func (p *TupleProtocol) WriteResponse(id *jsonrpc.ID, result any) error { method := "" if id != nil { method = id.String() @@ -216,19 +234,19 @@ func (p *MessagePackProtocol) WriteResponse(id *jsonrpc.ID, result any) error { } } - return p.writeTuple(MessageTypeResponse, method, payload) + return p.t.WriteTuple(MessageTypeResponse, method, payload) } // WriteError implements Protocol. -func (p *MessagePackProtocol) WriteError(id *jsonrpc.ID, respErr *jsonrpc.ResponseError) error { +func (p *TupleProtocol) WriteError(id *jsonrpc.ID, respErr *jsonrpc.ResponseError) error { method := "" if id != nil { method = id.String() } - return p.writeTuple(MessageTypeError, method, []byte(respErr.Message)) + return p.t.WriteTuple(MessageTypeError, method, []byte(respErr.Message)) } -func (p *MessagePackProtocol) writeTuple(msgType MessageType, method string, payload []byte) error { +func (p *messagePackTransport) WriteTuple(msgType MessageType, method string, payload []byte) error { // Write fixed array marker if err := p.w.WriteByte(msgpackFixedArray3); err != nil { return err @@ -248,7 +266,7 @@ func (p *MessagePackProtocol) writeTuple(msgType MessageType, method string, pay return p.w.Flush() } -func (p *MessagePackProtocol) writeBin(data []byte) error { +func (p *messagePackTransport) writeBin(data []byte) error { length := len(data) if length < 256 { if err := p.w.WriteByte(msgpackBin8); err != nil { @@ -277,5 +295,5 @@ func (p *MessagePackProtocol) writeBin(data []byte) error { } // RawBinary is a marker type for binary data that should be written -// directly by MessagePackProtocol instead of being JSON-encoded. +// directly by TupleProtocol instead of being JSON-encoded. type RawBinary []byte diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index 75f402ef9b423..02fcbc03f5085 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -11,6 +11,7 @@ import ( "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/osvfs" ) @@ -41,6 +42,12 @@ type StdioServerOptions struct { // RunExternalCode allows configured content mappers to execute. RunExternalCode bool ContentMapperSpawner contentmapper.Spawner + // FS replaces the operating-system file system as the server's base file + // system, which callbacks fall back to. + FS vfs.FS + // Tuples, if set, carries synchronous protocol messages directly instead + // of In, Out, or PipePath. + Tuples TupleTransport } // StdioServer runs an API session over STDIO using MessagePack protocol. @@ -64,7 +71,11 @@ func NewStdioServer(options *StdioServerOptions) *StdioServer { // Run starts the server and blocks until the connection closes. func (s *StdioServer) Run(ctx context.Context) error { var transport ipc.Transport - if s.options.PipePath != "" { + if s.options.Tuples != nil { + if s.options.Async { + panic("StdioServerOptions.Tuples requires the synchronous protocol") + } + } else if s.options.PipePath != "" { t, err := ipc.NewPipeTransport(s.options.PipePath) if err != nil { return fmt.Errorf("failed to create pipe transport: %w", err) @@ -77,7 +88,11 @@ func (s *StdioServer) Run(ctx context.Context) error { transport = t } - fs := bundled.WrapFS(osvfs.FS()) + baseFS := s.options.FS + if baseFS == nil { + baseFS = osvfs.FS() + } + fs := bundled.WrapFS(baseFS) // Wrap the base FS when callbacks or an explicit case-sensitivity setting are requested. var callbackFS *callbackFS @@ -105,15 +120,14 @@ func (s *StdioServer) Run(ctx context.Context) error { }) defer session.Close() - // Accept connection from transport - rwc, err := transport.Accept() - if err != nil { - return fmt.Errorf("failed to accept connection: %w", err) - } - - // Create protocol and connection based on async mode var conn ipc.Conn - if s.options.Async { + if s.options.Tuples != nil { + syncConn := ipc.NewSyncConn(nil, NewTupleProtocol(s.options.Tuples), session) + syncConn.SetCollectTiming(s.options.CollectTiming) + conn = syncConn + } else if rwc, err := transport.Accept(); err != nil { + return fmt.Errorf("failed to accept connection: %w", err) + } else if s.options.Async { protocol := ipc.NewJSONRPCProtocol(rwc) asyncConn := ipc.NewAsyncConnWithProtocol(rwc, protocol, session) asyncConn.SetCollectTiming(s.options.CollectTiming) diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index c88ef9a1b3bee..1990eb616248c 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -561,7 +561,7 @@ type Session struct { closeOnce sync.Once - // This is set to true when using MessagePackProtocol. + // This is set to true when using TupleProtocol. useBinaryResponses bool batchResponsePages sync.Map nextBatchResponsePageID atomic.Uint64 @@ -3864,6 +3864,9 @@ func (s *Session) handleTypeToTypeNode(ctx context.Context, params *TypeToTypeNo typeNode := setup.checker.TypeToTypeNode(t, enclosingDeclaration, nodebuilder.Flags(params.Flags), nil) if typeNode == nil { + if s.useBinaryResponses { + return RawBinary(nil), nil + } return nil, nil } @@ -3904,6 +3907,9 @@ func (s *Session) handleSignatureToSignatureDeclaration(ctx context.Context, par node := setup.checker.SignatureToSignatureDeclaration(sig, ast.Kind(params.Kind), enclosingDeclaration, nodebuilder.Flags(params.Flags)) if node == nil { + if s.useBinaryResponses { + return RawBinary(nil), nil + } return nil, nil } diff --git a/tsc/internal/api/wasmreactor/memfs.go b/tsc/internal/api/wasmreactor/memfs.go new file mode 100644 index 0000000000000..a12acc2c47356 --- /dev/null +++ b/tsc/internal/api/wasmreactor/memfs.go @@ -0,0 +1,223 @@ +package wasmreactor + +import ( + "io/fs" + "maps" + "slices" + "sync" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" +) + +// memFS holds the files mirrored into the reactor by its host. +type memFS struct { + mu sync.RWMutex + caseSensitivity tspath.CaseSensitivity + files map[string]*memFile + dirs map[string]*memDir +} + +type memFile struct { + path tspath.RootedFilePath + content string + modTime time.Time +} + +type memDir struct { + path tspath.RootedDirectoryPath + // Children are keyed by canonical base name and map to their original base name. + files map[string]string + dirs map[string]string +} + +var _ vfs.FS = (*memFS)(nil) + +func newMemFS(caseSensitivity tspath.CaseSensitivity) *memFS { + m := &memFS{ + caseSensitivity: caseSensitivity, + files: map[string]*memFile{}, + dirs: map[string]*memDir{}, + } + m.ensureDir(tspath.RootedDirectoryPathFromAbsolute("/")) + return m +} + +func (m *memFS) key(path string) string { + return m.caseSensitivity.Canonicalize(path) +} + +func (m *memFS) ensureDir(path tspath.RootedDirectoryPath) *memDir { + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + return dir + } + dir := &memDir{path: path, files: map[string]string{}, dirs: map[string]string{}} + m.dirs[m.key(path.AsString())] = dir + if parent := path.AsPath().Directory(); parent != path { + name := path.BaseName() + m.ensureDir(parent).dirs[m.key(name)] = name + } + return dir +} + +func (m *memFS) CaseSensitivity() tspath.CaseSensitivity { + return m.caseSensitivity +} + +func (m *memFS) FileExists(path tspath.RootedFilePath) bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.files[m.key(path.AsString())] != nil +} + +func (m *memFS) DirectoryExists(path tspath.RootedDirectoryPath) bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.dirs[m.key(path.AsString())] != nil +} + +func (m *memFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + if file := m.files[m.key(path.AsString())]; file != nil { + return file.content, true + } + return "", false +} + +func (m *memFS) WriteFile(path tspath.RootedFilePath, data string) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.writeFile(path, data) +} + +func (m *memFS) AppendFile(path tspath.RootedFilePath, data string) error { + m.mu.Lock() + defer m.mu.Unlock() + if file := m.files[m.key(path.AsString())]; file != nil { + data = file.content + data + } + return m.writeFile(path, data) +} + +func (m *memFS) writeFile(path tspath.RootedFilePath, data string) error { + key := m.key(path.AsString()) + if m.dirs[key] != nil { + return &fs.PathError{Op: "write", Path: path.AsString(), Err: fs.ErrInvalid} + } + if file := m.files[key]; file != nil { + file.content = data + file.modTime = time.Now() + return nil + } + for dir := path.Directory(); m.dirs[m.key(dir.AsString())] == nil; { + if m.files[m.key(dir.AsString())] != nil { + return &fs.PathError{Op: "write", Path: path.AsString(), Err: fs.ErrInvalid} + } + parent := dir.AsPath().Directory() + if parent == dir { + break + } + dir = parent + } + name := path.BaseName() + m.ensureDir(path.Directory()).files[m.key(name)] = name + m.files[key] = &memFile{path: path, content: data, modTime: time.Now()} + return nil +} + +func (m *memFS) Remove(path tspath.RootedPath) error { + m.mu.Lock() + defer m.mu.Unlock() + key := m.key(path.AsString()) + if file := m.files[key]; file != nil { + delete(m.files, key) + m.removeFromParent(file.path.AsPath()) + } else if dir := m.dirs[key]; dir != nil { + m.removeDir(dir) + if dir.path.AsPath().Directory() != dir.path { + m.removeFromParent(dir.path.AsPath()) + } + } + return nil +} + +func (m *memFS) removeFromParent(path tspath.RootedPath) { + name := m.key(path.BaseName()) + parent := m.dirs[m.key(path.Directory().AsString())] + delete(parent.files, name) + delete(parent.dirs, name) +} + +func (m *memFS) removeDir(dir *memDir) { + for _, name := range dir.files { + delete(m.files, m.key(dir.path.ResolveFile(name).AsString())) + } + for _, name := range dir.dirs { + m.removeDir(m.dirs[m.key(dir.path.ResolveDirectory(name).AsString())]) + } + delete(m.dirs, m.key(dir.path.AsString())) +} + +func (m *memFS) Chtimes(path tspath.RootedPath, aTime time.Time, mTime time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if file := m.files[m.key(path.AsString())]; file != nil { + file.modTime = mTime + return nil + } + if m.dirs[m.key(path.AsString())] != nil { + return nil + } + return &fs.PathError{Op: "chtimes", Path: path.AsString(), Err: fs.ErrNotExist} +} + +func (m *memFS) GetAccessibleEntries(path tspath.RootedDirectoryPath) vfs.Entries { + m.mu.RLock() + defer m.mu.RUnlock() + entries := vfs.Entries{Symlinks: map[string]struct{}{}} + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + entries.Files = slices.Sorted(maps.Values(dir.files)) + entries.Directories = slices.Sorted(maps.Values(dir.dirs)) + } + return entries +} + +func (m *memFS) Stat(path tspath.RootedPath) vfs.FileInfo { + m.mu.RLock() + defer m.mu.RUnlock() + if file := m.files[m.key(path.AsString())]; file != nil { + return &memFileInfo{name: file.path.BaseName(), size: int64(len(file.content)), modTime: file.modTime} + } + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + return &memFileInfo{name: dir.path.BaseName(), mode: fs.ModeDir} + } + return nil +} + +func (m *memFS) Realpath(path tspath.RootedPath) tspath.RootedPath { + m.mu.RLock() + defer m.mu.RUnlock() + if file := m.files[m.key(path.AsString())]; file != nil { + return file.path.AsPath() + } + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + return dir.path.AsPath() + } + return path +} + +type memFileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time +} + +func (i *memFileInfo) Name() string { return i.name } +func (i *memFileInfo) Size() int64 { return i.size } +func (i *memFileInfo) Mode() fs.FileMode { return i.mode } +func (i *memFileInfo) ModTime() time.Time { return i.modTime } +func (i *memFileInfo) IsDir() bool { return i.mode.IsDir() } +func (i *memFileInfo) Sys() any { return nil } diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go new file mode 100644 index 0000000000000..c161e1c0bf07a --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -0,0 +1,214 @@ +// Package wasmreactor runs an API server inside the current process and +// exchanges its protocol messages through direct calls instead of a pipe. +package wasmreactor + +import ( + "context" + "errors" + "io" + "sync" + + "github.com/microsoft/TypeScript/tsc/internal/api" + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +type Options struct { + Cwd string `json:"cwd"` + UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` + CollectTiming bool `json:"collectTiming"` +} + +// Message is a protocol tuple exchanged with the server. +type Message struct { + Type api.MessageType + Method string + Payload []byte +} + +// ErrIdle is returned by Receive when the server is waiting for a message +// instead of producing one. +var ErrIdle = errors.New("the server is waiting for a message") + +// Reactor hosts an API server whose base file system is in memory. The +// server runs on its own goroutine and is driven by Send and Receive, so the +// client can serve callbacks and make nested requests exactly as it would +// over a pipe, even on single-threaded hosts where blocking with no runnable +// goroutine would be fatal. +type Reactor struct { + ctx context.Context + cancel context.CancelFunc + options Options + files *memFS + cwd tspath.RootedDirectoryPath + + mu sync.Mutex + inbox chan Message + outbox chan Message + idle chan struct{} + closed chan struct{} + started bool + done chan struct{} + err error +} + +func New(ctx context.Context, options Options) *Reactor { + if options.Cwd == "" { + options.Cwd = "/" + } + caseSensitivity := tspath.CaseSensitive + if options.UseCaseSensitiveFileNames != nil && !*options.UseCaseSensitiveFileNames { + caseSensitivity = tspath.CaseInsensitive + } + ctx, cancel := context.WithCancel(ctx) + return &Reactor{ + ctx: ctx, + cancel: cancel, + options: options, + files: newMemFS(caseSensitivity), + cwd: tspath.RootedDirectoryPathFromAbsolute(options.Cwd), + inbox: make(chan Message, 1), + outbox: make(chan Message), + idle: make(chan struct{}, 1), + closed: make(chan struct{}), + done: make(chan struct{}), + } +} + +// Start runs the server, delegating the named file system operations to the +// client. It must be called once, before the first Send. +func (r *Reactor) Start(callbacks []string) error { + if r.started { + return errors.New("the server has already started") + } + r.started = true + server := api.NewStdioServer(&api.StdioServerOptions{ + Cwd: r.cwd, + DefaultLibraryPath: bundled.LibPath(), + Callbacks: callbacks, + UseCaseSensitiveFileNames: r.options.UseCaseSensitiveFileNames, + CollectTiming: r.options.CollectTiming, + FS: r.files, + Tuples: (*tupleTransport)(r), + }) + go func() { + defer close(r.done) + r.err = server.Run(r.ctx) + }() + return nil +} + +// Started reports whether Start has been called. +func (r *Reactor) Started() bool { + return r.started +} + +// Send delivers a message to the server without running it. +func (r *Reactor) Send(message Message) error { + if !r.started { + return errors.New("the server has not started") + } + r.mu.Lock() + defer r.mu.Unlock() + select { + case <-r.idle: + // The server is about to receive this message, so it is no longer idle. + default: + // The server has not reported being idle. + } + select { + case r.inbox <- message: + return nil + default: + return errors.New("a message is already waiting for the server") + } +} + +// Receive runs the server until it produces its next message. It returns +// ErrIdle if the server needs a message first, or io.EOF with the server's +// result once it has stopped. +func (r *Reactor) Receive() (Message, error) { + if !r.started { + return Message{}, errors.New("the server has not started") + } + select { + case message := <-r.outbox: + return message, nil + default: + // Run the server until it writes, waits, or stops. + } + select { + case message := <-r.outbox: + return message, nil + case <-r.idle: + return Message{}, ErrIdle + case <-r.done: + if r.err != nil { + return Message{}, r.err + } + return Message{}, io.EOF + } +} + +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)) +} + +// Close stops the server and waits for it to exit. +func (r *Reactor) Close() { + select { + case <-r.closed: + return + default: + // Not yet closed. + } + r.cancel() + close(r.closed) + if r.started { + <-r.done + } +} + +// tupleTransport is the server's end of the reactor's message channels. +type tupleTransport Reactor + +func (t *tupleTransport) ReadTuple() (api.MessageType, string, []byte, error) { + t.mu.Lock() + select { + case message := <-t.inbox: + t.mu.Unlock() + return message.Type, message.Method, message.Payload, nil + default: + // No message is waiting, so the server is idle. + } + select { + case t.idle <- struct{}{}: + // Receive may now report that the server is waiting. + default: + // An idle signal is already pending. + } + t.mu.Unlock() + select { + case message := <-t.inbox: + return message.Type, message.Method, message.Payload, nil + case <-t.closed: + return 0, "", nil, io.EOF + } +} + +func (t *tupleTransport) WriteTuple(msgType api.MessageType, method string, payload []byte) error { + select { + case t.outbox <- Message{Type: msgType, Method: method, Payload: payload}: + return nil + case <-t.closed: + return io.ErrClosedPipe + } +} diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go new file mode 100644 index 0000000000000..0d06abf977988 --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -0,0 +1,90 @@ +package wasmreactor + +import ( + "context" + "io" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/api" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestReactor(t *testing.T) { + t.Parallel() + + reactor := New(context.Background(), Options{Cwd: "/"}) + defer reactor.Close() + + assert.NilError(t, reactor.SetFile("/src/index.ts", "export const value = 1;")) + contents, ok := reactor.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "export const value = 1;") + _, ok = reactor.ReadFile("/src/missing.ts") + assert.Assert(t, !ok) + + assert.Assert(t, reactor.Send(Message{Type: api.MessageTypeRequest, Method: "echo"}) != nil) + assert.NilError(t, reactor.Start(nil)) + assert.Assert(t, reactor.Start(nil) != nil) + + request := func(method string, payload []byte) Message { + t.Helper() + assert.NilError(t, reactor.Send(Message{Type: api.MessageTypeRequest, Method: method, Payload: payload})) + response, err := reactor.Receive() + assert.NilError(t, err) + assert.Equal(t, response.Type, api.MessageTypeResponse) + assert.Equal(t, response.Method, method) + return response + } + + var initializeResponse struct { + CurrentDirectory string `json:"currentDirectory"` + } + assert.NilError(t, json.Unmarshal(request("initialize", nil).Payload, &initializeResponse)) + assert.Equal(t, initializeResponse.CurrentDirectory, "/") + assert.DeepEqual(t, request("echo", []byte("reactor")).Payload, []byte("reactor")) + + _, err := reactor.Receive() + assert.Equal(t, err, ErrIdle) + + assert.NilError(t, reactor.RemoveFile("/src/index.ts")) + _, ok = reactor.ReadFile("/src/index.ts") + assert.Assert(t, !ok) + + reactor.Close() + _, err = reactor.Receive() + assert.Equal(t, err, io.EOF) +} + +func TestMemFS(t *testing.T) { + t.Parallel() + + m := newMemFS(tspath.CaseInsensitive) + assert.NilError(t, m.WriteFile("/Src/Index.ts", "a")) + assert.NilError(t, m.AppendFile("/src/index.ts", "b")) + assert.NilError(t, m.WriteFile("/src/lib/util.ts", "c")) + + contents, ok := m.ReadFile("/SRC/INDEX.TS") + assert.Assert(t, ok) + assert.Equal(t, contents, "ab") + assert.Equal(t, m.Realpath("/src/index.ts"), tspath.RootedPath("/Src/Index.ts")) + assert.DeepEqual(t, m.GetAccessibleEntries("/src").Files, []string{"Index.ts"}) + assert.DeepEqual(t, m.GetAccessibleEntries("/src").Directories, []string{"lib"}) + assert.Assert(t, m.Stat("/src/lib").IsDir()) + + assert.NilError(t, m.Remove("/src/lib")) + assert.Assert(t, !m.FileExists("/src/lib/util.ts")) + assert.Assert(t, !m.DirectoryExists("/src/lib")) + assert.Assert(t, m.GetAccessibleEntries("/src").Directories == nil) + assert.Assert(t, m.DirectoryExists("/")) + + assert.Assert(t, m.WriteFile("/src/index.ts/nested/out.js", "d") != nil) + assert.Assert(t, !m.DirectoryExists("/src/index.ts")) + + assert.NilError(t, m.WriteFile("C:/src/index.ts", "e")) + assert.Assert(t, m.DirectoryExists("C:/")) + assert.NilError(t, m.Remove("/")) + assert.NilError(t, m.WriteFile("/src/index.ts", "f")) + assert.Assert(t, m.DirectoryExists("/")) +} diff --git a/tsc/internal/vfs/osvfs/os.go b/tsc/internal/vfs/osvfs/os.go index 6ad2eb1837f29..9f3f954be6989 100644 --- a/tsc/internal/vfs/osvfs/os.go +++ b/tsc/internal/vfs/osvfs/os.go @@ -158,7 +158,7 @@ func osFSRealpath(path tspath.RootedPath) tspath.RootedPath { if nativepath.IsNetworkPath(nativePath) { return orig } - return tspath.RootedPathFromAbsolute(nativePath) + return tspath.RootedPathFromAbsolute(fromOSPath(nativePath, orig.AsString())) } func isReparsePoint(path string) bool { diff --git a/tsc/internal/vfs/osvfs/ospath.go b/tsc/internal/vfs/osvfs/ospath.go new file mode 100644 index 0000000000000..2934b7dc8b17a --- /dev/null +++ b/tsc/internal/vfs/osvfs/ospath.go @@ -0,0 +1,7 @@ +//go:build !wasip1 + +package osvfs + +func fromOSPath(path string, original string) string { + return path +} diff --git a/tsc/internal/vfs/osvfs/ospath_wasip1.go b/tsc/internal/vfs/osvfs/ospath_wasip1.go new file mode 100644 index 0000000000000..d5aae77bfe2dc --- /dev/null +++ b/tsc/internal/vfs/osvfs/ospath_wasip1.go @@ -0,0 +1,50 @@ +//go:build wasip1 + +package osvfs + +import ( + "os" + "strings" +) + +// Dynamic URI paths (^/...) and drive-rooted paths are relative in WASI, so +// they resolve against the working directory. The browser host mounts +// workspaces there for them. +var relativeRoot = func() string { + pwd := os.Getenv("PWD") + if !strings.HasSuffix(pwd, "/") { + pwd += "/" + } + return pwd +}() + +// fromOSPath converts a path the OS resolved from original back to a +// compiler path. +func fromOSPath(path string, original string) string { + if !isRelativeInWASI(original) { + return path + } + rel, ok := strings.CutPrefix(path, relativeRoot) + if !ok { + return path + } + if len(rel) == 2 && isDriveLetter(rel[0]) && rel[1] == ':' { + rel += "/" + } + if !isRelativeInWASI(rel) { + return path + } + return rel +} + +func isRelativeInWASI(path string) bool { + return strings.HasPrefix(path, "^/") || isDriveRooted(path) +} + +func isDriveRooted(path string) bool { + return len(path) >= 3 && isDriveLetter(path[0]) && path[1] == ':' && (path[2] == '/' || path[2] == '\\') +} + +func isDriveLetter(char byte) bool { + return 'a' <= char && char <= 'z' || 'A' <= char && char <= 'Z' +}