diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a864147929cf..96faa11126278 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,10 @@ jobs: - run: npx hereby test:extension - run: npx hereby test:tools - run: npx hereby test:api + - run: npx playwright install --with-deps chromium + if: ${{ matrix.config.main }} + - run: npx hereby test:api:browser + if: ${{ matrix.config.main }} - run: git add . - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 if: ${{ always() && matrix.config.coverage && github.event_name != 'merge_group' }} diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 381b13808b5e4..bdfaeb3ddfd67 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"; @@ -1214,7 +1215,15 @@ async function runTestTools() { async function runTestAPI() { // Running the package script doesn't work on Windows; some path escaping isn't done correctly and the test runner runs no tests. - await run("node", ["--conditions", "@typescript/source", "--test", "./test/**/*.test.ts"], { cwd: "./packages/typescript" }); + await run( + "node", + ["--conditions", "@typescript/source", "--test", "./test/*.test.ts", "./test/async/**/*.test.ts", "./test/sync/**/*.test.ts"], + { cwd: "./packages/typescript" }, + ); +} + +async function runTestAPIBrowser() { + await run("node", ["--conditions", "@typescript/source", "--test", "./test/browser/**/*.test.ts"], { cwd: "./packages/typescript" }); } export const testTools = task({ @@ -1237,10 +1246,78 @@ export const buildAPI = task({ }, }); +/** + * @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(); + } +} + +export const buildWasi = task({ + name: "build:wasip1", + description: "Builds the @typescript/typescript-wasip1-wasm package.", + dependencies: [lib], + run: async () => { + await run("npm", ["run", "-w", "@typescript/typescript-wasip1-wasm", "build:js"]); + await buildWasiFile("./packages/typescript-wasip1-wasm/dist/tsc.wasm"); + }, +}); + export const buildAPITests = task({ name: "build:api:test", description: "Builds the @typescript/typescript JS API tests.", - dependencies: [generateEnums, generateAPI], + dependencies: [generateEnums, generateAPI, buildWasi], run: async () => { await run("npm", ["run", "-w", "@typescript/typescript", "build:test"]); }, @@ -1253,6 +1330,13 @@ export const testAPI = task({ run: runTestAPI, }); +export const testAPIBrowser = task({ + name: "test:api:browser", + description: "Runs the @typescript/typescript browser API tests.", + dependencies: [buildAPITests], + run: runTestAPIBrowser, +}); + export const testAll = task({ name: "test:all", description: "Runs ALL tests in the repo, including benchmarks, tools, and the API tests.", @@ -1264,6 +1348,7 @@ export const testAll = task({ await runTestBenchmarks(); await runTestTools(); await runTestAPI(); + await runTestAPIBrowser(); }, }); @@ -1934,6 +2019,12 @@ const mainNativePreviewPackage = { npmTarball: path.join(builtNpm, publishAsTypescript ? "typescript.tgz" : "native-preview.tgz"), }; +const wasip1Package = { + npmPackageName: "@typescript/typescript-wasip1-wasm", + npmDir: path.join(builtNpm, "typescript-wasip1-wasm"), + npmTarball: path.join(builtNpm, "typescript-wasip1-wasm.tgz"), +}; + const typescriptMacEntitlements = [ "com.apple.security.cs.allow-dyld-environment-variables", "com.apple.security.cs.disable-library-validation", @@ -2289,6 +2380,13 @@ async function runBuildNativePreviewPackages() { const platforms = getPlatforms(); const inputDir = "./packages/typescript"; + await Promise.all([ + fs.promises.rm(path.join(inputDir, "dist", "tsc.wasm"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.js"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.js.map"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.d.ts"), { force: true }), + fs.promises.rm(path.join(inputDir, "dist", "api", "wasm.d.ts.map"), { force: true }), + ]); const inputPackageJson = JSON.parse(fs.readFileSync(path.join(inputDir, "package.json"), "utf8")); inputPackageJson.version = getVersion(); @@ -2349,7 +2447,7 @@ async function runBuildNativePreviewPackages() { await fs.promises.copyFile("LICENSE.txt", path.join(mainPackageDir, "LICENSE")); await fs.promises.copyFile("NOTICE.txt", path.join(mainPackageDir, "NOTICE.txt")); - // Build JS API and copy dist into the package. + // Build the JS API and copy dist into the main package. await run("npm", ["run", "-w", "@typescript/typescript", "build"]); await cpRecursive(path.join(inputDir, "dist"), path.join(mainPackageDir, "dist")); @@ -2377,7 +2475,33 @@ async function runBuildNativePreviewPackages() { throw new Error(`Found external imports in .d.ts files:\n${importErrors.map(e => " " + e).join("\n")}`); } + const wasmInputDir = "./packages/typescript-wasip1-wasm"; const extraFlags = getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined); + const wasmPackageJson = JSON.parse(fs.readFileSync(path.join(wasmInputDir, "package.json"), "utf8")); + wasmPackageJson.version = getVersion(); + wasmPackageJson.gitHead = gitHead; + wasmPackageJson.peerDependencies = { + [mainNativePreviewPackage.npmPackageName]: getVersion(), + }; + wasmPackageJson.publishConfig = { + access: "public", + tag: getPublishTag(), + }; + wasmPackageJson.files = [...new Set([...(wasmPackageJson.files ?? []), "NOTICE.txt"])]; + delete wasmPackageJson.private; + delete wasmPackageJson.scripts; + stripSourceConditions(wasmPackageJson); + + await run("npm", ["run", "-w", "@typescript/typescript-wasip1-wasm", "build:js"]); + await buildWasiFile(path.join(wasmInputDir, "dist", "tsc.wasm"), extraFlags); + await cpRecursive(wasmInputDir, wasip1Package.npmDir, p => !p.endsWith("/node_modules") && !p.includes("/dist")); + await cpRecursive(path.join(wasmInputDir, "dist"), path.join(wasip1Package.npmDir, "dist")); + await fs.promises.writeFile( + path.join(wasip1Package.npmDir, "package.json"), + JSON.stringify(wasmPackageJson, undefined, 4), + ); + await fs.promises.copyFile("LICENSE.txt", path.join(wasip1Package.npmDir, "LICENSE")); + await fs.promises.copyFile("NOTICE.txt", path.join(wasip1Package.npmDir, "NOTICE.txt")); const platformBuilders = platforms.map(({ npmDir, npmPackageName, nodeOs, nodeArch, goos, goarch }) => async () => { const packageJson = { @@ -2587,14 +2711,14 @@ async function runPackNativePreviewPackages() { } const platforms = getPlatforms(); - await Promise.all([mainNativePreviewPackage, ...platforms].map(async ({ npmDir, npmTarball }) => { + await Promise.all([mainNativePreviewPackage, wasip1Package, ...platforms].map(async ({ npmDir, npmTarball }) => { const { stdout } = await runOutput("npm", ["pack", "--json", npmDir]); const filename = JSON.parse(stdout)[0].filename.replace("@", "").replace("/", "-"); await fs.promises.rename(filename, npmTarball); })); - // npm packages need to be published in dependency order: platform packages - // first, then the main package that references them as optionalDependencies. + // Publish in dependency order: platform packages, the main package that references + // them as optionalDependencies, then the WASI package with its exact main-package peer. const publishManifest = { stages: [ platforms.map(p => ({ @@ -2605,6 +2729,11 @@ async function runPackNativePreviewPackages() { filename: path.basename(mainNativePreviewPackage.npmTarball), }, ], + [ + { + filename: path.basename(wasip1Package.npmTarball), + }, + ], ], }; diff --git a/package-lock.json b/package-lock.json index 6bfed937d972a..b9868ca2b55a4 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.0", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.56.1", "dprint-plugin-yaml": "^0.6.0", @@ -1867,6 +1868,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", @@ -2220,6 +2225,20 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2237,6 +2256,22 @@ "dev": true, "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -2279,6 +2314,24 @@ "license": "MIT", "optional": true }, + "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", @@ -2406,6 +2459,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2683,6 +2755,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -2696,6 +2786,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3077,6 +3185,22 @@ "node": ">=8" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -3117,6 +3241,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3127,6 +3266,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3269,6 +3418,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3481,8 +3643,7 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -3492,6 +3653,36 @@ "license": "ISC", "optional": true }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -3528,6 +3719,26 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3560,6 +3771,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3570,6 +3798,41 @@ "node": ">=0.12.0" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4135,6 +4398,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4326,6 +4637,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -4336,6 +4679,16 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -4365,6 +4718,16 @@ "node": ">=10" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4610,6 +4973,24 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4661,6 +5042,24 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -5288,6 +5687,20 @@ "dev": true, "license": "MIT" }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5406,6 +5819,28 @@ "node": ">=18" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5496,6 +5931,9 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "assert": "^2.1.0", + "playwright": "^1.62.1", + "process": "^0.11.10", "tinybench": "^6.1.3", "vscode-jsonrpc": "^9.0.1" }, @@ -5503,6 +5941,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": "0.0.0", diff --git a/package.json b/package.json index d391ed5032502..14901d739de92 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.0", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.56.1", "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..bbabf5cf34d95 --- /dev/null +++ b/packages/typescript-wasip1-wasm/README.md @@ -0,0 +1,35 @@ +# `@typescript/typescript-wasip1-wasm` + +The TypeScript compiler and language server for `wasip1/wasm`, with helpers for +using the TypeScript API in JavaScript hosts. + +The package contains one `tsc.wasm` module. Run it as a standard WASI command +for the compiler, `--lsp --stdio`, or `--api`: + +```sh +wasmtime run --dir=.::/ node_modules/@typescript/typescript-wasip1-wasm/dist/tsc.wasm --version +``` + +Installing this package alongside `typescript` also lets the `tsc` JavaScript +launcher use the WASI module when a native platform package is unavailable. +That fallback requires Node.js 23 or newer. + +The same module is a reactor for in-process API use: + +```ts +import { API } from "typescript/unstable/async"; +import { + instantiateWasm, + WasmTransport, + wasmURL, +} from "@typescript/typescript-wasip1-wasm"; + +const response = await fetch(wasmURL); +const module = await WebAssembly.compileStreaming(response); +const instance = await instantiateWasm(module); + +const transport = new WasmTransport({ instance }); +const api = new API({ transport }); +``` + +Use `typescript/unstable/sync` instead for the synchronous API. diff --git a/packages/typescript-wasip1-wasm/package.json b/packages/typescript-wasip1-wasm/package.json new file mode 100644 index 0000000000000..ed13c096a3c26 --- /dev/null +++ b/packages/typescript-wasip1-wasm/package.json @@ -0,0 +1,31 @@ +{ + "private": true, + "name": "@typescript/typescript-wasip1-wasm", + "version": "0.0.0", + "license": "Apache-2.0", + "author": "Microsoft Corp.", + "homepage": "https://www.typescriptlang.org/", + "description": "TypeScript compiler, language server, and API transport for wasip1/wasm", + "type": "module", + "files": [ + "dist" + ], + "exports": { + "./package.json": "./package.json", + ".": { + "@typescript/source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./tsc.wasm": "./dist/tsc.wasm" + }, + "imports": { + "#wasmURL": { + "@typescript/source": "./src/wasmURL.source.ts", + "default": "./dist/wasmURL.js" + } + }, + "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..7d9b7d699aa52 --- /dev/null +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -0,0 +1,213 @@ +export { wasmURL } from "#wasmURL"; +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import { + setWasmFileSystem, + type WasmFileSystem, +} from "./wasi.ts"; + +export interface WasmReactorExports { + memory: { readonly buffer: ArrayBufferLike; }; + create_session(optionsPointer: number, optionsLength: number): number; + close_session(): void; + get_request_buffer(size: number): number; + handle_request(methodLength: number, payloadLength: number): number; + set_file(pathLength: number, contentLength: number): number; + read_file(pathLength: number): number; + remove_file(pathLength: number): number; + response_ptr(): number; + response_len(): number; +} + +export interface WasmReactorInstance { + readonly exports: object; +} + +export interface WasmTransportOptions { + /** + * An instantiated reactor whose WASI host has already initialized it. + * For example, call `wasi.initialize(instance)` before constructing the transport. + */ + instance: WasmReactorInstance; + cwd?: string; + useCaseSensitiveFileNames?: boolean; + collectTiming?: boolean; + fs?: WasmFileSystem; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. */ +export class WasmTransport { + lastBytesSent = 0; + lastBytesReceived = 0; + + private readonly exports: WasmReactorExports; + private readonly instance: WasmReactorInstance; + private requestPointer = 0; + private closed = false; + + constructor(options: WasmTransportOptions) { + this.instance = options.instance; + this.exports = getReactorExports(options.instance); + const sessionOptions = encoder.encode(JSON.stringify({ + cwd: options.cwd ?? "/", + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, + collectTiming: options.collectTiming, + })); + this.writeRequest(sessionOptions); + if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { + throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); + } + try { + setWasmFileSystem(options.instance, options.fs); + } + catch (error) { + try { + this.exports.close_session(); + } + catch (closeError) { + throw new AggregateError( + [error, closeError], + "Failed to configure the TypeScript WASM filesystem and close its session", + ); + } + throw error; + } + } + + setFileSystem(fs: WasmFileSystem | undefined): void { + this.ensureOpen(); + setWasmFileSystem(this.instance, fs); + } + + requestSync(method: string, payload: string): string { + return decoder.decode(this.call(method, encoder.encode(payload))); + } + + request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } { + const value = this.requestSync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + requestBinarySync(method: string, payload: Uint8Array): Uint8Array { + return this.call(method, payload); + } + + requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { + const value = this.requestBinarySync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + setFile(path: string, content: string): void { + this.ensureOpen(); + const pathBytes = encoder.encode(path); + const contentBytes = encoder.encode(content); + this.writeRequest(pathBytes, contentBytes); + if (this.exports.set_file(pathBytes.length, contentBytes.length) !== 0) { + throw new Error(`Failed to write ${path}: ${this.readResponseText()}`); + } + } + + /** Read a file from the reactor's in-memory filesystem. */ + readFile(path: string): string | undefined { + this.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 this.readResponseText(); + } + + 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 { + setWasmFileSystem(this.instance, undefined); + } + } + + private call(method: string, payload: Uint8Array): Uint8Array { + this.ensureOpen(); + const methodBytes = encoder.encode(method); + this.writeRequest(methodBytes, payload); + this.lastBytesSent = payload.length; + if (this.exports.handle_request(methodBytes.length, payload.length) !== 0) { + throw new Error(`TypeScript WASM request "${method}" failed: ${this.readResponseText()}`); + } + this.lastBytesReceived = this.exports.response_len(); + return this.readResponseBytes(); + } + + private writeRequest(first: Uint8Array, second?: Uint8Array): void { + const total = first.length + (second?.length ?? 0); + this.requestPointer = this.exports.get_request_buffer(total) >>> 0; + const memory = new Uint8Array(this.exports.memory.buffer); + memory.set(first, this.requestPointer); + if (second) { + memory.set(second, this.requestPointer + first.length); + } + } + + private readResponseText(): string { + return decoder.decode(this.readResponseBytes()); + } + + private readResponseBytes(): Uint8Array { + const length = this.exports.response_len(); + if (length === 0) return new Uint8Array(); + return new Uint8Array(this.exports.memory.buffer, this.exports.response_ptr() >>> 0, length).slice(); + } + + private ensureOpen(): void { + if (this.closed) { + throw new Error("The TypeScript WASM transport is closed"); + } + } +} + +function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { + const exports = instance.exports as unknown as Partial; + const required = [ + "create_session", + "close_session", + "get_request_buffer", + "handle_request", + "set_file", + "read_file", + "remove_file", + "response_ptr", + "response_len", + ] as const; + const missing: string[] = required.filter(name => typeof exports[name] !== "function"); + if (exports.memory == null || typeof exports.memory !== "object" || !("buffer" in exports.memory)) { + missing.push("memory"); + } + if (missing.length > 0) { + throw new Error(`Invalid TypeScript WASM reactor: missing ${missing.join(", ")}`); + } + return exports as WasmReactorExports; +} diff --git a/packages/typescript-wasip1-wasm/src/wasi.ts b/packages/typescript-wasip1-wasm/src/wasi.ts new file mode 100644 index 0000000000000..4b33f75737a91 --- /dev/null +++ b/packages/typescript-wasip1-wasm/src/wasi.ts @@ -0,0 +1,303 @@ +import type { WasmReactorInstance } from "./index.ts"; + +const errnoSuccess = 0; +const errnoBadFileDescriptor = 8; +const errnoInvalidArgument = 28; +const errnoIo = 29; +const errnoNoSys = 52; +const fileTypeCharacterDevice = 2; +const eventTypeClock = 0; +const subscriptionClockAbstime = 1; +const hostWriteFileFD = 0x7fff_fffe; + +export interface InstantiateWasmOptions { + stdout?: (text: string) => void; + stderr?: (text: string) => void; +} + +export interface WasmFileSystem { + writeFile?(path: string, data: string): void; +} + +interface WasmHost { + setFileSystem(fs: WasmFileSystem | undefined): void; +} + +const wasmHosts = new WeakMap(); + +export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { + const host = wasmHosts.get(instance); + if (!host) { + if (fs === undefined) return; + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.setFileSystem(fs); +} + +/** 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. */ +export function instantiateWasmSync( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): WasmReactorInstance { + const host = createWasiHost(options); + const instance = new WebAssembly.Instance(module, host.imports); + return host.initialize(instance); +} + +function createWasiHost(options: InstantiateWasmOptions): { + imports: WebAssembly.Imports; + initialize(instance: WebAssembly.Instance): WasmReactorInstance; +} { + let instance: WebAssembly.Instance | undefined; + const stdout = options.stdout ?? (text => console.log(text)); + const stderr = options.stderr ?? (text => console.error(text)); + const decoders = new Map(); + const encoder = new TextEncoder(); + let fileSystem: WasmFileSystem | undefined; + + function getMemory(): WebAssembly.Memory { + const memory = instance?.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error("TypeScript WASM reactor did not export its memory"); + } + return memory; + } + + function getView(): DataView { + return new DataView(getMemory().buffer); + } + + function argsSizesGet(countPointer: number, sizePointer: number): number { + countPointer >>>= 0; + sizePointer >>>= 0; + const view = getView(); + view.setUint32(countPointer, 0, true); + view.setUint32(sizePointer, 0, true); + return errnoSuccess; + } + + function clockTimeGet(clockId: number, _precision: bigint, timePointer: number): number { + timePointer >>>= 0; + let nanoseconds: bigint; + switch (clockId) { + case 0: + nanoseconds = BigInt(Date.now()) * 1_000_000n; + break; + case 1: + nanoseconds = BigInt(Math.round(performance.now() * 1e6)); + break; + default: + return errnoInvalidArgument; + } + getView().setBigUint64(timePointer, nanoseconds, true); + return errnoSuccess; + } + + function fdFdstatGet(fd: number, statPointer: number): number { + statPointer >>>= 0; + if (fd < 0 || fd > 2) 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 fd >= 0 && fd <= 2 ? errnoSuccess : errnoBadFileDescriptor; + } + + function fdRead(fd: number, _iovsPointer: number, _iovsLength: number, readPointer: number): number { + readPointer >>>= 0; + if (fd !== 0) return errnoBadFileDescriptor; + getView().setUint32(readPointer, 0, true); + return errnoSuccess; + } + + function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { + iovsPointer >>>= 0; + writtenPointer >>>= 0; + if (fd === hostWriteFileFD) { + return hostWriteFile(iovsPointer, iovsLength, writtenPointer); + } + if (fd !== 1 && fd !== 2) return errnoBadFileDescriptor; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const chunks: Uint8Array[] = []; + let length = 0; + for (let i = 0; i < iovsLength; i++) { + const iovPointer = iovsPointer + i * 8; + const chunkPointer = view.getUint32(iovPointer, true); + const chunkLength = view.getUint32(iovPointer + 4, true); + chunks.push(new Uint8Array(memory.buffer, chunkPointer, chunkLength)); + length += chunkLength; + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + view.setUint32(writtenPointer, length, true); + const decoder = decoders.get(fd) ?? new TextDecoder(); + decoders.set(fd, decoder); + (fd === 1 ? stdout : stderr)(decoder.decode(bytes, { stream: true })); + return errnoSuccess; + } + + function randomGet(bufferPointer: number, bufferLength: number): number { + bufferPointer >>>= 0; + const buffer = new Uint8Array(getMemory().buffer, bufferPointer, bufferLength); + for (let offset = 0; offset < buffer.length; offset += 65_536) { + crypto.getRandomValues(buffer.subarray(offset, Math.min(offset + 65_536, buffer.length))); + } + return errnoSuccess; + } + + function hostWriteFile(iovsPointer: number, iovsLength: number, writtenPointer: number): number { + iovsPointer >>>= 0; + writtenPointer >>>= 0; + if (!fileSystem?.writeFile) return errnoBadFileDescriptor; + if (iovsLength !== 1) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const bufferPointer = view.getUint32(iovsPointer, true); + const bufferLength = view.getUint32(iovsPointer + 4, true); + if (bufferLength < 12) return errnoInvalidArgument; + const pathLength = view.getUint32(bufferPointer, true); + const dataLength = view.getUint32(bufferPointer + 4, true); + const errorCapacity = view.getUint32(bufferPointer + 8, true); + if (12 + pathLength + dataLength + errorCapacity !== bufferLength) return errnoInvalidArgument; + const decoder = new TextDecoder(); + const pathPointer = bufferPointer + 12; + const dataPointer = pathPointer + pathLength; + const errorPointer = dataPointer + dataLength; + const path = decoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); + const data = decoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); + try { + fileSystem.writeFile(path, data); + view.setUint32(writtenPointer, bufferLength, true); + return errnoSuccess; + } + catch (error) { + const errorBytes = encoder.encode(error instanceof Error ? error.message : String(error)); + const errorLength = Math.min(errorBytes.length, errorCapacity); + new Uint8Array(memory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); + view.setUint32(bufferPointer + 8, errorLength, true); + view.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + function pollOneoff( + subscriptionsPointer: number, + eventsPointer: number, + subscriptionsLength: number, + eventsLengthPointer: number, + ): number { + subscriptionsPointer >>>= 0; + eventsPointer >>>= 0; + eventsLengthPointer >>>= 0; + if (subscriptionsLength === 0) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + let selected = -1; + let shortestDelay = Number.POSITIVE_INFINITY; + for (let i = 0; i < subscriptionsLength; i++) { + const subscription = subscriptionsPointer + i * 48; + if (view.getUint8(subscription + 8) !== eventTypeClock) continue; + const clockId = view.getUint32(subscription + 16, true); + if (clockId !== 0 && clockId !== 1) return errnoInvalidArgument; + const timeout = view.getBigUint64(subscription + 24, true); + const flags = view.getUint16(subscription + 40, true); + const now = clockId === 0 ? Date.now() : performance.now(); + const delay = flags & subscriptionClockAbstime + ? Number(timeout) / 1e6 - now + : Number(timeout) / 1e6; + if (delay < shortestDelay) { + selected = subscription; + shortestDelay = delay; + } + } + if (selected < 0) return errnoNoSys; + + const deadline = performance.now() + Math.max(0, shortestDelay); + while (performance.now() < deadline) { + // WASI imports are synchronous, so a clock subscription must wait here. + } + + new Uint8Array(memory.buffer, eventsPointer, 32).fill(0); + view.setBigUint64(eventsPointer, view.getBigUint64(selected, true), true); + view.setUint8(eventsPointer + 10, eventTypeClock); + view.setUint32(eventsLengthPointer, 1, true); + return errnoSuccess; + } + + function unsupported(): number { + return errnoNoSys; + } + + const wasi = { + args_get: () => errnoSuccess, + args_sizes_get: argsSizesGet, + clock_time_get: clockTimeGet, + environ_get: () => errnoSuccess, + environ_sizes_get: argsSizesGet, + fd_close: (fd: number) => fd >= 0 && fd <= 2 ? errnoSuccess : errnoBadFileDescriptor, + fd_fdstat_get: fdFdstatGet, + fd_fdstat_set_flags: fdFdstatSetFlags, + fd_filestat_get: unsupported, + fd_pread: unsupported, + fd_prestat_dir_name: unsupported, + fd_prestat_get: () => errnoBadFileDescriptor, + fd_read: fdRead, + fd_readdir: unsupported, + fd_write: fdWrite, + path_create_directory: unsupported, + path_filestat_get: unsupported, + path_filestat_set_times: unsupported, + path_open: unsupported, + path_readlink: unsupported, + path_remove_directory: unsupported, + path_unlink_file: unsupported, + poll_oneoff: pollOneoff, + proc_exit: (code: number) => { + throw new Error(`TypeScript WASM runtime exited with code ${code}`); + }, + random_get: randomGet, + sched_yield: () => errnoSuccess, + sock_accept: unsupported, + }; + + return { + imports: { + wasi_snapshot_preview1: wasi, + }, + initialize(value) { + instance = value; + const initialize = instance.exports.typescript_initialize; + if (typeof initialize !== "function") { + throw new Error("TypeScript WASM reactor did not export typescript_initialize"); + } + initialize(); + wasmHosts.set(instance, { + setFileSystem(value) { + fileSystem = value; + }, + }); + return instance; + }, + }; +} diff --git a/packages/typescript-wasip1-wasm/src/wasmURL.source.ts b/packages/typescript-wasip1-wasm/src/wasmURL.source.ts new file mode 100644 index 0000000000000..0141e3edf005d --- /dev/null +++ b/packages/typescript-wasip1-wasm/src/wasmURL.source.ts @@ -0,0 +1,2 @@ +/** URL of the locally built TypeScript WASI module when using source conditions. */ +export const wasmURL: URL = new URL("../dist/tsc.wasm", import.meta.url); diff --git a/packages/typescript-wasip1-wasm/src/wasmURL.ts b/packages/typescript-wasip1-wasm/src/wasmURL.ts new file mode 100644 index 0000000000000..3862eebe4773a --- /dev/null +++ b/packages/typescript-wasip1-wasm/src/wasmURL.ts @@ -0,0 +1,2 @@ +/** URL of the TypeScript WASI module distributed with this package. */ +export const wasmURL: URL = new URL("./tsc.wasm", import.meta.url); diff --git a/packages/typescript-wasip1-wasm/tsconfig.json b/packages/typescript-wasip1-wasm/tsconfig.json new file mode 100644 index 0000000000000..65c131d98313e --- /dev/null +++ b/packages/typescript-wasip1-wasm/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": [ + "es2022", + "dom" + ], + "strict": true, + "exactOptionalPropertyTypes": true, + "composite": true, + "forceConsistentCasingInFileNames": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + "module": "node16", + "isolatedDeclarations": true, + "sourceMap": true, + "declaration": true, + "declarationMap": true, + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src" + ] +} diff --git a/packages/typescript/lib/getExePath.d.ts b/packages/typescript/lib/getExePath.d.ts index bf33fb7cf6882..9efbab990f9b8 100644 --- a/packages/typescript/lib/getExePath.d.ts +++ b/packages/typescript/lib/getExePath.d.ts @@ -1,2 +1,3 @@ declare function getExePath(): string; export default getExePath; +export declare function getWasmPath(): string; diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index 0bb4ab39e13e7..5db72ddfe41d3 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -5,10 +5,7 @@ import { fileURLToPath } from "node:url"; // NOTE: Keep VS Code extension's resolveTsdkPathToExe in sync with this function. export default function getExePath() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const normalizedDirname = __dirname.replace(/\\/g, "/"); - - const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); + const { __dirname, normalizedDirname, pkg } = getPackageInfo(); const pkgName = pkg.name; const baseName = pkgName.startsWith("@") ? pkgName.split("/")[1] : pkgName; const expectedBinName = baseName === "typescript" ? "tsc" : "tsgo"; @@ -68,3 +65,33 @@ export default function getExePath() { return exe; } + +export function getWasmPath() { + const { __dirname, normalizedDirname } = getPackageInfo(); + + let wasmDir; + if (normalizedDirname.endsWith("/packages/typescript/lib")) { + wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "dist"); + } + else if (normalizedDirname.endsWith("/built/npm/typescript/lib") || normalizedDirname.endsWith("/built/npm/native-preview/lib")) { + wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "dist"); + } + else { + const require = module.createRequire(import.meta.url); + const packageJson = require.resolve("@typescript/typescript-wasip1-wasm/package.json"); + wasmDir = path.join(path.dirname(packageJson), "dist"); + } + + const wasmPath = path.join(wasmDir, "tsc.wasm"); + if (!fs.existsSync(wasmPath)) { + throw new Error("WebAssembly compiler not found: " + wasmPath); + } + return wasmPath; +} + +function getPackageInfo() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const normalizedDirname = __dirname.replace(/\\/g, "/"); + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); + return { __dirname, normalizedDirname, pkg }; +} diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index eb869b715737e..7e094ad8a5a9c 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -1,11 +1,25 @@ #!/usr/bin/env node -import getExePath from "#getExePath"; +import getExePath, { getWasmPath } from "#getExePath"; import { execFileSync } from "node:child_process"; +import path from "node:path"; -const exe = getExePath(); +let exe; +try { + exe = getExePath(); +} +catch (nativeError) { + let wasmPath; + try { + wasmPath = getWasmPath(); + } + catch { + throw nativeError; + } + await runWasi(wasmPath); +} -if (process.platform !== "win32" && typeof process.execve === "function") { +if (exe && process.platform !== "win32" && typeof process.execve === "function") { // > v22.15.0 try { process.execve(exe, [exe, ...process.argv.slice(2)]); @@ -15,14 +29,84 @@ if (process.platform !== "win32" && typeof process.execve === "function") { } } -try { - execFileSync(exe, process.argv.slice(2), { stdio: "inherit" }); +if (exe) { + try { + execFileSync(exe, process.argv.slice(2), { stdio: "inherit" }); + } + catch (e) { + if (e.status) { + process.exitCode = e.status; + } + else { + throw e; + } + } } -catch (e) { - if (e.status) { - process.exitCode = e.status; + +async function runWasi(wasmPath) { + const nodeMajor = Number(process.versions.node.split(".")[0]); + if (nodeMajor < 23) { + throw new Error("The WASI fallback requires Node.js 23 or newer."); } - else { - throw e; + + const [{ readFile }, { WASI }] = await Promise.all([ + import("node:fs/promises"), + import("node:wasi"), + ]); + + const { args, cwd, preopens } = getWasiPaths(wasmPath); + const wasi = new WASI({ + version: "preview1", + args, + env: { ...process.env, PWD: cwd }, + preopens, + returnOnExit: true, + }); + const module = await WebAssembly.compile(await readFile(wasmPath)); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + process.exitCode = wasi.start(instance); +} + +function getWasiPaths(wasmPath) { + const cwd = process.cwd(); + if (process.platform !== "win32") { + return { + args: [wasmPath, ...process.argv.slice(2)], + cwd, + preopens: { "/": "/" }, + }; } + + const preopens = {}; + const guestRoots = new Map(); + const getGuestRoot = hostRoot => { + const key = hostRoot.toLowerCase(); + let guestRoot = guestRoots.get(key); + if (!guestRoot) { + const drive = /^([a-z]):[\\/]$/i.exec(hostRoot); + guestRoot = drive ? `/mnt/${drive[1].toLowerCase()}` : `/mnt/root-${guestRoots.size}`; + guestRoots.set(key, guestRoot); + preopens[guestRoot] = hostRoot; + } + return guestRoot; + }; + const toGuestPath = hostPath => { + const root = path.parse(hostPath).root; + const relative = path.relative(root, hostPath).replaceAll("\\", "/"); + return path.posix.join(getGuestRoot(root), relative); + }; + const translatePath = arg => { + return path.isAbsolute(arg) ? toGuestPath(arg) : arg; + }; + + return { + args: [ + toGuestPath(wasmPath), + ...process.argv.slice(2).map(translatePath), + ], + cwd: toGuestPath(cwd), + preopens, + }; } diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 9cebd17e7a436..7884b4d4d5aa8 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -42,10 +42,18 @@ "@typescript/source": "./src/api/sync/api.ts", "default": "./dist/api/sync/api.js" }, + "./unstable/sync/transport": { + "@typescript/source": "./src/api/sync/transport.ts", + "default": "./dist/api/sync/transport.js" + }, "./unstable/async": { "@typescript/source": "./src/api/async/api.ts", "default": "./dist/api/async/api.js" }, + "./unstable/async/transport": { + "@typescript/source": "./src/api/async/transport.ts", + "default": "./dist/api/async/transport.js" + }, "./unstable/fs": { "@typescript/source": "./src/api/fs.ts", "default": "./dist/api/fs.js" @@ -85,6 +93,22 @@ }, "imports": { "#getExePath": "./lib/getExePath.js", + "#asyncClient": { + "@typescript/source": { + "browser": "./src/api/async/browserClient.ts", + "default": "./src/api/async/client.ts" + }, + "browser": "./dist/api/async/browserClient.js", + "default": "./dist/api/async/client.js" + }, + "#syncClient": { + "@typescript/source": { + "browser": "./src/api/sync/browserClient.ts", + "default": "./src/api/sync/client.ts" + }, + "browser": "./dist/api/sync/browserClient.js", + "default": "./dist/api/sync/client.js" + }, "#enums/*": { "@typescript/source": { "types": "./src/enums/*.enum.ts", @@ -103,11 +127,15 @@ "generate:sync": "npm run node -- scripts/generateSync.ts", "build": "tsc -b", "build:test": "tsc -b test", - "test:only": "npm run node -- --test './test/**/*.test.ts'", + "test:only": "npm run node -- --test './test/*.test.ts' './test/async/**/*.test.ts' './test/sync/**/*.test.ts'", + "test:browser:only": "npm run node -- --test './test/browser/**/*.test.ts'", "test": "npm run test:only" }, "devDependencies": { "@types/node": "^22.20.1", + "assert": "^2.1.0", + "playwright": "^1.62.1", + "process": "^0.11.10", "tinybench": "^6.1.3", "vscode-jsonrpc": "^9.0.1" } diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 208313dac3502..1db3ca653ddae 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1,3 +1,8 @@ +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "#asyncClient"; // @sync: } from "#syncClient"; import { CheckFlags } from "#enums/checkFlags"; import { CompletionItemKind } from "#enums/completionItemKind"; import { DiagnosticCategory } from "#enums/diagnosticCategory"; @@ -46,7 +51,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 { @@ -93,11 +98,6 @@ import type { TimingAccumulators, TimingInfo, } from "../timing.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, @@ -391,6 +391,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 // Dispose all active snapshots try { diff --git a/packages/typescript/src/api/async/browserClient.ts b/packages/typescript/src/api/async/browserClient.ts new file mode 100644 index 0000000000000..d70e67203af9d --- /dev/null +++ b/packages/typescript/src/api/async/browserClient.ts @@ -0,0 +1,22 @@ +import type { + AsyncClientOptions, + ClientSocketOptions, + ClientSpawnOptions, +} from "../options.ts"; +import { TransportClient } from "./transportClient.ts"; + +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions } from "../options.ts"; +export type { AsyncTransport } from "./transport.ts"; + +export class Client extends TransportClient { + constructor(options: AsyncClientOptions) { + if (!("transport" in options)) { + throw new Error("The browser async API requires an injected transport"); + } + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); + } +} diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 03809285c9b4d..64ad7291f699c 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -14,10 +14,12 @@ import { fsCallbackNames, } from "../fs.ts"; import { + type AsyncClientOptions, type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, getAPIProcessArgs, + isAsyncTransportOptions, isSpawnOptions, resolveExePath, } from "../options.ts"; @@ -36,8 +38,10 @@ import { TimingCollector, type TimingInfo, } from "../timing.ts"; +import { TransportClient } from "./transportClient.ts"; -export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions }; +export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; +export type { AsyncTransport } from "./transport.ts"; /** * Client handles communication with the TypeScript API server @@ -47,7 +51,8 @@ export class Client { private socket: Socket | undefined; private process: ChildProcess | undefined; private connection: MessageConnection | undefined; - private options: ClientOptions; + private options: ClientOptions | undefined; + private transportClient: TransportClient | undefined; private connected = false; private closed = false; private connecting: Promise | undefined; @@ -55,7 +60,18 @@ export class Client { private batchedRequests: { method: APIRequest["method"]; params: APIRequest["params"]; resolve: (value: unknown) => void; reject: (reason?: any) => void; }[] = []; private nextBatch: NodeJS.Immediate | "manual" | undefined; - constructor(options: ClientOptions) { + constructor(options: AsyncClientOptions) { + if (isAsyncTransportOptions(options)) { + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + this.transportClient = new TransportClient( + options.transport, + options.collectTiming ?? false, + options.maxResponseBytesPerPage, + ); + return; + } this.options = options; if (isSpawnOptions(options) && options.collectTiming) { this.timing = new TimingCollector(); @@ -63,6 +79,7 @@ export class Client { } connect(): Promise { + if (this.transportClient) return this.transportClient.connect(); if (this.closed) return Promise.reject(new Error("Client is closed")); if (this.connected) return Promise.resolve(); return this.connecting ??= this.connectWorker().finally(() => { @@ -71,6 +88,7 @@ export class Client { } private async connectWorker(): Promise { + if (!this.options) throw new Error("Client options are not available"); if (isSpawnOptions(this.options)) { await this.connectViaSpawn(this.options); } @@ -222,8 +240,9 @@ export class Client { const requestType = new RequestType("batchRequests"); const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; - if (this.options.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + const maxResponseBytesPerPage = this.options?.maxResponseBytesPerPage; + if (maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = maxResponseBytesPerPage; } const response = await this.sendRequestWithTiming(requestType, params); let responses = response.responses; @@ -233,8 +252,8 @@ export class Client { requests: [], continuationToken, }; - if (this.options.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + if (maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = maxResponseBytesPerPage; } const page = await this.sendRequestWithTiming(requestType, pageParams); responses = responses.concat(page.responses); @@ -262,6 +281,7 @@ export class Client { } batchContext(): { [Symbol.dispose](): void; } { + if (this.transportClient) return this.transportClient.batchContext(); if (this.nextBatch === "manual") { throw new Error("Already in a manual batch context"); } @@ -279,6 +299,7 @@ export class Client { } async apiRequest(method: K, params: APIMethodInfo[K]["params"]): Promise { + if (this.transportClient) return this.transportClient.apiRequest(method, params); if (this.closed) throw new Error("Client is closed"); if (!this.connected) { await this.connect(); @@ -295,6 +316,7 @@ export class Client { } async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { + if (this.transportClient) return this.transportClient.apiRequestBinary(method, params); const response = await this.apiRequest(method, params); if (!response) return undefined; const buffer = Buffer.from(response.data, "base64"); @@ -308,6 +330,7 @@ export class Client { * materialization totals surface alongside request timings. */ getTimingCollector(): TimingCollector | undefined { + if (this.transportClient) return this.transportClient.getTimingCollector(); return this.timing; } @@ -317,6 +340,7 @@ export class Client { * (fetched via a getServerTiming request) and estimated transport overhead. */ async getTimingInfo(): Promise { + if (this.transportClient) return this.transportClient.getTimingInfo(); if (!this.timing) { return disabledTimingInfo(); } @@ -329,6 +353,7 @@ export class Client { } async resetTimingInfo(): Promise { + if (this.transportClient) return this.transportClient.resetTimingInfo(); if (!this.timing) return; this.timing.reset(); if (this.connected && this.connection) { @@ -349,6 +374,7 @@ export class Client { } async close(): Promise { + if (this.transportClient) return this.transportClient.close(); await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection this.closed = true; if (this.connection) { diff --git a/packages/typescript/src/api/async/transport.ts b/packages/typescript/src/api/async/transport.ts new file mode 100644 index 0000000000000..28fd716f2f96d --- /dev/null +++ b/packages/typescript/src/api/async/transport.ts @@ -0,0 +1,19 @@ +/** + * 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>; + setFileSystem?(fs: FileSystem | undefined): void; + close(): void | PromiseLike; +} +import type { FileSystem } from "../fs.ts"; diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts new file mode 100644 index 0000000000000..fd1ac99507246 --- /dev/null +++ b/packages/typescript/src/api/async/transportClient.ts @@ -0,0 +1,283 @@ +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import type { AsyncTransport } from "./transport.ts"; + +interface PendingRequestBase { + reject: (reason?: unknown) => void; +} + +interface PendingTextRequest extends PendingRequestBase { + kind: "text"; + method: APIRequest["method"]; + params: APIRequest["params"]; + resolve: (value: unknown) => void; +} + +interface PendingBinaryRequest extends PendingRequestBase { + kind: "binary"; + method: SourceFileResponseMethod; + payload: Uint8Array; + resolve: (value: Uint8Array | undefined) => void; +} + +type PendingRequest = PendingTextRequest | PendingBinaryRequest; + +function isTextRequest(request: PendingRequest): request is PendingTextRequest { + return request.kind === "text"; +} + +/** Protocol client for an injected asynchronous transport. */ +export class TransportClient { + private readonly encoder = new TextEncoder(); + private readonly timing: TimingCollector | undefined; + private readonly transport: AsyncTransport; + private readonly maxResponseBytesPerPage: number | undefined; + private connected = false; + private closed = false; + private nextBatch: boolean | "manual" = false; + private batchGeneration = 0; + private batchedRequests: PendingRequest[] = []; + private readonly closedPromise: Promise; + private rejectClosed!: (reason: Error) => void; + + constructor(transport: AsyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + this.closedPromise = new Promise((_, reject) => { + this.rejectClosed = reject; + }); + void this.closedPromise.catch(() => {}); + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + connect(): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + this.connected = true; + return Promise.resolve(); + } + + batchContext(): { [Symbol.dispose](): void; } { + if (this.nextBatch === "manual") { + throw new Error("Already in a manual batch context"); + } + if (this.nextBatch) { + this.batchGeneration++; + this.nextBatch = false; + void this.doBatch(); + } + this.nextBatch = "manual"; + return { + [Symbol.dispose]: () => { + this.nextBatch = false; + this.scheduleBatch(); + }, + }; + } + + async apiRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + if (method === "initialize") { + return this.sendRequest(method, params); + } + const result = new Promise((resolve, reject) => { + this.batchedRequests.push({ kind: "text", method, params, resolve, reject }); + this.scheduleBatch(); + }); + return result; + } + + async apiRequestBinary( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + return new Promise((resolve, reject) => { + this.batchedRequests.push({ + kind: "binary", + method, + payload: this.encoder.encode(JSON.stringify(params)), + resolve, + reject, + }); + this.scheduleBatch(); + }); + } + + getTimingCollector(): TimingCollector | undefined { + return this.timing; + } + + async getTimingInfo(): Promise { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + if (!this.connected) { + return local; + } + const response = await this.invokeTransport(() => this.transport.request("getServerTiming", "")); + return combineTimingInfo(local, JSON.parse(response.value) as ServerTimingInfo); + } + + async resetTimingInfo(): Promise { + if (!this.timing) return; + this.timing.reset(); + if (this.connected) { + await this.invokeTransport(() => this.transport.request("resetServerTiming", "")); + } + } + + async close(): Promise { + this.closed = true; + this.rejectClosed(new Error("Client is closed")); + this.batchGeneration++; + this.nextBatch = false; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.transport.close(); + this.connected = false; + } + + private scheduleBatch(): void { + if (this.closed || this.nextBatch) return; + this.nextBatch = true; + const generation = ++this.batchGeneration; + queueMicrotask(() => { + if (this.batchGeneration !== generation || this.nextBatch !== true) return; + this.nextBatch = false; + void this.doBatch(); + }); + } + + private async doBatch(): Promise { + if (!this.batchedRequests.length) return; + const requests = this.batchedRequests; + this.batchedRequests = []; + if (this.closed) { + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + return; + } + try { + if (requests.length === 1 && requests[0].kind === "text") { + const request = requests[0]; + request.resolve(await this.sendRequest(request.method, request.params)); + return; + } + + if (requests.every(isTextRequest)) { + const params: BatchRequestsParams = { + requests: requests.map(request => ({ method: request.method, params: request.params })), + }; + if (this.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const response = await this.sendRequest("batchRequests", params) as BatchRequestsResponse; + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const page = await this.sendRequest("batchRequests", pageParams) as BatchRequestsResponse; + responses = responses.concat(page.responses); + continuationToken = page.continuationToken; + } + for (let i = 0; i < requests.length; i++) { + const { resolve, reject } = requests[i]; + const item = responses[i]; + if (item.error !== undefined) { + reject(new Error(item.error)); + } + else { + resolve(item.result); + } + } + return; + } + + for (const request of requests) { + if (request.kind === "text") { + request.resolve(await this.sendRequest(request.method, request.params)); + } + else { + request.resolve(await this.sendBinaryRequest(request.method, request.payload)); + } + } + } + catch (error) { + for (const { reject } of requests) reject(error); + } + } + + private async sendRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + const payload = JSON.stringify(params) ?? ""; + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.request(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length + ? JSON.parse(response.value) as APIMethodInfo[K]["result"] + : undefined as APIMethodInfo[K]["result"]; + } + + private async sendBinaryRequest(method: SourceFileResponseMethod, payload: Uint8Array): Promise { + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.requestBinary(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length === 0 ? undefined : response.value; + } + + private invokeTransport(operation: () => T | PromiseLike): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + return Promise.race([ + Promise.resolve().then(operation), + this.closedPromise, + ]); + } + + private recordTiming(method: string, start: number, bytesSent: number, bytesReceived: number): void { + if (!this.timing) return; + this.timing.record({ + method, + roundTripMs: performance.now() - start, + bytesSent, + bytesReceived, + }); + } +} diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 5ed4bdbf71d86..c70cec94b1e1b 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -1,4 +1,3 @@ -import { TextEncoder } from "node:util"; import type { FileReference, LiteralLikeNode, @@ -80,8 +79,12 @@ class StringTable { } } -let _encoder: TextEncoder | undefined; -function cachedEncoder(): TextEncoder { +interface Encoder { + encode(input?: string): Uint8Array; +} + +let _encoder: Encoder | undefined; +function cachedEncoder(): Encoder { return _encoder ??= new TextEncoder(); } @@ -376,5 +379,32 @@ export function encodeNode(node: Node): Uint8Array { * Encode a Uint8Array to a base64 string. */ export function uint8ArrayToBase64(data: Uint8Array): string { - return Buffer.from(data).toString("base64"); + const nativeToBase64 = (data as Uint8Array & { toBase64?: () => string; }).toBase64; + if (nativeToBase64) { + return nativeToBase64.call(data); + } + + const bufferConstructor = (globalThis as { + Buffer?: { + from(buffer: ArrayBufferLike, byteOffset: number, length: number): { + toString(encoding: "base64"): string; + }; + }; + }).Buffer; + if (bufferConstructor) { + return bufferConstructor.from(data.buffer, data.byteOffset, data.byteLength).toString("base64"); + } + + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let result = ""; + for (let i = 0; i < data.length; i += 3) { + const first = data[i]; + const second = data[i + 1]; + const third = data[i + 2]; + result += alphabet[first >> 2]; + result += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)]; + result += second === undefined ? "=" : alphabet[((second & 0x0F) << 2) | ((third ?? 0) >> 6)]; + result += third === undefined ? "=" : alphabet[third & 0x3F]; + } + return result; } diff --git a/packages/typescript/src/api/node/wtf8.ts b/packages/typescript/src/api/node/wtf8.ts index afd8eb2ade201..507e098a995d3 100644 --- a/packages/typescript/src/api/node/wtf8.ts +++ b/packages/typescript/src/api/node/wtf8.ts @@ -1,5 +1,3 @@ -import { Buffer } from "node:buffer"; - const surrogateLeadByte = 0xED; const surrogateSecondByteMin = 0xA0; const surrogateSecondByteMax = 0xBF; @@ -24,7 +22,7 @@ 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 toUint8Array(input: Exclude): Uint8Array { diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 7e67f739a686c..0cf87286dca2e 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -3,7 +3,9 @@ */ import getExePath from "#getExePath"; +import type { AsyncTransport } from "./async/transport.ts"; import type { FileSystem } from "./fs.ts"; +import type { SyncTransport } from "./sync/transport.ts"; export interface ClientSocketOptions { /** Path to the Unix domain socket or Windows named pipe for API communication */ @@ -39,6 +41,40 @@ export function isSpawnOptions(options: ClientOptions): options is ClientSpawnOp return !("pipe" in options); } +export interface ClientTransportOptions { + /** An existing synchronous transport connected to an API session. */ + transport: SyncTransport; + /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ + maxResponseBytesPerPage?: number; + /** Virtual filesystem callbacks used by transports that support them. */ + fs?: FileSystem; + /** Collect timing information for requests made through the transport. */ + collectTiming?: boolean; +} + +export type SyncClientOptions = ClientSocketOptions | ClientSpawnOptions | ClientTransportOptions; + +export function isTransportOptions(options: SyncClientOptions): options is ClientTransportOptions { + return "transport" in options; +} + +export interface AsyncClientTransportOptions { + /** An existing asynchronous transport connected to an API session. */ + transport: AsyncTransport; + /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ + maxResponseBytesPerPage?: number; + /** Virtual filesystem callbacks used by transports that support them. */ + fs?: FileSystem; + /** Collect timing information for requests made through the transport. */ + collectTiming?: boolean; +} + +export type AsyncClientOptions = ClientOptions | AsyncClientTransportOptions; + +export function isAsyncTransportOptions(options: AsyncClientOptions): options is AsyncClientTransportOptions { + return "transport" in options; +} + export function resolveExePath(options: ClientSpawnOptions): string { return options.tsserverPath ?? getExePath(); } @@ -55,5 +91,6 @@ export function getAPIProcessArgs(options: ClientSpawnOptions, async: boolean): export interface LSPConnectionOptions extends ClientSocketOptions { } -export interface APIOptions extends ClientSpawnOptions { -} +export type APIOptions = ClientSpawnOptions | AsyncClientTransportOptions; + +export type SyncAPIOptions = ClientSpawnOptions | ClientTransportOptions; diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 4ec27f4805f98..2c48c0f6a80db 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -32,6 +32,11 @@ import { SymbolFlags } from "#enums/symbolFlags"; import { TypeFlags } from "#enums/typeFlags"; import { TypeFormatFlags } from "#enums/typeFormatFlags"; import { TypePredicateKind } from "#enums/typePredicateKind"; +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "#syncClient"; import { type __String, type Declaration, @@ -63,8 +68,8 @@ import { } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; import type { - APIOptions, LSPConnectionOptions, + SyncAPIOptions as APIOptions, } from "../options.ts"; import { createGetCanonicalFileName, @@ -110,11 +115,6 @@ import type { TimingAccumulators, TimingInfo, } from "../timing.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, diff --git a/packages/typescript/src/api/sync/browserClient.ts b/packages/typescript/src/api/sync/browserClient.ts new file mode 100644 index 0000000000000..17c493d1b20ff --- /dev/null +++ b/packages/typescript/src/api/sync/browserClient.ts @@ -0,0 +1,22 @@ +import type { + ClientSocketOptions, + ClientSpawnOptions, + SyncClientOptions, +} from "../options.ts"; +import { TransportClient } from "./transportClient.ts"; + +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { ClientTransportOptions, SyncClientOptions as ClientOptions } from "../options.ts"; +export type { SyncTransport } from "./transport.ts"; + +export class Client extends TransportClient { + constructor(options: SyncClientOptions) { + if (!("transport" in options)) { + throw new Error("The browser sync API requires an injected transport"); + } + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); + } +} diff --git a/packages/typescript/src/api/sync/client.ts b/packages/typescript/src/api/sync/client.ts index b174784d7006e..1c3fea4ce013b 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,43 +1,34 @@ import { fsCallbackNames } from "../fs.ts"; import { - type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, getAPIProcessArgs, isSpawnOptions, + isTransportOptions, resolveExePath, + type SyncClientOptions, } from "../options.ts"; -import type { - APIMethodInfo, - APIRequest, - BatchRequestsParams, - BatchRequestsResponse, - SourceFileResponseMethod, -} from "../proto.ts"; import { SyncRpcChannel } from "../syncChannel.ts"; -import { - combineTimingInfo, - disabledTimingInfo, - type ServerTimingInfo, - TimingCollector, - type TimingInfo, -} from "../timing.ts"; - -export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions }; +import { TransportClient } from "./transportClient.ts"; -export class Client { - private channel: SyncRpcChannel; - private encoder = new TextEncoder(); - private timing: TimingCollector | undefined; - private maxResponseBytesPerPage: number | undefined; +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { ClientTransportOptions, SyncClientOptions as ClientOptions } from "../options.ts"; +export type { SyncTransport } from "./transport.ts"; - constructor(options: ClientOptions) { +export class Client extends TransportClient { + constructor(options: SyncClientOptions) { + if (isTransportOptions(options)) { + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); + return; + } if (!isSpawnOptions(options)) { throw new Error("Socket connections are not yet supported in the sync client"); } const args = getAPIProcessArgs(options, false); - this.maxResponseBytesPerPage = options.maxResponseBytesPerPage; // Enable virtual FS callbacks for each provided FS function const enabledCallbacks: (typeof fsCallbackNames[number])[] = []; @@ -53,12 +44,8 @@ export class Client { } const collectTiming = options.collectTiming ?? false; - if (collectTiming) { - this.timing = new TimingCollector(); - } - const channel = new SyncRpcChannel(resolveExePath(options), args, collectTiming); - this.channel = channel; + super(channel, collectTiming, options.maxResponseBytesPerPage); if (options.fs) { for (const name of enabledCallbacks) { @@ -89,107 +76,4 @@ export class Client { } } } - - apiRequest(method: K, params?: APIMethodInfo[K]["params"]): APIMethodInfo[K]["result"] { - const encodedPayload = JSON.stringify(params); - const start = performance.now(); - const result = this.channel.requestSync(method, encodedPayload); - this.recordTiming(method, start); - if (result.length) { - return JSON.parse(result) as APIMethodInfo[K]["result"]; - } - return undefined as APIMethodInfo[K]["result"]; - } - - batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { - const params: BatchRequestsParams = { requests }; - if (this.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const response = this.apiRequest("batchRequests", params); - let responses = response.responses; - let continuationToken = response.continuationToken; - while (continuationToken) { - const pageParams: BatchRequestsParams = { - requests: [], - continuationToken, - }; - if (this.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const page = this.apiRequest("batchRequests", pageParams); - if (page.responses.length < 200) { - responses.push(...page.responses); - } - else { - // If the number of responses is approaching the max argument length, we need to concat instead of push - responses = responses.concat(page.responses); - } - continuationToken = page.continuationToken; - } - return { responses }; - } - - apiRequestBinary(method: K, params?: APIMethodInfo[K]["params"]): Uint8Array | undefined { - const start = performance.now(); - const result = this.channel.requestBinarySync(method, this.encoder.encode(JSON.stringify(params))); - this.recordTiming(method, start); - if (result.length === 0) return undefined; - return result; - } - - echo(payload: string): string { - return this.channel.requestSync("echo", payload); - } - - echoBinary(payload: Uint8Array): Uint8Array { - return this.channel.requestBinarySync("echo", payload); - } - - /** - * Returns a combined timing snapshot: client-measured round-trip and byte - * counts folded together with the server's own per-request processing time - * (fetched via a getServerTiming request) and estimated transport overhead. - */ - getTimingInfo(): TimingInfo { - if (!this.timing) { - return disabledTimingInfo(); - } - const local = this.timing.getInfo(); - // requestSync bypasses recordTiming, so this query does not pollute the - // client-side collector. - const result = this.channel.requestSync("getServerTiming", ""); - return combineTimingInfo(local, JSON.parse(result) as ServerTimingInfo); - } - - resetTimingInfo(): void { - if (!this.timing) return; - this.timing.reset(); - // Keep the server's collection in sync so combined totals stay meaningful. - this.channel.requestSync("resetServerTiming", ""); - } - - /** - * Returns the timing collector that per-node materialization is reported - * into, or undefined when timing collection is disabled. The returned - * collector is the same one folded into {@link getTimingInfo}, so - * materialization totals surface alongside request timings. - */ - getTimingCollector(): TimingCollector | undefined { - return this.timing; - } - - private recordTiming(method: string, start: number): void { - if (!this.timing) return; - this.timing.record({ - method, - roundTripMs: performance.now() - start, - bytesSent: this.channel.lastBytesSent, - bytesReceived: this.channel.lastBytesReceived, - }); - } - - close(): void { - this.channel.close(); - } } diff --git a/packages/typescript/src/api/sync/transport.ts b/packages/typescript/src/api/sync/transport.ts new file mode 100644 index 0000000000000..589eb35914966 --- /dev/null +++ b/packages/typescript/src/api/sync/transport.ts @@ -0,0 +1,18 @@ +/** + * 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; + setFileSystem?(fs: FileSystem | undefined): void; + close(): void; +} +import type { FileSystem } from "../fs.ts"; diff --git a/packages/typescript/src/api/sync/transportClient.ts b/packages/typescript/src/api/sync/transportClient.ts new file mode 100644 index 0000000000000..733c4deafc59d --- /dev/null +++ b/packages/typescript/src/api/sync/transportClient.ts @@ -0,0 +1,121 @@ +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"]; + } + + 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/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts new file mode 100644 index 0000000000000..6993fc4c9e29b --- /dev/null +++ b/packages/typescript/test/async/transport.test.ts @@ -0,0 +1,353 @@ +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/unstable/async"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { TransportClient } from "../../src/api/async/transportClient.ts"; +import type { + BatchRequestsParams, + BatchRequestsResponse, +} from "../../src/api/proto.ts"; + +describe("async transport", () => { + test("constructs an API over an injected transport", async () => { + const methods: string[] = []; + let batchParams: BatchRequestsParams | undefined; + let closed = false; + const api = new API({ + maxResponseBytesPerPage: 1, + transport: { + request(method, payload) { + methods.push(method); + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + case "batchRequests": { + batchParams = JSON.parse(payload) as BatchRequestsParams; + assert.ok(batchParams.requests); + const response: BatchRequestsResponse = { + responses: batchParams.requests.map(request => ({ + method: request.method, + result: { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + })), + }; + return { + value: JSON.stringify(response), + bytesSent: 0, + bytesReceived: 0, + }; + } + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() { + closed = true; + }, + }, + }); + + assert.deepStrictEqual(await api.parseCommandLine([]), { + options: {}, + fileNames: [], + errors: [], + }); + let batchRequests: Promise[]; + { + using _ = api.batchContext(); + batchRequests = [api.parseCommandLine([]), api.parseCommandLine([])]; + } + await Promise.all(batchRequests); + assert.deepStrictEqual(methods, ["initialize", "parseCommandLine", "batchRequests"]); + assert.strictEqual(batchParams?.maxResponseBytesPerPage, 1); + await api.close(); + assert.strictEqual(closed, true); + }); + + test("rejects requests queued when the transport closes", async () => { + const api = new API({ + transport: { + request(method) { + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + await api.parseCommandLine([]); + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("can close while initialization is manually batched", async () => { + const api = new API({ + transport: { + request(method) { + if (method !== "initialize") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("does not flush a manual batch from a stale microtask", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + if (method !== "parseCommandLine") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const first = client.apiRequest("parseCommandLine", { commandLine: [] }); + const batch = client.batchContext(); + const second = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + assert.deepStrictEqual(methods, ["parseCommandLine"]); + batch[Symbol.dispose](); + await Promise.all([first, second]); + await client.close(); + }); + + test("keeps binary requests ordered inside a manual batch", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary(method, payload) { + methods.push(method); + return { + value: payload, + bytesSent: payload.length, + bytesReceived: payload.length, + }; + }, + close() {}, + }, false); + + await client.connect(); + const batch = client.batchContext(); + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project", + file: "/index.ts", + }); + await Promise.resolve(); + assert.deepStrictEqual(methods, []); + batch[Symbol.dispose](); + await Promise.all([text, binary]); + assert.deepStrictEqual(methods, ["parseCommandLine", "getSourceFile"]); + await client.close(); + }); + + test("rejects requests that are still active when closed", async () => { + let resolveRequest: + | ((value: { + value: string; + bytesSent: number; + bytesReceived: number; + }) => void) + | undefined; + const client = new TransportClient({ + request() { + return new Promise(resolve => { + resolveRequest = resolve; + }); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const request = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + await client.close(); + await assert.rejects(request, /Client is closed/); + resolveRequest?.({ + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }); + }); + + test("can close while initialization is in flight", async () => { + const api = new API({ + transport: { + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await Promise.resolve(); + await api.close(); + await rejected; + }); + + test("rejects timing requests when closed", async () => { + const client = new TransportClient({ + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, true); + + await client.connect(); + const timing = client.getTimingInfo(); + const reset = client.resetTimingInfo(); + await client.close(); + await assert.rejects(timing, /Client is closed/); + await assert.rejects(reset, /Client is closed/); + }); + + test("does not queue requests after closing during connect", async () => { + const client = new TransportClient({ + request() { + throw new Error("Unexpected request"); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project", + file: "/index.ts", + }); + await client.close(); + await assert.rejects(text, /Client is closed/); + await assert.rejects(binary, /Client is closed/); + }); +}); diff --git a/packages/typescript/test/browser/apiWrapper.ts b/packages/typescript/test/browser/apiWrapper.ts new file mode 100644 index 0000000000000..ac37b98c7530c --- /dev/null +++ b/packages/typescript/test/browser/apiWrapper.ts @@ -0,0 +1,169 @@ +import { + instantiateWasm, + type WasmReactorInstance, + WasmTransport, +} from "@typescript/typescript-wasip1-wasm"; +import type { FileSystem } from "@typescript/typescript/unstable/fs"; +import { + type APIFileChanges, + resolveFileName, +} from "../../src/api/proto.ts"; + +interface BrowserAPIOptions { + cwd?: string; + fs?: FileSystem; + collectTiming?: boolean; + maxResponseBytesPerPage?: number; + transport?: object; +} + +const availableInstances: WasmReactorInstance[] = []; +const mirroredFiles = new WeakMap>(); + +export async function initializeBrowserAPIInstances(module: WebAssembly.Module, count: number): Promise { + availableInstances.push( + ...await Promise.all( + Array.from({ length: count }, () => instantiateWasm(module)), + ), + ); +} + +class BrowserWasmTransport extends WasmTransport { + private returned = false; + + constructor( + private readonly reactorInstance: WasmReactorInstance, + options: ConstructorParameters[0], + ) { + super(options); + } + + override close(): void { + if (this.returned) return; + super.close(); + this.returned = true; + availableInstances.push(this.reactorInstance); + } +} + +export function createBrowserAPIOptions(options: BrowserAPIOptions): { + options: BrowserAPIOptions; + transport?: WasmTransport; +} { + if (options.transport) return { options }; + const instance = availableInstances.pop(); + if (!instance) { + throw new Error("No initialized TypeScript WASM reactor is available"); + } + const transport = new BrowserWasmTransport(instance, { + instance, + cwd: options.cwd ?? "/", + ...options.fs === undefined ? {} : { fs: options.fs }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + }); + if (options.fs) { + synchronizeFileSystem(options.fs, transport); + } + return { + options: { + transport, + ...options.fs === undefined ? {} : { fs: options.fs }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + ...options.maxResponseBytesPerPage === undefined ? {} : { maxResponseBytesPerPage: options.maxResponseBytesPerPage }, + }, + transport, + }; +} + +export function wrapFileUpdates(api: T, fs: FileSystem | undefined, transport: WasmTransport | undefined): void { + if (!fs || !transport) return; + wrapMethod("updateSnapshot", args => { + const params = args[0] as { fileChanges?: APIFileChanges; } | undefined; + syncFileChanges(fs, transport, params?.fileChanges); + }); + wrapMethod("createProgram", args => { + syncFileChanges(fs, transport, args[3] as APIFileChanges | undefined); + }); + + function wrapMethod(name: string, before: (args: unknown[]) => void): void { + const target = api as Record; + const original = target[name] as ((...args: unknown[]) => unknown) & { + gen?: (...args: unknown[]) => Generator; + }; + const bound = original.bind(api); + const wrapped = (...args: unknown[]) => { + before(args); + return bound(...args); + }; + if (original.gen) { + const boundGen = original.gen.bind(api); + wrapped.gen = (...args: unknown[]) => { + before(args); + return boundGen(...args); + }; + } + Object.defineProperty(api, name, { + configurable: true, + value: wrapped, + }); + } +} + +function synchronizeFileSystem(fs: FileSystem, transport: WasmTransport): void { + const previous = mirroredFiles.get(transport) ?? new Set(); + const current = new Set(); + visit("/"); + for (const file of previous) { + if (!current.has(file)) { + transport.removeFile(file); + } + } + mirroredFiles.set(transport, current); + + function visit(directory: string): void { + const entries = fs.getAccessibleEntries?.(directory); + if (!entries) return; + for (const file of entries.files) { + const path = join(directory, file); + current.add(path); + const content = fs.readFile?.(path); + if (typeof content === "string") { + transport.setFile(path, content); + } + } + for (const child of entries.directories) { + visit(join(directory, child)); + } + } +} + +function syncFileChanges( + fs: FileSystem, + transport: WasmTransport, + changes: APIFileChanges | undefined, +): void { + if (changes?.invalidateAll) { + synchronizeFileSystem(fs, transport); + } + for (const file of [...changes?.changed ?? [], ...changes?.created ?? []]) { + const fileName = resolveFileName(file); + const content = fs.readFile?.(fileName); + if (typeof content === "string") { + transport.setFile(fileName, content); + mirroredFiles.get(transport)?.add(fileName); + } + else { + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); + } + } + for (const file of changes?.deleted ?? []) { + const fileName = resolveFileName(file); + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); + } +} + +function join(directory: string, name: string): string { + return directory === "/" ? `/${name}` : `${directory}/${name}`; +} diff --git a/packages/typescript/test/browser/asyncAPI.ts b/packages/typescript/test/browser/asyncAPI.ts new file mode 100644 index 0000000000000..efd76e0e5475e --- /dev/null +++ b/packages/typescript/test/browser/asyncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/async/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/async/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as APIOptions & { fs?: import("../../src/api/fs.ts").FileSystem; }; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/browser/bundle.test.ts b/packages/typescript/test/browser/bundle.test.ts new file mode 100644 index 0000000000000..11e70244b67da --- /dev/null +++ b/packages/typescript/test/browser/bundle.test.ts @@ -0,0 +1,37 @@ +import { build } from "esbuild"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; + +describe("browser API bundle", () => { + test("uses browser clients without Node dependencies", async () => { + const result = await build({ + stdin: { + contents: ` + import { WasmTransport, wasmURL } from "@typescript/typescript-wasip1-wasm"; + import { API as AsyncAPI } from "typescript/unstable/async"; + import { API as SyncAPI } from "typescript/unstable/sync"; + globalThis.typescriptAPI = { AsyncAPI, SyncAPI, WasmTransport, wasmURL }; + `, + loader: "ts", + resolveDir: process.cwd(), + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source", "browser"], + format: "esm", + platform: "browser", + write: false, + }); + + assert.strictEqual(result.outputFiles.length, 1); + const output = result.outputFiles[0].text; + assert.doesNotMatch(output, /(?:from|import)\s*\(?["']node:/); + assert.match(output, /The browser async API requires an injected transport/); + assert.match(output, /The browser sync API requires an injected transport/); + }); +}); diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts new file mode 100644 index 0000000000000..89c0d3c2207d9 --- /dev/null +++ b/packages/typescript/test/browser/harness.ts @@ -0,0 +1,103 @@ +interface RegisteredTest { + name: string; + run: () => unknown; +} + +export interface BrowserTestFailure { + name: string; + message: string; + stack?: string; +} + +export interface BrowserTestSkip { + name: string; + reason: string; +} + +export interface BrowserTestResults { + passed: number; + skipped: BrowserTestSkip[]; + failures: BrowserTestFailure[]; +} + +export interface BrowserTestExclusion { + pattern: RegExp; + reason: string; +} + +const tests: RegisteredTest[] = []; +const suites: string[] = []; + +export function describe(name: string, run: () => void): void { + suites.push(name); + try { + run(); + } + finally { + suites.pop(); + } +} + +export function test(name: string, run: () => unknown): void { + tests.push({ + name: [...suites, name].join(" > "), + run, + }); +} + +export async function runRegisteredTests(exclusions: readonly BrowserTestExclusion[], timeoutMs = 10_000): Promise { + const failures: BrowserTestFailure[] = []; + const skipped: BrowserTestSkip[] = []; + let passed = 0; + for (const registered of tests) { + const exclusion = exclusions.find(entry => entry.pattern.test(registered.name)); + if (exclusion) { + skipped.push({ + name: registered.name, + reason: exclusion.reason, + }); + continue; + } + try { + Reflect.set(globalThis, "browserTestProgress", registered.name); + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.resolve().then(registered.run), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Test timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } + finally { + clearTimeout(timer); + } + passed++; + } + catch (error) { + const normalized = normalizeError(error); + failures.push({ + name: registered.name, + message: normalized.message, + ...normalized.stack === undefined ? {} : { stack: normalized.stack }, + }); + } + } + return { passed, skipped, failures }; +} + +function normalizeError(error: unknown): Error { + if ( + error instanceof Error + && error.name === "SuppressedError" + && "error" in error + && "suppressed" in error + ) { + const primary = normalizeError(error.error); + const suppressed = normalizeError(error.suppressed); + return new Error(`${primary.message}\nSuppressed during disposal: ${suppressed.message}`, { + cause: primary, + }); + } + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/typescript/test/browser/processShim.ts b/packages/typescript/test/browser/processShim.ts new file mode 100644 index 0000000000000..ecb611abf6d32 --- /dev/null +++ b/packages/typescript/test/browser/processShim.ts @@ -0,0 +1,11 @@ +import process from "process"; + +export { process }; + +export function setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): ReturnType { + return setTimeout(callback, 0, ...args); +} + +export function clearImmediate(handle: ReturnType): void { + clearTimeout(handle); +} diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts new file mode 100644 index 0000000000000..72f16b7e84b64 --- /dev/null +++ b/packages/typescript/test/browser/suite.test.ts @@ -0,0 +1,264 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import { + build, + type Plugin, +} from "esbuild"; +import assert from "node:assert"; +import { globSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import path from "node:path"; +import { + after, + before, + describe, + test, +} from "node:test"; +import { fileURLToPath } from "node:url"; +import { + type Browser, + chromium, +} from "playwright"; +import type { BrowserTestResults } from "./harness.ts"; + +const browserDir = fileURLToPath(new URL(".", import.meta.url)); +const packageDir = path.resolve(browserDir, "../.."); +const modes = ["async", "sync"] as const; +const expectedTestCounts = { + async: 335, + sync: 386, +} as const; +const fileExclusions = [ + { + mode: "async", + file: "version.test.ts", + tests: 2, + reason: "These tests exercise Node.js executable and package resolution behavior.", + }, + { + mode: "async", + file: "async/astnav.test.ts", + tests: 4, + reason: "These tests read compiler fixtures and Go baselines from the repository filesystem.", + }, + { + mode: "sync", + file: "sync/astnav.test.ts", + tests: 4, + reason: "These tests read compiler fixtures and Go baselines from the repository filesystem.", + }, + { + mode: "sync", + file: "sync/wasm.test.ts", + tests: 3, + reason: "These Node.js integration tests use node:fs and node:wasi; wasm.test.ts covers browser API loading.", + }, +] as const; + +describe("API test suite in a browser", () => { + let browser: Browser; + let origin: string; + const bundles = new Map(); + const server = createServer(); + + before(async () => { + const allTestFiles = globSync(["*.test.ts", "async/**/*.test.ts", "sync/**/*.test.ts"], { + cwd: path.join(packageDir, "test"), + }); + for (const mode of modes) { + const testFiles = allTestFiles.filter(file => { + const belongsToMode = mode === "async" + ? !file.includes("/") || file.startsWith("async/") + : file.startsWith("sync/"); + return belongsToMode && !fileExclusions.some(exclusion => exclusion.file === file); + }); + const result = await build({ + stdin: { + contents: ` + ${testFiles.map(file => `import ${JSON.stringify(path.join(packageDir, "test", file))};`).join("\n")} + import { runRegisteredTests } from ${JSON.stringify(path.join(browserDir, "harness.ts"))}; + import { initializeBrowserAPIInstances } from ${JSON.stringify(path.join(browserDir, "apiWrapper.ts"))}; + try { + const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); + await initializeBrowserAPIInstances(module, 4); + globalThis.browserTestResults = await runRegisteredTests([ + { + pattern: /Benchmarks$/, + reason: "Benchmarks require Node.js process and filesystem APIs.", + }, + { + pattern: /Parse-clone-emit roundtrip$/, + reason: "The roundtrip fixture discovers source files through Node.js glob and filesystem APIs.", + }, + { + pattern: /parseJsonConfigFileContent accepts non-object JSON$|parseConfigFile$|project exposes parsedCommandLine$/, + reason: "These assertions depend on insertion-ordered callback filesystem directory listings.", + }, + ]); + } + catch (error) { + globalThis.browserTestError = { + message: String(error), + stack: error instanceof Error ? error.stack : undefined, + }; + } + `, + loader: "ts", + resolveDir: packageDir, + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source", "browser"], + format: "esm", + inject: [path.join(browserDir, "processShim.ts")], + platform: "browser", + plugins: [browserTestPlugin(mode)], + write: false, + }); + assert.strictEqual(result.outputFiles.length, 1); + bundles.set(mode, result.outputFiles[0].contents); + } + const wasm = await readFile(wasmURL); + + server.on("request", (request, response) => { + if (request.url === "/tsc.wasm") { + response.setHeader("Content-Type", "application/wasm"); + response.end(wasm); + return; + } + const pageMode = request.url?.slice(1); + if (pageMode && bundles.has(pageMode)) { + response.setHeader("Content-Type", "text/html"); + response.end(``); + return; + } + const mode = request.url?.slice(1, -3); + const bundle = mode && bundles.get(mode); + if (bundle) { + response.setHeader("Content-Type", "text/javascript"); + response.end(bundle); + return; + } + response.statusCode = 404; + response.end(); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + origin = `http://127.0.0.1:${address.port}`; + browser = await chromium.launch(); + }); + + after(async () => { + await browser?.close(); + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + }); + + for (const mode of modes) { + test(`runs the existing ${mode} API tests`, async t => { + const page = await browser.newPage(); + const pageErrors: string[] = []; + const pageConsole: string[] = []; + page.on("pageerror", error => pageErrors.push(error.stack ?? error.message)); + page.on("console", message => pageConsole.push(message.text())); + await page.goto(`${origin}/${mode}`, { waitUntil: "domcontentloaded" }); + try { + await page.waitForFunction( + () => Reflect.has(globalThis, "browserTestResults") || Reflect.has(globalThis, "browserTestError"), + undefined, + { timeout: 300_000 }, + ); + } + catch (error) { + const progress = await page.evaluate(() => Reflect.get(globalThis, "browserTestProgress")); + throw new Error(`Browser API tests stalled at: ${progress}\n${pageErrors.join("\n\n")}`, { cause: error }); + } + const error = await page.evaluate(() => Reflect.get(globalThis, "browserTestError")); + assert.strictEqual(error, undefined, error?.stack ?? error?.message); + const results = await page.evaluate(() => Reflect.get(globalThis, "browserTestResults")) as BrowserTestResults; + assert.strictEqual( + results.failures.length, + 0, + [ + ...results.failures.map(failure => `${failure.name}: ${failure.message}\n${failure.stack ?? ""}`), + ...pageConsole, + ].join("\n\n"), + ); + assert.ok(results.passed > 0); + const excludedFiles = fileExclusions.filter(exclusion => exclusion.mode === mode); + const excludedFileTests = excludedFiles.reduce((count, exclusion) => count + exclusion.tests, 0); + assert.strictEqual( + results.passed + results.skipped.length + excludedFileTests, + expectedTestCounts[mode], + `Browser ${mode} API test accounting changed`, + ); + t.diagnostic(`${results.passed} passed, ${results.skipped.length + excludedFileTests} skipped`); + for (const skipped of results.skipped) { + t.diagnostic(`SKIP ${skipped.name}: ${skipped.reason}`); + } + for (const exclusion of excludedFiles) { + t.diagnostic(`SKIP ${exclusion.file} (${exclusion.tests} tests): ${exclusion.reason}`); + } + }); + } +}); + +function browserTestPlugin(mode: "async" | "sync"): Plugin { + const harness = path.join(browserDir, "harness.ts"); + const api = path.join(browserDir, `${mode}API.ts`); + return { + name: "browser-test", + setup(build) { + build.onResolve({ filter: /^node:test$/ }, () => ({ path: harness })); + build.onResolve({ filter: /^node:assert$/ }, args => { + return build.resolve("assert", { + kind: args.kind, + resolveDir: packageDir, + }); + }); + build.onResolve({ filter: /^node:fs$/ }, () => ({ path: "node-fs", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:path$/ }, () => ({ path: "node-path", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:url$/ }, () => ({ path: "node-url", namespace: "browser-shim" })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/async$/ }, () => ({ + path: mode === "async" ? api : path.join(browserDir, "asyncAPI.ts"), + })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/sync$/ }, () => ({ + path: mode === "sync" ? api : path.join(browserDir, "syncAPI.ts"), + })); + build.onResolve({ filter: /^\.\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onLoad({ filter: /.*/, namespace: "browser-shim" }, args => { + switch (args.path) { + case "api-bench": + return { contents: "export function runBenchmarks() { throw new Error('Benchmarks are not available in a browser'); }" }; + case "node-fs": + return { contents: "export function globSync() { throw new Error('globSync is not available in a browser'); }" }; + case "node-path": + return { + contents: ` + export function resolve(...parts) { + const path = parts.join("/"); + const normalized = []; + for (const part of path.split("/")) { + if (!part || part === ".") continue; + if (part === "..") normalized.pop(); + else normalized.push(part); + } + return "/" + normalized.join("/"); + } + `, + }; + case "node-url": + return { contents: "export function fileURLToPath(url) { return new URL(url).pathname; }" }; + } + return undefined; + }); + }, + }; +} diff --git a/packages/typescript/test/browser/syncAPI.ts b/packages/typescript/test/browser/syncAPI.ts new file mode 100644 index 0000000000000..08c385a4e22e6 --- /dev/null +++ b/packages/typescript/test/browser/syncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/sync/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/sync/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as APIOptions & { fs?: import("../../src/api/fs.ts").FileSystem; }; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/browser/wasm.test.ts b/packages/typescript/test/browser/wasm.test.ts new file mode 100644 index 0000000000000..c59c983257382 --- /dev/null +++ b/packages/typescript/test/browser/wasm.test.ts @@ -0,0 +1,122 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import { build } from "esbuild"; +import assert from "node:assert"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { + after, + before, + describe, + test, +} from "node:test"; +import { + type Browser, + chromium, +} from "playwright"; + +describe("API over WebAssembly in a browser", () => { + let browser: Browser; + let origin: string; + const server = createServer(); + + before(async () => { + const result = await build({ + stdin: { + contents: ` + import { instantiateWasm, WasmTransport } from "@typescript/typescript-wasip1-wasm"; + import { API as AsyncAPI } from "typescript/unstable/async"; + import { API as SyncAPI } from "typescript/unstable/sync"; + + 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.updateSnapshot({ + openFiles: ["/src/index.ts"], + fileChanges: { + changed: ["/tsconfig.json", "/src/index.ts"], + deleted: [], + }, + }); + const project = await snapshot.getDefaultProjectForFile("/src/index.ts"); + const sourceFile = await project.program.getSourceFile("/src/index.ts"); + const name = sourceFile.statements[0].declarationList.declarations[0].name; + const type = await project.checker.getTypeAtLocation(name); + return project.checker.typeToString(type); + } + finally { + await api.close(); + } + } + + export async function run() { + const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); + return [ + await check(AsyncAPI, module), + await check(SyncAPI, module), + ]; + } + `, + loader: "ts", + resolveDir: process.cwd(), + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source", "browser"], + format: "esm", + platform: "browser", + write: false, + }); + assert.strictEqual(result.outputFiles.length, 1); + const bundle = result.outputFiles[0].contents; + const wasm = await readFile(wasmURL); + + server.on("request", (request, response) => { + switch (request.url) { + case "/": + response.setHeader("Content-Type", "text/html"); + response.end(''); + break; + case "/bundle.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(bundle); + break; + case "/tsc.wasm": + response.setHeader("Content-Type", "application/wasm"); + response.end(wasm); + break; + default: + response.statusCode = 404; + response.end(); + break; + } + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + assert.ok(address && typeof address !== "string"); + origin = `http://127.0.0.1:${address.port}`; + browser = await chromium.launch(); + }); + + after(async () => { + await browser?.close(); + await new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + }); + }); + + test("runs the async and sync APIs in Chromium", async () => { + const page = await browser.newPage(); + await page.goto(origin); + const result = await page.evaluate(() => Reflect.get(globalThis, "result")); + assert.deepStrictEqual(result, ["42", "42"]); + }); +}); diff --git a/packages/typescript/test/encoder.test.ts b/packages/typescript/test/encoder.test.ts index c214e0675f56e..6e88571865a80 100644 --- a/packages/typescript/test/encoder.test.ts +++ b/packages/typescript/test/encoder.test.ts @@ -35,6 +35,7 @@ import { import { encodeNode, encodeSourceFile, + uint8ArrayToBase64, } from "../src/api/node/encoder.ts"; import { RemoteNode, @@ -57,6 +58,16 @@ function decode(data: Uint8Array): RemoteSourceFile { } describe("Encoder", () => { + test("encodes base64 using the available platform implementation", () => { + const data = new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4); + assert.strictEqual(uint8ArrayToBase64(data), "AQID"); + + Object.defineProperty(data, "toBase64", { + value: () => "native", + }); + assert.strictEqual(uint8ArrayToBase64(data), "native"); + }); + test("encodes empty source file", () => { const sf = makeSF("", "/test.ts", []); diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts new file mode 100644 index 0000000000000..e6088891370f6 --- /dev/null +++ b/packages/typescript/test/sync/transport.test.ts @@ -0,0 +1,176 @@ +import { + type WasmReactorExports, + WasmTransport, +} from "@typescript/typescript-wasip1-wasm"; +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/unstable/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); + }); + + test("drives the WASM reactor ABI", () => { + const memory = { buffer: new ArrayBuffer(65536) }; + const requestPointer = 0; + const responsePointer = 4096; + let responseLength = 0; + let closed = false; + const files = new Map(); + + function setResponse(value: string | Uint8Array) { + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value; + new Uint8Array(memory.buffer).set(bytes, responsePointer); + responseLength = bytes.length; + } + + const exports: WasmReactorExports = { + memory, + create_session() { + setResponse(""); + return 0; + }, + close_session() { + closed = true; + }, + get_request_buffer() { + return requestPointer; + }, + handle_request(methodLength, payloadLength) { + const bytes = new Uint8Array(memory.buffer); + const method = new TextDecoder().decode(bytes.subarray(requestPointer, requestPointer + methodLength)); + const payload = bytes.slice(requestPointer + methodLength, requestPointer + methodLength + payloadLength); + if (method === "text") { + setResponse(new TextDecoder().decode(payload).toUpperCase()); + } + else { + setResponse(payload.reverse()); + } + return 0; + }, + set_file(pathLength, contentLength) { + const bytes = new Uint8Array(memory.buffer); + const path = new TextDecoder().decode(bytes.subarray(requestPointer, requestPointer + pathLength)); + const content = new TextDecoder().decode( + bytes.subarray(requestPointer + pathLength, requestPointer + pathLength + contentLength), + ); + files.set(path, content); + return 0; + }, + read_file(pathLength) { + const path = new TextDecoder().decode(new Uint8Array(memory.buffer, requestPointer, pathLength)); + const content = files.get(path); + if (content === undefined) { + setResponse(""); + return 2; + } + setResponse(content); + return 0; + }, + remove_file(pathLength) { + const path = new TextDecoder().decode(new Uint8Array(memory.buffer, requestPointer, pathLength)); + files.delete(path); + return 0; + }, + response_ptr: () => responsePointer, + response_len: () => responseLength, + }; + + const transport = new WasmTransport({ + instance: { exports }, + cwd: "/workspace", + }); + transport.setFile("/workspace/index.ts", "const value = 1;"); + assert.strictEqual(files.get("/workspace/index.ts"), "const value = 1;"); + assert.strictEqual(transport.readFile("/workspace/index.ts"), "const value = 1;"); + assert.strictEqual(transport.readFile("/workspace/missing.ts"), undefined); + assert.strictEqual(transport.requestSync("text", "hello"), "HELLO"); + assert.deepStrictEqual(transport.requestBinarySync("binary", new Uint8Array([1, 2, 3])), new Uint8Array([3, 2, 1])); + transport.removeFile("/workspace/index.ts"); + assert.strictEqual(files.has("/workspace/index.ts"), false); + transport.close(); + assert.strictEqual(closed, true); + assert.throws( + () => transport.setFileSystem({ writeFile() {} }), + /TypeScript WASM transport is closed/, + ); + }); +}); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts new file mode 100644 index 0000000000000..16795b748a911 --- /dev/null +++ b/packages/typescript/test/sync/wasm.test.ts @@ -0,0 +1,202 @@ +import { + instantiateWasm, + WasmTransport, + wasmURL, +} from "@typescript/typescript-wasip1-wasm"; +import { + isIdentifier, + isVariableStatement, +} from "@typescript/typescript/unstable/ast"; +import { API as AsyncAPI } from "@typescript/typescript/unstable/async"; +import { API as SyncAPI } from "@typescript/typescript/unstable/sync"; +import assert from "node:assert"; +import { + mkdtemp, + open, + readFile, + rm, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + describe, + test, +} from "node:test"; +import { WASI } from "node:wasi"; + +describe("API over WebAssembly", () => { + test("runs the compiler command from the same module", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); + try { + const stdoutPath = path.join(directory, "stdout"); + const stdout = await open(stdoutPath, "w+"); + try { + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--version"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + stdout: stdout.fd, + returnOnExit: true, + }); + const module = await WebAssembly.compile(await readFile(wasmURL)); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + assert.strictEqual(wasi.start(instance), 0); + } + finally { + await stdout.close(); + } + assert.match(await readFile(stdoutPath, "utf8"), /^Version \d+\.\d+\.\d+/); + } + finally { + await rm(directory, { recursive: true }); + } + }); + + test("exits successfully when LSP stdin closes", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); + try { + const stdin = await open(path.join(directory, "stdin"), "w+"); + const stdout = await open(path.join(directory, "stdout"), "w+"); + const stderr = await open(path.join(directory, "stderr"), "w+"); + try { + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--lsp", "--stdio"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + stdin: stdin.fd, + stdout: stdout.fd, + stderr: stderr.fd, + returnOnExit: true, + }); + const module = await WebAssembly.compile(await readFile(wasmURL)); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + assert.strictEqual(wasi.start(instance), 0); + } + finally { + await Promise.all([stdin.close(), stdout.close(), stderr.close()]); + } + } + finally { + await rm(directory, { recursive: true }); + } + }); + + test("runs the compiler and checker through the reactor", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const module = await WebAssembly.compile( + await readFile(wasmURL), + ); + assert.deepStrictEqual( + [...new Set(WebAssembly.Module.imports(module).map((value: { module: string; }) => value.module))], + ["wasi_snapshot_preview1"], + ); + const exportNames = WebAssembly.Module.exports(module).map((value: { name: string; }) => value.name); + assert.ok(exportNames.includes("_start")); + assert.ok(exportNames.includes("typescript_initialize")); + assert.ok(!exportNames.includes("_initialize")); + const instance = await instantiateWasm(module); + + assert.throws( + () => + new WasmTransport({ + instance: { exports: instance.exports }, + fs: { writeFile() {} }, + }), + /was not created by instantiateWasm/, + ); + + const emittedFiles = new Map(); + const transport = new WasmTransport({ + instance, + cwd: "/", + collectTiming: true, + fs: { + writeFile(path, data) { + emittedFiles.set(path, data); + }, + }, + }); + const api = new SyncAPI({ transport, collectTiming: true }); + assert.strictEqual(transport.requestSync("echo", "text"), "text"); + assert.deepStrictEqual( + transport.requestBinarySync("echo", new Uint8Array([1, 2, 3])), + new Uint8Array([1, 2, 3]), + ); + + transport.setFile("/tsconfig.json", "{}"); + transport.setFile("/src/index.ts", "export const value = 42 as const;"); + const snapshot = api.updateSnapshot({ + openFiles: ["/src/index.ts"], + fileChanges: { + changed: ["/tsconfig.json", "/src/index.ts"], + deleted: [], + }, + }); + const project = snapshot.getDefaultProjectForFile("/src/index.ts"); + assert.ok(project); + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const statement = sourceFile.statements[0]; + assert.ok(isVariableStatement(statement)); + const name = statement.declarationList.declarations[0].name; + assert.ok(isIdentifier(name)); + const type = project.checker.getTypeAtLocation(name); + assert.strictEqual(project.checker.typeToString(type), "42"); + assert.throws( + () => + new WasmTransport({ + instance, + fs: { writeFile() {} }, + }), + /session already created/, + ); + assert.deepStrictEqual(project.program.emit(), { + diagnostics: [], + emitSkipped: false, + emittedFiles: ["/src/index.js"], + }); + assert.strictEqual(emittedFiles.get("/src/index.js"), "export const value = 42;\n"); + + const timing = api.getTimingInfo(); + assert.strictEqual(timing.enabled, true); + assert.ok(timing.totals.requestCount > 0); + assert.ok(timing.totals.serverTimeMs >= 0); + api.close(); + + const secondTransport = new WasmTransport({ instance, cwd: "/" }); + const second = new SyncAPI({ transport: secondTransport }); + assert.strictEqual(secondTransport.requestSync("echo", "recreated"), "recreated"); + second.close(); + + const asyncTransport = new WasmTransport({ instance, cwd: "/" }); + const asyncAPI = new AsyncAPI({ transport: asyncTransport }); + asyncTransport.setFile("/tsconfig.json", "{}"); + asyncTransport.setFile("/src/async.ts", "export const value = 42 as const;"); + const asyncSnapshot = await asyncAPI.updateSnapshot({ + openFiles: ["/src/async.ts"], + fileChanges: { + changed: ["/tsconfig.json", "/src/async.ts"], + deleted: [], + }, + }); + const asyncProject = await asyncSnapshot.getDefaultProjectForFile("/src/async.ts"); + assert.ok(asyncProject); + const asyncSourceFile = await asyncProject.program.getSourceFile("/src/async.ts"); + assert.ok(asyncSourceFile); + const asyncStatement = asyncSourceFile.statements[0]; + assert.ok(isVariableStatement(asyncStatement)); + const asyncName = asyncStatement.declarationList.declarations[0].name; + assert.ok(isIdentifier(asyncName)); + const asyncType = await asyncProject.checker.getTypeAtLocation(asyncName); + assert.strictEqual(await asyncProject.checker.typeToString(asyncType), "42"); + await asyncAPI.close(); + }); +}); diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index a2630f570a209..4ec2270ada084 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -3,6 +3,16 @@ import { versionMajorMinor } from "@typescript/typescript"; import assert from "node:assert"; import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { + cp, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { test } from "node:test"; test("versionMajorMinor runtime and declaration match the compiler version", () => { @@ -17,3 +27,43 @@ test("versionMajorMinor runtime and declaration match the compiler version", () assert.ok(declarationMatch, "versionMajorMinor declaration not found"); assert.strictEqual(declarationMatch[1], majorMinor); }); + +test("the CLI falls back to the WASI package", { + skip: Number(process.versions.node.split(".")[0]) < 23 ? "Node.js 23 or newer is required" : undefined, +}, async () => { + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasi-fallback-")); + try { + const packageDirectory = path.join(directory, "node_modules", "typescript"); + const libraryDirectory = path.join(packageDirectory, "lib"); + await mkdir(libraryDirectory, { recursive: true }); + await Promise.all([ + cp(new URL("../lib/tsc.js", import.meta.url), path.join(libraryDirectory, "tsc.js")), + cp(new URL("../lib/getExePath.js", import.meta.url), path.join(libraryDirectory, "getExePath.js")), + writeFile( + path.join(packageDirectory, "package.json"), + JSON.stringify({ + name: "typescript", + type: "module", + imports: { "#getExePath": "./lib/getExePath.js" }, + }), + ), + ]); + + const scopeDirectory = path.join(directory, "node_modules", "@typescript"); + await mkdir(scopeDirectory, { recursive: true }); + await symlink( + new URL("../../typescript-wasip1-wasm", import.meta.url), + path.join(scopeDirectory, "typescript-wasip1-wasm"), + process.platform === "win32" ? "junction" : "dir", + ); + + const output = execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), "--version"], { + cwd: directory, + encoding: "utf8", + }); + assert.match(output, /^Version \d+\.\d+\.\d+/); + } + finally { + await rm(directory, { recursive: true }); + } +}); diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index b85898c865e99..d215dd3907de9 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -73,6 +73,10 @@ extends: targetPath: '$(Build.ArtifactStagingDirectory)/npm-main' artifactName: 'npm-main' displayName: 'Publish npm-main artifact' + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/npm-wasip1' + artifactName: 'npm-wasip1' + displayName: 'Publish npm-wasip1 artifact' steps: - checkout: none @@ -83,24 +87,31 @@ extends: src="$(Pipeline.Workspace)/npm" platformDst="$(Build.ArtifactStagingDirectory)/npm-platform" mainDst="$(Build.ArtifactStagingDirectory)/npm-main" - mkdir -p "$platformDst" "$mainDst" + wasmDst="$(Build.ArtifactStagingDirectory)/npm-wasip1" + mkdir -p "$platformDst" "$mainDst" "$wasmDst" manifest="$src/publish-manifest.json" stageCount="$(jq '.stages | length' "$manifest")" - if [ "$stageCount" -ne 2 ]; then - echo "Expected exactly 2 npm publish stages, found $stageCount." >&2 + if [ "$stageCount" -ne 3 ]; then + echo "Expected exactly 3 npm publish stages, found $stageCount." >&2 exit 1 fi jq -r '.stages[0][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$platformDst/" jq -r '.stages[1][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$mainDst/" + jq -r '.stages[2][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$wasmDst/" platformCount="$(find "$platformDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" mainCount="$(find "$mainDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" - echo "##[section]Staged $platformCount platform package(s) and $mainCount main package(s)." + wasmCount="$(find "$wasmDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" + echo "##[section]Staged $platformCount platform package(s), $mainCount main package(s), and $wasmCount WASM package(s)." if [ "$mainCount" -ne 1 ]; then echo "Expected 1 main package, found $mainCount." >&2 exit 1 fi + if [ "$wasmCount" -ne 1 ]; then + echo "Expected 1 WASM package, found $wasmCount." >&2 + exit 1 + fi displayName: 'Stage npm artifacts' - stage: Publish_npm @@ -122,6 +133,9 @@ extends: - input: pipelineArtifact artifactName: 'npm-main' targetPath: '$(Pipeline.Workspace)/npm-main' + - input: pipelineArtifact + artifactName: 'npm-wasip1' + targetPath: '$(Pipeline.Workspace)/npm-wasip1' steps: - checkout: none @@ -146,6 +160,16 @@ extends: owners: 'jabaile@microsoft.com' approvers: 'alexchi@microsoft.com' + - template: MicroBuild.Publish.yml@MicroBuildTemplate + parameters: + intent: 'PackageDistribution' + contentType: 'npm' + contentSource: 'Folder' + folderLocation: '$(Pipeline.Workspace)/npm-wasip1' + waitForReleaseCompletion: true + owners: 'jabaile@microsoft.com' + approvers: 'alexchi@microsoft.com' + - stage: Publish_vsix displayName: Publish VSIX dependsOn: Prepare diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index 56058687c66e5..4d932d8bae412 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" @@ -26,7 +25,7 @@ type apiFlags struct { func parseAPIFlags(args []string) (apiFlags, error) { flags := flag.NewFlagSet("api", flag.ContinueOnError) result := apiFlags{} - flags.StringVar(&result.cwd, "cwd", core.Must(os.Getwd()), "current working directory") + flags.StringVar(&result.cwd, "cwd", core.Must(getCurrentDirectory()), "current working directory") flags.StringVar(&result.pipePath, "pipe", "", "use named pipe or Unix domain socket for communication instead of stdio") flags.StringVar(&result.callbacks, "callbacks", "", "comma-separated list of FS callbacks to enable (readFile,fileExists,directoryExists,getAccessibleEntries,realpath)") flags.BoolVar(&result.async, "async", false, "use JSON-RPC protocol instead of MessagePack (for async API)") @@ -71,7 +70,7 @@ func runAPI(args []string) int { s := api.NewStdioServer(options) - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := notifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := s.Run(ctx); err != nil { diff --git a/tsc/cmd/tsc/currentdirectory.go b/tsc/cmd/tsc/currentdirectory.go new file mode 100644 index 0000000000000..a132c34ae6837 --- /dev/null +++ b/tsc/cmd/tsc/currentdirectory.go @@ -0,0 +1,9 @@ +//go:build !wasip1 + +package main + +import "os" + +func getCurrentDirectory() (string, error) { + return os.Getwd() +} diff --git a/tsc/cmd/tsc/currentdirectory_wasip1.go b/tsc/cmd/tsc/currentdirectory_wasip1.go new file mode 100644 index 0000000000000..96f1adbd201d2 --- /dev/null +++ b/tsc/cmd/tsc/currentdirectory_wasip1.go @@ -0,0 +1,17 @@ +//go:build wasip1 + +package main + +import ( + "os" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +func getCurrentDirectory() (string, error) { + cwd := os.Getenv("PWD") + if !tspath.IsRootedDiskPath(cwd) { + cwd = "/" + } + return cwd, nil +} diff --git a/tsc/cmd/tsc/lsp.go b/tsc/cmd/tsc/lsp.go index 2a0361723ab8b..dbc63f231ca34 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,23 +43,19 @@ func runLSP(args []string) int { defaultLibraryPath := bundled.LibPath() typingsLocation := osvfs.GetGlobalTypingsCacheLocation() - 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(lspStdin), Out: lsp.ToWriter(os.Stdout), Err: os.Stderr, - Cwd: core.Must(os.Getwd()), + Cwd: core.Must(getCurrentDirectory()), FS: fs, DefaultLibraryPath: defaultLibraryPath, TypingsLocation: typingsLocation, - NpmInstall: func(cwd string, args []string) ([]byte, error) { - cmd := exec.Command("npm", args...) - cmd.Dir = cwd - return cmd.Output() - }, - Spawn: spawnProcess, + NpmInstall: getNpmInstall(), + Spawn: getLSPSpawn(), ProgressDelay: 250 * time.Millisecond, SetParentProcessID: newParentProcessWatchdog(ctx, stop, *clientProcessID), }) diff --git a/tsc/cmd/tsc/lsp_process.go b/tsc/cmd/tsc/lsp_process.go new file mode 100644 index 0000000000000..1f584125f8d44 --- /dev/null +++ b/tsc/cmd/tsc/lsp_process.go @@ -0,0 +1,20 @@ +//go:build !wasip1 + +package main + +import ( + "io" + "os/exec" +) + +func getNpmInstall() func(cwd string, args []string) ([]byte, error) { + return func(cwd string, args []string) ([]byte, error) { + cmd := exec.Command("npm", args...) + cmd.Dir = cwd + return cmd.Output() + } +} + +func getLSPSpawn() func(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + return spawnProcess +} diff --git a/tsc/cmd/tsc/lsp_process_wasip1.go b/tsc/cmd/tsc/lsp_process_wasip1.go new file mode 100644 index 0000000000000..b10eb4699fa14 --- /dev/null +++ b/tsc/cmd/tsc/lsp_process_wasip1.go @@ -0,0 +1,18 @@ +//go:build wasip1 + +package main + +import ( + "errors" + "io" +) + +func getNpmInstall() func(cwd string, args []string) ([]byte, error) { + return func(string, []string) ([]byte, error) { + return nil, errors.New("installing npm packages is not supported in WebAssembly") + } +} + +func getLSPSpawn() func(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + return nil +} diff --git a/tsc/cmd/tsc/lsp_stdin.go b/tsc/cmd/tsc/lsp_stdin.go new file mode 100644 index 0000000000000..53541f288959e --- /dev/null +++ b/tsc/cmd/tsc/lsp_stdin.go @@ -0,0 +1,10 @@ +//go:build !wasip1 + +package main + +import ( + "io" + "os" +) + +var lspStdin io.Reader = os.Stdin diff --git a/tsc/cmd/tsc/lsp_stdin_wasip1.go b/tsc/cmd/tsc/lsp_stdin_wasip1.go new file mode 100644 index 0000000000000..3f6ed68d10dd1 --- /dev/null +++ b/tsc/cmd/tsc/lsp_stdin_wasip1.go @@ -0,0 +1,26 @@ +//go:build wasip1 + +package main + +import ( + "io" + "runtime" + "syscall" +) + +type wasiStdin struct{} + +func (wasiStdin) Read(buffer []byte) (int, error) { + for { + n, err := syscall.Read(syscall.Stdin, buffer) + if n == 0 && err == nil { + return 0, io.EOF + } + if err != syscall.EAGAIN { + return n, err + } + runtime.Gosched() + } +} + +var lspStdin wasiStdin diff --git a/tsc/cmd/tsc/main.go b/tsc/cmd/tsc/main.go index cbe3f0f4e567b..899113dc19fc9 100644 --- a/tsc/cmd/tsc/main.go +++ b/tsc/cmd/tsc/main.go @@ -3,7 +3,6 @@ package main import ( "context" "os" - "os/signal" "syscall" "github.com/microsoft/TypeScript/tsc/internal/core" @@ -26,7 +25,7 @@ func runMain() int { return runAPI(args[1:]) } } - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + ctx, stop := notifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() result := execute.CommandLine(ctx, newSystem(), args, nil) return int(result.Status) diff --git a/tsc/cmd/tsc/notifycontext.go b/tsc/cmd/tsc/notifycontext.go new file mode 100644 index 0000000000000..0508bcd115e89 --- /dev/null +++ b/tsc/cmd/tsc/notifycontext.go @@ -0,0 +1,13 @@ +//go:build !wasip1 + +package main + +import ( + "context" + "os" + "os/signal" +) + +func notifyContext(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, signals...) +} diff --git a/tsc/cmd/tsc/notifycontext_wasip1.go b/tsc/cmd/tsc/notifycontext_wasip1.go new file mode 100644 index 0000000000000..807f7b1c9b261 --- /dev/null +++ b/tsc/cmd/tsc/notifycontext_wasip1.go @@ -0,0 +1,12 @@ +//go:build wasip1 + +package main + +import ( + "context" + "os" +) + +func notifyContext(parent context.Context, _ ...os.Signal) (context.Context, context.CancelFunc) { + return context.WithCancel(parent) +} diff --git a/tsc/cmd/tsc/sys.go b/tsc/cmd/tsc/sys.go index d73dfc393fff2..719a2b2902040 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..afb7ce87703eb --- /dev/null +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -0,0 +1,273 @@ +//go:build wasip1 + +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "os" + "runtime/debug" + "unsafe" + + "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/wrapvfs" +) + +const ( + // API filesystem callbacks are tunneled through a reserved descriptor so that + // the module only requires standard WASI imports and remains directly runnable. + hostWriteFileFD = 0x7fff_fffe + hostErrorCapacity = 16 * 1024 + errnoBadFileNumber = 8 +) + +var ( + reactor *wasmreactor.Reactor + + requestBuffer []byte + + responseBuffer []byte + responsePtr uint32 + responseLen uint32 + + inCall bool +) + +//go:wasmexport create_session +func createSession(optionsPtr uint32, optionsLen uint32) (status uint32) { + if reactor != nil { + return fail("session already created") + } + defer func() { + if recovered := recover(); recovered != nil { + reactor = nil + status = fail(fmt.Sprintf("panic creating session: %v\n%s", recovered, debug.Stack())) + } + }() + + var options wasmreactor.Options + if err := json.Unmarshal(readMemory(optionsPtr, optionsLen), &options); err != nil { + return fail(fmt.Sprintf("invalid session options: %v", err)) + } + options.WrapFS = func(files vfs.FS) vfs.FS { + return wrapvfs.Wrap(files, wrapvfs.Replacements{ + WriteFile: func(path string, data string) error { + handled, err := writeFileToHost(path, data) + if !handled { + return files.WriteFile(path, data) + } + return err + }, + }) + } + reactor = wasmreactor.New(context.Background(), options) + setResponse(nil) + return 0 +} + +//go:wasmexport close_session +func closeSession() { + if reactor == nil { + return + } + current := reactor + reactor = nil + defer func() { + _ = recover() + }() + current.Close() +} + +//go:wasmexport get_request_buffer +func getRequestBuffer(size uint32) uint32 { + if uint32(cap(requestBuffer)) < size || (cap(requestBuffer) > 1<<20 && uint32(cap(requestBuffer)) > 4*size) { + requestBuffer = make([]byte, size) + } + requestBuffer = requestBuffer[:size] + return bytesPointer(requestBuffer) +} + +//go:wasmexport handle_request +func handleRequest(methodLen uint32, payloadLen uint32) (status uint32) { + if inCall { + return fail("re-entrant handle_request: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(methodLen, payloadLen) { + return fail("invalid request lengths: method + payload exceeds the request buffer") + } + inCall = true + defer func() { + inCall = false + }() + defer func() { + if recovered := recover(); recovered != nil { + status = fail(fmt.Sprintf("panic: %v\n%s", recovered, debug.Stack())) + } + }() + + method := string(requestBuffer[:methodLen]) + payload := append([]byte(nil), requestBuffer[methodLen:methodLen+payloadLen]...) + response, err := reactor.HandleRequest(method, payload) + if err != nil { + return fail(err.Error()) + } + setResponse(response.Data) + return 0 +} + +//go:wasmexport set_file +func setFile(pathLen uint32, contentLen uint32) uint32 { + if inCall { + return fail("re-entrant set_file: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(pathLen, contentLen) { + return fail("invalid set_file lengths: path + content exceeds the request buffer") + } + path := string(requestBuffer[:pathLen]) + content := string(requestBuffer[pathLen : pathLen+contentLen]) + if err := reactor.SetFile(path, content); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return 0 +} + +//go:wasmexport read_file +func readFile(pathLen uint32) (status uint32) { + if inCall { + return fail("re-entrant read_file: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(pathLen, 0) { + return fail("invalid read_file length: path exceeds the request buffer") + } + defer func() { + if recovered := recover(); recovered != nil { + status = fail(fmt.Sprintf("panic reading file: %v\n%s", recovered, debug.Stack())) + } + }() + content, ok := reactor.ReadFile(string(requestBuffer[:pathLen])) + if !ok { + setResponse(nil) + return 2 + } + setResponse([]byte(content)) + return 0 +} + +//go:wasmexport remove_file +func removeFile(pathLen uint32) uint32 { + if inCall { + return fail("re-entrant remove_file: a request is already in flight") + } + if reactor == nil { + return fail("no session: create_session must be called first") + } + if !withinRequest(pathLen, 0) { + return fail("invalid remove_file length: path exceeds the request buffer") + } + if err := reactor.RemoveFile(string(requestBuffer[:pathLen])); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return 0 +} + +//go:wasmexport response_ptr +func responsePointer() uint32 { + return responsePtr +} + +//go:wasmexport response_len +func responseLength() uint32 { + return responseLen +} + +//go:wasmexport __typescript_cli_start +func cliStart() { + os.Exit(runMain()) +} + +func fail(message string) uint32 { + setResponse([]byte(message)) + return 1 +} + +func setResponse(data []byte) { + responseBuffer = data + responsePtr = bytesPointer(responseBuffer) + responseLen = uint32(len(responseBuffer)) +} + +func withinRequest(first uint32, second uint32) bool { + return first+second >= first && first+second <= uint32(len(requestBuffer)) +} + +func readMemory(pointer uint32, length uint32) []byte { + if length == 0 { + return nil + } + return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(pointer))), length) +} + +func bytesPointer(bytes []byte) uint32 { + if len(bytes) == 0 { + return 0 + } + return uint32(uintptr(unsafe.Pointer(&bytes[0]))) +} + +func bytePointer(bytes []byte) *byte { + if len(bytes) == 0 { + return nil + } + return &bytes[0] +} + +func writeFileToHost(path string, data string) (bool, error) { + pathBytes := []byte(path) + dataBytes := []byte(data) + buffer := make([]byte, 12+len(pathBytes)+len(dataBytes)+hostErrorCapacity) + binary.LittleEndian.PutUint32(buffer, uint32(len(pathBytes))) + binary.LittleEndian.PutUint32(buffer[4:], uint32(len(dataBytes))) + binary.LittleEndian.PutUint32(buffer[8:], hostErrorCapacity) + copy(buffer[12:], pathBytes) + copy(buffer[12+len(pathBytes):], dataBytes) + + iov := [2]uint32{bytesPointer(buffer), uint32(len(buffer))} + var written uint32 + errno := hostFDWrite(hostWriteFileFD, unsafe.Pointer(&iov[0]), 1, &written) + if errno == errnoBadFileNumber { + return false, nil + } + if errno == 0 { + return true, nil + } + + errorLength := binary.LittleEndian.Uint32(buffer[8:]) + if errorLength > hostErrorCapacity { + errorLength = hostErrorCapacity + } + errorStart := 12 + len(pathBytes) + len(dataBytes) + message := string(buffer[errorStart : errorStart+int(errorLength)]) + if message == "" { + message = "host filesystem callback failed" + } + return true, errors.New(message) +} + +//go:wasmimport wasi_snapshot_preview1 fd_write +//go:noescape +func hostFDWrite(fd int32, iovs unsafe.Pointer, iovsLen uint32, written *uint32) uint32 diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go new file mode 100644 index 0000000000000..bbf1d75ba48ce --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -0,0 +1,122 @@ +// Package wasmreactor hosts an API session without an IPC transport. +package wasmreactor + +import ( + "context" + "sync" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/api" + "github.com/microsoft/TypeScript/tsc/internal/bundled" + "github.com/microsoft/TypeScript/tsc/internal/ipc" + "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" + "github.com/microsoft/TypeScript/tsc/internal/project" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" +) + +type Options struct { + Cwd string `json:"cwd"` + UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` + CollectTiming bool `json:"collectTiming"` + WrapFS func(vfs.FS) vfs.FS `json:"-"` +} + +type Response struct { + Data []byte +} + +type Reactor struct { + ctx context.Context + cancel context.CancelFunc + session *api.Session + files vfs.FS + timing *ipc.ServerTimingCollector + closeOnce sync.Once +} + +func New(ctx context.Context, options Options) *Reactor { + if options.Cwd == "" { + options.Cwd = "/" + } + useCaseSensitiveFileNames := options.UseCaseSensitiveFileNames == nil || *options.UseCaseSensitiveFileNames + files := vfstest.FromMap(map[string]string{}, useCaseSensitiveFileNames) + var projectFiles vfs.FS = files + if options.WrapFS != nil { + projectFiles = options.WrapFS(projectFiles) + } + ctx, cancel := context.WithCancel(ctx) + sessionInit := &project.SessionInit{ + BackgroundCtx: ctx, + FS: bundled.WrapFS(projectFiles), + Options: &project.SessionOptions{ + CurrentDirectory: options.Cwd, + DefaultLibraryPath: bundled.LibPath(), + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoggingEnabled: false, + }, + } + reactor := &Reactor{ + ctx: ctx, + cancel: cancel, + session: api.NewStandaloneSession(sessionInit, &api.SessionOptions{UseBinaryResponses: true}), + files: files, + } + if options.CollectTiming { + reactor.timing = ipc.NewServerTimingCollector() + } + return reactor +} + +func (r *Reactor) HandleRequest(method string, payload []byte) (Response, error) { + switch method { + case ipc.MethodGetServerTiming: + return marshalResponse(ipc.ServerTimingSnapshot(r.timing)) + case ipc.MethodResetServerTiming: + if r.timing != nil { + r.timing.Reset() + } + return marshalResponse(nil) + } + + start := time.Now() + result, err := r.session.HandleRequest(r.ctx, method, json.Value(payload)) + if r.timing != nil { + r.timing.Record(method, time.Since(start)) + } + if err != nil { + return Response{}, err + } + if raw, ok := result.(api.RawBinary); ok { + return Response{Data: []byte(raw)}, nil + } + return marshalResponse(result) +} + +func marshalResponse(result any) (Response, error) { + encoded, err := json.Marshal(result) + if err != nil { + return Response{}, err + } + return Response{Data: encoded}, nil +} + +func (r *Reactor) SetFile(path string, content string) error { + return r.files.WriteFile(path, content) +} + +func (r *Reactor) ReadFile(path string) (string, bool) { + return r.files.ReadFile(path) +} + +func (r *Reactor) RemoveFile(path string) error { + return r.files.Remove(path) +} + +func (r *Reactor) Close() { + r.closeOnce.Do(func() { + r.cancel() + r.session.Close() + }) +} diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go new file mode 100644 index 0000000000000..a59eb8901ab14 --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -0,0 +1,56 @@ +package wasmreactor + +import ( + "bytes" + "context" + "testing" + + "github.com/microsoft/TypeScript/tsc/internal/json" +) + +func TestReactor(t *testing.T) { + t.Parallel() + + reactor := New(context.Background(), Options{Cwd: "/"}) + defer reactor.Close() + + if err := reactor.SetFile("/src/index.ts", "export const value = 1;"); err != nil { + t.Fatal(err) + } + if contents, ok := reactor.ReadFile("/src/index.ts"); !ok || contents != "export const value = 1;" { + t.Fatalf("ReadFile() = %q, %v", contents, ok) + } + if _, ok := reactor.ReadFile("/src/missing.ts"); ok { + t.Fatal("ReadFile() found a missing file") + } + + initialize, err := reactor.HandleRequest("initialize", nil) + if err != nil { + t.Fatal(err) + } + var initializeResponse struct { + CurrentDirectory string `json:"currentDirectory"` + } + if unmarshalErr := json.Unmarshal(initialize.Data, &initializeResponse); unmarshalErr != nil { + t.Fatal(unmarshalErr) + } + if initializeResponse.CurrentDirectory != "/" { + t.Fatalf("currentDirectory = %q, want /", initializeResponse.CurrentDirectory) + } + + payload := []byte("reactor") + echo, err := reactor.HandleRequest("echo", payload) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(echo.Data, payload) { + t.Fatalf("echo = %q", echo.Data) + } + + if err := reactor.RemoveFile("/src/index.ts"); err != nil { + t.Fatal(err) + } + if _, ok := reactor.files.ReadFile("/src/index.ts"); ok { + t.Fatal("removed file still exists") + } +} diff --git a/tsc/internal/ipc/timing.go b/tsc/internal/ipc/timing.go index 4114a548eae99..5a6148c5aea24 100644 --- a/tsc/internal/ipc/timing.go +++ b/tsc/internal/ipc/timing.go @@ -60,6 +60,35 @@ type timingCollector struct { head int } +// ServerTimingCollector records server-side request processing time for transports +// that dispatch directly to a Handler rather than through a connection. +type ServerTimingCollector struct { + collector *timingCollector +} + +// NewServerTimingCollector creates an empty server timing collector. +func NewServerTimingCollector() *ServerTimingCollector { + return &ServerTimingCollector{collector: newTimingCollector()} +} + +// Record adds a completed request to the collector. +func (c *ServerTimingCollector) Record(method string, duration time.Duration) { + c.collector.record(method, duration) +} + +// Reset clears all collected request timing. +func (c *ServerTimingCollector) Reset() { + c.collector.reset() +} + +// ServerTimingSnapshot returns the collector's current JSON-serializable snapshot. +func ServerTimingSnapshot(collector *ServerTimingCollector) any { + if collector == nil { + return disabledServerTimingInfo() + } + return collector.collector.snapshot() +} + func newTimingCollector() *timingCollector { return &timingCollector{} }