From 78e77aab21c49bdae76ef17e692d01e93992fe22 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:47:52 -0700 Subject: [PATCH 001/142] Add WebAssembly transport for the TypeScript API Add an injectable synchronous transport and a browser-conditioned client for the TypeScript API. Provide a Go WebAssembly reactor backed by an in-memory filesystem and exercise it through the compiler and checker APIs. Publish the reactor and its transport separately as @typescript/api-wasm, with release-versioned builds and ordered npm publishing. Keep the main TypeScript package free of the WebAssembly binary and verify its browser module graph with an esbuild regression test. --- .github/workflows/ci.yml | 4 + Herebyfile.mjs | 95 ++++- package-lock.json | 39 ++ packages/api-wasm/README.md | 21 ++ packages/api-wasm/package.json | 30 ++ packages/api-wasm/src/index.ts | 181 +++++++++ packages/api-wasm/src/wasi.ts | 237 ++++++++++++ packages/api-wasm/src/wasmURL.source.ts | 2 + packages/api-wasm/src/wasmURL.ts | 2 + packages/api-wasm/tsconfig.json | 25 ++ packages/typescript/package.json | 28 +- packages/typescript/src/api/async/api.ts | 21 +- .../typescript/src/api/async/browserClient.ts | 19 + packages/typescript/src/api/async/client.ts | 40 +- .../typescript/src/api/async/transport.ts | 19 + .../src/api/async/transportClient.ts | 299 +++++++++++++++ packages/typescript/src/api/node/encoder.ts | 29 +- packages/typescript/src/api/node/wtf8.ts | 4 +- packages/typescript/src/api/options.ts | 37 +- packages/typescript/src/api/sync/api.ts | 12 +- .../typescript/src/api/sync/browserClient.ts | 19 + packages/typescript/src/api/sync/client.ts | 154 +------- packages/typescript/src/api/sync/transport.ts | 18 + .../src/api/sync/transportClient.ts | 131 +++++++ .../typescript/test/async/transport.test.ts | 354 ++++++++++++++++++ .../typescript/test/browser/bundle.test.ts | 37 ++ packages/typescript/test/browser/wasm.test.ts | 122 ++++++ packages/typescript/test/encoder.test.ts | 11 + .../typescript/test/sync/transport.test.ts | 160 ++++++++ packages/typescript/test/sync/wasm.test.ts | 95 +++++ tools/pipelines/typescript-publish.yml | 32 +- tsc/cmd/tsc-api-wasm/main.go | 250 +++++++++++++ tsc/internal/api/wasmreactor/reactor.go | 118 ++++++ tsc/internal/api/wasmreactor/reactor_test.go | 53 +++ tsc/internal/ipc/timing.go | 29 ++ 35 files changed, 2553 insertions(+), 174 deletions(-) create mode 100644 packages/api-wasm/README.md create mode 100644 packages/api-wasm/package.json create mode 100644 packages/api-wasm/src/index.ts create mode 100644 packages/api-wasm/src/wasi.ts create mode 100644 packages/api-wasm/src/wasmURL.source.ts create mode 100644 packages/api-wasm/src/wasmURL.ts create mode 100644 packages/api-wasm/tsconfig.json create mode 100644 packages/typescript/src/api/async/browserClient.ts create mode 100644 packages/typescript/src/api/async/transport.ts create mode 100644 packages/typescript/src/api/async/transportClient.ts create mode 100644 packages/typescript/src/api/sync/browserClient.ts create mode 100644 packages/typescript/src/api/sync/transport.ts create mode 100644 packages/typescript/src/api/sync/transportClient.ts create mode 100644 packages/typescript/test/async/transport.test.ts create mode 100644 packages/typescript/test/browser/bundle.test.ts create mode 100644 packages/typescript/test/browser/wasm.test.ts create mode 100644 packages/typescript/test/sync/transport.test.ts create mode 100644 packages/typescript/test/sync/wasm.test.ts create mode 100644 tsc/cmd/tsc-api-wasm/main.go create mode 100644 tsc/internal/api/wasmreactor/reactor.go create mode 100644 tsc/internal/api/wasmreactor/reactor_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d82fe7f836a3..053e28da3e78a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,6 +225,10 @@ jobs: - run: npx hereby test:tools - run: npx hereby test:api - run: npx hereby test:benchmarks:api + - run: npx playwright install --with-deps chromium + if: ${{ matrix.config.main }} + - run: npx hereby test:api:browser + if: ${{ matrix.config.main }} - run: git add . - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 if: ${{ always() && matrix.config.coverage && github.event_name != 'merge_group' }} diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 33ae679c70fc9..ff7cde164bd4a 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -944,7 +944,15 @@ async function runTestTools() { async function runTestAPI() { // Running the package script doesn't work on Windows; some path escaping isn't done correctly and the test runner runs no tests. - await run("node", ["--conditions", "@typescript/source", "--test", "./test/**/*.test.ts"], { cwd: "./packages/typescript" }); + await run( + "node", + ["--conditions", "@typescript/source", "--test", "./test/*.test.ts", "./test/async/**/*.test.ts", "./test/sync/**/*.test.ts"], + { cwd: "./packages/typescript" }, + ); +} + +async function runTestAPIBrowser() { + await run("node", ["--conditions", "@typescript/source", "--test", "./test/browser/**/*.test.ts"], { cwd: "./packages/typescript" }); } async function runTestAPIBenchmarks() { @@ -981,10 +989,33 @@ async function runBuildAPITests(generateSources = true) { await run("npm", ["run", "-w", "@typescript/typescript", "build:test"]); } +/** + * @param {string} out + * @param {string[]} [extraFlags] + */ +async function buildAPIWasmFile(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-api-wasm"], { + cwd: "./tsc", + env: { GOOS: "wasip1", GOARCH: "wasm" }, + }); +} + +export const buildAPIWasm = task({ + name: "build:api:wasm", + description: "Builds the @typescript/api-wasm package.", + dependencies: [lib], + run: async () => { + await run("npm", ["run", "-w", "@typescript/api-wasm", "build:js"]); + await buildAPIWasmFile("./packages/api-wasm/dist/typescript-api.wasm"); + }, +}); + export const buildAPITests = task({ name: "build:api:test", description: "Builds the @typescript/typescript JS API tests.", - dependencies: [generateEnums, generateAPI], + dependencies: [generateEnums, generateAPI, buildAPIWasm], run: runBuildAPITests, }); @@ -1002,6 +1033,13 @@ export const testAPIBenchmarks = task({ run: runTestAPIBenchmarks, }); +export const testAPIBrowser = task({ + name: "test:api:browser", + description: "Runs the @typescript/typescript browser API tests.", + dependencies: [buildAPITests], + run: runTestAPIBrowser, +}); + export const testAll = task({ name: "test:all", description: "Runs compiler, extension, benchmark, tools, and API tests. Codegen tests are opt-in via test:codegen.", @@ -1014,6 +1052,7 @@ export const testAll = task({ await runTestTools(); await runTestAPI(); await runTestAPIBenchmarks(); + await runTestAPIBrowser(); }, }); @@ -1818,6 +1857,12 @@ const mainNativePreviewPackage = { npmTarball: path.join(builtNpm, publishAsTypescript ? "typescript.tgz" : "native-preview.tgz"), }; +const apiWasmPackage = { + npmPackageName: "@typescript/api-wasm", + npmDir: path.join(builtNpm, "api-wasm"), + npmTarball: path.join(builtNpm, "typescript-api-wasm.tgz"), +}; + const typescriptMacEntitlements = [ "com.apple.security.cs.allow-dyld-environment-variables", "com.apple.security.cs.disable-library-validation", @@ -2185,6 +2230,13 @@ async function runBuildNativePreviewPackages() { const platforms = getPlatforms(); const inputDir = "./packages/typescript"; + await Promise.all([ + fs.promises.rm(path.join(inputDir, "dist", "typescript-api.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(); @@ -2247,7 +2299,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")); @@ -2275,9 +2327,35 @@ async function runBuildNativePreviewPackages() { throw new Error(`Found external imports in .d.ts files:\n${importErrors.map(e => " " + e).join("\n")}`); } + const wasmInputDir = "./packages/api-wasm"; const extraFlags = options.respectGoEnv ? [] : getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined); + const wasmPackageJson = JSON.parse(fs.readFileSync(path.join(wasmInputDir, "package.json"), "utf8")); + wasmPackageJson.version = getVersion(); + wasmPackageJson.gitHead = 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/api-wasm", "build:js"]); + await buildAPIWasmFile(path.join(wasmInputDir, "dist", "typescript-api.wasm"), extraFlags); + await cpRecursive(wasmInputDir, apiWasmPackage.npmDir, p => !p.endsWith("/node_modules") && !p.includes("/dist")); + await cpRecursive(path.join(wasmInputDir, "dist"), path.join(apiWasmPackage.npmDir, "dist")); + await fs.promises.writeFile( + path.join(apiWasmPackage.npmDir, "package.json"), + JSON.stringify(wasmPackageJson, undefined, 4), + ); + await fs.promises.copyFile("LICENSE.txt", path.join(apiWasmPackage.npmDir, "LICENSE")); + await fs.promises.copyFile("NOTICE.txt", path.join(apiWasmPackage.npmDir, "NOTICE.txt")); const platformBuilders = platforms.map(({ npmDir, npmPackageName, nodeOs, nodeArch, goos, goarch }) => async () => { const packageJson = { @@ -2537,14 +2615,14 @@ async function runPackNativePreviewPackages() { } const platforms = getPlatforms(); - await Promise.all([mainNativePreviewPackage, ...platforms].map(async ({ npmDir, npmTarball }) => { + await Promise.all([mainNativePreviewPackage, apiWasmPackage, ...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 WASM package with its exact main-package peer. const publishManifest = { stages: [ platforms.map(p => ({ @@ -2555,6 +2633,11 @@ async function runPackNativePreviewPackages() { filename: path.basename(mainNativePreviewPackage.npmTarball), }, ], + [ + { + filename: path.basename(apiWasmPackage.npmTarball), + }, + ], ], }; diff --git a/package-lock.json b/package-lock.json index da1e499cf3832..647f0430297d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1548,6 +1548,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript/api-wasm": { + "resolved": "packages/api-wasm", + "link": true + }, "node_modules/@typescript/bundled-typescript": { "name": "typescript", "version": "7.0.2", @@ -3709,6 +3713,35 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -4603,6 +4636,11 @@ "buffer-crc32": "~0.2.3" } }, + "packages/api-wasm": { + "name": "@typescript/api-wasm", + "version": "0.0.0", + "license": "Apache-2.0" + }, "packages/typescript": { "name": "@typescript/typescript", "version": "0.0.0", @@ -4612,6 +4650,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "playwright": "^1.62.1", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" }, diff --git a/packages/api-wasm/README.md b/packages/api-wasm/README.md new file mode 100644 index 0000000000000..7f72450e9bd9a --- /dev/null +++ b/packages/api-wasm/README.md @@ -0,0 +1,21 @@ +# `@typescript/api-wasm` + +WebAssembly transport for the TypeScript API. + +```ts +import { API } from "typescript/unstable/async"; +import { + instantiateWasm, + WasmTransport, + wasmURL, +} from "@typescript/api-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/api-wasm/package.json b/packages/api-wasm/package.json new file mode 100644 index 0000000000000..2ea25c7590de9 --- /dev/null +++ b/packages/api-wasm/package.json @@ -0,0 +1,30 @@ +{ + "private": true, + "name": "@typescript/api-wasm", + "version": "0.0.0", + "license": "Apache-2.0", + "author": "Microsoft Corp.", + "homepage": "https://www.typescriptlang.org/", + "description": "WebAssembly transport for the native TypeScript compiler API", + "type": "module", + "files": [ + "dist" + ], + "exports": { + ".": { + "@typescript/source": "./src/index.ts", + "default": "./dist/index.js" + }, + "./api.wasm": "./dist/typescript-api.wasm" + }, + "imports": { + "#wasmURL": { + "@typescript/source": "./src/wasmURL.source.ts", + "default": "./dist/wasmURL.js" + } + }, + "scripts": { + "build": "hereby --herebyfile ../../Herebyfile.mjs build:api:wasm", + "build:js": "tsc -b" + } +} diff --git a/packages/api-wasm/src/index.ts b/packages/api-wasm/src/index.ts new file mode 100644 index 0000000000000..7648e29fb6acb --- /dev/null +++ b/packages/api-wasm/src/index.ts @@ -0,0 +1,181 @@ +export { wasmURL } from "#wasmURL"; +export { instantiateWasm, type InstantiateWasmOptions } from "./wasi.ts"; +import { + registerWasmCallback, + unregisterWasmCallback, +} 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; + 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; +} + +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 instance: WasmReactorInstance; + private readonly exports: WasmReactorExports; + 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()}`); + } + } + + requestSync(method: string, payload: string): string { + return decoder.decode(this.call(method, encoder.encode(payload))); + } + + request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } { + const value = this.requestSync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + requestBinarySync(method: string, payload: Uint8Array): Uint8Array { + return this.call(method, payload); + } + + requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { + const value = this.requestBinarySync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + registerCallback(name: string, callback: (name: string, payload: string) => string): void { + this.ensureOpen(); + registerWasmCallback(this.instance, name, callback); + } + + unregisterCallback(name: string): void { + unregisterWasmCallback(this.instance, name); + } + + 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()}`); + } + } + + 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; + this.exports.close_session(); + } + + 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", + "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/api-wasm/src/wasi.ts b/packages/api-wasm/src/wasi.ts new file mode 100644 index 0000000000000..2a674c6bd091d --- /dev/null +++ b/packages/api-wasm/src/wasi.ts @@ -0,0 +1,237 @@ +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 hostCallbackFD = 0x7fff_fffd; + +const wasmCallbacks = new WeakMap string>>(); + +export function registerWasmCallback( + instance: WasmReactorInstance, + name: string, + callback: (name: string, payload: string) => string, +): void { + const callbacks = wasmCallbacks.get(instance); + if (!callbacks) { + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + callbacks.set(name, callback); +} + +export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { + wasmCallbacks.get(instance)?.delete(name); +} + +export interface InstantiateWasmOptions { + stdout?: (text: string) => void; + stderr?: (text: string) => void; +} + +/** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ +export async function instantiateWasm( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): Promise { + let instance: WebAssembly.Instance | undefined; + const stdout = options.stdout ?? (text => console.log(text)); + const stderr = options.stderr ?? (text => console.error(text)); + const decoders = new Map(); + const encoder = new TextEncoder(); + const callbacks = new Map string>(); + + 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 === hostCallbackFD) { + return hostCallback(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 hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { + if (iovsLength !== 1) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const bufferPointer = view.getUint32(iovsPointer, true); + const bufferLength = view.getUint32(iovsPointer + 4, true); + if (bufferLength < 16) return errnoInvalidArgument; + const methodLength = view.getUint32(bufferPointer, true); + const payloadLength = view.getUint32(bufferPointer + 4, true); + const resultCapacity = view.getUint32(bufferPointer + 8, true); + const errorCapacity = view.getUint32(bufferPointer + 12, true); + if (16 + methodLength + payloadLength + resultCapacity + errorCapacity > bufferLength) { + return errnoInvalidArgument; + } + const methodPointer = bufferPointer + 16; + const payloadPointer = methodPointer + methodLength; + const resultPointer = payloadPointer + payloadLength; + const errorPointer = resultPointer + resultCapacity; + const decoder = new TextDecoder(); + const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); + const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); + try { + const callback = callbacks.get(method); + if (!callback) { + throw new Error(`No callback is registered for ${method}`); + } + const result = encoder.encode(callback(method, payload)); + if (result.length > resultCapacity) { + throw new Error(`Callback result exceeds ${resultCapacity} bytes`); + } + new Uint8Array(memory.buffer, resultPointer, result.length).set(result); + view.setUint32(bufferPointer + 8, result.length, true); + view.setUint32(bufferPointer + 12, 0, true); + 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, 0, true); + view.setUint32(bufferPointer + 12, errorLength, true); + view.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + function randomGet(bufferPointer: number, bufferLength: number): number { + bufferPointer >>>= 0; + const buffer = new Uint8Array(getMemory().buffer, bufferPointer, bufferLength); + for (let offset = 0; offset < buffer.length; offset += 65_536) { + crypto.getRandomValues(buffer.subarray(offset, Math.min(offset + 65_536, buffer.length))); + } + return errnoSuccess; + } + + function 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_open: unsupported, + path_remove_directory: unsupported, + path_unlink_file: unsupported, + poll_oneoff: unsupported, + proc_exit: (code: number) => { + throw new Error(`TypeScript WASM runtime exited with code ${code}`); + }, + random_get: randomGet, + sched_yield: () => errnoSuccess, + }; + + instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi, + }); + const initialize = instance.exports._initialize; + if (typeof initialize !== "function") { + throw new Error("TypeScript WASM reactor did not export _initialize"); + } + initialize(); + wasmCallbacks.set(instance, callbacks); + return instance; +} diff --git a/packages/api-wasm/src/wasmURL.source.ts b/packages/api-wasm/src/wasmURL.source.ts new file mode 100644 index 0000000000000..a3cc55dbd74c7 --- /dev/null +++ b/packages/api-wasm/src/wasmURL.source.ts @@ -0,0 +1,2 @@ +/** URL of the locally built WebAssembly reactor when using source conditions. */ +export const wasmURL: URL = new URL("../dist/typescript-api.wasm", import.meta.url); diff --git a/packages/api-wasm/src/wasmURL.ts b/packages/api-wasm/src/wasmURL.ts new file mode 100644 index 0000000000000..e50abf9a5f29b --- /dev/null +++ b/packages/api-wasm/src/wasmURL.ts @@ -0,0 +1,2 @@ +/** URL of the WebAssembly reactor distributed with this package. */ +export const wasmURL: URL = new URL("./typescript-api.wasm", import.meta.url); diff --git a/packages/api-wasm/tsconfig.json b/packages/api-wasm/tsconfig.json new file mode 100644 index 0000000000000..65c131d98313e --- /dev/null +++ b/packages/api-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/package.json b/packages/typescript/package.json index 5e26fd27a4524..d0aaba78529c1 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,13 @@ "generate:sync": "npm run node -- scripts/generateSync.ts", "build": "tsc -b", "build:test": "tsc -b test", - "test:only": "npm run node -- --test './test/**/*.test.ts'", + "test:only": "npm run node -- --test './test/*.test.ts' './test/async/**/*.test.ts' './test/sync/**/*.test.ts'", + "test:browser:only": "npm run node -- --test './test/browser/**/*.test.ts'", "test": "npm run test:only" }, "devDependencies": { "@types/node": "^22.20.1", + "playwright": "^1.62.1", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" } diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 35866d4c53132..922957b0d2151 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"; @@ -51,7 +56,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 { @@ -112,11 +117,6 @@ import type { TimingAccumulators, TimingInfo, } from "../timing.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, @@ -607,6 +607,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..bf5c5f31b78a5 --- /dev/null +++ b/packages/typescript/src/api/async/browserClient.ts @@ -0,0 +1,19 @@ +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"); + } + 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 ff7b9c2f60f8f..f85d5e6ccedff 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,15 @@ 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)) { + 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 +76,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 +85,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); } @@ -200,6 +215,9 @@ export class Client { } registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { + if (this.transportClient) { + return this.transportClient.registerCallback(name, callback); + } if (!this.connection) { throw new Error("Connection not established"); } @@ -230,8 +248,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; @@ -241,8 +260,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); @@ -270,6 +289,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"); } @@ -287,6 +307,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(); @@ -303,6 +324,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"); @@ -316,6 +338,7 @@ export class Client { * materialization totals surface alongside request timings. */ getTimingCollector(): TimingCollector | undefined { + if (this.transportClient) return this.transportClient.getTimingCollector(); return this.timing; } @@ -325,6 +348,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(); } @@ -337,6 +361,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) { @@ -357,6 +382,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..0fcee4b622fce --- /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>; + registerCallback?(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback?(name: string): void; + close(): void | PromiseLike; +} diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts new file mode 100644 index 0000000000000..639e5ac152693 --- /dev/null +++ b/packages/typescript/src/api/async/transportClient.ts @@ -0,0 +1,299 @@ +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import type { AsyncTransport } from "./transport.ts"; + +interface PendingRequestBase { + reject: (reason?: unknown) => void; +} + +interface PendingTextRequest extends PendingRequestBase { + kind: "text"; + method: APIRequest["method"]; + params: APIRequest["params"]; + resolve: (value: unknown) => void; +} + +interface PendingBinaryRequest extends PendingRequestBase { + kind: "binary"; + method: SourceFileResponseMethod; + payload: Uint8Array; + resolve: (value: Uint8Array | undefined) => void; +} + +type PendingRequest = PendingTextRequest | PendingBinaryRequest; + +function isTextRequest(request: PendingRequest): request is PendingTextRequest { + return request.kind === "text"; +} + +/** Protocol client for an injected asynchronous transport. */ +export class TransportClient { + private readonly encoder = new TextEncoder(); + private readonly timing: TimingCollector | undefined; + private readonly transport: AsyncTransport; + private readonly maxResponseBytesPerPage: number | undefined; + private connected = false; + private closed = false; + private nextBatch: boolean | "manual" = false; + private batchGeneration = 0; + private batchedRequests: PendingRequest[] = []; + private readonly closedPromise: Promise; + private rejectClosed!: (reason: Error) => void; + + constructor(transport: AsyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + this.closedPromise = new Promise((_, reject) => { + this.rejectClosed = reject; + }); + void this.closedPromise.catch(() => {}); + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + connect(): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + this.connected = true; + return Promise.resolve(); + } + + batchContext(): { [Symbol.dispose](): void; } { + if (this.nextBatch === "manual") { + throw new Error("Already in a manual batch context"); + } + if (this.nextBatch) { + this.batchGeneration++; + this.nextBatch = false; + void this.doBatch(); + } + this.nextBatch = "manual"; + return { + [Symbol.dispose]: () => { + this.nextBatch = false; + this.scheduleBatch(); + }, + }; + } + + async apiRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + if (method === "initialize") { + return this.sendRequest(method, params); + } + const result = new Promise((resolve, reject) => { + this.batchedRequests.push({ kind: "text", method, params, resolve, reject }); + this.scheduleBatch(); + }); + return result; + } + + async apiRequestBinary( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + return new Promise((resolve, reject) => { + this.batchedRequests.push({ + kind: "binary", + method, + payload: this.encoder.encode(JSON.stringify(params)), + resolve, + reject, + }); + this.scheduleBatch(); + }); + } + + registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { + const register = this.transport.registerCallback; + const unregister = this.transport.unregisterCallback; + if (!register || !unregister) { + throw new Error("Callbacks are not supported by this transport"); + } + register.call(this.transport, name, (_, payload) => { + const result = callback(JSON.parse(payload)); + if (result instanceof Promise) { + throw new Error("Asynchronous callbacks are not supported by this transport"); + } + return JSON.stringify(result) ?? ""; + }); + return () => unregister.call(this.transport, name); + } + + getTimingCollector(): TimingCollector | undefined { + return this.timing; + } + + async getTimingInfo(): Promise { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + if (!this.connected) { + return local; + } + const response = await this.invokeTransport(() => this.transport.request("getServerTiming", "")); + return combineTimingInfo(local, JSON.parse(response.value) as ServerTimingInfo); + } + + async resetTimingInfo(): Promise { + if (!this.timing) return; + this.timing.reset(); + if (this.connected) { + await this.invokeTransport(() => this.transport.request("resetServerTiming", "")); + } + } + + async close(): Promise { + this.closed = true; + this.rejectClosed(new Error("Client is closed")); + this.batchGeneration++; + this.nextBatch = false; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.transport.close(); + this.connected = false; + } + + private scheduleBatch(): void { + if (this.closed || this.nextBatch) return; + this.nextBatch = true; + const generation = ++this.batchGeneration; + queueMicrotask(() => { + if (this.batchGeneration !== generation || this.nextBatch !== true) return; + this.nextBatch = false; + void this.doBatch(); + }); + } + + private async doBatch(): Promise { + if (!this.batchedRequests.length) return; + const requests = this.batchedRequests; + this.batchedRequests = []; + if (this.closed) { + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + return; + } + try { + if (requests.length === 1 && requests[0].kind === "text") { + const request = requests[0]; + request.resolve(await this.sendRequest(request.method, request.params)); + return; + } + + if (requests.every(isTextRequest)) { + const params: BatchRequestsParams = { + requests: requests.map(request => ({ method: request.method, params: request.params })), + }; + if (this.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const response = await this.sendRequest("batchRequests", params) as BatchRequestsResponse; + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const page = await this.sendRequest("batchRequests", pageParams) as BatchRequestsResponse; + responses = responses.concat(page.responses); + continuationToken = page.continuationToken; + } + for (let i = 0; i < requests.length; i++) { + const { resolve, reject } = requests[i]; + const item = responses[i]; + if (item.error !== undefined) { + reject(new Error(item.error)); + } + else { + resolve(item.result); + } + } + return; + } + + for (const request of requests) { + if (request.kind === "text") { + request.resolve(await this.sendRequest(request.method, request.params)); + } + else { + request.resolve(await this.sendBinaryRequest(request.method, request.payload)); + } + } + } + catch (error) { + for (const { reject } of requests) reject(error); + } + } + + private async sendRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + const payload = JSON.stringify(params) ?? ""; + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.request(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length + ? JSON.parse(response.value) as APIMethodInfo[K]["result"] + : undefined as APIMethodInfo[K]["result"]; + } + + private async sendBinaryRequest(method: SourceFileResponseMethod, payload: Uint8Array): Promise { + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.requestBinary(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length === 0 ? undefined : response.value; + } + + private invokeTransport(operation: () => T | PromiseLike): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + return Promise.race([ + Promise.resolve().then(operation), + this.closedPromise, + ]); + } + + private recordTiming(method: string, start: number, bytesSent: number, bytesReceived: number): void { + if (!this.timing) return; + this.timing.record({ + method, + roundTripMs: performance.now() - start, + bytesSent, + bytesReceived, + }); + } +} diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 0bfdcc2ff02e4..02ced487fef29 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -372,7 +372,34 @@ export function encodeNode(node: Node): Uint8Array { * Encode a Uint8Array to a base64 string. */ export function uint8ArrayToBase64(data: Uint8Array): string { - return Buffer.from(data).toString("base64"); + const nativeToBase64 = (data as Uint8Array & { toBase64?: () => string; }).toBase64; + if (nativeToBase64) { + return nativeToBase64.call(data); + } + + const bufferConstructor = (globalThis as { + Buffer?: { + from(buffer: ArrayBufferLike, byteOffset: number, length: number): { + toString(encoding: "base64"): string; + }; + }; + }).Buffer; + if (bufferConstructor) { + return bufferConstructor.from(data.buffer, data.byteOffset, data.byteLength).toString("base64"); + } + + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let result = ""; + for (let i = 0; i < data.length; i += 3) { + const first = data[i]; + const second = data[i + 1]; + const third = data[i + 2]; + result += alphabet[first >> 2]; + result += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)]; + result += second === undefined ? "=" : alphabet[((second & 0x0F) << 2) | ((third ?? 0) >> 6)]; + result += third === undefined ? "=" : alphabet[third & 0x3F]; + } + return result; } export function sourceFileResponseToUint8Array(response: { readonly data: string; } | null | undefined): Uint8Array | undefined { diff --git a/packages/typescript/src/api/node/wtf8.ts b/packages/typescript/src/api/node/wtf8.ts index 30321e734b872..2837c62ea5f92 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; @@ -28,7 +26,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 98751e6d99b32..a797cb25ad0b6 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,36 @@ 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; + /** 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; + /** 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 +87,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 351dbfc7605a9..b2cdc2159cfc8 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -34,6 +34,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 CallLikeExpression, @@ -68,8 +73,8 @@ import { } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; import type { - APIOptions, LSPConnectionOptions, + SyncAPIOptions as APIOptions, } from "../options.ts"; import { createGetCanonicalFileName, @@ -129,11 +134,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..5e731a7d6b631 --- /dev/null +++ b/packages/typescript/src/api/sync/browserClient.ts @@ -0,0 +1,19 @@ +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"); + } + 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 d3c3ccc64b9fc..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,113 +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"]; - } - - registerCallback(name: string, callback: (params: unknown) => unknown): () => void { - this.channel.registerCallback(name, (_, payload) => JSON.stringify(callback(JSON.parse(payload))) ?? ""); - return () => this.channel.unregisterCallback(name); - } - - batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { - const params: BatchRequestsParams = { requests }; - if (this.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const response = this.apiRequest("batchRequests", params); - let responses = response.responses; - let continuationToken = response.continuationToken; - while (continuationToken) { - const pageParams: BatchRequestsParams = { - requests: [], - continuationToken, - }; - if (this.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const page = this.apiRequest("batchRequests", pageParams); - if (page.responses.length < 200) { - responses.push(...page.responses); - } - else { - // If the number of responses is approaching the max argument length, we need to concat instead of push - responses = responses.concat(page.responses); - } - continuationToken = page.continuationToken; - } - return { responses }; - } - - apiRequestBinary(method: K, params?: APIMethodInfo[K]["params"]): Uint8Array | undefined { - const start = performance.now(); - const result = this.channel.requestBinarySync(method, this.encoder.encode(JSON.stringify(params))); - this.recordTiming(method, start); - if (result.length === 0) return undefined; - return result; - } - - echo(payload: string): string { - return this.channel.requestSync("echo", payload); - } - - echoBinary(payload: Uint8Array): Uint8Array { - return this.channel.requestBinarySync("echo", payload); - } - - /** - * Returns a combined timing snapshot: client-measured round-trip and byte - * counts folded together with the server's own per-request processing time - * (fetched via a getServerTiming request) and estimated transport overhead. - */ - getTimingInfo(): TimingInfo { - if (!this.timing) { - return disabledTimingInfo(); - } - const local = this.timing.getInfo(); - // requestSync bypasses recordTiming, so this query does not pollute the - // client-side collector. - const result = this.channel.requestSync("getServerTiming", ""); - return combineTimingInfo(local, JSON.parse(result) as ServerTimingInfo); - } - - resetTimingInfo(): void { - if (!this.timing) return; - this.timing.reset(); - // Keep the server's collection in sync so combined totals stay meaningful. - this.channel.requestSync("resetServerTiming", ""); - } - - /** - * Returns the timing collector that per-node materialization is reported - * into, or undefined when timing collection is disabled. The returned - * collector is the same one folded into {@link getTimingInfo}, so - * materialization totals surface alongside request timings. - */ - getTimingCollector(): TimingCollector | undefined { - return this.timing; - } - - private recordTiming(method: string, start: number): void { - if (!this.timing) return; - this.timing.record({ - method, - roundTripMs: performance.now() - start, - bytesSent: this.channel.lastBytesSent, - bytesReceived: this.channel.lastBytesReceived, - }); - } - - close(): void { - this.channel.close(); - } } diff --git a/packages/typescript/src/api/sync/transport.ts b/packages/typescript/src/api/sync/transport.ts new file mode 100644 index 0000000000000..63cd9a8333066 --- /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; + registerCallback?(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback?(name: string): void; + close(): void; +} diff --git a/packages/typescript/src/api/sync/transportClient.ts b/packages/typescript/src/api/sync/transportClient.ts new file mode 100644 index 0000000000000..461d4f44ae5b4 --- /dev/null +++ b/packages/typescript/src/api/sync/transportClient.ts @@ -0,0 +1,131 @@ +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import type { SyncTransport } from "./transport.ts"; + +/** Protocol client shared by the process and embedded synchronous transports. */ +export class TransportClient { + private readonly encoder = new TextEncoder(); + private readonly timing: TimingCollector | undefined; + private readonly transport: SyncTransport; + private readonly maxResponseBytesPerPage: number | undefined; + + constructor(transport: SyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + apiRequest(method: K, params?: APIMethodInfo[K]["params"]): APIMethodInfo[K]["result"] { + const encodedPayload = JSON.stringify(params); + const start = performance.now(); + const result = this.transport.requestSync(method, encodedPayload); + this.recordTiming(method, start); + if (result.length) { + return JSON.parse(result) as APIMethodInfo[K]["result"]; + } + return undefined as APIMethodInfo[K]["result"]; + } + + registerCallback(name: string, callback: (params: unknown) => unknown): () => void { + const register = this.transport.registerCallback; + const unregister = this.transport.unregisterCallback; + if (!register || !unregister) { + throw new Error("Callbacks are not supported by this transport"); + } + register.call(this.transport, name, (_, payload) => JSON.stringify(callback(JSON.parse(payload))) ?? ""); + return () => unregister.call(this.transport, name); + } + + batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { + const params: BatchRequestsParams = { requests }; + if (this.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const response = this.apiRequest("batchRequests", params); + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const page = this.apiRequest("batchRequests", pageParams); + if (page.responses.length < 200) { + responses.push(...page.responses); + } + else { + responses = responses.concat(page.responses); + } + continuationToken = page.continuationToken; + } + return { responses }; + } + + echo(payload: string): string { + return this.transport.requestSync("echo", payload); + } + + echoBinary(payload: Uint8Array): Uint8Array { + return this.transport.requestBinarySync("echo", payload); + } + + apiRequestBinary( + method: K, + params?: APIMethodInfo[K]["params"], + ): Uint8Array | undefined { + const start = performance.now(); + const result = this.transport.requestBinarySync(method, this.encoder.encode(JSON.stringify(params))); + this.recordTiming(method, start); + return result.length === 0 ? undefined : result; + } + + getTimingCollector(): TimingCollector | undefined { + return this.timing; + } + + getTimingInfo(): TimingInfo { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + const result = this.transport.requestSync("getServerTiming", ""); + return combineTimingInfo(local, JSON.parse(result) as ServerTimingInfo); + } + + resetTimingInfo(): void { + if (!this.timing) return; + this.timing.reset(); + this.transport.requestSync("resetServerTiming", ""); + } + + close(): void { + this.transport.close(); + } + + private recordTiming(method: string, start: number): void { + if (!this.timing) return; + this.timing.record({ + method, + roundTripMs: performance.now() - start, + bytesSent: this.transport.lastBytesSent, + bytesReceived: this.transport.lastBytesReceived, + }); + } +} diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts new file mode 100644 index 0000000000000..9b95a2a8526da --- /dev/null +++ b/packages/typescript/test/async/transport.test.ts @@ -0,0 +1,354 @@ +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/unstable/async"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { TransportClient } from "../../src/api/async/transportClient.ts"; +import type { + BatchRequestsParams, + BatchRequestsResponse, + InferredProjectId, +} from "../../src/api/proto.ts"; + +describe("async transport", () => { + test("constructs an API over an injected transport", async () => { + const methods: string[] = []; + let batchParams: BatchRequestsParams | undefined; + let closed = false; + const api = new API({ + maxResponseBytesPerPage: 1, + transport: { + request(method, payload) { + methods.push(method); + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + case "batchRequests": { + batchParams = JSON.parse(payload) as BatchRequestsParams; + assert.ok(batchParams.requests); + const response: BatchRequestsResponse = { + responses: batchParams.requests.map(request => ({ + method: request.method, + result: { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + })), + }; + return { + value: JSON.stringify(response), + bytesSent: 0, + bytesReceived: 0, + }; + } + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() { + closed = true; + }, + }, + }); + + assert.deepStrictEqual(await api.parseCommandLine([]), { + options: {}, + fileNames: [], + errors: [], + }); + let batchRequests: Promise[]; + { + using _ = api.batchContext(); + batchRequests = [api.parseCommandLine([]), api.parseCommandLine([])]; + } + await Promise.all(batchRequests); + assert.deepStrictEqual(methods, ["initialize", "parseCommandLine", "batchRequests"]); + assert.strictEqual(batchParams?.maxResponseBytesPerPage, 1); + await api.close(); + assert.strictEqual(closed, true); + }); + + test("rejects requests queued when the transport closes", async () => { + const api = new API({ + transport: { + request(method) { + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + await api.parseCommandLine([]); + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("can close while initialization is manually batched", async () => { + const api = new API({ + transport: { + request(method) { + if (method !== "initialize") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("does not flush a manual batch from a stale microtask", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + if (method !== "parseCommandLine") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const first = client.apiRequest("parseCommandLine", { commandLine: [] }); + const batch = client.batchContext(); + const second = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + assert.deepStrictEqual(methods, ["parseCommandLine"]); + batch[Symbol.dispose](); + await Promise.all([first, second]); + await client.close(); + }); + + test("keeps binary requests ordered inside a manual batch", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary(method, payload) { + methods.push(method); + return { + value: payload, + bytesSent: payload.length, + bytesReceived: payload.length, + }; + }, + close() {}, + }, false); + + await client.connect(); + const batch = client.batchContext(); + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project" as InferredProjectId, + file: "/index.ts", + }); + await Promise.resolve(); + assert.deepStrictEqual(methods, []); + batch[Symbol.dispose](); + await Promise.all([text, binary]); + assert.deepStrictEqual(methods, ["parseCommandLine", "getSourceFile"]); + await client.close(); + }); + + test("rejects requests that are still active when closed", async () => { + let resolveRequest: + | ((value: { + value: string; + bytesSent: number; + bytesReceived: number; + }) => void) + | undefined; + const client = new TransportClient({ + request() { + return new Promise(resolve => { + resolveRequest = resolve; + }); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const request = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + await client.close(); + await assert.rejects(request, /Client is closed/); + resolveRequest?.({ + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }); + }); + + test("can close while initialization is in flight", async () => { + const api = new API({ + transport: { + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await Promise.resolve(); + await api.close(); + await rejected; + }); + + test("rejects timing requests when closed", async () => { + const client = new TransportClient({ + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, true); + + await client.connect(); + const timing = client.getTimingInfo(); + const reset = client.resetTimingInfo(); + await client.close(); + await assert.rejects(timing, /Client is closed/); + await assert.rejects(reset, /Client is closed/); + }); + + test("does not queue requests after closing during connect", async () => { + const client = new TransportClient({ + request() { + throw new Error("Unexpected request"); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project" as InferredProjectId, + file: "/index.ts", + }); + await client.close(); + await assert.rejects(text, /Client is closed/); + await assert.rejects(binary, /Client is closed/); + }); +}); diff --git a/packages/typescript/test/browser/bundle.test.ts b/packages/typescript/test/browser/bundle.test.ts new file mode 100644 index 0000000000000..a1d7e7a883fba --- /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/api-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/wasm.test.ts b/packages/typescript/test/browser/wasm.test.ts new file mode 100644 index 0000000000000..4f71992a04dc8 --- /dev/null +++ b/packages/typescript/test/browser/wasm.test.ts @@ -0,0 +1,122 @@ +import { wasmURL } from "@typescript/api-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/api-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("/typescript-api.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 "/typescript-api.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 7570d240300a4..a9db95a01a173 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, @@ -61,6 +62,16 @@ function decode(data: Uint8Array): RemoteSourceFile { } describe("Encoder", { concurrency }, () => { + test("encodes base64 using the available platform implementation", () => { + const data = new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4); + assert.strictEqual(uint8ArrayToBase64(data), "AQID"); + + Object.defineProperty(data, "toBase64", { + value: () => "native", + }); + assert.strictEqual(uint8ArrayToBase64(data), "native"); + }); + test("encodes empty source file", () => { const sf = makeSF("", "/test.ts", []); diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts new file mode 100644 index 0000000000000..cb9d3c1062ff6 --- /dev/null +++ b/packages/typescript/test/sync/transport.test.ts @@ -0,0 +1,160 @@ +import { + type WasmReactorExports, + WasmTransport, +} from "@typescript/api-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; + }, + 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.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); + }); +}); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts new file mode 100644 index 0000000000000..acf4be82b89b7 --- /dev/null +++ b/packages/typescript/test/sync/wasm.test.ts @@ -0,0 +1,95 @@ +import { + instantiateWasm, + WasmTransport, + wasmURL, +} from "@typescript/api-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 { readFile } from "node:fs/promises"; +import { + describe, + test, +} from "node:test"; + +describe("API over WebAssembly", () => { + test("runs the compiler and checker through the reactor", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const module = await WebAssembly.compile( + await readFile(wasmURL), + ); + const instance = await instantiateWasm(module); + + const transport = new WasmTransport({ instance, cwd: "/", collectTiming: true }); + const api = new SyncAPI({ transport, collectTiming: true }); + assert.strictEqual(transport.requestSync("echo", "text"), "text"); + assert.deepStrictEqual( + transport.requestBinarySync("echo", new Uint8Array([1, 2, 3])), + new Uint8Array([1, 2, 3]), + ); + + transport.setFile("/tsconfig.json", "{}"); + transport.setFile("/src/index.ts", "export const value = 42 as const;"); + const snapshot = api.createSnapshot({ + openFiles: ["/src/index.ts"], + fileNotifications: { + changed: ["/tsconfig.json", "/src/index.ts"], + deleted: [], + }, + }); + const project = snapshot.getDefaultProjectForFile("/src/index.ts"); + assert.ok(project); + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const statement = sourceFile.statements[0]; + assert.ok(isVariableStatement(statement)); + const name = statement.declarationList.declarations[0].name; + assert.ok(isIdentifier(name)); + const type = project.checker.getTypeAtLocation(name); + assert.strictEqual(project.checker.typeToString(type), "42"); + assert.deepStrictEqual(project.program.emit(), { + diagnostics: [], + emitSkipped: false, + emittedFiles: ["/src/index.js"], + fileSystem: undefined, + }); + + 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.createSnapshot({ + openFiles: ["/src/async.ts"], + fileNotifications: { + changed: ["/tsconfig.json", "/src/async.ts"], + deleted: [], + }, + }); + const asyncProject = await asyncSnapshot.getDefaultProjectForFile("/src/async.ts"); + assert.ok(asyncProject); + const asyncSourceFile = await asyncProject.program.getSourceFile("/src/async.ts"); + assert.ok(asyncSourceFile); + const asyncStatement = asyncSourceFile.statements[0]; + assert.ok(isVariableStatement(asyncStatement)); + const asyncName = asyncStatement.declarationList.declarations[0].name; + assert.ok(isIdentifier(asyncName)); + const asyncType = await asyncProject.checker.getTypeAtLocation(asyncName); + assert.strictEqual(await asyncProject.checker.typeToString(asyncType), "42"); + await asyncAPI.close(); + }); +}); diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index 92a035cc3d927..50ad9bc88d65d 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -77,6 +77,10 @@ extends: targetPath: '$(Build.ArtifactStagingDirectory)/npm-main' artifactName: 'npm-main' displayName: 'Publish npm-main artifact' + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/npm-api-wasm' + artifactName: 'npm-api-wasm' + displayName: 'Publish npm-api-wasm artifact' steps: - checkout: none @@ -98,12 +102,13 @@ extends: src="$(Pipeline.Workspace)/npm" platformDst="$(Build.ArtifactStagingDirectory)/npm-platform" mainDst="$(Build.ArtifactStagingDirectory)/npm-main" - mkdir -p "$platformDst" "$mainDst" + wasmDst="$(Build.ArtifactStagingDirectory)/npm-api-wasm" + mkdir -p "$platformDst" "$mainDst" "$wasmDst" manifest="$src/publish-manifest.json" stageCount="$(jq '.stages | length' "$manifest")" - if [ "$stageCount" -ne 2 ]; then - echo "Expected exactly 2 npm publish stages, found $stageCount." >&2 + if [ "$stageCount" -ne 3 ]; then + echo "Expected exactly 3 npm publish stages, found $stageCount." >&2 exit 1 fi @@ -168,14 +173,20 @@ extends: jq -r '.stages[0][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$platformDst/" jq -r '.stages[1][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$mainDst/" + jq -r '.stages[2][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$wasmDst/" platformCount="$(find "$platformDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" mainCount="$(find "$mainDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" - echo "##[section]Staged $platformCount platform package(s) and $mainCount main package(s)." + wasmCount="$(find "$wasmDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" + echo "##[section]Staged $platformCount platform package(s), $mainCount main package(s), and $wasmCount WASM package(s)." if [ "$mainCount" -ne 1 ]; then echo "Expected 1 main package, found $mainCount." >&2 exit 1 fi + if [ "$wasmCount" -ne 1 ]; then + echo "Expected 1 WASM package, found $wasmCount." >&2 + exit 1 + fi name: releaseMetadata displayName: 'Stage npm artifacts' env: @@ -200,6 +211,9 @@ extends: - input: pipelineArtifact artifactName: 'npm-main' targetPath: '$(Pipeline.Workspace)/npm-main' + - input: pipelineArtifact + artifactName: 'npm-api-wasm' + targetPath: '$(Pipeline.Workspace)/npm-api-wasm' steps: - checkout: none @@ -224,6 +238,16 @@ extends: owners: 'jabaile@microsoft.com' approvers: 'alexchi@microsoft.com' + - template: MicroBuild.Publish.yml@MicroBuildTemplate + parameters: + intent: 'PackageDistribution' + contentType: 'npm' + contentSource: 'Folder' + folderLocation: '$(Pipeline.Workspace)/npm-api-wasm' + 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-wasm/main.go b/tsc/cmd/tsc-api-wasm/main.go new file mode 100644 index 0000000000000..26eff09907e2f --- /dev/null +++ b/tsc/cmd/tsc-api-wasm/main.go @@ -0,0 +1,250 @@ +//go:build wasip1 + +// Command tsc-api-wasm builds the TypeScript API server as a WebAssembly reactor. +package main + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "runtime/debug" + "unsafe" + + "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" + "github.com/microsoft/TypeScript/tsc/internal/ipc" + "github.com/microsoft/TypeScript/tsc/internal/json" +) + +func main() {} + +const ( + // API callbacks are tunneled through a reserved descriptor so that + // the module only requires standard WASI imports and remains directly runnable. + hostCallbackFD = 0x7fff_fffd + hostErrorCapacity = 16 * 1024 + hostResultCapacity = 16 * 1024 * 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.Conn = hostCallbackConn{} + 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 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 +} + +func fail(message string) uint32 { + setResponse([]byte(message)) + return 1 +} + +func setResponse(data []byte) { + responseBuffer = data + responsePtr = bytesPointer(responseBuffer) + responseLen = uint32(len(responseBuffer)) +} + +func withinRequest(first uint32, second uint32) bool { + return first+second >= first && first+second <= uint32(len(requestBuffer)) +} + +func readMemory(pointer uint32, length uint32) []byte { + if length == 0 { + return nil + } + return unsafe.Slice((*byte)(unsafe.Pointer(uintptr(pointer))), length) +} + +func bytesPointer(bytes []byte) uint32 { + if len(bytes) == 0 { + return 0 + } + return uint32(uintptr(unsafe.Pointer(&bytes[0]))) +} + +func bytePointer(bytes []byte) *byte { + if len(bytes) == 0 { + return nil + } + return &bytes[0] +} + +type hostCallbackConn struct{} + +var _ ipc.Conn = hostCallbackConn{} + +func (hostCallbackConn) Run(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() +} + +func (hostCallbackConn) Call(_ context.Context, method string, params any) (json.Value, error) { + paramsBytes, err := json.Marshal(params) + if err != nil { + return nil, err + } + methodBytes := []byte(method) + buffer := make([]byte, 16+len(methodBytes)+len(paramsBytes)+hostResultCapacity+hostErrorCapacity) + binary.LittleEndian.PutUint32(buffer, uint32(len(methodBytes))) + binary.LittleEndian.PutUint32(buffer[4:], uint32(len(paramsBytes))) + binary.LittleEndian.PutUint32(buffer[8:], hostResultCapacity) + binary.LittleEndian.PutUint32(buffer[12:], hostErrorCapacity) + copy(buffer[16:], methodBytes) + copy(buffer[16+len(methodBytes):], paramsBytes) + + iov := [2]uint32{bytesPointer(buffer), uint32(len(buffer))} + var written uint32 + errno := hostFDWrite(hostCallbackFD, unsafe.Pointer(&iov[0]), 1, &written) + if errno == errnoBadFileNumber { + return nil, errors.New("host callbacks are not supported by this WebAssembly host") + } + resultLength := min(binary.LittleEndian.Uint32(buffer[8:]), uint32(hostResultCapacity)) + errorLength := min(binary.LittleEndian.Uint32(buffer[12:]), uint32(hostErrorCapacity)) + resultStart := 16 + len(methodBytes) + len(paramsBytes) + if errno != 0 { + errorStart := resultStart + hostResultCapacity + message := string(buffer[errorStart : errorStart+int(errorLength)]) + if message == "" { + message = "host callback failed" + } + return nil, errors.New(message) + } + return json.Value(buffer[resultStart : resultStart+int(resultLength)]), nil +} + +func (hostCallbackConn) Notify(context.Context, string, any) error { + return errors.New("host callback notifications are not supported") +} + +//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..e74c2d6998a93 --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -0,0 +1,118 @@ +// 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"` + Conn ipc.Conn `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) + ctx, cancel := context.WithCancel(ctx) + sessionInit := &project.SessionInit{ + BackgroundCtx: ctx, + FS: bundled.WrapFS(files), + Options: &project.SessionOptions{ + CurrentDirectory: options.Cwd, + DefaultLibraryPath: bundled.LibPath(), + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoggingEnabled: false, + }, + } + session := api.NewStandaloneSession(sessionInit, &api.SessionOptions{UseBinaryResponses: true}) + if options.Conn != nil { + session.SetConnection(options.Conn) + } + reactor := &Reactor{ + ctx: ctx, + cancel: cancel, + session: session, + files: files, + } + 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) 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..eaf62821e787b --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -0,0 +1,53 @@ +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.files.ReadFile("/src/index.ts"); !ok || contents != "export const value = 1;" { + t.Fatalf("ReadFile() = %q, %v", contents, ok) + } + + 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{} } From c4fbbb474145d1182b69be794be1c0799e004737 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 11:54:41 -0700 Subject: [PATCH 002/142] Run API tests in Chromium Reuse the existing async and sync API suites in Playwright with narrow browser shims and explicit host-only exclusions. Add WASI scheduler polling and callback-backed output writes so WasmTransport preserves native filesystem behavior. --- package-lock.json | 391 ++++++++++++++++++ packages/api-wasm/src/index.ts | 37 +- packages/api-wasm/src/wasi.ts | 163 +++++++- packages/typescript/package.json | 2 + .../typescript/src/api/async/browserClient.ts | 1 + packages/typescript/src/api/async/client.ts | 1 + .../typescript/src/api/async/transport.ts | 2 + packages/typescript/src/api/node/encoder.ts | 18 +- packages/typescript/src/api/node/wtf8.ts | 17 +- packages/typescript/src/api/options.ts | 4 + .../typescript/src/api/sync/browserClient.ts | 1 + packages/typescript/src/api/sync/transport.ts | 2 + .../typescript/test/browser/apiWrapper.ts | 198 +++++++++ packages/typescript/test/browser/asyncAPI.ts | 23 ++ packages/typescript/test/browser/harness.ts | 155 +++++++ .../typescript/test/browser/processShim.ts | 13 + .../typescript/test/browser/suite.test.ts | 299 ++++++++++++++ packages/typescript/test/browser/syncAPI.ts | 23 ++ packages/typescript/test/browser/wasm.test.ts | 4 +- .../typescript/test/sync/transport.test.ts | 16 + tsc/cmd/tsc-api-wasm/main.go | 62 +++ tsc/internal/api/wasmreactor/reactor.go | 19 +- tsc/internal/api/wasmreactor/reactor_test.go | 5 +- 23 files changed, 1420 insertions(+), 36 deletions(-) create mode 100644 packages/typescript/test/browser/apiWrapper.ts create mode 100644 packages/typescript/test/browser/asyncAPI.ts create mode 100644 packages/typescript/test/browser/harness.ts create mode 100644 packages/typescript/test/browser/processShim.ts create mode 100644 packages/typescript/test/browser/suite.test.ts create mode 100644 packages/typescript/test/browser/syncAPI.ts diff --git a/package-lock.json b/package-lock.json index 647f0430297d0..4ea2ee473a3ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2231,6 +2231,36 @@ "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/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", @@ -2326,6 +2356,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", @@ -2620,6 +2669,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", @@ -2633,6 +2700,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/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2922,6 +3007,22 @@ "flat": "cli.js" } }, + "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/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2962,6 +3063,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-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -3096,6 +3207,19 @@ "dev": true, "license": "ISC" }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3109,6 +3233,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -3186,6 +3326,43 @@ "node": ">= 14" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -3212,6 +3389,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-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -3231,6 +3428,58 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3620,6 +3869,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -3742,6 +4039,26 @@ "node": ">=20" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -3892,6 +4209,24 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/sax": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", @@ -3914,6 +4249,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/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -4283,6 +4636,20 @@ "dev": true, "license": "MIT" }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -4381,6 +4748,28 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -4650,7 +5039,9 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "assert": "^2.1.0", "playwright": "^1.62.1", + "process": "^0.11.10", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" }, diff --git a/packages/api-wasm/src/index.ts b/packages/api-wasm/src/index.ts index 7648e29fb6acb..5ff3ad5f68cef 100644 --- a/packages/api-wasm/src/index.ts +++ b/packages/api-wasm/src/index.ts @@ -1,8 +1,10 @@ export { wasmURL } from "#wasmURL"; -export { instantiateWasm, type InstantiateWasmOptions } from "./wasi.ts"; +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; import { registerWasmCallback, + setWasmFileSystem, unregisterWasmCallback, + type WasmFileSystem, } from "./wasi.ts"; export interface WasmReactorExports { @@ -12,6 +14,7 @@ export interface WasmReactorExports { 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; @@ -30,6 +33,7 @@ export interface WasmTransportOptions { cwd?: string; useCaseSensitiveFileNames?: boolean; collectTiming?: boolean; + fs?: WasmFileSystem; } const encoder = new TextEncoder(); @@ -48,6 +52,7 @@ export class WasmTransport { constructor(options: WasmTransportOptions) { this.instance = options.instance; this.exports = getReactorExports(options.instance); + setWasmFileSystem(options.instance, options.fs); const sessionOptions = encoder.encode(JSON.stringify({ cwd: options.cwd ?? "/", useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, @@ -55,10 +60,17 @@ export class WasmTransport { })); this.writeRequest(sessionOptions); if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { - throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); + const message = this.readResponseText(); + setWasmFileSystem(this.instance, undefined); + throw new Error(`Failed to create TypeScript WASM session: ${message}`); } } + 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))); } @@ -104,6 +116,19 @@ export class WasmTransport { } } + /** 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); @@ -116,7 +141,12 @@ export class WasmTransport { close(): void { if (this.closed) return; this.closed = true; - this.exports.close_session(); + try { + this.exports.close_session(); + } + finally { + setWasmFileSystem(this.instance, undefined); + } } private call(method: string, payload: Uint8Array): Uint8Array { @@ -166,6 +196,7 @@ function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { "get_request_buffer", "handle_request", "set_file", + "read_file", "remove_file", "response_ptr", "response_len", diff --git a/packages/api-wasm/src/wasi.ts b/packages/api-wasm/src/wasi.ts index 2a674c6bd091d..5b8df93412392 100644 --- a/packages/api-wasm/src/wasi.ts +++ b/packages/api-wasm/src/wasi.ts @@ -6,29 +6,50 @@ const errnoInvalidArgument = 28; const errnoIo = 29; const errnoNoSys = 52; const fileTypeCharacterDevice = 2; +const eventTypeClock = 0; +const subscriptionClockAbstime = 1; const hostCallbackFD = 0x7fff_fffd; -const wasmCallbacks = new WeakMap string>>(); +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; + registerCallback(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback(name: string): void; +} + +const wasmHosts = new WeakMap(); + +export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { + const host = wasmHosts.get(instance); + if (!host) { + if (fs === undefined) return; + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.setFileSystem(fs); +} export function registerWasmCallback( instance: WasmReactorInstance, name: string, callback: (name: string, payload: string) => string, ): void { - const callbacks = wasmCallbacks.get(instance); - if (!callbacks) { + const host = wasmHosts.get(instance); + if (!host) { throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); } - callbacks.set(name, callback); + host.registerCallback(name, callback); } export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { - wasmCallbacks.get(instance)?.delete(name); -} - -export interface InstantiateWasmOptions { - stdout?: (text: string) => void; - stderr?: (text: string) => void; + wasmHosts.get(instance)?.unregisterCallback(name); } /** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ @@ -36,12 +57,33 @@ 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(); const callbacks = new Map string>(); + let fileSystem: WasmFileSystem | undefined; + let hostError = new Uint8Array(); function getMemory(): WebAssembly.Memory { const memory = instance?.exports.memory; @@ -191,6 +233,63 @@ export async function instantiateWasm( return errnoSuccess; } + function hostWriteFile(pathPointer: number, pathLength: number, dataPointer: number, dataLength: number): number { + if (!fileSystem?.writeFile) return 2; + const memory = getMemory(); + const decoder = new TextDecoder(); + 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); + return errnoSuccess; + } + catch (error) { + hostError = encoder.encode(error instanceof Error ? error.message : String(error)); + return 1; + } + } + + function pollOneoff( + subscriptionsPointer: number, + eventsPointer: number, + subscriptionsLength: number, + eventsLengthPointer: number, + ): number { + 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; } @@ -216,7 +315,7 @@ export async function instantiateWasm( path_open: unsupported, path_remove_directory: unsupported, path_unlink_file: unsupported, - poll_oneoff: unsupported, + poll_oneoff: pollOneoff, proc_exit: (code: number) => { throw new Error(`TypeScript WASM runtime exited with code ${code}`); }, @@ -224,14 +323,36 @@ export async function instantiateWasm( sched_yield: () => errnoSuccess, }; - instance = await WebAssembly.instantiate(module, { - wasi_snapshot_preview1: wasi, - }); - const initialize = instance.exports._initialize; - if (typeof initialize !== "function") { - throw new Error("TypeScript WASM reactor did not export _initialize"); - } - initialize(); - wasmCallbacks.set(instance, callbacks); - return instance; + return { + imports: { + typescript_host: { + error_copy: (bufferPointer: number, bufferLength: number) => { + new Uint8Array(getMemory().buffer, bufferPointer, bufferLength).set(hostError.subarray(0, bufferLength)); + }, + error_length: () => hostError.length, + write_file: hostWriteFile, + }, + wasi_snapshot_preview1: wasi, + }, + initialize(value) { + instance = value; + const initialize = instance.exports._initialize; + if (typeof initialize !== "function") { + throw new Error("TypeScript WASM reactor did not export _initialize"); + } + initialize(); + wasmHosts.set(instance, { + setFileSystem(value) { + fileSystem = value; + }, + registerCallback(name, callback) { + callbacks.set(name, callback); + }, + unregisterCallback(name) { + callbacks.delete(name); + }, + }); + return instance; + }, + }; } diff --git a/packages/typescript/package.json b/packages/typescript/package.json index d0aaba78529c1..602c9cae55443 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -133,7 +133,9 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "assert": "^2.1.0", "playwright": "^1.62.1", + "process": "^0.11.10", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" } diff --git a/packages/typescript/src/api/async/browserClient.ts b/packages/typescript/src/api/async/browserClient.ts index bf5c5f31b78a5..2a5fa22bd2fdf 100644 --- a/packages/typescript/src/api/async/browserClient.ts +++ b/packages/typescript/src/api/async/browserClient.ts @@ -14,6 +14,7 @@ export class Client extends TransportClient { if (!("transport" in options)) { throw new Error("The browser async API requires an injected transport"); } + 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 f85d5e6ccedff..e438d3edc04f8 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -62,6 +62,7 @@ export class Client { constructor(options: AsyncClientOptions) { if (isAsyncTransportOptions(options)) { + options.transport.setFileSystem?.(options.fs); this.transportClient = new TransportClient( options.transport, options.collectTiming ?? false, diff --git a/packages/typescript/src/api/async/transport.ts b/packages/typescript/src/api/async/transport.ts index 0fcee4b622fce..32670de1821bc 100644 --- a/packages/typescript/src/api/async/transport.ts +++ b/packages/typescript/src/api/async/transport.ts @@ -15,5 +15,7 @@ export interface AsyncTransport { ): AsyncTransportResponse | PromiseLike>; registerCallback?(name: string, callback: (name: string, payload: string) => string): void; unregisterCallback?(name: string): void; + setFileSystem?(fs: FileSystem | undefined): void; close(): void | PromiseLike; } +import type { FileSystem } from "../fs.ts"; diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 02ced487fef29..7302cfb2fc715 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -404,6 +404,20 @@ export function uint8ArrayToBase64(data: Uint8Array): string { export function sourceFileResponseToUint8Array(response: { readonly data: string; } | null | undefined): Uint8Array | undefined { if (!response) return undefined; - const buffer = Buffer.from(response.data, "base64"); - return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + const fromBase64 = (Uint8Array as { + fromBase64?: (data: string) => Uint8Array; + }).fromBase64; + if (fromBase64) { + return fromBase64(response.data); + } + const bufferConstructor = (globalThis as { + Buffer?: { + from(data: string, encoding: "base64"): Uint8Array; + }; + }).Buffer; + if (bufferConstructor) { + return new Uint8Array(bufferConstructor.from(response.data, "base64")); + } + const decoded = atob(response.data); + return Uint8Array.from(decoded, character => character.charCodeAt(0)); } diff --git a/packages/typescript/src/api/node/wtf8.ts b/packages/typescript/src/api/node/wtf8.ts index 2837c62ea5f92..5159ec1546b8a 100644 --- a/packages/typescript/src/api/node/wtf8.ts +++ b/packages/typescript/src/api/node/wtf8.ts @@ -4,7 +4,7 @@ const surrogateSecondByteMax = 0xBF; const continuationByteMin = 0x80; const continuationByteMax = 0xBF; const textEncoder = new TextEncoder(); -const replacementCharacterUtf8 = Buffer.from([0xEF, 0xBF, 0xBD]); +const replacementCharacterUtf8 = new Uint8Array([0xEF, 0xBF, 0xBD]); const loneSurrogateRegExp = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?:^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]/; const shortStringLength = 64; type DecodeInput = ArrayBufferView | ArrayBufferLike | null; @@ -29,6 +29,19 @@ function hasSurrogateLeadByte(bytes: Uint8Array): boolean { return bytes.includes(surrogateLeadByte); } +function hasReplacementCharacter(bytes: Uint8Array): boolean { + for (let i = 0; i <= bytes.length - replacementCharacterUtf8.length; i++) { + if ( + bytes[i] === replacementCharacterUtf8[0] + && bytes[i + 1] === replacementCharacterUtf8[1] + && bytes[i + 2] === replacementCharacterUtf8[2] + ) { + return true; + } + } + return false; +} + function toUint8Array(input: Exclude): Uint8Array { if (input instanceof Uint8Array) { return input; @@ -43,7 +56,7 @@ export function encodeWtf8(text: string): Uint8Array { if (text.length > shortStringLength) { const utf8 = textEncoder.encode(text); if ( - Buffer.from(utf8.buffer, utf8.byteOffset, utf8.byteLength).indexOf(replacementCharacterUtf8) < 0 + !hasReplacementCharacter(utf8) || !loneSurrogateRegExp.test(text) ) { return utf8; diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index a797cb25ad0b6..1f4ff1100cac3 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -46,6 +46,8 @@ export interface ClientTransportOptions { 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; } @@ -61,6 +63,8 @@ export interface AsyncClientTransportOptions { 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; } diff --git a/packages/typescript/src/api/sync/browserClient.ts b/packages/typescript/src/api/sync/browserClient.ts index 5e731a7d6b631..b18230b0103e5 100644 --- a/packages/typescript/src/api/sync/browserClient.ts +++ b/packages/typescript/src/api/sync/browserClient.ts @@ -14,6 +14,7 @@ export class Client extends TransportClient { if (!("transport" in options)) { throw new Error("The browser sync API requires an injected transport"); } + options.transport.setFileSystem?.(options.fs); super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); } } diff --git a/packages/typescript/src/api/sync/transport.ts b/packages/typescript/src/api/sync/transport.ts index 63cd9a8333066..fcf82b40b9c1c 100644 --- a/packages/typescript/src/api/sync/transport.ts +++ b/packages/typescript/src/api/sync/transport.ts @@ -14,5 +14,7 @@ export interface SyncTransport { requestBinarySync(method: string, payload: Uint8Array): Uint8Array; registerCallback?(name: string, callback: (name: string, payload: string) => string): void; unregisterCallback?(name: string): void; + setFileSystem?(fs: FileSystem | undefined): void; close(): void; } +import type { FileSystem } from "../fs.ts"; diff --git a/packages/typescript/test/browser/apiWrapper.ts b/packages/typescript/test/browser/apiWrapper.ts new file mode 100644 index 0000000000000..a7694ea2140ab --- /dev/null +++ b/packages/typescript/test/browser/apiWrapper.ts @@ -0,0 +1,198 @@ +import { + instantiateWasm, + type WasmReactorInstance, + WasmTransport, +} from "@typescript/api-wasm"; +import type { FileSystem } from "@typescript/typescript/unstable/fs"; +import { + type DocumentIdentifier, + type FileNotifications, + resolveFileName, +} from "../../src/api/proto.ts"; + +interface BrowserAPIOptions { + cwd?: string; + fs?: FileSystem; + collectTiming?: boolean; + maxResponseBytesPerPage?: number; + transport?: object; +} + +interface BrowserSnapshotChanges { + fileNotifications?: FileNotifications; + openFiles?: readonly DocumentIdentifier[]; +} + +const availableInstances: WasmReactorInstance[] = []; +const mirroredFiles = new WeakMap>(); + +export async function initializeBrowserAPIInstances(module: WebAssembly.Module, count: number): Promise { + availableInstances.push( + ...await Promise.all( + Array.from({ length: count }, () => instantiateWasm(module)), + ), + ); +} + +class BrowserWasmTransport extends WasmTransport { + private returned = false; + + constructor( + private readonly reactorInstance: WasmReactorInstance, + options: ConstructorParameters[0], + ) { + super(options); + } + + override close(): void { + if (this.returned) return; + super.close(); + this.returned = true; + availableInstances.push(this.reactorInstance); + } +} + +export function createBrowserAPIOptions(options: BrowserAPIOptions): { + options: BrowserAPIOptions; + transport?: WasmTransport; +} { + if (options.transport) return { options }; + const instance = availableInstances.pop(); + if (!instance) { + throw new Error("No initialized TypeScript WASM reactor is available"); + } + const writeFile = options.fs?.writeFile; + const transport = new BrowserWasmTransport(instance, { + instance, + cwd: options.cwd ?? "/", + ...writeFile === undefined ? {} : { fs: { writeFile: writeFile.bind(options.fs) } }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + }); + if (options.fs) { + synchronizeFileSystem(options.fs, transport); + } + return { + options: { + transport, + ...options.fs === undefined ? {} : { fs: options.fs }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + ...options.maxResponseBytesPerPage === undefined ? {} : { maxResponseBytesPerPage: options.maxResponseBytesPerPage }, + }, + transport, + }; +} + +export function wrapFileUpdates(api: T, fs: FileSystem | undefined, transport: WasmTransport | undefined): void { + if (!fs || !transport) return; + wrapMethod("createSnapshot", args => { + const params = args[0] as BrowserSnapshotChanges | undefined; + syncFileChanges(fs, transport, params?.fileNotifications); + syncOpenFiles(fs, transport, params); + }); + wrapMethod("updateSnapshot", args => { + const params = args[1] as BrowserSnapshotChanges | undefined; + syncFileChanges(fs, transport, params?.fileNotifications); + syncOpenFiles(fs, transport, params); + }); + + function wrapMethod(name: string, before: (args: unknown[]) => void): void { + const target = api as Record; + const original = target[name] as ((...args: unknown[]) => unknown) & { + gen?: (...args: unknown[]) => Generator; + }; + const bound = original.bind(api); + const wrapped = (...args: unknown[]) => { + before(args); + return bound(...args); + }; + if (original.gen) { + const boundGen = original.gen.bind(api); + wrapped.gen = (...args: unknown[]) => { + before(args); + return boundGen(...args); + }; + } + Object.defineProperty(api, name, { + configurable: true, + value: wrapped, + }); + } +} + +function syncOpenFiles( + fs: FileSystem, + transport: WasmTransport, + changes: BrowserSnapshotChanges | undefined, +): void { + for (const file of changes?.openFiles ?? []) { + 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); + } + } +} + +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: FileNotifications | 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..f13ace06de468 --- /dev/null +++ b/packages/typescript/test/browser/asyncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/async/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/async/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as unknown as Parameters[0]; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts new file mode 100644 index 0000000000000..f029c7eed5b14 --- /dev/null +++ b/packages/typescript/test/browser/harness.ts @@ -0,0 +1,155 @@ +interface RegisteredTest { + name: string; + run: (context: BrowserTestContext) => unknown; +} + +interface BrowserTestContext { + after(callback: () => unknown): void; + mock: { + method( + target: T, + name: K, + implementation: T[K], + ): void; + }; +} + +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[] = []; + +interface TestOptions { + skip?: boolean | string; +} + +export function describe(name: string, options: TestOptions, run: () => void): void; +export function describe(name: string, run: () => void): void; +export function describe(name: string, optionsOrRun: TestOptions | (() => void), run?: () => void): void { + const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; + suites.push(name); + try { + callback(); + } + finally { + suites.pop(); + } +} + +export function test(name: string, options: TestOptions, run: (context: BrowserTestContext) => unknown): void; +export function test(name: string, run: (context: BrowserTestContext) => unknown): void; +export function test( + name: string, + optionsOrRun: TestOptions | ((context: BrowserTestContext) => unknown), + run?: (context: BrowserTestContext) => unknown, +): void { + const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; + tests.push({ + name: [...suites, name].join(" > "), + run: callback, + }); +} + +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); + const restorations: (() => void)[] = []; + const context: BrowserTestContext = { + after(callback) { + restorations.push(callback); + }, + mock: { + method(target, name, implementation) { + const descriptor = Object.getOwnPropertyDescriptor(target, name); + Object.defineProperty(target, name, { + configurable: true, + value: implementation, + writable: true, + }); + restorations.push(() => { + if (descriptor) { + Object.defineProperty(target, name, descriptor); + } + else { + delete target[name]; + } + }); + }, + }, + }; + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.resolve().then(() => registered.run(context)), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Test timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } + finally { + clearTimeout(timer); + for (let i = restorations.length - 1; i >= 0; i--) { + await restorations[i](); + } + } + passed++; + } + catch (error) { + const normalized = normalizeError(error); + failures.push({ + name: registered.name, + message: normalized.message, + ...normalized.stack === undefined ? {} : { stack: normalized.stack }, + }); + } + } + return { 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..6332e4d8982af --- /dev/null +++ b/packages/typescript/test/browser/processShim.ts @@ -0,0 +1,13 @@ +import process from "process"; + +(process as { execArgv?: string[]; }).execArgv ??= []; + +export { process }; + +export function setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): ReturnType { + return setTimeout(callback, 0, ...args); +} + +export function clearImmediate(handle: ReturnType): void { + clearTimeout(handle); +} diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts new file mode 100644 index 0000000000000..4235a4e44f851 --- /dev/null +++ b/packages/typescript/test/browser/suite.test.ts @@ -0,0 +1,299 @@ +import { wasmURL } from "@typescript/api-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: 385, + sync: 467, +} as const; +const fileExclusions = [ + { + mode: "async", + file: "version.test.ts", + tests: 1, + reason: "This test compares the Node.js executable and generated declaration files.", + }, + { + 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: 1, + reason: "This Node.js integration test loads the WASM binary through node:fs; wasm.test.ts covers browser loading.", + }, + { + mode: "sync", + file: "sync/shutdown.test.ts", + tests: 1, + reason: "This test launches a Node.js child process.", + }, +] as const; + +describe("API test suite in a browser", () => { + let browser: Browser; + let origin: string; + const bundles = new Map(); + const server = createServer(); + + before(async () => { + const allTestFiles = globSync(["*.test.ts", "async/**/*.test.ts", "sync/**/*.test.ts"], { + cwd: path.join(packageDir, "test"), + }); + for (const mode of modes) { + const testFiles = allTestFiles.filter(file => { + const belongsToMode = mode === "async" + ? !file.includes("/") || file.startsWith("async/") + : file.startsWith("sync/"); + return belongsToMode && !fileExclusions.some(exclusion => exclusion.file === file); + }); + const result = await build({ + stdin: { + contents: ` + ${testFiles.map(file => `import ${JSON.stringify(path.join(packageDir, "test", file))};`).join("\n")} + import { runRegisteredTests } from ${JSON.stringify(path.join(browserDir, "harness.ts"))}; + import { initializeBrowserAPIInstances } from ${JSON.stringify(path.join(browserDir, "apiWrapper.ts"))}; + try { + const module = await WebAssembly.compileStreaming(fetch("/typescript-api.wasm")); + await initializeBrowserAPIInstances(module, 4); + globalThis.browserTestResults = await runRegisteredTests([ + { + pattern: /benchmarks$/i, + reason: "Benchmarks require Node.js process and filesystem APIs.", + }, + { + pattern: /updateSnapshot file systems > (?:full file system is total and does not invoke host callbacks|full file system accepts paths decoded from VS Code document URIs|file system layer bypasses callbacks on hits and falls back on misses)$/, + reason: "The browser WASM adapter mirrors callback files into reactor memory instead of exposing live host callback layering.", + }, + { + pattern: /module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)$/, + reason: "An in-thread WebAssembly reactor cannot service nested API requests while a host callback is in flight.", + }, + { + 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 === "/typescript-api.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); + t.diagnostic(`${results.passed} passed, ${results.skipped.length + excludedFileTests} skipped`); + for (const skipped of results.skipped) { + t.diagnostic(`SKIP ${skipped.name}: ${skipped.reason}`); + } + for (const exclusion of excludedFiles) { + t.diagnostic(`SKIP ${exclusion.file} (${exclusion.tests} tests): ${exclusion.reason}`); + } + }); + } +}); + +function browserTestPlugin(mode: "async" | "sync"): Plugin { + const harness = path.join(browserDir, "harness.ts"); + const api = path.join(browserDir, `${mode}API.ts`); + return { + name: "browser-test", + setup(build) { + build.onResolve({ filter: /^node:test$/ }, () => ({ path: harness })); + build.onResolve({ filter: /^node:assert$/ }, () => ({ path: "node-assert", namespace: "browser-shim" })); + build.onResolve({ filter: /^\.\/ast\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:fs$/ }, () => ({ path: "node-fs", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:module$/ }, () => ({ path: "node-module", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:path$/ }, () => ({ path: "node-path", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:url$/ }, () => ({ path: "node-url", namespace: "browser-shim" })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/async$/ }, () => ({ + path: mode === "async" ? api : path.join(browserDir, "asyncAPI.ts"), + })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/sync$/ }, () => ({ + path: mode === "sync" ? api : path.join(browserDir, "syncAPI.ts"), + })); + build.onResolve({ filter: /^\.\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onResolve({ filter: /^(?:\.\.\/)+generators\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onLoad({ filter: /.*/, namespace: "browser-shim" }, args => { + switch (args.path) { + case "api-bench": + return { contents: "export function runBenchmarks() { throw new Error('Benchmarks are not available in a browser'); }" }; + case "node-assert": + return { + contents: ` + import assert from "assert"; + const equal = assert.equal; + assert.equal = (actual, expected, message) => { + if (Number.isNaN(actual) && Number.isNaN(expected)) return; + equal(actual, expected, message); + }; + export default assert; + `, + resolveDir: packageDir, + }; + case "node-fs": + return { + contents: ` + export function globSync() { throw new Error("globSync is not available in a browser"); } + export default { + existsSync() { return false; }, + readFileSync() { throw new Error("readFileSync is not available in a browser"); }, + }; + `, + }; + case "node-module": + return { contents: "export default {};" }; + case "node-path": + return { + contents: ` + export function dirname(path) { + const index = path.lastIndexOf("/"); + return index < 0 ? "." : path.slice(0, index) || "/"; + } + export function resolve(...parts) { + const path = parts.join("/"); + const normalized = []; + for (const part of path.split("/")) { + if (!part || part === ".") continue; + if (part === "..") normalized.pop(); + else normalized.push(part); + } + return "/" + normalized.join("/"); + } + export default { dirname, resolve }; + `, + }; + case "node-url": + return { contents: "export function fileURLToPath(url) { return new URL(url).pathname; }" }; + } + return undefined; + }); + }, + }; +} diff --git a/packages/typescript/test/browser/syncAPI.ts b/packages/typescript/test/browser/syncAPI.ts new file mode 100644 index 0000000000000..1c2ca8cff4279 --- /dev/null +++ b/packages/typescript/test/browser/syncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/sync/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/sync/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as unknown as Parameters[0]; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/browser/wasm.test.ts b/packages/typescript/test/browser/wasm.test.ts index 4f71992a04dc8..7cdd7d4ba3c29 100644 --- a/packages/typescript/test/browser/wasm.test.ts +++ b/packages/typescript/test/browser/wasm.test.ts @@ -34,9 +34,9 @@ describe("API over WebAssembly in a browser", () => { transport.setFile("/tsconfig.json", "{}"); transport.setFile("/src/index.ts", "export const value = 42 as const;"); try { - const snapshot = await api.updateSnapshot({ + const snapshot = await api.createSnapshot({ openFiles: ["/src/index.ts"], - fileChanges: { + fileNotifications: { changed: ["/tsconfig.json", "/src/index.ts"], deleted: [], }, diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts index cb9d3c1062ff6..a5b03207b7751 100644 --- a/packages/typescript/test/sync/transport.test.ts +++ b/packages/typescript/test/sync/transport.test.ts @@ -135,6 +135,16 @@ describe("sync transport", () => { 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); @@ -150,11 +160,17 @@ describe("sync transport", () => { }); 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/tsc/cmd/tsc-api-wasm/main.go b/tsc/cmd/tsc-api-wasm/main.go index 26eff09907e2f..39cc2d43e4c96 100644 --- a/tsc/cmd/tsc-api-wasm/main.go +++ b/tsc/cmd/tsc-api-wasm/main.go @@ -14,6 +14,8 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" "github.com/microsoft/TypeScript/tsc/internal/ipc" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/wrapvfs" ) func main() {} @@ -56,6 +58,22 @@ func createSession(optionsPtr uint32, optionsLen uint32) (status uint32) { return fail(fmt.Sprintf("invalid session options: %v", err)) } options.Conn = hostCallbackConn{} + options.WrapFS = func(files vfs.FS) vfs.FS { + return wrapvfs.Wrap(files, wrapvfs.Replacements{ + WriteFile: func(path string, data string) error { + pathBytes := []byte(path) + dataBytes := []byte(data) + switch hostWriteFile(bytePointer(pathBytes), uint32(len(pathBytes)), bytePointer(dataBytes), uint32(len(dataBytes))) { + case 0: + return nil + case 2: + return files.WriteFile(path, data) + default: + return fmt.Errorf("%s", readHostError()) + } + }, + }) + } reactor = wasmreactor.New(context.Background(), options) setResponse(nil) return 0 @@ -134,6 +152,31 @@ func setFile(pathLen uint32, contentLen uint32) uint32 { 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 { @@ -248,3 +291,22 @@ func (hostCallbackConn) Notify(context.Context, string, any) error { //go:wasmimport wasi_snapshot_preview1 fd_write //go:noescape func hostFDWrite(fd int32, iovs unsafe.Pointer, iovsLen uint32, written *uint32) uint32 + +func readHostError() string { + length := hostErrorLength() + if length == 0 { + return "host filesystem callback failed" + } + buffer := make([]byte, length) + hostErrorCopy(&buffer[0], length) + return string(buffer) +} + +//go:wasmimport typescript_host write_file +func hostWriteFile(path *byte, pathLen uint32, data *byte, dataLen uint32) uint32 + +//go:wasmimport typescript_host error_length +func hostErrorLength() uint32 + +//go:wasmimport typescript_host error_copy +func hostErrorCopy(buffer *byte, length uint32) diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go index e74c2d6998a93..1d9cac1354830 100644 --- a/tsc/internal/api/wasmreactor/reactor.go +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -17,10 +17,11 @@ import ( ) type Options struct { - Cwd string `json:"cwd"` - UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` - CollectTiming bool `json:"collectTiming"` - Conn ipc.Conn `json:"-"` + Cwd string `json:"cwd"` + UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` + CollectTiming bool `json:"collectTiming"` + WrapFS func(vfs.FS) vfs.FS `json:"-"` + Conn ipc.Conn `json:"-"` } type Response struct { @@ -42,10 +43,14 @@ func New(ctx context.Context, options Options) *Reactor { } 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(files), + FS: bundled.WrapFS(projectFiles), Options: &project.SessionOptions{ CurrentDirectory: options.Cwd, DefaultLibraryPath: bundled.LibPath(), @@ -106,6 +111,10 @@ 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) } diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go index eaf62821e787b..a59eb8901ab14 100644 --- a/tsc/internal/api/wasmreactor/reactor_test.go +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -17,9 +17,12 @@ func TestReactor(t *testing.T) { if err := reactor.SetFile("/src/index.ts", "export const value = 1;"); err != nil { t.Fatal(err) } - if contents, ok := reactor.files.ReadFile("/src/index.ts"); !ok || contents != "export const value = 1;" { + 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 { From bdef80bfd12d6d52b2ad420d66ed04a22cf04adc Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:06:09 -0700 Subject: [PATCH 003/142] Preserve transport filesystem callbacks Do not clear filesystem callbacks configured directly on a transport when the API is constructed without its own filesystem. Assert that the browser harness accounts for all 669 API tests and cover WASM emit callbacks through the public transport setup. --- packages/typescript/src/api/async/browserClient.ts | 4 +++- packages/typescript/src/api/async/client.ts | 4 +++- packages/typescript/src/api/sync/browserClient.ts | 4 +++- packages/typescript/test/browser/suite.test.ts | 5 +++++ packages/typescript/test/sync/wasm.test.ts | 13 ++++++++++++- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/typescript/src/api/async/browserClient.ts b/packages/typescript/src/api/async/browserClient.ts index 2a5fa22bd2fdf..d70e67203af9d 100644 --- a/packages/typescript/src/api/async/browserClient.ts +++ b/packages/typescript/src/api/async/browserClient.ts @@ -14,7 +14,9 @@ export class Client extends TransportClient { if (!("transport" in options)) { throw new Error("The browser async API requires an injected transport"); } - options.transport.setFileSystem?.(options.fs); + 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 e438d3edc04f8..7f41f9cf90b12 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -62,7 +62,9 @@ export class Client { constructor(options: AsyncClientOptions) { if (isAsyncTransportOptions(options)) { - options.transport.setFileSystem?.(options.fs); + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } this.transportClient = new TransportClient( options.transport, options.collectTiming ?? false, diff --git a/packages/typescript/src/api/sync/browserClient.ts b/packages/typescript/src/api/sync/browserClient.ts index b18230b0103e5..17c493d1b20ff 100644 --- a/packages/typescript/src/api/sync/browserClient.ts +++ b/packages/typescript/src/api/sync/browserClient.ts @@ -14,7 +14,9 @@ export class Client extends TransportClient { if (!("transport" in options)) { throw new Error("The browser sync API requires an injected transport"); } - options.transport.setFileSystem?.(options.fs); + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); } } diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index 4235a4e44f851..a4e7c96cd2e72 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -208,6 +208,11 @@ describe("API test suite in a browser", () => { 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}`); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index acf4be82b89b7..5c0095a14b6ab 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -24,7 +24,17 @@ describe("API over WebAssembly", () => { ); const instance = await instantiateWasm(module); - const transport = new WasmTransport({ instance, cwd: "/", collectTiming: true }); + 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( @@ -57,6 +67,7 @@ describe("API over WebAssembly", () => { emittedFiles: ["/src/index.js"], fileSystem: undefined, }); + assert.strictEqual(emittedFiles.get("/src/index.js"), "export const value = 42;\n"); const timing = api.getTimingInfo(); assert.strictEqual(timing.enabled, true); From 9ca128ee8b23b55e495df6c9195af1e443cedc7f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:14:41 -0700 Subject: [PATCH 004/142] Install WASM callbacks after session creation Keep an active transport's filesystem callback intact when a second transport fails to create a session on the same reactor instance. --- packages/api-wasm/src/index.ts | 6 ++---- packages/typescript/test/sync/wasm.test.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/api-wasm/src/index.ts b/packages/api-wasm/src/index.ts index 5ff3ad5f68cef..c82c4304c86ea 100644 --- a/packages/api-wasm/src/index.ts +++ b/packages/api-wasm/src/index.ts @@ -52,7 +52,6 @@ export class WasmTransport { constructor(options: WasmTransportOptions) { this.instance = options.instance; this.exports = getReactorExports(options.instance); - setWasmFileSystem(options.instance, options.fs); const sessionOptions = encoder.encode(JSON.stringify({ cwd: options.cwd ?? "/", useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, @@ -60,10 +59,9 @@ export class WasmTransport { })); this.writeRequest(sessionOptions); if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { - const message = this.readResponseText(); - setWasmFileSystem(this.instance, undefined); - throw new Error(`Failed to create TypeScript WASM session: ${message}`); + throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); } + setWasmFileSystem(options.instance, options.fs); } setFileSystem(fs: WasmFileSystem | undefined): void { diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 5c0095a14b6ab..c6472b7c70d8d 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -61,6 +61,14 @@ describe("API over WebAssembly", () => { 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, From c0436aec55d0416a9e7e7593d46fe54d159b19ed Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:21:01 -0700 Subject: [PATCH 005/142] Clean up failed WASM transport setup Close a newly created reactor session when filesystem callback attachment fails so the instance remains reusable. --- packages/api-wasm/src/index.ts | 16 +++++++++++++++- packages/typescript/test/sync/wasm.test.ts | 9 +++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/api-wasm/src/index.ts b/packages/api-wasm/src/index.ts index c82c4304c86ea..4db9f3a9b4081 100644 --- a/packages/api-wasm/src/index.ts +++ b/packages/api-wasm/src/index.ts @@ -61,7 +61,21 @@ export class WasmTransport { if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); } - setWasmFileSystem(options.instance, options.fs); + 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 { diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index c6472b7c70d8d..5509c22106c08 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -24,6 +24,15 @@ describe("API over WebAssembly", () => { ); 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, From 87b7d761f396ad30e90ff2874aa25edd67a4e581 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:28:15 -0700 Subject: [PATCH 006/142] Unify TypeScript WASI package Build the compiler, language server, and in-process API from one WASI module. Add the Node fallback, platform-specific runtime behavior, and release packaging for the unified artifact. --- Herebyfile.mjs | 96 +++++++++++----- package-lock.json | 43 ++++++-- package.json | 1 + packages/api-wasm/README.md | 21 ---- packages/api-wasm/src/wasmURL.source.ts | 2 - packages/api-wasm/src/wasmURL.ts | 2 - packages/typescript-wasip1-wasm/README.md | 35 ++++++ .../package.json | 9 +- .../src/index.ts | 0 .../src/wasi.ts | 49 ++++++--- .../src/wasmURL.source.ts | 2 + .../typescript-wasip1-wasm/src/wasmURL.ts | 2 + .../tsconfig.json | 0 packages/typescript/lib/getExePath.d.ts | 1 + packages/typescript/lib/getExePath.js | 35 +++++- packages/typescript/lib/tsc.js | 104 ++++++++++++++++-- .../typescript/test/browser/apiWrapper.ts | 2 +- .../typescript/test/browser/bundle.test.ts | 2 +- .../typescript/test/browser/suite.test.ts | 14 +-- packages/typescript/test/browser/wasm.test.ts | 8 +- .../typescript/test/sync/transport.test.ts | 2 +- packages/typescript/test/sync/wasm.test.ts | 51 ++++++++- packages/typescript/test/version.test.ts | 50 +++++++++ tools/pipelines/typescript-publish.yml | 14 +-- tsc/cmd/tsc/api.go | 5 +- tsc/cmd/tsc/currentdirectory.go | 9 ++ tsc/cmd/tsc/currentdirectory_wasip1.go | 17 +++ tsc/cmd/tsc/lsp.go | 16 +-- tsc/cmd/tsc/lsp_process.go | 20 ++++ tsc/cmd/tsc/lsp_process_wasip1.go | 18 +++ tsc/cmd/tsc/lsp_stdin.go | 10 ++ tsc/cmd/tsc/lsp_stdin_wasip1.go | 22 ++++ tsc/cmd/tsc/main.go | 3 +- tsc/cmd/tsc/notifycontext.go | 13 +++ tsc/cmd/tsc/notifycontext_wasip1.go | 12 ++ tsc/cmd/tsc/sys.go | 2 +- .../main.go => tsc/wasmapi_wasip1.go} | 72 +++++++----- 37 files changed, 600 insertions(+), 164 deletions(-) delete mode 100644 packages/api-wasm/README.md delete mode 100644 packages/api-wasm/src/wasmURL.source.ts delete mode 100644 packages/api-wasm/src/wasmURL.ts create mode 100644 packages/typescript-wasip1-wasm/README.md rename packages/{api-wasm => typescript-wasip1-wasm}/package.json (72%) rename packages/{api-wasm => typescript-wasip1-wasm}/src/index.ts (100%) rename packages/{api-wasm => typescript-wasip1-wasm}/src/wasi.ts (85%) create mode 100644 packages/typescript-wasip1-wasm/src/wasmURL.source.ts create mode 100644 packages/typescript-wasip1-wasm/src/wasmURL.ts rename packages/{api-wasm => typescript-wasip1-wasm}/tsconfig.json (100%) create mode 100644 tsc/cmd/tsc/currentdirectory.go create mode 100644 tsc/cmd/tsc/currentdirectory_wasip1.go create mode 100644 tsc/cmd/tsc/lsp_process.go create mode 100644 tsc/cmd/tsc/lsp_process_wasip1.go create mode 100644 tsc/cmd/tsc/lsp_stdin.go create mode 100644 tsc/cmd/tsc/lsp_stdin_wasip1.go create mode 100644 tsc/cmd/tsc/notifycontext.go create mode 100644 tsc/cmd/tsc/notifycontext_wasip1.go rename tsc/cmd/{tsc-api-wasm/main.go => tsc/wasmapi_wasip1.go} (85%) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index ff7cde164bd4a..09f6b45312019 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"; @@ -993,29 +994,74 @@ async function runBuildAPITests(generateSources = true) { * @param {string} out * @param {string[]} [extraFlags] */ -async function buildAPIWasmFile(out, 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-api-wasm"], { + await run("go", ["build", "-buildmode=c-shared", ...extraFlags, "-o", out, "./cmd/tsc"], { cwd: "./tsc", env: { GOOS: "wasip1", GOARCH: "wasm" }, }); + patchWasiFile(out); } -export const buildAPIWasm = task({ - name: "build:api:wasm", - description: "Builds the @typescript/api-wasm package.", +/** + * @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/api-wasm", "build:js"]); - await buildAPIWasmFile("./packages/api-wasm/dist/typescript-api.wasm"); + 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, buildAPIWasm], + dependencies: [generateEnums, generateAPI, buildWasi], run: runBuildAPITests, }); @@ -1857,10 +1903,10 @@ const mainNativePreviewPackage = { npmTarball: path.join(builtNpm, publishAsTypescript ? "typescript.tgz" : "native-preview.tgz"), }; -const apiWasmPackage = { - npmPackageName: "@typescript/api-wasm", - npmDir: path.join(builtNpm, "api-wasm"), - npmTarball: path.join(builtNpm, "typescript-api-wasm.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 = [ @@ -2231,7 +2277,7 @@ async function runBuildNativePreviewPackages() { const inputDir = "./packages/typescript"; await Promise.all([ - fs.promises.rm(path.join(inputDir, "dist", "typescript-api.wasm"), { force: true }), + 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 }), @@ -2327,13 +2373,13 @@ async function runBuildNativePreviewPackages() { throw new Error(`Found external imports in .d.ts files:\n${importErrors.map(e => " " + e).join("\n")}`); } - const wasmInputDir = "./packages/api-wasm"; + const wasmInputDir = "./packages/typescript-wasip1-wasm"; const extraFlags = options.respectGoEnv ? [] : getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined); const wasmPackageJson = JSON.parse(fs.readFileSync(path.join(wasmInputDir, "package.json"), "utf8")); wasmPackageJson.version = getVersion(); - wasmPackageJson.gitHead = gitHead; + wasmPackageJson.gitHead = inputPackageJson.gitHead; wasmPackageJson.peerDependencies = { [mainNativePreviewPackage.npmPackageName]: getVersion(), }; @@ -2346,16 +2392,16 @@ async function runBuildNativePreviewPackages() { delete wasmPackageJson.scripts; stripSourceConditions(wasmPackageJson); - await run("npm", ["run", "-w", "@typescript/api-wasm", "build:js"]); - await buildAPIWasmFile(path.join(wasmInputDir, "dist", "typescript-api.wasm"), extraFlags); - await cpRecursive(wasmInputDir, apiWasmPackage.npmDir, p => !p.endsWith("/node_modules") && !p.includes("/dist")); - await cpRecursive(path.join(wasmInputDir, "dist"), path.join(apiWasmPackage.npmDir, "dist")); + 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(apiWasmPackage.npmDir, "package.json"), + path.join(wasip1Package.npmDir, "package.json"), JSON.stringify(wasmPackageJson, undefined, 4), ); - await fs.promises.copyFile("LICENSE.txt", path.join(apiWasmPackage.npmDir, "LICENSE")); - await fs.promises.copyFile("NOTICE.txt", path.join(apiWasmPackage.npmDir, "NOTICE.txt")); + 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 = { @@ -2615,14 +2661,14 @@ async function runPackNativePreviewPackages() { } const platforms = getPlatforms(); - await Promise.all([mainNativePreviewPackage, apiWasmPackage, ...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); })); // Publish in dependency order: platform packages, the main package that references - // them as optionalDependencies, then the WASM package with its exact main-package peer. + // them as optionalDependencies, then the WASI package with its exact main-package peer. const publishManifest = { stages: [ platforms.map(p => ({ @@ -2635,7 +2681,7 @@ async function runPackNativePreviewPackages() { ], [ { - filename: path.basename(apiWasmPackage.npmTarball), + filename: path.basename(wasip1Package.npmTarball), }, ], ], diff --git a/package-lock.json b/package-lock.json index 4ea2ee473a3ae..e1dc343cf8e72 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^22.20.1", "@unicode/unicode-15.1.0": "^1.6.17", "adm-zip": "^0.6.1", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.57.4", "dprint-plugin-yaml": "^0.6.0", @@ -1548,10 +1549,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@typescript/api-wasm": { - "resolved": "packages/api-wasm", - "link": true - }, "node_modules/@typescript/bundled-typescript": { "name": "typescript", "version": "7.0.2", @@ -1898,6 +1895,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", @@ -2281,6 +2282,24 @@ "node": "18 || 20 || >=22" } }, + "node_modules/binaryen": { + "version": "132.0.0", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-132.0.0.tgz", + "integrity": "sha512-4Usg7yEvwO3FMso3Oov6qkOOHSod1IEv+boKkh77kTljM+eNBouqCTbqWswGzbG+vDNO/b/v2C4gfEh1WgB9Lg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-as": "bin/wasm-as", + "wasm-ctor-eval": "bin/wasm-ctor-eval", + "wasm-dis": "bin/wasm-dis", + "wasm-merge": "bin/wasm-merge", + "wasm-metadce": "bin/wasm-metadce", + "wasm-opt": "bin/wasm-opt", + "wasm-reduce": "bin/wasm-reduce", + "wasm-shell": "bin/wasm-shell", + "wasm2js": "bin/wasm2js" + } + }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -4749,9 +4768,9 @@ } }, "node_modules/which-typed-array": { - "version": "1.1.24", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", - "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "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": { @@ -5025,11 +5044,6 @@ "buffer-crc32": "~0.2.3" } }, - "packages/api-wasm": { - "name": "@typescript/api-wasm", - "version": "0.0.0", - "license": "Apache-2.0" - }, "packages/typescript": { "name": "@typescript/typescript", "version": "0.0.0", @@ -5049,6 +5063,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 320354f8d467c..f6b4cf0bf6ab4 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/node": "^22.20.1", "@unicode/unicode-15.1.0": "^1.6.17", "adm-zip": "^0.6.1", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.57.4", "dprint-plugin-yaml": "^0.6.0", diff --git a/packages/api-wasm/README.md b/packages/api-wasm/README.md deleted file mode 100644 index 7f72450e9bd9a..0000000000000 --- a/packages/api-wasm/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# `@typescript/api-wasm` - -WebAssembly transport for the TypeScript API. - -```ts -import { API } from "typescript/unstable/async"; -import { - instantiateWasm, - WasmTransport, - wasmURL, -} from "@typescript/api-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/api-wasm/src/wasmURL.source.ts b/packages/api-wasm/src/wasmURL.source.ts deleted file mode 100644 index a3cc55dbd74c7..0000000000000 --- a/packages/api-wasm/src/wasmURL.source.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** URL of the locally built WebAssembly reactor when using source conditions. */ -export const wasmURL: URL = new URL("../dist/typescript-api.wasm", import.meta.url); diff --git a/packages/api-wasm/src/wasmURL.ts b/packages/api-wasm/src/wasmURL.ts deleted file mode 100644 index e50abf9a5f29b..0000000000000 --- a/packages/api-wasm/src/wasmURL.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** URL of the WebAssembly reactor distributed with this package. */ -export const wasmURL: URL = new URL("./typescript-api.wasm", import.meta.url); 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/api-wasm/package.json b/packages/typescript-wasip1-wasm/package.json similarity index 72% rename from packages/api-wasm/package.json rename to packages/typescript-wasip1-wasm/package.json index 2ea25c7590de9..ed13c096a3c26 100644 --- a/packages/api-wasm/package.json +++ b/packages/typescript-wasip1-wasm/package.json @@ -1,21 +1,22 @@ { "private": true, - "name": "@typescript/api-wasm", + "name": "@typescript/typescript-wasip1-wasm", "version": "0.0.0", "license": "Apache-2.0", "author": "Microsoft Corp.", "homepage": "https://www.typescriptlang.org/", - "description": "WebAssembly transport for the native TypeScript compiler API", + "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" }, - "./api.wasm": "./dist/typescript-api.wasm" + "./tsc.wasm": "./dist/tsc.wasm" }, "imports": { "#wasmURL": { @@ -24,7 +25,7 @@ } }, "scripts": { - "build": "hereby --herebyfile ../../Herebyfile.mjs build:api:wasm", + "build": "hereby --herebyfile ../../Herebyfile.mjs build:wasip1", "build:js": "tsc -b" } } diff --git a/packages/api-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts similarity index 100% rename from packages/api-wasm/src/index.ts rename to packages/typescript-wasip1-wasm/src/index.ts diff --git a/packages/api-wasm/src/wasi.ts b/packages/typescript-wasip1-wasm/src/wasi.ts similarity index 85% rename from packages/api-wasm/src/wasi.ts rename to packages/typescript-wasip1-wasm/src/wasi.ts index 5b8df93412392..11fd14c677f3d 100644 --- a/packages/api-wasm/src/wasi.ts +++ b/packages/typescript-wasip1-wasm/src/wasi.ts @@ -9,6 +9,7 @@ const fileTypeCharacterDevice = 2; const eventTypeClock = 0; const subscriptionClockAbstime = 1; const hostCallbackFD = 0x7fff_fffd; +const hostWriteFileFD = 0x7fff_fffe; export interface InstantiateWasmOptions { stdout?: (text: string) => void; @@ -83,7 +84,6 @@ function createWasiHost(options: InstantiateWasmOptions): { const encoder = new TextEncoder(); const callbacks = new Map string>(); let fileSystem: WasmFileSystem | undefined; - let hostError = new Uint8Array(); function getMemory(): WebAssembly.Memory { const memory = instance?.exports.memory; @@ -149,6 +149,9 @@ function createWasiHost(options: InstantiateWasmOptions): { function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { iovsPointer >>>= 0; writtenPointer >>>= 0; + if (fd === hostWriteFileFD) { + return hostWriteFile(iovsPointer, iovsLength, writtenPointer); + } if (fd === hostCallbackFD) { return hostCallback(iovsPointer, iovsLength, writtenPointer); } @@ -233,19 +236,38 @@ function createWasiHost(options: InstantiateWasmOptions): { return errnoSuccess; } - function hostWriteFile(pathPointer: number, pathLength: number, dataPointer: number, dataLength: number): number { - if (!fileSystem?.writeFile) return 2; + 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) { - hostError = encoder.encode(error instanceof Error ? error.message : String(error)); - return 1; + 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; } } @@ -255,6 +277,9 @@ function createWasiHost(options: InstantiateWasmOptions): { 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); @@ -312,7 +337,9 @@ function createWasiHost(options: InstantiateWasmOptions): { 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, @@ -321,24 +348,18 @@ function createWasiHost(options: InstantiateWasmOptions): { }, random_get: randomGet, sched_yield: () => errnoSuccess, + sock_accept: unsupported, }; return { imports: { - typescript_host: { - error_copy: (bufferPointer: number, bufferLength: number) => { - new Uint8Array(getMemory().buffer, bufferPointer, bufferLength).set(hostError.subarray(0, bufferLength)); - }, - error_length: () => hostError.length, - write_file: hostWriteFile, - }, wasi_snapshot_preview1: wasi, }, initialize(value) { instance = value; - const initialize = instance.exports._initialize; + const initialize = instance.exports.typescript_initialize; if (typeof initialize !== "function") { - throw new Error("TypeScript WASM reactor did not export _initialize"); + throw new Error("TypeScript WASM reactor did not export typescript_initialize"); } initialize(); wasmHosts.set(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/api-wasm/tsconfig.json b/packages/typescript-wasip1-wasm/tsconfig.json similarity index 100% rename from packages/api-wasm/tsconfig.json rename to packages/typescript-wasip1-wasm/tsconfig.json 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/test/browser/apiWrapper.ts b/packages/typescript/test/browser/apiWrapper.ts index a7694ea2140ab..ae6458b8b83cc 100644 --- a/packages/typescript/test/browser/apiWrapper.ts +++ b/packages/typescript/test/browser/apiWrapper.ts @@ -2,7 +2,7 @@ import { instantiateWasm, type WasmReactorInstance, WasmTransport, -} from "@typescript/api-wasm"; +} from "@typescript/typescript-wasip1-wasm"; import type { FileSystem } from "@typescript/typescript/unstable/fs"; import { type DocumentIdentifier, diff --git a/packages/typescript/test/browser/bundle.test.ts b/packages/typescript/test/browser/bundle.test.ts index a1d7e7a883fba..11e70244b67da 100644 --- a/packages/typescript/test/browser/bundle.test.ts +++ b/packages/typescript/test/browser/bundle.test.ts @@ -10,7 +10,7 @@ describe("browser API bundle", () => { const result = await build({ stdin: { contents: ` - import { WasmTransport, wasmURL } from "@typescript/api-wasm"; + 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 }; diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index a4e7c96cd2e72..6bbad6c16b1f4 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -1,4 +1,4 @@ -import { wasmURL } from "@typescript/api-wasm"; +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; import { build, type Plugin, @@ -32,8 +32,8 @@ const fileExclusions = [ { mode: "async", file: "version.test.ts", - tests: 1, - reason: "This test compares the Node.js executable and generated declaration files.", + tests: 2, + reason: "These tests exercise Node.js executable and package resolution behavior.", }, { mode: "async", @@ -50,8 +50,8 @@ const fileExclusions = [ { mode: "sync", file: "sync/wasm.test.ts", - tests: 1, - reason: "This Node.js integration test loads the WASM binary through node:fs; wasm.test.ts covers browser loading.", + tests: 2, + reason: "These Node.js integration tests use node:fs and node:wasi; wasm.test.ts covers browser API loading.", }, { mode: "sync", @@ -85,7 +85,7 @@ describe("API test suite in a browser", () => { 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("/typescript-api.wasm")); + const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); await initializeBrowserAPIInstances(module, 4); globalThis.browserTestResults = await runRegisteredTests([ { @@ -137,7 +137,7 @@ describe("API test suite in a browser", () => { const wasm = await readFile(wasmURL); server.on("request", (request, response) => { - if (request.url === "/typescript-api.wasm") { + if (request.url === "/tsc.wasm") { response.setHeader("Content-Type", "application/wasm"); response.end(wasm); return; diff --git a/packages/typescript/test/browser/wasm.test.ts b/packages/typescript/test/browser/wasm.test.ts index 7cdd7d4ba3c29..89469f4abeff5 100644 --- a/packages/typescript/test/browser/wasm.test.ts +++ b/packages/typescript/test/browser/wasm.test.ts @@ -1,4 +1,4 @@ -import { wasmURL } from "@typescript/api-wasm"; +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; import { build } from "esbuild"; import assert from "node:assert"; import { readFile } from "node:fs/promises"; @@ -23,7 +23,7 @@ describe("API over WebAssembly in a browser", () => { const result = await build({ stdin: { contents: ` - import { instantiateWasm, WasmTransport } from "@typescript/api-wasm"; + import { instantiateWasm, WasmTransport } from "@typescript/typescript-wasip1-wasm"; import { API as AsyncAPI } from "typescript/unstable/async"; import { API as SyncAPI } from "typescript/unstable/sync"; @@ -53,7 +53,7 @@ describe("API over WebAssembly in a browser", () => { } export async function run() { - const module = await WebAssembly.compileStreaming(fetch("/typescript-api.wasm")); + const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); return [ await check(AsyncAPI, module), await check(SyncAPI, module), @@ -86,7 +86,7 @@ describe("API over WebAssembly in a browser", () => { response.setHeader("Content-Type", "text/javascript"); response.end(bundle); break; - case "/typescript-api.wasm": + case "/tsc.wasm": response.setHeader("Content-Type", "application/wasm"); response.end(wasm); break; diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts index a5b03207b7751..e6088891370f6 100644 --- a/packages/typescript/test/sync/transport.test.ts +++ b/packages/typescript/test/sync/transport.test.ts @@ -1,7 +1,7 @@ import { type WasmReactorExports, WasmTransport, -} from "@typescript/api-wasm"; +} from "@typescript/typescript-wasip1-wasm"; import { API, type ParsedCommandLine, diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 5509c22106c08..a19a5fdbadaf8 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -2,7 +2,7 @@ import { instantiateWasm, WasmTransport, wasmURL, -} from "@typescript/api-wasm"; +} from "@typescript/typescript-wasip1-wasm"; import { isIdentifier, isVariableStatement, @@ -10,18 +10,65 @@ import { import { API as AsyncAPI } from "@typescript/typescript/unstable/async"; import { API as SyncAPI } from "@typescript/typescript/unstable/sync"; import assert from "node:assert"; -import { readFile } from "node:fs/promises"; +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("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( 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 50ad9bc88d65d..97f7b2a0e673a 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -78,9 +78,9 @@ extends: artifactName: 'npm-main' displayName: 'Publish npm-main artifact' - output: pipelineArtifact - targetPath: '$(Build.ArtifactStagingDirectory)/npm-api-wasm' - artifactName: 'npm-api-wasm' - displayName: 'Publish npm-api-wasm artifact' + targetPath: '$(Build.ArtifactStagingDirectory)/npm-wasip1' + artifactName: 'npm-wasip1' + displayName: 'Publish npm-wasip1 artifact' steps: - checkout: none @@ -102,7 +102,7 @@ extends: src="$(Pipeline.Workspace)/npm" platformDst="$(Build.ArtifactStagingDirectory)/npm-platform" mainDst="$(Build.ArtifactStagingDirectory)/npm-main" - wasmDst="$(Build.ArtifactStagingDirectory)/npm-api-wasm" + wasmDst="$(Build.ArtifactStagingDirectory)/npm-wasip1" mkdir -p "$platformDst" "$mainDst" "$wasmDst" manifest="$src/publish-manifest.json" @@ -212,8 +212,8 @@ extends: artifactName: 'npm-main' targetPath: '$(Pipeline.Workspace)/npm-main' - input: pipelineArtifact - artifactName: 'npm-api-wasm' - targetPath: '$(Pipeline.Workspace)/npm-api-wasm' + artifactName: 'npm-wasip1' + targetPath: '$(Pipeline.Workspace)/npm-wasip1' steps: - checkout: none @@ -243,7 +243,7 @@ extends: intent: 'PackageDistribution' contentType: 'npm' contentSource: 'Folder' - folderLocation: '$(Pipeline.Workspace)/npm-api-wasm' + folderLocation: '$(Pipeline.Workspace)/npm-wasip1' waitForReleaseCompletion: true owners: 'jabaile@microsoft.com' approvers: 'alexchi@microsoft.com' 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..49153351d8b1c --- /dev/null +++ b/tsc/cmd/tsc/lsp_stdin_wasip1.go @@ -0,0 +1,22 @@ +//go:build wasip1 + +package main + +import ( + "runtime" + "syscall" +) + +type wasiStdin struct{} + +func (wasiStdin) Read(buffer []byte) (int, error) { + for { + n, err := syscall.Read(syscall.Stdin, buffer) + 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-api-wasm/main.go b/tsc/cmd/tsc/wasmapi_wasip1.go similarity index 85% rename from tsc/cmd/tsc-api-wasm/main.go rename to tsc/cmd/tsc/wasmapi_wasip1.go index 39cc2d43e4c96..ccd2418eaa1ae 100644 --- a/tsc/cmd/tsc-api-wasm/main.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -1,6 +1,5 @@ //go:build wasip1 -// Command tsc-api-wasm builds the TypeScript API server as a WebAssembly reactor. package main import ( @@ -8,6 +7,7 @@ import ( "encoding/binary" "errors" "fmt" + "os" "runtime/debug" "unsafe" @@ -18,11 +18,10 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/vfs/wrapvfs" ) -func main() {} - const ( - // API callbacks are tunneled through a reserved descriptor so that + // API callbacks are tunneled through reserved descriptors so that // the module only requires standard WASI imports and remains directly runnable. + hostWriteFileFD = 0x7fff_fffe hostCallbackFD = 0x7fff_fffd hostErrorCapacity = 16 * 1024 hostResultCapacity = 16 * 1024 * 1024 @@ -61,16 +60,11 @@ func createSession(optionsPtr uint32, optionsLen uint32) (status uint32) { options.WrapFS = func(files vfs.FS) vfs.FS { return wrapvfs.Wrap(files, wrapvfs.Replacements{ WriteFile: func(path string, data string) error { - pathBytes := []byte(path) - dataBytes := []byte(data) - switch hostWriteFile(bytePointer(pathBytes), uint32(len(pathBytes)), bytePointer(dataBytes), uint32(len(dataBytes))) { - case 0: - return nil - case 2: + handled, err := writeFileToHost(path, data) + if !handled { return files.WriteFile(path, data) - default: - return fmt.Errorf("%s", readHostError()) } + return err }, }) } @@ -205,6 +199,11 @@ 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 @@ -288,25 +287,38 @@ func (hostCallbackConn) Notify(context.Context, string, any) error { return errors.New("host callback notifications are not supported") } -//go:wasmimport wasi_snapshot_preview1 fd_write -//go:noescape -func hostFDWrite(fd int32, iovs unsafe.Pointer, iovsLen uint32, written *uint32) uint32 +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) -func readHostError() string { - length := hostErrorLength() - if length == 0 { - return "host filesystem callback failed" + 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 } - buffer := make([]byte, length) - hostErrorCopy(&buffer[0], length) - return string(buffer) -} - -//go:wasmimport typescript_host write_file -func hostWriteFile(path *byte, pathLen uint32, data *byte, dataLen uint32) uint32 -//go:wasmimport typescript_host error_length -func hostErrorLength() uint32 + 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 typescript_host error_copy -func hostErrorCopy(buffer *byte, length uint32) +//go:wasmimport wasi_snapshot_preview1 fd_write +//go:noescape +func hostFDWrite(fd int32, iovs unsafe.Pointer, iovsLen uint32, written *uint32) uint32 From 3788e0b6118c59f421238ab0eb55f7fb18b768b6 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:12:59 -0700 Subject: [PATCH 007/142] Fix WASI LSP shutdown Return io.EOF when WASI stdin closes instead of triggering repeated empty reads. Cover the command lifecycle in the unified module test. --- .../typescript/test/browser/suite.test.ts | 2 +- packages/typescript/test/sync/wasm.test.ts | 33 +++++++++++++++++++ tsc/cmd/tsc/lsp_stdin_wasip1.go | 4 +++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index 6bbad6c16b1f4..1b10e620960c0 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -50,7 +50,7 @@ const fileExclusions = [ { mode: "sync", file: "sync/wasm.test.ts", - tests: 2, + tests: 3, reason: "These Node.js integration tests use node:fs and node:wasi; wasm.test.ts covers browser API loading.", }, { diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index a19a5fdbadaf8..4636a94493b0d 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -56,6 +56,39 @@ describe("API over WebAssembly", () => { } }); + 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( diff --git a/tsc/cmd/tsc/lsp_stdin_wasip1.go b/tsc/cmd/tsc/lsp_stdin_wasip1.go index 49153351d8b1c..3f6ed68d10dd1 100644 --- a/tsc/cmd/tsc/lsp_stdin_wasip1.go +++ b/tsc/cmd/tsc/lsp_stdin_wasip1.go @@ -3,6 +3,7 @@ package main import ( + "io" "runtime" "syscall" ) @@ -12,6 +13,9 @@ 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 } From 677c6226ef06cd80e4deada9090adab80d1e11cf Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:24:31 -0700 Subject: [PATCH 008/142] Avoid busy polling WASI stdin --- packages/typescript/test/sync/wasm.test.ts | 58 ++++++++++++++++++++++ tsc/cmd/tsc/lsp_stdin_wasip1.go | 13 +---- tsc/cmd/tsc/wasi_io_wasip1.go | 30 +++++++++++ 3 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 tsc/cmd/tsc/wasi_io_wasip1.go diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 4636a94493b0d..fdfa3b35b5c8f 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -10,6 +10,7 @@ import { import { API as AsyncAPI } from "@typescript/typescript/unstable/async"; import { API as SyncAPI } from "@typescript/typescript/unstable/sync"; import assert from "node:assert"; +import { spawn } from "node:child_process"; import { mkdtemp, open, @@ -89,6 +90,63 @@ describe("API over WebAssembly", () => { } }); + test("waits for LSP stdin without repeatedly reading", { timeout: 10_000 }, async () => { + const childSource = ` + import { readFileSync } from "node:fs"; + import { WASI } from "node:wasi"; + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--lsp", "--stdio"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + returnOnExit: true, + }); + let reads = 0; + const imports = { + ...wasi.wasiImport, + fd_read(...args) { + if (++reads > 10) throw new Error("stdin was read repeatedly while idle"); + return wasi.wasiImport.fd_read(...args); + }, + }; + const module = await WebAssembly.compile(readFileSync(new URL(${JSON.stringify(wasmURL.toString())}))); + const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: imports }); + process.exitCode = wasi.start(instance); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", childSource], { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", chunk => stdout += chunk); + child.stderr.on("data", chunk => stderr += chunk); + + await new Promise(resolve => setTimeout(resolve, 250)); + const request = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + processId: null, + capabilities: {}, + rootUri: null, + workspaceFolders: null, + }, + }); + child.stdin.write(`Content-Length: ${Buffer.byteLength(request)}\r\n\r\n${request}`); + + while (!stdout.includes("Resolved client capabilities") && child.exitCode === null) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + child.stdin.end(); + const exitCode = child.exitCode ?? await new Promise(resolve => child.once("exit", resolve)); + assert.strictEqual(exitCode, 0, stderr); + assert.match(stdout, /Resolved client capabilities/); + }); + test("runs the compiler and checker through the reactor", async () => { const WebAssembly = (globalThis as any).WebAssembly; const module = await WebAssembly.compile( diff --git a/tsc/cmd/tsc/lsp_stdin_wasip1.go b/tsc/cmd/tsc/lsp_stdin_wasip1.go index 3f6ed68d10dd1..7f696fff2adf3 100644 --- a/tsc/cmd/tsc/lsp_stdin_wasip1.go +++ b/tsc/cmd/tsc/lsp_stdin_wasip1.go @@ -3,24 +3,13 @@ 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() - } + return readWasiFD(syscall.Stdin, buffer) } var lspStdin wasiStdin diff --git a/tsc/cmd/tsc/wasi_io_wasip1.go b/tsc/cmd/tsc/wasi_io_wasip1.go new file mode 100644 index 0000000000000..c26da883f6bf9 --- /dev/null +++ b/tsc/cmd/tsc/wasi_io_wasip1.go @@ -0,0 +1,30 @@ +//go:build wasip1 + +package main + +import ( + "io" + "syscall" + "time" +) + +const maxWasiPollDelay = 50 * time.Millisecond + +func readWasiFD(fd int, buffer []byte) (int, error) { + delay := time.Millisecond + for { + n, err := syscall.Read(fd, buffer) + if n > 0 { + return n, nil + } + if err == nil { + return 0, io.EOF + } + if err != syscall.EAGAIN { + return 0, err + } + // These descriptors are not registered with Go's WASI poller. + time.Sleep(delay) + delay = min(delay*2, maxWasiPollDelay) + } +} From 806857ef96bb2d8e02e5d8ee4089d3bba2c5acc3 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:24:35 -0700 Subject: [PATCH 009/142] Close queued async API requests --- packages/typescript/src/api/async/client.ts | 15 +++++++++++++-- packages/typescript/test/async/transport.test.ts | 11 +++++++++++ packages/typescript/test/browser/suite.test.ts | 6 +++++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 7f41f9cf90b12..28ddf1aeea163 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -287,7 +287,7 @@ export class Client { } private scheduleImmediateBatch(): void { - if (this.nextBatch) return; + if (this.closed || this.nextBatch) return; this.nextBatch = setImmediate(this.doBatch.bind(this)); } @@ -315,6 +315,7 @@ export class Client { if (!this.connected) { await this.connect(); } + if (this.closed) throw new Error("Client is closed"); if (!this.connection) { throw new Error("Connection not established"); } @@ -386,8 +387,18 @@ 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 + if (this.closed) return; this.closed = true; + if (this.nextBatch && this.nextBatch !== "manual") { + clearImmediate(this.nextBatch); + } + this.nextBatch = undefined; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection if (this.connection) { this.connection.dispose(); this.connection = undefined; diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts index 9b95a2a8526da..1d090caccc1f7 100644 --- a/packages/typescript/test/async/transport.test.ts +++ b/packages/typescript/test/async/transport.test.ts @@ -172,6 +172,17 @@ describe("async transport", () => { await rejected; }); + test("can close a process client while initialization is manually batched", async () => { + const api = new API({ cwd: process.cwd() }); + await api["client"].connect(); + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + test("does not flush a manual batch from a stale microtask", async () => { const methods: string[] = []; const client = new TransportClient({ diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index 1b10e620960c0..64dba493dfd9d 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -25,7 +25,7 @@ const browserDir = fileURLToPath(new URL(".", import.meta.url)); const packageDir = path.resolve(browserDir, "../.."); const modes = ["async", "sync"] as const; const expectedTestCounts = { - async: 385, + async: 386, sync: 467, } as const; const fileExclusions = [ @@ -100,6 +100,10 @@ describe("API test suite in a browser", () => { pattern: /module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)$/, reason: "An in-thread WebAssembly reactor cannot service nested API requests while a host callback is in flight.", }, + { + pattern: /can close a process client while initialization is manually batched$/, + reason: "This test exercises the Node.js spawned-process client.", + }, { pattern: /Parse-clone-emit roundtrip$/, reason: "The roundtrip fixture discovers source files through Node.js glob and filesystem APIs.", From 13b642ea0410e7a291763a0887c1cff2622ccf81 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:24:39 -0700 Subject: [PATCH 010/142] Document WASM callback limitations --- packages/typescript-wasip1-wasm/README.md | 5 ++++ packages/typescript-wasip1-wasm/src/index.ts | 29 +++++++++++++++---- packages/typescript-wasip1-wasm/src/wasi.ts | 6 ++-- .../typescript/src/api/async/transport.ts | 5 ++++ .../src/api/async/transportClient.ts | 2 +- packages/typescript/src/api/options.ts | 25 +++++++++++----- packages/typescript/src/api/sync/transport.ts | 1 + packages/typescript/test/sync/wasm.test.ts | 5 +++- 8 files changed, 59 insertions(+), 19 deletions(-) diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index bbabf5cf34d95..fc62d626277ae 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -33,3 +33,8 @@ const api = new API({ transport }); ``` Use `typescript/unstable/sync` instead for the synchronous API. + +WASM host callbacks run synchronously while the outer API request is in +progress. A callback must not return a Promise or call the same API transport; +use a process-backed API client when callbacks need asynchronous work or nested +API requests. diff --git a/packages/typescript-wasip1-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts index 4db9f3a9b4081..2078cb44f0df7 100644 --- a/packages/typescript-wasip1-wasm/src/index.ts +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -30,16 +30,21 @@ export interface WasmTransportOptions { * For example, call `wasi.initialize(instance)` before constructing the transport. */ instance: WasmReactorInstance; - cwd?: string; - useCaseSensitiveFileNames?: boolean; - collectTiming?: boolean; - fs?: WasmFileSystem; + cwd?: string | undefined; + useCaseSensitiveFileNames?: boolean | undefined; + collectTiming?: boolean | undefined; + fs?: WasmFileSystem | undefined; } const encoder = new TextEncoder(); const decoder = new TextDecoder(); -/** Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. */ +/** + * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. + * + * Host callbacks must complete synchronously and cannot call this transport while + * an outer request is in progress. + */ export class WasmTransport { lastBytesSent = 0; lastBytesReceived = 0; @@ -48,6 +53,7 @@ export class WasmTransport { private readonly exports: WasmReactorExports; private requestPointer = 0; private closed = false; + private inCallback = false; constructor(options: WasmTransportOptions) { this.instance = options.instance; @@ -111,7 +117,15 @@ export class WasmTransport { registerCallback(name: string, callback: (name: string, payload: string) => string): void { this.ensureOpen(); - registerWasmCallback(this.instance, name, callback); + registerWasmCallback(this.instance, name, (callbackName, payload) => { + this.inCallback = true; + try { + return callback(callbackName, payload); + } + finally { + this.inCallback = false; + } + }); } unregisterCallback(name: string): void { @@ -163,6 +177,9 @@ export class WasmTransport { private call(method: string, payload: Uint8Array): Uint8Array { this.ensureOpen(); + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot call the same API transport"); + } const methodBytes = encoder.encode(method); this.writeRequest(methodBytes, payload); this.lastBytesSent = payload.length; diff --git a/packages/typescript-wasip1-wasm/src/wasi.ts b/packages/typescript-wasip1-wasm/src/wasi.ts index 11fd14c677f3d..ab606bc367b00 100644 --- a/packages/typescript-wasip1-wasm/src/wasi.ts +++ b/packages/typescript-wasip1-wasm/src/wasi.ts @@ -12,12 +12,12 @@ const hostCallbackFD = 0x7fff_fffd; const hostWriteFileFD = 0x7fff_fffe; export interface InstantiateWasmOptions { - stdout?: (text: string) => void; - stderr?: (text: string) => void; + stdout?: ((text: string) => void) | undefined; + stderr?: ((text: string) => void) | undefined; } export interface WasmFileSystem { - writeFile?(path: string, data: string): void; + writeFile?: ((path: string, data: string) => void) | undefined; } interface WasmHost { diff --git a/packages/typescript/src/api/async/transport.ts b/packages/typescript/src/api/async/transport.ts index 32670de1821bc..f67a4a70ea541 100644 --- a/packages/typescript/src/api/async/transport.ts +++ b/packages/typescript/src/api/async/transport.ts @@ -13,6 +13,11 @@ export interface AsyncTransport { method: string, payload: Uint8Array, ): AsyncTransportResponse | PromiseLike>; + /** + * Registers a callback invoked synchronously by the transport. + * The callback cannot return a Promise or make a nested request through a + * non-reentrant transport. + */ registerCallback?(name: string, callback: (name: string, payload: string) => string): void; unregisterCallback?(name: string): void; setFileSystem?(fs: FileSystem | undefined): void; diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index 639e5ac152693..bd3a0bf6efb7e 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -137,7 +137,7 @@ export class TransportClient { register.call(this.transport, name, (_, payload) => { const result = callback(JSON.parse(payload)); if (result instanceof Promise) { - throw new Error("Asynchronous callbacks are not supported by this transport"); + throw new Error("Injected transport callbacks must complete synchronously"); } return JSON.stringify(result) ?? ""; }); diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 1f4ff1100cac3..ea18fdb2ce878 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -42,14 +42,18 @@ export function isSpawnOptions(options: ClientOptions): options is ClientSpawnOp } export interface ClientTransportOptions { - /** An existing synchronous transport connected to an API session. */ + /** + * An existing synchronous transport connected to an API session. + * Custom module resolution callbacks must obey the transport's reentrancy + * constraints. + */ transport: SyncTransport; /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ - maxResponseBytesPerPage?: number; + maxResponseBytesPerPage?: number | undefined; /** Virtual filesystem callbacks used by transports that support them. */ - fs?: FileSystem; + fs?: FileSystem | undefined; /** Collect timing information for requests made through the transport. */ - collectTiming?: boolean; + collectTiming?: boolean | undefined; } export type SyncClientOptions = ClientSocketOptions | ClientSpawnOptions | ClientTransportOptions; @@ -59,14 +63,19 @@ export function isTransportOptions(options: SyncClientOptions): options is Clien } export interface AsyncClientTransportOptions { - /** An existing asynchronous transport connected to an API session. */ + /** + * An existing asynchronous transport connected to an API session. + * Host callbacks are synchronous even when requests are asynchronous, so + * custom module resolution callbacks cannot return Promises and must obey + * the transport's reentrancy constraints. + */ transport: AsyncTransport; /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ - maxResponseBytesPerPage?: number; + maxResponseBytesPerPage?: number | undefined; /** Virtual filesystem callbacks used by transports that support them. */ - fs?: FileSystem; + fs?: FileSystem | undefined; /** Collect timing information for requests made through the transport. */ - collectTiming?: boolean; + collectTiming?: boolean | undefined; } export type AsyncClientOptions = ClientOptions | AsyncClientTransportOptions; diff --git a/packages/typescript/src/api/sync/transport.ts b/packages/typescript/src/api/sync/transport.ts index fcf82b40b9c1c..fe46a7a133f80 100644 --- a/packages/typescript/src/api/sync/transport.ts +++ b/packages/typescript/src/api/sync/transport.ts @@ -12,6 +12,7 @@ export interface SyncTransport { requestSync(method: string, payload: string): string; requestBinarySync(method: string, payload: Uint8Array): Uint8Array; + /** Registers a synchronous callback, subject to the transport's reentrancy constraints. */ registerCallback?(name: string, callback: (name: string, payload: string) => string): void; unregisterCallback?(name: string): void; setFileSystem?(fs: FileSystem | undefined): void; diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index fdfa3b35b5c8f..f43ffd866431b 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -160,12 +160,15 @@ describe("API over WebAssembly", () => { assert.ok(exportNames.includes("_start")); assert.ok(exportNames.includes("typescript_initialize")); assert.ok(!exportNames.includes("_initialize")); - const instance = await instantiateWasm(module); + const instance = await instantiateWasm(module, { stdout: undefined, stderr: undefined }); assert.throws( () => new WasmTransport({ instance: { exports: instance.exports }, + cwd: undefined, + useCaseSensitiveFileNames: undefined, + collectTiming: undefined, fs: { writeFile() {} }, }), /was not created by instantiateWasm/, From 573c39d574f6d1167a6c94f9d99957b51e5f84b6 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:38:56 -0700 Subject: [PATCH 011/142] Add web support to TypeScript 7 extension Browser-hosted VS Code cannot launch the native TypeScript server. Use the existing WASI build as an LSP process and map VS Code workspaces into its filesystem so the TypeScript 7 experience is available on the web. --- Herebyfile.mjs | 109 +++++++--- package-lock.json | 19 ++ packages/vscode-typescript/package.json | 10 +- .../vscode-typescript/src/extension.web.ts | 186 ++++++++++++++++++ .../vscode-typescript/src/extensionIds.ts | 1 + packages/vscode-typescript/src/util.ts | 2 +- .../vscode-typescript/src/webMountPoint.ts | 21 ++ packages/vscode-typescript/test/index.test.ts | 1 + .../test/webMountPoint.test.ts | 36 ++++ 9 files changed, 354 insertions(+), 31 deletions(-) create mode 100644 packages/vscode-typescript/src/extension.web.ts create mode 100644 packages/vscode-typescript/src/extensionIds.ts create mode 100644 packages/vscode-typescript/src/webMountPoint.ts create mode 100644 packages/vscode-typescript/test/webMountPoint.test.ts diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 09f6b45312019..e3851c83261ab 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1932,7 +1932,7 @@ ${entries} * @typedef {"Microsoft400" | "LinuxSign" | "MacDeveloperHarden" | "8020" | "VSCodePublisher"} Cert * @typedef {`${OS | "alpine"}-${Exclude | "armhf"}`} VSCodeTarget * @typedef {{ name: string; sourceDir: string }} VsixExtensionPackage - * @typedef {{ nodeOs: string; vscodeTarget: string; sourceDir: string; extensionDir: string; vsixPath: string; vsixManifestPath: string; vsixSignaturePath: string }} VsixExtension + * @typedef {{ nodeOs?: string; vscodeTarget: VSCodeTarget | "web"; sourceDir: string; extensionDir: string; vsixPath: string; vsixManifestPath: string; vsixSignaturePath: string }} VsixExtension * @typedef {{ GOOS: string; GOARCH: string }} GoDistTarget * @typedef {{ os: OS; arch: Arch; cert?: Cert; vsix?: boolean; alpine?: boolean }} Platform */ @@ -1944,6 +1944,36 @@ const vsixExtensionPackages = [ ...(produceTypeScriptNightlyVsix ? [{ name: "vscode-typescript-nightly", sourceDir: nightlyExtensionDir }] : []), ]; +/** + * @param {string} packageName + * @param {string} sourceDir + * @param {VSCodeTarget | "web"} vscodeTarget + * @param {string} [nodeOs] + * @returns {VsixExtension} + */ +function createVsixExtension(packageName, sourceDir, vscodeTarget, nodeOs) { + const extensionDir = path.join(builtVsix, `${packageName}-${vscodeTarget}`); + return { + nodeOs, + vscodeTarget, + sourceDir, + extensionDir, + vsixPath: extensionDir + ".vsix", + vsixManifestPath: extensionDir + ".manifest", + vsixSignaturePath: extensionDir + ".signature.p7s", + }; +} + +function getLocalWebVsixExtensions() { + if (options.forRelease) { + return []; + } + return [ + createVsixExtension("native-preview", extensionDir, "web"), + createVsixExtension("vscode-typescript-nightly", nightlyExtensionDir, "web"), + ]; +} + /** * npm package platforms supported by the native release. * The native-preview package publishes only the entries with vsix: true; @@ -2073,23 +2103,7 @@ const getPlatforms = memoize(() => { vscodeTargets.push(`alpine-${arch === "arm" ? "armhf" : arch}`); } - extensions = vscodeTargets.flatMap(vscodeTarget => - vsixExtensionPackages.map(({ name: packageName, sourceDir }) => { - const extensionDir = path.join(builtVsix, `${packageName}-${vscodeTarget}`); - const vsixPath = extensionDir + ".vsix"; - const vsixManifestPath = extensionDir + ".manifest"; - const vsixSignaturePath = extensionDir + ".signature.p7s"; - return { - nodeOs: os, - vscodeTarget, - sourceDir, - extensionDir, - vsixPath, - vsixManifestPath, - vsixSignaturePath, - }; - }) - ); + extensions = vscodeTargets.flatMap(vscodeTarget => vsixExtensionPackages.map(({ name: packageName, sourceDir }) => createVsixExtension(packageName, sourceDir, vscodeTarget, os))); } return { @@ -2833,7 +2847,15 @@ async function runPackVsixExtensions() { } const platforms = getPlatforms(); - const extensions = platforms.flatMap(({ npmDir, npmPackageName, extensions }) => extensions.map(e => ({ npmDir, npmPackageName, ...e }))); + const localPlatform = !options.forRelease ? platforms[0] : undefined; + const extensions = [ + ...platforms.flatMap(({ npmDir, npmPackageName, extensions }) => extensions.map(e => ({ npmDir, npmPackageName, ...e }))), + ...getLocalWebVsixExtensions().map(e => ({ + npmDir: localPlatform?.npmDir, + npmPackageName: localPlatform?.npmPackageName, + ...e, + })), + ]; if (!extensions.length) { console.log("No VSIX targets configured; skipping extension packaging."); return; @@ -2841,6 +2863,9 @@ async function runPackVsixExtensions() { // We don't use vscode:prepublish, as that would run the build for each package below. await run("npm", ["run", "bundle:release"], { cwd: extensionDir, env: releasePackageEnv }); + if (extensions.some(extension => extension.vscodeTarget === "web")) { + await run("npm", ["run", "bundle:release:web"], { cwd: extensionDir, env: releasePackageEnv }); + } let version = "0.0.0"; if (options.forRelease) { @@ -2866,20 +2891,50 @@ async function runPackVsixExtensions() { console.log("Version:", version); await Promise.all(extensions.map(async ({ npmDir, npmPackageName, nodeOs, vscodeTarget, sourceDir, extensionDir: thisExtensionDir, vsixPath, vsixManifestPath, vsixSignaturePath }) => { - const npmLibDir = usePublishedPlatformPackagesForVsix - ? await getPublishedPlatformPackageLibDir(npmPackageName) - : path.join(npmDir, "lib"); - const extensionLibDir = path.join(thisExtensionDir, "lib"); - await fs.promises.mkdir(extensionLibDir, { recursive: true }); - await cpWithoutNodeModulesOrTsconfig(sourceDir, thisExtensionDir); - await cpWithoutNodeModulesOrTsconfig(npmLibDir, extensionLibDir); - await fs.promises.chmod(path.join(extensionLibDir, nativePreviewExeName(nodeOs)), 0o755); + if (vscodeTarget === "web") { + assert(npmDir); + const npmLibDir = path.join(npmDir, "lib"); + const extensionLibDir = path.join(thisExtensionDir, "lib"); + await cpRecursive(npmLibDir, extensionLibDir, path => path === npmLibDir || path.endsWith(".d.ts")); + if (sourceDir === nightlyExtensionDir) { + const wasmDir = path.join(thisExtensionDir, "dist"); + await fs.promises.mkdir(wasmDir, { recursive: true }); + await fs.promises.copyFile(path.join(extensionDir, "dist", "tsc.wasm"), path.join(wasmDir, "tsc.wasm")); + } + } + else { + assert(npmDir); + assert(npmPackageName); + assert(nodeOs); + const npmLibDir = usePublishedPlatformPackagesForVsix + ? await getPublishedPlatformPackageLibDir(npmPackageName) + : path.join(npmDir, "lib"); + const extensionLibDir = path.join(thisExtensionDir, "lib"); + await fs.promises.mkdir(extensionLibDir, { recursive: true }); + await cpWithoutNodeModulesOrTsconfig(npmLibDir, extensionLibDir); + await fs.promises.chmod(path.join(extensionLibDir, nativePreviewExeName(nodeOs)), 0o755); + } const packageJsonPath = path.join(thisExtensionDir, "package.json"); const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); packageJson.version = version; packageJson.bundledTypeScriptVersion = usePublishedPlatformPackagesForVsix ? getPublishedTypeScriptVersion() : getVersion(); + if (vscodeTarget === "web") { + delete packageJson.main; + packageJson.files = (packageJson.files ?? []).filter(file => file !== "dist/extension.bundle.js"); + if (sourceDir === nightlyExtensionDir) { + packageJson.files.push("dist/tsc.wasm"); + } + else { + packageJson.browser = "./dist/extension.web.bundle.js"; + packageJson.extensionDependencies = [ + ...(packageJson.extensionDependencies ?? []), + "ms-vscode.wasm-wasi-core", + ]; + packageJson.files.push("dist/extension.web.bundle.js", "dist/tsc.wasm"); + } + } fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 4)); await fs.promises.copyFile("NOTICE.txt", path.join(thisExtensionDir, "NOTICE.txt")); diff --git a/package-lock.json b/package-lock.json index e1dc343cf8e72..ac986f83897aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2179,6 +2179,24 @@ "win32" ] }, + "node_modules/@vscode/wasm-wasi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vscode/wasm-wasi/-/wasm-wasi-1.0.2.tgz", + "integrity": "sha512-JLvtxHiLVIg5cPQBBueQWn7M4njfchKvh32Ujr844UCgFTU5myKL6LY5DrDai/oZfbAyx46T2VmJpF34d6vSWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.2", + "yargs": "^17.7.2" + }, + "bin": { + "dir-dump": "bin/dir-dump" + }, + "engines": { + "node": ">=20.9.0", + "vscode": "^1.88.0" + } + }, "node_modules/adm-zip": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.6.1.tgz", @@ -5081,6 +5099,7 @@ "@typescript/bundled-typescript": "npm:typescript@7.0.2", "@vscode/l10n-dev": "0.0.35", "@vscode/vsce": "4.0.0", + "@vscode/wasm-wasi": "^1.0.2", "esbuild": "^0.28.2" }, "engines": { diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index c14d0a09749ba..4ecf3faef755c 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -369,13 +369,16 @@ ], "scripts": { "build": "tsc && npm run bundle", - "bundle": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", + "bundle": "npm run bundle:desktop && npm run bundle:web", + "bundle:desktop": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", + "bundle:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap && node -e \"require('fs').copyFileSync('../typescript-wasip1-wasm/dist/tsc.wasm', 'dist/tsc.wasm')\"", "test": "tsc -p test && esbuild test/index.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/index.test.cjs && node --test dist/test/index.test.cjs", - "watch": "npm run bundle -- --watch", + "watch": "npm run bundle:desktop -- --watch", "generateLocTest": "vscode-l10n-dev generate-pseudo -o ./l10n/ ./l10n/bundle.l10n.json ./package.nls.json", "generateLocBundle": "vscode-l10n-dev export --outDir ./l10n ./src", "//vscode:prepublish": "we do not use this hook; the Herebyfile runs bundle:release once instead", - "bundle:release": "npm run bundle -- --minify" + "bundle:release": "npm run bundle:desktop -- --minify", + "bundle:release:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap --minify && node -e \"require('fs').copyFileSync('../typescript-wasip1-wasm/dist/tsc.wasm', 'dist/tsc.wasm')\"" }, "dependencies": { "@vscode/extension-telemetry": "^1.5.2", @@ -387,6 +390,7 @@ "@typescript/bundled-typescript": "npm:typescript@7.0.2", "@vscode/l10n-dev": "0.0.35", "@vscode/vsce": "4.0.0", + "@vscode/wasm-wasi": "^1.0.2", "esbuild": "^0.28.2" } } diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts new file mode 100644 index 0000000000000..3a72bc0be9773 --- /dev/null +++ b/packages/vscode-typescript/src/extension.web.ts @@ -0,0 +1,186 @@ +import { + Wasm, + type WasmProcess, + type Writable, +} from "@vscode/wasm-wasi/v1"; +import * as vscode from "vscode"; +import { + AbstractMessageReader, + AbstractMessageWriter, + type DataCallback, + LanguageClient, + type LanguageClientOptions, + type Message, + type MessageTransports, +} from "vscode-languageclient/browser"; +import { nightlyExtensionId } from "./extensionIds"; +import { uriToWasmMountPoint } from "./webMountPoint"; + +declare global { + interface DedicatedWorkerGlobalScope { + readonly name: string; + } +} + +class WasmMessageReader extends AbstractMessageReader { + private readonly decoder = new TextDecoder(); + private buffer = new Uint8Array(); + private callback: DataCallback | undefined; + private readonly dataSubscription: vscode.Disposable; + + constructor(process: WasmProcess) { + super(); + this.dataSubscription = process.stdout?.onData(chunk => this.accept(chunk)) ?? new vscode.Disposable(() => {}); + } + + listen(callback: DataCallback): vscode.Disposable { + this.callback = callback; + return new vscode.Disposable(() => this.callback = undefined); + } + + override dispose(): void { + this.dataSubscription.dispose(); + super.dispose(); + } + + private accept(chunk: Uint8Array): void { + const next = new Uint8Array(this.buffer.length + chunk.length); + next.set(this.buffer); + next.set(chunk, this.buffer.length); + this.buffer = next; + + while (true) { + const headerEnd = findHeaderEnd(this.buffer); + if (headerEnd < 0) return; + const header = this.decoder.decode(this.buffer.subarray(0, headerEnd)); + const match = /(?:^|\r\n)Content-Length: (\d+)(?:\r\n|$)/i.exec(header); + if (!match) { + this.fireError(new Error(`Language server response is missing Content-Length: ${header}`)); + return; + } + const contentLength = Number(match[1]); + const bodyStart = headerEnd + 4; + if (this.buffer.length < bodyStart + contentLength) return; + const body = this.decoder.decode(this.buffer.subarray(bodyStart, bodyStart + contentLength)); + this.buffer = this.buffer.slice(bodyStart + contentLength); + try { + this.callback?.(JSON.parse(body)); + } + catch (error) { + this.fireError(error); + } + } + } +} + +class WasmMessageWriter extends AbstractMessageWriter { + private readonly encoder = new TextEncoder(); + private writes = Promise.resolve(); + + constructor(private readonly stdin: Writable) { + super(); + } + + write(message: Message): Promise { + const body = this.encoder.encode(JSON.stringify(message)); + const header = this.encoder.encode(`Content-Length: ${body.length}\r\n\r\n`); + const framed = new Uint8Array(header.length + body.length); + framed.set(header); + framed.set(body, header.length); + this.writes = this.writes.then(() => this.stdin.write(framed)); + return this.writes; + } + + end(): void {} +} + +export async function activate(context: vscode.ExtensionContext): Promise { + const output = vscode.window.createOutputChannel("TypeScript 7", { log: true }); + context.subscriptions.push(output); + + const nightlyExtension = vscode.extensions.getExtension(nightlyExtensionId); + const moduleExtensionUri = nightlyExtension?.extensionUri ?? context.extensionUri; + context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider("bundled", { + async provideTextDocumentContent(uri) { + const prefix = "/libs/"; + if (!uri.path.startsWith(prefix)) { + throw new Error(`Unsupported bundled TypeScript path: ${uri.toString()}`); + } + const fileName = uri.path.slice(prefix.length); + if (!fileName || fileName.includes("/")) { + throw new Error(`Unsupported bundled TypeScript path: ${uri.toString()}`); + } + const contents = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(moduleExtensionUri, "lib", fileName)); + return new TextDecoder().decode(contents); + }, + })); + + let process: WasmProcess | undefined; + const stderrDecoder = new TextDecoder(); + const serverOptions = async (): Promise => { + const wasm = await Wasm.load(); + const module = wasm.compile(vscode.Uri.joinPath(moduleExtensionUri, "dist", "tsc.wasm")); + const mountPoints = (vscode.workspace.workspaceFolders ?? []).map(folder => ({ + kind: "vscodeFileSystem" as const, + uri: folder.uri, + mountPoint: uriToWasmMountPoint(folder.uri), + })); + // `nonBlocking` is pending in https://github.com/microsoft/vscode-wasm/pull/298. + const stdin = { kind: "pipeIn" as const, nonBlocking: true }; + process = await wasm.createProcess("tsc", module, { + args: ["--lsp", "--stdio"], + env: { PWD: "/" }, + mountPoints, + stdio: { + in: stdin, + out: { kind: "pipeOut" }, + err: { kind: "pipeOut" }, + }, + }); + if (!process.stdin || !process.stdout) { + throw new Error("WASM WASI did not create language server stdio pipes"); + } + process.stderr?.onData(chunk => output.append(stderrDecoder.decode(chunk, { stream: true }))); + void process.run().then( + code => { + if (code !== 0) output.error(`TypeScript language server exited with code ${code}`); + }, + error => output.error(`TypeScript language server failed: ${String(error)}`), + ); + return { + reader: new WasmMessageReader(process), + writer: new WasmMessageWriter(process.stdin), + }; + }; + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { language: "javascript" }, + { language: "javascriptreact" }, + { language: "typescript" }, + { language: "typescriptreact" }, + ], + initializationOptions: { + enableTelemetry: false, + runExternalCode: false, + }, + outputChannel: output, + }; + const client = new LanguageClient("js/ts", "TypeScript Language Server", serverOptions, clientOptions); + context.subscriptions.push(client, { + dispose() { + void process?.terminate(); + }, + }); + await client.start(); +} + +export function deactivate(): void {} + +function findHeaderEnd(buffer: Uint8Array): number { + for (let i = 0; i + 3 < buffer.length; i++) { + if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) { + return i; + } + } + return -1; +} diff --git a/packages/vscode-typescript/src/extensionIds.ts b/packages/vscode-typescript/src/extensionIds.ts new file mode 100644 index 0000000000000..2aa3ff365939c --- /dev/null +++ b/packages/vscode-typescript/src/extensionIds.ts @@ -0,0 +1 @@ +export const nightlyExtensionId = "TypeScriptTeam.vscode-typescript-nightly"; diff --git a/packages/vscode-typescript/src/util.ts b/packages/vscode-typescript/src/util.ts index a299423f81948..208d90f3ce5af 100644 --- a/packages/vscode-typescript/src/util.ts +++ b/packages/vscode-typescript/src/util.ts @@ -1,12 +1,12 @@ import * as fs from "fs"; import * as path from "path"; import * as vscode from "vscode"; +import { nightlyExtensionId } from "./extensionIds"; import { resolvePackageExecutable } from "./tsdkPackage"; export const aiConnectionString = "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255"; export const languageClientName = "TypeScript Language Server"; -export const nightlyExtensionId = "TypeScriptTeam.vscode-typescript-nightly"; export const enableContributedNightlyVersion = true; export const jsTsLanguageModes = [ diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts new file mode 100644 index 0000000000000..74e66a4da1905 --- /dev/null +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -0,0 +1,21 @@ +export interface WebFileSystemUri { + readonly scheme: string; + readonly authority: string; + readonly path: string; +} + +export function uriToWasmMountPoint(uri: WebFileSystemUri): string { + let fileName: string; + if (uri.scheme === "file") { + if (uri.authority) { + fileName = `//${uri.authority}${uri.path}`; + } + else { + fileName = /^\/[A-Za-z]:/.test(uri.path) ? uri.path.slice(1) : uri.path; + } + } + else { + fileName = `^/${uri.scheme}/${uri.authority || "ts-nul-authority"}${uri.path}`; + } + return fileName.startsWith("/") ? fileName : `/${fileName}`; +} diff --git a/packages/vscode-typescript/test/index.test.ts b/packages/vscode-typescript/test/index.test.ts index 1688ac53bc213..f932406781828 100644 --- a/packages/vscode-typescript/test/index.test.ts +++ b/packages/vscode-typescript/test/index.test.ts @@ -1,2 +1,3 @@ import "./contentMapperContributions.test"; import "./tsdkPackage.test"; +import "./webMountPoint.test"; diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts new file mode 100644 index 0000000000000..a4634c7ed1e71 --- /dev/null +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { uriToWasmMountPoint } from "../src/webMountPoint"; + +describe("web WASI mount points", () => { + test("maps POSIX file workspaces to their absolute paths", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "", path: "/work/project" }), + "/work/project", + ); + }); + + test("maps Windows file workspaces below the WASI root", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "", path: "/c:/work/project" }), + "/c:/work/project", + ); + }); + + test("maps UNC workspaces to UNC paths", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "server", path: "/share/project" }), + "//server/share/project", + ); + }); + + test("maps virtual workspaces to the path used by DocumentUri.FileName", () => { + assert.strictEqual( + uriToWasmMountPoint({ scheme: "vscode-test-web", authority: "mount", path: "/" }), + "/^/vscode-test-web/mount/", + ); + }); +}); From 0bc213576c0614df68a4341babdb0cfe6ae8aff9 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:59:56 -0700 Subject: [PATCH 012/142] Correct browser API test exclusion count --- packages/typescript/test/browser/suite.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index 64dba493dfd9d..6a958928e2221 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -50,7 +50,7 @@ const fileExclusions = [ { mode: "sync", file: "sync/wasm.test.ts", - tests: 3, + tests: 4, reason: "These Node.js integration tests use node:fs and node:wasi; wasm.test.ts covers browser API loading.", }, { From 5ff6c1f4caea00a106f11282a9ae746b15cd6a4d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:59:56 -0700 Subject: [PATCH 013/142] Translate WASI response file paths on Windows --- packages/typescript/lib/tsc.js | 3 +++ packages/typescript/test/version.test.ts | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 7e094ad8a5a9c..017b859d59cb3 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -98,6 +98,9 @@ function getWasiPaths(wasmPath) { return path.posix.join(getGuestRoot(root), relative); }; const translatePath = arg => { + if (arg.startsWith("@") && path.isAbsolute(arg.slice(1))) { + return `@${toGuestPath(arg.slice(1))}`; + } return path.isAbsolute(arg) ? toGuestPath(arg) : arg; }; diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index 4ec2270ada084..013f68cb9822a 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -36,9 +36,11 @@ test("the CLI falls back to the WASI package", { const packageDirectory = path.join(directory, "node_modules", "typescript"); const libraryDirectory = path.join(packageDirectory, "lib"); await mkdir(libraryDirectory, { recursive: true }); + const responseFile = path.join(directory, "args.rsp"); await Promise.all([ cp(new URL("../lib/tsc.js", import.meta.url), path.join(libraryDirectory, "tsc.js")), cp(new URL("../lib/getExePath.js", import.meta.url), path.join(libraryDirectory, "getExePath.js")), + writeFile(responseFile, "--version"), writeFile( path.join(packageDirectory, "package.json"), JSON.stringify({ @@ -57,7 +59,7 @@ test("the CLI falls back to the WASI package", { process.platform === "win32" ? "junction" : "dir", ); - const output = execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), "--version"], { + const output = execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { cwd: directory, encoding: "utf8", }); From 75b6d54b2873a2e77349723f351367dc4c92c46c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:59:56 -0700 Subject: [PATCH 014/142] Ignore nightlies without a web WASM payload --- .../vscode-typescript/src/extension.web.ts | 15 +++++++++- .../src/webModuleExtension.ts | 7 +++++ packages/vscode-typescript/test/index.test.ts | 1 + .../test/webModuleExtension.test.ts | 29 +++++++++++++++++++ 4 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 packages/vscode-typescript/src/webModuleExtension.ts create mode 100644 packages/vscode-typescript/test/webModuleExtension.test.ts diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 3a72bc0be9773..3e2159794d0d0 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -14,6 +14,7 @@ import { type MessageTransports, } from "vscode-languageclient/browser"; import { nightlyExtensionId } from "./extensionIds"; +import { selectWebModuleExtensionUri } from "./webModuleExtension"; import { uriToWasmMountPoint } from "./webMountPoint"; declare global { @@ -99,7 +100,19 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(output); const nightlyExtension = vscode.extensions.getExtension(nightlyExtensionId); - const moduleExtensionUri = nightlyExtension?.extensionUri ?? context.extensionUri; + const moduleExtensionUri = await selectWebModuleExtensionUri( + context.extensionUri, + nightlyExtension?.extensionUri, + async extensionUri => { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, "dist", "tsc.wasm")); + return true; + } + catch { + return false; + } + }, + ); context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider("bundled", { async provideTextDocumentContent(uri) { const prefix = "/libs/"; diff --git a/packages/vscode-typescript/src/webModuleExtension.ts b/packages/vscode-typescript/src/webModuleExtension.ts new file mode 100644 index 0000000000000..bcf426d089d88 --- /dev/null +++ b/packages/vscode-typescript/src/webModuleExtension.ts @@ -0,0 +1,7 @@ +export async function selectWebModuleExtensionUri( + builtinUri: T, + nightlyUri: T | undefined, + hasWasmPayload: (uri: T) => Promise, +): Promise { + return nightlyUri && await hasWasmPayload(nightlyUri) ? nightlyUri : builtinUri; +} diff --git a/packages/vscode-typescript/test/index.test.ts b/packages/vscode-typescript/test/index.test.ts index f932406781828..efaf23c93e07a 100644 --- a/packages/vscode-typescript/test/index.test.ts +++ b/packages/vscode-typescript/test/index.test.ts @@ -1,3 +1,4 @@ import "./contentMapperContributions.test"; import "./tsdkPackage.test"; +import "./webModuleExtension.test"; import "./webMountPoint.test"; diff --git a/packages/vscode-typescript/test/webModuleExtension.test.ts b/packages/vscode-typescript/test/webModuleExtension.test.ts new file mode 100644 index 0000000000000..d83f8c6d8dbf6 --- /dev/null +++ b/packages/vscode-typescript/test/webModuleExtension.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { selectWebModuleExtensionUri } from "../src/webModuleExtension"; + +describe("web module extension", () => { + test("uses the nightly extension when it contains the WASM payload", async () => { + assert.strictEqual( + await selectWebModuleExtensionUri("builtin", "nightly", async uri => uri === "nightly"), + "nightly", + ); + }); + + test("uses the builtin extension when the nightly does not contain the WASM payload", async () => { + assert.strictEqual( + await selectWebModuleExtensionUri("builtin", "nightly", async () => false), + "builtin", + ); + }); + + test("uses the builtin extension when no nightly is installed", async () => { + assert.strictEqual( + await selectWebModuleExtensionUri("builtin", undefined, async () => true), + "builtin", + ); + }); +}); From 884d83ce7272fc2bb7cf8abfc6708ae91af89e41 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:33:29 -0700 Subject: [PATCH 015/142] Update browser API test accounting --- packages/typescript/test/browser/suite.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index 6a958928e2221..d0a067115dd84 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -25,8 +25,8 @@ const browserDir = fileURLToPath(new URL(".", import.meta.url)); const packageDir = path.resolve(browserDir, "../.."); const modes = ["async", "sync"] as const; const expectedTestCounts = { - async: 386, - sync: 467, + async: 388, + sync: 469, } as const; const fileExclusions = [ { From 1685900b402481cc2486500231d38649dda8f002 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:33:29 -0700 Subject: [PATCH 016/142] Fix VSIX packaging type checks --- Herebyfile.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index e3851c83261ab..3ce11b1d9f4a9 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -2097,7 +2097,7 @@ const getPlatforms = memoize(() => { /** @type {VsixExtension[]} */ let extensions = []; if (produceAnyVsix && vsix) { - /** @type {string[]} */ + /** @type {VSCodeTarget[]} */ const vscodeTargets = [`${os}-${arch === "arm" ? "armhf" : arch}`]; if (alpine) { vscodeTargets.push(`alpine-${arch === "arm" ? "armhf" : arch}`); @@ -2922,7 +2922,7 @@ async function runPackVsixExtensions() { packageJson.bundledTypeScriptVersion = usePublishedPlatformPackagesForVsix ? getPublishedTypeScriptVersion() : getVersion(); if (vscodeTarget === "web") { delete packageJson.main; - packageJson.files = (packageJson.files ?? []).filter(file => file !== "dist/extension.bundle.js"); + packageJson.files = /** @type {string[]} */ (packageJson.files ?? []).filter(file => file !== "dist/extension.bundle.js"); if (sourceDir === nightlyExtensionDir) { packageJson.files.push("dist/tsc.wasm"); } From a29fca2ea176215f4fd87cef5e7335ad224b6184 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:46:20 -0700 Subject: [PATCH 017/142] Preserve browser test skip options --- .../typescript/test/browser/harness.test.ts | 26 ++++++++++++ packages/typescript/test/browser/harness.ts | 41 +++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/typescript/test/browser/harness.test.ts diff --git a/packages/typescript/test/browser/harness.test.ts b/packages/typescript/test/browser/harness.test.ts new file mode 100644 index 0000000000000..7f0138777def5 --- /dev/null +++ b/packages/typescript/test/browser/harness.test.ts @@ -0,0 +1,26 @@ +import assert from "node:assert"; +import { test as nodeTest } from "node:test"; +import { + describe, + runRegisteredTests, + test, +} from "./harness.ts"; + +nodeTest("browser test harness preserves skip options", async () => { + test("skipped test", { skip: "test reason" }, () => assert.fail("skipped test ran")); + describe("skipped suite", { skip: "suite reason" }, () => { + test("nested test", () => assert.fail("skipped suite ran")); + }); + describe("nested suite", () => { + test("passing test", () => {}); + }); + + assert.deepStrictEqual(await runRegisteredTests([]), { + passed: 1, + skipped: [ + { name: "skipped test", reason: "test reason" }, + { name: "skipped suite > nested test", reason: "suite reason" }, + ], + failures: [], + }); +}); diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts index f029c7eed5b14..426f3ba3a5c83 100644 --- a/packages/typescript/test/browser/harness.ts +++ b/packages/typescript/test/browser/harness.ts @@ -1,6 +1,12 @@ interface RegisteredTest { name: string; run: (context: BrowserTestContext) => unknown; + skip?: string; +} + +interface RegisteredSuite { + name: string; + skip?: string; } interface BrowserTestContext { @@ -37,7 +43,7 @@ export interface BrowserTestExclusion { } const tests: RegisteredTest[] = []; -const suites: string[] = []; +const suites: RegisteredSuite[] = []; interface TestOptions { skip?: boolean | string; @@ -47,7 +53,11 @@ export function describe(name: string, options: TestOptions, run: () => void): v export function describe(name: string, run: () => void): void; export function describe(name: string, optionsOrRun: TestOptions | (() => void), run?: () => void): void { const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; - suites.push(name); + const skip = currentSuiteSkip() ?? (typeof optionsOrRun === "function" ? undefined : skipReason(optionsOrRun.skip)); + suites.push({ + name, + ...skip === undefined ? {} : { skip }, + }); try { callback(); } @@ -64,9 +74,11 @@ export function test( run?: (context: BrowserTestContext) => unknown, ): void { const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; + const skip = currentSuiteSkip() ?? (typeof optionsOrRun === "function" ? undefined : skipReason(optionsOrRun.skip)); tests.push({ - name: [...suites, name].join(" > "), + name: [...suites.map(suite => suite.name), name].join(" > "), run: callback, + ...skip === undefined ? {} : { skip }, }); } @@ -75,6 +87,13 @@ export async function runRegisteredTests(exclusions: readonly BrowserTestExclusi const skipped: BrowserTestSkip[] = []; let passed = 0; for (const registered of tests) { + if (registered.skip) { + skipped.push({ + name: registered.name, + reason: registered.skip, + }); + continue; + } const exclusion = exclusions.find(entry => entry.pattern.test(registered.name)); if (exclusion) { skipped.push({ @@ -138,6 +157,22 @@ export async function runRegisteredTests(exclusions: readonly BrowserTestExclusi return { passed, skipped, failures }; } +function currentSuiteSkip(): string | undefined { + for (let i = suites.length - 1; i >= 0; i--) { + if (suites[i].skip) { + return suites[i].skip; + } + } + return undefined; +} + +function skipReason(skip: boolean | string | undefined): string | undefined { + if (!skip) { + return undefined; + } + return typeof skip === "string" ? skip : "Skipped by test option"; +} + function normalizeError(error: unknown): Error { if ( error instanceof Error From e35ce7a06eea464f6c0abd67fc5f3843732b3991 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:46:20 -0700 Subject: [PATCH 018/142] Close web LSP reader when the process exits --- packages/vscode-typescript/src/extension.web.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 3e2159794d0d0..6c76dffa8ab39 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -27,6 +27,7 @@ class WasmMessageReader extends AbstractMessageReader { private readonly decoder = new TextDecoder(); private buffer = new Uint8Array(); private callback: DataCallback | undefined; + private closed = false; private readonly dataSubscription: vscode.Disposable; constructor(process: WasmProcess) { @@ -44,6 +45,13 @@ class WasmMessageReader extends AbstractMessageReader { super.dispose(); } + close(): void { + if (!this.closed) { + this.closed = true; + this.fireClose(); + } + } + private accept(chunk: Uint8Array): void { const next = new Uint8Array(this.buffer.length + chunk.length); next.set(this.buffer); @@ -154,14 +162,15 @@ export async function activate(context: vscode.ExtensionContext): Promise throw new Error("WASM WASI did not create language server stdio pipes"); } process.stderr?.onData(chunk => output.append(stderrDecoder.decode(chunk, { stream: true }))); + const reader = new WasmMessageReader(process); void process.run().then( code => { if (code !== 0) output.error(`TypeScript language server exited with code ${code}`); }, error => output.error(`TypeScript language server failed: ${String(error)}`), - ); + ).finally(() => reader.close()); return { - reader: new WasmMessageReader(process), + reader, writer: new WasmMessageWriter(process.stdin), }; }; From 4836b69f02554795f77a68eb9cf567af308e828a Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:46:20 -0700 Subject: [PATCH 019/142] Reuse the WASM host callback buffer --- tsc/cmd/tsc/wasmapi_wasip1.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index ccd2418eaa1ae..3790d4a5dd577 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -37,7 +37,8 @@ var ( responsePtr uint32 responseLen uint32 - inCall bool + hostCallbackBuffer []byte + inCall bool ) //go:wasmexport create_session @@ -255,7 +256,11 @@ func (hostCallbackConn) Call(_ context.Context, method string, params any) (json return nil, err } methodBytes := []byte(method) - buffer := make([]byte, 16+len(methodBytes)+len(paramsBytes)+hostResultCapacity+hostErrorCapacity) + bufferSize := 16 + len(methodBytes) + len(paramsBytes) + hostResultCapacity + hostErrorCapacity + if cap(hostCallbackBuffer) < bufferSize { + hostCallbackBuffer = make([]byte, bufferSize) + } + buffer := hostCallbackBuffer[:bufferSize] binary.LittleEndian.PutUint32(buffer, uint32(len(methodBytes))) binary.LittleEndian.PutUint32(buffer[4:], uint32(len(paramsBytes))) binary.LittleEndian.PutUint32(buffer[8:], hostResultCapacity) From 087a8f491466d5cc5f225804d01ecee9d7f73ece Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:30:17 -0700 Subject: [PATCH 020/142] Build WASI before API test compilation --- Herebyfile.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 3ce11b1d9f4a9..5ed30a9dbc3cb 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -987,6 +987,7 @@ export const buildAPI = task({ async function runBuildAPITests(generateSources = true) { if (generateSources) await runGenerateSync(); + await runBuildWasi(); await run("npm", ["run", "-w", "@typescript/typescript", "build:test"]); } @@ -1048,20 +1049,22 @@ function patchWasiFile(file) { } } +async function runBuildWasi() { + await run("npm", ["run", "-w", "@typescript/typescript-wasip1-wasm", "build:js"]); + await buildWasiFile("./packages/typescript-wasip1-wasm/dist/tsc.wasm"); +} + 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"); - }, + run: runBuildWasi, }); export const buildAPITests = task({ name: "build:api:test", description: "Builds the @typescript/typescript JS API tests.", - dependencies: [generateEnums, generateAPI, buildWasi], + dependencies: [generateEnums, generateAPI, lib], run: runBuildAPITests, }); From c81a7448c1b3ab64ca15809266c6c756dfaefa61 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:30:17 -0700 Subject: [PATCH 021/142] Reject thenable transport callbacks --- .../src/api/async/transportClient.ts | 11 ++++++++- .../typescript/test/async/transport.test.ts | 23 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index bd3a0bf6efb7e..1a7490accae41 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -136,7 +136,7 @@ export class TransportClient { } register.call(this.transport, name, (_, payload) => { const result = callback(JSON.parse(payload)); - if (result instanceof Promise) { + if (isPromiseLike(result)) { throw new Error("Injected transport callbacks must complete synchronously"); } return JSON.stringify(result) ?? ""; @@ -242,6 +242,7 @@ export class TransportClient { resolve(item.result); } } + return; } @@ -297,3 +298,11 @@ export class TransportClient { }); } } + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" && value !== null || typeof value === "function") + && "then" in value + && typeof value.then === "function" + ); +} diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts index 1d090caccc1f7..50c13cc14f4f5 100644 --- a/packages/typescript/test/async/transport.test.ts +++ b/packages/typescript/test/async/transport.test.ts @@ -362,4 +362,27 @@ describe("async transport", () => { await assert.rejects(text, /Client is closed/); await assert.rejects(binary, /Client is closed/); }); + + test("rejects thenable callback results", () => { + let registeredCallback: ((name: string, payload: string) => string) | undefined; + const client = new TransportClient({ + request() { + throw new Error("Unexpected request"); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + registerCallback(_name, callback) { + registeredCallback = callback; + }, + unregisterCallback() {}, + }, false); + + client.registerCallback("callback", () => ({ then() {} })); + assert.throws( + () => registeredCallback?.("callback", "null"), + /Injected transport callbacks must complete synchronously/, + ); + }); }); From 8f3df20c84b36dd4862bb845528b8273557ea567 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:07:44 -0700 Subject: [PATCH 022/142] Package WASM like other platform variants --- .gitignore | 1 + Herebyfile.mjs | 62 ++++++++++--------- packages/typescript-wasip1-wasm/package.json | 5 +- .../src/wasmURL.source.ts | 2 +- .../typescript-wasip1-wasm/src/wasmURL.ts | 2 +- packages/typescript/lib/getExePath.js | 6 +- packages/vscode-typescript/package.json | 4 +- .../vscode-typescript/src/extension.web.ts | 4 +- 8 files changed, 45 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 198596e5aa56d..77f7d7dc8a66e 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,7 @@ tsc/testdata/baselines/tmp /TEST-results.xml /*v8.log /lib/ +/packages/typescript-wasip1-wasm/lib/ /internal/ /scripts/eslint/built/ /scripts/debug.bat diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 5ed30a9dbc3cb..0d27444d8fb03 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1049,9 +1049,15 @@ function patchWasiFile(file) { } } -async function runBuildWasi() { +/** + * @param {string[]} [extraFlags] + */ +async function runBuildWasi(extraFlags = []) { + const packageDir = "./packages/typescript-wasip1-wasm"; await run("npm", ["run", "-w", "@typescript/typescript-wasip1-wasm", "build:js"]); - await buildWasiFile("./packages/typescript-wasip1-wasm/dist/tsc.wasm"); + await fs.promises.rm(path.join(packageDir, "dist", "tsc.wasm"), { force: true }); + await buildWasiFile(path.join(packageDir, "lib", "tsc.wasm"), extraFlags); + await generateLibs(path.join(packageDir, "lib")); } export const buildWasi = task({ @@ -2409,8 +2415,7 @@ async function runBuildNativePreviewPackages() { 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 runBuildWasi(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( @@ -2487,12 +2492,14 @@ async function testNativePreviewPackage(platforms) { const nodeModules = path.join(testRoot, "node_modules"); const mainPackageDir = path.join(nodeModules, ...mainNativePreviewPackage.npmPackageName.split("/")); const platformPackageDir = path.join(nodeModules, ...hostPlatform.npmPackageName.split("/")); + const wasmPackageDir = path.join(nodeModules, ...wasip1Package.npmPackageName.split("/")); const sourceFile = path.join(testRoot, "index.ts"); await rimraf(testRoot); try { await cpRecursive(mainNativePreviewPackage.npmDir, mainPackageDir); await cpRecursive(hostPlatform.npmDir, platformPackageDir); + await cpRecursive(wasip1Package.npmDir, wasmPackageDir); await fs.promises.writeFile(sourceFile, 'export const value: string = "value";\n'); const binName = publishAsTypescript ? "tsc" : "tsgo"; @@ -2504,6 +2511,8 @@ async function testNativePreviewPackage(platforms) { assert(!listFilesOutput.includes("bundled:///"), "Packaged compiler listed an embedded library path"); const expectedLib = path.resolve(platformPackageDir, "lib", "lib.es5.d.ts"); + const expectedWasmLib = path.resolve(wasmPackageDir, "lib", "lib.es5.d.ts"); + assert(fs.existsSync(expectedWasmLib), `Expected WASM package to contain ${expectedWasmLib}`); const listedFiles = listFilesOutput .split(/\r?\n/) .filter(Boolean) @@ -2850,12 +2859,11 @@ async function runPackVsixExtensions() { } const platforms = getPlatforms(); - const localPlatform = !options.forRelease ? platforms[0] : undefined; const extensions = [ ...platforms.flatMap(({ npmDir, npmPackageName, extensions }) => extensions.map(e => ({ npmDir, npmPackageName, ...e }))), ...getLocalWebVsixExtensions().map(e => ({ - npmDir: localPlatform?.npmDir, - npmPackageName: localPlatform?.npmPackageName, + npmDir: "./packages/typescript-wasip1-wasm", + npmPackageName: wasip1Package.npmPackageName, ...e, })), ]; @@ -2895,27 +2903,24 @@ async function runPackVsixExtensions() { await Promise.all(extensions.map(async ({ npmDir, npmPackageName, nodeOs, vscodeTarget, sourceDir, extensionDir: thisExtensionDir, vsixPath, vsixManifestPath, vsixSignaturePath }) => { await cpWithoutNodeModulesOrTsconfig(sourceDir, thisExtensionDir); - if (vscodeTarget === "web") { - assert(npmDir); - const npmLibDir = path.join(npmDir, "lib"); - const extensionLibDir = path.join(thisExtensionDir, "lib"); - await cpRecursive(npmLibDir, extensionLibDir, path => path === npmLibDir || path.endsWith(".d.ts")); - if (sourceDir === nightlyExtensionDir) { - const wasmDir = path.join(thisExtensionDir, "dist"); - await fs.promises.mkdir(wasmDir, { recursive: true }); - await fs.promises.copyFile(path.join(extensionDir, "dist", "tsc.wasm"), path.join(wasmDir, "tsc.wasm")); - } + assert(npmDir); + if (vscodeTarget !== "web") { + assert(npmPackageName); + assert(nodeOs); } - else { - assert(npmDir); + let npmLibDir; + if (vscodeTarget !== "web" && usePublishedPlatformPackagesForVsix) { assert(npmPackageName); + npmLibDir = await getPublishedPlatformPackageLibDir(npmPackageName); + } + else { + npmLibDir = path.join(npmDir, "lib"); + } + const extensionLibDir = path.join(thisExtensionDir, "lib"); + await fs.promises.mkdir(extensionLibDir, { recursive: true }); + await cpWithoutNodeModulesOrTsconfig(npmLibDir, extensionLibDir); + if (vscodeTarget !== "web") { assert(nodeOs); - const npmLibDir = usePublishedPlatformPackagesForVsix - ? await getPublishedPlatformPackageLibDir(npmPackageName) - : path.join(npmDir, "lib"); - const extensionLibDir = path.join(thisExtensionDir, "lib"); - await fs.promises.mkdir(extensionLibDir, { recursive: true }); - await cpWithoutNodeModulesOrTsconfig(npmLibDir, extensionLibDir); await fs.promises.chmod(path.join(extensionLibDir, nativePreviewExeName(nodeOs)), 0o755); } @@ -2926,16 +2931,13 @@ async function runPackVsixExtensions() { if (vscodeTarget === "web") { delete packageJson.main; packageJson.files = /** @type {string[]} */ (packageJson.files ?? []).filter(file => file !== "dist/extension.bundle.js"); - if (sourceDir === nightlyExtensionDir) { - packageJson.files.push("dist/tsc.wasm"); - } - else { + if (sourceDir !== nightlyExtensionDir) { packageJson.browser = "./dist/extension.web.bundle.js"; packageJson.extensionDependencies = [ ...(packageJson.extensionDependencies ?? []), "ms-vscode.wasm-wasi-core", ]; - packageJson.files.push("dist/extension.web.bundle.js", "dist/tsc.wasm"); + packageJson.files.push("dist/extension.web.bundle.js"); } } fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, undefined, 4)); diff --git a/packages/typescript-wasip1-wasm/package.json b/packages/typescript-wasip1-wasm/package.json index ed13c096a3c26..e3de85682cb65 100644 --- a/packages/typescript-wasip1-wasm/package.json +++ b/packages/typescript-wasip1-wasm/package.json @@ -8,7 +8,8 @@ "description": "TypeScript compiler, language server, and API transport for wasip1/wasm", "type": "module", "files": [ - "dist" + "dist", + "lib" ], "exports": { "./package.json": "./package.json", @@ -16,7 +17,7 @@ "@typescript/source": "./src/index.ts", "default": "./dist/index.js" }, - "./tsc.wasm": "./dist/tsc.wasm" + "./tsc.wasm": "./lib/tsc.wasm" }, "imports": { "#wasmURL": { diff --git a/packages/typescript-wasip1-wasm/src/wasmURL.source.ts b/packages/typescript-wasip1-wasm/src/wasmURL.source.ts index 0141e3edf005d..b75558792b17d 100644 --- a/packages/typescript-wasip1-wasm/src/wasmURL.source.ts +++ b/packages/typescript-wasip1-wasm/src/wasmURL.source.ts @@ -1,2 +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); +export const wasmURL: URL = new URL("../lib/tsc.wasm", import.meta.url); diff --git a/packages/typescript-wasip1-wasm/src/wasmURL.ts b/packages/typescript-wasip1-wasm/src/wasmURL.ts index 3862eebe4773a..47148d0928722 100644 --- a/packages/typescript-wasip1-wasm/src/wasmURL.ts +++ b/packages/typescript-wasip1-wasm/src/wasmURL.ts @@ -1,2 +1,2 @@ /** URL of the TypeScript WASI module distributed with this package. */ -export const wasmURL: URL = new URL("./tsc.wasm", import.meta.url); +export const wasmURL: URL = new URL("../lib/tsc.wasm", import.meta.url); diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index 5db72ddfe41d3..ebda9944730dd 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -71,15 +71,15 @@ export function getWasmPath() { let wasmDir; if (normalizedDirname.endsWith("/packages/typescript/lib")) { - wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "dist"); + wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "lib"); } else if (normalizedDirname.endsWith("/built/npm/typescript/lib") || normalizedDirname.endsWith("/built/npm/native-preview/lib")) { - wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "dist"); + wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "lib"); } 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"); + wasmDir = path.join(path.dirname(packageJson), "lib"); } const wasmPath = path.join(wasmDir, "tsc.wasm"); diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 4ecf3faef755c..43bed0dd12f7f 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -371,14 +371,14 @@ "build": "tsc && npm run bundle", "bundle": "npm run bundle:desktop && npm run bundle:web", "bundle:desktop": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", - "bundle:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap && node -e \"require('fs').copyFileSync('../typescript-wasip1-wasm/dist/tsc.wasm', 'dist/tsc.wasm')\"", + "bundle:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap", "test": "tsc -p test && esbuild test/index.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/index.test.cjs && node --test dist/test/index.test.cjs", "watch": "npm run bundle:desktop -- --watch", "generateLocTest": "vscode-l10n-dev generate-pseudo -o ./l10n/ ./l10n/bundle.l10n.json ./package.nls.json", "generateLocBundle": "vscode-l10n-dev export --outDir ./l10n ./src", "//vscode:prepublish": "we do not use this hook; the Herebyfile runs bundle:release once instead", "bundle:release": "npm run bundle:desktop -- --minify", - "bundle:release:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap --minify && node -e \"require('fs').copyFileSync('../typescript-wasip1-wasm/dist/tsc.wasm', 'dist/tsc.wasm')\"" + "bundle:release:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap --minify" }, "dependencies": { "@vscode/extension-telemetry": "^1.5.2", diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 6c76dffa8ab39..bbf6c876232e4 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -113,7 +113,7 @@ export async function activate(context: vscode.ExtensionContext): Promise nightlyExtension?.extensionUri, async extensionUri => { try { - await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, "dist", "tsc.wasm")); + await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, "lib", "tsc.wasm")); return true; } catch { @@ -140,7 +140,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const stderrDecoder = new TextDecoder(); const serverOptions = async (): Promise => { const wasm = await Wasm.load(); - const module = wasm.compile(vscode.Uri.joinPath(moduleExtensionUri, "dist", "tsc.wasm")); + const module = wasm.compile(vscode.Uri.joinPath(moduleExtensionUri, "lib", "tsc.wasm")); const mountPoints = (vscode.workspace.workspaceFolders ?? []).map(folder => ({ kind: "vscodeFileSystem" as const, uri: folder.uri, From 90564f76080ebb3848eab5f7b3309ae17be8c979 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:07:44 -0700 Subject: [PATCH 023/142] Translate Windows response file paths for WASI --- packages/typescript/lib/tsc.js | 133 +++++++++++++++++++---- packages/typescript/test/version.test.ts | 8 ++ 2 files changed, 119 insertions(+), 22 deletions(-) diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 017b859d59cb3..484d074105eb2 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -2,6 +2,13 @@ import getExePath, { getWasmPath } from "#getExePath"; import { execFileSync } from "node:child_process"; +import { + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; let exe; @@ -49,27 +56,29 @@ async function runWasi(wasmPath) { throw new Error("The WASI fallback requires Node.js 23 or newer."); } - const [{ readFile }, { WASI }] = await Promise.all([ - import("node:fs/promises"), - import("node:wasi"), - ]); + const { WASI } = await 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); + const { args, cwd, preopens, cleanup } = await getWasiPaths(wasmPath); + try { + const wasi = new WASI({ + version: "preview1", + args, + env: { ...process.env, PWD: cwd }, + preopens, + returnOnExit: true, + }); + const module = await WebAssembly.compile(await readFile(wasmPath)); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + process.exitCode = wasi.start(instance); + } + finally { + await cleanup?.(); + } } -function getWasiPaths(wasmPath) { +async function getWasiPaths(wasmPath) { const cwd = process.cwd(); if (process.platform !== "win32") { return { @@ -98,18 +107,98 @@ function getWasiPaths(wasmPath) { return path.posix.join(getGuestRoot(root), relative); }; const translatePath = arg => { - if (arg.startsWith("@") && path.isAbsolute(arg.slice(1))) { - return `@${toGuestPath(arg.slice(1))}`; - } return path.isAbsolute(arg) ? toGuestPath(arg) : arg; }; + const responseFiles = new Map(); + let responseDirectory; + const translateResponseFile = async hostPath => { + const key = hostPath.toLowerCase(); + const existing = responseFiles.get(key); + if (existing) { + return existing; + } + responseDirectory ??= await mkdtemp(path.join(tmpdir(), "typescript-wasi-")); + const translatedPath = path.join(responseDirectory, `${responseFiles.size}.rsp`); + const guestPath = toGuestPath(translatedPath); + responseFiles.set(key, guestPath); + let contents; + try { + contents = await readFile(hostPath, "utf8"); + } + catch { + const fallbackPath = toGuestPath(hostPath); + responseFiles.set(key, fallbackPath); + return fallbackPath; + } + const tokens = parseResponseFile(contents); + if (tokens) { + const replacements = []; + for (const token of tokens) { + replacements.push({ + ...token, + value: await translateArgument(token.value), + }); + } + for (let i = replacements.length - 1; i >= 0; i--) { + const replacement = replacements[i]; + contents = contents.slice(0, replacement.start) + replacement.value + contents.slice(replacement.end); + } + } + await writeFile(translatedPath, contents); + return guestPath; + }; + const translateArgument = async arg => { + if (arg.startsWith("@") && arg.length > 1) { + return `@${await translateResponseFile(path.resolve(cwd, arg.slice(1)))}`; + } + return translatePath(arg); + }; + const args = []; + for (const arg of process.argv.slice(2)) { + args.push(await translateArgument(arg)); + } return { args: [ toGuestPath(wasmPath), - ...process.argv.slice(2).map(translatePath), + ...args, ], cwd: toGuestPath(cwd), preopens, + cleanup: responseDirectory + ? () => rm(responseDirectory, { recursive: true, force: true }) + : undefined, }; } + +function parseResponseFile(contents) { + const tokens = []; + let position = 0; + while (position < contents.length) { + while (position < contents.length && contents.charCodeAt(position) <= 32) { + position++; + } + if (position >= contents.length) { + break; + } + if (contents[position] === '"') { + const start = ++position; + while (position < contents.length && contents[position] !== '"') { + position++; + } + if (position >= contents.length) { + return undefined; + } + tokens.push({ start, end: position, value: contents.slice(start, position) }); + position++; + } + else { + const start = position; + while (position < contents.length && contents.charCodeAt(position) > 32) { + position++; + } + tokens.push({ start, end: position, value: contents.slice(start, position) }); + } + } + return tokens; +} diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index 013f68cb9822a..fc0a80ceb4d70 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -37,10 +37,12 @@ test("the CLI falls back to the WASI package", { const libraryDirectory = path.join(packageDirectory, "lib"); await mkdir(libraryDirectory, { recursive: true }); const responseFile = path.join(directory, "args.rsp"); + const sourceFile = path.join(directory, "input.ts"); await Promise.all([ cp(new URL("../lib/tsc.js", import.meta.url), path.join(libraryDirectory, "tsc.js")), cp(new URL("../lib/getExePath.js", import.meta.url), path.join(libraryDirectory, "getExePath.js")), writeFile(responseFile, "--version"), + writeFile(sourceFile, "export const value: string = 'value';\n"), writeFile( path.join(packageDirectory, "package.json"), JSON.stringify({ @@ -64,6 +66,12 @@ test("the CLI falls back to the WASI package", { encoding: "utf8", }); assert.match(output, /^Version \d+\.\d+\.\d+/); + + await writeFile(responseFile, `--noEmit\n"${sourceFile}"\n`); + execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { + cwd: directory, + stdio: "inherit", + }); } finally { await rm(directory, { recursive: true }); From 629edc0d33069a95cd6f36cbcc25342070429ada Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:07:45 -0700 Subject: [PATCH 024/142] Accept undefined browser API options --- packages/typescript/test/browser/apiWrapper.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/typescript/test/browser/apiWrapper.ts b/packages/typescript/test/browser/apiWrapper.ts index ae6458b8b83cc..c15b819fe0473 100644 --- a/packages/typescript/test/browser/apiWrapper.ts +++ b/packages/typescript/test/browser/apiWrapper.ts @@ -11,16 +11,16 @@ import { } from "../../src/api/proto.ts"; interface BrowserAPIOptions { - cwd?: string; - fs?: FileSystem; - collectTiming?: boolean; - maxResponseBytesPerPage?: number; - transport?: object; + cwd?: string | undefined; + fs?: FileSystem | undefined; + collectTiming?: boolean | undefined; + maxResponseBytesPerPage?: number | undefined; + transport?: object | undefined; } interface BrowserSnapshotChanges { - fileNotifications?: FileNotifications; - openFiles?: readonly DocumentIdentifier[]; + fileNotifications?: FileNotifications | undefined; + openFiles?: readonly DocumentIdentifier[] | undefined; } const availableInstances: WasmReactorInstance[] = []; From 9082842e82f0c06c04ff92225b1e0289ffe6baef Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:07:45 -0700 Subject: [PATCH 025/142] Harden WASI command transports --- packages/typescript-wasip1-wasm/src/index.ts | 3 ++ .../typescript/test/browser/suite.test.ts | 6 +-- packages/typescript/test/sync/wasm.test.ts | 43 +++++++++++++++++++ tsc/cmd/tsc/api.go | 2 +- tsc/cmd/tsc/lsp.go | 2 +- tsc/cmd/tsc/lsp_stdin.go | 3 +- tsc/cmd/tsc/lsp_stdin_wasip1.go | 6 ++- 7 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/typescript-wasip1-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts index 2078cb44f0df7..18bbbcc789cb9 100644 --- a/packages/typescript-wasip1-wasm/src/index.ts +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -166,6 +166,9 @@ export class WasmTransport { close(): void { if (this.closed) return; + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot close the same API transport"); + } this.closed = true; try { this.exports.close_session(); diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index d0a067115dd84..b58837259b89f 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -25,8 +25,8 @@ const browserDir = fileURLToPath(new URL(".", import.meta.url)); const packageDir = path.resolve(browserDir, "../.."); const modes = ["async", "sync"] as const; const expectedTestCounts = { - async: 388, - sync: 469, + async: 389, + sync: 470, } as const; const fileExclusions = [ { @@ -50,7 +50,7 @@ const fileExclusions = [ { mode: "sync", file: "sync/wasm.test.ts", - tests: 4, + tests: 5, reason: "These Node.js integration tests use node:fs and node:wasi; wasm.test.ts covers browser API loading.", }, { diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index f43ffd866431b..9db3159ae2b5d 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -147,6 +147,43 @@ describe("API over WebAssembly", () => { assert.match(stdout, /Resolved client capabilities/); }); + test("waits for API stdin without repeatedly reading", { timeout: 10_000 }, async () => { + const childSource = ` + import { readFileSync } from "node:fs"; + import { WASI } from "node:wasi"; + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "--api"], + env: { PWD: "/" }, + preopens: { "/": process.cwd() }, + returnOnExit: true, + }); + let reads = 0; + const imports = { + ...wasi.wasiImport, + fd_read(...args) { + if (++reads > 10) throw new Error("stdin was read repeatedly while idle"); + return wasi.wasiImport.fd_read(...args); + }, + }; + const module = await WebAssembly.compile(readFileSync(new URL(${JSON.stringify(wasmURL.toString())}))); + const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: imports }); + process.exitCode = wasi.start(instance); + `; + const child = spawn(process.execPath, ["--input-type=module", "-e", childSource], { + cwd: process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + }); + let stderr = ""; + child.stderr.setEncoding("utf8"); + child.stderr.on("data", chunk => stderr += chunk); + + await new Promise(resolve => setTimeout(resolve, 250)); + child.stdin.end(); + const exitCode = child.exitCode ?? await new Promise(resolve => child.once("exit", resolve)); + assert.strictEqual(exitCode, 0, stderr); + }); + test("runs the compiler and checker through the reactor", async () => { const WebAssembly = (globalThis as any).WebAssembly; const module = await WebAssembly.compile( @@ -231,6 +268,12 @@ describe("API over WebAssembly", () => { assert.strictEqual(timing.enabled, true); assert.ok(timing.totals.requestCount > 0); assert.ok(timing.totals.serverTimeMs >= 0); + Reflect.set(transport, "inCallback", true); + assert.throws( + () => transport.close(), + /TypeScript WASM callbacks cannot close the same API transport/, + ); + Reflect.set(transport, "inCallback", false); api.close(); const secondTransport = new WasmTransport({ instance, cwd: "/" }); diff --git a/tsc/cmd/tsc/api.go b/tsc/cmd/tsc/api.go index 4d932d8bae412..b36f62f267844 100644 --- a/tsc/cmd/tsc/api.go +++ b/tsc/cmd/tsc/api.go @@ -64,7 +64,7 @@ func runAPI(args []string) int { if flags.pipePath != "" { options.PipePath = flags.pipePath } else { - options.In = os.Stdin + options.In = stdioStdin options.Out = os.Stdout } diff --git a/tsc/cmd/tsc/lsp.go b/tsc/cmd/tsc/lsp.go index dbc63f231ca34..f119a5ff7633d 100644 --- a/tsc/cmd/tsc/lsp.go +++ b/tsc/cmd/tsc/lsp.go @@ -47,7 +47,7 @@ func runLSP(args []string) int { defer stop() s := lsp.NewServer(&lsp.ServerOptions{ - In: lsp.ToReader(lspStdin), + In: lsp.ToReader(stdioStdin), Out: lsp.ToWriter(os.Stdout), Err: os.Stderr, Cwd: core.Must(getCurrentDirectory()), diff --git a/tsc/cmd/tsc/lsp_stdin.go b/tsc/cmd/tsc/lsp_stdin.go index 53541f288959e..502a032ad5243 100644 --- a/tsc/cmd/tsc/lsp_stdin.go +++ b/tsc/cmd/tsc/lsp_stdin.go @@ -3,8 +3,7 @@ package main import ( - "io" "os" ) -var lspStdin io.Reader = os.Stdin +var stdioStdin = os.Stdin diff --git a/tsc/cmd/tsc/lsp_stdin_wasip1.go b/tsc/cmd/tsc/lsp_stdin_wasip1.go index 7f696fff2adf3..1e32a0083187c 100644 --- a/tsc/cmd/tsc/lsp_stdin_wasip1.go +++ b/tsc/cmd/tsc/lsp_stdin_wasip1.go @@ -12,4 +12,8 @@ func (wasiStdin) Read(buffer []byte) (int, error) { return readWasiFD(syscall.Stdin, buffer) } -var lspStdin wasiStdin +func (wasiStdin) Close() error { + return nil +} + +var stdioStdin wasiStdin From fbe1216ac7e2e29ae1e454109a2a02e7cf6e2945 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:53:18 -0700 Subject: [PATCH 026/142] Update browser API test compatibility Upstream API tests now use the default node:test export and exercise filesystem behavior specific to spawned process transports. Keep the browser suite building while continuing to account explicitly for behavior the injected WASM transport cannot model. --- packages/typescript/test/browser/harness.test.ts | 3 ++- packages/typescript/test/browser/harness.ts | 2 ++ packages/typescript/test/browser/suite.test.ts | 12 ++++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/typescript/test/browser/harness.test.ts b/packages/typescript/test/browser/harness.test.ts index 7f0138777def5..3aae321454faf 100644 --- a/packages/typescript/test/browser/harness.test.ts +++ b/packages/typescript/test/browser/harness.test.ts @@ -1,12 +1,13 @@ import assert from "node:assert"; import { test as nodeTest } from "node:test"; -import { +import defaultTest, { describe, runRegisteredTests, test, } from "./harness.ts"; nodeTest("browser test harness preserves skip options", async () => { + assert.strictEqual(defaultTest, test); test("skipped test", { skip: "test reason" }, () => assert.fail("skipped test ran")); describe("skipped suite", { skip: "suite reason" }, () => { test("nested test", () => assert.fail("skipped suite ran")); diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts index 426f3ba3a5c83..6a1e15ddd51e5 100644 --- a/packages/typescript/test/browser/harness.ts +++ b/packages/typescript/test/browser/harness.ts @@ -82,6 +82,8 @@ export function test( }); } +export default test; + export async function runRegisteredTests(exclusions: readonly BrowserTestExclusion[], timeoutMs = 10_000): Promise { const failures: BrowserTestFailure[] = []; const skipped: BrowserTestSkip[] = []; diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index b58837259b89f..f32b8450a9f69 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -25,8 +25,8 @@ const browserDir = fileURLToPath(new URL(".", import.meta.url)); const packageDir = path.resolve(browserDir, "../.."); const modes = ["async", "sync"] as const; const expectedTestCounts = { - async: 389, - sync: 470, + async: 422, + sync: 505, } as const; const fileExclusions = [ { @@ -112,6 +112,14 @@ describe("API test suite in a browser", () => { pattern: /parseJsonConfigFileContent accepts non-object JSON$|parseConfigFile$|project exposes parsedCommandLine$/, reason: "These assertions depend on insertion-ordered callback filesystem directory listings.", }, + { + pattern: /BuildOrchestrator > (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)$/, + reason: "The browser WASM adapter does not round-trip build output writes and removals through the mirrored host filesystem.", + }, + { + pattern: /readFile callback semantics > (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting|configured case sensitivity is used by the server and client)$/, + reason: "These assertions exercise spawned-process callback configuration that is not used by an injected WASM transport.", + }, ]); } catch (error) { From d27e2f5148eec753f9492e7ff730adedf336d146 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:13:30 -0700 Subject: [PATCH 027/142] Mark WASI package experimental --- packages/typescript-wasip1-wasm/README.md | 42 +++----------------- packages/typescript-wasip1-wasm/package.json | 2 +- 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index fc62d626277ae..61a0bfb23f8dc 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -1,40 +1,8 @@ # `@typescript/typescript-wasip1-wasm` -The TypeScript compiler and language server for `wasip1/wasm`, with helpers for -using the TypeScript API in JavaScript hosts. +> [!WARNING] +> This package is experimental. Its APIs, behavior, and package layout may +> change without notice. -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. - -WASM host callbacks run synchronously while the outer API request is in -progress. A callback must not return a Promise or call the same API transport; -use a process-backed API client when callbacks need asynchronous work or nested -API requests. +This package contains the TypeScript compiler, language server, and API +transport for `wasip1/wasm`. diff --git a/packages/typescript-wasip1-wasm/package.json b/packages/typescript-wasip1-wasm/package.json index e3de85682cb65..55a32580f35e8 100644 --- a/packages/typescript-wasip1-wasm/package.json +++ b/packages/typescript-wasip1-wasm/package.json @@ -5,7 +5,7 @@ "license": "Apache-2.0", "author": "Microsoft Corp.", "homepage": "https://www.typescriptlang.org/", - "description": "TypeScript compiler, language server, and API transport for wasip1/wasm", + "description": "Experimental TypeScript support for wasip1/wasm", "type": "module", "files": [ "dist", From 507412a4bafa6b4efbeafa8e09d1b832e105a884 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:28:13 -0700 Subject: [PATCH 028/142] Clear WASM callbacks when closing transport --- packages/typescript-wasip1-wasm/src/index.ts | 7 +++++++ packages/typescript/test/sync/wasm.test.ts | 5 +++++ 2 files changed, 12 insertions(+) diff --git a/packages/typescript-wasip1-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts index 18bbbcc789cb9..f0c61841ab13b 100644 --- a/packages/typescript-wasip1-wasm/src/index.ts +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -54,6 +54,7 @@ export class WasmTransport { private requestPointer = 0; private closed = false; private inCallback = false; + private readonly callbackNames = new Set(); constructor(options: WasmTransportOptions) { this.instance = options.instance; @@ -126,9 +127,11 @@ export class WasmTransport { this.inCallback = false; } }); + this.callbackNames.add(name); } unregisterCallback(name: string): void { + if (!this.callbackNames.delete(name)) return; unregisterWasmCallback(this.instance, name); } @@ -174,6 +177,10 @@ export class WasmTransport { this.exports.close_session(); } finally { + for (const name of this.callbackNames) { + unregisterWasmCallback(this.instance, name); + } + this.callbackNames.clear(); setWasmFileSystem(this.instance, undefined); } } diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 9db3159ae2b5d..fd337c15a4da5 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -274,11 +274,16 @@ describe("API over WebAssembly", () => { /TypeScript WASM callbacks cannot close the same API transport/, ); Reflect.set(transport, "inCallback", false); + transport.registerCallback("retained", () => ""); api.close(); + assert.deepStrictEqual(Reflect.get(transport, "callbackNames"), new Set()); const secondTransport = new WasmTransport({ instance, cwd: "/" }); const second = new SyncAPI({ transport: secondTransport }); assert.strictEqual(secondTransport.requestSync("echo", "recreated"), "recreated"); + secondTransport.registerCallback("retained", () => ""); + transport.unregisterCallback("retained"); + assert.deepStrictEqual(Reflect.get(secondTransport, "callbackNames"), new Set(["retained"])); second.close(); const asyncTransport = new WasmTransport({ instance, cwd: "/" }); From a1617eb6c9d99f53ec79b1af306ed83ab542d45c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:25:25 -0700 Subject: [PATCH 029/142] Make browser API test accounting resilient --- packages/typescript/test/browser/harness.test.ts | 1 + packages/typescript/test/browser/harness.ts | 3 ++- packages/typescript/test/browser/suite.test.ts | 14 +++++--------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/typescript/test/browser/harness.test.ts b/packages/typescript/test/browser/harness.test.ts index 3aae321454faf..90946336a931c 100644 --- a/packages/typescript/test/browser/harness.test.ts +++ b/packages/typescript/test/browser/harness.test.ts @@ -17,6 +17,7 @@ nodeTest("browser test harness preserves skip options", async () => { }); assert.deepStrictEqual(await runRegisteredTests([]), { + total: 3, passed: 1, skipped: [ { name: "skipped test", reason: "test reason" }, diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts index 6a1e15ddd51e5..f34e0be70e3bc 100644 --- a/packages/typescript/test/browser/harness.ts +++ b/packages/typescript/test/browser/harness.ts @@ -32,6 +32,7 @@ export interface BrowserTestSkip { } export interface BrowserTestResults { + total: number; passed: number; skipped: BrowserTestSkip[]; failures: BrowserTestFailure[]; @@ -156,7 +157,7 @@ export async function runRegisteredTests(exclusions: readonly BrowserTestExclusi }); } } - return { passed, skipped, failures }; + return { total: tests.length, passed, skipped, failures }; } function currentSuiteSkip(): string | undefined { diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index f32b8450a9f69..9e794a8d09098 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -24,10 +24,6 @@ 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: 422, - sync: 505, -} as const; const fileExclusions = [ { mode: "async", @@ -209,6 +205,11 @@ describe("API test suite in a browser", () => { const error = await page.evaluate(() => Reflect.get(globalThis, "browserTestError")); assert.strictEqual(error, undefined, error?.stack ?? error?.message); const results = await page.evaluate(() => Reflect.get(globalThis, "browserTestResults")) as BrowserTestResults; + assert.strictEqual( + results.passed + results.skipped.length + results.failures.length, + results.total, + `Browser ${mode} API test registration accounting changed`, + ); assert.strictEqual( results.failures.length, 0, @@ -220,11 +221,6 @@ describe("API test suite in a browser", () => { 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}`); From 87a56eb3d48af00d198ce419c1480f3780fa33ba Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:50:28 -0700 Subject: [PATCH 030/142] Expose standalone WASI transport entry --- packages/typescript-wasip1-wasm/package.json | 4 + packages/typescript-wasip1-wasm/src/index.ts | 253 +----------------- .../typescript-wasip1-wasm/src/transport.ts | 250 +++++++++++++++++ packages/typescript-wasip1-wasm/src/wasi.ts | 2 +- packages/typescript/test/browser/wasm.test.ts | 17 ++ 5 files changed, 274 insertions(+), 252 deletions(-) create mode 100644 packages/typescript-wasip1-wasm/src/transport.ts diff --git a/packages/typescript-wasip1-wasm/package.json b/packages/typescript-wasip1-wasm/package.json index 55a32580f35e8..fa1f51ccbd417 100644 --- a/packages/typescript-wasip1-wasm/package.json +++ b/packages/typescript-wasip1-wasm/package.json @@ -17,6 +17,10 @@ "@typescript/source": "./src/index.ts", "default": "./dist/index.js" }, + "./transport": { + "@typescript/source": "./src/transport.ts", + "default": "./dist/transport.js" + }, "./tsc.wasm": "./lib/tsc.wasm" }, "imports": { diff --git a/packages/typescript-wasip1-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts index f0c61841ab13b..bf2dbee945e96 100644 --- a/packages/typescript-wasip1-wasm/src/index.ts +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -1,251 +1,2 @@ -export { wasmURL } from "#wasmURL"; -export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; -import { - registerWasmCallback, - setWasmFileSystem, - unregisterWasmCallback, - type WasmFileSystem, -} from "./wasi.ts"; - -export interface WasmReactorExports { - memory: { readonly buffer: ArrayBufferLike; }; - create_session(optionsPointer: number, optionsLength: number): number; - close_session(): void; - get_request_buffer(size: number): number; - handle_request(methodLength: number, payloadLength: number): number; - set_file(pathLength: number, contentLength: number): number; - read_file(pathLength: number): number; - remove_file(pathLength: number): number; - response_ptr(): number; - response_len(): number; -} - -export interface WasmReactorInstance { - readonly exports: object; -} - -export interface WasmTransportOptions { - /** - * An instantiated reactor whose WASI host has already initialized it. - * For example, call `wasi.initialize(instance)` before constructing the transport. - */ - instance: WasmReactorInstance; - cwd?: string | undefined; - useCaseSensitiveFileNames?: boolean | undefined; - collectTiming?: boolean | undefined; - fs?: WasmFileSystem | undefined; -} - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); - -/** - * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. - * - * Host callbacks must complete synchronously and cannot call this transport while - * an outer request is in progress. - */ -export class WasmTransport { - lastBytesSent = 0; - lastBytesReceived = 0; - - private readonly instance: WasmReactorInstance; - private readonly exports: WasmReactorExports; - private requestPointer = 0; - private closed = false; - private inCallback = false; - private readonly callbackNames = new Set(); - - constructor(options: WasmTransportOptions) { - this.instance = options.instance; - this.exports = getReactorExports(options.instance); - const sessionOptions = encoder.encode(JSON.stringify({ - cwd: options.cwd ?? "/", - useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, - collectTiming: options.collectTiming, - })); - this.writeRequest(sessionOptions); - if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { - throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); - } - try { - 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, - }; - } - - registerCallback(name: string, callback: (name: string, payload: string) => string): void { - this.ensureOpen(); - registerWasmCallback(this.instance, name, (callbackName, payload) => { - this.inCallback = true; - try { - return callback(callbackName, payload); - } - finally { - this.inCallback = false; - } - }); - this.callbackNames.add(name); - } - - unregisterCallback(name: string): void { - if (!this.callbackNames.delete(name)) return; - unregisterWasmCallback(this.instance, name); - } - - setFile(path: string, content: string): void { - this.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; - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot close the same API transport"); - } - this.closed = true; - try { - this.exports.close_session(); - } - finally { - for (const name of this.callbackNames) { - unregisterWasmCallback(this.instance, name); - } - this.callbackNames.clear(); - setWasmFileSystem(this.instance, undefined); - } - } - - private call(method: string, payload: Uint8Array): Uint8Array { - this.ensureOpen(); - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot call the same API transport"); - } - 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; -} +export * from "./transport.ts"; +export { wasmURL } from "#wasmURL"; diff --git a/packages/typescript-wasip1-wasm/src/transport.ts b/packages/typescript-wasip1-wasm/src/transport.ts new file mode 100644 index 0000000000000..bfad2a9b5a7db --- /dev/null +++ b/packages/typescript-wasip1-wasm/src/transport.ts @@ -0,0 +1,250 @@ +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import { + registerWasmCallback, + setWasmFileSystem, + unregisterWasmCallback, + type WasmFileSystem, +} from "./wasi.ts"; + +export interface WasmReactorExports { + memory: { readonly buffer: ArrayBufferLike; }; + create_session(optionsPointer: number, optionsLength: number): number; + close_session(): void; + get_request_buffer(size: number): number; + handle_request(methodLength: number, payloadLength: number): number; + set_file(pathLength: number, contentLength: number): number; + read_file(pathLength: number): number; + remove_file(pathLength: number): number; + response_ptr(): number; + response_len(): number; +} + +export interface WasmReactorInstance { + readonly exports: object; +} + +export interface WasmTransportOptions { + /** + * An instantiated reactor whose WASI host has already initialized it. + * For example, call `wasi.initialize(instance)` before constructing the transport. + */ + instance: WasmReactorInstance; + cwd?: string | undefined; + useCaseSensitiveFileNames?: boolean | undefined; + collectTiming?: boolean | undefined; + fs?: WasmFileSystem | undefined; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +/** + * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. + * + * Host callbacks must complete synchronously and cannot call this transport while + * an outer request is in progress. + */ +export class WasmTransport { + lastBytesSent = 0; + lastBytesReceived = 0; + + private readonly instance: WasmReactorInstance; + private readonly exports: WasmReactorExports; + private requestPointer = 0; + private closed = false; + private inCallback = false; + private readonly callbackNames = new Set(); + + constructor(options: WasmTransportOptions) { + this.instance = options.instance; + this.exports = getReactorExports(options.instance); + const sessionOptions = encoder.encode(JSON.stringify({ + cwd: options.cwd ?? "/", + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, + collectTiming: options.collectTiming, + })); + this.writeRequest(sessionOptions); + if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { + throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); + } + try { + 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, + }; + } + + registerCallback(name: string, callback: (name: string, payload: string) => string): void { + this.ensureOpen(); + registerWasmCallback(this.instance, name, (callbackName, payload) => { + this.inCallback = true; + try { + return callback(callbackName, payload); + } + finally { + this.inCallback = false; + } + }); + this.callbackNames.add(name); + } + + unregisterCallback(name: string): void { + if (!this.callbackNames.delete(name)) return; + unregisterWasmCallback(this.instance, name); + } + + setFile(path: string, content: string): void { + this.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; + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot close the same API transport"); + } + this.closed = true; + try { + this.exports.close_session(); + } + finally { + for (const name of this.callbackNames) { + unregisterWasmCallback(this.instance, name); + } + this.callbackNames.clear(); + setWasmFileSystem(this.instance, undefined); + } + } + + private call(method: string, payload: Uint8Array): Uint8Array { + this.ensureOpen(); + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot call the same API transport"); + } + 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 index c9c587f9e8e0c..68b7f747ed688 100644 --- a/packages/typescript-wasip1-wasm/src/wasi.ts +++ b/packages/typescript-wasip1-wasm/src/wasi.ts @@ -1,4 +1,4 @@ -import type { WasmReactorInstance } from "./index.ts"; +import type { WasmReactorInstance } from "./transport.ts"; const errnoSuccess = 0; const errnoBadFileDescriptor = 8; diff --git a/packages/typescript/test/browser/wasm.test.ts b/packages/typescript/test/browser/wasm.test.ts index 89469f4abeff5..c07de93123fac 100644 --- a/packages/typescript/test/browser/wasm.test.ts +++ b/packages/typescript/test/browser/wasm.test.ts @@ -20,6 +20,8 @@ describe("API over WebAssembly in a browser", () => { const server = createServer(); before(async () => { + const transport = await readFile(new URL("../../../typescript-wasip1-wasm/dist/transport.js", import.meta.url)); + const wasi = await readFile(new URL("../../../typescript-wasip1-wasm/dist/wasi.js", import.meta.url)); const result = await build({ stdin: { contents: ` @@ -90,6 +92,14 @@ describe("API over WebAssembly in a browser", () => { response.setHeader("Content-Type", "application/wasm"); response.end(wasm); break; + case "/transport.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(transport); + break; + case "/wasi.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(wasi); + break; default: response.statusCode = 404; response.end(); @@ -119,4 +129,11 @@ describe("API over WebAssembly in a browser", () => { const result = await page.evaluate(() => Reflect.get(globalThis, "result")); assert.deepStrictEqual(result, ["42", "42"]); }); + + test("loads the transport entry without package resolution", async () => { + const page = await browser.newPage(); + await page.goto(origin); + const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/transport.js`)), origin); + assert.deepStrictEqual(exports, ["WasmTransport", "instantiateWasm", "instantiateWasmSync"]); + }); }); From 5c13607b0f7ffecc4c605ae0fc767d86b1ed78e1 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:01:13 -0700 Subject: [PATCH 031/142] Format WASI package entry --- packages/typescript-wasip1-wasm/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typescript-wasip1-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts index bf2dbee945e96..3da28c4aab792 100644 --- a/packages/typescript-wasip1-wasm/src/index.ts +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -1,2 +1,2 @@ -export * from "./transport.ts"; -export { wasmURL } from "#wasmURL"; +export { wasmURL } from "#wasmURL"; +export * from "./transport.ts"; From 5bee7d7858105b158b6f70a77da3a88f4287b78f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:37:36 -0700 Subject: [PATCH 032/142] Support call-through browser method mocks --- .../typescript/test/browser/harness.test.ts | 16 ++++++++++-- packages/typescript/test/browser/harness.ts | 26 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/typescript/test/browser/harness.test.ts b/packages/typescript/test/browser/harness.test.ts index 90946336a931c..5a1194ab8a4cb 100644 --- a/packages/typescript/test/browser/harness.test.ts +++ b/packages/typescript/test/browser/harness.test.ts @@ -15,10 +15,22 @@ nodeTest("browser test harness preserves skip options", async () => { describe("nested suite", () => { test("passing test", () => {}); }); + test("mock method", context => { + const target = { + value: 0, + increment(amount: number) { + this.value += amount; + return this.value; + }, + }; + const method = context.mock.method(target, "increment"); + assert.strictEqual(target.increment(2), 2); + assert.strictEqual(method.mock.callCount(), 1); + }); assert.deepStrictEqual(await runRegisteredTests([]), { - total: 3, - passed: 1, + total: 4, + passed: 2, skipped: [ { name: "skipped test", reason: "test reason" }, { name: "skipped suite > nested test", reason: "suite reason" }, diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts index f34e0be70e3bc..7c8ca6c5bce9c 100644 --- a/packages/typescript/test/browser/harness.ts +++ b/packages/typescript/test/browser/harness.ts @@ -15,8 +15,8 @@ interface BrowserTestContext { method( target: T, name: K, - implementation: T[K], - ): void; + implementation?: T[K] | undefined, + ): T[K] & { mock: { callCount(): number; }; }; }; } @@ -113,11 +113,28 @@ export async function runRegisteredTests(exclusions: readonly BrowserTestExclusi restorations.push(callback); }, mock: { - method(target, name, implementation) { + method: ( + target: T, + name: K, + implementation?: T[K] | undefined, + ): T[K] & { mock: { callCount(): number; }; } => { const descriptor = Object.getOwnPropertyDescriptor(target, name); + const original = target[name]; + const callback = implementation ?? original; + if (typeof callback !== "function") { + throw new TypeError(`Cannot mock non-function property ${String(name)}`); + } + let callCount = 0; + const wrapped = function (this: unknown, ...args: unknown[]) { + callCount++; + return callback.apply(this, args); + }; + wrapped.mock = { + callCount: () => callCount, + }; Object.defineProperty(target, name, { configurable: true, - value: implementation, + value: wrapped, writable: true, }); restorations.push(() => { @@ -128,6 +145,7 @@ export async function runRegisteredTests(exclusions: readonly BrowserTestExclusi delete target[name]; } }); + return wrapped as T[K] & { mock: { callCount(): number; }; }; }, }, }; From c09d2d0db13fe1afe0c2365d20bb8280352865b5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:37:17 -0700 Subject: [PATCH 033/142] Harden WASI host callbacks --- .../typescript-wasip1-wasm/src/transport.ts | 53 ++++++++++++------- packages/typescript-wasip1-wasm/src/wasi.ts | 30 ++++++----- packages/typescript/test/sync/wasm.test.ts | 15 +++++- 3 files changed, 67 insertions(+), 31 deletions(-) diff --git a/packages/typescript-wasip1-wasm/src/transport.ts b/packages/typescript-wasip1-wasm/src/transport.ts index bfad2a9b5a7db..ad591fa7f1f2d 100644 --- a/packages/typescript-wasip1-wasm/src/transport.ts +++ b/packages/typescript-wasip1-wasm/src/transport.ts @@ -68,7 +68,7 @@ export class WasmTransport { throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); } try { - setWasmFileSystem(options.instance, options.fs); + this.configureFileSystem(options.fs); } catch (error) { try { @@ -86,7 +86,7 @@ export class WasmTransport { setFileSystem(fs: WasmFileSystem | undefined): void { this.ensureOpen(); - setWasmFileSystem(this.instance, fs); + this.configureFileSystem(fs); } requestSync(method: string, payload: string): string { @@ -117,15 +117,7 @@ export class WasmTransport { registerCallback(name: string, callback: (name: string, payload: string) => string): void { this.ensureOpen(); - registerWasmCallback(this.instance, name, (callbackName, payload) => { - this.inCallback = true; - try { - return callback(callbackName, payload); - } - finally { - this.inCallback = false; - } - }); + registerWasmCallback(this.instance, name, (callbackName, payload) => this.invokeCallback(() => callback(callbackName, payload))); this.callbackNames.add(name); } @@ -135,7 +127,7 @@ export class WasmTransport { } setFile(path: string, content: string): void { - this.ensureOpen(); + this.ensureCanEnterWasm(); const pathBytes = encoder.encode(path); const contentBytes = encoder.encode(content); this.writeRequest(pathBytes, contentBytes); @@ -146,7 +138,7 @@ export class WasmTransport { /** Read a file from the reactor's in-memory filesystem. */ readFile(path: string): string | undefined { - this.ensureOpen(); + this.ensureCanEnterWasm(); const pathBytes = encoder.encode(path); this.writeRequest(pathBytes); const status = this.exports.read_file(pathBytes.length); @@ -158,7 +150,7 @@ export class WasmTransport { } removeFile(path: string): void { - this.ensureOpen(); + this.ensureCanEnterWasm(); const pathBytes = encoder.encode(path); this.writeRequest(pathBytes); if (this.exports.remove_file(pathBytes.length) !== 0) { @@ -185,10 +177,7 @@ export class WasmTransport { } private call(method: string, payload: Uint8Array): Uint8Array { - this.ensureOpen(); - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot call the same API transport"); - } + this.ensureCanEnterWasm(); const methodBytes = encoder.encode(method); this.writeRequest(methodBytes, payload); this.lastBytesSent = payload.length; @@ -224,6 +213,34 @@ export class WasmTransport { throw new Error("The TypeScript WASM transport is closed"); } } + + private ensureCanEnterWasm(): void { + this.ensureOpen(); + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot call the same API transport"); + } + } + + private configureFileSystem(fs: WasmFileSystem | undefined): void { + const writeFile = fs?.writeFile; + setWasmFileSystem( + this.instance, + typeof writeFile === "function" + ? { writeFile: (path, data) => this.invokeCallback(() => writeFile(path, data)) } + : fs, + ); + } + + private invokeCallback(callback: () => T): T { + const previous = this.inCallback; + this.inCallback = true; + try { + return callback(); + } + finally { + this.inCallback = previous; + } + } } function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { diff --git a/packages/typescript-wasip1-wasm/src/wasi.ts b/packages/typescript-wasip1-wasm/src/wasi.ts index 68b7f747ed688..8036ed3bb28ca 100644 --- a/packages/typescript-wasip1-wasm/src/wasi.ts +++ b/packages/typescript-wasip1-wasm/src/wasi.ts @@ -210,19 +210,23 @@ function createWasiHost(options: InstantiateWasmOptions): { if (result.length > resultCapacity) { throw new Error(`Callback result exceeds ${resultCapacity} bytes`); } - new Uint8Array(memory.buffer, resultPointer, result.length).set(result); - view.setUint32(bufferPointer + 8, result.length, true); - view.setUint32(bufferPointer + 12, 0, true); - view.setUint32(writtenPointer, bufferLength, true); + const resultMemory = getMemory(); + const resultView = new DataView(resultMemory.buffer); + new Uint8Array(resultMemory.buffer, resultPointer, result.length).set(result); + resultView.setUint32(bufferPointer + 8, result.length, true); + resultView.setUint32(bufferPointer + 12, 0, true); + resultView.setUint32(writtenPointer, bufferLength, true); return errnoSuccess; } catch (error) { const errorBytes = encoder.encode(error instanceof Error ? error.message : String(error)); const errorLength = Math.min(errorBytes.length, errorCapacity); - new Uint8Array(memory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); - view.setUint32(bufferPointer + 8, 0, true); - view.setUint32(bufferPointer + 12, errorLength, true); - view.setUint32(writtenPointer, 0, true); + const errorMemory = getMemory(); + const errorView = new DataView(errorMemory.buffer); + new Uint8Array(errorMemory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); + errorView.setUint32(bufferPointer + 8, 0, true); + errorView.setUint32(bufferPointer + 12, errorLength, true); + errorView.setUint32(writtenPointer, 0, true); return errnoIo; } } @@ -258,15 +262,17 @@ function createWasiHost(options: InstantiateWasmOptions): { const data = decoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); try { fileSystem.writeFile(path, data); - view.setUint32(writtenPointer, bufferLength, true); + new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); return errnoSuccess; } catch (error) { const errorBytes = encoder.encode(error instanceof Error ? error.message : String(error)); const errorLength = Math.min(errorBytes.length, errorCapacity); - new Uint8Array(memory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); - view.setUint32(bufferPointer + 8, errorLength, true); - view.setUint32(writtenPointer, 0, true); + const errorMemory = getMemory(); + const errorView = new DataView(errorMemory.buffer); + new Uint8Array(errorMemory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); + errorView.setUint32(bufferPointer + 8, errorLength, true); + errorView.setUint32(writtenPointer, 0, true); return errnoIo; } } diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index fd337c15a4da5..a69b76f73126b 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -212,12 +212,24 @@ describe("API over WebAssembly", () => { ); const emittedFiles = new Map(); - const transport = new WasmTransport({ + const memory = Reflect.get(instance.exports, "memory"); + assert.ok(memory instanceof WebAssembly.Memory); + let transport!: WasmTransport; + transport = new WasmTransport({ instance, cwd: "/", collectTiming: true, fs: { writeFile(path, data) { + memory.grow(0); + assert.throws( + () => transport.close(), + /TypeScript WASM callbacks cannot close the same API transport/, + ); + assert.throws( + () => transport.readFile("/src/index.ts"), + /TypeScript WASM callbacks cannot call the same API transport/, + ); emittedFiles.set(path, data); }, }, @@ -263,6 +275,7 @@ describe("API over WebAssembly", () => { fileSystem: undefined, }); assert.strictEqual(emittedFiles.get("/src/index.js"), "export const value = 42;\n"); + assert.strictEqual(transport.requestSync("echo", "after callback"), "after callback"); const timing = api.getTimingInfo(); assert.strictEqual(timing.enabled, true); From cfe976ec3a554c521979b5caa54a5f0a8dd77061 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 2 Oct 2026 10:44:07 -0700 Subject: [PATCH 034/142] Implement WASI stdio descriptor lifecycle --- packages/typescript-wasip1-wasm/src/wasi.ts | 35 ++++++++++++--- packages/typescript/test/sync/wasm.test.ts | 48 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/typescript-wasip1-wasm/src/wasi.ts b/packages/typescript-wasip1-wasm/src/wasi.ts index 8036ed3bb28ca..e0dad1eff7a1d 100644 --- a/packages/typescript-wasip1-wasm/src/wasi.ts +++ b/packages/typescript-wasip1-wasm/src/wasi.ts @@ -83,6 +83,7 @@ function createWasiHost(options: InstantiateWasmOptions): { const decoders = new Map(); const encoder = new TextEncoder(); const callbacks = new Map string>(); + const closedDescriptors = new Set(); let fileSystem: WasmFileSystem | undefined; function getMemory(): WebAssembly.Memory { @@ -125,7 +126,7 @@ function createWasiHost(options: InstantiateWasmOptions): { function fdFdstatGet(fd: number, statPointer: number): number { statPointer >>>= 0; - if (fd < 0 || fd > 2) return errnoBadFileDescriptor; + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; const memory = getMemory(); new Uint8Array(memory.buffer, statPointer, 24).fill(0); const view = new DataView(memory.buffer); @@ -136,12 +137,12 @@ function createWasiHost(options: InstantiateWasmOptions): { } function fdFdstatSetFlags(fd: number, _flags: number): number { - return fd >= 0 && fd <= 2 ? errnoSuccess : errnoBadFileDescriptor; + return isOpenStdioDescriptor(fd) ? errnoSuccess : errnoBadFileDescriptor; } function fdRead(fd: number, _iovsPointer: number, _iovsLength: number, readPointer: number): number { readPointer >>>= 0; - if (fd !== 0) return errnoBadFileDescriptor; + if (fd !== 0 || closedDescriptors.has(fd)) return errnoBadFileDescriptor; getView().setUint32(readPointer, 0, true); return errnoSuccess; } @@ -155,7 +156,7 @@ function createWasiHost(options: InstantiateWasmOptions): { if (fd === hostCallbackFD) { return hostCallback(iovsPointer, iovsLength, writtenPointer); } - if (fd !== 1 && fd !== 2) return errnoBadFileDescriptor; + if ((fd !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; const memory = getMemory(); const view = new DataView(memory.buffer); const chunks: Uint8Array[] = []; @@ -174,12 +175,34 @@ function createWasiHost(options: InstantiateWasmOptions): { offset += chunk.length; } view.setUint32(writtenPointer, length, true); + if (length === 0) return errnoSuccess; const decoder = decoders.get(fd) ?? new TextDecoder(); decoders.set(fd, decoder); - (fd === 1 ? stdout : stderr)(decoder.decode(bytes, { stream: true })); + const text = decoder.decode(bytes, { stream: true }); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + return errnoSuccess; + } + + function fdClose(fd: number): number { + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; + closedDescriptors.add(fd); + const decoder = decoders.get(fd); + if (decoder) { + decoders.delete(fd); + const text = decoder.decode(); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + } return errnoSuccess; } + function isOpenStdioDescriptor(fd: number): boolean { + return fd >= 0 && fd <= 2 && !closedDescriptors.has(fd); + } + function hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { if (iovsLength !== 1) return errnoInvalidArgument; const memory = getMemory(); @@ -331,7 +354,7 @@ function createWasiHost(options: InstantiateWasmOptions): { clock_time_get: clockTimeGet, environ_get: () => errnoSuccess, environ_sizes_get: argsSizesGet, - fd_close: (fd: number) => fd >= 0 && fd <= 2 ? errnoSuccess : errnoBadFileDescriptor, + fd_close: fdClose, fd_fdstat_get: fdFdstatGet, fd_fdstat_set_flags: fdFdstatSetFlags, fd_filestat_get: unsupported, diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index a69b76f73126b..4bc121c8c770a 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -1,5 +1,6 @@ import { instantiateWasm, + instantiateWasmSync, WasmTransport, wasmURL, } from "@typescript/typescript-wasip1-wasm"; @@ -9,6 +10,7 @@ import { } from "@typescript/typescript/unstable/ast"; import { API as AsyncAPI } from "@typescript/typescript/unstable/async"; import { API as SyncAPI } from "@typescript/typescript/unstable/sync"; +import binaryen from "binaryen"; import assert from "node:assert"; import { spawn } from "node:child_process"; import { @@ -26,6 +28,52 @@ import { import { WASI } from "node:wasi"; describe("API over WebAssembly", () => { + test("implements reactor stdio descriptor lifecycle", () => { + const WebAssembly = (globalThis as any).WebAssembly; + const output: string[] = []; + const module = binaryen.parseText(` + (module + (import "wasi_snapshot_preview1" "fd_close" (func $fd_close (param i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_fdstat_get" (func $fd_fdstat_get (param i32 i32) (result i32))) + (import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32))) + (memory (export "memory") 1) + (func (export "typescript_initialize")) + (func (export "write_stdout") (param $pointer i32) (param $length i32) (result i32) + (i32.store (i32.const 0) (local.get $pointer)) + (i32.store (i32.const 4) (local.get $length)) + (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)) + ) + (func (export "close_stdout") (result i32) + (call $fd_close (i32.const 1)) + ) + (func (export "stat_stdout") (result i32) + (call $fd_fdstat_get (i32.const 1) (i32.const 16)) + ) + (data (i32.const 32) "\\e2\\82") + ) + `); + const instance = instantiateWasmSync( + new WebAssembly.Module(module.emitBinary()), + { stdout: (text: string) => output.push(text) }, + ); + const exports = instance.exports as { + write_stdout(pointer: number, length: number): number; + close_stdout(): number; + stat_stdout(): number; + }; + + assert.strictEqual(exports.write_stdout(32, 0), 0); + assert.deepStrictEqual(output, []); + assert.strictEqual(exports.write_stdout(32, 2), 0); + assert.deepStrictEqual(output, []); + assert.strictEqual(exports.close_stdout(), 0); + assert.deepStrictEqual(output, ["\uFFFD"]); + assert.strictEqual(exports.write_stdout(32, 0), 8); + assert.strictEqual(exports.stat_stdout(), 8); + assert.strictEqual(exports.close_stdout(), 8); + module.dispose(); + }); + test("runs the compiler command from the same module", async () => { const WebAssembly = (globalThis as any).WebAssembly; const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); From 3aea1384518b3412342cda21a68215108a211f58 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 2 Oct 2026 12:31:28 -0700 Subject: [PATCH 035/142] Move WASM host into TypeScript package Keep the JavaScript API host versioned with the main TypeScript package while leaving the WASI package focused on compiler artifacts. This gives browser consumers an explicit host entry and a discoverable list of bundled library files without coupling the artifact package back to the API package. --- Herebyfile.mjs | 43 +++++++++++++++---- packages/typescript-wasip1-wasm/README.md | 4 +- packages/typescript-wasip1-wasm/package.json | 13 +----- packages/typescript-wasip1-wasm/src/index.ts | 3 +- .../src/wasmURL.source.ts | 2 - .../typescript-wasip1-wasm/src/wasmURL.ts | 2 - packages/typescript/lib/getExePath.js | 28 +++++++++++- packages/typescript/lib/tsc.js | 5 ++- packages/typescript/package.json | 4 ++ packages/typescript/src/wasm/index.ts | 1 + .../src => typescript/src/wasm}/transport.ts | 0 .../src => typescript/src/wasm}/wasi.ts | 34 ++++++++++++++- .../typescript/test/browser/apiWrapper.ts | 4 +- .../typescript/test/browser/bundle.test.ts | 3 +- packages/typescript/test/browser/wasm.test.ts | 13 ++++-- .../typescript/test/sync/transport.test.ts | 8 ++-- packages/typescript/test/sync/wasm.test.ts | 20 ++++++--- packages/typescript/test/version.test.ts | 40 +++++++++++++++-- 18 files changed, 175 insertions(+), 52 deletions(-) delete mode 100644 packages/typescript-wasip1-wasm/src/wasmURL.source.ts delete mode 100644 packages/typescript-wasip1-wasm/src/wasmURL.ts create mode 100644 packages/typescript/src/wasm/index.ts rename packages/{typescript-wasip1-wasm/src => typescript/src/wasm}/transport.ts (100%) rename packages/{typescript-wasip1-wasm/src => typescript/src/wasm}/wasi.ts (92%) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index adeffba70cf13..33346ebda1acf 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1098,9 +1098,16 @@ function patchWasiFile(file) { async function runBuildWasi(extraFlags = []) { const packageDir = "./packages/typescript-wasip1-wasm"; await run("npm", ["run", "-w", "@typescript/typescript-wasip1-wasm", "build:js"]); - await fs.promises.rm(path.join(packageDir, "dist", "tsc.wasm"), { force: true }); await buildWasiFile(path.join(packageDir, "lib", "tsc.wasm"), extraFlags); - await generateLibs(path.join(packageDir, "lib")); + const libDir = path.join(packageDir, "lib"); + await generateLibs(libDir); + const libFiles = (await fs.promises.readdir(libDir)) + .filter(file => file === "lib.d.ts" || file.startsWith("lib.") && file.endsWith(".d.ts")) + .sort(); + await fs.promises.writeFile( + path.join(libDir, "libFiles.json"), + JSON.stringify(libFiles, undefined, 4) + "\n", + ); } export const buildWasi = task({ @@ -2436,7 +2443,7 @@ async function runBuildNativePreviewPackages() { // Copy package contents excluding node_modules and dist (dist is copied separately after build). // The package.json "files" field controls what npm pack actually includes. - await cpRecursive(inputDir, mainPackageDir, p => !p.endsWith("/node_modules") && !p.includes("/dist")); + await cpRecursive(inputDir, mainPackageDir, p => !p.endsWith("/node_modules") && !p.split("/").includes("dist")); if (publishAsTypescript) { await fs.promises.writeFile(path.join(mainPackageDir, "bin", "tsc"), '#!/usr/bin/env node\nimport "../lib/tsc.js";\n'); await fs.promises.chmod(path.join(mainPackageDir, "bin", "tsc"), 0o755); @@ -2482,9 +2489,6 @@ async function runBuildNativePreviewPackages() { const wasmPackageJson = JSON.parse(fs.readFileSync(path.join(wasmInputDir, "package.json"), "utf8")); wasmPackageJson.version = getVersion(); wasmPackageJson.gitHead = inputPackageJson.gitHead; - wasmPackageJson.peerDependencies = { - [mainNativePreviewPackage.npmPackageName]: getVersion(), - }; wasmPackageJson.publishConfig = { access: "public", tag: getPublishTag(), @@ -2495,7 +2499,7 @@ async function runBuildNativePreviewPackages() { stripSourceConditions(wasmPackageJson); await runBuildWasi(extraFlags); - await cpRecursive(wasmInputDir, wasip1Package.npmDir, p => !p.endsWith("/node_modules") && !p.includes("/dist")); + await cpRecursive(wasmInputDir, wasip1Package.npmDir, p => !p.endsWith("/node_modules") && !p.split("/").includes("dist")); await cpRecursive(path.join(wasmInputDir, "dist"), path.join(wasip1Package.npmDir, "dist")); await fs.promises.writeFile( path.join(wasip1Package.npmDir, "package.json"), @@ -2585,6 +2589,10 @@ async function testNativePreviewPackage(platforms) { const { stdout } = await runOutput("npm", ["pack", "--dry-run", "--json", mainPackageDir]); /** @type {{ files: { path: string }[] }[]} */ const packed = JSON.parse(stdout); + assert( + packed[0].files.some(file => file.path === "dist/wasm/index.js"), + "Main package is missing the WASM host entrypoint", + ); for (const name of ["tsconfig", "jsconfig"]) { const schemaPath = `schemas/${name}.schema.json`; assert(packed[0].files.some(file => file.path === schemaPath), `Package is missing ${schemaPath}`); @@ -2595,6 +2603,23 @@ async function testNativePreviewPackage(platforms) { assert.equal(require.resolve(`${mainNativePreviewPackage.npmPackageName}/${schemaPath}`), path.join(mainPackageDir, schemaPath)); } + const wasmPackageJson = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "package.json"), "utf8")); + assert.equal(wasmPackageJson.peerDependencies, undefined, "WASI artifact package must not depend on the main package"); + const libFiles = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "lib", "libFiles.json"), "utf8")); + const expectedLibFiles = (await fs.promises.readdir(path.join(wasmPackageDir, "lib"))) + .filter(file => file === "lib.d.ts" || file.startsWith("lib.") && file.endsWith(".d.ts")) + .sort(); + assert.deepEqual(libFiles, expectedLibFiles); + + const { stdout: wasmPackOutput } = await runOutput("npm", ["pack", "--dry-run", "--json", wasmPackageDir]); + /** @type {{ files: { path: string }[] }[]} */ + const wasmPacked = JSON.parse(wasmPackOutput); + const wasmFiles = new Set(wasmPacked[0].files.map(file => file.path)); + assert(wasmFiles.has("dist/index.js"), "WASI artifact package is missing its URL helper"); + assert(wasmFiles.has("lib/libFiles.json"), "WASI artifact package is missing its library file list"); + assert(!wasmFiles.has("dist/transport.js"), "WASI artifact package contains the reactor transport"); + assert(!wasmFiles.has("dist/wasi.js"), "WASI artifact package contains the reactor host"); + const binName = publishAsTypescript ? "tsc" : "tsgo"; const binPath = path.join(mainPackageDir, "bin", binName); const { stdout: versionOutput } = await runOutput(process.execPath, [binPath, "--version"]); @@ -2786,8 +2811,8 @@ async function runPackNativePreviewPackages() { await fs.promises.rename(filename, npmTarball); })); - // Publish in dependency order: platform packages, the main package that references - // them as optionalDependencies, then the WASI package with its exact main-package peer. + // Publish platform packages before the main package that references them as + // optionalDependencies. The independent WASI artifact package is published last. const publishManifest = { stages: [ platforms.map(p => ({ diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index 61a0bfb23f8dc..5a311dcadeec4 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -4,5 +4,5 @@ > This package is experimental. Its APIs, behavior, and package layout may > change without notice. -This package contains the TypeScript compiler, language server, and API -transport for `wasip1/wasm`. +This package contains the TypeScript compiler and language server artifacts +for `wasip1/wasm`, plus a list of the bundled library files. diff --git a/packages/typescript-wasip1-wasm/package.json b/packages/typescript-wasip1-wasm/package.json index fa1f51ccbd417..aa1755ac9b014 100644 --- a/packages/typescript-wasip1-wasm/package.json +++ b/packages/typescript-wasip1-wasm/package.json @@ -17,17 +17,8 @@ "@typescript/source": "./src/index.ts", "default": "./dist/index.js" }, - "./transport": { - "@typescript/source": "./src/transport.ts", - "default": "./dist/transport.js" - }, - "./tsc.wasm": "./lib/tsc.wasm" - }, - "imports": { - "#wasmURL": { - "@typescript/source": "./src/wasmURL.source.ts", - "default": "./dist/wasmURL.js" - } + "./lib/libFiles.json": "./lib/libFiles.json", + "./lib/tsc.wasm": "./lib/tsc.wasm" }, "scripts": { "build": "hereby --herebyfile ../../Herebyfile.mjs build:wasip1", diff --git a/packages/typescript-wasip1-wasm/src/index.ts b/packages/typescript-wasip1-wasm/src/index.ts index 3da28c4aab792..33d40da10b77f 100644 --- a/packages/typescript-wasip1-wasm/src/index.ts +++ b/packages/typescript-wasip1-wasm/src/index.ts @@ -1,2 +1 @@ -export { wasmURL } from "#wasmURL"; -export * from "./transport.ts"; +export const wasmURL: URL = new URL("../lib/tsc.wasm", import.meta.url); diff --git a/packages/typescript-wasip1-wasm/src/wasmURL.source.ts b/packages/typescript-wasip1-wasm/src/wasmURL.source.ts deleted file mode 100644 index b75558792b17d..0000000000000 --- a/packages/typescript-wasip1-wasm/src/wasmURL.source.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** URL of the locally built TypeScript WASI module when using source conditions. */ -export const wasmURL: URL = new URL("../lib/tsc.wasm", import.meta.url); diff --git a/packages/typescript-wasip1-wasm/src/wasmURL.ts b/packages/typescript-wasip1-wasm/src/wasmURL.ts deleted file mode 100644 index 47148d0928722..0000000000000 --- a/packages/typescript-wasip1-wasm/src/wasmURL.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** URL of the TypeScript WASI module distributed with this package. */ -export const wasmURL: URL = new URL("../lib/tsc.wasm", import.meta.url); diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index ebda9944730dd..cddaf18d160b1 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -67,7 +67,7 @@ export default function getExePath() { } export function getWasmPath() { - const { __dirname, normalizedDirname } = getPackageInfo(); + const { __dirname, normalizedDirname, pkg } = getPackageInfo(); let wasmDir; if (normalizedDirname.endsWith("/packages/typescript/lib")) { @@ -78,7 +78,31 @@ export function getWasmPath() { } else { const require = module.createRequire(import.meta.url); - const packageJson = require.resolve("@typescript/typescript-wasip1-wasm/package.json"); + let packageJson; + try { + packageJson = require.resolve("@typescript/typescript-wasip1-wasm/package.json"); + } + catch (error) { + if (error?.code === "MODULE_NOT_FOUND") { + throw Object.assign( + new Error( + `WebAssembly compiler package not found. Install @typescript/typescript-wasip1-wasm@${pkg.version} to enable WASI fallback.`, + { cause: error }, + ), + { code: "ERR_TYPESCRIPT_WASM_PACKAGE" }, + ); + } + throw error; + } + const wasmPackage = JSON.parse(fs.readFileSync(packageJson, "utf8")); + if (wasmPackage.version !== pkg.version) { + throw Object.assign( + new Error( + `WebAssembly compiler package version ${wasmPackage.version} is incompatible with ${pkg.name}@${pkg.version}. Install @typescript/typescript-wasip1-wasm@${pkg.version}.`, + ), + { code: "ERR_TYPESCRIPT_WASM_PACKAGE" }, + ); + } wasmDir = path.join(path.dirname(packageJson), "lib"); } diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 484d074105eb2..10a94cdafeca9 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -20,7 +20,10 @@ catch (nativeError) { try { wasmPath = getWasmPath(); } - catch { + catch (wasmError) { + if (wasmError?.code === "ERR_TYPESCRIPT_WASM_PACKAGE") { + throw wasmError; + } throw nativeError; } await runWasi(wasmPath); diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 2fe154e6127f2..607463d594ac0 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -65,6 +65,10 @@ "@typescript/source": "./src/api/proto.ts", "default": "./dist/api/proto.js" }, + "./unstable/wasm": { + "@typescript/source": "./src/wasm/index.ts", + "default": "./dist/wasm/index.js" + }, "./unstable/ast": { "@typescript/source": "./src/ast/index.ts", "default": "./dist/ast/index.js" diff --git a/packages/typescript/src/wasm/index.ts b/packages/typescript/src/wasm/index.ts new file mode 100644 index 0000000000000..c051713a83026 --- /dev/null +++ b/packages/typescript/src/wasm/index.ts @@ -0,0 +1 @@ +export * from "./transport.ts"; diff --git a/packages/typescript-wasip1-wasm/src/transport.ts b/packages/typescript/src/wasm/transport.ts similarity index 100% rename from packages/typescript-wasip1-wasm/src/transport.ts rename to packages/typescript/src/wasm/transport.ts diff --git a/packages/typescript-wasip1-wasm/src/wasi.ts b/packages/typescript/src/wasm/wasi.ts similarity index 92% rename from packages/typescript-wasip1-wasm/src/wasi.ts rename to packages/typescript/src/wasm/wasi.ts index e0dad1eff7a1d..b2960d6a4bbed 100644 --- a/packages/typescript-wasip1-wasm/src/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -1,5 +1,37 @@ import type { WasmReactorInstance } from "./transport.ts"; +declare namespace WebAssembly { + interface Module {} + + type Imports = Record>; + + interface Instance { + readonly exports: Record; + } + + const Instance: { + new (module: Module, imports?: Imports): Instance; + }; + + interface Memory { + readonly buffer: ArrayBufferLike; + } + + const Memory: { + readonly prototype: Memory; + new (descriptor: object): Memory; + }; + + function instantiate(module: Module, imports?: Imports): Promise; +} + +interface StreamingTextDecoder { + decode( + input?: ArrayBuffer | ArrayBufferView | null, + options?: { stream?: boolean | undefined; }, + ): string; +} + const errnoSuccess = 0; const errnoBadFileDescriptor = 8; const errnoInvalidArgument = 28; @@ -80,7 +112,7 @@ function createWasiHost(options: InstantiateWasmOptions): { 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 decoders = new Map(); const encoder = new TextEncoder(); const callbacks = new Map string>(); const closedDescriptors = new Set(); diff --git a/packages/typescript/test/browser/apiWrapper.ts b/packages/typescript/test/browser/apiWrapper.ts index 64f51d2ee11af..9b65ab5c00baa 100644 --- a/packages/typescript/test/browser/apiWrapper.ts +++ b/packages/typescript/test/browser/apiWrapper.ts @@ -1,9 +1,9 @@ +import type { FileSystemCallbacks } from "@typescript/typescript/unstable/fs"; import { instantiateWasm, type WasmReactorInstance, WasmTransport, -} from "@typescript/typescript-wasip1-wasm"; -import type { FileSystemCallbacks } from "@typescript/typescript/unstable/fs"; +} from "@typescript/typescript/unstable/wasm"; import { type DocumentIdentifier, type FileNotifications, diff --git a/packages/typescript/test/browser/bundle.test.ts b/packages/typescript/test/browser/bundle.test.ts index 11e70244b67da..54b9db8adef44 100644 --- a/packages/typescript/test/browser/bundle.test.ts +++ b/packages/typescript/test/browser/bundle.test.ts @@ -10,9 +10,10 @@ describe("browser API bundle", () => { const result = await build({ stdin: { contents: ` - import { WasmTransport, wasmURL } from "@typescript/typescript-wasip1-wasm"; + import { wasmURL } from "@typescript/typescript-wasip1-wasm"; import { API as AsyncAPI } from "typescript/unstable/async"; import { API as SyncAPI } from "typescript/unstable/sync"; + import { WasmTransport } from "typescript/unstable/wasm"; globalThis.typescriptAPI = { AsyncAPI, SyncAPI, WasmTransport, wasmURL }; `, loader: "ts", diff --git a/packages/typescript/test/browser/wasm.test.ts b/packages/typescript/test/browser/wasm.test.ts index c07de93123fac..072d1a008dfcb 100644 --- a/packages/typescript/test/browser/wasm.test.ts +++ b/packages/typescript/test/browser/wasm.test.ts @@ -20,14 +20,15 @@ describe("API over WebAssembly in a browser", () => { const server = createServer(); before(async () => { - const transport = await readFile(new URL("../../../typescript-wasip1-wasm/dist/transport.js", import.meta.url)); - const wasi = await readFile(new URL("../../../typescript-wasip1-wasm/dist/wasi.js", import.meta.url)); + const index = await readFile(new URL("../../dist/wasm/index.js", import.meta.url)); + const transport = await readFile(new URL("../../dist/wasm/transport.js", import.meta.url)); + const wasi = await readFile(new URL("../../dist/wasm/wasi.js", import.meta.url)); const result = await build({ stdin: { contents: ` - import { instantiateWasm, WasmTransport } from "@typescript/typescript-wasip1-wasm"; import { API as AsyncAPI } from "typescript/unstable/async"; import { API as SyncAPI } from "typescript/unstable/sync"; + import { instantiateWasm, WasmTransport } from "typescript/unstable/wasm"; async function check(API, module) { const instance = await instantiateWasm(module); @@ -96,6 +97,10 @@ describe("API over WebAssembly in a browser", () => { response.setHeader("Content-Type", "text/javascript"); response.end(transport); break; + case "/index.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(index); + break; case "/wasi.js": response.setHeader("Content-Type", "text/javascript"); response.end(wasi); @@ -133,7 +138,7 @@ describe("API over WebAssembly in a browser", () => { test("loads the transport entry without package resolution", async () => { const page = await browser.newPage(); await page.goto(origin); - const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/transport.js`)), origin); + const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/index.js`)), origin); assert.deepStrictEqual(exports, ["WasmTransport", "instantiateWasm", "instantiateWasmSync"]); }); }); diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts index e6088891370f6..e857d125f1cbc 100644 --- a/packages/typescript/test/sync/transport.test.ts +++ b/packages/typescript/test/sync/transport.test.ts @@ -1,11 +1,11 @@ -import { - type WasmReactorExports, - WasmTransport, -} from "@typescript/typescript-wasip1-wasm"; import { API, type ParsedCommandLine, } from "@typescript/typescript/unstable/sync"; +import { + type WasmReactorExports, + WasmTransport, +} from "@typescript/typescript/unstable/wasm"; import assert from "node:assert"; import { describe, diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 4bc121c8c770a..0eba6f35f826e 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -1,15 +1,15 @@ -import { - instantiateWasm, - instantiateWasmSync, - WasmTransport, - wasmURL, -} from "@typescript/typescript-wasip1-wasm"; +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; import { isIdentifier, isVariableStatement, } from "@typescript/typescript/unstable/ast"; import { API as AsyncAPI } from "@typescript/typescript/unstable/async"; import { API as SyncAPI } from "@typescript/typescript/unstable/sync"; +import { + instantiateWasm, + instantiateWasmSync, + WasmTransport, +} from "@typescript/typescript/unstable/wasm"; import binaryen from "binaryen"; import assert from "node:assert"; import { spawn } from "node:child_process"; @@ -28,6 +28,14 @@ import { import { WASI } from "node:wasi"; describe("API over WebAssembly", () => { + test("publishes its library file list", async () => { + const libFilesURL = new URL("libFiles.json", wasmURL); + const libFiles = JSON.parse(await readFile(libFilesURL, "utf8")) as string[]; + assert.ok(libFiles.includes("lib.d.ts")); + assert.ok(libFiles.includes("lib.es5.d.ts")); + assert.ok(libFiles.every(file => !file.includes("/"))); + }); + test("implements reactor stdio descriptor lifecycle", () => { const WebAssembly = (globalThis as any).WebAssembly; const output: string[] = []; diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index fc0a80ceb4d70..682bcc1ae107d 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -1,7 +1,10 @@ import getExePath from "#getExePath"; import { versionMajorMinor } from "@typescript/typescript"; import assert from "node:assert"; -import { execFileSync } from "node:child_process"; +import { + execFileSync, + spawnSync, +} from "node:child_process"; import { readFileSync } from "node:fs"; import { cp, @@ -47,19 +50,50 @@ test("the CLI falls back to the WASI package", { path.join(packageDirectory, "package.json"), JSON.stringify({ name: "typescript", + version: "0.0.0", type: "module", imports: { "#getExePath": "./lib/getExePath.js" }, }), ), ]); + const missingArtifact = spawnSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { + cwd: directory, + encoding: "utf8", + }); + assert.notStrictEqual(missingArtifact.status, 0); + assert.match( + missingArtifact.stderr, + /Install @typescript\/typescript-wasip1-wasm@0\.0\.0 to enable WASI fallback/, + ); + const scopeDirectory = path.join(directory, "node_modules", "@typescript"); await mkdir(scopeDirectory, { recursive: true }); + const artifactDirectory = path.join(scopeDirectory, "typescript-wasip1-wasm"); + await mkdir(artifactDirectory); await symlink( - new URL("../../typescript-wasip1-wasm", import.meta.url), - path.join(scopeDirectory, "typescript-wasip1-wasm"), + new URL("../../typescript-wasip1-wasm/lib", import.meta.url), + path.join(artifactDirectory, "lib"), process.platform === "win32" ? "junction" : "dir", ); + const artifactPackageJson = { + name: "@typescript/typescript-wasip1-wasm", + version: "1.0.0", + exports: { "./package.json": "./package.json" }, + }; + await writeFile(path.join(artifactDirectory, "package.json"), JSON.stringify(artifactPackageJson)); + + const mismatchedArtifact = spawnSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { + cwd: directory, + encoding: "utf8", + }); + assert.notStrictEqual(mismatchedArtifact.status, 0); + assert.match( + mismatchedArtifact.stderr, + /WebAssembly compiler package version 1\.0\.0 is incompatible with typescript@0\.0\.0/, + ); + artifactPackageJson.version = "0.0.0"; + await writeFile(path.join(artifactDirectory, "package.json"), JSON.stringify(artifactPackageJson)); const output = execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { cwd: directory, From 6f6c9fd94c82f5830ab8421f4ebbde2467d820ba Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:22:55 -0700 Subject: [PATCH 036/142] Document WASM package compatibility Browser consumers need guidance on avoiding synchronous instantiation limits and selecting compatible API and artifact packages. Document the preferred browser path and require both packages to use the same exact version. --- packages/typescript-wasip1-wasm/README.md | 4 ++++ packages/typescript/src/wasm/wasi.ts | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index 5a311dcadeec4..5d1cf73ed7cea 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -6,3 +6,7 @@ This package contains the TypeScript compiler and language server artifacts for `wasip1/wasm`, plus a list of the bundled library files. + +Install the same exact version of this package and the TypeScript package that +provides `typescript/unstable/wasm`. When loading both packages from a CDN, +select one version and use it for both package URLs. diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index b2960d6a4bbed..7acd55cf620d7 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -95,7 +95,12 @@ export async function instantiateWasm( return host.initialize(instance); } -/** Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. */ +/** + * Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. + * + * Prefer `instantiateWasm` on browser main threads, where synchronous + * instantiation of large WebAssembly modules may be rejected. + */ export function instantiateWasmSync( module: WebAssembly.Module, options: InstantiateWasmOptions = {}, From 40a26f624d6864de3930135cee14b3fb135f35e1 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:13:17 -0700 Subject: [PATCH 037/142] Validate Herebyfile in full validation Web VSIX targets intentionally omit a Node operating system, while the host execution check requires one only for desktop targets. Cover the same Herebyfile type-check in full local validation that CI runs in the misc job. --- Herebyfile.mjs | 53 +++++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 657d0c8f42330..b6371b801eb28 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1328,6 +1328,7 @@ export const validate = task({ await runValidation("test:api", runTestAPI); } if (options.all) { + await runValidation("check:herebyfile", runCheckHerebyfile); await runValidation("test:tools", runTestTools); await runValidation("test:options", async () => { await run("node", ["--test", "./tools/scripts/tsc/options.test.ts"]); @@ -1369,29 +1370,32 @@ export const checkFormat = task({ export const checkHerebyfile = task({ name: "check:herebyfile", description: "Type-checks Herebyfile.mjs.", - run: () => - run("node", [ - "./node_modules/typescript/bin/tsc", - "--noEmit", - "--allowJs", - "--allowImportingTsExtensions", - "--checkJs", - "--target", - "es2022", - "--lib", - "es2024,esnext.array,esnext.collection,esnext.iterator", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - "--types", - "node", - "--strict", - "--esModuleInterop", - "--skipLibCheck", - "Herebyfile.mjs", - ]), -}); + run: runCheckHerebyfile, +}); + +async function runCheckHerebyfile() { + await run("node", [ + "./node_modules/typescript/bin/tsc", + "--noEmit", + "--allowJs", + "--allowImportingTsExtensions", + "--checkJs", + "--target", + "es2022", + "--lib", + "es2024,esnext.array,esnext.collection,esnext.iterator", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--types", + "node", + "--strict", + "--esModuleInterop", + "--skipLibCheck", + "Herebyfile.mjs", + ]); +} export const checkVsceVersion = task({ name: "check:vsce-version", @@ -3121,7 +3125,7 @@ async function runPackVsixExtensions() { * @param {string} vsixPath * @param {string} extensionPath * @param {string} platformPackageName - * @param {string} nodeOs + * @param {string | undefined} nodeOs * @param {string} vscodeTarget */ async function testVsixPackage(vsixPath, extensionPath, platformPackageName, nodeOs, vscodeTarget) { @@ -3142,6 +3146,7 @@ async function testVsixPackage(vsixPath, extensionPath, platformPackageName, nod const hostTarget = `${process.platform}-${process.arch === "arm" ? "armhf" : process.arch}`; if (vscodeTarget !== hostTarget) return; + assert(nodeOs); const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "typescript-vsix-test-")); try { From 59ac84e03d975703bae79c384b6f93a6d4cb8759 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:01:00 -0700 Subject: [PATCH 038/142] Include WASI in parallel nightly builds The WASI artifact must participate in the nightly platform matrix so it is built independently and assembled with the native packages. Keep its version paired with TypeScript without restricting installation by host OS or CPU. --- Herebyfile.mjs | 24 +++++++++++++++++++---- packages/typescript-wasip1-wasm/README.md | 3 +++ tools/pipelines/typescript-build.yml | 13 +++++++++--- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 035c84ed5c5ea..ca9699d22f594 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -2032,6 +2032,7 @@ const mainNativePreviewPackage = { const wasip1Package = { npmPackageName: "@typescript/typescript-wasip1-wasm", + npmDirName: "typescript-wasip1-wasm", npmDir: path.join(builtNpm, "typescript-wasip1-wasm"), npmTarball: path.join(builtNpm, "typescript-wasip1-wasm.tgz"), }; @@ -2416,6 +2417,7 @@ async function runBuildNativePreviewPackages() { const platforms = getPlatforms(); await buildMainNativePreviewPackage(platforms); + await buildWasip1Package(); if (isCI) { for (const platform of platforms) { @@ -2457,6 +2459,12 @@ export const buildNativePreviewPlatformTask = task({ throw new Error("typescript:build-platform requires --platform."); } + if (options.platform === "wasip1-wasm") { + await rimraf(wasip1Package.npmDir); + await buildWasip1Package(); + return; + } + const platform = getPlatforms().find(({ nodeOs, nodeArch }) => `${nodeOs}-${nodeArch}` === options.platform); if (!platform) { throw new Error(`Unknown release platform: ${options.platform}`); @@ -2472,10 +2480,13 @@ export const buildNativePreviewPlatformMatrix = task({ hiddenFromTaskList: true, run: () => { const matrix = Object.fromEntries( - getPlatforms().map(({ nodeOs, nodeArch, npmDirName }) => { - const platform = `${nodeOs}-${nodeArch}`; - return [platform.replaceAll("-", "_"), { platform, npmDirName }]; - }), + [ + ...getPlatforms().map(({ nodeOs, nodeArch, npmDirName }) => { + const platform = `${nodeOs}-${nodeArch}`; + return [platform.replaceAll("-", "_"), { platform, npmDirName }]; + }), + ["wasip1_wasm", { platform: "wasip1-wasm", npmDirName: wasip1Package.npmDirName }], + ], ); console.log(`##vso[task.setvariable variable=matrix;isOutput=true]${JSON.stringify(matrix)}`); console.log(`##vso[task.setvariable variable=packageBaseName;isOutput=true]${path.basename(mainNativePreviewPackage.npmDir)}`); @@ -2548,7 +2559,10 @@ async function buildMainNativePreviewPackage(platforms) { if (importErrors.length) { throw new Error(`Found external imports in .d.ts files:\n${importErrors.map(e => " " + e).join("\n")}`); } +} +async function buildWasip1Package() { + const inputPackageJson = await createNativePreviewInputPackageJson(); const wasmInputDir = "./packages/typescript-wasip1-wasm"; const extraFlags = options.respectGoEnv ? [] @@ -2708,6 +2722,7 @@ async function testNativePreviewPackage(platforms) { } const wasmPackageJson = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "package.json"), "utf8")); + assert.equal(wasmPackageJson.version, getVersion(), "WASI artifact package must match the main package version"); assert.equal(wasmPackageJson.peerDependencies, undefined, "WASI artifact package must not depend on the main package"); const libFiles = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "lib", "libFiles.json"), "utf8")); const expectedLibFiles = (await fs.promises.readdir(path.join(wasmPackageDir, "lib"))) @@ -2719,6 +2734,7 @@ async function testNativePreviewPackage(platforms) { /** @type {{ files: { path: string }[] }[]} */ const wasmPacked = JSON.parse(wasmPackOutput); const wasmFiles = new Set(wasmPacked[0].files.map(file => file.path)); + assert(wasmFiles.has("lib/tsc.wasm"), "WASI artifact package is missing its compiler"); assert(wasmFiles.has("dist/index.js"), "WASI artifact package is missing its URL helper"); assert(wasmFiles.has("lib/libFiles.json"), "WASI artifact package is missing its library file list"); assert(!wasmFiles.has("dist/transport.js"), "WASI artifact package contains the reactor transport"); diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index 5d1cf73ed7cea..97bd1645dc208 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -7,6 +7,9 @@ This package contains the TypeScript compiler and language server artifacts for `wasip1/wasm`, plus a list of the bundled library files. +Nightly builds publish this package alongside the TypeScript package with the +same version and `next` tag. + Install the same exact version of this package and the TypeScript package that provides `typescript/unstable/wasm`. When loading both packages from a CDN, select one version and use it for both package URLs. diff --git a/tools/pipelines/typescript-build.yml b/tools/pipelines/typescript-build.yml index 371608eb63cf0..e055965396a00 100755 --- a/tools/pipelines/typescript-build.yml +++ b/tools/pipelines/typescript-build.yml @@ -199,12 +199,17 @@ extends: inputs: buildType: current targetPath: $(Pipeline.Workspace)/platform-packages - patterns: '$(packageBaseName)-*/**' + patterns: | + $(packageBaseName)-*/** + typescript-wasip1-wasm/** - bash: | set -euo pipefail shopt -s nullglob platformArtifacts=("$(Pipeline.Workspace)"/platform-packages/"$PACKAGE_BASE_NAME"-*) + if [[ "$PACKAGE_BASE_NAME" != "typescript" ]]; then + platformArtifacts+=("$(Pipeline.Workspace)"/platform-packages/typescript-wasip1-wasm) + fi if (( ${#platformArtifacts[@]} == 0 )); then echo "No platform package artifacts were downloaded." >&2 exit 1 @@ -212,12 +217,14 @@ extends: for artifact in "${platformArtifacts[@]}"; do packageDir="built/npm/$(basename "$artifact")" cp -a "$artifact" "$packageDir" - executable="$(find "$packageDir/lib" -maxdepth 1 -type f \( -name tsc -o -name tsc.exe -o -name tsgo -o -name tsgo.exe \) -print -quit)" + executable="$(find "$packageDir/lib" -maxdepth 1 -type f \( -name tsc -o -name tsc.exe -o -name tsgo -o -name tsgo.exe -o -name tsc.wasm \) -print -quit)" if [[ -z "$executable" ]]; then echo "Missing platform executable in $packageDir." >&2 exit 1 fi - chmod +x "$executable" + if [[ "$executable" != *.wasm ]]; then + chmod +x "$executable" + fi done displayName: 'Assemble platform packages' env: From e0925018aa8a2ce5a652432db7e459eb061160bd Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:08:33 -0700 Subject: [PATCH 039/142] Remove nightly build details from package README --- packages/typescript-wasip1-wasm/README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index 97bd1645dc208..5d1cf73ed7cea 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -7,9 +7,6 @@ This package contains the TypeScript compiler and language server artifacts for `wasip1/wasm`, plus a list of the bundled library files. -Nightly builds publish this package alongside the TypeScript package with the -same version and `next` tag. - Install the same exact version of this package and the TypeScript package that provides `typescript/unstable/wasm`. When loading both packages from a CDN, select one version and use it for both package URLs. From c0fd6cfd1e6496e0c296f768951dd1aea2e8b0d3 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:23:02 -0700 Subject: [PATCH 040/142] Update browser test adapter for stable API exports The browser suite must intercept the stable API export paths to inject the WASM transport rather than constructing a process-backed client. --- packages/typescript/test/browser/suite.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts index 2c6057a69cd55..3d6370f186802 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -251,10 +251,10 @@ function browserTestPlugin(mode: "async" | "sync"): Plugin { build.onResolve({ filter: /^node:module$/ }, () => ({ path: "node-module", namespace: "browser-shim" })); build.onResolve({ filter: /^node:path$/ }, () => ({ path: "node-path", namespace: "browser-shim" })); build.onResolve({ filter: /^node:url$/ }, () => ({ path: "node-url", namespace: "browser-shim" })); - build.onResolve({ filter: /^@typescript\/typescript\/unstable\/async$/ }, () => ({ + build.onResolve({ filter: /^@typescript\/typescript\/async$/ }, () => ({ path: mode === "async" ? api : path.join(browserDir, "asyncAPI.ts"), })); - build.onResolve({ filter: /^@typescript\/typescript\/unstable\/sync$/ }, () => ({ + build.onResolve({ filter: /^@typescript\/typescript\/sync$/ }, () => ({ path: mode === "sync" ? api : path.join(browserDir, "syncAPI.ts"), })); build.onResolve({ filter: /^\.\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); From b006170b4eb9312675ee6eff017da71b61be1166 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:16:50 -0700 Subject: [PATCH 041/142] Run WASM API suites in Node and keep browser smoke tests The shared API suites do not need a browser-specific test framework or Node polyfills. Run them with Node's test runner against the reactor, and reserve Chromium for browser loading and end-to-end smoke coverage. Keep native, WASM, and browser smoke tasks explicit so CI and local validation exercise the intended backend without import interception. --- .github/workflows/ci.yml | 3 +- Herebyfile.mjs | 46 ++- packages/typescript/package.json | 2 +- packages/typescript/test/async/api.test.ts | 2 +- .../typescript/test/async/api.testUtils.ts | 6 +- packages/typescript/test/browser/asyncAPI.ts | 23 -- .../typescript/test/browser/bundle.test.ts | 38 --- .../typescript/test/browser/harness.test.ts | 40 --- packages/typescript/test/browser/harness.ts | 211 ------------ .../typescript/test/browser/processShim.ts | 13 - .../browser/{wasm.test.ts => smoke.test.ts} | 4 + .../typescript/test/browser/suite.test.ts | 318 ------------------ packages/typescript/test/browser/syncAPI.ts | 23 -- packages/typescript/test/sync/api.test.ts | 2 +- .../typescript/test/sync/api.testUtils.ts | 6 +- packages/typescript/test/sync/ast.test.ts | 2 +- .../test/{browser/apiWrapper.ts => wasm.ts} | 74 ++-- tools/scripts/gen/generatedFile.test.mts | 2 + 18 files changed, 105 insertions(+), 710 deletions(-) delete mode 100644 packages/typescript/test/browser/asyncAPI.ts delete mode 100644 packages/typescript/test/browser/bundle.test.ts delete mode 100644 packages/typescript/test/browser/harness.test.ts delete mode 100644 packages/typescript/test/browser/harness.ts delete mode 100644 packages/typescript/test/browser/processShim.ts rename packages/typescript/test/browser/{wasm.test.ts => smoke.test.ts} (93%) delete mode 100644 packages/typescript/test/browser/suite.test.ts delete mode 100644 packages/typescript/test/browser/syncAPI.ts rename packages/typescript/test/{browser/apiWrapper.ts => wasm.ts} (71%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b25f6dbff8fc..cfcb2110b7c19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -224,10 +224,11 @@ jobs: - run: npx hereby test:extension - run: npx hereby test:tools - run: npx hereby test:api + - run: npx hereby test:api:wasm - run: npx hereby test:benchmarks:api - run: npx playwright install --with-deps chromium if: ${{ matrix.config.main }} - - run: npx hereby test:api:browser + - run: npx hereby test:api:browser-smoke if: ${{ matrix.config.main }} - run: git add . - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 7df87aa5c209f..ea589443acfe1 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1014,7 +1014,32 @@ async function runTestAPI() { ); } -async function runTestAPIBrowser() { +async function runTestAPIWasm() { + const skip = [ + // Reactor callbacks cannot yield to promises or reenter Go. + "module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)", + // These tests require spawned processes or callback filesystem layering. + "readFile callback semantics (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting|configured case sensitivity is used by the server and client)", + "updateSnapshot file systems (?:full file system is total and does not invoke host callbacks|full file system accepts paths decoded from VS Code document URIs|file system layer bypasses callbacks on hits and falls back on misses)", + // The reactor mirrors input files but does not mirror build output changes. + "BuildOrchestrator (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)", + "parseJsonConfigFileContent accepts non-object JSON|parseConfigFile$|project exposes parsedCommandLine", + "Benchmarks|Parse-clone-emit roundtrip", + ].join("|"); + for (const mode of ["async", "sync"]) { + await run("node", [ + "--conditions", + "@typescript/source", + "--test", + "--test-skip-pattern", + mode === "async" ? `${skip}|source imports do not request module resolution` : skip, + `./test/${mode}/api.test.ts`, + ...mode === "sync" ? ["./test/sync/ast.test.ts", "./test/sync/api-generators.test.ts"] : [], + ], { cwd: "./packages/typescript", env: { TS_API_TEST_WASM: "1" } }); + } +} + +async function runTestAPIBrowserSmoke() { await run("node", ["--conditions", "@typescript/source", "--test", "./test/browser/**/*.test.ts"], { cwd: "./packages/typescript" }); } @@ -1161,6 +1186,13 @@ export const testAPI = task({ run: runTestAPI, }); +export const testAPIWasm = task({ + name: "test:api:wasm", + description: "Runs the API suites against the WASM reactor in Node.js.", + dependencies: [buildAPITests], + run: runTestAPIWasm, +}); + export const testAPIBenchmarks = task({ name: "test:benchmarks:api", description: "Runs async, sync, and generator API benchmarks once; excluded from validate.", @@ -1168,11 +1200,11 @@ export const testAPIBenchmarks = task({ run: runTestAPIBenchmarks, }); -export const testAPIBrowser = task({ - name: "test:api:browser", - description: "Runs the @typescript/typescript browser API tests.", +export const testAPIBrowserSmoke = task({ + name: "test:api:browser-smoke", + description: "Runs the @typescript/typescript browser API smoke tests.", dependencies: [buildAPITests], - run: runTestAPIBrowser, + run: runTestAPIBrowserSmoke, }); export const testAll = task({ @@ -1186,8 +1218,9 @@ export const testAll = task({ await runTestBenchmarks(); await runTestTools(); await runTestAPI(); + await runTestAPIWasm(); await runTestAPIBenchmarks(); - await runTestAPIBrowser(); + await runTestAPIBrowserSmoke(); }, }); @@ -1342,6 +1375,7 @@ export const validate = task({ if (!options.all) await runGenerateEnums(); await runBuildAPITests(!options.all); await runValidation("test:api", runTestAPI); + await runValidation("test:api:wasm", runTestAPIWasm); } if (options.all) { await runValidation("check:herebyfile", runCheckHerebyfile); diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 8ce9fd408581a..4f787d92c59ca 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -143,7 +143,7 @@ "build": "tsc -b", "build:test": "tsc -b test", "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:browser-smoke:only": "npm run node -- --test './test/browser/**/*.test.ts'", "test": "npm run test:only" }, "devDependencies": { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 379f07b6b68f7..b67e89aea49dd 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -61,7 +61,6 @@ import { } from "@typescript/typescript/ast/factory"; import { visitEachChild } from "@typescript/typescript/ast/visitor"; import { - API, type BigIntLiteralType, CheckFlags, type CompilerOptions, @@ -146,6 +145,7 @@ import { createVirtualFileSystem, } from "../testUtils.ts"; import { + API, defaultFiles, spawnAPI, } from "./api.testUtils.ts"; diff --git a/packages/typescript/test/async/api.testUtils.ts b/packages/typescript/test/async/api.testUtils.ts index 04e45ff6489e8..9eb05df5d103d 100644 --- a/packages/typescript/test/async/api.testUtils.ts +++ b/packages/typescript/test/async/api.testUtils.ts @@ -1,9 +1,13 @@ import { - API, + API as NativeAPI, type APIOptions, } from "@typescript/typescript/async"; import { fileURLToPath } from "node:url"; import { createVirtualFileSystem } from "../testUtils.ts"; +import { withWasm } from "../wasm.ts"; + +export const API = process.env.TS_API_TEST_WASM ? withWasm(NativeAPI) : NativeAPI; +export type API = NativeAPI; export const defaultFiles = { "/tsconfig.json": "{}", diff --git a/packages/typescript/test/browser/asyncAPI.ts b/packages/typescript/test/browser/asyncAPI.ts deleted file mode 100644 index f13ace06de468..0000000000000 --- a/packages/typescript/test/browser/asyncAPI.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - API as BaseAPI, - type APIOptions, -} from "../../src/api/async/api.ts"; -import { - createBrowserAPIOptions, - wrapFileUpdates, -} from "./apiWrapper.ts"; - -export * from "../../src/api/async/api.ts"; - -export type API = BaseAPI; - -export const API: typeof BaseAPI = new Proxy(BaseAPI, { - construct(_target, args) { - const options = (args[0] ?? {}) as APIOptions; - const browserOptions = options as unknown as Parameters[0]; - const created = createBrowserAPIOptions(browserOptions); - const api = new BaseAPI(created.options as APIOptions); - wrapFileUpdates(api, browserOptions.fs, created.transport); - return api; - }, -}); diff --git a/packages/typescript/test/browser/bundle.test.ts b/packages/typescript/test/browser/bundle.test.ts deleted file mode 100644 index 24dab0ab7eeca..0000000000000 --- a/packages/typescript/test/browser/bundle.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { build } from "esbuild"; -import assert from "node:assert"; -import { - describe, - test, -} from "node:test"; - -describe("browser API bundle", () => { - test("uses browser clients without Node dependencies", async () => { - const result = await build({ - stdin: { - contents: ` - import { wasmURL } from "@typescript/typescript-wasip1-wasm"; - import { API as AsyncAPI } from "typescript/async"; - import { API as SyncAPI } from "typescript/sync"; - import { WasmTransport } from "typescript/unstable/wasm"; - globalThis.typescriptAPI = { AsyncAPI, SyncAPI, WasmTransport, wasmURL }; - `, - loader: "ts", - resolveDir: process.cwd(), - }, - alias: { - typescript: "@typescript/typescript", - }, - bundle: true, - conditions: ["@typescript/source", "browser"], - format: "esm", - platform: "browser", - write: false, - }); - - assert.strictEqual(result.outputFiles.length, 1); - const output = result.outputFiles[0].text; - assert.doesNotMatch(output, /(?:from|import)\s*\(?["']node:/); - assert.match(output, /The browser async API requires an injected transport/); - assert.match(output, /The browser sync API requires an injected transport/); - }); -}); diff --git a/packages/typescript/test/browser/harness.test.ts b/packages/typescript/test/browser/harness.test.ts deleted file mode 100644 index 5a1194ab8a4cb..0000000000000 --- a/packages/typescript/test/browser/harness.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import assert from "node:assert"; -import { test as nodeTest } from "node:test"; -import defaultTest, { - describe, - runRegisteredTests, - test, -} from "./harness.ts"; - -nodeTest("browser test harness preserves skip options", async () => { - assert.strictEqual(defaultTest, test); - test("skipped test", { skip: "test reason" }, () => assert.fail("skipped test ran")); - describe("skipped suite", { skip: "suite reason" }, () => { - test("nested test", () => assert.fail("skipped suite ran")); - }); - describe("nested suite", () => { - test("passing test", () => {}); - }); - test("mock method", context => { - const target = { - value: 0, - increment(amount: number) { - this.value += amount; - return this.value; - }, - }; - const method = context.mock.method(target, "increment"); - assert.strictEqual(target.increment(2), 2); - assert.strictEqual(method.mock.callCount(), 1); - }); - - assert.deepStrictEqual(await runRegisteredTests([]), { - total: 4, - passed: 2, - skipped: [ - { name: "skipped test", reason: "test reason" }, - { name: "skipped suite > nested test", reason: "suite reason" }, - ], - failures: [], - }); -}); diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts deleted file mode 100644 index 7c8ca6c5bce9c..0000000000000 --- a/packages/typescript/test/browser/harness.ts +++ /dev/null @@ -1,211 +0,0 @@ -interface RegisteredTest { - name: string; - run: (context: BrowserTestContext) => unknown; - skip?: string; -} - -interface RegisteredSuite { - name: string; - skip?: string; -} - -interface BrowserTestContext { - after(callback: () => unknown): void; - mock: { - method( - target: T, - name: K, - implementation?: T[K] | undefined, - ): T[K] & { mock: { callCount(): number; }; }; - }; -} - -export interface BrowserTestFailure { - name: string; - message: string; - stack?: string; -} - -export interface BrowserTestSkip { - name: string; - reason: string; -} - -export interface BrowserTestResults { - total: number; - passed: number; - skipped: BrowserTestSkip[]; - failures: BrowserTestFailure[]; -} - -export interface BrowserTestExclusion { - pattern: RegExp; - reason: string; -} - -const tests: RegisteredTest[] = []; -const suites: RegisteredSuite[] = []; - -interface TestOptions { - skip?: boolean | string; -} - -export function describe(name: string, options: TestOptions, run: () => void): void; -export function describe(name: string, run: () => void): void; -export function describe(name: string, optionsOrRun: TestOptions | (() => void), run?: () => void): void { - const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; - const skip = currentSuiteSkip() ?? (typeof optionsOrRun === "function" ? undefined : skipReason(optionsOrRun.skip)); - suites.push({ - name, - ...skip === undefined ? {} : { skip }, - }); - try { - callback(); - } - finally { - suites.pop(); - } -} - -export function test(name: string, options: TestOptions, run: (context: BrowserTestContext) => unknown): void; -export function test(name: string, run: (context: BrowserTestContext) => unknown): void; -export function test( - name: string, - optionsOrRun: TestOptions | ((context: BrowserTestContext) => unknown), - run?: (context: BrowserTestContext) => unknown, -): void { - const callback = typeof optionsOrRun === "function" ? optionsOrRun : run!; - const skip = currentSuiteSkip() ?? (typeof optionsOrRun === "function" ? undefined : skipReason(optionsOrRun.skip)); - tests.push({ - name: [...suites.map(suite => suite.name), name].join(" > "), - run: callback, - ...skip === undefined ? {} : { skip }, - }); -} - -export default test; - -export async function runRegisteredTests(exclusions: readonly BrowserTestExclusion[], timeoutMs = 10_000): Promise { - const failures: BrowserTestFailure[] = []; - const skipped: BrowserTestSkip[] = []; - let passed = 0; - for (const registered of tests) { - if (registered.skip) { - skipped.push({ - name: registered.name, - reason: registered.skip, - }); - continue; - } - const exclusion = exclusions.find(entry => entry.pattern.test(registered.name)); - if (exclusion) { - skipped.push({ - name: registered.name, - reason: exclusion.reason, - }); - continue; - } - try { - Reflect.set(globalThis, "browserTestProgress", registered.name); - const restorations: (() => void)[] = []; - const context: BrowserTestContext = { - after(callback) { - restorations.push(callback); - }, - mock: { - method: ( - target: T, - name: K, - implementation?: T[K] | undefined, - ): T[K] & { mock: { callCount(): number; }; } => { - const descriptor = Object.getOwnPropertyDescriptor(target, name); - const original = target[name]; - const callback = implementation ?? original; - if (typeof callback !== "function") { - throw new TypeError(`Cannot mock non-function property ${String(name)}`); - } - let callCount = 0; - const wrapped = function (this: unknown, ...args: unknown[]) { - callCount++; - return callback.apply(this, args); - }; - wrapped.mock = { - callCount: () => callCount, - }; - Object.defineProperty(target, name, { - configurable: true, - value: wrapped, - writable: true, - }); - restorations.push(() => { - if (descriptor) { - Object.defineProperty(target, name, descriptor); - } - else { - delete target[name]; - } - }); - return wrapped as T[K] & { mock: { callCount(): number; }; }; - }, - }, - }; - let timer: ReturnType | undefined; - try { - await Promise.race([ - Promise.resolve().then(() => registered.run(context)), - new Promise((_, reject) => { - timer = setTimeout(() => reject(new Error(`Test timed out after ${timeoutMs}ms`)), timeoutMs); - }), - ]); - } - finally { - clearTimeout(timer); - for (let i = restorations.length - 1; i >= 0; i--) { - await restorations[i](); - } - } - passed++; - } - catch (error) { - const normalized = normalizeError(error); - failures.push({ - name: registered.name, - message: normalized.message, - ...normalized.stack === undefined ? {} : { stack: normalized.stack }, - }); - } - } - return { total: tests.length, passed, skipped, failures }; -} - -function currentSuiteSkip(): string | undefined { - for (let i = suites.length - 1; i >= 0; i--) { - if (suites[i].skip) { - return suites[i].skip; - } - } - return undefined; -} - -function skipReason(skip: boolean | string | undefined): string | undefined { - if (!skip) { - return undefined; - } - return typeof skip === "string" ? skip : "Skipped by test option"; -} - -function normalizeError(error: unknown): Error { - if ( - error instanceof Error - && error.name === "SuppressedError" - && "error" in error - && "suppressed" in error - ) { - const primary = normalizeError(error.error); - const suppressed = normalizeError(error.suppressed); - return new Error(`${primary.message}\nSuppressed during disposal: ${suppressed.message}`, { - cause: primary, - }); - } - return error instanceof Error ? error : new Error(String(error)); -} diff --git a/packages/typescript/test/browser/processShim.ts b/packages/typescript/test/browser/processShim.ts deleted file mode 100644 index 6332e4d8982af..0000000000000 --- a/packages/typescript/test/browser/processShim.ts +++ /dev/null @@ -1,13 +0,0 @@ -import process from "process"; - -(process as { execArgv?: string[]; }).execArgv ??= []; - -export { process }; - -export function setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): ReturnType { - return setTimeout(callback, 0, ...args); -} - -export function clearImmediate(handle: ReturnType): void { - clearTimeout(handle); -} diff --git a/packages/typescript/test/browser/wasm.test.ts b/packages/typescript/test/browser/smoke.test.ts similarity index 93% rename from packages/typescript/test/browser/wasm.test.ts rename to packages/typescript/test/browser/smoke.test.ts index 64b7ca6d0ada6..878153d78d38b 100644 --- a/packages/typescript/test/browser/wasm.test.ts +++ b/packages/typescript/test/browser/smoke.test.ts @@ -76,6 +76,10 @@ describe("API over WebAssembly in a 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/); const bundle = result.outputFiles[0].contents; const wasm = await readFile(wasmURL); diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts deleted file mode 100644 index 3d6370f186802..0000000000000 --- a/packages/typescript/test/browser/suite.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { wasmURL } from "@typescript/typescript-wasip1-wasm"; -import { - build, - type Plugin, -} from "esbuild"; -import assert from "node:assert"; -import { globSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { createServer } from "node:http"; -import path from "node:path"; -import { - after, - before, - describe, - test, -} from "node:test"; -import { fileURLToPath } from "node:url"; -import { - type Browser, - chromium, -} from "playwright"; -import type { BrowserTestResults } from "./harness.ts"; - -const browserDir = fileURLToPath(new URL(".", import.meta.url)); -const packageDir = path.resolve(browserDir, "../.."); -const modes = ["async", "sync"] as const; -const fileExclusions = [ - { - mode: "async", - file: "version.test.ts", - tests: 2, - reason: "These tests exercise Node.js executable and package resolution behavior.", - }, - { - mode: "async", - file: "async/astnav.test.ts", - tests: 4, - reason: "These tests read compiler fixtures and Go baselines from the repository filesystem.", - }, - { - mode: "sync", - file: "sync/astnav.test.ts", - tests: 4, - reason: "These tests read compiler fixtures and Go baselines from the repository filesystem.", - }, - { - mode: "sync", - file: "sync/wasm.test.ts", - tests: 5, - reason: "These Node.js integration tests use node:fs and node:wasi; wasm.test.ts covers browser API loading.", - }, - { - mode: "sync", - file: "sync/shutdown.test.ts", - tests: 1, - reason: "This test launches a Node.js child process.", - }, -] as const; - -describe("API test suite in a browser", () => { - let browser: Browser; - let origin: string; - const bundles = new Map(); - const server = createServer(); - - before(async () => { - const allTestFiles = globSync(["*.test.ts", "async/**/*.test.ts", "sync/**/*.test.ts"], { - cwd: path.join(packageDir, "test"), - }); - for (const mode of modes) { - const testFiles = allTestFiles.filter(file => { - const belongsToMode = mode === "async" - ? !file.includes("/") || file.startsWith("async/") - : file.startsWith("sync/"); - return belongsToMode && !fileExclusions.some(exclusion => exclusion.file === file); - }); - const result = await build({ - stdin: { - contents: ` - ${testFiles.map(file => `import ${JSON.stringify(path.join(packageDir, "test", file))};`).join("\n")} - import { runRegisteredTests } from ${JSON.stringify(path.join(browserDir, "harness.ts"))}; - import { initializeBrowserAPIInstances } from ${JSON.stringify(path.join(browserDir, "apiWrapper.ts"))}; - try { - const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); - await initializeBrowserAPIInstances(module, 4); - globalThis.browserTestResults = await runRegisteredTests([ - { - pattern: /benchmarks$/i, - reason: "Benchmarks require Node.js process and filesystem APIs.", - }, - { - pattern: /updateSnapshot file systems > (?:full file system is total and does not invoke host callbacks|full file system accepts paths decoded from VS Code document URIs|file system layer bypasses callbacks on hits and falls back on misses)$/, - reason: "The browser WASM adapter mirrors callback files into reactor memory instead of exposing live host callback layering.", - }, - { - pattern: /module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)$/, - reason: "An in-thread WebAssembly reactor cannot service nested API requests while a host callback is in flight.", - }, - ...${JSON.stringify(mode)} === "async" - ? [{ - pattern: /source imports do not request module resolution$/, - reason: "The async resolver returns a Promise, which cannot complete inside a synchronous WebAssembly host callback.", - }] - : [], - { - pattern: /can close a process client while initialization is manually batched$/, - reason: "This test exercises the Node.js spawned-process client.", - }, - { - pattern: /Parse-clone-emit roundtrip$/, - reason: "The roundtrip fixture discovers source files through Node.js glob and filesystem APIs.", - }, - { - pattern: /parseJsonConfigFileContent accepts non-object JSON$|parseConfigFile$|project exposes parsedCommandLine$/, - reason: "These assertions depend on insertion-ordered callback filesystem directory listings.", - }, - { - pattern: /BuildOrchestrator > (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)$/, - reason: "The browser WASM adapter does not round-trip build output writes and removals through the mirrored host filesystem.", - }, - { - pattern: /readFile callback semantics > (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting|configured case sensitivity is used by the server and client)$/, - reason: "These assertions exercise spawned-process callback configuration that is not used by an injected WASM transport.", - }, - ]); - } - catch (error) { - globalThis.browserTestError = { - message: String(error), - stack: error instanceof Error ? error.stack : undefined, - }; - } - `, - loader: "ts", - resolveDir: packageDir, - }, - alias: { - typescript: "@typescript/typescript", - }, - bundle: true, - conditions: ["@typescript/source", "browser"], - format: "esm", - inject: [path.join(browserDir, "processShim.ts")], - platform: "browser", - plugins: [browserTestPlugin(mode)], - write: false, - }); - assert.strictEqual(result.outputFiles.length, 1); - bundles.set(mode, result.outputFiles[0].contents); - } - const wasm = await readFile(wasmURL); - - server.on("request", (request, response) => { - if (request.url === "/tsc.wasm") { - response.setHeader("Content-Type", "application/wasm"); - response.end(wasm); - return; - } - const pageMode = request.url?.slice(1); - if (pageMode && bundles.has(pageMode)) { - response.setHeader("Content-Type", "text/html"); - response.end(``); - return; - } - const mode = request.url?.slice(1, -3); - const bundle = mode && bundles.get(mode); - if (bundle) { - response.setHeader("Content-Type", "text/javascript"); - response.end(bundle); - return; - } - response.statusCode = 404; - response.end(); - }); - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); - const address = server.address(); - assert.ok(address && typeof address !== "string"); - origin = `http://127.0.0.1:${address.port}`; - browser = await chromium.launch(); - }); - - after(async () => { - await browser?.close(); - await new Promise((resolve, reject) => { - server.close(error => error ? reject(error) : resolve()); - }); - }); - - for (const mode of modes) { - test(`runs the existing ${mode} API tests`, async t => { - const page = await browser.newPage(); - const pageErrors: string[] = []; - const pageConsole: string[] = []; - page.on("pageerror", error => pageErrors.push(error.stack ?? error.message)); - page.on("console", message => pageConsole.push(message.text())); - await page.goto(`${origin}/${mode}`, { waitUntil: "domcontentloaded" }); - try { - await page.waitForFunction( - () => Reflect.has(globalThis, "browserTestResults") || Reflect.has(globalThis, "browserTestError"), - undefined, - { timeout: 300_000 }, - ); - } - catch (error) { - const progress = await page.evaluate(() => Reflect.get(globalThis, "browserTestProgress")); - throw new Error(`Browser API tests stalled at: ${progress}\n${pageErrors.join("\n\n")}`, { cause: error }); - } - const error = await page.evaluate(() => Reflect.get(globalThis, "browserTestError")); - assert.strictEqual(error, undefined, error?.stack ?? error?.message); - const results = await page.evaluate(() => Reflect.get(globalThis, "browserTestResults")) as BrowserTestResults; - assert.strictEqual( - results.passed + results.skipped.length + results.failures.length, - results.total, - `Browser ${mode} API test registration accounting changed`, - ); - assert.strictEqual( - results.failures.length, - 0, - [ - ...results.failures.map(failure => `${failure.name}: ${failure.message}\n${failure.stack ?? ""}`), - ...pageConsole, - ].join("\n\n"), - ); - assert.ok(results.passed > 0); - const excludedFiles = fileExclusions.filter(exclusion => exclusion.mode === mode); - const excludedFileTests = excludedFiles.reduce((count, exclusion) => count + exclusion.tests, 0); - t.diagnostic(`${results.passed} passed, ${results.skipped.length + excludedFileTests} skipped`); - for (const skipped of results.skipped) { - t.diagnostic(`SKIP ${skipped.name}: ${skipped.reason}`); - } - for (const exclusion of excludedFiles) { - t.diagnostic(`SKIP ${exclusion.file} (${exclusion.tests} tests): ${exclusion.reason}`); - } - }); - } -}); - -function browserTestPlugin(mode: "async" | "sync"): Plugin { - const harness = path.join(browserDir, "harness.ts"); - const api = path.join(browserDir, `${mode}API.ts`); - return { - name: "browser-test", - setup(build) { - build.onResolve({ filter: /^node:test$/ }, () => ({ path: harness })); - build.onResolve({ filter: /^node:assert$/ }, () => ({ path: "node-assert", namespace: "browser-shim" })); - build.onResolve({ filter: /^\.\/ast\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); - build.onResolve({ filter: /^node:fs$/ }, () => ({ path: "node-fs", namespace: "browser-shim" })); - build.onResolve({ filter: /^node:module$/ }, () => ({ path: "node-module", namespace: "browser-shim" })); - build.onResolve({ filter: /^node:path$/ }, () => ({ path: "node-path", namespace: "browser-shim" })); - build.onResolve({ filter: /^node:url$/ }, () => ({ path: "node-url", namespace: "browser-shim" })); - build.onResolve({ filter: /^@typescript\/typescript\/async$/ }, () => ({ - path: mode === "async" ? api : path.join(browserDir, "asyncAPI.ts"), - })); - build.onResolve({ filter: /^@typescript\/typescript\/sync$/ }, () => ({ - path: mode === "sync" ? api : path.join(browserDir, "syncAPI.ts"), - })); - build.onResolve({ filter: /^\.\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); - build.onResolve({ filter: /^(?:\.\.\/)+generators\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); - build.onLoad({ filter: /.*/, namespace: "browser-shim" }, args => { - switch (args.path) { - case "api-bench": - return { contents: "export function runBenchmarks() { throw new Error('Benchmarks are not available in a browser'); }" }; - case "node-assert": - return { - contents: ` - import assert from "assert"; - const equal = assert.equal; - assert.equal = (actual, expected, message) => { - if (Number.isNaN(actual) && Number.isNaN(expected)) return; - equal(actual, expected, message); - }; - export default assert; - `, - resolveDir: packageDir, - }; - case "node-fs": - return { - contents: ` - export function globSync() { throw new Error("globSync is not available in a browser"); } - export default { - existsSync() { return false; }, - readFileSync() { throw new Error("readFileSync is not available in a browser"); }, - }; - `, - }; - case "node-module": - return { contents: "export default {};" }; - case "node-path": - return { - contents: ` - export function dirname(path) { - const index = path.lastIndexOf("/"); - return index < 0 ? "." : path.slice(0, index) || "/"; - } - export function resolve(...parts) { - const path = parts.join("/"); - const normalized = []; - for (const part of path.split("/")) { - if (!part || part === ".") continue; - if (part === "..") normalized.pop(); - else normalized.push(part); - } - return "/" + normalized.join("/"); - } - export default { dirname, resolve }; - `, - }; - case "node-url": - return { contents: "export function fileURLToPath(url) { return new URL(url).pathname; }" }; - } - return undefined; - }); - }, - }; -} diff --git a/packages/typescript/test/browser/syncAPI.ts b/packages/typescript/test/browser/syncAPI.ts deleted file mode 100644 index 1c2ca8cff4279..0000000000000 --- a/packages/typescript/test/browser/syncAPI.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { - API as BaseAPI, - type APIOptions, -} from "../../src/api/sync/api.ts"; -import { - createBrowserAPIOptions, - wrapFileUpdates, -} from "./apiWrapper.ts"; - -export * from "../../src/api/sync/api.ts"; - -export type API = BaseAPI; - -export const API: typeof BaseAPI = new Proxy(BaseAPI, { - construct(_target, args) { - const options = (args[0] ?? {}) as APIOptions; - const browserOptions = options as unknown as Parameters[0]; - const created = createBrowserAPIOptions(browserOptions); - const api = new BaseAPI(created.options as APIOptions); - wrapFileUpdates(api, browserOptions.fs, created.transport); - return api; - }, -}); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index f048e8ede87e6..bed2a709e28ed 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -82,7 +82,6 @@ import { toRootedFilePath, } from "@typescript/typescript/path"; import { - API, type BigIntLiteralType, CheckFlags, type CompilerOptions, @@ -154,6 +153,7 @@ import { createVirtualFileSystem, } from "../testUtils.ts"; import { + API, defaultFiles, spawnAPI, } from "./api.testUtils.ts"; diff --git a/packages/typescript/test/sync/api.testUtils.ts b/packages/typescript/test/sync/api.testUtils.ts index c9d282d4a261e..36fccb5df0429 100644 --- a/packages/typescript/test/sync/api.testUtils.ts +++ b/packages/typescript/test/sync/api.testUtils.ts @@ -1,9 +1,13 @@ import { - API, + API as NativeAPI, type APIOptions, } from "@typescript/typescript/sync"; import { fileURLToPath } from "node:url"; import { createVirtualFileSystem } from "../testUtils.ts"; +import { withWasm } from "../wasm.ts"; + +export const API = process.env.TS_API_TEST_WASM ? withWasm(NativeAPI) : NativeAPI; +export type API = NativeAPI; export const defaultFiles = { "/tsconfig.json": "{}", diff --git a/packages/typescript/test/sync/ast.test.ts b/packages/typescript/test/sync/ast.test.ts index 1c0cab8becb8c..eff8e6eb3b000 100644 --- a/packages/typescript/test/sync/ast.test.ts +++ b/packages/typescript/test/sync/ast.test.ts @@ -67,7 +67,6 @@ import { visitNodes, } from "@typescript/typescript/ast/visitor"; import { - API, Checker, isRemoteNode, TypeFlags, @@ -82,6 +81,7 @@ import { areTestsFiltered, createVirtualFileSystem, } from "../testUtils.ts"; +import { API } from "./api.testUtils.ts"; import { runBenchmarks } from "./ast.bench.ts"; const concurrency = areTestsFiltered(); diff --git a/packages/typescript/test/browser/apiWrapper.ts b/packages/typescript/test/wasm.ts similarity index 71% rename from packages/typescript/test/browser/apiWrapper.ts rename to packages/typescript/test/wasm.ts index 0196f0445ee5e..3313b3e432121 100644 --- a/packages/typescript/test/browser/apiWrapper.ts +++ b/packages/typescript/test/wasm.ts @@ -1,20 +1,28 @@ +import { wasmURL } from "@typescript/typescript-wasip1-wasm"; +import type { API as AsyncAPI } from "@typescript/typescript/async"; import type { FileSystemCallbacks } from "@typescript/typescript/fs"; import { toRootedDirectoryPath, toRootedFilePath, } from "@typescript/typescript/path"; +import type { API as SyncAPI } from "@typescript/typescript/sync"; import { - instantiateWasm, + instantiateWasmSync, type WasmReactorInstance, WasmTransport, } from "@typescript/typescript/unstable/wasm"; +import { readFileSync } from "node:fs"; import { type DocumentIdentifier, type FileNotifications, resolveFileName, -} from "../../src/api/proto.ts"; +} from "../src/api/proto.ts"; -interface BrowserAPIOptions { +declare const WebAssembly: { + Module: new (bytes: Uint8Array) => WebAssembly.Module; +}; + +interface WasmAPIOptions { cwd?: string | undefined; fs?: FileSystemCallbacks | undefined; collectTiming?: boolean | undefined; @@ -22,30 +30,25 @@ interface BrowserAPIOptions { transport?: object | undefined; } -interface BrowserSnapshotChanges { +interface SnapshotChanges { fileNotifications?: FileNotifications | undefined; openFiles?: readonly DocumentIdentifier[] | undefined; } const availableInstances: WasmReactorInstance[] = []; const mirroredFiles = new WeakMap>(); +let module: WebAssembly.Module | undefined; -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 { +class TestWasmTransport extends WasmTransport { private returned = false; + private readonly reactorInstance: WasmReactorInstance; constructor( - private readonly reactorInstance: WasmReactorInstance, + reactorInstance: WasmReactorInstance, options: ConstructorParameters[0], ) { super(options); + this.reactorInstance = reactorInstance; } override close(): void { @@ -56,21 +59,18 @@ class BrowserWasmTransport extends WasmTransport { } } -export function createBrowserAPIOptions(options: BrowserAPIOptions): { - options: BrowserAPIOptions; - transport?: WasmTransport; +function createWasmAPIOptions(options: WasmAPIOptions): { + options: WasmAPIOptions; + transport?: WasmTransport | undefined; } { if (options.transport) return { options }; - const instance = availableInstances.pop(); - if (!instance) { - throw new Error("No initialized TypeScript WASM reactor is available"); - } + const instance = availableInstances.pop() ?? instantiateWasmSync(module ??= new WebAssembly.Module(readFileSync(wasmURL))); const writeFile = typeof options.fs?.writeFile === "function" ? options.fs.writeFile : undefined; - const transport = new BrowserWasmTransport(instance, { + const transport = new TestWasmTransport(instance, { instance, cwd: options.cwd ?? "/", - ...writeFile === undefined ? {} : { fs: { writeFile: writeFile.bind(options.fs) } }, - ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + fs: writeFile === undefined ? undefined : { writeFile: writeFile.bind(options.fs) }, + collectTiming: options.collectTiming, }); if (options.fs) { synchronizeFileSystem(options.fs, transport); @@ -78,23 +78,23 @@ export function createBrowserAPIOptions(options: BrowserAPIOptions): { return { options: { transport, - ...options.fs === undefined ? {} : { fs: options.fs }, - ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, - ...options.maxResponseBytesPerPage === undefined ? {} : { maxResponseBytesPerPage: options.maxResponseBytesPerPage }, + fs: options.fs, + collectTiming: options.collectTiming, + maxResponseBytesPerPage: options.maxResponseBytesPerPage, }, transport, }; } -export function wrapFileUpdates(api: T, fs: FileSystemCallbacks | undefined, transport: WasmTransport | undefined): void { +function wrapFileUpdates(api: T, fs: FileSystemCallbacks | undefined, transport: WasmTransport | undefined): void { if (!fs || !transport) return; wrapMethod("createSnapshot", args => { - const params = args[0] as BrowserSnapshotChanges | undefined; + const params = args[0] as SnapshotChanges | undefined; syncFileChanges(fs, transport, params?.fileNotifications); syncOpenFiles(fs, transport, params); }); wrapMethod("updateSnapshot", args => { - const params = args[1] as BrowserSnapshotChanges | undefined; + const params = args[1] as SnapshotChanges | undefined; syncFileChanges(fs, transport, params?.fileNotifications); syncOpenFiles(fs, transport, params); }); @@ -126,7 +126,7 @@ export function wrapFileUpdates(api: T, fs: FileSystemCallback function syncOpenFiles( fs: FileSystemCallbacks, transport: WasmTransport, - changes: BrowserSnapshotChanges | undefined, + changes: SnapshotChanges | undefined, ): void { for (const file of changes?.openFiles ?? []) { const fileName = resolveFileName(file); @@ -202,3 +202,15 @@ function syncFileChanges( function join(directory: string, name: string): string { return directory === "/" ? `/${name}` : `${directory}/${name}`; } + +export function withWasm(API: T): T { + return new Proxy(API, { + construct(target, args) { + const options: WasmAPIOptions = args[0] ?? {}; + const created = createWasmAPIOptions(options); + const api = Reflect.construct(target, [created.options]); + wrapFileUpdates(api, options.fs, created.transport); + return api; + }, + }); +} diff --git a/tools/scripts/gen/generatedFile.test.mts b/tools/scripts/gen/generatedFile.test.mts index e7928c50b8c4d..fc4677b1c55f3 100644 --- a/tools/scripts/gen/generatedFile.test.mts +++ b/tools/scripts/gen/generatedFile.test.mts @@ -60,6 +60,7 @@ test("validate generates before building and selects the generation scope", asyn runCheckAPIDeclarations: action("check:api-declarations"), runCheckHerebyfile: action("check:herebyfile"), runTestAPI: action("test:api"), + runTestAPIWasm: action("test:api:wasm"), runTestBenchmarks: action("test:benchmarks"), runTestTools: action("test:tools"), run: async (command: string, args: string[]) => { @@ -78,6 +79,7 @@ test("validate generates before building and selects the generation scope", asyn assert.ok(calls.indexOf("build") < calls.indexOf("test:tsc")); assert.ok(calls.indexOf("check:api-declarations") > calls.indexOf("test:extension")); assert.equal(calls.includes("test:api"), "api" in options || "all" in options); + assert.equal(calls.includes("test:api:wasm"), "api" in options || "all" in options); assert.equal(calls.includes("check:herebyfile"), "all" in options); assert.equal(calls.includes("test:tools"), "all" in options); assert.equal(calls.includes("test:options"), "all" in options); From 1c33cbfd0a55d93304d3ab937fe747ef1a1cb06f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:35:25 -0700 Subject: [PATCH 042/142] Drop unused browser shim devDependencies assert and process were only needed by the browser test shims, which were removed when browser testing moved to a Node-based WASM suite. --- package-lock.json | 391 ------------------------------- packages/typescript/package.json | 2 - 2 files changed, 393 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0bc82519eaca0..e3662ee7728c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2157,36 +2157,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/assert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", - "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "is-nan": "^1.3.2", - "object-is": "^1.1.5", - "object.assign": "^4.1.4", - "util": "^0.12.5" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -2293,25 +2263,6 @@ "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", @@ -2472,24 +2423,6 @@ "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", @@ -2503,24 +2436,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/dprint": { "version": "0.57.4", "resolved": "https://registry.npmjs.org/dprint/-/dprint-0.57.4.tgz", @@ -2721,22 +2636,6 @@ } } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2747,16 +2646,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -2826,19 +2715,6 @@ "dev": true, "license": "ISC" }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2852,22 +2728,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", @@ -2935,43 +2795,6 @@ "node": ">= 14" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -2998,26 +2821,6 @@ "node": ">=8" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -3037,58 +2840,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-nan": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", - "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.0", - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3350,54 +3101,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -3479,26 +3182,6 @@ "node": ">=20" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -3609,24 +3292,6 @@ ], "license": "MIT" }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/sax": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", @@ -3649,24 +3314,6 @@ "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", @@ -3930,20 +3577,6 @@ "dev": true, "license": "MIT" }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "node_modules/version-range": { "version": "4.15.0", "resolved": "https://registry.npmjs.org/version-range/-/version-range-4.15.0.tgz", @@ -4019,28 +3652,6 @@ "resolved": "packages/vscode-typescript-nightly", "link": true }, - "node_modules/which-typed-array": { - "version": "1.1.24", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", - "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -4180,9 +3791,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", - "assert": "^2.1.0", "playwright": "^1.62.1", - "process": "^0.11.10", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" }, diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 4f787d92c59ca..ad746fb567a9c 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -148,9 +148,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", - "assert": "^2.1.0", "playwright": "^1.62.1", - "process": "^0.11.10", "tinybench": "^6.1.6", "vscode-jsonrpc": "^9.0.2" } From 6d2ac4676ab313d885e210c0ad6d15dcc95226fb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:35:25 -0700 Subject: [PATCH 043/142] Stop building WASM in extension web bundle scripts The web extension bundle does not import the WASM package, and VSIX staging takes the payload from the packed tarball, so compiling Go to WASM here was wasted work. --- packages/vscode-typescript/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 351934e385b08..509c3175d3dd9 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -372,14 +372,14 @@ "build": "tsc -b && npm run bundle", "bundle": "npm run bundle:desktop && npm run bundle:web", "bundle:desktop": "esbuild src/extension.ts --bundle --external:vscode --platform=node --format=cjs --outfile=dist/extension.bundle.js --sourcemap", - "bundle:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap", + "bundle:web": "esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap", "test": "tsc -b test && esbuild test/index.test.ts --bundle --platform=node --format=cjs --outfile=dist/test/index.test.cjs && node --test dist/test/index.test.cjs", "watch": "npm run bundle:desktop -- --watch", "generateLocTest": "node ../../tools/scripts/gen/extensionLocalization.mts pseudo", "generateLocBundle": "node ../../tools/scripts/gen/extensionLocalization.mts bundle", "//vscode:prepublish": "we do not use this hook; the Herebyfile runs bundle:release once instead", "bundle:release": "npm run bundle:desktop -- --minify", - "bundle:release:web": "npm run -w @typescript/typescript-wasip1-wasm build && esbuild src/extension.web.ts --bundle --external:vscode --platform=browser --format=cjs --outfile=dist/extension.web.bundle.js --sourcemap --minify" + "bundle:release:web": "npm run bundle:web -- --minify" }, "dependencies": { "@vscode/extension-telemetry": "^1.5.2", From 53b43ea63ee48a86168fba5e05eb742bb267d8f9 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:35:25 -0700 Subject: [PATCH 044/142] Remove stale dist cleanup from native preview packaging The WASM artifacts no longer land in the main package's dist, so the cleanup and the changed copy filter were no longer needed. --- Herebyfile.mjs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index ea589443acfe1..70ed050aead60 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -2535,14 +2535,6 @@ export const buildNativePreviewPlatformMatrix = task({ */ async function buildMainNativePreviewPackage(platforms) { 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 = await createNativePreviewInputPackageJson(); const mainPackage = { @@ -2557,7 +2549,7 @@ async function buildMainNativePreviewPackage(platforms) { // Copy package contents excluding node_modules and dist (dist is copied separately after build). // The package.json "files" field controls what npm pack actually includes. - await cpRecursive(inputDir, mainPackageDir, p => !p.endsWith("/node_modules") && !p.split("/").includes("dist")); + await cpRecursive(inputDir, mainPackageDir, p => !p.endsWith("/node_modules") && !p.includes("/dist")); if (publishAsTypescript) { await fs.promises.writeFile(path.join(mainPackageDir, "bin", "tsc"), '#!/usr/bin/env node\nimport "../lib/tsc.js";\n'); await fs.promises.chmod(path.join(mainPackageDir, "bin", "tsc"), 0o755); @@ -2617,7 +2609,7 @@ async function buildWasip1Package() { stripSourceConditions(wasmPackageJson); await runBuildWasi(extraFlags); - await cpRecursive(wasmInputDir, wasip1Package.npmDir, p => !p.endsWith("/node_modules") && !p.split("/").includes("dist")); + 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"), From b315460f036ae679e22c2f8cfaa32229234c1771 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:35:25 -0700 Subject: [PATCH 045/142] Inline web module extension selection A one-line choice did not justify its own module and test file. --- .../vscode-typescript/src/extension.web.ts | 27 +++++++++-------- .../src/webModuleExtension.ts | 7 ----- packages/vscode-typescript/test/index.test.ts | 1 - .../test/webModuleExtension.test.ts | 29 ------------------- 4 files changed, 13 insertions(+), 51 deletions(-) delete mode 100644 packages/vscode-typescript/src/webModuleExtension.ts delete mode 100644 packages/vscode-typescript/test/webModuleExtension.test.ts diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index d4c666f4bafcc..dc3fb828b5816 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -14,7 +14,6 @@ import { type MessageTransports, } from "vscode-languageclient/browser"; import { nightlyExtensionId } from "./extensionIds"; -import { selectWebModuleExtensionUri } from "./webModuleExtension"; import { uriToWasmMountPoint } from "./webMountPoint"; declare global { @@ -110,19 +109,9 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(output); const nightlyExtension = vscode.extensions.getExtension(nightlyExtensionId); - const moduleExtensionUri = await selectWebModuleExtensionUri( - context.extensionUri, - nightlyExtension?.extensionUri, - async extensionUri => { - try { - await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, ...wasmPackagePath, "tsc.wasm")); - return true; - } - catch { - return false; - } - }, - ); + const moduleExtensionUri = nightlyExtension && await hasWasmPayload(nightlyExtension.extensionUri) + ? nightlyExtension.extensionUri + : context.extensionUri; const modulePackageUri = vscode.Uri.joinPath(moduleExtensionUri, ...wasmPackagePath); context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider("bundled", { async provideTextDocumentContent(uri) { @@ -201,6 +190,16 @@ export async function activate(context: vscode.ExtensionContext): Promise export function deactivate(): void {} +async function hasWasmPayload(extensionUri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, ...wasmPackagePath, "tsc.wasm")); + return true; + } + catch { + return false; + } +} + function findHeaderEnd(buffer: Uint8Array): number { for (let i = 0; i + 3 < buffer.length; i++) { if (buffer[i] === 13 && buffer[i + 1] === 10 && buffer[i + 2] === 13 && buffer[i + 3] === 10) { diff --git a/packages/vscode-typescript/src/webModuleExtension.ts b/packages/vscode-typescript/src/webModuleExtension.ts deleted file mode 100644 index bcf426d089d88..0000000000000 --- a/packages/vscode-typescript/src/webModuleExtension.ts +++ /dev/null @@ -1,7 +0,0 @@ -export async function selectWebModuleExtensionUri( - builtinUri: T, - nightlyUri: T | undefined, - hasWasmPayload: (uri: T) => Promise, -): Promise { - return nightlyUri && await hasWasmPayload(nightlyUri) ? nightlyUri : builtinUri; -} diff --git a/packages/vscode-typescript/test/index.test.ts b/packages/vscode-typescript/test/index.test.ts index 46b59aaa280c5..94cb06471e0ae 100644 --- a/packages/vscode-typescript/test/index.test.ts +++ b/packages/vscode-typescript/test/index.test.ts @@ -1,5 +1,4 @@ import "./contentMapperContributions.test"; import "./lspMiddleware.test"; import "./tsdkPackage.test"; -import "./webModuleExtension.test"; import "./webMountPoint.test"; diff --git a/packages/vscode-typescript/test/webModuleExtension.test.ts b/packages/vscode-typescript/test/webModuleExtension.test.ts deleted file mode 100644 index d83f8c6d8dbf6..0000000000000 --- a/packages/vscode-typescript/test/webModuleExtension.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import assert from "node:assert"; -import { - describe, - test, -} from "node:test"; -import { selectWebModuleExtensionUri } from "../src/webModuleExtension"; - -describe("web module extension", () => { - test("uses the nightly extension when it contains the WASM payload", async () => { - assert.strictEqual( - await selectWebModuleExtensionUri("builtin", "nightly", async uri => uri === "nightly"), - "nightly", - ); - }); - - test("uses the builtin extension when the nightly does not contain the WASM payload", async () => { - assert.strictEqual( - await selectWebModuleExtensionUri("builtin", "nightly", async () => false), - "builtin", - ); - }); - - test("uses the builtin extension when no nightly is installed", async () => { - assert.strictEqual( - await selectWebModuleExtensionUri("builtin", undefined, async () => true), - "builtin", - ); - }); -}); From 8b4399242d58be7a3dd8a1145d116c8b0c9c174c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:35:25 -0700 Subject: [PATCH 046/142] Merge identical getWasmPath branches The source and built package layouts resolve the WASM package from the same relative location. --- packages/typescript/lib/getExePath.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index cddaf18d160b1..3bb41471559c2 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -70,10 +70,11 @@ export function getWasmPath() { const { __dirname, normalizedDirname, pkg } = getPackageInfo(); let wasmDir; - if (normalizedDirname.endsWith("/packages/typescript/lib")) { - wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "lib"); - } - else if (normalizedDirname.endsWith("/built/npm/typescript/lib") || normalizedDirname.endsWith("/built/npm/native-preview/lib")) { + if ( + normalizedDirname.endsWith("/packages/typescript/lib") + || normalizedDirname.endsWith("/built/npm/typescript/lib") + || normalizedDirname.endsWith("/built/npm/native-preview/lib") + ) { wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "lib"); } else { From c99f3778a2facd32b5e7eb36cb8084dfe4796dc4 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:35:25 -0700 Subject: [PATCH 047/142] Trim reactor test coverage duplicated by WASM suite The Node WASM suite already runs the sync and async API tests through the reactor, so this test only needs the reactor-specific invariants. --- packages/typescript/test/sync/wasm.test.ts | 44 +--------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index c32151ea54f77..590639f6630b7 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -1,9 +1,4 @@ import { wasmURL } from "@typescript/typescript-wasip1-wasm"; -import { - isIdentifier, - isVariableStatement, -} from "@typescript/typescript/ast"; -import { API as AsyncAPI } from "@typescript/typescript/async"; import { API as SyncAPI } from "@typescript/typescript/sync"; import { instantiateWasm, @@ -240,7 +235,7 @@ describe("API over WebAssembly", () => { assert.strictEqual(exitCode, 0, stderr); }); - test("runs the compiler and checker through the reactor", async () => { + test("enforces reactor transport invariants", async () => { const WebAssembly = (globalThis as any).WebAssembly; const module = await WebAssembly.compile( await readFile(wasmURL), @@ -308,14 +303,6 @@ describe("API over WebAssembly", () => { }); 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({ @@ -337,12 +324,6 @@ describe("API over WebAssembly", () => { assert.strictEqual(timing.enabled, true); assert.ok(timing.totals.requestCount > 0); assert.ok(timing.totals.serverTimeMs >= 0); - Reflect.set(transport, "inCallback", true); - assert.throws( - () => transport.close(), - /TypeScript WASM callbacks cannot close the same API transport/, - ); - Reflect.set(transport, "inCallback", false); transport.registerCallback("retained", () => ""); api.close(); assert.deepStrictEqual(Reflect.get(transport, "callbackNames"), new Set()); @@ -354,28 +335,5 @@ describe("API over WebAssembly", () => { transport.unregisterCallback("retained"); assert.deepStrictEqual(Reflect.get(secondTransport, "callbackNames"), new Set(["retained"])); second.close(); - - const asyncTransport = new WasmTransport({ instance, cwd: "/" }); - const asyncAPI = new AsyncAPI({ transport: asyncTransport }); - asyncTransport.setFile("/tsconfig.json", "{}"); - asyncTransport.setFile("/src/async.ts", "export const value = 42 as const;"); - const asyncSnapshot = await asyncAPI.createSnapshot({ - openFiles: ["/src/async.ts"], - fileNotifications: { - changed: ["/tsconfig.json", "/src/async.ts"], - deleted: [], - }, - }); - const asyncProject = await asyncSnapshot.getDefaultProjectForFile("/src/async.ts"); - assert.ok(asyncProject); - const asyncSourceFile = await asyncProject.program.getSourceFile("/src/async.ts"); - assert.ok(asyncSourceFile); - const asyncStatement = asyncSourceFile.statements[0]; - assert.ok(isVariableStatement(asyncStatement)); - const asyncName = asyncStatement.declarationList.declarations[0].name; - assert.ok(isIdentifier(asyncName)); - const asyncType = await asyncProject.checker.getTypeAtLocation(asyncName); - assert.strictEqual(await asyncProject.checker.typeToString(asyncType), "42"); - await asyncAPI.close(); }); }); From e0e0852736e4530cd068534d42e84a220451e633 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:42:44 -0700 Subject: [PATCH 048/142] Keep the native error when no WASM package is installed Missing platform packages are the common failure, and suggesting an experimental WASM package there obscures the real problem. Only report WASM package errors when that package is present but unusable. --- packages/typescript/lib/getExePath.js | 17 +---------------- packages/typescript/test/version.test.ts | 3 ++- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index 3bb41471559c2..7d6cbdd9392d5 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -79,22 +79,7 @@ export function getWasmPath() { } else { const require = module.createRequire(import.meta.url); - let packageJson; - try { - packageJson = require.resolve("@typescript/typescript-wasip1-wasm/package.json"); - } - catch (error) { - if (error?.code === "MODULE_NOT_FOUND") { - throw Object.assign( - new Error( - `WebAssembly compiler package not found. Install @typescript/typescript-wasip1-wasm@${pkg.version} to enable WASI fallback.`, - { cause: error }, - ), - { code: "ERR_TYPESCRIPT_WASM_PACKAGE" }, - ); - } - throw error; - } + const packageJson = require.resolve("@typescript/typescript-wasip1-wasm/package.json"); const wasmPackage = JSON.parse(fs.readFileSync(packageJson, "utf8")); if (wasmPackage.version !== pkg.version) { throw Object.assign( diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index 682bcc1ae107d..bb9285f3a20c7 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -52,6 +52,7 @@ test("the CLI falls back to the WASI package", { name: "typescript", version: "0.0.0", type: "module", + bin: { tsc: "./lib/tsc.js" }, imports: { "#getExePath": "./lib/getExePath.js" }, }), ), @@ -64,7 +65,7 @@ test("the CLI falls back to the WASI package", { assert.notStrictEqual(missingArtifact.status, 0); assert.match( missingArtifact.stderr, - /Install @typescript\/typescript-wasip1-wasm@0\.0\.0 to enable WASI fallback/, + /Unable to resolve @typescript\/typescript-[\w-]+\. Either your platform is unsupported/, ); const scopeDirectory = path.join(directory, "node_modules", "@typescript"); From eb865ad6dae5e9ca52fb7b59ab8ca7f03bdf1302 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:42:50 -0700 Subject: [PATCH 049/142] Document why the WASI clock poll can spin A blocking poll looks like a CPU hazard, but the Go scheduler only reaches it when all goroutines are idle with a timer pending, which API requests do not do. --- packages/typescript/src/wasm/wasi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index 62c5b576e2c79..16208f1b52ebe 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -373,7 +373,9 @@ function createWasiHost(options: InstantiateWasmOptions): { const deadline = performance.now() + Math.max(0, shortestDelay); while (performance.now() < deadline) { - // WASI imports are synchronous, so a clock subscription must wait here. + // WASI imports are synchronous, so a clock subscription must spin here. + // The Go scheduler only polls when every goroutine is idle with a + // pending timer, which the reactor does not hit during API requests. } new Uint8Array(memory.buffer, eventsPointer, 32).fill(0); From 399db4215c2e344bf5bf121b06f31e79427574fc Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:47:50 -0700 Subject: [PATCH 050/142] Choose the async API client once at construction The Node client wrapped an optional transport client and forwarded every method through it, so each method carried a branch unrelated to its RPC logic. Picking the implementation up front keeps the RPC client focused and matches how the browser client already works. --- packages/typescript/src/api/async/api.ts | 5 ++- .../typescript/src/api/async/browserClient.ts | 20 +++++----- packages/typescript/src/api/async/client.ts | 38 ++++++------------- .../src/api/async/transportClient.ts | 8 ++++ 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 0c6cae0e32af5..c93a12d0978eb 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1,7 +1,8 @@ import { - Client, + type Client, // @sync: Client, type ClientSocketOptions, type ClientSpawnOptions, + createClient, // @sync-skip } from "#asyncClient"; // @sync: } from "#syncClient"; import { CheckFlags } from "#enums/checkFlags"; import { CompletionItemKind } from "#enums/completionItemKind"; @@ -364,7 +365,7 @@ export class API implements FormatDiagnosticsHo readonly debug: DebugHandlers; constructor(options: APIOptions | LSPConnectionOptions = {}) { - this.client = new Client(options); + this.client = createClient(options); // @sync: this.client = new Client(options); this.sourceFileCache = new SourceFileCache(); this.printer = new Printer(this.client); this.debug = new DebugHandlers(this.client, () => this.ensureInitialized()); // @sync: this.debug = new DebugHandlers(this.client, this.ensureInitialized); diff --git a/packages/typescript/src/api/async/browserClient.ts b/packages/typescript/src/api/async/browserClient.ts index d70e67203af9d..439ea4dc3d521 100644 --- a/packages/typescript/src/api/async/browserClient.ts +++ b/packages/typescript/src/api/async/browserClient.ts @@ -3,20 +3,20 @@ import type { ClientSocketOptions, ClientSpawnOptions, } from "../options.ts"; -import { TransportClient } from "./transportClient.ts"; +import { + createTransportClient, + type 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); +export type Client = TransportClient; + +export function createClient(options: AsyncClientOptions): Client { + if (!("transport" in options)) { + throw new Error("The browser async API requires an injected transport"); } + return createTransportClient(options); } diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 8b4f303767a58..fcd1548071217 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -46,21 +46,29 @@ import { type TimingInfo, } from "../timing.ts"; import { TimingDump } from "../timingDump.ts"; -import { TransportClient } from "./transportClient.ts"; +import { + createTransportClient, + type TransportClient, +} from "./transportClient.ts"; export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; export type { AsyncTransport } from "./transport.ts"; +export type Client = TransportClient | RpcClient; + +export function createClient(options: AsyncClientOptions): Client { + return isAsyncTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); +} + /** * Client handles communication with the TypeScript API server * over STDIO (spawned process) or a Unix domain socket using JSON-RPC. */ -export class Client { +class RpcClient { private socket: Socket | undefined; private process: ChildProcess | undefined; private connection: MessageConnection | undefined; private options: ClientOptions | undefined; - private transportClient: TransportClient | undefined; private connected = false; private closed = false; private connecting: Promise | undefined; @@ -70,18 +78,7 @@ 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: 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; - } + constructor(options: ClientOptions) { this.timingDump = TimingDump.fromEnvironment("async", isSpawnOptions(options) ? "spawn" : "socket"); if (this.timingDump || isSpawnOptions(options) && options.collectTiming) { this.timing = new TimingCollector(); @@ -96,7 +93,6 @@ 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(() => { @@ -270,9 +266,6 @@ export class Client { } registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { - if (this.transportClient) { - return this.transportClient.registerCallback(name, callback); - } if (!this.connection) { throw new Error("Connection not established"); } @@ -344,7 +337,6 @@ 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"); } @@ -362,7 +354,6 @@ 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(); @@ -380,7 +371,6 @@ 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"); @@ -394,7 +384,6 @@ export class Client { * materialization totals surface alongside request timings. */ getTimingCollector(): TimingCollector | undefined { - if (this.transportClient) return this.transportClient.getTimingCollector(); return this.timing; } @@ -404,7 +393,6 @@ 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(); } @@ -417,7 +405,6 @@ 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) { @@ -438,7 +425,6 @@ export class Client { } close(): Promise { - if (this.transportClient) return this.transportClient.close(); return this.closing ??= this.closeWorker(); } diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index 1a7490accae41..addb66f19247c 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -1,3 +1,4 @@ +import type { AsyncClientTransportOptions } from "../options.ts"; import type { APIMethodInfo, APIRequest, @@ -38,6 +39,13 @@ function isTextRequest(request: PendingRequest): request is PendingTextRequest { return request.kind === "text"; } +export function createTransportClient(options: AsyncClientTransportOptions): TransportClient { + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + return new TransportClient(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); +} + /** Protocol client for an injected asynchronous transport. */ export class TransportClient { private readonly encoder = new TextEncoder(); From a78c71751025ff99b247ff40c35b25ca685a4046 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:49:38 -0700 Subject: [PATCH 051/142] Grow WASM host callback results on demand Every host callback reserved 16 MiB in the module's linear memory, which WASM never returns, and still rejected larger results. Start small and let the host hand back an oversized result through a follow-up fetch so the callback is not run twice. --- packages/typescript/src/wasm/wasi.ts | 29 +++++++--- packages/typescript/test/sync/wasm.test.ts | 11 ++++ tsc/cmd/tsc/wasmapi_wasip1.go | 61 ++++++++++++++-------- 3 files changed, 72 insertions(+), 29 deletions(-) diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index 16208f1b52ebe..676f45dcafffb 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -38,6 +38,7 @@ const errnoSuccess = 0; const errnoBadFileDescriptor = 8; const errnoInvalidArgument = 28; const errnoIo = 29; +const errnoNoBuffers = 42; const errnoNoSys = 52; const fileTypeCharacterDevice = 2; const eventTypeClock = 0; @@ -122,6 +123,7 @@ function createWasiHost(options: InstantiateWasmOptions): { const decoders = new Map(); const encoder = new TextEncoder(); const callbacks = new Map string>(); + let pendingCallbackResult: Uint8Array | undefined; const closedDescriptors = new Set(); let fileSystem: WasmFileSystem | undefined; @@ -263,14 +265,29 @@ function createWasiHost(options: InstantiateWasmOptions): { const decoder = new TextDecoder(); const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); + const pending = pendingCallbackResult; + pendingCallbackResult = undefined; try { - const callback = callbacks.get(method); - if (!callback) { - throw new Error(`No callback is registered for ${method}`); + let result: Uint8Array; + if (methodLength === 0) { + // An empty method fetches a result that did not fit the previous call's buffer. + if (!pending || pending.length > resultCapacity) return errnoInvalidArgument; + result = pending; } - const result = encoder.encode(callback(method, payload)); - if (result.length > resultCapacity) { - throw new Error(`Callback result exceeds ${resultCapacity} bytes`); + else { + const callback = callbacks.get(method); + if (!callback) { + throw new Error(`No callback is registered for ${method}`); + } + result = encoder.encode(callback(method, payload)); + if (result.length > resultCapacity) { + pendingCallbackResult = result; + const resultView = new DataView(getMemory().buffer); + resultView.setUint32(bufferPointer + 8, result.length, true); + resultView.setUint32(bufferPointer + 12, 0, true); + resultView.setUint32(writtenPointer, 0, true); + return errnoNoBuffers; + } } const resultMemory = getMemory(); const resultView = new DataView(resultMemory.buffer); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 590639f6630b7..0031100cd9a70 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -320,6 +320,17 @@ describe("API over WebAssembly", () => { assert.strictEqual(emittedFiles.get("/src/index.js"), "export const value = 42;\n"); assert.strictEqual(transport.requestSync("echo", "after callback"), "after callback"); + const largeResolution = { resolvedFileName: "/large.d.ts", padding: "x".repeat(100_000) }; + let resolverCalls = 0; + const resolver = api.createModuleResolver({}, { + resolveModuleName: () => { + resolverCalls++; + return largeResolution; + }, + }); + assert.strictEqual(resolver.resolveModuleName("large", "/src", undefined).resolvedModule?.resolvedFileName, "/large.d.ts"); + assert.strictEqual(resolverCalls, 1); + const timing = api.getTimingInfo(); assert.strictEqual(timing.enabled, true); assert.ok(timing.totals.requestCount > 0); diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index ce680d8cb3ac2..fefcefd68aad8 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -25,8 +25,9 @@ const ( hostWriteFileFD = 0x7fff_fffe hostCallbackFD = 0x7fff_fffd hostErrorCapacity = 16 * 1024 - hostResultCapacity = 16 * 1024 * 1024 + hostResultCapacity = 64 * 1024 errnoBadFileNumber = 8 + errnoNoBuffers = 42 ) var ( @@ -256,37 +257,51 @@ func (hostCallbackConn) Call(_ context.Context, method string, params any) (json if err != nil { return nil, err } - methodBytes := []byte(method) - bufferSize := 16 + len(methodBytes) + len(paramsBytes) + hostResultCapacity + hostErrorCapacity + bufferSize := 16 + len(method) + len(paramsBytes) + hostResultCapacity + hostErrorCapacity if cap(hostCallbackBuffer) < bufferSize { hostCallbackBuffer = make([]byte, bufferSize) } - buffer := hostCallbackBuffer[:bufferSize] - binary.LittleEndian.PutUint32(buffer, uint32(len(methodBytes))) - binary.LittleEndian.PutUint32(buffer[4:], uint32(len(paramsBytes))) - binary.LittleEndian.PutUint32(buffer[8:], hostResultCapacity) + result, errno, err := callHost(hostCallbackBuffer[:bufferSize], method, paramsBytes, hostResultCapacity) + if errno == errnoNoBuffers { + // The host retains a result that did not fit; fetch it into a buffer of + // the reported size rather than invoking the callback again. + size := int(binary.LittleEndian.Uint32(hostCallbackBuffer[8:])) + result, _, err = callHost(make([]byte, 16+size+hostErrorCapacity), "", nil, size) + } + if err != nil { + return nil, err + } + return json.Value(result), nil +} + +func callHost(buffer []byte, method string, params []byte, resultCapacity int) ([]byte, uint32, error) { + binary.LittleEndian.PutUint32(buffer, uint32(len(method))) + binary.LittleEndian.PutUint32(buffer[4:], uint32(len(params))) + binary.LittleEndian.PutUint32(buffer[8:], uint32(resultCapacity)) binary.LittleEndian.PutUint32(buffer[12:], hostErrorCapacity) - copy(buffer[16:], methodBytes) - copy(buffer[16+len(methodBytes):], paramsBytes) + copy(buffer[16:], method) + copy(buffer[16+len(method):], params) iov := [2]uint32{bytesPointer(buffer), uint32(len(buffer))} var written uint32 errno := hostFDWrite(hostCallbackFD, unsafe.Pointer(&iov[0]), 1, &written) - if errno == errnoBadFileNumber { - return nil, errors.New("host callbacks are not supported by this WebAssembly host") - } - resultLength := min(binary.LittleEndian.Uint32(buffer[8:]), uint32(hostResultCapacity)) - errorLength := min(binary.LittleEndian.Uint32(buffer[12:]), uint32(hostErrorCapacity)) - resultStart := 16 + len(methodBytes) + len(paramsBytes) - if errno != 0 { - errorStart := resultStart + hostResultCapacity - message := string(buffer[errorStart : errorStart+int(errorLength)]) - if message == "" { - message = "host callback failed" - } - return nil, errors.New(message) + switch errno { + case 0: + resultStart := 16 + len(method) + len(params) + resultLength := min(int(binary.LittleEndian.Uint32(buffer[8:])), resultCapacity) + return buffer[resultStart : resultStart+resultLength], 0, nil + case errnoNoBuffers: + return nil, errno, nil + case errnoBadFileNumber: + return nil, errno, errors.New("host callbacks are not supported by this WebAssembly host") + } + errorStart := 16 + len(method) + len(params) + resultCapacity + errorLength := min(int(binary.LittleEndian.Uint32(buffer[12:])), hostErrorCapacity) + message := string(buffer[errorStart : errorStart+errorLength]) + if message == "" { + message = "host callback failed" } - return json.Value(buffer[resultStart : resultStart+int(resultLength)]), nil + return nil, errno, errors.New(message) } func (hostCallbackConn) Notify(context.Context, string, any) error { From d89cab7a7d22d063458dd16bd304a89c1a1812ce Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:01:33 -0700 Subject: [PATCH 052/142] Give the WASM reactor its own in-memory file system The reactor borrowed the test file system, which pulled testing packages into the shipped WASM binary. It only needs a mirror of files the host pushes in, which a small dedicated implementation covers. --- tsc/internal/api/wasmreactor/memfs.go | 213 +++++++++++++++++++ tsc/internal/api/wasmreactor/reactor.go | 3 +- tsc/internal/api/wasmreactor/reactor_test.go | 25 +++ 3 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 tsc/internal/api/wasmreactor/memfs.go diff --git a/tsc/internal/api/wasmreactor/memfs.go b/tsc/internal/api/wasmreactor/memfs.go new file mode 100644 index 0000000000000..ff804f7efe3a5 --- /dev/null +++ b/tsc/internal/api/wasmreactor/memfs.go @@ -0,0 +1,213 @@ +package wasmreactor + +import ( + "io/fs" + "maps" + "slices" + "sync" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" +) + +// memFS holds the files mirrored into the reactor by its host. +type memFS struct { + mu sync.RWMutex + caseSensitivity tspath.CaseSensitivity + files map[string]*memFile + dirs map[string]*memDir +} + +type memFile struct { + path tspath.RootedFilePath + content string + modTime time.Time +} + +type memDir struct { + path tspath.RootedDirectoryPath + // Children are keyed by canonical base name and map to their original base name. + files map[string]string + dirs map[string]string +} + +var _ vfs.FS = (*memFS)(nil) + +func newMemFS(caseSensitivity tspath.CaseSensitivity) *memFS { + m := &memFS{ + caseSensitivity: caseSensitivity, + files: map[string]*memFile{}, + dirs: map[string]*memDir{}, + } + m.ensureDir(tspath.RootedDirectoryPathFromAbsolute("/")) + return m +} + +func (m *memFS) key(path string) string { + return m.caseSensitivity.Canonicalize(path) +} + +func (m *memFS) ensureDir(path tspath.RootedDirectoryPath) *memDir { + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + return dir + } + dir := &memDir{path: path, files: map[string]string{}, dirs: map[string]string{}} + m.dirs[m.key(path.AsString())] = dir + if parent := path.AsPath().Directory(); parent != path { + name := path.BaseName() + m.ensureDir(parent).dirs[m.key(name)] = name + } + return dir +} + +func (m *memFS) CaseSensitivity() tspath.CaseSensitivity { + return m.caseSensitivity +} + +func (m *memFS) FileExists(path tspath.RootedFilePath) bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.files[m.key(path.AsString())] != nil +} + +func (m *memFS) DirectoryExists(path tspath.RootedDirectoryPath) bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.dirs[m.key(path.AsString())] != nil +} + +func (m *memFS) ReadFile(path tspath.RootedFilePath) (string, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + if file := m.files[m.key(path.AsString())]; file != nil { + return file.content, true + } + return "", false +} + +func (m *memFS) WriteFile(path tspath.RootedFilePath, data string) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.writeFile(path, data) +} + +func (m *memFS) AppendFile(path tspath.RootedFilePath, data string) error { + m.mu.Lock() + defer m.mu.Unlock() + if file := m.files[m.key(path.AsString())]; file != nil { + data = file.content + data + } + return m.writeFile(path, data) +} + +func (m *memFS) writeFile(path tspath.RootedFilePath, data string) error { + key := m.key(path.AsString()) + if m.dirs[key] != nil { + return &fs.PathError{Op: "write", Path: path.AsString(), Err: fs.ErrInvalid} + } + if file := m.files[key]; file != nil { + file.content = data + file.modTime = time.Now() + return nil + } + name := path.BaseName() + m.ensureDir(path.Directory()).files[m.key(name)] = name + m.files[key] = &memFile{path: path, content: data, modTime: time.Now()} + return nil +} + +func (m *memFS) Remove(path tspath.RootedPath) error { + m.mu.Lock() + defer m.mu.Unlock() + key := m.key(path.AsString()) + if file := m.files[key]; file != nil { + delete(m.files, key) + m.removeFromParent(file.path.AsPath()) + } else if dir := m.dirs[key]; dir != nil { + m.removeDir(dir) + if dir.path.AsPath().Directory() != dir.path { + m.removeFromParent(dir.path.AsPath()) + } + } + return nil +} + +func (m *memFS) removeFromParent(path tspath.RootedPath) { + name := m.key(path.BaseName()) + parent := m.dirs[m.key(path.Directory().AsString())] + delete(parent.files, name) + delete(parent.dirs, name) +} + +func (m *memFS) removeDir(dir *memDir) { + for _, name := range dir.files { + delete(m.files, m.key(dir.path.ResolveFile(name).AsString())) + } + for _, name := range dir.dirs { + m.removeDir(m.dirs[m.key(dir.path.ResolveDirectory(name).AsString())]) + } + delete(m.dirs, m.key(dir.path.AsString())) +} + +func (m *memFS) Chtimes(path tspath.RootedPath, aTime time.Time, mTime time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + if file := m.files[m.key(path.AsString())]; file != nil { + file.modTime = mTime + return nil + } + if m.dirs[m.key(path.AsString())] != nil { + return nil + } + return &fs.PathError{Op: "chtimes", Path: path.AsString(), Err: fs.ErrNotExist} +} + +func (m *memFS) GetAccessibleEntries(path tspath.RootedDirectoryPath) vfs.Entries { + m.mu.RLock() + defer m.mu.RUnlock() + entries := vfs.Entries{Symlinks: map[string]struct{}{}} + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + entries.Files = slices.Sorted(maps.Values(dir.files)) + entries.Directories = slices.Sorted(maps.Values(dir.dirs)) + } + return entries +} + +func (m *memFS) Stat(path tspath.RootedPath) vfs.FileInfo { + m.mu.RLock() + defer m.mu.RUnlock() + if file := m.files[m.key(path.AsString())]; file != nil { + return &memFileInfo{name: file.path.BaseName(), size: int64(len(file.content)), modTime: file.modTime} + } + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + return &memFileInfo{name: dir.path.BaseName(), mode: fs.ModeDir} + } + return nil +} + +func (m *memFS) Realpath(path tspath.RootedPath) tspath.RootedPath { + m.mu.RLock() + defer m.mu.RUnlock() + if file := m.files[m.key(path.AsString())]; file != nil { + return file.path.AsPath() + } + if dir := m.dirs[m.key(path.AsString())]; dir != nil { + return dir.path.AsPath() + } + return path +} + +type memFileInfo struct { + name string + size int64 + mode fs.FileMode + modTime time.Time +} + +func (i *memFileInfo) Name() string { return i.name } +func (i *memFileInfo) Size() int64 { return i.size } +func (i *memFileInfo) Mode() fs.FileMode { return i.mode } +func (i *memFileInfo) ModTime() time.Time { return i.modTime } +func (i *memFileInfo) IsDir() bool { return i.mode.IsDir() } +func (i *memFileInfo) Sys() any { return nil } diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go index 84577febd9ba2..7aeb3e45e9ba5 100644 --- a/tsc/internal/api/wasmreactor/reactor.go +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -14,7 +14,6 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/tspath" "github.com/microsoft/TypeScript/tsc/internal/vfs" - "github.com/microsoft/TypeScript/tsc/internal/vfs/vfstest" ) type Options struct { @@ -48,7 +47,7 @@ func New(ctx context.Context, options Options) *Reactor { if options.UseCaseSensitiveFileNames != nil && !*options.UseCaseSensitiveFileNames { caseSensitivity = tspath.CaseInsensitive } - files := vfstest.FromMap(map[string]string{}, caseSensitivity) + files := newMemFS(caseSensitivity) var projectFiles vfs.FS = files if options.WrapFS != nil { projectFiles = options.WrapFS(projectFiles) diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go index a59eb8901ab14..540df24773db4 100644 --- a/tsc/internal/api/wasmreactor/reactor_test.go +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -6,6 +6,8 @@ import ( "testing" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/tspath" + "gotest.tools/v3/assert" ) func TestReactor(t *testing.T) { @@ -54,3 +56,26 @@ func TestReactor(t *testing.T) { t.Fatal("removed file still exists") } } + +func TestMemFS(t *testing.T) { + t.Parallel() + + m := newMemFS(tspath.CaseInsensitive) + assert.NilError(t, m.WriteFile("/Src/Index.ts", "a")) + assert.NilError(t, m.AppendFile("/src/index.ts", "b")) + assert.NilError(t, m.WriteFile("/src/lib/util.ts", "c")) + + contents, ok := m.ReadFile("/SRC/INDEX.TS") + assert.Assert(t, ok) + assert.Equal(t, contents, "ab") + assert.Equal(t, m.Realpath("/src/index.ts"), tspath.RootedPath("/Src/Index.ts")) + assert.DeepEqual(t, m.GetAccessibleEntries("/src").Files, []string{"Index.ts"}) + assert.DeepEqual(t, m.GetAccessibleEntries("/src").Directories, []string{"lib"}) + assert.Assert(t, m.Stat("/src/lib").IsDir()) + + assert.NilError(t, m.Remove("/src/lib")) + assert.Assert(t, !m.FileExists("/src/lib/util.ts")) + assert.Assert(t, !m.DirectoryExists("/src/lib")) + assert.Assert(t, m.GetAccessibleEntries("/src").Directories == nil) + assert.Assert(t, m.DirectoryExists("/")) +} From 6c9d0231b971a61175bf970eebca29e29210cd3b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 05:08:56 -0700 Subject: [PATCH 053/142] Read WASM session options from the request buffer create_session was the only export that trusted a raw pointer from the host. Reading from the request buffer like the other exports bounds checks the options and drops an unsafe pointer conversion. --- packages/typescript/src/wasm/transport.ts | 11 +++++------ tsc/cmd/tsc/wasmapi_wasip1.go | 14 +++++--------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/typescript/src/wasm/transport.ts b/packages/typescript/src/wasm/transport.ts index ad591fa7f1f2d..fe324ecf3e187 100644 --- a/packages/typescript/src/wasm/transport.ts +++ b/packages/typescript/src/wasm/transport.ts @@ -8,7 +8,7 @@ import { export interface WasmReactorExports { memory: { readonly buffer: ArrayBufferLike; }; - create_session(optionsPointer: number, optionsLength: number): number; + create_session(optionsLength: number): number; close_session(): void; get_request_buffer(size: number): number; handle_request(methodLength: number, payloadLength: number): number; @@ -50,7 +50,6 @@ export class WasmTransport { private readonly instance: WasmReactorInstance; private readonly exports: WasmReactorExports; - private requestPointer = 0; private closed = false; private inCallback = false; private readonly callbackNames = new Set(); @@ -64,7 +63,7 @@ export class WasmTransport { collectTiming: options.collectTiming, })); this.writeRequest(sessionOptions); - if (this.exports.create_session(this.requestPointer, sessionOptions.length) !== 0) { + if (this.exports.create_session(sessionOptions.length) !== 0) { throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); } try { @@ -190,11 +189,11 @@ export class WasmTransport { private writeRequest(first: Uint8Array, second?: Uint8Array): void { const total = first.length + (second?.length ?? 0); - this.requestPointer = this.exports.get_request_buffer(total) >>> 0; + const requestPointer = this.exports.get_request_buffer(total) >>> 0; const memory = new Uint8Array(this.exports.memory.buffer); - memory.set(first, this.requestPointer); + memory.set(first, requestPointer); if (second) { - memory.set(second, this.requestPointer + first.length); + memory.set(second, requestPointer + first.length); } } diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index fefcefd68aad8..4075a23874e50 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -44,10 +44,13 @@ var ( ) //go:wasmexport create_session -func createSession(optionsPtr uint32, optionsLen uint32) (status uint32) { +func createSession(optionsLen uint32) (status uint32) { if reactor != nil { return fail("session already created") } + if !withinRequest(optionsLen, 0) { + return fail("invalid session options length: exceeds the request buffer") + } defer func() { if recovered := recover(); recovered != nil { reactor = nil @@ -56,7 +59,7 @@ func createSession(optionsPtr uint32, optionsLen uint32) (status uint32) { }() var options wasmreactor.Options - if err := json.Unmarshal(readMemory(optionsPtr, optionsLen), &options); err != nil { + if err := json.Unmarshal(requestBuffer[:optionsLen], &options); err != nil { return fail(fmt.Sprintf("invalid session options: %v", err)) } options.Conn = hostCallbackConn{} @@ -222,13 +225,6 @@ 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 From fc604aa3c6d6246fc9a508172bfe0bb7d1394604 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:12:17 -0700 Subject: [PATCH 054/142] Require a WASI runtime that supports non-blocking stdin Older WebAssembly Execution Engine releases ignore the nonBlocking stdin option, which leaves the language server hung on its first read. A clear error pointing at the update is easier to act on than a silent hang. --- .../vscode-typescript/src/extension.web.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index dc3fb828b5816..fb40babf35a9e 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -132,13 +132,19 @@ export async function activate(context: vscode.ExtensionContext): Promise const stderrDecoder = new TextDecoder(); const serverOptions = async (): Promise => { const wasm = await Wasm.load(); + if (!supportsNonBlockingStdin(wasm.versions.extension)) { + throw new Error( + `TypeScript requires WebAssembly Execution Engine ${minWasiCoreVersion.join(".")} or newer; found ${wasm.versions.extension}. Update the extension and reload.`, + ); + } const module = wasm.compile(vscode.Uri.joinPath(modulePackageUri, "tsc.wasm")); const mountPoints = (vscode.workspace.workspaceFolders ?? []).map(folder => ({ kind: "vscodeFileSystem" as const, uri: folder.uri, mountPoint: uriToWasmMountPoint(folder.uri), })); - // `nonBlocking` is pending in https://github.com/microsoft/vscode-wasm/pull/298. + // Blocking stdin reads stall the Go scheduler, so the server cannot answer requests + // while waiting for input. `@vscode/wasm-wasi` does not declare this option yet. const stdin = { kind: "pipeIn" as const, nonBlocking: true }; process = await wasm.createProcess("tsc", module, { args: ["--lsp", "--stdio"], @@ -190,6 +196,16 @@ export async function activate(context: vscode.ExtensionContext): Promise export function deactivate(): void {} +const minWasiCoreVersion = [1, 0, 4]; + +function supportsNonBlockingStdin(version: string): boolean { + const parts = version.split(/[.-]/, 3).map(Number); + for (let i = 0; i < minWasiCoreVersion.length; i++) { + if (parts[i] !== minWasiCoreVersion[i]) return parts[i] > minWasiCoreVersion[i]; + } + return true; +} + async function hasWasmPayload(extensionUri: vscode.Uri): Promise { try { await vscode.workspace.fs.stat(vscode.Uri.joinPath(extensionUri, ...wasmPackagePath, "tsc.wasm")); From e227a08b5fb4bfb735b54bcd27296892e87c83ae Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:22:16 -0700 Subject: [PATCH 055/142] Isolate failures in mixed async transport batches Text and binary requests in one batch are unrelated, so one failing request should not reject the others that were queued alongside it. --- .../typescript/src/api/async/transportClient.ts | 13 +++++++++---- .../typescript/test/async/transport.test.ts | 17 +++++++---------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index addb66f19247c..b847a9213112d 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -255,11 +255,16 @@ export class TransportClient { } for (const request of requests) { - if (request.kind === "text") { - request.resolve(await this.sendRequest(request.method, request.params)); + try { + if (request.kind === "text") { + request.resolve(await this.sendRequest(request.method, request.params)); + } + else { + request.resolve(await this.sendBinaryRequest(request.method, request.payload)); + } } - else { - request.resolve(await this.sendBinaryRequest(request.method, request.payload)); + catch (error) { + request.reject(error); } } } diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts index 59de164b07086..033a1db174fa5 100644 --- a/packages/typescript/test/async/transport.test.ts +++ b/packages/typescript/test/async/transport.test.ts @@ -220,7 +220,7 @@ describe("async transport", () => { await client.close(); }); - test("keeps binary requests ordered inside a manual batch", async () => { + test("keeps binary requests ordered and isolated inside a manual batch", async () => { const methods: string[] = []; const client = new TransportClient({ request(method) { @@ -237,30 +237,27 @@ describe("async transport", () => { bytesReceived: 0, }; }, - requestBinary(method, payload) { + requestBinary(method) { methods.push(method); - return { - value: payload, - bytesSent: payload.length, - bytesReceived: payload.length, - }; + throw new Error("could not read file"); }, close() {}, }, false); await client.connect(); const batch = client.batchContext(); - const text = client.apiRequest("parseCommandLine", { commandLine: [] }); const binary = client.apiRequestBinary("getSourceFile", { snapshot: 1, project: 1, file: "/index.ts", }); + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); await Promise.resolve(); assert.deepStrictEqual(methods, []); batch[Symbol.dispose](); - await Promise.all([text, binary]); - assert.deepStrictEqual(methods, ["parseCommandLine", "getSourceFile"]); + await assert.rejects(binary, /could not read file/); + assert.deepStrictEqual(await text, { options: {}, fileNames: [], errors: [] }); + assert.deepStrictEqual(methods, ["getSourceFile", "parseCommandLine"]); await client.close(); }); From cd39b2bc2c20866d228e7db14889ce651e0ff875 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:22:16 -0700 Subject: [PATCH 056/142] Stop retaining settled responses until transport close Racing each request against one long-lived close promise left a reaction on it per request, keeping every response, including source file buffers, reachable until the client closed. --- .../src/api/async/transportClient.ts | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index b847a9213112d..e68996f2a970e 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -57,16 +57,11 @@ export class TransportClient { private nextBatch: boolean | "manual" = false; private batchGeneration = 0; private batchedRequests: PendingRequest[] = []; - private readonly closedPromise: Promise; - private rejectClosed!: (reason: Error) => void; + private readonly pendingOperations = new Set<(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(); } @@ -178,7 +173,10 @@ export class TransportClient { async close(): Promise { this.closed = true; - this.rejectClosed(new Error("Client is closed")); + for (const reject of this.pendingOperations) { + reject(new Error("Client is closed")); + } + this.pendingOperations.clear(); this.batchGeneration++; this.nextBatch = false; const requests = this.batchedRequests; @@ -295,10 +293,19 @@ export class TransportClient { 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, - ]); + return new Promise((resolve, reject) => { + this.pendingOperations.add(reject); + Promise.resolve().then(operation).then( + value => { + this.pendingOperations.delete(reject); + resolve(value); + }, + error => { + this.pendingOperations.delete(reject); + reject(error); + }, + ); + }); } private recordTiming(method: string, start: number, bytesSent: number, bytesReceived: number): void { From fadb08621ee2770f1cbc4c87d45af12f093e7193 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:26:06 -0700 Subject: [PATCH 057/142] Preserve raw file text across the WASM transport File contents are not protocol text: lone surrogates must survive as WTF-8 like the rest of the API, and a leading BOM is part of the file. --- packages/typescript/src/wasm/transport.ts | 9 +++-- .../typescript/test/browser/smoke.test.ts | 34 ++++++++----------- packages/typescript/test/sync/wasm.test.ts | 5 +++ 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/packages/typescript/src/wasm/transport.ts b/packages/typescript/src/wasm/transport.ts index fe324ecf3e187..c60fd5933cea0 100644 --- a/packages/typescript/src/wasm/transport.ts +++ b/packages/typescript/src/wasm/transport.ts @@ -1,4 +1,8 @@ export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import { + encodeWtf8, + Wtf8Decoder, +} from "../api/node/wtf8.ts"; import { registerWasmCallback, setWasmFileSystem, @@ -37,6 +41,7 @@ export interface WasmTransportOptions { const encoder = new TextEncoder(); const decoder = new TextDecoder(); +const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); /** * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. @@ -128,7 +133,7 @@ export class WasmTransport { setFile(path: string, content: string): void { this.ensureCanEnterWasm(); const pathBytes = encoder.encode(path); - const contentBytes = encoder.encode(content); + const contentBytes = encodeWtf8(content); this.writeRequest(pathBytes, contentBytes); if (this.exports.set_file(pathBytes.length, contentBytes.length) !== 0) { throw new Error(`Failed to write ${path}: ${this.readResponseText()}`); @@ -145,7 +150,7 @@ export class WasmTransport { if (status !== 0) { throw new Error(`Failed to read ${path}: ${this.readResponseText()}`); } - return this.readResponseText(); + return fileContentDecoder.decode(this.readResponseBytes()); } removeFile(path: string): void { diff --git a/packages/typescript/test/browser/smoke.test.ts b/packages/typescript/test/browser/smoke.test.ts index 878153d78d38b..7987116feb0eb 100644 --- a/packages/typescript/test/browser/smoke.test.ts +++ b/packages/typescript/test/browser/smoke.test.ts @@ -20,9 +20,6 @@ describe("API over WebAssembly in a browser", () => { const server = createServer(); before(async () => { - const index = await readFile(new URL("../../dist/wasm/index.js", import.meta.url)); - const transport = await readFile(new URL("../../dist/wasm/transport.js", import.meta.url)); - const wasi = await readFile(new URL("../../dist/wasm/wasi.js", import.meta.url)); const result = await build({ stdin: { contents: ` @@ -83,7 +80,8 @@ describe("API over WebAssembly in a browser", () => { const bundle = result.outputFiles[0].contents; const wasm = await readFile(wasmURL); - server.on("request", (request, response) => { + const dist = new URL("../../dist/", import.meta.url); + server.on("request", async (request, response) => { switch (request.url) { case "/": response.setHeader("Content-Type", "text/html"); @@ -97,22 +95,20 @@ describe("API over WebAssembly in a browser", () => { response.setHeader("Content-Type", "application/wasm"); response.end(wasm); break; - case "/transport.js": - response.setHeader("Content-Type", "text/javascript"); - response.end(transport); - break; - case "/index.js": - response.setHeader("Content-Type", "text/javascript"); - response.end(index); - break; - case "/wasi.js": + default: { + const file = new URL(`.${request.url}`, dist); + const contents = file.href.startsWith(dist.href) && file.pathname.endsWith(".js") + ? await readFile(file).catch(() => undefined) + : undefined; + if (contents === undefined) { + response.statusCode = 404; + response.end(); + break; + } response.setHeader("Content-Type", "text/javascript"); - response.end(wasi); - break; - default: - response.statusCode = 404; - response.end(); + response.end(contents); break; + } } }); await new Promise((resolve, reject) => { @@ -142,7 +138,7 @@ describe("API over WebAssembly in a browser", () => { test("loads the transport entry without package resolution", async () => { const page = await browser.newPage(); await page.goto(origin); - const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/index.js`)), origin); + const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/wasm/index.js`)), origin); assert.deepStrictEqual(exports, ["WasmTransport", "instantiateWasm", "instantiateWasmSync"]); }); }); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 0031100cd9a70..81b11d3c1546f 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -292,6 +292,11 @@ describe("API over WebAssembly", () => { new Uint8Array([1, 2, 3]), ); + const rawContent = "\uFEFFconst lone = \"\uD800\";"; + transport.setFile("/raw.ts", rawContent); + assert.strictEqual(transport.readFile("/raw.ts"), rawContent); + transport.removeFile("/raw.ts"); + transport.setFile("/tsconfig.json", "{}"); transport.setFile("/src/index.ts", "export const value = 42 as const;"); const snapshot = api.createSnapshot({ From 941dd6f6bba9dacd6bb2429378a5215b11f2962c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:27:13 -0700 Subject: [PATCH 058/142] Allow for stdin backoff in WASI idle read tests Idle stdin is re-read with a backoff that settles at 50ms, so about ten reads land inside the 250ms wait and the old limit flaked under load. A busy loop would still blow well past the new limit. --- packages/typescript/test/sync/wasm.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 81b11d3c1546f..9be48565029f5 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -156,7 +156,7 @@ describe("API over WebAssembly", () => { const imports = { ...wasi.wasiImport, fd_read(...args) { - if (++reads > 10) throw new Error("stdin was read repeatedly while idle"); + if (++reads > 50) throw new Error("stdin was read repeatedly while idle"); return wasi.wasiImport.fd_read(...args); }, }; @@ -213,7 +213,7 @@ describe("API over WebAssembly", () => { const imports = { ...wasi.wasiImport, fd_read(...args) { - if (++reads > 10) throw new Error("stdin was read repeatedly while idle"); + if (++reads > 50) throw new Error("stdin was read repeatedly while idle"); return wasi.wasiImport.fd_read(...args); }, }; From 1d4fe34284f6524427ccaf89f779b508ea6c1cee Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:27:55 -0700 Subject: [PATCH 059/142] Preserve emitted BOMs through the WASM writeFile bridge With emitBOM the compiler writes a leading BOM on purpose, and the default decoder silently dropped it before the host saw the file. --- packages/typescript/src/wasm/wasi.ts | 8 +++++--- packages/typescript/test/sync/wasm.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index 676f45dcafffb..a7f716eed694e 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -1,4 +1,5 @@ import type { FileSystemCallbacks } from "../api/fs.ts"; +import { Wtf8Decoder } from "../api/node/wtf8.ts"; import type { RootedFilePath } from "../ast/index.ts"; import type { WasmReactorInstance } from "./transport.ts"; @@ -45,6 +46,8 @@ const eventTypeClock = 0; const subscriptionClockAbstime = 1; const hostCallbackFD = 0x7fff_fffd; const hostWriteFileFD = 0x7fff_fffe; +const textDecoder = new TextDecoder(); +const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); export interface InstantiateWasmOptions { stdout?: ((text: string) => void) | undefined; @@ -333,12 +336,11 @@ function createWasiHost(options: InstantiateWasmOptions): { 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)); + const path = textDecoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); + const data = fileContentDecoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); try { fileSystem.writeFile(path as RootedFilePath, data); new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 9be48565029f5..211acb50e1173 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -297,7 +297,7 @@ describe("API over WebAssembly", () => { assert.strictEqual(transport.readFile("/raw.ts"), rawContent); transport.removeFile("/raw.ts"); - transport.setFile("/tsconfig.json", "{}"); + transport.setFile("/tsconfig.json", `{ "compilerOptions": { "emitBOM": true } }`); transport.setFile("/src/index.ts", "export const value = 42 as const;"); const snapshot = api.createSnapshot({ openFiles: ["/src/index.ts"], @@ -322,7 +322,7 @@ describe("API over WebAssembly", () => { emittedFiles: ["/src/index.js"], fileSystem: undefined, }); - assert.strictEqual(emittedFiles.get("/src/index.js"), "export const value = 42;\n"); + assert.strictEqual(emittedFiles.get("/src/index.js"), "\uFEFFexport const value = 42;\n"); assert.strictEqual(transport.requestSync("echo", "after callback"), "after callback"); const largeResolution = { resolvedFileName: "/large.d.ts", padding: "x".repeat(100_000) }; From e5b81ded25a92cbb472da8061ddfa439f20d710d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:28:52 -0700 Subject: [PATCH 060/142] Honor serverFS sentinels in the WASM writeFile bridge WasmFileSystem.writeFile accepts the same serverFS sentinels as other filesystem callbacks, but the bridge ignored them, so useOS never fell back to the reactor's file system and error never failed the write. --- packages/typescript/src/api/fs.ts | 26 ++----------------- packages/typescript/src/api/fsCallbacks.ts | 30 +++++++++++++++++++--- packages/typescript/src/wasm/wasi.ts | 12 +++++++-- packages/typescript/test/sync/wasm.test.ts | 10 ++++++++ 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index ea9c51291c085..58e82cab48649 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -5,6 +5,7 @@ import type { RootedFilePath, RootedPath, } from "../ast/index.ts"; +import { serverFS } from "./fsCallbacks.ts"; import { normalizePath } from "./path.ts"; import type { RequestDirectoryEntries, @@ -32,30 +33,7 @@ export interface FileSystemStat { mtime: Date; } -const useOS: unique symbol = Symbol("useOS"); -const identity: unique symbol = Symbol("identity"); -const fakeStat: unique symbol = Symbol("fakeStat"); -const noop: unique symbol = Symbol("noop"); -const error: unique symbol = Symbol("error"); - -export const serverFS: { - /** Delegate the configured operation, or the current callback invocation, to the server's operating-system filesystem. */ - readonly useOS: typeof useOS; - /** Use the input path as its own real path without consulting a filesystem. Valid only for `realpath`. */ - readonly identity: typeof identity; - /** Synthesize stat information from `directoryExists` and `fileExists`. Valid only for `stat`. */ - readonly fakeStat: typeof fakeStat; - /** Ignore writes without invoking a callback or writing to the server's operating-system filesystem. Valid only for `writeFile`. */ - readonly noop: typeof noop; - /** Panic if the configured operation, or current callback invocation, reaches the server filesystem. */ - readonly error: typeof error; -} = { - useOS: useOS, - identity: identity, - fakeStat: fakeStat, - noop: noop, - error: error, -}; +export { serverFS }; export interface FileSystemCallbacks { directoryExists: diff --git a/packages/typescript/src/api/fsCallbacks.ts b/packages/typescript/src/api/fsCallbacks.ts index bba5efa4155e5..474864bc7c0d9 100644 --- a/packages/typescript/src/api/fsCallbacks.ts +++ b/packages/typescript/src/api/fsCallbacks.ts @@ -1,7 +1,29 @@ -import { - type FileSystemCallbacks, - serverFS, -} from "./fs.ts"; +import type { FileSystemCallbacks } from "./fs.ts"; + +const useOS: unique symbol = Symbol("useOS"); +const identity: unique symbol = Symbol("identity"); +const fakeStat: unique symbol = Symbol("fakeStat"); +const noop: unique symbol = Symbol("noop"); +const error: unique symbol = Symbol("error"); + +export const serverFS: { + /** Delegate the configured operation, or the current callback invocation, to the server's operating-system filesystem. */ + readonly useOS: typeof useOS; + /** Use the input path as its own real path without consulting a filesystem. Valid only for `realpath`. */ + readonly identity: typeof identity; + /** Synthesize stat information from `directoryExists` and `fileExists`. Valid only for `stat`. */ + readonly fakeStat: typeof fakeStat; + /** Ignore writes without invoking a callback or writing to the server's operating-system filesystem. Valid only for `writeFile`. */ + readonly noop: typeof noop; + /** Panic if the configured operation, or current callback invocation, reaches the server filesystem. */ + readonly error: typeof error; +} = { + useOS: useOS, + identity: identity, + fakeStat: fakeStat, + noop: noop, + error: error, +}; type ServerFSSentinelName = keyof typeof serverFS; type ServerFSSentinel = typeof serverFS[ServerFSSentinelName]; diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index a7f716eed694e..e503cdb4f93bf 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -1,4 +1,8 @@ import type { FileSystemCallbacks } from "../api/fs.ts"; +import { + encodeFileSystemCallbackResult, + serverFS, +} from "../api/fsCallbacks.ts"; import { Wtf8Decoder } from "../api/node/wtf8.ts"; import type { RootedFilePath } from "../ast/index.ts"; import type { WasmReactorInstance } from "./transport.ts"; @@ -325,7 +329,8 @@ function createWasiHost(options: InstantiateWasmOptions): { function hostWriteFile(iovsPointer: number, iovsLength: number, writtenPointer: number): number { iovsPointer >>>= 0; writtenPointer >>>= 0; - if (typeof fileSystem?.writeFile !== "function") return errnoBadFileDescriptor; + const writeFile = fileSystem?.writeFile; + if (writeFile === undefined || writeFile === serverFS.useOS) return errnoBadFileDescriptor; if (iovsLength !== 1) return errnoInvalidArgument; const memory = getMemory(); const view = new DataView(memory.buffer); @@ -342,7 +347,10 @@ function createWasiHost(options: InstantiateWasmOptions): { const path = textDecoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); const data = fileContentDecoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); try { - fileSystem.writeFile(path as RootedFilePath, data); + const result = typeof writeFile === "function" ? writeFile(path as RootedFilePath, data) : writeFile; + const response = encodeFileSystemCallbackResult("writeFile", result); + if (response.kind === "useOS") return errnoBadFileDescriptor; + if (response.kind === "error") throw new Error("filesystem callback returned serverFS.error: writeFile"); new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); return errnoSuccess; } diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 211acb50e1173..0d3516307ec23 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -21,6 +21,7 @@ import { test, } from "node:test"; import { WASI } from "node:wasi"; +import { serverFS } from "../../src/api/fs.ts"; describe("API over WebAssembly", () => { test("publishes its library file list", async () => { @@ -325,6 +326,15 @@ describe("API over WebAssembly", () => { assert.strictEqual(emittedFiles.get("/src/index.js"), "\uFEFFexport const value = 42;\n"); assert.strictEqual(transport.requestSync("echo", "after callback"), "after callback"); + transport.setFileSystem({ writeFile: () => serverFS.useOS }); + project.program.emit(); + assert.strictEqual(transport.readFile("/src/index.js"), "\uFEFFexport const value = 42;\n"); + transport.setFileSystem({ writeFile: serverFS.error }); + assert.match( + project.program.emit().diagnostics[0]?.text ?? "", + /Could not write file '\/src\/index\.js': filesystem callback returned serverFS\.error/, + ); + const largeResolution = { resolvedFileName: "/large.d.ts", padding: "x".repeat(100_000) }; let resolverCalls = 0; const resolver = api.createModuleResolver({}, { From 74688fa2b7be2aaac240a9b0aeb12c98ba1e09ed Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:35:22 -0700 Subject: [PATCH 061/142] Mount web workspaces where the server looks for their files The server maps non-file document URIs onto escaped ^/~ts-uri~/ file names, so mounting virtual workspaces at the old unescaped location left their files invisible to the WASM language server. --- .../vscode-typescript/src/webMountPoint.ts | 47 +++++++++++++++++-- .../test/webMountPoint.test.ts | 12 +++-- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index 74e66a4da1905..8daa67ceb941c 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -2,20 +2,59 @@ export interface WebFileSystemUri { readonly scheme: string; readonly authority: string; readonly path: string; + toString(): string; } +const dynamicURIFileNamePrefix = "^/~ts-uri~/"; +const escapePrefixes = ["~ts-uri-escape~", "~ts-uri-spec~", "~ts-uri-no-path~"]; +const nulAuthority = "ts-nul-authority"; + export function uriToWasmMountPoint(uri: WebFileSystemUri): string { - let fileName: string; if (uri.scheme === "file") { + let fileName: string; if (uri.authority) { fileName = `//${uri.authority}${uri.path}`; } else { fileName = /^\/[A-Za-z]:/.test(uri.path) ? uri.path.slice(1) : uri.path; } + return fileName.startsWith("/") ? fileName : `/${fileName}`; + } + + // Files below the folder must land inside this mount, so this follows the + // server's mapping of non-file document URIs onto the file names it reads. + const text = uri.toString(); + const colon = text.indexOf(":"); + const scheme = text.slice(0, colon); + let path = text.slice(colon + 1); + let authority = nulAuthority; + if (path.startsWith("//")) { + const slash = path.indexOf("/", 2); + const rawAuthority = slash === -1 ? path.slice(2) : path.slice(2, slash); + authority = rawAuthority === nulAuthority || isDriveRoot(rawAuthority) ? forceEncodeSegment(rawAuthority) : encodeSegment(rawAuthority); + path = slash === -1 ? "" : path.slice(slash + 1); } - else { - fileName = `^/${uri.scheme}/${uri.authority || "ts-nul-authority"}${uri.path}`; + const segments = path === "" ? [] : (path.endsWith("/") ? path : `${path}/`).split("/").slice(0, -1).map(encodeSegment); + if (segments.length && isDriveRoot(segments[0])) { + segments[0] = forceEncodeSegment(segments[0]); } - return fileName.startsWith("/") ? fileName : `/${fileName}`; + return `/${dynamicURIFileNamePrefix}${[scheme, authority, ...segments].join("/")}`; +} + +function isDriveRoot(segment: string): boolean { + return /^[A-Za-z]:/.test(segment); +} + +function encodeSegment(segment: string): string { + const needsEncoding = segment === "" + || segment === "." + || segment === ".." + || segment.includes("\\") + || escapePrefixes.some(prefix => segment.startsWith(prefix)); + return needsEncoding ? forceEncodeSegment(segment) : segment; +} + +function forceEncodeSegment(segment: string): string { + const hex = Array.from(new TextEncoder().encode(segment), byte => byte.toString(16).padStart(2, "0")).join(""); + return `${escapePrefixes[0]}${hex}~`; } diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index a4634c7ed1e71..47834b68029b3 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -27,10 +27,12 @@ describe("web WASI mount points", () => { ); }); - test("maps virtual workspaces to the path used by DocumentUri.FileName", () => { - assert.strictEqual( - uriToWasmMountPoint({ scheme: "vscode-test-web", authority: "mount", path: "/" }), - "/^/vscode-test-web/mount/", - ); + test("maps virtual workspaces to the paths the server derives from document URIs", () => { + const virtual = (text: string) => uriToWasmMountPoint({ scheme: text.slice(0, text.indexOf(":")), authority: "", path: "", toString: () => text }); + assert.strictEqual(virtual("vscode-test-web://mount/"), "/^/~ts-uri~/vscode-test-web/mount"); + assert.strictEqual(virtual("vscode-vfs://github/microsoft/TypeScript"), "/^/~ts-uri~/vscode-vfs/github/microsoft/TypeScript"); + assert.strictEqual(virtual("memfs:/work/a%5Cb"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/a%5Cb"); + assert.strictEqual(virtual("memfs:/work/a\\b/"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~"); + assert.strictEqual(virtual("memfs:///x"), "/^/~ts-uri~/memfs/~ts-uri-escape~~/x"); }); }); From 7640849f7e4b4beb3be782783343fcb1379b6d08 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 09:40:32 -0700 Subject: [PATCH 062/142] Format WASM raw content test --- packages/typescript/test/sync/wasm.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 0d3516307ec23..5f15880d8ad05 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -293,7 +293,7 @@ describe("API over WebAssembly", () => { new Uint8Array([1, 2, 3]), ); - const rawContent = "\uFEFFconst lone = \"\uD800\";"; + const rawContent = '\uFEFFconst lone = "\uD800";'; transport.setFile("/raw.ts", rawContent); assert.strictEqual(transport.readFile("/raw.ts"), rawContent); transport.removeFile("/raw.ts"); From f2ea2b7e3385d634e9f63aaac947efd5821148ea Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:04:13 -0700 Subject: [PATCH 063/142] Reject writes below files in the WASM reactor file system Writing through a path whose ancestor is a file used to create a directory alongside the file, so both existence checks succeeded and emit could appear to work into an impossible output location. --- tsc/internal/api/wasmreactor/memfs.go | 8 ++++++++ tsc/internal/api/wasmreactor/reactor_test.go | 3 +++ 2 files changed, 11 insertions(+) diff --git a/tsc/internal/api/wasmreactor/memfs.go b/tsc/internal/api/wasmreactor/memfs.go index ff804f7efe3a5..9df68bbfbe9b6 100644 --- a/tsc/internal/api/wasmreactor/memfs.go +++ b/tsc/internal/api/wasmreactor/memfs.go @@ -111,6 +111,14 @@ func (m *memFS) writeFile(path tspath.RootedFilePath, data string) error { file.modTime = time.Now() return nil } + for dir := path.Directory(); ; dir = dir.AsPath().Directory() { + if m.dirs[m.key(dir.AsString())] != nil { + break + } + if m.files[m.key(dir.AsString())] != nil { + return &fs.PathError{Op: "write", Path: path.AsString(), Err: fs.ErrInvalid} + } + } name := path.BaseName() m.ensureDir(path.Directory()).files[m.key(name)] = name m.files[key] = &memFile{path: path, content: data, modTime: time.Now()} diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go index 540df24773db4..a473f6a13f07a 100644 --- a/tsc/internal/api/wasmreactor/reactor_test.go +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -78,4 +78,7 @@ func TestMemFS(t *testing.T) { assert.Assert(t, !m.DirectoryExists("/src/lib")) assert.Assert(t, m.GetAccessibleEntries("/src").Directories == nil) assert.Assert(t, m.DirectoryExists("/")) + + assert.Assert(t, m.WriteFile("/src/index.ts/nested/out.js", "d") != nil) + assert.Assert(t, !m.DirectoryExists("/src/index.ts")) } From 69591b5ce21685c540f8dad5cc731cad45fef6f6 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:04:34 -0700 Subject: [PATCH 064/142] Skip queued transport calls once the client closes Transport calls start a microtask later, so closing in between still invoked the transport after it was disposed even though the caller had already seen the request rejected. --- packages/typescript/src/api/async/transportClient.ts | 5 ++++- packages/typescript/test/async/transport.test.ts | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index e68996f2a970e..c8cd6b34f347e 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -295,7 +295,10 @@ export class TransportClient { if (this.closed) return Promise.reject(new Error("Client is closed")); return new Promise((resolve, reject) => { this.pendingOperations.add(reject); - Promise.resolve().then(operation).then( + Promise.resolve().then(() => { + if (this.closed) throw new Error("Client is closed"); + return operation(); + }).then( value => { this.pendingOperations.delete(reject); resolve(value); diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts index 033a1db174fa5..fdacffb70b494 100644 --- a/packages/typescript/test/async/transport.test.ts +++ b/packages/typescript/test/async/transport.test.ts @@ -320,8 +320,10 @@ describe("async transport", () => { }); test("rejects timing requests when closed", async () => { + const methods: string[] = []; const client = new TransportClient({ - request() { + request(method) { + methods.push(method); return new Promise(() => {}); }, requestBinary() { @@ -336,6 +338,7 @@ describe("async transport", () => { await client.close(); await assert.rejects(timing, /Client is closed/); await assert.rejects(reset, /Client is closed/); + assert.deepStrictEqual(methods, []); }); test("does not queue requests after closing during connect", async () => { From 05f72e243ae2f34930f08445247268e6a491e4f0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:06:36 -0700 Subject: [PATCH 065/142] Quote translated response file paths that contain spaces On Windows the WASI fallback rewrites nested response file references to guest paths under the temp directory, which can contain spaces, and an unquoted replacement then split into separate arguments. --- packages/typescript/lib/tsc.js | 13 +++++++------ packages/typescript/test/version.test.ts | 6 +++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 10a94cdafeca9..9a3e03e608aaf 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -137,10 +137,11 @@ async function getWasiPaths(wasmPath) { if (tokens) { const replacements = []; for (const token of tokens) { - replacements.push({ - ...token, - value: await translateArgument(token.value), - }); + let value = await translateArgument(token.value); + if (!token.quoted && /\s/.test(value)) { + value = `"${value}"`; + } + replacements.push({ ...token, value }); } for (let i = replacements.length - 1; i >= 0; i--) { const replacement = replacements[i]; @@ -192,7 +193,7 @@ function parseResponseFile(contents) { if (position >= contents.length) { return undefined; } - tokens.push({ start, end: position, value: contents.slice(start, position) }); + tokens.push({ start, end: position, value: contents.slice(start, position), quoted: true }); position++; } else { @@ -200,7 +201,7 @@ function parseResponseFile(contents) { while (position < contents.length && contents.charCodeAt(position) > 32) { position++; } - tokens.push({ start, end: position, value: contents.slice(start, position) }); + tokens.push({ start, end: position, value: contents.slice(start, position), quoted: false }); } } return tokens; diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index bb9285f3a20c7..a951745479d2b 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -102,9 +102,13 @@ test("the CLI falls back to the WASI package", { }); assert.match(output, /^Version \d+\.\d+\.\d+/); - await writeFile(responseFile, `--noEmit\n"${sourceFile}"\n`); + const temporaryDirectory = path.join(directory, "tmp dir"); + await mkdir(temporaryDirectory); + await writeFile(responseFile, "@inner.rsp\n"); + await writeFile(path.join(directory, "inner.rsp"), `--noEmit\n"${sourceFile}"\n`); execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { cwd: directory, + env: { ...process.env, TMPDIR: temporaryDirectory, TEMP: temporaryDirectory, TMP: temporaryDirectory }, stdio: "inherit", }); } From cc17482f9dd27e359fe0bd682263d253a9c55e58 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:22:10 -0700 Subject: [PATCH 066/142] Serve virtual workspace files under the server's names The server names files in non-file workspaces after their escaped document URIs, so a document like vscode-vfs://github/o/r/a%20b.ts is read as .../a%20b.ts. WASI core joins that name onto the folder URI literally, which looks for a file actually named "a%20b.ts", and its directory listings return decoded names that never match what the server derived from open documents. Since the web extension always ships its own server, it can own that mapping: mount virtual folders through a private file system that turns the server's names back into workspace URIs and names directory entries the way the server would. --- .../vscode-typescript/src/extension.web.ts | 76 +++++++++++++++++-- .../vscode-typescript/src/webMountPoint.ts | 75 ++++++++++++++++-- .../test/webMountPoint.test.ts | 22 +++++- 3 files changed, 161 insertions(+), 12 deletions(-) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index fb40babf35a9e..f48f25e6e9468 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -14,7 +14,11 @@ import { type MessageTransports, } from "vscode-languageclient/browser"; import { nightlyExtensionId } from "./extensionIds"; -import { uriToWasmMountPoint } from "./webMountPoint"; +import { + uriSegmentToWasmName, + uriToWasmMountPoint, + wasmPathToUriText, +} from "./webMountPoint"; declare global { interface DedicatedWorkerGlobalScope { @@ -24,6 +28,62 @@ declare global { const wasmPackagePath = ["node_modules", "@typescript", "typescript-wasip1-wasm", "lib"] as const; +const wasmFileSystemScheme = "ts-wasi"; + +/** + * Serves virtual workspace files under the names the server derives from + * their document URIs, since WASI joins those names onto the folder URI + * literally and would otherwise miss files whose URIs contain escapes. + */ +class WasmFileSystemProvider implements vscode.FileSystemProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + readonly onDidChangeFile = this.changeEmitter.event; + + watch(): vscode.Disposable { + return new vscode.Disposable(() => {}); + } + + stat(uri: vscode.Uri): Thenable { + return vscode.workspace.fs.stat(toWorkspaceUri(uri)); + } + + async readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> { + const directory = toWorkspaceUri(uri); + const entries = await vscode.workspace.fs.readDirectory(directory); + return entries.map(([name, type]) => { + const text = vscode.Uri.joinPath(directory, name).toString(); + const segment = text.slice(text.lastIndexOf("/") + 1); + return [uriSegmentToWasmName(segment, (type & vscode.FileType.Directory) === 0), type]; + }); + } + + createDirectory(uri: vscode.Uri): Thenable { + return vscode.workspace.fs.createDirectory(toWorkspaceUri(uri)); + } + + readFile(uri: vscode.Uri): Thenable { + return vscode.workspace.fs.readFile(toWorkspaceUri(uri)); + } + + writeFile(uri: vscode.Uri, content: Uint8Array): Thenable { + return vscode.workspace.fs.writeFile(toWorkspaceUri(uri), content); + } + + delete(uri: vscode.Uri, options: { readonly recursive: boolean; }): Thenable { + return vscode.workspace.fs.delete(toWorkspaceUri(uri), options); + } + + rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { readonly overwrite: boolean; }): Thenable { + return vscode.workspace.fs.rename(toWorkspaceUri(oldUri), toWorkspaceUri(newUri), options); + } +} + +function toWorkspaceUri(uri: vscode.Uri): vscode.Uri { + const text = wasmPathToUriText(uri.path); + if (text === undefined) throw vscode.FileSystemError.FileNotFound(uri); + return vscode.Uri.parse(text, true); +} + class WasmMessageReader extends AbstractMessageReader { private readonly decoder = new TextDecoder(); private buffer = new Uint8Array(); @@ -127,6 +187,7 @@ export async function activate(context: vscode.ExtensionContext): Promise return new TextDecoder().decode(contents); }, })); + context.subscriptions.push(vscode.workspace.registerFileSystemProvider(wasmFileSystemScheme, new WasmFileSystemProvider(), { isCaseSensitive: true })); let process: WasmProcess | undefined; const stderrDecoder = new TextDecoder(); @@ -138,11 +199,14 @@ export async function activate(context: vscode.ExtensionContext): Promise ); } const module = wasm.compile(vscode.Uri.joinPath(modulePackageUri, "tsc.wasm")); - const mountPoints = (vscode.workspace.workspaceFolders ?? []).map(folder => ({ - kind: "vscodeFileSystem" as const, - uri: folder.uri, - mountPoint: uriToWasmMountPoint(folder.uri), - })); + const mountPoints = (vscode.workspace.workspaceFolders ?? []).map(folder => { + const mountPoint = uriToWasmMountPoint(folder.uri); + return { + kind: "vscodeFileSystem" as const, + uri: folder.uri.scheme === "file" ? folder.uri : vscode.Uri.from({ scheme: wasmFileSystemScheme, path: mountPoint }), + mountPoint, + }; + }); // Blocking stdin reads stall the Go scheduler, so the server cannot answer requests // while waiting for input. `@vscode/wasm-wasi` does not declare this option yet. const stdin = { kind: "pipeIn" as const, nonBlocking: true }; diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index 8daa67ceb941c..fe0c23ee31d49 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -41,20 +41,85 @@ export function uriToWasmMountPoint(uri: WebFileSystemUri): string { return `/${dynamicURIFileNamePrefix}${[scheme, authority, ...segments].join("/")}`; } +/** + * Maps a file name below a virtual workspace mount back to the URI the server + * derived it from, or returns undefined for names no document URI produces. + */ +export function wasmPathToUriText(path: string): string | undefined { + const prefix = `/${dynamicURIFileNamePrefix}`; + if (!path.startsWith(prefix)) return undefined; + const [scheme, authority, ...segments] = path.slice(prefix.length).split("/"); + if (!scheme || authority === undefined) return undefined; + let uriPath = ""; + for (let i = 0; i < segments.length; i++) { + const segment = decodeSegment(segments[i]); + if (segment === undefined) return undefined; + uriPath += i === 0 ? segment : `/${segment}`; + } + if (authority === nulAuthority) return `${scheme}:${uriPath}`; + const decodedAuthority = decodeSegment(authority); + return decodedAuthority === undefined ? undefined : `${scheme}://${decodedAuthority}/${uriPath}`; +} + +/** + * Names a directory entry as the server would name it within a document URI, + * given the entry's segment in the escaped URI text. + */ +export function uriSegmentToWasmName(segment: string, isFile: boolean): string { + return needsEncoding(segment) ? forceEncodeSegment(segment, isFile) : segment; +} + function isDriveRoot(segment: string): boolean { return /^[A-Za-z]:/.test(segment); } -function encodeSegment(segment: string): string { - const needsEncoding = segment === "" +function needsEncoding(segment: string): boolean { + return segment === "" || segment === "." || segment === ".." || segment.includes("\\") || escapePrefixes.some(prefix => segment.startsWith(prefix)); - return needsEncoding ? forceEncodeSegment(segment) : segment; } -function forceEncodeSegment(segment: string): string { +function encodeSegment(segment: string): string { + return needsEncoding(segment) ? forceEncodeSegment(segment) : segment; +} + +function forceEncodeSegment(segment: string, preserveExtension = false): string { + let extension = ""; + if (preserveExtension && segment !== "." && segment !== "..") { + extension = getExtension(segment.slice(segment.lastIndexOf("\\") + 1)); + segment = segment.slice(0, segment.length - extension.length); + } const hex = Array.from(new TextEncoder().encode(segment), byte => byte.toString(16).padStart(2, "0")).join(""); - return `${escapePrefixes[0]}${hex}~`; + return `${escapePrefixes[0]}${hex}~${extension}`; +} + +function getExtension(baseName: string): string { + const declaration = [".d.ts", ".d.cts", ".d.mts"].find(ext => baseName.endsWith(ext)); + if (declaration) return declaration; + if (baseName.endsWith(".ts") && baseName.includes(".d.")) return baseName.slice(baseName.indexOf(".d.")); + const dot = baseName.lastIndexOf("."); + return dot === -1 ? "" : baseName.slice(dot); +} + +const strictUtf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +function decodeSegment(segment: string): string | undefined { + if (!segment.startsWith(escapePrefixes[0])) { + return needsEncoding(segment) ? undefined : segment; + } + const encoded = segment.slice(escapePrefixes[0].length); + const end = encoded.indexOf("~"); + const hex = encoded.slice(0, end); + if (end === -1 || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return undefined; + let decoded: string; + try { + decoded = strictUtf8Decoder.decode(new Uint8Array(hex.match(/../g)?.map(byte => parseInt(byte, 16)) ?? [])); + } + catch { + return undefined; + } + // A NUL marks a query or fragment, which only open documents carry. + return decoded.includes("\0") ? undefined : decoded + encoded.slice(end + 1); } diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index 47834b68029b3..84c9e54b10e1c 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -3,7 +3,11 @@ import { describe, test, } from "node:test"; -import { uriToWasmMountPoint } from "../src/webMountPoint"; +import { + uriSegmentToWasmName, + uriToWasmMountPoint, + wasmPathToUriText, +} from "../src/webMountPoint"; describe("web WASI mount points", () => { test("maps POSIX file workspaces to their absolute paths", () => { @@ -35,4 +39,20 @@ describe("web WASI mount points", () => { assert.strictEqual(virtual("memfs:/work/a\\b/"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~"); assert.strictEqual(virtual("memfs:///x"), "/^/~ts-uri~/memfs/~ts-uri-escape~~/x"); }); + + test("maps virtual workspace file names back to their escaped URIs", () => { + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/vscode-vfs/github/o/r/a%20b.ts"), "vscode-vfs://github/o/r/a%20b.ts"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/vscode-test-web/mount"), "vscode-test-web://mount/"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~.ts"), "memfs:/work/a\\b.ts"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/~ts-uri-escape~~/x"), "memfs:///x"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/a\\b"), undefined); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/~ts-uri-escape~6100713d31~.ts"), undefined); + assert.strictEqual(wasmPathToUriText("/work/a.ts"), undefined); + }); + + test("names directory entries as the server names them in document URIs", () => { + assert.strictEqual(uriSegmentToWasmName("a%20b.ts", true), "a%20b.ts"); + assert.strictEqual(uriSegmentToWasmName("a\\b.d.ts", true), "~ts-uri-escape~615c62~.d.ts"); + assert.strictEqual(uriSegmentToWasmName("a\\b.d", false), "~ts-uri-escape~615c622e64~"); + }); }); From 1b820313cd1d6cf393796a041047d41226142fec Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:27:50 -0700 Subject: [PATCH 067/142] Stop the WASM file system ancestor walk at its root Writing below a missing root, such as C:/ in a fresh file system or / after it was removed, looped forever on the self-parenting root while holding the file system lock, freezing the synchronous WASM call. --- tsc/internal/api/wasmreactor/memfs.go | 10 ++++++---- tsc/internal/api/wasmreactor/reactor_test.go | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/tsc/internal/api/wasmreactor/memfs.go b/tsc/internal/api/wasmreactor/memfs.go index 9df68bbfbe9b6..a12acc2c47356 100644 --- a/tsc/internal/api/wasmreactor/memfs.go +++ b/tsc/internal/api/wasmreactor/memfs.go @@ -111,13 +111,15 @@ func (m *memFS) writeFile(path tspath.RootedFilePath, data string) error { file.modTime = time.Now() return nil } - for dir := path.Directory(); ; dir = dir.AsPath().Directory() { - if m.dirs[m.key(dir.AsString())] != nil { - break - } + for dir := path.Directory(); m.dirs[m.key(dir.AsString())] == nil; { if m.files[m.key(dir.AsString())] != nil { return &fs.PathError{Op: "write", Path: path.AsString(), Err: fs.ErrInvalid} } + parent := dir.AsPath().Directory() + if parent == dir { + break + } + dir = parent } name := path.BaseName() m.ensureDir(path.Directory()).files[m.key(name)] = name diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go index a473f6a13f07a..05baf30720494 100644 --- a/tsc/internal/api/wasmreactor/reactor_test.go +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -81,4 +81,10 @@ func TestMemFS(t *testing.T) { assert.Assert(t, m.WriteFile("/src/index.ts/nested/out.js", "d") != nil) assert.Assert(t, !m.DirectoryExists("/src/index.ts")) + + assert.NilError(t, m.WriteFile("C:/src/index.ts", "e")) + assert.Assert(t, m.DirectoryExists("C:/")) + assert.NilError(t, m.Remove("/")) + assert.NilError(t, m.WriteFile("/src/index.ts", "f")) + assert.Assert(t, m.DirectoryExists("/")) } From 75670bc048904e5db31038ae08a051b8a997d051 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:28:45 -0700 Subject: [PATCH 068/142] Decode BOM-marked response files before translating them The compiler accepts response files with a UTF-8 BOM or in BOM-marked UTF-16, but the Windows WASI fallback read them as plain UTF-8. The BOM stuck to the first argument, so an absolute path there was never translated, and UTF-16 files were mangled entirely. --- packages/typescript/lib/tsc.js | 9 ++++++++- packages/typescript/test/version.test.ts | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 9a3e03e608aaf..8ef9e04a6807a 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -112,6 +112,13 @@ async function getWasiPaths(wasmPath) { const translatePath = arg => { return path.isAbsolute(arg) ? toGuestPath(arg) : arg; }; + const decodeResponseFile = bytes => { + // Match the compiler, which accepts UTF-16 files marked with a BOM. + const encoding = bytes[0] === 0xFF && bytes[1] === 0xFE ? "utf-16le" + : bytes[0] === 0xFE && bytes[1] === 0xFF ? "utf-16be" + : "utf-8"; + return new TextDecoder(encoding).decode(bytes); + }; const responseFiles = new Map(); let responseDirectory; const translateResponseFile = async hostPath => { @@ -126,7 +133,7 @@ async function getWasiPaths(wasmPath) { responseFiles.set(key, guestPath); let contents; try { - contents = await readFile(hostPath, "utf8"); + contents = decodeResponseFile(await readFile(hostPath)); } catch { const fallbackPath = toGuestPath(hostPath); diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index a951745479d2b..678e8c181f4fd 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -104,8 +104,8 @@ test("the CLI falls back to the WASI package", { const temporaryDirectory = path.join(directory, "tmp dir"); await mkdir(temporaryDirectory); - await writeFile(responseFile, "@inner.rsp\n"); - await writeFile(path.join(directory, "inner.rsp"), `--noEmit\n"${sourceFile}"\n`); + await writeFile(responseFile, "\uFEFF@inner.rsp\n"); + await writeFile(path.join(directory, "inner.rsp"), Buffer.from(`\uFEFF"${sourceFile}"\n--noEmit\n`, "utf16le")); execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { cwd: directory, env: { ...process.env, TMPDIR: temporaryDirectory, TEMP: temporaryDirectory, TMP: temporaryDirectory }, From 12de6b0e78f5fe2ac9a3e6b4694bfe838db19cce Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:56:16 -0700 Subject: [PATCH 069/142] Report invalid file names from WASM set_file and remove_file An empty or otherwise invalid path panics while rooting it, and without a recovery boundary that panic kills the Go runtime, leaving the transport unusable for every later request. read_file already turns such panics into failures. --- packages/typescript/test/sync/wasm.test.ts | 3 +++ tsc/cmd/tsc/wasmapi_wasip1.go | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 5f15880d8ad05..86319b106c7c9 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -297,6 +297,9 @@ describe("API over WebAssembly", () => { transport.setFile("/raw.ts", rawContent); assert.strictEqual(transport.readFile("/raw.ts"), rawContent); transport.removeFile("/raw.ts"); + assert.throws(() => transport.setFile("", "text"), /panic setting file/); + assert.throws(() => transport.removeFile(""), /panic removing file/); + assert.throws(() => transport.readFile(""), /panic reading file/); transport.setFile("/tsconfig.json", `{ "compilerOptions": { "emitBOM": true } }`); transport.setFile("/src/index.ts", "export const value = 42 as const;"); diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index 4075a23874e50..3556ae4ec6a41 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -133,7 +133,7 @@ func handleRequest(methodLen uint32, payloadLen uint32) (status uint32) { } //go:wasmexport set_file -func setFile(pathLen uint32, contentLen uint32) uint32 { +func setFile(pathLen uint32, contentLen uint32) (status uint32) { if inCall { return fail("re-entrant set_file: a request is already in flight") } @@ -143,6 +143,11 @@ func setFile(pathLen uint32, contentLen uint32) uint32 { if !withinRequest(pathLen, contentLen) { return fail("invalid set_file lengths: path + content exceeds the request buffer") } + defer func() { + if recovered := recover(); recovered != nil { + status = fail(fmt.Sprintf("panic setting file: %v\n%s", recovered, debug.Stack())) + } + }() path := string(requestBuffer[:pathLen]) content := string(requestBuffer[pathLen : pathLen+contentLen]) if err := reactor.SetFile(path, content); err != nil { @@ -178,7 +183,7 @@ func readFile(pathLen uint32) (status uint32) { } //go:wasmexport remove_file -func removeFile(pathLen uint32) uint32 { +func removeFile(pathLen uint32) (status uint32) { if inCall { return fail("re-entrant remove_file: a request is already in flight") } @@ -188,6 +193,11 @@ func removeFile(pathLen uint32) uint32 { if !withinRequest(pathLen, 0) { return fail("invalid remove_file length: path exceeds the request buffer") } + defer func() { + if recovered := recover(); recovered != nil { + status = fail(fmt.Sprintf("panic removing file: %v\n%s", recovered, debug.Stack())) + } + }() if err := reactor.RemoveFile(string(requestBuffer[:pathLen])); err != nil { return fail(err.Error()) } From 6f743f8d0367724fca8fa90c558472464a2ea71d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:14:11 -0700 Subject: [PATCH 070/142] Restart the web language server when workspace folders change WASI mounts are fixed when the process starts, so adding a folder to a running workspace left the server unable to read its config files or any files that are not open. --- packages/vscode-typescript/src/extension.web.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index f48f25e6e9468..2188cf8efcf18 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -210,6 +210,7 @@ export async function activate(context: vscode.ExtensionContext): Promise // Blocking stdin reads stall the Go scheduler, so the server cannot answer requests // while waiting for input. `@vscode/wasm-wasi` does not declare this option yet. const stdin = { kind: "pipeIn" as const, nonBlocking: true }; + await process?.terminate(); process = await wasm.createProcess("tsc", module, { args: ["--lsp", "--stdio"], env: { PWD: "/" }, @@ -255,6 +256,14 @@ export async function activate(context: vscode.ExtensionContext): Promise void process?.terminate(); }, }); + // WASI mounts are fixed when the process starts, so new folders need a new process. + let restarting = Promise.resolve(); + context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(() => { + restarting = restarting.then(async () => { + await client.stop(); + await client.start(); + }).catch(error => output.error(`Failed to restart TypeScript language server: ${String(error)}`)); + })); await client.start(); } From 3491ac7e092356c8d95b4f86d1b4ebddf89dbb3f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:23:26 -0700 Subject: [PATCH 071/142] Map Windows drives inside the WASI file system WASI paths have no drive roots, so the Windows fallback rewrote absolute paths on the command line and in response files to /mnt/. Paths the compiler found elsewhere, such as files listed in a tsconfig, were never rewritten and could not be read. Doing the mapping where the compiler touches the OS covers every path the same way, lets diagnostics show real Windows paths, and removes the need to rewrite arguments and response files at all. --- packages/typescript/lib/tsc.js | 176 ++++------------------- packages/typescript/test/version.test.ts | 9 +- tsc/internal/vfs/osvfs/os.go | 17 +-- tsc/internal/vfs/osvfs/ospath.go | 11 ++ tsc/internal/vfs/osvfs/ospath_wasip1.go | 40 ++++++ 5 files changed, 91 insertions(+), 162 deletions(-) create mode 100644 tsc/internal/vfs/osvfs/ospath.go create mode 100644 tsc/internal/vfs/osvfs/ospath_wasip1.go diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 8ef9e04a6807a..b09f2f52bcb38 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -2,14 +2,8 @@ import getExePath, { getWasmPath } from "#getExePath"; import { execFileSync } from "node:child_process"; -import { - mkdtemp, - readFile, - rm, - writeFile, -} from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; let exe; try { @@ -61,155 +55,35 @@ async function runWasi(wasmPath) { const { WASI } = await import("node:wasi"); - const { args, cwd, preopens, cleanup } = await getWasiPaths(wasmPath); - try { - const wasi = new WASI({ - version: "preview1", - args, - env: { ...process.env, PWD: cwd }, - preopens, - returnOnExit: true, - }); - const module = await WebAssembly.compile(await readFile(wasmPath)); - const instance = await WebAssembly.instantiate(module, { - wasi_snapshot_preview1: wasi.wasiImport, - }); - process.exitCode = wasi.start(instance); - } - finally { - await cleanup?.(); - } + 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); } -async function getWasiPaths(wasmPath) { - const cwd = process.cwd(); +function getWasiPaths(wasmPath) { + const args = [wasmPath, ...process.argv.slice(2)]; if (process.platform !== "win32") { - return { - args: [wasmPath, ...process.argv.slice(2)], - cwd, - preopens: { "/": "/" }, - }; + return { args, cwd: process.cwd(), preopens: { "/": "/" } }; } + // WASI paths have no drive roots, so the compiler maps each drive to + // /mnt/ when its working directory is drive-rooted. const preopens = {}; - const guestRoots = new Map(); - const getGuestRoot = hostRoot => { - const key = hostRoot.toLowerCase(); - let guestRoot = guestRoots.get(key); - if (!guestRoot) { - const drive = /^([a-z]):[\\/]$/i.exec(hostRoot); - guestRoot = drive ? `/mnt/${drive[1].toLowerCase()}` : `/mnt/root-${guestRoots.size}`; - guestRoots.set(key, guestRoot); - preopens[guestRoot] = hostRoot; - } - return guestRoot; - }; - const toGuestPath = hostPath => { - const root = path.parse(hostPath).root; - const relative = path.relative(root, hostPath).replaceAll("\\", "/"); - return path.posix.join(getGuestRoot(root), relative); - }; - const translatePath = arg => { - return path.isAbsolute(arg) ? toGuestPath(arg) : arg; - }; - const decodeResponseFile = bytes => { - // Match the compiler, which accepts UTF-16 files marked with a BOM. - const encoding = bytes[0] === 0xFF && bytes[1] === 0xFE ? "utf-16le" - : bytes[0] === 0xFE && bytes[1] === 0xFF ? "utf-16be" - : "utf-8"; - return new TextDecoder(encoding).decode(bytes); - }; - const responseFiles = new Map(); - let responseDirectory; - const translateResponseFile = async hostPath => { - const key = hostPath.toLowerCase(); - const existing = responseFiles.get(key); - if (existing) { - return existing; - } - responseDirectory ??= await mkdtemp(path.join(tmpdir(), "typescript-wasi-")); - const translatedPath = path.join(responseDirectory, `${responseFiles.size}.rsp`); - const guestPath = toGuestPath(translatedPath); - responseFiles.set(key, guestPath); - let contents; - try { - contents = decodeResponseFile(await readFile(hostPath)); - } - catch { - const fallbackPath = toGuestPath(hostPath); - responseFiles.set(key, fallbackPath); - return fallbackPath; - } - const tokens = parseResponseFile(contents); - if (tokens) { - const replacements = []; - for (const token of tokens) { - let value = await translateArgument(token.value); - if (!token.quoted && /\s/.test(value)) { - value = `"${value}"`; - } - replacements.push({ ...token, value }); - } - for (let i = replacements.length - 1; i >= 0; i--) { - const replacement = replacements[i]; - contents = contents.slice(0, replacement.start) + replacement.value + contents.slice(replacement.end); - } - } - await writeFile(translatedPath, contents); - return guestPath; - }; - const translateArgument = async arg => { - if (arg.startsWith("@") && arg.length > 1) { - return `@${await translateResponseFile(path.resolve(cwd, arg.slice(1)))}`; - } - return translatePath(arg); - }; - const args = []; - for (const arg of process.argv.slice(2)) { - args.push(await translateArgument(arg)); - } - - return { - args: [ - toGuestPath(wasmPath), - ...args, - ], - cwd: toGuestPath(cwd), - preopens, - cleanup: responseDirectory - ? () => rm(responseDirectory, { recursive: true, force: true }) - : undefined, - }; -} - -function parseResponseFile(contents) { - const tokens = []; - let position = 0; - while (position < contents.length) { - while (position < contents.length && contents.charCodeAt(position) <= 32) { - position++; - } - if (position >= contents.length) { - break; - } - if (contents[position] === '"') { - const start = ++position; - while (position < contents.length && contents[position] !== '"') { - position++; - } - if (position >= contents.length) { - return undefined; - } - tokens.push({ start, end: position, value: contents.slice(start, position), quoted: true }); - position++; - } - else { - const start = position; - while (position < contents.length && contents.charCodeAt(position) > 32) { - position++; - } - tokens.push({ start, end: position, value: contents.slice(start, position), quoted: false }); + for (let code = 97; code <= 122; code++) { + const drive = `${String.fromCharCode(code)}:\\`; + if (existsSync(drive)) { + preopens[`/mnt/${drive[0]}`] = drive; } } - return tokens; + return { args, cwd: process.cwd().replaceAll("\\", "/"), preopens }; } diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index 678e8c181f4fd..5fa82e953dd97 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -102,13 +102,16 @@ test("the CLI falls back to the WASI package", { }); assert.match(output, /^Version \d+\.\d+\.\d+/); - const temporaryDirectory = path.join(directory, "tmp dir"); - await mkdir(temporaryDirectory); await writeFile(responseFile, "\uFEFF@inner.rsp\n"); await writeFile(path.join(directory, "inner.rsp"), Buffer.from(`\uFEFF"${sourceFile}"\n--noEmit\n`, "utf16le")); execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { cwd: directory, - env: { ...process.env, TMPDIR: temporaryDirectory, TEMP: temporaryDirectory, TMP: temporaryDirectory }, + stdio: "inherit", + }); + + await writeFile(path.join(directory, "tsconfig.json"), JSON.stringify({ files: [sourceFile], compilerOptions: { noEmit: true } })); + execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), "-p", "."], { + cwd: directory, stdio: "inherit", }); } diff --git a/tsc/internal/vfs/osvfs/os.go b/tsc/internal/vfs/osvfs/os.go index 6ad2eb1837f29..bcd1f46bc7400 100644 --- a/tsc/internal/vfs/osvfs/os.go +++ b/tsc/internal/vfs/osvfs/os.go @@ -3,6 +3,7 @@ package osvfs import ( "errors" "fmt" + "io/fs" "os" "path/filepath" "runtime" @@ -34,7 +35,7 @@ func FS() vfs.FS { var osVFS vfs.FS = &osFS{ common: internal.Common{ - RootFor: os.DirFS, + RootFor: func(root string) fs.FS { return os.DirFS(toOSPath(root)) }, IsReparsePoint: isReparsePoint, }, } @@ -144,7 +145,7 @@ func osFSRealpath(path tspath.RootedPath) tspath.RootedPath { if nativepath.IsNetworkPath(orig.AsString()) { return orig } - nativePath := filepath.FromSlash(path.AsString()) + nativePath := filepath.FromSlash(toOSPath(path.AsString())) nativePath, err := nativepath.Realpath(nativePath) if err != nil { return orig @@ -158,20 +159,20 @@ func osFSRealpath(path tspath.RootedPath) tspath.RootedPath { if nativepath.IsNetworkPath(nativePath) { return orig } - return tspath.RootedPathFromAbsolute(nativePath) + return tspath.RootedPathFromAbsolute(fromOSPath(nativePath)) } func isReparsePoint(path string) bool { if nativepath.IsNetworkPath(path) { return false } - return nativepath.IsSymlinkOrReparsePoint(filepath.FromSlash(path)) + return nativepath.IsSymlinkOrReparsePoint(filepath.FromSlash(toOSPath(path))) } func (vfs *osFS) writeFileWithFlag(path string, content string, flag int) error { defer writeSema.Acquire()() - file, err := os.OpenFile(path, flag, 0o666) + file, err := os.OpenFile(toOSPath(path), flag, 0o666) if err != nil { return err } @@ -186,7 +187,7 @@ func (vfs *osFS) writeFileWithFlag(path string, content string, flag int) error func (vfs *osFS) ensureDirectoryExists(directoryPath string) error { defer blockingOpSema.Acquire()() - return os.MkdirAll(directoryPath, 0o777) + return os.MkdirAll(toOSPath(directoryPath), 0o777) } func (vfs *osFS) writeFileEnsuringDir(path tspath.RootedFilePath, content string, flag int) error { @@ -217,7 +218,7 @@ func (vfs *osFS) Remove(path tspath.RootedPath) error { } defer blockingOpSema.Acquire()() // todo: #701 add retry mechanism? - return os.RemoveAll(path.AsString()) + return os.RemoveAll(toOSPath(path.AsString())) } func (vfs *osFS) Chtimes(path tspath.RootedPath, aTime time.Time, mTime time.Time) error { @@ -225,7 +226,7 @@ func (vfs *osFS) Chtimes(path tspath.RootedPath, aTime time.Time, mTime time.Tim return errNetworkPath } defer blockingOpSema.Acquire()() - return os.Chtimes(path.AsString(), aTime, mTime) + return os.Chtimes(toOSPath(path.AsString()), aTime, mTime) } func GetGlobalTypingsCacheLocation() tspath.RootedDirectoryPath { diff --git a/tsc/internal/vfs/osvfs/ospath.go b/tsc/internal/vfs/osvfs/ospath.go new file mode 100644 index 0000000000000..357ec45cf4e18 --- /dev/null +++ b/tsc/internal/vfs/osvfs/ospath.go @@ -0,0 +1,11 @@ +//go:build !wasip1 + +package osvfs + +func toOSPath(path string) string { + return path +} + +func fromOSPath(path string) string { + return path +} diff --git a/tsc/internal/vfs/osvfs/ospath_wasip1.go b/tsc/internal/vfs/osvfs/ospath_wasip1.go new file mode 100644 index 0000000000000..a4863ded74eac --- /dev/null +++ b/tsc/internal/vfs/osvfs/ospath_wasip1.go @@ -0,0 +1,40 @@ +//go:build wasip1 + +package osvfs + +import ( + "os" + "strings" +) + +// A Windows host runs the compiler with a drive-rooted working directory and +// preopens each drive at /mnt/, since WASI paths have no drive roots. +var mapDrives = isDriveRooted(os.Getenv("PWD")) + +const driveMountPrefix = "/mnt/" + +func toOSPath(path string) string { + if !mapDrives || !isDriveRooted(path) { + return path + } + return driveMountPrefix + strings.ToLower(path[:1]) + path[2:] +} + +func fromOSPath(path string) string { + rest, ok := strings.CutPrefix(path, driveMountPrefix) + if !mapDrives || !ok || len(rest) == 0 || !isDriveLetter(rest[0]) || len(rest) > 1 && rest[1] != '/' { + return path + } + if len(rest) == 1 { + rest += "/" + } + return strings.ToUpper(rest[:1]) + ":" + rest[1:] +} + +func isDriveRooted(path string) bool { + return len(path) >= 3 && isDriveLetter(path[0]) && path[1] == ':' && (path[2] == '/' || path[2] == '\\') +} + +func isDriveLetter(char byte) bool { + return 'a' <= char && char <= 'z' || 'A' <= char && char <= 'Z' +} From b33a5915ea3cb9dd1827ac1039d3efbe7339cc87 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:48:17 -0700 Subject: [PATCH 072/142] Stop declaring #getExePath in the WASI fallback test package tsc.js now imports getExePath by relative path, so the test's stand-in package no longer needs an imports map. --- packages/typescript/test/version.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index 3767edbbcd883..904e30e44c0f2 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -53,7 +53,6 @@ test("the CLI falls back to the WASI package", { version: "0.0.0", type: "module", bin: { tsc: "./lib/tsc.js" }, - imports: { "#getExePath": "./lib/getExePath.js" }, }), ), ]); From 26add571b04045fc032edca90cbc010b2f479abb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:48:17 -0700 Subject: [PATCH 073/142] Regenerate the sync API after merging main The merge moved the client import in the async API, which the generated sync API mirrors. --- packages/typescript/src/api/sync/api.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 875783155b157..9746cc073093c 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -15,6 +15,11 @@ import { cacheGeneratorMethod, } from "./generatorSupport.ts"; +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "#syncClient"; import { CheckFlags } from "../../../enums/checkFlags.js"; import { CompletionItemKind } from "../../../enums/completionItemKind.js"; import { DiagnosticCategory } from "../../../enums/diagnosticCategory.js"; @@ -35,11 +40,6 @@ import { SymbolOwnerKind } from "../../../enums/symbolOwnerKind.js"; import { TypeFlags } from "../../../enums/typeFlags.js"; import { TypeFormatFlags } from "../../../enums/typeFormatFlags.js"; import { TypePredicateKind } from "../../../enums/typePredicateKind.js"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "#syncClient"; import { type __String, type CallLikeExpression, From f6f4acbb220e3e0778435dddf800be4318d7a875 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:37:05 -0700 Subject: [PATCH 074/142] Load Node modules lazily in the API The API client is meant to work outside Node when given a transport, but its option handling, filesystem helpers, timing dumps and sync channel imported Node modules at load time, so importing any of them outside Node failed before a transport could be used. Load Node modules through process.getBuiltinModule only when spawning or connecting. getExePath now receives the Node modules it uses instead of importing them, so the CLI can keep passing static imports on older Node versions while the API passes modules obtained at runtime. --- packages/typescript/lib/getExePath.d.ts | 10 +- packages/typescript/lib/getExePath.js | 24 ++--- packages/typescript/lib/tsc.js | 11 ++- .../typescript/src/api/async/browserClient.ts | 22 ----- packages/typescript/src/api/fs.ts | 12 ++- packages/typescript/src/api/nodeProcess.ts | 24 +++++ packages/typescript/src/api/options.ts | 16 ++-- .../typescript/src/api/sync/browserClient.ts | 22 ----- packages/typescript/src/api/syncChannel.ts | 95 ++++++++++--------- packages/typescript/src/api/timingDump.ts | 18 ++-- packages/typescript/test/version.test.ts | 6 +- 11 files changed, 132 insertions(+), 128 deletions(-) delete mode 100644 packages/typescript/src/api/async/browserClient.ts create mode 100644 packages/typescript/src/api/nodeProcess.ts delete mode 100644 packages/typescript/src/api/sync/browserClient.ts diff --git a/packages/typescript/lib/getExePath.d.ts b/packages/typescript/lib/getExePath.d.ts index 9efbab990f9b8..d76a8ccc3dfbb 100644 --- a/packages/typescript/lib/getExePath.d.ts +++ b/packages/typescript/lib/getExePath.d.ts @@ -1,3 +1,9 @@ -declare function getExePath(): string; +export interface NodeModules { + fs: typeof import("node:fs"); + module: typeof import("node:module"); + path: typeof import("node:path"); + url: typeof import("node:url"); +} +declare function getExePath(modules: NodeModules): string; export default getExePath; -export declare function getWasmPath(): string; +export declare function getWasmPath(modules: NodeModules): string; diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index 7d6cbdd9392d5..ab98049508889 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -1,11 +1,11 @@ -import fs from "node:fs"; -import module from "node:module"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; +// Node modules are passed in rather than imported so that this file can be +// loaded in any environment; callers choose how to obtain them. +/** @typedef {import("./getExePath.d.ts").NodeModules} NodeModules */ // NOTE: Keep VS Code extension's resolveTsdkPathToExe in sync with this function. -export default function getExePath() { - const { __dirname, normalizedDirname, pkg } = getPackageInfo(); +/** @param {NodeModules} modules */ +export default function getExePath({ fs, module, path, url }) { + const { __dirname, normalizedDirname, pkg } = getPackageInfo({ fs, path, url }); const pkgName = pkg.name; const baseName = pkgName.startsWith("@") ? pkgName.split("/")[1] : pkgName; const expectedBinName = baseName === "typescript" ? "tsc" : "tsgo"; @@ -42,7 +42,7 @@ export default function getExePath() { else { // v20.6.0, v18.19.0 const packageJson = import.meta.resolve(platformPackageName + "/package.json"); - const packageJsonPath = fileURLToPath(packageJson); + const packageJsonPath = url.fileURLToPath(packageJson); exeDir = path.join(path.dirname(packageJsonPath), "lib"); } } @@ -66,8 +66,9 @@ export default function getExePath() { return exe; } -export function getWasmPath() { - const { __dirname, normalizedDirname, pkg } = getPackageInfo(); +/** @param {NodeModules} modules */ +export function getWasmPath({ fs, module, path, url }) { + const { __dirname, normalizedDirname, pkg } = getPackageInfo({ fs, path, url }); let wasmDir; if ( @@ -99,8 +100,9 @@ export function getWasmPath() { return wasmPath; } -function getPackageInfo() { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); +/** @param {Pick} modules */ +function getPackageInfo({ fs, path, url }) { + const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); const normalizedDirname = __dirname.replace(/\\/g, "/"); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); return { __dirname, normalizedDirname, pkg }; diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 7457ec826c1ca..433291e60f537 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -1,18 +1,23 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import fs, { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import nodeModule from "node:module"; +import path from "node:path"; +import url from "node:url"; import getExePath, { getWasmPath } from "./getExePath.js"; +const nodeModules = { fs, module: nodeModule, path, url }; + let exe; try { - exe = getExePath(); + exe = getExePath(nodeModules); } catch (nativeError) { let wasmPath; try { - wasmPath = getWasmPath(); + wasmPath = getWasmPath(nodeModules); } catch (wasmError) { if (wasmError?.code === "ERR_TYPESCRIPT_WASM_PACKAGE") { diff --git a/packages/typescript/src/api/async/browserClient.ts b/packages/typescript/src/api/async/browserClient.ts deleted file mode 100644 index 439ea4dc3d521..0000000000000 --- a/packages/typescript/src/api/async/browserClient.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { - AsyncClientOptions, - ClientSocketOptions, - ClientSpawnOptions, -} from "../options.ts"; -import { - createTransportClient, - type TransportClient, -} from "./transportClient.ts"; - -export type { ClientSocketOptions, ClientSpawnOptions }; -export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions } from "../options.ts"; -export type { AsyncTransport } from "./transport.ts"; - -export type Client = TransportClient; - -export function createClient(options: AsyncClientOptions): Client { - if (!("transport" in options)) { - throw new Error("The browser async API requires an injected transport"); - } - return createTransportClient(options); -} diff --git a/packages/typescript/src/api/fs.ts b/packages/typescript/src/api/fs.ts index e1433d36747e7..095c53a81df1b 100644 --- a/packages/typescript/src/api/fs.ts +++ b/packages/typescript/src/api/fs.ts @@ -1,11 +1,13 @@ -import { dirname } from "node:path"; -import getExePath from "../../lib/getExePath.js"; import type { RootedDirectoryPath, RootedFilePath, RootedPath, } from "../ast/index.ts"; import { serverFS } from "./fsCallbacks.ts"; +import { + getDefaultExePath, + nodeProcess, +} from "./nodeProcess.ts"; import { normalizePath } from "./path.ts"; import type { RequestDirectoryEntries, @@ -121,11 +123,11 @@ export function createFileSystemWithLib( : [normalizePath("bundled:///libs")]; if (!options.defaultLibraryPath) { try { - defaultLibraryPaths.push(normalizePath(dirname(getExePath()))); + defaultLibraryPaths.push(normalizePath(nodeProcess().getBuiltinModule("node:path").dirname(getDefaultExePath()))); } catch { - // A socket-connected embedded server can provide bundled libs without - // a locally installed compiler executable. + // A socket-connected embedded server or a WebAssembly transport can + // provide bundled libs without a locally installed compiler executable. } } const symlinks = { ...options.symlinks }; diff --git a/packages/typescript/src/api/nodeProcess.ts b/packages/typescript/src/api/nodeProcess.ts new file mode 100644 index 0000000000000..f958866a333af --- /dev/null +++ b/packages/typescript/src/api/nodeProcess.ts @@ -0,0 +1,24 @@ +import getExePath from "../../lib/getExePath.js"; + +/** + * Returns `process` when running in Node.js with `process.getBuiltinModule`. + * Node modules are loaded through it so that the API can also be loaded in + * environments without them, where a transport must be provided instead. + */ +export function nodeProcess(): NodeJS.Process { + const process = (globalThis as { process?: Partial; }).process; + if (typeof process?.getBuiltinModule !== "function") { + throw new Error("Starting or connecting to a TypeScript API server requires Node.js 20.16 or later; in other environments, provide a transport."); + } + return process as NodeJS.Process; +} + +export function getDefaultExePath(): string { + const process = nodeProcess(); + return getExePath({ + fs: process.getBuiltinModule("node:fs"), + module: process.getBuiltinModule("node:module"), + path: process.getBuiltinModule("node:path"), + url: process.getBuiltinModule("node:url"), + }); +} diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 6549d58698fc0..1081ec01af653 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -2,11 +2,12 @@ * Shared utilities for the TypeScript API client. */ -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import getExePath from "../../lib/getExePath.js"; import type { AsyncTransport } from "./async/transport.ts"; import type { FileSystemCallbacks } from "./fs.ts"; +import { + getDefaultExePath, + nodeProcess, +} from "./nodeProcess.ts"; import type { SyncTransport } from "./sync/transport.ts"; export interface ClientSocketOptions { @@ -92,13 +93,13 @@ export function isAsyncTransportOptions(options: AsyncClientOptions): options is } export function resolveExePath(options: ClientSpawnOptions): string { - return options.tsserverPath ?? getExePath(); + return options.tsserverPath ?? getDefaultExePath(); } export function getAPIProcessArgs(options: ClientSpawnOptions, async: boolean): string[] { const args = ["--api"]; if (async) args.push("--async"); - args.push("--cwd", options.cwd ?? process.cwd()); + args.push("--cwd", options.cwd ?? nodeProcess().cwd()); args.push(`--useCaseSensitiveFileNames=${options.useCaseSensitiveFileNames ?? inferUseCaseSensitiveFileNames()}`); if (options.runExternalCode) args.push("--runExternalCode"); if (options.collectTiming) args.push("--timing"); @@ -106,11 +107,12 @@ export function getAPIProcessArgs(options: ClientSpawnOptions, async: boolean): } function inferUseCaseSensitiveFileNames(): boolean { + const process = nodeProcess(); if (process.platform === "win32") { return false; } - const fileName = fileURLToPath(import.meta.url); - return !existsSync(fileName.replace(/\w/g, char => char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase())); + const fileName = process.getBuiltinModule("node:url").fileURLToPath(import.meta.url); + return !process.getBuiltinModule("node:fs").existsSync(fileName.replace(/\w/g, char => char === char.toUpperCase() ? char.toLowerCase() : char.toUpperCase())); } export interface LSPConnectionOptions extends ClientSocketOptions { diff --git a/packages/typescript/src/api/sync/browserClient.ts b/packages/typescript/src/api/sync/browserClient.ts deleted file mode 100644 index 17c493d1b20ff..0000000000000 --- a/packages/typescript/src/api/sync/browserClient.ts +++ /dev/null @@ -1,22 +0,0 @@ -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/syncChannel.ts b/packages/typescript/src/api/syncChannel.ts index 4076d907578b2..880c963a66f48 100644 --- a/packages/typescript/src/api/syncChannel.ts +++ b/packages/typescript/src/api/syncChannel.ts @@ -10,17 +10,7 @@ * ChildProcess. */ -import { - type ChildProcess, - spawn, -} from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { - closeSync, - openSync, - readSync, - writeSync, -} from "node:fs"; +import type { ChildProcess } from "node:child_process"; import type { Readable, Writable, @@ -50,6 +40,7 @@ import { MSGPACK_UINT8, writeBinHeader, } from "./node/msgpack.ts"; +import { nodeProcess } from "./nodeProcess.ts"; // ── MessageType constants ──────────────────────────────────────────── // Sent by channel (parent → child) @@ -61,34 +52,44 @@ const MSG_RESPONSE = 4; const MSG_ERROR = 5; const MSG_CALL = 6; -// Pre-allocated buffer used by Atomics.wait for tiny sleeps when a -// non-blocking fd returns EAGAIN. -const sleepBuf = new Int32Array(new SharedArrayBuffer(4)); +// Module state is created on first use so that this module can be loaded +// outside Node.js. -// Shared empty buffer – avoids allocating Buffer.alloc(0) on every -// zero-length bin field. -const EMPTY_BUF = Buffer.alloc(0); +// Buffer used by Atomics.wait for tiny sleeps when a non-blocking fd +// returns EAGAIN. +let sleepBuf: Int32Array | undefined; + +function sleep(ms: number): void { + Atomics.wait(sleepBuf ??= new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} // ── Global cleanup tracking ───────────────────────────────────────── // Track all live child processes so they can be killed on process exit. // This mimics the auto-cleanup behavior of the native libsyncrpc module, // whose Rust/C++ destructors would kill children automatically. -const liveChildren = new Set(); +let liveChildren: Set | undefined; -process.on("exit", () => { - for (const child of liveChildren) { - try { - child.kill(); - } - catch { - // swallow – process may already be dead - } +function trackChild(process: NodeJS.Process, child: ChildProcess): void { + if (!liveChildren) { + const children = liveChildren = new Set(); + process.on("exit", () => { + for (const child of children) { + try { + child.kill(); + } + catch { + // swallow – process may already be dead + } + } + children.clear(); + }); } - liveChildren.clear(); -}); + liveChildren.add(child); +} export function createPipePath(): string { - return `\\\\.\\pipe\\tsgo-sync-${process.pid}-${randomBytes(16).toString("hex")}`; + const process = nodeProcess(); + return `\\\\.\\pipe\\tsgo-sync-${process.pid}-${process.getBuiltinModule("node:crypto").randomBytes(16).toString("hex")}`; } /** @@ -129,9 +130,15 @@ export class SyncRpcChannel { lastBytesSent = 0; lastBytesReceived = 0; + private readonly fs = nodeProcess().getBuiltinModule("node:fs"); + + // Shared empty buffer – avoids allocating Buffer.alloc(0) on every + // zero-length bin field. + private readonly emptyBuf = Buffer.alloc(0); + private _msgType = 0; - private _msgName: Buffer = EMPTY_BUF; - private _msgPayload: Buffer = EMPTY_BUF; + private _msgName: Buffer = this.emptyBuf; + private _msgPayload: Buffer = this.emptyBuf; private headerBuf = Buffer.allocUnsafe(4); @@ -145,6 +152,8 @@ export class SyncRpcChannel { constructor(exe: string, args: string[], collectTiming = false) { this.collectTiming = collectTiming; + const process = nodeProcess(); + const { spawn } = process.getBuiltinModule("node:child_process"); const isWindows = process.platform === "win32"; if (isWindows) { @@ -162,7 +171,7 @@ export class SyncRpcChannel { let fd: number | undefined; for (let i = 0; i < 500; i++) { try { - fd = openSync(pipePath, "r+"); + fd = this.fs.openSync(pipePath, "r+"); break; } catch { @@ -171,7 +180,7 @@ export class SyncRpcChannel { `Child process exited with code ${this.child.exitCode} before pipe was ready`, ); } - Atomics.wait(sleepBuf, 0, 0, 10); + sleep(10); } } if (fd === undefined) { @@ -220,7 +229,7 @@ export class SyncRpcChannel { } // Track for auto-cleanup on process exit. - liveChildren.add(this.child); + trackChild(process, this.child); this.child.unref(); } @@ -257,9 +266,9 @@ export class SyncRpcChannel { /** Kill the child process and release resources. */ close(): void { try { - liveChildren.delete(this.child); + liveChildren?.delete(this.child); if (this.pipeFd !== undefined) { - closeSync(this.pipeFd); + this.fs.closeSync(this.pipeFd); this.pipeFd = undefined; } // Destroy the stdio streams so that their pipe handles are closed @@ -509,7 +518,7 @@ export class SyncRpcChannel { `Expected binary data (0xc4-0xc6), received: 0x${marker.toString(16)}`, ); } - if (size === 0) return EMPTY_BUF; + if (size === 0) return this.emptyBuf; return this.readExact(size); } @@ -557,7 +566,7 @@ export class SyncRpcChannel { this.readBufLen = 0; for (;;) { try { - const n = readSync(this.readFd, this.readBuf, 0, this.readBuf.length, null); + const n = this.fs.readSync(this.readFd, this.readBuf, 0, this.readBuf.length, null); if (n === 0) { throw this.eofError(); } @@ -566,7 +575,7 @@ export class SyncRpcChannel { } catch (e: unknown) { if (e instanceof Error && ("code" in e) && ((e as NodeJS.ErrnoException).code === "EAGAIN" || (e as NodeJS.ErrnoException).code === "EWOULDBLOCK")) { - Atomics.wait(sleepBuf, 0, 0, 1); + sleep(1); continue; } throw e; @@ -595,7 +604,7 @@ export class SyncRpcChannel { // Remaining data is larger than read buffer; read directly // into the target to avoid unnecessary copying. try { - const n = readSync(this.readFd, buffer, pos, length - pos, null); + const n = this.fs.readSync(this.readFd, buffer, pos, length - pos, null); if (n === 0) { throw this.eofError(); } @@ -603,7 +612,7 @@ export class SyncRpcChannel { } catch (e: unknown) { if (e instanceof Error && ("code" in e) && ((e as NodeJS.ErrnoException).code === "EAGAIN" || (e as NodeJS.ErrnoException).code === "EWOULDBLOCK")) { - Atomics.wait(sleepBuf, 0, 0, 1); + sleep(1); continue; } throw e; @@ -625,12 +634,12 @@ export class SyncRpcChannel { let pos = 0; while (pos < total) { try { - const n = writeSync(this.writeFd, data, pos, total - pos); + const n = this.fs.writeSync(this.writeFd, data, pos, total - pos); pos += n; } catch (e: unknown) { if (e instanceof Error && ("code" in e) && ((e as NodeJS.ErrnoException).code === "EAGAIN" || (e as NodeJS.ErrnoException).code === "EWOULDBLOCK")) { - Atomics.wait(sleepBuf, 0, 0, 1); + sleep(1); continue; } throw e; diff --git a/packages/typescript/src/api/timingDump.ts b/packages/typescript/src/api/timingDump.ts index 6bb17ce13c413..597c4fa4d1795 100644 --- a/packages/typescript/src/api/timingDump.ts +++ b/packages/typescript/src/api/timingDump.ts @@ -1,12 +1,4 @@ -import { randomUUID } from "node:crypto"; -import { - mkdirSync, - writeFileSync, -} from "node:fs"; -import { - join, - resolve, -} from "node:path"; +import { nodeProcess } from "./nodeProcess.ts"; import { combineTimingInfo, type ServerTimingInfo, @@ -15,15 +7,16 @@ import { /** One final snapshot per client, written before its connection is closed. */ export class TimingDump { - private readonly instanceId = randomUUID(); + private readonly instanceId = nodeProcess().getBuiltinModule("node:crypto").randomUUID(); private readonly startedAt = new Date().toISOString(); private readonly directory: string; private readonly mode: "sync" | "async"; private readonly transport: "spawn" | "socket"; static fromEnvironment(mode: "sync" | "async", transport: "spawn" | "socket"): TimingDump | undefined { + const process = nodeProcess(); const directory = process.env.TS_API_TIMING_DIR; - return directory ? new TimingDump(resolve(directory), mode, transport) : undefined; + return directory ? new TimingDump(process.getBuiltinModule("node:path").resolve(directory), mode, transport) : undefined; } private constructor(directory: string, mode: "sync" | "async", transport: "spawn" | "socket") { @@ -33,6 +26,9 @@ export class TimingDump { } write(client: TimingInfo, server: ServerTimingInfo | undefined, serverTimingError?: string): void { + const process = nodeProcess(); + const { mkdirSync, writeFileSync } = process.getBuiltinModule("node:fs"); + const { join } = process.getBuiltinModule("node:path"); const snapshot = { schemaVersion: 1, instanceId: this.instanceId, diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index 904e30e44c0f2..163a8b769eaaa 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -4,7 +4,7 @@ import { execFileSync, spawnSync, } from "node:child_process"; -import { readFileSync } from "node:fs"; +import fs, { readFileSync } from "node:fs"; import { cp, mkdir, @@ -13,13 +13,15 @@ import { symlink, writeFile, } from "node:fs/promises"; +import module from "node:module"; import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; +import url from "node:url"; import getExePath from "../lib/getExePath.js"; test("versionMajorMinor runtime and declaration match the compiler version", () => { - const compilerVersion = execFileSync(getExePath(), ["--version"], { encoding: "utf8" }) + const compilerVersion = execFileSync(getExePath({ fs, module, path, url }), ["--version"], { encoding: "utf8" }) .trim() .replace(/^Version /, ""); const majorMinor = compilerVersion.split(".", 2).join("."); From 750dffda95d9c2bc5a01db6acc730b289a5f7793 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:37:06 -0700 Subject: [PATCH 075/142] Avoid loading vscode-jsonrpc's Node entry point vscode-jsonrpc's Node entry point requires Node modules when it loads, so even a lazy import of it makes bundlers targeting browsers fail. Use the library's environment-neutral core and provide the small Node runtime layer and stream adapters the async client needs. --- packages/typescript/src/api/async/client.ts | 27 ++--- packages/typescript/src/api/async/jsonrpc.ts | 118 +++++++++++++++++++ 2 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 packages/typescript/src/api/async/jsonrpc.ts diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 726d3cb60a908..705563d96c68e 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -1,14 +1,5 @@ import type { ChildProcess } from "node:child_process"; import type { Socket } from "node:net"; -import { - createMessageConnection, - type MessageConnection, - RequestType, - SocketMessageReader, - SocketMessageWriter, - StreamMessageReader, - StreamMessageWriter, -} from "../../../vendor/vscode-jsonrpc/lib/node/main.js"; import type { RootedDirectoryPath, RootedFilePath, @@ -20,6 +11,7 @@ import { encodeFileSystemCallbackResult, type FileSystemCallbackConfiguration, } from "../fsCallbacks.ts"; +import { nodeProcess } from "../nodeProcess.ts"; import { type AsyncClientOptions, type ClientOptions, @@ -46,6 +38,11 @@ import { type TimingInfo, } from "../timing.ts"; import { TimingDump } from "../timingDump.ts"; +import { + createStreamConnection, + type MessageConnection, + RequestType, +} from "./jsonrpc.ts"; import { createTransportClient, type TransportClient, @@ -111,7 +108,7 @@ class RpcClient { } private async connectViaSpawn(options: ClientSpawnOptions): Promise { - const { spawn } = await import("node:child_process"); + const { spawn } = nodeProcess().getBuiltinModule("node:child_process"); return new Promise((resolve, reject) => { const args = getAPIProcessArgs(options, true); @@ -134,22 +131,18 @@ class RpcClient { resolve(); }); - const reader = new StreamMessageReader(this.process.stdout!); - const writer = new StreamMessageWriter(this.process.stdin!); - this.connection = createMessageConnection(reader, writer); + this.connection = createStreamConnection(this.process.stdout!, this.process.stdin!); this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); this.connection.listen(); }); } private async connectViaSocket(options: ClientSocketOptions): Promise { - const { createConnection } = await import("node:net"); + const { createConnection } = nodeProcess().getBuiltinModule("node:net"); return new Promise((resolve, reject) => { this.socket = createConnection(options.pipe, () => { - const reader = new SocketMessageReader(this.socket!); - const writer = new SocketMessageWriter(this.socket!); - this.connection = createMessageConnection(reader, writer); + this.connection = createStreamConnection(this.socket!, this.socket!); this.connection.listen(); this.connected = true; resolve(); diff --git a/packages/typescript/src/api/async/jsonrpc.ts b/packages/typescript/src/api/async/jsonrpc.ts new file mode 100644 index 0000000000000..a8f68fbbace49 --- /dev/null +++ b/packages/typescript/src/api/async/jsonrpc.ts @@ -0,0 +1,118 @@ +// Only vscode-jsonrpc's environment-neutral core is imported here. Its Node +// entry point loads Node modules at import time, which would prevent this +// package from being loaded or bundled for other environments, so the Node +// runtime layer it would install is provided below instead. +import type { + Readable, + Writable, +} from "node:stream"; +import { + AbstractMessageBuffer, + createMessageConnection, + Disposable, + type MessageConnection, + NullLogger, + RAL, + ReadableStreamMessageReader, + WriteableStreamMessageWriter, +} from "../../../vendor/vscode-jsonrpc/lib/common/api.js"; + +export { type MessageConnection, RequestType } from "../../../vendor/vscode-jsonrpc/lib/common/api.js"; + +class MessageBuffer extends AbstractMessageBuffer { + protected emptyBuffer(): Uint8Array { + return Buffer.allocUnsafe(0); + } + + protected fromString(value: string, encoding: RAL.MessageBufferEncoding): Uint8Array { + return Buffer.from(value, encoding); + } + + protected toString(value: Uint8Array, encoding: RAL.MessageBufferEncoding): string { + return value instanceof Buffer ? value.toString(encoding) : new TextDecoder(encoding).decode(value); + } + + protected asNative(buffer: Uint8Array, length?: number): Uint8Array { + if (length === undefined) { + return buffer instanceof Buffer ? buffer : Buffer.from(buffer); + } + return buffer instanceof Buffer ? buffer.subarray(0, length) : Buffer.from(buffer.buffer, buffer.byteOffset, length); + } + + protected allocNative(length: number): Uint8Array { + return Buffer.allocUnsafe(length); + } +} + +function listen(stream: Readable | Writable, event: string, listener: (...args: any[]) => void): Disposable { + stream.on(event, listener); + return Disposable.create(() => stream.off(event, listener)); +} + +let installed = false; + +function installRuntime(): void { + if (installed) return; + installed = true; + RAL.install({ + messageBuffer: { + create: encoding => new MessageBuffer(encoding), + }, + applicationJson: { + encoder: { + name: "application/json", + encode: async (message, options) => Buffer.from(JSON.stringify(message, undefined, 0), options.charset), + }, + decoder: { + name: "application/json", + decode: async (buffer, options) => + JSON.parse( + buffer instanceof Buffer ? buffer.toString(options.charset) : new TextDecoder(options.charset).decode(buffer), + ), + }, + }, + console, + timer: { + setTimeout(callback, ms, ...args) { + const handle = setTimeout(callback, ms, ...args); + return { dispose: () => clearTimeout(handle) }; + }, + setImmediate(callback, ...args) { + const handle = setImmediate(callback, ...args); + return { dispose: () => clearImmediate(handle) }; + }, + setInterval(callback, ms, ...args) { + const handle = setInterval(callback, ms, ...args); + return { dispose: () => clearInterval(handle) }; + }, + }, + }); +} + +/** Creates a JSON-RPC connection over Node.js streams. */ +export function createStreamConnection(readable: Readable, writable: Writable): MessageConnection { + installRuntime(); + const reader = new ReadableStreamMessageReader({ + onClose: listener => listen(readable, "close", listener), + onError: listener => listen(readable, "error", listener), + onEnd: listener => listen(readable, "end", listener), + onData: listener => listen(readable, "data", listener), + }); + const writer = new WriteableStreamMessageWriter({ + onClose: listener => listen(writable, "close", listener), + onError: listener => listen(writable, "error", listener), + onEnd: listener => listen(writable, "end", listener), + write: (data: Uint8Array | string, encoding?: RAL.MessageBufferEncoding) => + new Promise((resolve, reject) => { + const callback = (error: Error | null | undefined) => error ? reject(error) : resolve(); + if (typeof data === "string") { + writable.write(data, encoding ?? "utf-8", callback); + } + else { + writable.write(data, callback); + } + }), + end: () => writable.end(), + }); + return createMessageConnection(reader, writer, NullLogger); +} From e9d2a7b91e0d85e6fdbf74e7d096a4f847448b4a Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:37:06 -0700 Subject: [PATCH 076/142] Use a single API client entry point on every platform With Node modules loaded only when spawning or connecting, the Node client works anywhere a transport is supplied, so the browser-specific client variants and the package imports that chose between them are no longer needed. --- packages/typescript/package.json | 18 ------------------ packages/typescript/src/api/async/api.ts | 12 ++++++------ packages/typescript/src/api/sync/api.ts | 10 +++++----- packages/typescript/test/browser/smoke.test.ts | 8 ++++---- 4 files changed, 15 insertions(+), 33 deletions(-) diff --git a/packages/typescript/package.json b/packages/typescript/package.json index 0ce88a6853407..baf26ccd4d297 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -107,24 +107,6 @@ "default": "./dist/ast/clone.js" } }, - "imports": { - "#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" - } - }, "scripts": { "node": "node --conditions @typescript/source", "generate": "npm run node -- scripts/generate.ts", diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 1f8b050698dff..3a5f0d9d41819 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1,9 +1,3 @@ -import { - type Client, // @sync: Client, - type ClientSocketOptions, - type ClientSpawnOptions, - createClient, // @sync-skip -} from "#asyncClient"; // @sync: } from "#syncClient"; import { CheckFlags } from "../../../enums/checkFlags.js"; import { CompletionItemKind } from "../../../enums/completionItemKind.js"; import { DiagnosticCategory } from "../../../enums/diagnosticCategory.js"; @@ -143,6 +137,12 @@ import type { TimingCollector, TimingInfo, } from "../timing.ts"; +import { + type Client, // @sync: Client, + type ClientSocketOptions, + type ClientSpawnOptions, + createClient, // @sync-skip +} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, diff --git a/packages/typescript/src/api/sync/api.ts b/packages/typescript/src/api/sync/api.ts index 9746cc073093c..59e6be31400ac 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -15,11 +15,6 @@ import { cacheGeneratorMethod, } from "./generatorSupport.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "#syncClient"; import { CheckFlags } from "../../../enums/checkFlags.js"; import { CompletionItemKind } from "../../../enums/completionItemKind.js"; import { DiagnosticCategory } from "../../../enums/diagnosticCategory.js"; @@ -159,6 +154,11 @@ import type { TimingCollector, TimingInfo, } from "../timing.ts"; +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, diff --git a/packages/typescript/test/browser/smoke.test.ts b/packages/typescript/test/browser/smoke.test.ts index 7987116feb0eb..bd9796e8411a3 100644 --- a/packages/typescript/test/browser/smoke.test.ts +++ b/packages/typescript/test/browser/smoke.test.ts @@ -26,6 +26,7 @@ describe("API over WebAssembly in a browser", () => { import { API as AsyncAPI } from "typescript/async"; import { API as SyncAPI } from "typescript/sync"; import { instantiateWasm, WasmTransport } from "typescript/unstable/wasm"; + import { createFileSystemWithLib } from "typescript/fs"; async function check(API, module) { const instance = await instantiateWasm(module); @@ -57,6 +58,7 @@ describe("API over WebAssembly in a browser", () => { return [ await check(AsyncAPI, module), await check(SyncAPI, module), + Object.keys(createFileSystemWithLib([]).symlinks).length, ]; } `, @@ -67,7 +69,7 @@ describe("API over WebAssembly in a browser", () => { typescript: "@typescript/typescript", }, bundle: true, - conditions: ["@typescript/source", "browser"], + conditions: ["@typescript/source"], format: "esm", platform: "browser", write: false, @@ -75,8 +77,6 @@ describe("API over WebAssembly in a browser", () => { 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/); const bundle = result.outputFiles[0].contents; const wasm = await readFile(wasmURL); @@ -132,7 +132,7 @@ describe("API over WebAssembly in a browser", () => { const page = await browser.newPage(); await page.goto(origin); const result = await page.evaluate(() => Reflect.get(globalThis, "result")); - assert.deepStrictEqual(result, ["42", "42"]); + assert.deepStrictEqual(result, ["42", "42", 1]); }); test("loads the transport entry without package resolution", async () => { From cff4d271a10614cf62552df0dc8353f41bffa807 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:50:31 -0700 Subject: [PATCH 077/142] Load the JSON-RPC adapter only when connecting vscode-jsonrpc ships only CommonJS, which browsers cannot import as modules, so loading the async API directly from its built files failed even when a transport was injected. Load the adapter only when the client spawns or connects to a server, which only happens in Node. The browser tests bundled the API, which hid this; also load the built entries directly from a server rooted at the package. --- packages/typescript/src/api/async/client.ts | 50 ++++---- .../typescript/test/browser/smoke.test.ts | 121 +++++++++++------- 2 files changed, 104 insertions(+), 67 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 705563d96c68e..cc99e6dbb1eb4 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -38,11 +38,7 @@ import { type TimingInfo, } from "../timing.ts"; import { TimingDump } from "../timingDump.ts"; -import { - createStreamConnection, - type MessageConnection, - RequestType, -} from "./jsonrpc.ts"; +import type * as JsonRpc from "./jsonrpc.ts"; import { createTransportClient, type TransportClient, @@ -64,7 +60,9 @@ export function createClient(options: AsyncClientOptions): Client { class RpcClient { private socket: Socket | undefined; private process: ChildProcess | undefined; - private connection: MessageConnection | undefined; + private connection: JsonRpc.MessageConnection | undefined; + // Loaded on connection so that injected-transport clients never load it. + private jsonrpc: typeof JsonRpc | undefined; private options: ClientOptions | undefined; private connected = false; private closed = false; @@ -99,6 +97,7 @@ class RpcClient { private async connectWorker(): Promise { if (!this.options) throw new Error("Client options are not available"); + this.jsonrpc ??= await import("./jsonrpc.ts"); if (isSpawnOptions(this.options)) { await this.connectViaSpawn(this.options); } @@ -131,7 +130,7 @@ class RpcClient { resolve(); }); - this.connection = createStreamConnection(this.process.stdout!, this.process.stdin!); + this.connection = this.jsonrpc!.createStreamConnection(this.process.stdout!, this.process.stdin!); this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); this.connection.listen(); }); @@ -142,7 +141,7 @@ class RpcClient { return new Promise((resolve, reject) => { this.socket = createConnection(options.pipe, () => { - this.connection = createStreamConnection(this.socket!, this.socket!); + this.connection = this.jsonrpc!.createStreamConnection(this.socket!, this.socket!); this.connection.listen(); this.connected = true; resolve(); @@ -155,7 +154,7 @@ class RpcClient { } private registerFSCallbacks( - connection: MessageConnection, + connection: JsonRpc.MessageConnection, fs: FileSystemCallbacks | undefined, configuration: FileSystemCallbackConfiguration, ): void { @@ -165,7 +164,7 @@ class RpcClient { case "readFile": { const callback = fs.readFile; if (typeof callback !== "function") throw new Error("Invalid readFile callback configuration"); - connection.onRequest(new RequestType(name), fileName => { + connection.onRequest(this.requestType(name), fileName => { return encodeFileSystemCallbackResult(name, callback(fileName)); }); break; @@ -173,7 +172,7 @@ class RpcClient { case "fileExists": { const callback = fs.fileExists; if (typeof callback !== "function") throw new Error("Invalid fileExists callback configuration"); - connection.onRequest(new RequestType(name), fileName => { + connection.onRequest(this.requestType(name), fileName => { return encodeFileSystemCallbackResult(name, callback(fileName)); }); break; @@ -181,7 +180,7 @@ class RpcClient { case "directoryExists": { const callback = fs.directoryExists; if (typeof callback !== "function") throw new Error("Invalid directoryExists callback configuration"); - connection.onRequest(new RequestType(name), directoryName => { + connection.onRequest(this.requestType(name), directoryName => { return encodeFileSystemCallbackResult(name, callback(directoryName)); }); break; @@ -189,7 +188,7 @@ class RpcClient { case "getAccessibleEntries": { const callback = fs.getAccessibleEntries; if (typeof callback !== "function") throw new Error("Invalid getAccessibleEntries callback configuration"); - connection.onRequest(new RequestType(name), directoryName => { + connection.onRequest(this.requestType(name), directoryName => { return encodeFileSystemCallbackResult(name, callback(directoryName)); }); break; @@ -197,7 +196,7 @@ class RpcClient { case "realpath": { const callback = fs.realpath; if (typeof callback !== "function") throw new Error("Invalid realpath callback configuration"); - connection.onRequest(new RequestType(name), path => { + connection.onRequest(this.requestType(name), path => { return encodeFileSystemCallbackResult(name, callback(path)); }); break; @@ -205,7 +204,7 @@ class RpcClient { case "stat": { const callback = fs.stat; if (typeof callback !== "function") throw new Error("Invalid stat callback configuration"); - connection.onRequest(new RequestType(name), path => { + connection.onRequest(this.requestType(name), path => { return encodeFileSystemCallbackResult(name, callback(path)); }); break; @@ -213,7 +212,7 @@ class RpcClient { case "writeFile": { const callback = fs.writeFile; if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); - connection.onRequest(new RequestType<{ path: RootedFilePath; data: string; }, unknown, void>(name), arg => { + connection.onRequest(this.requestType<{ path: RootedFilePath; data: string; }, unknown>(name), arg => { return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); }); break; @@ -221,7 +220,7 @@ class RpcClient { case "removeFile": { const callback = fs.removeFile; if (typeof callback !== "function") throw new Error("Invalid removeFile callback configuration"); - connection.onRequest(new RequestType(name), path => { + connection.onRequest(this.requestType(name), path => { return encodeFileSystemCallbackResult(name, callback(path)); }); break; @@ -230,7 +229,12 @@ class RpcClient { } } - private async sendRequestWithTiming(requestType: RequestType, params: unknown): Promise { + private requestType(method: string): JsonRpc.RequestType { + if (!this.jsonrpc) throw new Error("Connection not established"); + return new this.jsonrpc.RequestType(method); + } + + private async sendRequestWithTiming(requestType: JsonRpc.RequestType, params: unknown): Promise { if (!this.connection) { throw new Error("Connection not established"); } @@ -262,7 +266,7 @@ class RpcClient { if (!this.connection) { throw new Error("Connection not established"); } - const disposable = this.connection.onRequest(new RequestType(name), callback); + const disposable = this.connection.onRequest(this.requestType(name), callback); return () => disposable.dispose(); } @@ -281,13 +285,13 @@ class RpcClient { if (requests.length === 1) { // send single queued requests directly instead of as a batched request - const requestType = new RequestType(requests[0].method); + const requestType = this.requestType(requests[0].method); const response = await this.sendRequestWithTiming(requestType, requests[0].params); requests[0].resolve(response); return; } - const requestType = new RequestType("batchRequests"); + const requestType = this.requestType("batchRequests"); const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; const maxResponseBytesPerPage = this.options?.maxResponseBytesPerPage; if (maxResponseBytesPerPage !== undefined) { @@ -402,7 +406,7 @@ class RpcClient { this.timing.reset(); if (this.connected && this.connection) { // Keep the server's collection in sync so combined totals stay meaningful. - const requestType = new RequestType("resetServerTiming"); + const requestType = this.requestType("resetServerTiming"); await this.connection.sendRequest(requestType, undefined); } } @@ -413,7 +417,7 @@ class RpcClient { } // Fetch the server's own timing collection via a dedicated request. This // bypasses the client-side collector so the query does not pollute it. - const requestType = new RequestType("getServerTiming"); + const requestType = this.requestType("getServerTiming"); return this.connection.sendRequest(requestType, undefined); } diff --git a/packages/typescript/test/browser/smoke.test.ts b/packages/typescript/test/browser/smoke.test.ts index bd9796e8411a3..cec9f894f5436 100644 --- a/packages/typescript/test/browser/smoke.test.ts +++ b/packages/typescript/test/browser/smoke.test.ts @@ -14,6 +14,50 @@ import { chromium, } from "playwright"; +/** Returns a module whose `run` export exercises the API entries at the given specifiers. */ +function testModule(entries: { async: string; sync: string; wasm: string; fs: string; }): string { + return ` +import { API as AsyncAPI } from "${entries.async}"; +import { API as SyncAPI } from "${entries.sync}"; +import { instantiateWasm, WasmTransport } from "${entries.wasm}"; +import { createFileSystemWithLib } from "${entries.fs}"; + +async function check(API, module) { + const instance = await instantiateWasm(module); + const transport = new WasmTransport({ instance, cwd: "/" }); + const api = new API({ transport }); + transport.setFile("/tsconfig.json", "{}"); + transport.setFile("/src/index.ts", "export const value = 42 as const;"); + try { + const snapshot = await api.createSnapshot({ + openFiles: ["/src/index.ts"], + fileNotifications: { + changed: ["/tsconfig.json", "/src/index.ts"], + deleted: [], + }, + }); + const project = await snapshot.getDefaultProjectForFile("/src/index.ts"); + const sourceFile = await project.program.getSourceFile("/src/index.ts"); + const name = sourceFile.statements[0].declarationList.declarations[0].name; + const type = await project.checker.getTypeAtLocation(name); + return project.checker.typeToString(type); + } + finally { + await api.close(); + } +} + +export async function run() { + const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); + return [ + await check(AsyncAPI, module), + await check(SyncAPI, module), + Object.keys(createFileSystemWithLib([]).symlinks).length, + ]; +} +`; +} + describe("API over WebAssembly in a browser", () => { let browser: Browser; let origin: string; @@ -22,46 +66,12 @@ describe("API over WebAssembly in a browser", () => { before(async () => { const result = await build({ stdin: { - contents: ` - import { API as AsyncAPI } from "typescript/async"; - import { API as SyncAPI } from "typescript/sync"; - import { instantiateWasm, WasmTransport } from "typescript/unstable/wasm"; - import { createFileSystemWithLib } from "typescript/fs"; - - async function check(API, module) { - const instance = await instantiateWasm(module); - const transport = new WasmTransport({ instance, cwd: "/" }); - const api = new API({ transport }); - transport.setFile("/tsconfig.json", "{}"); - transport.setFile("/src/index.ts", "export const value = 42 as const;"); - try { - const snapshot = await api.createSnapshot({ - openFiles: ["/src/index.ts"], - fileNotifications: { - changed: ["/tsconfig.json", "/src/index.ts"], - deleted: [], - }, - }); - const project = await snapshot.getDefaultProjectForFile("/src/index.ts"); - const sourceFile = await project.program.getSourceFile("/src/index.ts"); - const name = sourceFile.statements[0].declarationList.declarations[0].name; - const type = await project.checker.getTypeAtLocation(name); - return project.checker.typeToString(type); - } - finally { - await api.close(); - } - } - - export async function run() { - const module = await WebAssembly.compileStreaming(fetch("/tsc.wasm")); - return [ - await check(AsyncAPI, module), - await check(SyncAPI, module), - Object.keys(createFileSystemWithLib([]).symlinks).length, - ]; - } - `, + contents: testModule({ + async: "typescript/async", + sync: "typescript/sync", + wasm: "typescript/unstable/wasm", + fs: "typescript/fs", + }), loader: "ts", resolveDir: process.cwd(), }, @@ -80,24 +90,40 @@ describe("API over WebAssembly in a browser", () => { const bundle = result.outputFiles[0].contents; const wasm = await readFile(wasmURL); - const dist = new URL("../../dist/", import.meta.url); + const unbundled = testModule({ + async: "/dist/api/async/api.js", + sync: "/dist/api/sync/api.js", + wasm: "/dist/wasm/index.js", + fs: "/dist/api/fs.js", + }); + + // Serve the package root so the built entries' relative imports resolve as published. + const root = new URL("../../", import.meta.url); server.on("request", async (request, response) => { switch (request.url) { case "/": response.setHeader("Content-Type", "text/html"); response.end(''); break; + case "/unbundled": + response.setHeader("Content-Type", "text/html"); + response.end(''); + break; case "/bundle.js": response.setHeader("Content-Type", "text/javascript"); response.end(bundle); break; + case "/unbundled.js": + response.setHeader("Content-Type", "text/javascript"); + response.end(unbundled); + break; case "/tsc.wasm": response.setHeader("Content-Type", "application/wasm"); response.end(wasm); break; default: { - const file = new URL(`.${request.url}`, dist); - const contents = file.href.startsWith(dist.href) && file.pathname.endsWith(".js") + const file = new URL(`.${request.url}`, root); + const contents = file.href.startsWith(root.href) && file.pathname.endsWith(".js") ? await readFile(file).catch(() => undefined) : undefined; if (contents === undefined) { @@ -135,10 +161,17 @@ describe("API over WebAssembly in a browser", () => { assert.deepStrictEqual(result, ["42", "42", 1]); }); + test("runs the built async and sync API entries without bundling", async () => { + const page = await browser.newPage(); + await page.goto(`${origin}/unbundled`); + const result = await page.evaluate(() => Reflect.get(globalThis, "result")); + assert.deepStrictEqual(result, ["42", "42", 1]); + }); + test("loads the transport entry without package resolution", async () => { const page = await browser.newPage(); await page.goto(origin); - const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/wasm/index.js`)), origin); + const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/dist/wasm/index.js`)), origin); assert.deepStrictEqual(exports, ["WasmTransport", "instantiateWasm", "instantiateWasmSync"]); }); }); From f5316a86d4b74e68a712160827c8f56a7c42f31b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:59:29 -0700 Subject: [PATCH 078/142] Escape only exact drive roots in web mount paths The server escapes a leading segment only when it is exactly a drive letter and colon, so authorities such as x:8080 were escaped on the client but not on the server, and the workspace mount pointed somewhere the server never reads. --- packages/vscode-typescript/src/webMountPoint.ts | 2 +- packages/vscode-typescript/test/webMountPoint.test.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index fe0c23ee31d49..76a034ec7ffbd 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -70,7 +70,7 @@ export function uriSegmentToWasmName(segment: string, isFile: boolean): string { } function isDriveRoot(segment: string): boolean { - return /^[A-Za-z]:/.test(segment); + return /^[A-Za-z]:$/.test(segment); } function needsEncoding(segment: string): boolean { diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index 84c9e54b10e1c..5c6192ccf75ef 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -38,6 +38,8 @@ describe("web WASI mount points", () => { assert.strictEqual(virtual("memfs:/work/a%5Cb"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/a%5Cb"); assert.strictEqual(virtual("memfs:/work/a\\b/"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~"); assert.strictEqual(virtual("memfs:///x"), "/^/~ts-uri~/memfs/~ts-uri-escape~~/x"); + assert.strictEqual(virtual("memfs://c:/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~633a~/work"); + assert.strictEqual(virtual("memfs://x:8080/work"), "/^/~ts-uri~/memfs/x:8080/work"); }); test("maps virtual workspace file names back to their escaped URIs", () => { From 47606e2a2116401461b598265eacf690ec3cd5be Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:02:01 -0700 Subject: [PATCH 079/142] Close async transports only once Closing the API more than once, or combining close() with await using, would close the injected transport again. Transports are not required to tolerate that, and a second close could tear down a session another caller is still awaiting. --- packages/typescript/src/api/async/transportClient.ts | 7 ++++++- packages/typescript/test/async/transport.test.ts | 7 ++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index c8cd6b34f347e..48ce3979d0279 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -54,6 +54,7 @@ export class TransportClient { private readonly maxResponseBytesPerPage: number | undefined; private connected = false; private closed = false; + private closing: Promise | undefined; private nextBatch: boolean | "manual" = false; private batchGeneration = 0; private batchedRequests: PendingRequest[] = []; @@ -171,7 +172,11 @@ export class TransportClient { } } - async close(): Promise { + close(): Promise { + return this.closing ??= this.closeWorker(); + } + + private async closeWorker(): Promise { this.closed = true; for (const reject of this.pendingOperations) { reject(new Error("Client is closed")); diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts index fdacffb70b494..e98746548b15a 100644 --- a/packages/typescript/test/async/transport.test.ts +++ b/packages/typescript/test/async/transport.test.ts @@ -17,7 +17,7 @@ describe("async transport", () => { test("constructs an API over an injected transport", async () => { const methods: string[] = []; let batchParams: BatchRequestsParams | undefined; - let closed = false; + let closes = 0; const api = new API({ maxResponseBytesPerPage: 1, transport: { @@ -72,7 +72,7 @@ describe("async transport", () => { throw new Error("Unexpected binary request"); }, close() { - closed = true; + closes++; }, }, }); @@ -90,8 +90,9 @@ describe("async transport", () => { await Promise.all(batchRequests); assert.deepStrictEqual(methods, ["initialize", "parseCommandLine", "batchRequests"]); assert.strictEqual(batchParams?.maxResponseBytesPerPage, 1); + await Promise.all([api.close(), api.close()]); await api.close(); - assert.strictEqual(closed, true); + assert.strictEqual(closes, 1); }); test("rejects requests queued when the transport closes", async () => { From 27c46b84e50bd572cc9cafb9768af89b2ae1f7dd Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:02:54 -0700 Subject: [PATCH 080/142] Avoid quadratic buffering of web server output The web reader copied everything buffered so far on every stdout chunk, so a large response arriving in small pieces cost time quadratic in its size. Large responses such as full-file semantic tokens or workspace symbols are common enough for this to stall the editor. --- .../vscode-typescript/src/extension.web.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 2188cf8efcf18..1cd7a2abbb598 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -86,7 +86,8 @@ function toWorkspaceUri(uri: vscode.Uri): vscode.Uri { class WasmMessageReader extends AbstractMessageReader { private readonly decoder = new TextDecoder(); - private buffer = new Uint8Array(); + private buffer = new Uint8Array(4096); + private length = 0; private callback: DataCallback | undefined; private closed = false; private readonly dataSubscription: vscode.Disposable; @@ -114,25 +115,30 @@ class WasmMessageReader extends AbstractMessageReader { } private accept(chunk: Uint8Array): void { - const next = new Uint8Array(this.buffer.length + chunk.length); - next.set(this.buffer); - next.set(chunk, this.buffer.length); - this.buffer = next; + if (this.length + chunk.length > this.buffer.length) { + const next = new Uint8Array(Math.max(this.buffer.length * 2, this.length + chunk.length)); + next.set(this.buffer.subarray(0, this.length)); + this.buffer = next; + } + this.buffer.set(chunk, this.length); + this.length += chunk.length; + let offset = 0; while (true) { - const headerEnd = findHeaderEnd(this.buffer); - if (headerEnd < 0) return; - const header = this.decoder.decode(this.buffer.subarray(0, headerEnd)); + const pending = this.buffer.subarray(offset, this.length); + const headerEnd = findHeaderEnd(pending); + if (headerEnd < 0) break; + const header = this.decoder.decode(pending.subarray(0, headerEnd)); const match = /(?:^|\r\n)Content-Length: (\d+)(?:\r\n|$)/i.exec(header); if (!match) { this.fireError(new Error(`Language server response is missing Content-Length: ${header}`)); - return; + break; } const contentLength = Number(match[1]); const bodyStart = headerEnd + 4; - if (this.buffer.length < bodyStart + contentLength) return; - const body = this.decoder.decode(this.buffer.subarray(bodyStart, bodyStart + contentLength)); - this.buffer = this.buffer.slice(bodyStart + contentLength); + if (pending.length < bodyStart + contentLength) break; + const body = this.decoder.decode(pending.subarray(bodyStart, bodyStart + contentLength)); + offset += bodyStart + contentLength; try { this.callback?.(JSON.parse(body)); } @@ -140,6 +146,10 @@ class WasmMessageReader extends AbstractMessageReader { this.fireError(error); } } + if (offset > 0) { + this.buffer.copyWithin(0, offset, this.length); + this.length -= offset; + } } } From d047f8a9c7faa6489bd809cad1b224e2f1a4351d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:06:33 -0700 Subject: [PATCH 081/142] Move TypeScript 7 setting helpers out of util The web extension needs the same enablement logic as the desktop extension, but util imports Node modules that cannot be bundled for the browser. Keeping the setting helpers in their own module lets both entry points share them. --- packages/vscode-typescript/src/commands.ts | 4 +- packages/vscode-typescript/src/extension.ts | 15 +-- packages/vscode-typescript/src/settings.ts | 108 ++++++++++++++++++++ packages/vscode-typescript/src/util.ts | 94 ----------------- 4 files changed, 114 insertions(+), 107 deletions(-) create mode 100644 packages/vscode-typescript/src/settings.ts diff --git a/packages/vscode-typescript/src/commands.ts b/packages/vscode-typescript/src/commands.ts index de015f116bd64..1cfa5ee6e66dd 100644 --- a/packages/vscode-typescript/src/commands.ts +++ b/packages/vscode-typescript/src/commands.ts @@ -6,11 +6,11 @@ import type { Position, } from "vscode-languageclient"; -import type * as tr from "./telemetryReporting"; import { getExplicitConfigTarget, restartExtHostOnChangeIfNeeded, -} from "./util"; +} from "./settings"; +import type * as tr from "./telemetryReporting"; export function registerEnablementCommands(context: vscode.ExtensionContext, telemetryReporter: tr.TelemetryReporter, stopSession: () => Promise): void { context.subscriptions.push(vscode.commands.registerCommand("typescript.native-preview.enable", () => { diff --git a/packages/vscode-typescript/src/extension.ts b/packages/vscode-typescript/src/extension.ts index 7edd489ec2bfc..1608d6a3aa30e 100644 --- a/packages/vscode-typescript/src/extension.ts +++ b/packages/vscode-typescript/src/extension.ts @@ -7,12 +7,13 @@ import { updateUseTsgoSetting, } from "./commands"; import { - aiConnectionString, getExplicitConfigTarget, getUseTsgo, getWinningTsgoConfigKey, needsExtHostRestartOnChange, -} from "./util"; + shouldStartTsgo, +} from "./settings"; +import { aiConnectionString } from "./util"; import { TelemetryReporter as VSCodeTelemetryReporter } from "@vscode/extension-telemetry"; import { @@ -88,7 +89,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { - const useTsgo = getUseTsgo(); - if (context.extensionMode === vscode.ExtensionMode.Development && !vscode.extensions.getExtension("vscode.typescript-language-features")) { - return useTsgo !== false; - } - return useTsgo === true; - } - await startNativeServer(); return api; diff --git a/packages/vscode-typescript/src/settings.ts b/packages/vscode-typescript/src/settings.ts new file mode 100644 index 0000000000000..7c79c655eb785 --- /dev/null +++ b/packages/vscode-typescript/src/settings.ts @@ -0,0 +1,108 @@ +import * as vscode from "vscode"; + +export function needsExtHostRestartOnChange() { + const majorVersion = parseInt(vscode.version.split(".")[0]); + const minorVersion = parseInt(vscode.version.split(".")[1]); + return majorVersion <= 1 && minorVersion < 105; +} + +export async function restartExtHostOnChangeIfNeeded(): Promise { + if (needsExtHostRestartOnChange()) { + await vscode.commands.executeCommand("workbench.action.restartExtensionHost"); + } +} + +/** + * Read the useTsgo setting from both `js/ts.experimental.useTsgo` and + * `typescript.experimental.useTsgo`, using `inspect()` to only consider + * explicitly set values (ignoring VS Code defaults). + * + * Each setting key is resolved using standard VS Code precedence (workspace + * folder > workspace > global, with language-specific overrides taking + * priority within each scope). When both keys are explicitly configured, the + * value set at the most specific scope wins, and `js/ts` wins over + * `typescript` at the same scope. Returns `undefined` if neither setting has + * been explicitly configured. + */ +export function getUseTsgo(): boolean | undefined { + const tsValue = getExplicitUseTsgo("typescript"); + const jsTsValue = getExplicitUseTsgo("js/ts"); + + if (tsValue !== undefined || jsTsValue !== undefined) { + const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); + const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); + const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); + return jsTsTarget === mostSpecific ? jsTsValue : tsValue; + } + + return undefined; +} + +export function getExplicitConfigTarget( + config: vscode.WorkspaceConfiguration, + key: string, +): vscode.ConfigurationTarget | undefined { + const inspection = config.inspect(key); + if (!inspection) return undefined; + if (inspection.workspaceFolderValue !== undefined) return vscode.ConfigurationTarget.WorkspaceFolder; + if (inspection.workspaceValue !== undefined) return vscode.ConfigurationTarget.Workspace; + if (inspection.globalValue !== undefined) return vscode.ConfigurationTarget.Global; + return undefined; +} + +export enum ConfigName { + JsTsConfigName = "js/ts.experimental.useTsgo", + DeprecatedTypeScriptConfigName = "typescript.experimental.useTsgo", +} + +/** + * Returns the name of the setting with the appropriate precedence that is explicitly set, + * preferring `js/ts` over `typescript`. + * Returns `undefined` if neither is explicitly specified. + */ +export function getWinningTsgoConfigKey(): ConfigName | undefined { + const jsTsValue = getExplicitUseTsgo("js/ts"); + const tsValue = getExplicitUseTsgo("typescript"); + + if (jsTsValue !== undefined || tsValue !== undefined) { + const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); + const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); + const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); + return jsTsTarget === mostSpecific ? ConfigName.JsTsConfigName : ConfigName.DeprecatedTypeScriptConfigName; + } + + return undefined; +} + +function getExplicitUseTsgo(section: string): boolean | undefined { + const config = vscode.workspace.getConfiguration(section); + const inspected = config.inspect("experimental.useTsgo"); + if (!inspected) return undefined; + + const explicitValues: (boolean | undefined)[] = [ + inspected.workspaceFolderLanguageValue, + inspected.workspaceLanguageValue, + inspected.globalLanguageValue, + inspected.workspaceFolderValue, + inspected.workspaceValue, + inspected.globalValue, + ]; + + for (const v of explicitValues) { + if (v !== undefined) return v; + } + return undefined; +} + +/** + * Whether the native language server should run with the current settings. + * In development, it also runs when the built-in TypeScript extension is + * disabled unless the setting explicitly turns it off. + */ +export function shouldStartTsgo(context: vscode.ExtensionContext): boolean { + const useTsgo = getUseTsgo(); + if (context.extensionMode === vscode.ExtensionMode.Development && !vscode.extensions.getExtension("vscode.typescript-language-features")) { + return useTsgo !== false; + } + return useTsgo === true; +} diff --git a/packages/vscode-typescript/src/util.ts b/packages/vscode-typescript/src/util.ts index 05b38c15eb7c5..05a7b943eeb64 100644 --- a/packages/vscode-typescript/src/util.ts +++ b/packages/vscode-typescript/src/util.ts @@ -379,100 +379,6 @@ export function getLanguageForUri(uri: vscode.Uri): string | undefined { } } -export function needsExtHostRestartOnChange() { - const majorVersion = parseInt(vscode.version.split(".")[0]); - const minorVersion = parseInt(vscode.version.split(".")[1]); - return majorVersion <= 1 && minorVersion < 105; -} - -export async function restartExtHostOnChangeIfNeeded(): Promise { - if (needsExtHostRestartOnChange()) { - await vscode.commands.executeCommand("workbench.action.restartExtensionHost"); - } -} - -/** - * Read the useTsgo setting from both `js/ts.experimental.useTsgo` and - * `typescript.experimental.useTsgo`, using `inspect()` to only consider - * explicitly set values (ignoring VS Code defaults). - * - * Each setting key is resolved using standard VS Code precedence (workspace - * folder > workspace > global, with language-specific overrides taking - * priority within each scope). When both keys are explicitly configured, the - * value set at the most specific scope wins, and `js/ts` wins over - * `typescript` at the same scope. Returns `undefined` if neither setting has - * been explicitly configured. - */ -export function getUseTsgo(): boolean | undefined { - const tsValue = getExplicitUseTsgo("typescript"); - const jsTsValue = getExplicitUseTsgo("js/ts"); - - if (tsValue !== undefined || jsTsValue !== undefined) { - const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); - const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); - const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); - return jsTsTarget === mostSpecific ? jsTsValue : tsValue; - } - - return undefined; -} - -export function getExplicitConfigTarget( - config: vscode.WorkspaceConfiguration, - key: string, -): vscode.ConfigurationTarget | undefined { - const inspection = config.inspect(key); - if (!inspection) return undefined; - if (inspection.workspaceFolderValue !== undefined) return vscode.ConfigurationTarget.WorkspaceFolder; - if (inspection.workspaceValue !== undefined) return vscode.ConfigurationTarget.Workspace; - if (inspection.globalValue !== undefined) return vscode.ConfigurationTarget.Global; - return undefined; -} - -export enum ConfigName { - JsTsConfigName = "js/ts.experimental.useTsgo", - DeprecatedTypeScriptConfigName = "typescript.experimental.useTsgo", -} - -/** - * Returns the name of the setting with the appropriate precedence that is explicitly set, - * preferring `js/ts` over `typescript`. - * Returns `undefined` if neither is explicitly specified. - */ -export function getWinningTsgoConfigKey(): ConfigName | undefined { - const jsTsValue = getExplicitUseTsgo("js/ts"); - const tsValue = getExplicitUseTsgo("typescript"); - - if (jsTsValue !== undefined || tsValue !== undefined) { - const jsTsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("js/ts"), "experimental.useTsgo"); - const tsTarget = getExplicitConfigTarget(vscode.workspace.getConfiguration("typescript"), "experimental.useTsgo"); - const mostSpecific = Math.max(jsTsTarget ?? vscode.ConfigurationTarget.Global, tsTarget ?? vscode.ConfigurationTarget.Global); - return jsTsTarget === mostSpecific ? ConfigName.JsTsConfigName : ConfigName.DeprecatedTypeScriptConfigName; - } - - return undefined; -} - -function getExplicitUseTsgo(section: string): boolean | undefined { - const config = vscode.workspace.getConfiguration(section); - const inspected = config.inspect("experimental.useTsgo"); - if (!inspected) return undefined; - - const explicitValues: (boolean | undefined)[] = [ - inspected.workspaceFolderLanguageValue, - inspected.workspaceLanguageValue, - inspected.globalLanguageValue, - inspected.workspaceFolderValue, - inspected.workspaceValue, - inspected.globalValue, - ]; - - for (const v of explicitValues) { - if (v !== undefined) return v; - } - return undefined; -} - /** * Read a setting following the standard `js/ts > [typescript|javascript]` precedence: * the unified `js/ts.` setting wins if explicitly set at any scope, otherwise From 7651e625f476adcfa7cb8c6ff1fcccfbc992595d Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:06:37 -0700 Subject: [PATCH 082/142] Honor the TypeScript 7 setting in the web extension The web extension started its language server unconditionally, so it ran alongside the built-in TypeScript support even when TypeScript 7 was disabled, and the enable and disable commands were missing. It now onboards, starts, stops and restarts the same way as the desktop extension. --- packages/vscode-typescript/src/commands.ts | 6 +- .../vscode-typescript/src/extension.web.ts | 84 +++++++++++++++++-- 2 files changed, 81 insertions(+), 9 deletions(-) diff --git a/packages/vscode-typescript/src/commands.ts b/packages/vscode-typescript/src/commands.ts index 1cfa5ee6e66dd..b1b735b4083fd 100644 --- a/packages/vscode-typescript/src/commands.ts +++ b/packages/vscode-typescript/src/commands.ts @@ -12,15 +12,15 @@ import { } from "./settings"; import type * as tr from "./telemetryReporting"; -export function registerEnablementCommands(context: vscode.ExtensionContext, telemetryReporter: tr.TelemetryReporter, stopSession: () => Promise): void { +export function registerEnablementCommands(context: vscode.ExtensionContext, telemetryReporter: tr.TelemetryReporter | undefined, stopSession: () => Promise): void { context.subscriptions.push(vscode.commands.registerCommand("typescript.native-preview.enable", () => { // Fire and forget, because this will restart the extension host and cause an error if we await - telemetryReporter.sendTelemetryEvent("command.enableNativePreview"); + telemetryReporter?.sendTelemetryEvent("command.enableNativePreview"); updateUseTsgoSetting(true); })); context.subscriptions.push(vscode.commands.registerCommand("typescript.native-preview.disable", async () => { - telemetryReporter.sendTelemetryEvent("command.disableNativePreview"); + telemetryReporter?.sendTelemetryEvent("command.disableNativePreview"); await stopSession(); await updateUseTsgoSetting(false); })); diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 1cd7a2abbb598..4570083f4b05b 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -13,7 +13,16 @@ import { type Message, type MessageTransports, } from "vscode-languageclient/browser"; +import { + registerEnablementCommands, + updateUseTsgoSetting, +} from "./commands"; import { nightlyExtensionId } from "./extensionIds"; +import { + getUseTsgo, + needsExtHostRestartOnChange, + shouldStartTsgo, +} from "./settings"; import { uriSegmentToWasmName, uriToWasmMountPoint, @@ -175,6 +184,7 @@ class WasmMessageWriter extends AbstractMessageWriter { } export async function activate(context: vscode.ExtensionContext): Promise { + await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", false); const output = vscode.window.createOutputChannel("TypeScript 7", { log: true }); context.subscriptions.push(output); @@ -266,15 +276,77 @@ export async function activate(context: vscode.ExtensionContext): Promise void process?.terminate(); }, }); + + let running = false; + let pending = Promise.resolve(); + const enqueue = (action: () => Promise): Promise => pending = pending.then(action).catch(error => output.error(`TypeScript language server failed: ${String(error)}`)); + const start = async () => { + await client.start(); + running = true; + await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", true); + }; + const stop = async () => { + if (!running) return; + running = false; + await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", false); + await client.stop(); + await process?.terminate(); + process = undefined; + }; + + registerEnablementCommands(context, undefined, () => enqueue(stop)); + // WASI mounts are fixed when the process starts, so new folders need a new process. - let restarting = Promise.resolve(); context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(() => { - restarting = restarting.then(async () => { - await client.stop(); - await client.start(); - }).catch(error => output.error(`Failed to restart TypeScript language server: ${String(error)}`)); + void enqueue(async () => { + if (!running) return; + await stop(); + await start(); + }); })); - await client.start(); + + let configChangeTimeout: ReturnType | undefined; + context.subscriptions.push(vscode.workspace.onDidChangeConfiguration(event => { + if ( + event.affectsConfiguration("typescript.experimental.useTsgo") + || event.affectsConfiguration("js/ts.experimental.useTsgo") + ) { + // Debounce to coalesce rapid events when both settings are updated together. + clearTimeout(configChangeTimeout); + configChangeTimeout = setTimeout(async () => { + if (needsExtHostRestartOnChange()) { + const selected = await vscode.window.showInformationMessage(vscode.l10n.t("TypeScript 7 setting has changed. Restart extensions to apply changes."), vscode.l10n.t("Restart Extensions")); + if (selected) { + vscode.commands.executeCommand("workbench.action.restartExtensionHost"); + } + return; + } + void enqueue(async () => { + await stop(); + if (shouldStartTsgo(context)) { + await start(); + } + }); + }, 100); + } + })); + context.subscriptions.push({ dispose: () => clearTimeout(configChangeTimeout) }); + + const hasOnboardedTsgoStateKey = "hasOnboardedTsgo"; + const shouldOnboardTsgo = !context.globalState.get(hasOnboardedTsgoStateKey); + if (shouldOnboardTsgo) { + await context.globalState.update(hasOnboardedTsgoStateKey, true); + } + if (getUseTsgo() === undefined && shouldOnboardTsgo) { + void updateUseTsgoSetting(true); + return; + } + + if (!shouldStartTsgo(context)) { + output.appendLine(vscode.l10n.t("TypeScript 7 is disabled. Select 'TypeScript: Enable TypeScript 7' in the command palette to enable it.")); + return; + } + await enqueue(start); } export function deactivate(): void {} From f2b37fdf0eb50218d7906fb4125f6445e18c8a5f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:25:38 -0700 Subject: [PATCH 083/142] Keep Node stream types out of the JSON-RPC declarations The published declarations must not depend on Node types, since API consumers outside Node may not have them, and the release build rejects external imports in .d.ts files. --- packages/typescript/src/api/async/jsonrpc.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/typescript/src/api/async/jsonrpc.ts b/packages/typescript/src/api/async/jsonrpc.ts index a8f68fbbace49..fec7364099f04 100644 --- a/packages/typescript/src/api/async/jsonrpc.ts +++ b/packages/typescript/src/api/async/jsonrpc.ts @@ -2,10 +2,6 @@ // entry point loads Node modules at import time, which would prevent this // package from being loaded or bundled for other environments, so the Node // runtime layer it would install is provided below instead. -import type { - Readable, - Writable, -} from "node:stream"; import { AbstractMessageBuffer, createMessageConnection, @@ -44,7 +40,18 @@ class MessageBuffer extends AbstractMessageBuffer { } } -function listen(stream: Readable | Writable, event: string, listener: (...args: any[]) => void): Disposable { +interface EventSource { + on(event: string, listener: (...args: any[]) => void): unknown; + off(event: string, listener: (...args: any[]) => void): unknown; +} + +interface WritableStream extends EventSource { + write(data: Uint8Array, callback: (error: Error | null | undefined) => void): unknown; + write(data: string, encoding: RAL.MessageBufferEncoding, callback: (error: Error | null | undefined) => void): unknown; + end(): unknown; +} + +function listen(stream: EventSource, event: string, listener: (...args: any[]) => void): Disposable { stream.on(event, listener); return Disposable.create(() => stream.off(event, listener)); } @@ -90,7 +97,7 @@ function installRuntime(): void { } /** Creates a JSON-RPC connection over Node.js streams. */ -export function createStreamConnection(readable: Readable, writable: Writable): MessageConnection { +export function createStreamConnection(readable: EventSource, writable: WritableStream): MessageConnection { installRuntime(); const reader = new ReadableStreamMessageReader({ onClose: listener => listen(readable, "close", listener), From 4b93f40564ea9618434c545ac57d289f5f3bd9e5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:25:39 -0700 Subject: [PATCH 084/142] Document the Node.js version the API needs The package still installs on older Node.js for the compiler CLI, but the API's default transport relies on process.getBuiltinModule, which only exists from Node.js 20.16. --- packages/typescript/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/typescript/README.md b/packages/typescript/README.md index c35985115a453..fcb15ad43dab7 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -16,6 +16,8 @@ For other builds, use the `tsgo` command just like you would use `tsc`: npx tsgo --help ``` +The JavaScript API requires Node.js 20.16 or later when it starts the compiler itself. In other environments, provide a transport. + ## Issues and Feedback The native port of TypeScript is still in progress. From 7fa2e81ecbb436b2eeb532e5e048e2ef35065c21 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:43:14 -0700 Subject: [PATCH 085/142] Drop the unstable prefix from the new API exports The API exports no longer use an unstable/ prefix, so the WASM host and transport exports added here should not reintroduce it. --- packages/typescript-wasip1-wasm/README.md | 2 +- packages/typescript/package.json | 6 +++--- packages/typescript/test/browser/smoke.test.ts | 2 +- packages/typescript/test/sync/transport.test.ts | 2 +- packages/typescript/test/sync/wasm.test.ts | 2 +- packages/typescript/test/wasm.ts | 2 +- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index 5d1cf73ed7cea..6c0268c02d89c 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -8,5 +8,5 @@ This package contains the TypeScript compiler and language server artifacts for `wasip1/wasm`, plus a list of the bundled library files. Install the same exact version of this package and the TypeScript package that -provides `typescript/unstable/wasm`. When loading both packages from a CDN, +provides `typescript/wasm`. When loading both packages from a CDN, select one version and use it for both package URLs. diff --git a/packages/typescript/package.json b/packages/typescript/package.json index baf26ccd4d297..0b2aaaa79218d 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -50,7 +50,7 @@ "@typescript/source": "./src/api/sync/api.ts", "default": "./dist/api/sync/api.js" }, - "./unstable/sync/transport": { + "./sync/transport": { "@typescript/source": "./src/api/sync/transport.ts", "default": "./dist/api/sync/transport.js" }, @@ -58,7 +58,7 @@ "@typescript/source": "./src/api/async/api.ts", "default": "./dist/api/async/api.js" }, - "./unstable/async/transport": { + "./async/transport": { "@typescript/source": "./src/api/async/transport.ts", "default": "./dist/api/async/transport.js" }, @@ -74,7 +74,7 @@ "@typescript/source": "./src/api/proto.ts", "default": "./dist/api/proto.js" }, - "./unstable/wasm": { + "./wasm": { "@typescript/source": "./src/wasm/index.ts", "default": "./dist/wasm/index.js" }, diff --git a/packages/typescript/test/browser/smoke.test.ts b/packages/typescript/test/browser/smoke.test.ts index cec9f894f5436..5d67ed123f366 100644 --- a/packages/typescript/test/browser/smoke.test.ts +++ b/packages/typescript/test/browser/smoke.test.ts @@ -69,7 +69,7 @@ describe("API over WebAssembly in a browser", () => { contents: testModule({ async: "typescript/async", sync: "typescript/sync", - wasm: "typescript/unstable/wasm", + wasm: "typescript/wasm", fs: "typescript/fs", }), loader: "ts", diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts index afef4bc24251e..ac8ddaea2730f 100644 --- a/packages/typescript/test/sync/transport.test.ts +++ b/packages/typescript/test/sync/transport.test.ts @@ -5,7 +5,7 @@ import { import { type WasmReactorExports, WasmTransport, -} from "@typescript/typescript/unstable/wasm"; +} from "@typescript/typescript/wasm"; import assert from "node:assert"; import { describe, diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 86319b106c7c9..4db915280f482 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -4,7 +4,7 @@ import { instantiateWasm, instantiateWasmSync, WasmTransport, -} from "@typescript/typescript/unstable/wasm"; +} from "@typescript/typescript/wasm"; import binaryen from "binaryen"; import assert from "node:assert"; import { spawn } from "node:child_process"; diff --git a/packages/typescript/test/wasm.ts b/packages/typescript/test/wasm.ts index 3313b3e432121..3990a5ca98b1b 100644 --- a/packages/typescript/test/wasm.ts +++ b/packages/typescript/test/wasm.ts @@ -10,7 +10,7 @@ import { instantiateWasmSync, type WasmReactorInstance, WasmTransport, -} from "@typescript/typescript/unstable/wasm"; +} from "@typescript/typescript/wasm"; import { readFileSync } from "node:fs"; import { type DocumentIdentifier, From a8fc23a8e72d8b4473905c66698b52572bccb66b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:45:21 -0700 Subject: [PATCH 086/142] Correct the WASM transport initialization docs The documented wasi.initialize(instance) call cannot work: the module exports _start for the command line and typescript_initialize for the reactor, and Node's WASI rejects modules shaped that way. --- packages/typescript/src/wasm/transport.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/typescript/src/wasm/transport.ts b/packages/typescript/src/wasm/transport.ts index c60fd5933cea0..d525c362a0091 100644 --- a/packages/typescript/src/wasm/transport.ts +++ b/packages/typescript/src/wasm/transport.ts @@ -29,8 +29,11 @@ export interface WasmReactorInstance { export interface WasmTransportOptions { /** - * An instantiated reactor whose WASI host has already initialized it. - * For example, call `wasi.initialize(instance)` before constructing the transport. + * An initialized reactor, normally created by `instantiateWasm` or + * `instantiateWasmSync`. Other WASI hosts must call the module's + * `typescript_initialize` export instead of `_start`, which runs the + * command line compiler, and cannot use the `fs` option or host + * callbacks such as custom file systems passed to the API. */ instance: WasmReactorInstance; cwd?: string | undefined; From 2b5f3edd93b78d16d20f58c769d2d8b6ed1bb935 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:49:09 -0700 Subject: [PATCH 087/142] Serialize WASM host callback exchanges Host callback responses were returned as views into a shared buffer, and a large result is fetched in a second exchange through the host's single pending slot. Concurrent goroutines in one request, such as file loading and module resolution, could overwrite a response before its caller decoded it, or take another call's pending result. --- tsc/cmd/tsc/wasmapi_wasip1.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index 3556ae4ec6a41..b50dd47ec5614 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -9,6 +9,8 @@ import ( "fmt" "os" "runtime/debug" + "slices" + "sync" "unsafe" "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" @@ -39,6 +41,9 @@ var ( responsePtr uint32 responseLen uint32 + // hostCallbackMu serializes host callback exchanges, which share + // hostCallbackBuffer and the host's single pending-result slot. + hostCallbackMu sync.Mutex hostCallbackBuffer []byte inCall bool ) @@ -263,6 +268,8 @@ func (hostCallbackConn) Call(_ context.Context, method string, params any) (json if err != nil { return nil, err } + hostCallbackMu.Lock() + defer hostCallbackMu.Unlock() bufferSize := 16 + len(method) + len(paramsBytes) + hostResultCapacity + hostErrorCapacity if cap(hostCallbackBuffer) < bufferSize { hostCallbackBuffer = make([]byte, bufferSize) @@ -277,7 +284,7 @@ func (hostCallbackConn) Call(_ context.Context, method string, params any) (json if err != nil { return nil, err } - return json.Value(result), nil + return json.Value(slices.Clone(result)), nil } func callHost(buffer []byte, method string, params []byte, resultCapacity int) ([]byte, uint32, error) { From f143ca232241353f58752d19eca739631d1b9aa6 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:53:13 -0700 Subject: [PATCH 088/142] Keep escaped authority extensions in web mount paths The server encodes a URI authority as a final path segment, keeping its extension outside the escape. The web mount escaped it whole, so workspaces whose authority needs escaping were mounted at a path the server never reads. --- packages/vscode-typescript/src/webMountPoint.ts | 2 +- packages/vscode-typescript/test/webMountPoint.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index 76a034ec7ffbd..7e86b3b7d99a8 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -31,7 +31,7 @@ export function uriToWasmMountPoint(uri: WebFileSystemUri): string { if (path.startsWith("//")) { const slash = path.indexOf("/", 2); const rawAuthority = slash === -1 ? path.slice(2) : path.slice(2, slash); - authority = rawAuthority === nulAuthority || isDriveRoot(rawAuthority) ? forceEncodeSegment(rawAuthority) : encodeSegment(rawAuthority); + authority = rawAuthority === nulAuthority || isDriveRoot(rawAuthority) ? forceEncodeSegment(rawAuthority) : needsEncoding(rawAuthority) ? forceEncodeSegment(rawAuthority, /* preserveExtension */ true) : rawAuthority; path = slash === -1 ? "" : path.slice(slash + 1); } const segments = path === "" ? [] : (path.endsWith("/") ? path : `${path}/`).split("/").slice(0, -1).map(encodeSegment); diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index 5c6192ccf75ef..65437515609f5 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -40,6 +40,7 @@ describe("web WASI mount points", () => { assert.strictEqual(virtual("memfs:///x"), "/^/~ts-uri~/memfs/~ts-uri-escape~~/x"); assert.strictEqual(virtual("memfs://c:/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~633a~/work"); assert.strictEqual(virtual("memfs://x:8080/work"), "/^/~ts-uri~/memfs/x:8080/work"); + assert.strictEqual(virtual("memfs://~ts-uri-escape~fs.example/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~7e74732d7572692d6573636170657e6673~.example/work"); }); test("maps virtual workspace file names back to their escaped URIs", () => { From 03fb32422017337f789cdf48f645d5961f558e1c Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:53:37 -0700 Subject: [PATCH 089/142] Terminate web servers created after disposal Disposing the extension only terminated the process that already existed. A start or restart still waiting for the WASI runtime would then create a server after disposal and leave it running. --- packages/vscode-typescript/src/extension.web.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 4570083f4b05b..4605cdb0d7309 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -210,6 +210,7 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(vscode.workspace.registerFileSystemProvider(wasmFileSystemScheme, new WasmFileSystemProvider(), { isCaseSensitive: true })); let process: WasmProcess | undefined; + let disposed = false; const stderrDecoder = new TextDecoder(); const serverOptions = async (): Promise => { const wasm = await Wasm.load(); @@ -231,6 +232,7 @@ export async function activate(context: vscode.ExtensionContext): Promise // while waiting for input. `@vscode/wasm-wasi` does not declare this option yet. const stdin = { kind: "pipeIn" as const, nonBlocking: true }; await process?.terminate(); + if (disposed) throw new Error("TypeScript language server was disposed"); process = await wasm.createProcess("tsc", module, { args: ["--lsp", "--stdio"], env: { PWD: "/" }, @@ -241,6 +243,11 @@ export async function activate(context: vscode.ExtensionContext): Promise err: { kind: "pipeOut" }, }, }); + // Disposal may have run while the process was being created. + if (disposed) { + await process.terminate(); + throw new Error("TypeScript language server was disposed"); + } if (!process.stdin || !process.stdout) { throw new Error("WASM WASI did not create language server stdio pipes"); } @@ -273,6 +280,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const client = new LanguageClient("js/ts", "TypeScript Language Server", serverOptions, clientOptions); context.subscriptions.push(client, { dispose() { + disposed = true; void process?.terminate(); }, }); From 1f1829f0dc568b5f57ae5b06d3e6ca01e672a5b6 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:57:35 -0700 Subject: [PATCH 090/142] Share server commands with browser-safe code The web extension cannot load session.ts or the Node language client, but output focus, import source actions, code lens locations, and source definition only need a running language server. --- packages/vscode-typescript/src/commands.ts | 23 +++++++++++++++++-- .../src/languageFeatures/sourceDefinition.ts | 8 +++---- packages/vscode-typescript/src/session.ts | 22 ++---------------- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/packages/vscode-typescript/src/commands.ts b/packages/vscode-typescript/src/commands.ts index b1b735b4083fd..19272cef0b508 100644 --- a/packages/vscode-typescript/src/commands.ts +++ b/packages/vscode-typescript/src/commands.ts @@ -70,8 +70,27 @@ export async function updateWorkspaceUseTsgoSetting(enable: boolean): Promise { + outputChannel.show(); + }), + vscode.commands.registerCommand("typescript.native-preview.sortImports", () => { + return vscode.commands.executeCommand("editor.action.sourceAction", { + kind: "source.sortImports", + apply: "first", + }); + }), + vscode.commands.registerCommand("typescript.native-preview.removeUnusedImports", () => { + return vscode.commands.executeCommand("editor.action.sourceAction", { + kind: "source.removeUnusedImports", + apply: "first", + }); + }), + ); function showCodeLensLocations(...args: unknown[]): void { if (args.length !== 3) { diff --git a/packages/vscode-typescript/src/languageFeatures/sourceDefinition.ts b/packages/vscode-typescript/src/languageFeatures/sourceDefinition.ts index e719e1b2f4411..34818fd0c3068 100644 --- a/packages/vscode-typescript/src/languageFeatures/sourceDefinition.ts +++ b/packages/vscode-typescript/src/languageFeatures/sourceDefinition.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode"; import { - LanguageClient, - Location, + type BaseLanguageClient, + type Location, LocationLink, -} from "vscode-languageclient/node"; +} from "vscode-languageclient"; const sourceDefinitionMethod = "custom/textDocument/sourceDefinition"; const sourceDefinitionCommand = "typescript.native-preview.goToSourceDefinition"; @@ -11,7 +11,7 @@ const sourceDefinitionContext = "tsSupportsSourceDefinition"; type SourceDefinitionResponse = Location | Location[] | LocationLink[] | null; -export function registerSourceDefinitionFeature(client: LanguageClient): vscode.Disposable { +export function registerSourceDefinitionFeature(client: BaseLanguageClient): vscode.Disposable { const experimental = client.initializeResult?.capabilities?.experimental as { customSourceDefinitionProvider?: boolean; } | undefined; const enabled = !!experimental?.customSourceDefinitionProvider; void vscode.commands.executeCommand("setContext", sourceDefinitionContext, enabled); diff --git a/packages/vscode-typescript/src/session.ts b/packages/vscode-typescript/src/session.ts index 943f2af6817f3..d5aa0d53d58eb 100644 --- a/packages/vscode-typescript/src/session.ts +++ b/packages/vscode-typescript/src/session.ts @@ -8,7 +8,7 @@ import * as vscode from "vscode"; import { ActiveJsTsEditorTracker } from "./activeJsTsEditorTracker"; import { Client } from "./client"; import { - registerCodeLensShowLocationsCommand, + registerServerCommands, updateWorkspaceUseTsgoSetting, } from "./commands"; import { @@ -317,11 +317,7 @@ class Session implements vscode.Disposable { } registerCommands(): void { - this.disposables.push(registerCodeLensShowLocationsCommand()); - - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.output.focus", () => { - this.outputChannel.show(); - })); + this.disposables.push(registerServerCommands(this.outputChannel)); // TODO: Support the standard reload/go-to-project-config commands while // Strada leaves their command palette entries visible in native mode: @@ -332,20 +328,6 @@ class Session implements vscode.Disposable { showCommands(this.client); })); - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.sortImports", () => { - return vscode.commands.executeCommand("editor.action.sourceAction", { - kind: "source.sortImports", - apply: "first", - }); - })); - - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.removeUnusedImports", () => { - return vscode.commands.executeCommand("editor.action.sourceAction", { - kind: "source.removeUnusedImports", - apply: "first", - }); - })); - this.disposables.push(vscode.commands.registerCommand("typescript.native-preview.reportIssue", () => { this.telemetryReporter.sendTelemetryEvent("command.reportIssue"); vscode.commands.executeCommand("workbench.action.openIssueReporter", { From f4f63abe41aa31f2c9fd15d5890bcca7c26444a4 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:57:35 -0700 Subject: [PATCH 091/142] Support the server commands in the web extension The web VSIX kept every desktop command contribution but registered only enable and disable, so the rest failed once the server started. Register the commands that work in the browser, and keep the commands that need a local executable or Node out of the web manifest. --- Herebyfile.mjs | 16 ++++++++++++++++ .../vscode-typescript/src/extension.web.ts | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 026834944e923..c5dfa8e82b079 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -3228,6 +3228,22 @@ async function runPackVsixExtensions() { "ms-vscode.wasm-wasi-core", ]; packageJson.files.push("dist/extension.web.bundle.js"); + const webCommands = new Set([ + "typescript.native-preview.enable", + "typescript.native-preview.disable", + "typescript.native-preview.restart", + "typescript.native-preview.output.focus", + "typescript.native-preview.goToSourceDefinition", + "typescript.native-preview.sortImports", + "typescript.native-preview.removeUnusedImports", + "typescript.native-preview.codeLens.showLocations", + ]); + const contributes = packageJson.contributes; + contributes.commands = contributes.commands.filter((/** @type {{ command: string }} */ c) => webCommands.has(c.command)); + assert.equal(contributes.commands.length, webCommands.size, "Web command allowlist names a command the extension does not contribute"); + for (const [menu, items] of Object.entries(contributes.menus)) { + contributes.menus[menu] = items.filter((/** @type {{ command: string }} */ item) => webCommands.has(item.command)); + } } } packageJson.dependencies = packageJsonDependencies; diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 4605cdb0d7309..19df7ff1992b5 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -14,10 +14,13 @@ import { type MessageTransports, } from "vscode-languageclient/browser"; import { + codeLensShowLocationsCommandName, registerEnablementCommands, + registerServerCommands, updateUseTsgoSetting, } from "./commands"; import { nightlyExtensionId } from "./extensionIds"; +import { registerSourceDefinitionFeature } from "./languageFeatures/sourceDefinition"; import { getUseTsgo, needsExtHostRestartOnChange, @@ -272,6 +275,7 @@ export async function activate(context: vscode.ExtensionContext): Promise { language: "typescriptreact" }, ], initializationOptions: { + codeLensShowLocationsCommandName, enableTelemetry: false, runExternalCode: false, }, @@ -286,16 +290,23 @@ export async function activate(context: vscode.ExtensionContext): Promise }); let running = false; + let serverCommands: vscode.Disposable | undefined; let pending = Promise.resolve(); const enqueue = (action: () => Promise): Promise => pending = pending.then(action).catch(error => output.error(`TypeScript language server failed: ${String(error)}`)); const start = async () => { await client.start(); running = true; + serverCommands = vscode.Disposable.from( + registerServerCommands(output), + registerSourceDefinitionFeature(client), + ); await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", true); }; const stop = async () => { if (!running) return; running = false; + serverCommands?.dispose(); + serverCommands = undefined; await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", false); await client.stop(); await process?.terminate(); @@ -303,6 +314,14 @@ export async function activate(context: vscode.ExtensionContext): Promise }; registerEnablementCommands(context, undefined, () => enqueue(stop)); + context.subscriptions.push( + vscode.commands.registerCommand("typescript.native-preview.restart", () => + enqueue(async () => { + await stop(); + await start(); + })), + { dispose: () => serverCommands?.dispose() }, + ); // WASI mounts are fixed when the process starts, so new folders need a new process. context.subscriptions.push(vscode.workspace.onDidChangeWorkspaceFolders(() => { From 52bdc38fbb9d8a3f55aa7429a718e3f88745bccd Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:09:20 -0700 Subject: [PATCH 092/142] Recover the web server when restarting during a crash After a crash the language client restarts itself, and it refuses to stop while that start is in progress or after it fails. Restarting then gave up before starting a new server, leaving the extension without its commands or a way to recover short of reloading. --- .../vscode-typescript/src/extension.web.ts | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 19df7ff1992b5..a1b260a24b817 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -214,8 +214,11 @@ export async function activate(context: vscode.ExtensionContext): Promise let process: WasmProcess | undefined; let disposed = false; + // Bumped whenever the current client is replaced, so starts by older clients give up. + let generation = 0; const stderrDecoder = new TextDecoder(); - const serverOptions = async (): Promise => { + const serverOptions = async (owner: number): Promise => { + const isStale = () => disposed || owner !== generation; const wasm = await Wasm.load(); if (!supportsNonBlockingStdin(wasm.versions.extension)) { throw new Error( @@ -234,9 +237,10 @@ export async function activate(context: vscode.ExtensionContext): Promise // Blocking stdin reads stall the Go scheduler, so the server cannot answer requests // while waiting for input. `@vscode/wasm-wasi` does not declare this option yet. const stdin = { kind: "pipeIn" as const, nonBlocking: true }; + if (isStale()) throw new Error("TypeScript language server was stopped"); await process?.terminate(); - if (disposed) throw new Error("TypeScript language server was disposed"); - process = await wasm.createProcess("tsc", module, { + if (isStale()) throw new Error("TypeScript language server was stopped"); + const newProcess = await wasm.createProcess("tsc", module, { args: ["--lsp", "--stdio"], env: { PWD: "/" }, mountPoints, @@ -246,11 +250,12 @@ export async function activate(context: vscode.ExtensionContext): Promise err: { kind: "pipeOut" }, }, }); - // Disposal may have run while the process was being created. - if (disposed) { - await process.terminate(); - throw new Error("TypeScript language server was disposed"); + // The server may have been stopped while the process was being created. + if (isStale()) { + await newProcess.terminate(); + throw new Error("TypeScript language server was stopped"); } + process = newProcess; if (!process.stdin || !process.stdout) { throw new Error("WASM WASI did not create language server stdio pipes"); } @@ -281,10 +286,12 @@ export async function activate(context: vscode.ExtensionContext): Promise }, outputChannel: output, }; - const client = new LanguageClient("js/ts", "TypeScript Language Server", serverOptions, clientOptions); - context.subscriptions.push(client, { + // A client whose start failed cannot start again, so each start gets a new one. + let client: LanguageClient | undefined; + context.subscriptions.push({ dispose() { disposed = true; + void client?.dispose().catch(() => {}); void process?.terminate(); }, }); @@ -294,11 +301,20 @@ export async function activate(context: vscode.ExtensionContext): Promise let pending = Promise.resolve(); const enqueue = (action: () => Promise): Promise => pending = pending.then(action).catch(error => output.error(`TypeScript language server failed: ${String(error)}`)); const start = async () => { - await client.start(); + const owner = ++generation; + const newClient = new LanguageClient("js/ts", "TypeScript Language Server", () => serverOptions(owner), clientOptions); + client = newClient; + try { + await newClient.start(); + } + catch (error) { + await disposeClient(); + throw error; + } running = true; serverCommands = vscode.Disposable.from( registerServerCommands(output), - registerSourceDefinitionFeature(client), + registerSourceDefinitionFeature(newClient), ); await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", true); }; @@ -308,7 +324,16 @@ export async function activate(context: vscode.ExtensionContext): Promise serverCommands?.dispose(); serverCommands = undefined; await vscode.commands.executeCommand("setContext", "typescript.native-preview.serverRunning", false); - await client.stop(); + await disposeClient(); + }; + const disposeClient = async () => { + const oldClient = client; + client = undefined; + generation++; + // Stopping fails while the client is starting or after its start failed; + // disposing still prevents further restarts, and terminating the process + // ends any start in progress. + await oldClient?.dispose().catch(() => {}); await process?.terminate(); process = undefined; }; From abe2fa2811c8fbeb028fca665ec6cf86f8e055b5 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:37:06 -0700 Subject: [PATCH 093/142] Merge TypeScript settings in the web extension Without the configuration middleware, the web client sent the server VS Code's defaults for js/ts, which the server applies last, so they overrode explicit typescript and javascript preferences. The legacy custom config file name setting was also ignored. --- packages/vscode-typescript/src/configurationMiddleware.ts | 2 +- packages/vscode-typescript/src/extension.web.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/vscode-typescript/src/configurationMiddleware.ts b/packages/vscode-typescript/src/configurationMiddleware.ts index 643d300be04ce..7d73b145c645b 100644 --- a/packages/vscode-typescript/src/configurationMiddleware.ts +++ b/packages/vscode-typescript/src/configurationMiddleware.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import type { ConfigurationMiddleware } from "vscode-languageclient/node"; +import type { ConfigurationMiddleware } from "vscode-languageclient"; import type { MessageSignature } from "vscode-languageserver-protocol"; /** diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index a1b260a24b817..3c9740c2cf70f 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -19,6 +19,10 @@ import { registerServerCommands, updateUseTsgoSetting, } from "./commands"; +import { + configurationMiddleware, + sendNotificationMiddleware, +} from "./configurationMiddleware"; import { nightlyExtensionId } from "./extensionIds"; import { registerSourceDefinitionFeature } from "./languageFeatures/sourceDefinition"; import { @@ -285,6 +289,10 @@ export async function activate(context: vscode.ExtensionContext): Promise runExternalCode: false, }, outputChannel: output, + middleware: { + workspace: configurationMiddleware, + sendNotification: sendNotificationMiddleware, + }, }; // A client whose start failed cannot start again, so each start gets a new one. let client: LanguageClient | undefined; From 3788d21d3fdb1f3844dd31c34c81b9dbe1e1375f Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:38:39 -0700 Subject: [PATCH 094/142] Return empty binary results when a node cannot be built This bug also exists on main, where it affects the native msgpack transport too. Binary clients treat only an empty payload as a missing node, but typeToTypeNode and signatureToSignatureDeclaration returned null when the node builder failed. Clients then tried to decode the JSON null as an AST and threw instead of returning undefined. --- packages/typescript/test/sync/api.test.ts | 15 +++++++++++++++ tsc/internal/api/session.go | 6 ++++++ 2 files changed, 21 insertions(+) diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index bed2a709e28ed..b742434897269 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -7876,6 +7876,21 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "(name: string) => string"); }); + test("typeToTypeNode returns undefined when the type cannot be named", () => { + using api = spawnAPI({ + "/tsconfig.json": "{}", + "/main.ts": "export const x = new (class { a = 1; })();\n", + }); + + const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const { checker } = project; + const symbol = checker.getSymbolAtPosition("/main.ts", 13); + assert.strictEqual(symbol?.name, "x"); + const type = checker.getTypeOfSymbol(symbol); + assert.strictEqual(checker.typeToTypeNode(type, project.program.getSourceFile("/main.ts")), undefined); + }); + test("visitEachChild on typeToTypeNode result with keyword types", () => { using api = spawnAPI(emitterFiles); diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index c88ef9a1b3bee..d600fded72e3e 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -3864,6 +3864,9 @@ func (s *Session) handleTypeToTypeNode(ctx context.Context, params *TypeToTypeNo typeNode := setup.checker.TypeToTypeNode(t, enclosingDeclaration, nodebuilder.Flags(params.Flags), nil) if typeNode == nil { + if s.useBinaryResponses { + return RawBinary(nil), nil + } return nil, nil } @@ -3904,6 +3907,9 @@ func (s *Session) handleSignatureToSignatureDeclaration(ctx context.Context, par node := setup.checker.SignatureToSignatureDeclaration(sig, ast.Kind(params.Kind), enclosingDeclaration, nodebuilder.Flags(params.Flags)) if node == nil { + if s.useBinaryResponses { + return RawBinary(nil), nil + } return nil, nil } From 4a99f41756ad42e1f7b79fc0e1f70bd9a9d75a46 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:24:56 -0700 Subject: [PATCH 095/142] Add the unnamed type regression test to the async source The sync API tests are generated from the async ones, so a test added only to the sync file is removed by the next regeneration. --- packages/typescript/test/async/api.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index b67e89aea49dd..9218b5be8da34 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -8075,6 +8075,21 @@ export const obj = { m: 1, s: "hi", b: true }; assert.strictEqual(text, "(name: string) => string"); }); + test("typeToTypeNode returns undefined when the type cannot be named", async () => { + await using api = spawnAPI({ + "/tsconfig.json": "{}", + "/main.ts": "export const x = new (class { a = 1; })();\n", + }); + + const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); + const project = snapshot.getConfiguredProject("/tsconfig.json")!; + const { checker } = project; + const symbol = await checker.getSymbolAtPosition("/main.ts", 13); + assert.strictEqual(symbol?.name, "x"); + const type = await checker.getTypeOfSymbol(symbol); + assert.strictEqual(await checker.typeToTypeNode(type, await project.program.getSourceFile("/main.ts")), undefined); + }); + test("visitEachChild on typeToTypeNode result with keyword types", async () => { await using api = spawnAPI(emitterFiles); From e1d8a2cfe3e397e5dac8f1c1adec3e5cec458591 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:24:57 -0700 Subject: [PATCH 096/142] Pass case sensitivity through the WASM test wrapper The wrapper dropped useCaseSensitiveFileNames, so the reactor always used its case-sensitive default and the case sensitivity test had to be skipped under WASM. --- Herebyfile.mjs | 2 +- packages/typescript/test/wasm.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index c5dfa8e82b079..15fbceb207862 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1019,7 +1019,7 @@ async function runTestAPIWasm() { // Reactor callbacks cannot yield to promises or reenter Go. "module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)", // These tests require spawned processes or callback filesystem layering. - "readFile callback semantics (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting|configured case sensitivity is used by the server and client)", + "readFile callback semantics (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting)", "updateSnapshot file systems (?:full file system is total and does not invoke host callbacks|full file system accepts paths decoded from VS Code document URIs|file system layer bypasses callbacks on hits and falls back on misses)", // The reactor mirrors input files but does not mirror build output changes. "BuildOrchestrator (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)", diff --git a/packages/typescript/test/wasm.ts b/packages/typescript/test/wasm.ts index 3990a5ca98b1b..fde4e68beb77d 100644 --- a/packages/typescript/test/wasm.ts +++ b/packages/typescript/test/wasm.ts @@ -25,6 +25,7 @@ declare const WebAssembly: { interface WasmAPIOptions { cwd?: string | undefined; fs?: FileSystemCallbacks | undefined; + useCaseSensitiveFileNames?: boolean | undefined; collectTiming?: boolean | undefined; maxResponseBytesPerPage?: number | undefined; transport?: object | undefined; @@ -70,6 +71,7 @@ function createWasmAPIOptions(options: WasmAPIOptions): { instance, cwd: options.cwd ?? "/", fs: writeFile === undefined ? undefined : { writeFile: writeFile.bind(options.fs) }, + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, collectTiming: options.collectTiming, }); if (options.fs) { From 05d953e1c952920b1e3f55aa21d86f08d7a6c4bb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:59:09 -0700 Subject: [PATCH 097/142] Ignore query and fragment when mounting web workspace folders The server names documents with a query or fragment by folding the suffix into the final segment only, so the directories below a folder are named as though the folder had no suffix. Keeping the suffix in the mount path put those directories outside the mount, leaving the server unable to read any workspace files. Backing reads already go to the suffix-free URI, matching the previous tsserver web support. --- packages/vscode-typescript/src/extension.web.ts | 2 +- packages/vscode-typescript/src/webMountPoint.ts | 4 ++++ packages/vscode-typescript/test/webMountPoint.test.ts | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index 3c9740c2cf70f..cbfd64a607e9e 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -234,7 +234,7 @@ export async function activate(context: vscode.ExtensionContext): Promise const mountPoint = uriToWasmMountPoint(folder.uri); return { kind: "vscodeFileSystem" as const, - uri: folder.uri.scheme === "file" ? folder.uri : vscode.Uri.from({ scheme: wasmFileSystemScheme, path: mountPoint }), + uri: folder.uri.scheme === "file" ? folder.uri.with({ query: "", fragment: "" }) : vscode.Uri.from({ scheme: wasmFileSystemScheme, path: mountPoint }), mountPoint, }; }); diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index 7e86b3b7d99a8..76ce4ea9346e3 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -27,6 +27,10 @@ export function uriToWasmMountPoint(uri: WebFileSystemUri): string { const colon = text.indexOf(":"); const scheme = text.slice(0, colon); let path = text.slice(colon + 1); + // Only the final segment of a document name carries a query or fragment, + // so directories below the folder are named as if it had none. + const suffixStart = path.search(/[?#]/); + if (suffixStart !== -1) path = path.slice(0, suffixStart); let authority = nulAuthority; if (path.startsWith("//")) { const slash = path.indexOf("/", 2); diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index 65437515609f5..53163d2f9e1fc 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -41,6 +41,10 @@ describe("web WASI mount points", () => { assert.strictEqual(virtual("memfs://c:/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~633a~/work"); assert.strictEqual(virtual("memfs://x:8080/work"), "/^/~ts-uri~/memfs/x:8080/work"); assert.strictEqual(virtual("memfs://~ts-uri-escape~fs.example/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~7e74732d7572692d6573636170657e6673~.example/work"); + assert.strictEqual(virtual("memfs://mount/work?ref=main"), "/^/~ts-uri~/memfs/mount/work"); + assert.strictEqual(virtual("memfs://mount/work/#x"), "/^/~ts-uri~/memfs/mount/work"); + assert.strictEqual(virtual("memfs://mount?ref=a/b"), "/^/~ts-uri~/memfs/mount"); + assert.strictEqual(virtual("memfs:/work#a/b"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work"); }); test("maps virtual workspace file names back to their escaped URIs", () => { From e7423e1325eca3452e32b98db4dc08b645f9edfa Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:09:28 -0700 Subject: [PATCH 098/142] Skip nested workspace folders when mounting web workspaces The WASI host refuses to mount a folder over an existing mount, so a multi-root workspace containing both a folder and one of its descendants failed to start the language server at all. The outer mount already exposes the nested folder's files. --- packages/vscode-typescript/src/extension.web.ts | 5 +++-- packages/vscode-typescript/src/webMountPoint.ts | 17 +++++++++++++++++ .../test/webMountPoint.test.ts | 11 +++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/vscode-typescript/src/extension.web.ts b/packages/vscode-typescript/src/extension.web.ts index cbfd64a607e9e..d9fb84b7bd759 100644 --- a/packages/vscode-typescript/src/extension.web.ts +++ b/packages/vscode-typescript/src/extension.web.ts @@ -31,6 +31,7 @@ import { shouldStartTsgo, } from "./settings"; import { + outermostMountPoints, uriSegmentToWasmName, uriToWasmMountPoint, wasmPathToUriText, @@ -230,14 +231,14 @@ export async function activate(context: vscode.ExtensionContext): Promise ); } const module = wasm.compile(vscode.Uri.joinPath(modulePackageUri, "tsc.wasm")); - const mountPoints = (vscode.workspace.workspaceFolders ?? []).map(folder => { + const mountPoints = outermostMountPoints((vscode.workspace.workspaceFolders ?? []).map(folder => { const mountPoint = uriToWasmMountPoint(folder.uri); return { kind: "vscodeFileSystem" as const, uri: folder.uri.scheme === "file" ? folder.uri.with({ query: "", fragment: "" }) : vscode.Uri.from({ scheme: wasmFileSystemScheme, path: mountPoint }), mountPoint, }; - }); + })); // Blocking stdin reads stall the Go scheduler, so the server cannot answer requests // while waiting for input. `@vscode/wasm-wasi` does not declare this option yet. const stdin = { kind: "pipeIn" as const, nonBlocking: true }; diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index 76ce4ea9346e3..20293bbd16240 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -45,6 +45,23 @@ export function uriToWasmMountPoint(uri: WebFileSystemUri): string { return `/${dynamicURIFileNamePrefix}${[scheme, authority, ...segments].join("/")}`; } +/** + * Drops mounts that are the same as, or nested below, another mount on the + * same backing file system, since the WASI host cannot mount a folder over an + * existing mount. + */ +export function outermostMountPoints(mounts: readonly T[]): T[] { + const withSlash = (path: string) => path.endsWith("/") ? path : `${path}/`; + const sorted = [...mounts].sort((a, b) => a.mountPoint.length - b.mountPoint.length); + const kept: T[] = []; + for (const mount of sorted) { + if (!kept.some(outer => outer.uri.scheme === mount.uri.scheme && withSlash(mount.mountPoint).startsWith(withSlash(outer.mountPoint)))) { + kept.push(mount); + } + } + return kept; +} + /** * Maps a file name below a virtual workspace mount back to the URI the server * derived it from, or returns undefined for names no document URI produces. diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index 53163d2f9e1fc..a1dbbd3ba651b 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -4,6 +4,7 @@ import { test, } from "node:test"; import { + outermostMountPoints, uriSegmentToWasmName, uriToWasmMountPoint, wasmPathToUriText, @@ -57,6 +58,16 @@ describe("web WASI mount points", () => { assert.strictEqual(wasmPathToUriText("/work/a.ts"), undefined); }); + test("drops mounts nested below or equal to another mount", () => { + const mounts = (...paths: string[]) => outermostMountPoints(paths.map(mountPoint => ({ mountPoint, uri: { scheme: "ts-wasi" } }))).map(mount => mount.mountPoint); + assert.deepStrictEqual(mounts("/^/~ts-uri~/memfs/host/project/src", "/^/~ts-uri~/memfs/host/project"), ["/^/~ts-uri~/memfs/host/project"]); + assert.deepStrictEqual(mounts("/^/~ts-uri~/memfs/host/project", "/^/~ts-uri~/memfs/host/project"), ["/^/~ts-uri~/memfs/host/project"]); + assert.deepStrictEqual(mounts("/work/a", "/work/ab"), ["/work/a", "/work/ab"]); + assert.deepStrictEqual(mounts("/work", "/"), ["/"]); + const mixed = outermostMountPoints([{ mountPoint: "/", uri: { scheme: "file" } }, { mountPoint: "/^/~ts-uri~/memfs/host", uri: { scheme: "ts-wasi" } }]); + assert.strictEqual(mixed.length, 2); + }); + test("names directory entries as the server names them in document URIs", () => { assert.strictEqual(uriSegmentToWasmName("a%20b.ts", true), "a%20b.ts"); assert.strictEqual(uriSegmentToWasmName("a\\b.d.ts", true), "~ts-uri-escape~615c62~.d.ts"); From a920ac3bd05c7f735d211419407538ede9d13527 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:09:54 -0700 Subject: [PATCH 099/142] Match serialized drive letter and authority case in file mounts VS Code serializes file URIs with a lowercase drive letter and authority, and the server derives file names from that text. Mounting at the URI's unserialized path left documents in a folder like C:/Project outside the case-sensitive WASI mount, so the server could not read unopened files there. --- packages/vscode-typescript/src/webMountPoint.ts | 6 ++++-- packages/vscode-typescript/test/webMountPoint.test.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index 20293bbd16240..77389f32b2ecd 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -11,12 +11,14 @@ const nulAuthority = "ts-nul-authority"; export function uriToWasmMountPoint(uri: WebFileSystemUri): string { if (uri.scheme === "file") { + // Document URIs are serialized with a lowercase authority and drive + // letter, and the server names files from that serialized form. let fileName: string; if (uri.authority) { - fileName = `//${uri.authority}${uri.path}`; + fileName = `//${uri.authority.toLowerCase()}${uri.path}`; } else { - fileName = /^\/[A-Za-z]:/.test(uri.path) ? uri.path.slice(1) : uri.path; + fileName = /^\/[A-Za-z]:/.test(uri.path) ? uri.path[1].toLowerCase() + uri.path.slice(2) : uri.path; } return fileName.startsWith("/") ? fileName : `/${fileName}`; } diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index a1dbbd3ba651b..98938673dfe7d 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -23,6 +23,10 @@ describe("web WASI mount points", () => { uriToWasmMountPoint({ scheme: "file", authority: "", path: "/c:/work/project" }), "/c:/work/project", ); + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "", path: "/C:/Work/Project" }), + "/c:/Work/Project", + ); }); test("maps UNC workspaces to UNC paths", () => { @@ -30,6 +34,10 @@ describe("web WASI mount points", () => { uriToWasmMountPoint({ scheme: "file", authority: "server", path: "/share/project" }), "//server/share/project", ); + assert.strictEqual( + uriToWasmMountPoint({ scheme: "file", authority: "Server.Name", path: "/Share/Project" }), + "//server.name/Share/Project", + ); }); test("maps virtual workspaces to the paths the server derives from document URIs", () => { From d6440e8b4fbb85dbf10d03846c3625cc2abe3372 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:15:00 -0700 Subject: [PATCH 100/142] Keep realpath results in the compiler namespace under WASI Dynamic URI paths and, without drive mapping, drive-rooted paths are relative in WASI and resolve against the working directory, where the browser host mounts workspaces. Realpath made them absolute, so a dependency under ^/~ts-uri~/memfs/... came back as /^/~ts-uri~/..., which the compiler treats as a different, POSIX file. Navigation into dependencies then produced file: URIs instead of the workspace's own. --- packages/typescript/test/sync/wasm.test.ts | 50 ++++++++++++++++++++++ tsc/internal/vfs/osvfs/os.go | 2 +- tsc/internal/vfs/osvfs/ospath.go | 2 +- tsc/internal/vfs/osvfs/ospath_wasip1.go | 34 ++++++++++++++- 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 4db915280f482..b70c170ccda99 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -9,10 +9,12 @@ import binaryen from "binaryen"; import assert from "node:assert"; import { spawn } from "node:child_process"; import { + mkdir, mkdtemp, open, readFile, rm, + writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -109,6 +111,54 @@ describe("API over WebAssembly", () => { } }); + test("keeps paths resolved below the working directory in the compiler namespace", async () => { + const WebAssembly = (globalThis as any).WebAssembly; + const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); + try { + const root = path.join(directory, "root"); + // Windows cannot create a directory named like a drive. + const projects = ["^/~ts-uri~/memfs/host/project", ...process.platform === "win32" ? [] : ["c:/project"]]; + for (const project of projects) { + const projectDir = path.join(root, project); + await mkdir(path.join(projectDir, "node_modules", "pkg"), { recursive: true }); + await writeFile(path.join(projectDir, "tsconfig.json"), `{ "compilerOptions": { "noEmit": true, "noLib": true, "types": [] } }`); + await writeFile(path.join(projectDir, "index.ts"), `import { x } from "pkg";`); + await writeFile(path.join(projectDir, "node_modules", "pkg", "package.json"), `{ "name": "pkg", "types": "index.d.ts" }`); + await writeFile(path.join(projectDir, "node_modules", "pkg", "index.d.ts"), `export declare const x: number;`); + } + const module = await WebAssembly.compile(await readFile(wasmURL)); + // The same directories named by absolute paths stay POSIX paths. + for (const project of [...projects, ...projects.map(project => `/${project}`)]) { + const stdoutPath = path.join(directory, "stdout"); + const stdout = await open(stdoutPath, "w+"); + try { + const wasi = new WASI({ + version: "preview1", + args: ["tsc.wasm", "-p", project, "--listFilesOnly"], + env: { PWD: "/" }, + preopens: { "/": root }, + stdout: stdout.fd, + returnOnExit: true, + }); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport, + }); + assert.strictEqual(wasi.start(instance), 0); + } + finally { + await stdout.close(); + } + assert.deepStrictEqual( + (await readFile(stdoutPath, "utf8")).trim().split("\n").sort(), + [`${project}/index.ts`, `${project}/node_modules/pkg/index.d.ts`], + ); + } + } + 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-")); diff --git a/tsc/internal/vfs/osvfs/os.go b/tsc/internal/vfs/osvfs/os.go index bcd1f46bc7400..59b813642f7df 100644 --- a/tsc/internal/vfs/osvfs/os.go +++ b/tsc/internal/vfs/osvfs/os.go @@ -159,7 +159,7 @@ func osFSRealpath(path tspath.RootedPath) tspath.RootedPath { if nativepath.IsNetworkPath(nativePath) { return orig } - return tspath.RootedPathFromAbsolute(fromOSPath(nativePath)) + return tspath.RootedPathFromAbsolute(fromOSPath(nativePath, orig.AsString())) } func isReparsePoint(path string) bool { diff --git a/tsc/internal/vfs/osvfs/ospath.go b/tsc/internal/vfs/osvfs/ospath.go index 357ec45cf4e18..ffec82343f6db 100644 --- a/tsc/internal/vfs/osvfs/ospath.go +++ b/tsc/internal/vfs/osvfs/ospath.go @@ -6,6 +6,6 @@ func toOSPath(path string) string { return path } -func fromOSPath(path string) string { +func fromOSPath(path string, original string) string { return path } diff --git a/tsc/internal/vfs/osvfs/ospath_wasip1.go b/tsc/internal/vfs/osvfs/ospath_wasip1.go index a4863ded74eac..b24e597c116b7 100644 --- a/tsc/internal/vfs/osvfs/ospath_wasip1.go +++ b/tsc/internal/vfs/osvfs/ospath_wasip1.go @@ -13,6 +13,22 @@ var mapDrives = isDriveRooted(os.Getenv("PWD")) const driveMountPrefix = "/mnt/" +// Dynamic URI paths (^/...) and, without drive mapping, drive-rooted paths +// are relative in WASI, so they resolve against the working directory. The +// browser host mounts workspaces there for them. +var relativeRoot = relativeRootFor(os.Getenv("PWD")) + +func relativeRootFor(pwd string) string { + if pwd == "" { + pwd = "/" + } + root := toOSPath(pwd) + if !strings.HasSuffix(root, "/") { + root += "/" + } + return root +} + func toOSPath(path string) string { if !mapDrives || !isDriveRooted(path) { return path @@ -20,7 +36,19 @@ func toOSPath(path string) string { return driveMountPrefix + strings.ToLower(path[:1]) + path[2:] } -func fromOSPath(path string) string { +// fromOSPath converts a path the OS resolved from original back to a +// compiler path. +func fromOSPath(path string, original string) string { + if isRelativeInWASI(original) { + if rel, ok := strings.CutPrefix(path, relativeRoot); ok { + if len(rel) == 2 && isDriveLetter(rel[0]) && rel[1] == ':' { + rel += "/" + } + if isRelativeInWASI(rel) { + return rel + } + } + } rest, ok := strings.CutPrefix(path, driveMountPrefix) if !mapDrives || !ok || len(rest) == 0 || !isDriveLetter(rest[0]) || len(rest) > 1 && rest[1] != '/' { return path @@ -31,6 +59,10 @@ func fromOSPath(path string) string { return strings.ToUpper(rest[:1]) + ":" + rest[1:] } +func isRelativeInWASI(path string) bool { + return strings.HasPrefix(path, "^/") || !mapDrives && isDriveRooted(path) +} + func isDriveRooted(path string) bool { return len(path) >= 3 && isDriveLetter(path[0]) && path[1] == ':' && (path[2] == '/' || path[2] == '\\') } From 0307988bcf29e94be606b1e8590c97b27f998f90 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:44:26 -0700 Subject: [PATCH 101/142] Share the guards around reactor exports Each export that touches the session repeated the same re-entrancy, session, length, and panic handling, which made it easy for one of them to drift. Also drop an unused pointer helper. --- tsc/cmd/tsc/wasmapi_wasip1.go | 135 ++++++++++++---------------------- 1 file changed, 49 insertions(+), 86 deletions(-) diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index b50dd47ec5614..8c247d6466e51 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -107,107 +107,77 @@ func getRequestBuffer(size uint32) uint32 { } //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())) +func handleRequest(methodLen uint32, payloadLen uint32) uint32 { + return guarded("handling request", methodLen, payloadLen, func() uint32 { + 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()) } - }() - - 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 + setResponse(response.Data) + return 0 + }) } //go:wasmexport set_file -func setFile(pathLen uint32, contentLen uint32) (status 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") - } - defer func() { - if recovered := recover(); recovered != nil { - status = fail(fmt.Sprintf("panic setting file: %v\n%s", recovered, debug.Stack())) +func setFile(pathLen uint32, contentLen uint32) uint32 { + return guarded("setting file", pathLen, contentLen, func() uint32 { + path := string(requestBuffer[:pathLen]) + content := string(requestBuffer[pathLen : pathLen+contentLen]) + if err := reactor.SetFile(path, content); err != nil { + return fail(err.Error()) } - }() - 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 + 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())) +func readFile(pathLen uint32) uint32 { + return guarded("reading file", pathLen, 0, func() uint32 { + content, ok := reactor.ReadFile(string(requestBuffer[:pathLen])) + if !ok { + setResponse(nil) + return 2 } - }() - content, ok := reactor.ReadFile(string(requestBuffer[:pathLen])) - if !ok { - setResponse(nil) - return 2 - } - setResponse([]byte(content)) - return 0 + setResponse([]byte(content)) + return 0 + }) } //go:wasmexport remove_file -func removeFile(pathLen uint32) (status uint32) { +func removeFile(pathLen uint32) uint32 { + return guarded("removing file", pathLen, 0, func() uint32 { + if err := reactor.RemoveFile(string(requestBuffer[:pathLen])); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return 0 + }) +} + +// guarded runs an export that operates on the session and the first+second +// bytes of the request buffer, rejecting re-entrant calls and reporting panics +// as failures. +func guarded(operation string, first uint32, second uint32, run func() uint32) (status uint32) { if inCall { - return fail("re-entrant remove_file: a request is already in flight") + return fail(operation + ": re-entrant call while a request is 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 !withinRequest(first, second) { + return fail(operation + ": lengths exceed the request buffer") } + inCall = true defer func() { + inCall = false if recovered := recover(); recovered != nil { - status = fail(fmt.Sprintf("panic removing file: %v\n%s", recovered, debug.Stack())) + status = fail(fmt.Sprintf("panic %s: %v\n%s", operation, recovered, debug.Stack())) } }() - if err := reactor.RemoveFile(string(requestBuffer[:pathLen])); err != nil { - return fail(err.Error()) - } - setResponse(nil) - return 0 + return run() } //go:wasmexport response_ptr @@ -247,13 +217,6 @@ func bytesPointer(bytes []byte) uint32 { return uint32(uintptr(unsafe.Pointer(&bytes[0]))) } -func bytePointer(bytes []byte) *byte { - if len(bytes) == 0 { - return nil - } - return &bytes[0] -} - type hostCallbackConn struct{} var _ ipc.Conn = hostCallbackConn{} From fc1d6de75772ce3401f75b97e7dbeeae1b80b64a Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:44:41 -0700 Subject: [PATCH 102/142] Share host error decoding between reactor callbacks Both host exchanges decode the error region the same way but clamped its length differently, which invited the two to diverge. --- tsc/cmd/tsc/wasmapi_wasip1.go | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index 8c247d6466e51..fc1b173d1841b 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -271,13 +271,17 @@ func callHost(buffer []byte, method string, params []byte, resultCapacity int) ( case errnoBadFileNumber: return nil, errno, errors.New("host callbacks are not supported by this WebAssembly host") } - errorStart := 16 + len(method) + len(params) + resultCapacity - errorLength := min(int(binary.LittleEndian.Uint32(buffer[12:])), hostErrorCapacity) - message := string(buffer[errorStart : errorStart+errorLength]) + return nil, errno, hostError(buffer[16+len(method)+len(params)+resultCapacity:], binary.LittleEndian.Uint32(buffer[12:]), "host callback failed") +} + +// hostError reads the error message a host wrote into the error region of an +// exchange buffer. +func hostError(region []byte, length uint32, fallback string) error { + message := string(region[:min(int(length), hostErrorCapacity)]) if message == "" { - message = "host callback failed" + message = fallback } - return nil, errno, errors.New(message) + return errors.New(message) } func (hostCallbackConn) Notify(context.Context, string, any) error { @@ -304,16 +308,7 @@ func writeFileToHost(path string, data string) (bool, error) { 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) + return true, hostError(buffer[12+len(pathBytes)+len(dataBytes):], binary.LittleEndian.Uint32(buffer[8:]), "host filesystem callback failed") } //go:wasmimport wasi_snapshot_preview1 fd_write From 34af26dfd00080df3e174f7cbfe778f9e8fd8436 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:45:07 -0700 Subject: [PATCH 103/142] Return plain bytes from the reactor and drop its close guard The response wrapper carried a single field, and the only caller of Close already clears its reference first, so neither earned its indirection. --- tsc/cmd/tsc/wasmapi_wasip1.go | 2 +- tsc/internal/api/wasmreactor/reactor.go | 36 ++++++++------------ tsc/internal/api/wasmreactor/reactor_test.go | 6 ++-- 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index fc1b173d1841b..0d0cd62b6d355 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -115,7 +115,7 @@ func handleRequest(methodLen uint32, payloadLen uint32) uint32 { if err != nil { return fail(err.Error()) } - setResponse(response.Data) + setResponse(response) return 0 }) } diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go index 7aeb3e45e9ba5..b0bda014bb27a 100644 --- a/tsc/internal/api/wasmreactor/reactor.go +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -3,7 +3,6 @@ package wasmreactor import ( "context" - "sync" "time" "github.com/microsoft/TypeScript/tsc/internal/api" @@ -24,18 +23,13 @@ type Options struct { Conn ipc.Conn `json:"-"` } -type Response struct { - Data []byte -} - type Reactor struct { - ctx context.Context - cancel context.CancelFunc - session *api.Session - files vfs.FS - cwd tspath.RootedDirectoryPath - timing *ipc.ServerTimingCollector - closeOnce sync.Once + ctx context.Context + cancel context.CancelFunc + session *api.Session + files vfs.FS + cwd tspath.RootedDirectoryPath + timing *ipc.ServerTimingCollector } func New(ctx context.Context, options Options) *Reactor { @@ -80,7 +74,7 @@ func New(ctx context.Context, options Options) *Reactor { return reactor } -func (r *Reactor) HandleRequest(method string, payload []byte) (Response, error) { +func (r *Reactor) HandleRequest(method string, payload []byte) ([]byte, error) { switch method { case ipc.MethodGetServerTiming: return marshalResponse(ipc.ServerTimingSnapshot(r.timing)) @@ -97,20 +91,20 @@ func (r *Reactor) HandleRequest(method string, payload []byte) (Response, error) r.timing.Record(method, time.Since(start)) } if err != nil { - return Response{}, err + return nil, err } if raw, ok := result.(api.RawBinary); ok { - return Response{Data: []byte(raw)}, nil + return []byte(raw), nil } return marshalResponse(result) } -func marshalResponse(result any) (Response, error) { +func marshalResponse(result any) ([]byte, error) { encoded, err := json.Marshal(result) if err != nil { - return Response{}, err + return nil, err } - return Response{Data: encoded}, nil + return encoded, nil } func (r *Reactor) SetFile(path string, content string) error { @@ -126,8 +120,6 @@ func (r *Reactor) RemoveFile(path string) error { } func (r *Reactor) Close() { - r.closeOnce.Do(func() { - r.cancel() - r.session.Close() - }) + r.cancel() + r.session.Close() } diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go index 05baf30720494..ffd117c967c0a 100644 --- a/tsc/internal/api/wasmreactor/reactor_test.go +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -33,7 +33,7 @@ func TestReactor(t *testing.T) { var initializeResponse struct { CurrentDirectory string `json:"currentDirectory"` } - if unmarshalErr := json.Unmarshal(initialize.Data, &initializeResponse); unmarshalErr != nil { + if unmarshalErr := json.Unmarshal(initialize, &initializeResponse); unmarshalErr != nil { t.Fatal(unmarshalErr) } if initializeResponse.CurrentDirectory != "/" { @@ -45,8 +45,8 @@ func TestReactor(t *testing.T) { if err != nil { t.Fatal(err) } - if !bytes.Equal(echo.Data, payload) { - t.Fatalf("echo = %q", echo.Data) + if !bytes.Equal(echo, payload) { + t.Fatalf("echo = %q", echo) } if err := reactor.RemoveFile("/src/index.ts"); err != nil { From 4dfcffca99b068945aa8ffe80922b7b12af9ee54 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:45:26 -0700 Subject: [PATCH 104/142] Fold the WASI descriptor reader into the stdin reader Stdin is its only caller, so a separate file and descriptor parameter only spread one loop across two places. --- tsc/cmd/tsc/lsp_stdin_wasip1.go | 21 ++++++++++++++++++++- tsc/cmd/tsc/wasi_io_wasip1.go | 30 ------------------------------ 2 files changed, 20 insertions(+), 31 deletions(-) delete mode 100644 tsc/cmd/tsc/wasi_io_wasip1.go diff --git a/tsc/cmd/tsc/lsp_stdin_wasip1.go b/tsc/cmd/tsc/lsp_stdin_wasip1.go index 1e32a0083187c..3cbaca907d214 100644 --- a/tsc/cmd/tsc/lsp_stdin_wasip1.go +++ b/tsc/cmd/tsc/lsp_stdin_wasip1.go @@ -3,13 +3,32 @@ package main import ( + "io" "syscall" + "time" ) +const maxStdinPollDelay = 50 * time.Millisecond + type wasiStdin struct{} func (wasiStdin) Read(buffer []byte) (int, error) { - return readWasiFD(syscall.Stdin, buffer) + delay := time.Millisecond + for { + n, err := syscall.Read(syscall.Stdin, buffer) + if n > 0 { + return n, nil + } + if err == nil { + return 0, io.EOF + } + if err != syscall.EAGAIN { + return 0, err + } + // Stdin is not registered with Go's WASI poller. + time.Sleep(delay) + delay = min(delay*2, maxStdinPollDelay) + } } func (wasiStdin) Close() error { diff --git a/tsc/cmd/tsc/wasi_io_wasip1.go b/tsc/cmd/tsc/wasi_io_wasip1.go deleted file mode 100644 index c26da883f6bf9..0000000000000 --- a/tsc/cmd/tsc/wasi_io_wasip1.go +++ /dev/null @@ -1,30 +0,0 @@ -//go:build wasip1 - -package main - -import ( - "io" - "syscall" - "time" -) - -const maxWasiPollDelay = 50 * time.Millisecond - -func readWasiFD(fd int, buffer []byte) (int, error) { - delay := time.Millisecond - for { - n, err := syscall.Read(fd, buffer) - if n > 0 { - return n, nil - } - if err == nil { - return 0, io.EOF - } - if err != syscall.EAGAIN { - return 0, err - } - // These descriptors are not registered with Go's WASI poller. - time.Sleep(delay) - delay = min(delay*2, maxWasiPollDelay) - } -} From 966da47b465f9f48445db2d2075398641d017c61 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:46:24 -0700 Subject: [PATCH 105/142] Share the common transport client options The sync and async transport options differ only in their transport, so the shared fields and the type guard no longer need to be duplicated. --- packages/typescript/src/api/async/client.ts | 4 +-- packages/typescript/src/api/options.ts | 31 ++++++++------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index cc99e6dbb1eb4..690030af551a7 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -18,8 +18,8 @@ import { type ClientSocketOptions, type ClientSpawnOptions, getAPIProcessArgs, - isAsyncTransportOptions, isSpawnOptions, + isTransportOptions, resolveExePath, } from "../options.ts"; import type { @@ -50,7 +50,7 @@ export type { AsyncTransport } from "./transport.ts"; export type Client = TransportClient | RpcClient; export function createClient(options: AsyncClientOptions): Client { - return isAsyncTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); + return isTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); } /** diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 1081ec01af653..5546d66d1cf61 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -49,13 +49,7 @@ export function isSpawnOptions(options: ClientOptions): options is ClientSpawnOp return !("pipe" in options); } -export interface ClientTransportOptions { - /** - * An existing synchronous transport connected to an API session. - * Custom module resolution callbacks must obey the transport's reentrancy - * constraints. - */ - transport: SyncTransport; +interface TransportOptionsBase { /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ maxResponseBytesPerPage?: number | undefined; /** Virtual filesystem callbacks used by transports that support them. */ @@ -64,13 +58,22 @@ export interface ClientTransportOptions { collectTiming?: boolean | undefined; } +export interface ClientTransportOptions extends TransportOptionsBase { + /** + * An existing synchronous transport connected to an API session. + * Custom module resolution callbacks must obey the transport's reentrancy + * constraints. + */ + transport: SyncTransport; +} + export type SyncClientOptions = ClientSocketOptions | ClientSpawnOptions | ClientTransportOptions; -export function isTransportOptions(options: SyncClientOptions): options is ClientTransportOptions { +export function isTransportOptions(options: T): options is Extract { return "transport" in options; } -export interface AsyncClientTransportOptions { +export interface AsyncClientTransportOptions extends TransportOptionsBase { /** * An existing asynchronous transport connected to an API session. * Host callbacks are synchronous even when requests are asynchronous, so @@ -78,20 +81,10 @@ export interface AsyncClientTransportOptions { * the transport's reentrancy constraints. */ transport: AsyncTransport; - /** Maximum encoded byte size of each batch response page. Defaults to 300 million bytes. Individual responses can be larger than this size, but this controls where batch pages are cutoff. */ - maxResponseBytesPerPage?: number | undefined; - /** Virtual filesystem callbacks used by transports that support them. */ - fs?: FileSystemCallbacks | undefined; - /** Collect timing information for requests made through the transport. */ - collectTiming?: boolean | undefined; } export type AsyncClientOptions = ClientOptions | AsyncClientTransportOptions; -export function isAsyncTransportOptions(options: AsyncClientOptions): options is AsyncClientTransportOptions { - return "transport" in options; -} - export function resolveExePath(options: ClientSpawnOptions): string { return options.tsserverPath ?? getDefaultExePath(); } From 7586544a4d616d06f22b451d03959faaebb9c655 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:46:38 -0700 Subject: [PATCH 106/142] Keep the async RPC client's options required The options are always provided by the constructor, so treating them as optional only added unreachable checks. --- packages/typescript/src/api/async/client.ts | 960 ++++++++++---------- 1 file changed, 479 insertions(+), 481 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 690030af551a7..5ad2523f0be2e 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -1,481 +1,479 @@ -import type { ChildProcess } from "node:child_process"; -import type { Socket } from "node:net"; -import type { - RootedDirectoryPath, - RootedFilePath, - RootedPath, -} from "../../ast/index.ts"; -import type { FileSystemCallbacks } from "../fs.ts"; -import { - configureFileSystemCallbacks, - encodeFileSystemCallbackResult, - type FileSystemCallbackConfiguration, -} from "../fsCallbacks.ts"; -import { nodeProcess } from "../nodeProcess.ts"; -import { - type AsyncClientOptions, - type ClientOptions, - type ClientSocketOptions, - type ClientSpawnOptions, - getAPIProcessArgs, - isSpawnOptions, - isTransportOptions, - resolveExePath, -} from "../options.ts"; -import type { - APIMethodInfo, - APIRequest, - BatchRequestsParams, - BatchRequestsResponse, - SourceFileResponseMethod, -} from "../proto.ts"; -import { - combineTimingInfo, - disabledServerTimingInfo, - disabledTimingInfo, - type ServerTimingInfo, - TimingCollector, - type TimingInfo, -} from "../timing.ts"; -import { TimingDump } from "../timingDump.ts"; -import type * as JsonRpc from "./jsonrpc.ts"; -import { - createTransportClient, - type TransportClient, -} from "./transportClient.ts"; - -export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; -export type { AsyncTransport } from "./transport.ts"; - -export type Client = TransportClient | RpcClient; - -export function createClient(options: AsyncClientOptions): Client { - return isTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); -} - -/** - * Client handles communication with the TypeScript API server - * over STDIO (spawned process) or a Unix domain socket using JSON-RPC. - */ -class RpcClient { - private socket: Socket | undefined; - private process: ChildProcess | undefined; - private connection: JsonRpc.MessageConnection | undefined; - // Loaded on connection so that injected-transport clients never load it. - private jsonrpc: typeof JsonRpc | undefined; - private options: ClientOptions | undefined; - private connected = false; - private closed = false; - private connecting: Promise | undefined; - private timing: TimingCollector | undefined; - private timingDump: TimingDump | undefined; - private closing: Promise | undefined; - 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) { - this.timingDump = TimingDump.fromEnvironment("async", isSpawnOptions(options) ? "spawn" : "socket"); - if (this.timingDump || isSpawnOptions(options) && options.collectTiming) { - this.timing = new TimingCollector(); - } - if (this.timingDump && isSpawnOptions(options)) { - options = { ...options, collectTiming: true }; - } - this.options = options; - if (isSpawnOptions(options)) { - configureFileSystemCallbacks(options.fs); - } - } - - connect(): Promise { - if (this.closed) return Promise.reject(new Error("Client is closed")); - if (this.connected) return Promise.resolve(); - return this.connecting ??= this.connectWorker().finally(() => { - this.connecting = undefined; - }); - } - - private async connectWorker(): Promise { - if (!this.options) throw new Error("Client options are not available"); - this.jsonrpc ??= await import("./jsonrpc.ts"); - if (isSpawnOptions(this.options)) { - await this.connectViaSpawn(this.options); - } - else { - await this.connectViaSocket(this.options); - } - } - - private async connectViaSpawn(options: ClientSpawnOptions): Promise { - const { spawn } = nodeProcess().getBuiltinModule("node:child_process"); - - return new Promise((resolve, reject) => { - const args = getAPIProcessArgs(options, true); - const fsConfiguration = configureFileSystemCallbacks(options.fs); - - if (fsConfiguration.arguments.length > 0) { - args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); - } - - this.process = spawn(resolveExePath(options), args, { - stdio: ["pipe", "pipe", "inherit"], - }); - - this.process.once("error", error => { - reject(new Error(`Failed to start tsgo process: ${error.message}`)); - }); - - this.process.once("spawn", () => { - this.connected = true; - resolve(); - }); - - this.connection = this.jsonrpc!.createStreamConnection(this.process.stdout!, this.process.stdin!); - this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); - this.connection.listen(); - }); - } - - private async connectViaSocket(options: ClientSocketOptions): Promise { - const { createConnection } = nodeProcess().getBuiltinModule("node:net"); - - return new Promise((resolve, reject) => { - this.socket = createConnection(options.pipe, () => { - this.connection = this.jsonrpc!.createStreamConnection(this.socket!, this.socket!); - this.connection.listen(); - this.connected = true; - resolve(); - }); - - this.socket.once("error", error => { - reject(new Error(`Socket error: ${error.message}`)); - }); - }); - } - - private registerFSCallbacks( - connection: JsonRpc.MessageConnection, - fs: FileSystemCallbacks | undefined, - configuration: FileSystemCallbackConfiguration, - ): void { - if (!fs) return; - for (const name of configuration.callbackNames) { - switch (name) { - case "readFile": { - const callback = fs.readFile; - if (typeof callback !== "function") throw new Error("Invalid readFile callback configuration"); - connection.onRequest(this.requestType(name), fileName => { - return encodeFileSystemCallbackResult(name, callback(fileName)); - }); - break; - } - case "fileExists": { - const callback = fs.fileExists; - if (typeof callback !== "function") throw new Error("Invalid fileExists callback configuration"); - connection.onRequest(this.requestType(name), fileName => { - return encodeFileSystemCallbackResult(name, callback(fileName)); - }); - break; - } - case "directoryExists": { - const callback = fs.directoryExists; - if (typeof callback !== "function") throw new Error("Invalid directoryExists callback configuration"); - connection.onRequest(this.requestType(name), directoryName => { - return encodeFileSystemCallbackResult(name, callback(directoryName)); - }); - break; - } - case "getAccessibleEntries": { - const callback = fs.getAccessibleEntries; - if (typeof callback !== "function") throw new Error("Invalid getAccessibleEntries callback configuration"); - connection.onRequest(this.requestType(name), directoryName => { - return encodeFileSystemCallbackResult(name, callback(directoryName)); - }); - break; - } - case "realpath": { - const callback = fs.realpath; - if (typeof callback !== "function") throw new Error("Invalid realpath callback configuration"); - connection.onRequest(this.requestType(name), path => { - return encodeFileSystemCallbackResult(name, callback(path)); - }); - break; - } - case "stat": { - const callback = fs.stat; - if (typeof callback !== "function") throw new Error("Invalid stat callback configuration"); - connection.onRequest(this.requestType(name), path => { - return encodeFileSystemCallbackResult(name, callback(path)); - }); - break; - } - case "writeFile": { - const callback = fs.writeFile; - if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); - connection.onRequest(this.requestType<{ path: RootedFilePath; data: string; }, unknown>(name), arg => { - return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); - }); - break; - } - case "removeFile": { - const callback = fs.removeFile; - if (typeof callback !== "function") throw new Error("Invalid removeFile callback configuration"); - connection.onRequest(this.requestType(name), path => { - return encodeFileSystemCallbackResult(name, callback(path)); - }); - break; - } - } - } - } - - private requestType(method: string): JsonRpc.RequestType { - if (!this.jsonrpc) throw new Error("Connection not established"); - return new this.jsonrpc.RequestType(method); - } - - private async sendRequestWithTiming(requestType: JsonRpc.RequestType, params: unknown): Promise { - if (!this.connection) { - throw new Error("Connection not established"); - } - - if (!this.timing) { - return this.connection.sendRequest(requestType, params); - } - - // Round-trip latency is measured here; byte counts approximate the wire - // payload via the serialized JSON. Server-side processing time is not - // carried on the response; it is retrieved separately (via a - // getServerTiming request) and folded in by getTimingInfo(). - const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); - const start = performance.now(); - const result = await this.connection.sendRequest(requestType, params); - const roundTripMs = performance.now() - start; - this.timing.record({ - method: requestType.method, - roundTripMs, - bytesSent, - bytesReceived: result === undefined || result === null - ? 0 - : Buffer.byteLength(JSON.stringify(result), "utf-8"), - }); - return result; - } - - registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { - if (!this.connection) { - throw new Error("Connection not established"); - } - const disposable = this.connection.onRequest(this.requestType(name), callback); - return () => disposable.dispose(); - } - - private async doBatch(): Promise { - this.nextBatch = undefined; - if (!this.batchedRequests.length) return; - const requests = this.batchedRequests; - this.batchedRequests = []; - try { - if (!this.connected) { - await this.connect(); - } - if (!this.connection) { - throw new Error("Connection not established"); - } - - if (requests.length === 1) { - // send single queued requests directly instead of as a batched request - const requestType = this.requestType(requests[0].method); - const response = await this.sendRequestWithTiming(requestType, requests[0].params); - requests[0].resolve(response); - return; - } - - const requestType = this.requestType("batchRequests"); - const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; - const maxResponseBytesPerPage = this.options?.maxResponseBytesPerPage; - if (maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = maxResponseBytesPerPage; - } - const response = await this.sendRequestWithTiming(requestType, params); - let responses = response.responses; - let continuationToken = response.continuationToken; - while (continuationToken) { - const pageParams: BatchRequestsParams = { - requests: [], - continuationToken, - }; - if (maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = maxResponseBytesPerPage; - } - const page = await this.sendRequestWithTiming(requestType, pageParams); - 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); - } - } - } - catch (error) { - for (const { reject } of requests) reject(error); - } - } - - private scheduleImmediateBatch(): void { - if (this.closed || this.nextBatch) return; - this.nextBatch = setImmediate(this.doBatch.bind(this)); - } - - batchContext(): { [Symbol.dispose](): void; } { - if (this.nextBatch === "manual") { - throw new Error("Already in a manual batch context"); - } - if (this.nextBatch) { - clearImmediate(this.nextBatch); - this.doBatch(); // empty the queue before entering a manual batch context - } - this.nextBatch = "manual"; - return { - [Symbol.dispose]: () => { - this.nextBatch = undefined; - this.scheduleImmediateBatch(); - }, - }; - } - - 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 (!this.connection) { - throw new Error("Connection not established"); - } - - const resultPromise = new Promise((resolve, reject) => { - this.batchedRequests.push({ method, params, resolve, reject }); - this.scheduleImmediateBatch(); - }); - return resultPromise; - } - - async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { - const response = await this.apiRequest(method, params); - if (!response) return undefined; - const buffer = Buffer.from(response.data, "base64"); - return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - } - - /** - * 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; - } - - /** - * 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. - */ - async getTimingInfo(): Promise { - if (!this.timing) { - return disabledTimingInfo(); - } - const local = this.timing.getInfo(); - // No requests have been sent yet: nothing to fetch from the server. - if (!this.connected || !this.connection) { - return local; - } - return combineTimingInfo(local, await this.fetchServerTiming()); - } - - async resetTimingInfo(): Promise { - if (!this.timing) return; - this.timing.reset(); - if (this.connected && this.connection) { - // Keep the server's collection in sync so combined totals stay meaningful. - const requestType = this.requestType("resetServerTiming"); - await this.connection.sendRequest(requestType, undefined); - } - } - - private async fetchServerTiming(): Promise { - if (!this.connection) { - return disabledServerTimingInfo(); - } - // Fetch the server's own timing collection via a dedicated request. This - // bypasses the client-side collector so the query does not pollute it. - const requestType = this.requestType("getServerTiming"); - return this.connection.sendRequest(requestType, undefined); - } - - close(): Promise { - return this.closing ??= this.closeWorker(); - } - - private async closeWorker(): Promise { - this.closed = true; - if (this.nextBatch && this.nextBatch !== "manual") { - clearImmediate(this.nextBatch); - } - this.nextBatch = undefined; - const requests = this.batchedRequests; - this.batchedRequests = []; - for (const { reject } of requests) { - reject(new Error("Client is closed")); - } - await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection - try { - if (this.timingDump && this.timing) { - const local = this.timing.getInfo(); - let server: ServerTimingInfo | undefined; - let serverTimingError: string | undefined; - const connection = this.connection; - // Closing alone does not reject pending JSON-RPC requests. - const onClose = connection?.onClose(() => connection.dispose()); - try { - if (this.connected) server = await this.fetchServerTiming(); - } - catch (error) { - serverTimingError = String(error); - } - finally { - onClose?.dispose(); - } - this.timingDump.write(local, server, serverTimingError); - } - } - finally { - if (this.connection) { - this.connection.dispose(); - this.connection = undefined; - } - if (this.socket) { - this.socket.destroy(); - this.socket = undefined; - } - if (this.process) { - // Close stdin to unblock the server's read loop, allowing it to exit cleanly. - // The server is blocked on stdin.Read(), so just sending SIGTERM would deadlock: - // - Node won't exit while child is alive - // - Child can't process SIGTERM while blocked on read - // - Read won't error until stdin is closed - this.process.stdin?.end(); - this.process = undefined; - } - this.connected = false; - } - } -} +import type { ChildProcess } from "node:child_process"; +import type { Socket } from "node:net"; +import type { + RootedDirectoryPath, + RootedFilePath, + RootedPath, +} from "../../ast/index.ts"; +import type { FileSystemCallbacks } from "../fs.ts"; +import { + configureFileSystemCallbacks, + encodeFileSystemCallbackResult, + type FileSystemCallbackConfiguration, +} from "../fsCallbacks.ts"; +import { nodeProcess } from "../nodeProcess.ts"; +import { + type AsyncClientOptions, + type ClientOptions, + type ClientSocketOptions, + type ClientSpawnOptions, + getAPIProcessArgs, + isSpawnOptions, + isTransportOptions, + resolveExePath, +} from "../options.ts"; +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledServerTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import { TimingDump } from "../timingDump.ts"; +import type * as JsonRpc from "./jsonrpc.ts"; +import { + createTransportClient, + type TransportClient, +} from "./transportClient.ts"; + +export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; +export type { AsyncTransport } from "./transport.ts"; + +export type Client = TransportClient | RpcClient; + +export function createClient(options: AsyncClientOptions): Client { + return isTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); +} + +/** + * Client handles communication with the TypeScript API server + * over STDIO (spawned process) or a Unix domain socket using JSON-RPC. + */ +class RpcClient { + private socket: Socket | undefined; + private process: ChildProcess | undefined; + private connection: JsonRpc.MessageConnection | undefined; + // Loaded on connection so that injected-transport clients never load it. + private jsonrpc: typeof JsonRpc | undefined; + private options: ClientOptions; + private connected = false; + private closed = false; + private connecting: Promise | undefined; + private timing: TimingCollector | undefined; + private timingDump: TimingDump | undefined; + private closing: Promise | undefined; + 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) { + this.timingDump = TimingDump.fromEnvironment("async", isSpawnOptions(options) ? "spawn" : "socket"); + if (this.timingDump || isSpawnOptions(options) && options.collectTiming) { + this.timing = new TimingCollector(); + } + if (this.timingDump && isSpawnOptions(options)) { + options = { ...options, collectTiming: true }; + } + this.options = options; + if (isSpawnOptions(options)) { + configureFileSystemCallbacks(options.fs); + } + } + + connect(): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + if (this.connected) return Promise.resolve(); + return this.connecting ??= this.connectWorker().finally(() => { + this.connecting = undefined; + }); + } + + private async connectWorker(): Promise { + this.jsonrpc ??= await import("./jsonrpc.ts"); + if (isSpawnOptions(this.options)) { + await this.connectViaSpawn(this.options); + } + else { + await this.connectViaSocket(this.options); + } + } + + private async connectViaSpawn(options: ClientSpawnOptions): Promise { + const { spawn } = nodeProcess().getBuiltinModule("node:child_process"); + + return new Promise((resolve, reject) => { + const args = getAPIProcessArgs(options, true); + const fsConfiguration = configureFileSystemCallbacks(options.fs); + + if (fsConfiguration.arguments.length > 0) { + args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); + } + + this.process = spawn(resolveExePath(options), args, { + stdio: ["pipe", "pipe", "inherit"], + }); + + this.process.once("error", error => { + reject(new Error(`Failed to start tsgo process: ${error.message}`)); + }); + + this.process.once("spawn", () => { + this.connected = true; + resolve(); + }); + + this.connection = this.jsonrpc!.createStreamConnection(this.process.stdout!, this.process.stdin!); + this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); + this.connection.listen(); + }); + } + + private async connectViaSocket(options: ClientSocketOptions): Promise { + const { createConnection } = nodeProcess().getBuiltinModule("node:net"); + + return new Promise((resolve, reject) => { + this.socket = createConnection(options.pipe, () => { + this.connection = this.jsonrpc!.createStreamConnection(this.socket!, this.socket!); + this.connection.listen(); + this.connected = true; + resolve(); + }); + + this.socket.once("error", error => { + reject(new Error(`Socket error: ${error.message}`)); + }); + }); + } + + private registerFSCallbacks( + connection: JsonRpc.MessageConnection, + fs: FileSystemCallbacks | undefined, + configuration: FileSystemCallbackConfiguration, + ): void { + if (!fs) return; + for (const name of configuration.callbackNames) { + switch (name) { + case "readFile": { + const callback = fs.readFile; + if (typeof callback !== "function") throw new Error("Invalid readFile callback configuration"); + connection.onRequest(this.requestType(name), fileName => { + return encodeFileSystemCallbackResult(name, callback(fileName)); + }); + break; + } + case "fileExists": { + const callback = fs.fileExists; + if (typeof callback !== "function") throw new Error("Invalid fileExists callback configuration"); + connection.onRequest(this.requestType(name), fileName => { + return encodeFileSystemCallbackResult(name, callback(fileName)); + }); + break; + } + case "directoryExists": { + const callback = fs.directoryExists; + if (typeof callback !== "function") throw new Error("Invalid directoryExists callback configuration"); + connection.onRequest(this.requestType(name), directoryName => { + return encodeFileSystemCallbackResult(name, callback(directoryName)); + }); + break; + } + case "getAccessibleEntries": { + const callback = fs.getAccessibleEntries; + if (typeof callback !== "function") throw new Error("Invalid getAccessibleEntries callback configuration"); + connection.onRequest(this.requestType(name), directoryName => { + return encodeFileSystemCallbackResult(name, callback(directoryName)); + }); + break; + } + case "realpath": { + const callback = fs.realpath; + if (typeof callback !== "function") throw new Error("Invalid realpath callback configuration"); + connection.onRequest(this.requestType(name), path => { + return encodeFileSystemCallbackResult(name, callback(path)); + }); + break; + } + case "stat": { + const callback = fs.stat; + if (typeof callback !== "function") throw new Error("Invalid stat callback configuration"); + connection.onRequest(this.requestType(name), path => { + return encodeFileSystemCallbackResult(name, callback(path)); + }); + break; + } + case "writeFile": { + const callback = fs.writeFile; + if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); + connection.onRequest(this.requestType<{ path: RootedFilePath; data: string; }, unknown>(name), arg => { + return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); + }); + break; + } + case "removeFile": { + const callback = fs.removeFile; + if (typeof callback !== "function") throw new Error("Invalid removeFile callback configuration"); + connection.onRequest(this.requestType(name), path => { + return encodeFileSystemCallbackResult(name, callback(path)); + }); + break; + } + } + } + } + + private requestType(method: string): JsonRpc.RequestType { + if (!this.jsonrpc) throw new Error("Connection not established"); + return new this.jsonrpc.RequestType(method); + } + + private async sendRequestWithTiming(requestType: JsonRpc.RequestType, params: unknown): Promise { + if (!this.connection) { + throw new Error("Connection not established"); + } + + if (!this.timing) { + return this.connection.sendRequest(requestType, params); + } + + // Round-trip latency is measured here; byte counts approximate the wire + // payload via the serialized JSON. Server-side processing time is not + // carried on the response; it is retrieved separately (via a + // getServerTiming request) and folded in by getTimingInfo(). + const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); + const start = performance.now(); + const result = await this.connection.sendRequest(requestType, params); + const roundTripMs = performance.now() - start; + this.timing.record({ + method: requestType.method, + roundTripMs, + bytesSent, + bytesReceived: result === undefined || result === null + ? 0 + : Buffer.byteLength(JSON.stringify(result), "utf-8"), + }); + return result; + } + + registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { + if (!this.connection) { + throw new Error("Connection not established"); + } + const disposable = this.connection.onRequest(this.requestType(name), callback); + return () => disposable.dispose(); + } + + private async doBatch(): Promise { + this.nextBatch = undefined; + if (!this.batchedRequests.length) return; + const requests = this.batchedRequests; + this.batchedRequests = []; + try { + if (!this.connected) { + await this.connect(); + } + if (!this.connection) { + throw new Error("Connection not established"); + } + + if (requests.length === 1) { + // send single queued requests directly instead of as a batched request + const requestType = this.requestType(requests[0].method); + const response = await this.sendRequestWithTiming(requestType, requests[0].params); + requests[0].resolve(response); + return; + } + + const requestType = this.requestType("batchRequests"); + const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; + if (this.options.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + } + const response = await this.sendRequestWithTiming(requestType, params); + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.options.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + } + const page = await this.sendRequestWithTiming(requestType, pageParams); + 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); + } + } + } + catch (error) { + for (const { reject } of requests) reject(error); + } + } + + private scheduleImmediateBatch(): void { + if (this.closed || this.nextBatch) return; + this.nextBatch = setImmediate(this.doBatch.bind(this)); + } + + batchContext(): { [Symbol.dispose](): void; } { + if (this.nextBatch === "manual") { + throw new Error("Already in a manual batch context"); + } + if (this.nextBatch) { + clearImmediate(this.nextBatch); + this.doBatch(); // empty the queue before entering a manual batch context + } + this.nextBatch = "manual"; + return { + [Symbol.dispose]: () => { + this.nextBatch = undefined; + this.scheduleImmediateBatch(); + }, + }; + } + + 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 (!this.connection) { + throw new Error("Connection not established"); + } + + const resultPromise = new Promise((resolve, reject) => { + this.batchedRequests.push({ method, params, resolve, reject }); + this.scheduleImmediateBatch(); + }); + return resultPromise; + } + + async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { + const response = await this.apiRequest(method, params); + if (!response) return undefined; + const buffer = Buffer.from(response.data, "base64"); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } + + /** + * 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; + } + + /** + * 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. + */ + async getTimingInfo(): Promise { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + // No requests have been sent yet: nothing to fetch from the server. + if (!this.connected || !this.connection) { + return local; + } + return combineTimingInfo(local, await this.fetchServerTiming()); + } + + async resetTimingInfo(): Promise { + if (!this.timing) return; + this.timing.reset(); + if (this.connected && this.connection) { + // Keep the server's collection in sync so combined totals stay meaningful. + const requestType = this.requestType("resetServerTiming"); + await this.connection.sendRequest(requestType, undefined); + } + } + + private async fetchServerTiming(): Promise { + if (!this.connection) { + return disabledServerTimingInfo(); + } + // Fetch the server's own timing collection via a dedicated request. This + // bypasses the client-side collector so the query does not pollute it. + const requestType = this.requestType("getServerTiming"); + return this.connection.sendRequest(requestType, undefined); + } + + close(): Promise { + return this.closing ??= this.closeWorker(); + } + + private async closeWorker(): Promise { + this.closed = true; + if (this.nextBatch && this.nextBatch !== "manual") { + clearImmediate(this.nextBatch); + } + this.nextBatch = undefined; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection + try { + if (this.timingDump && this.timing) { + const local = this.timing.getInfo(); + let server: ServerTimingInfo | undefined; + let serverTimingError: string | undefined; + const connection = this.connection; + // Closing alone does not reject pending JSON-RPC requests. + const onClose = connection?.onClose(() => connection.dispose()); + try { + if (this.connected) server = await this.fetchServerTiming(); + } + catch (error) { + serverTimingError = String(error); + } + finally { + onClose?.dispose(); + } + this.timingDump.write(local, server, serverTimingError); + } + } + finally { + if (this.connection) { + this.connection.dispose(); + this.connection = undefined; + } + if (this.socket) { + this.socket.destroy(); + this.socket = undefined; + } + if (this.process) { + // Close stdin to unblock the server's read loop, allowing it to exit cleanly. + // The server is blocked on stdin.Read(), so just sending SIGTERM would deadlock: + // - Node won't exit while child is alive + // - Child can't process SIGTERM while blocked on read + // - Read won't error until stdin is closed + this.process.stdin?.end(); + this.process = undefined; + } + this.connected = false; + } + } +} From e51d37ffd8ec607867a3dccbddc89669978b9153 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:47:09 -0700 Subject: [PATCH 107/142] Drop the connection step from the async transport client An injected transport is already connected when it is handed to the client, so tracking a separate connected state only duplicated the closed checks. --- packages/typescript/src/api/async/client.ts | 958 +++++++++--------- .../src/api/async/transportClient.ts | 26 +- .../typescript/test/async/transport.test.ts | 6 +- 3 files changed, 483 insertions(+), 507 deletions(-) diff --git a/packages/typescript/src/api/async/client.ts b/packages/typescript/src/api/async/client.ts index 5ad2523f0be2e..bb5adf7030aa3 100644 --- a/packages/typescript/src/api/async/client.ts +++ b/packages/typescript/src/api/async/client.ts @@ -1,479 +1,479 @@ -import type { ChildProcess } from "node:child_process"; -import type { Socket } from "node:net"; -import type { - RootedDirectoryPath, - RootedFilePath, - RootedPath, -} from "../../ast/index.ts"; -import type { FileSystemCallbacks } from "../fs.ts"; -import { - configureFileSystemCallbacks, - encodeFileSystemCallbackResult, - type FileSystemCallbackConfiguration, -} from "../fsCallbacks.ts"; -import { nodeProcess } from "../nodeProcess.ts"; -import { - type AsyncClientOptions, - type ClientOptions, - type ClientSocketOptions, - type ClientSpawnOptions, - getAPIProcessArgs, - isSpawnOptions, - isTransportOptions, - resolveExePath, -} from "../options.ts"; -import type { - APIMethodInfo, - APIRequest, - BatchRequestsParams, - BatchRequestsResponse, - SourceFileResponseMethod, -} from "../proto.ts"; -import { - combineTimingInfo, - disabledServerTimingInfo, - disabledTimingInfo, - type ServerTimingInfo, - TimingCollector, - type TimingInfo, -} from "../timing.ts"; -import { TimingDump } from "../timingDump.ts"; -import type * as JsonRpc from "./jsonrpc.ts"; -import { - createTransportClient, - type TransportClient, -} from "./transportClient.ts"; - -export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; -export type { AsyncTransport } from "./transport.ts"; - -export type Client = TransportClient | RpcClient; - -export function createClient(options: AsyncClientOptions): Client { - return isTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); -} - -/** - * Client handles communication with the TypeScript API server - * over STDIO (spawned process) or a Unix domain socket using JSON-RPC. - */ -class RpcClient { - private socket: Socket | undefined; - private process: ChildProcess | undefined; - private connection: JsonRpc.MessageConnection | undefined; - // Loaded on connection so that injected-transport clients never load it. - private jsonrpc: typeof JsonRpc | undefined; - private options: ClientOptions; - private connected = false; - private closed = false; - private connecting: Promise | undefined; - private timing: TimingCollector | undefined; - private timingDump: TimingDump | undefined; - private closing: Promise | undefined; - 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) { - this.timingDump = TimingDump.fromEnvironment("async", isSpawnOptions(options) ? "spawn" : "socket"); - if (this.timingDump || isSpawnOptions(options) && options.collectTiming) { - this.timing = new TimingCollector(); - } - if (this.timingDump && isSpawnOptions(options)) { - options = { ...options, collectTiming: true }; - } - this.options = options; - if (isSpawnOptions(options)) { - configureFileSystemCallbacks(options.fs); - } - } - - connect(): Promise { - if (this.closed) return Promise.reject(new Error("Client is closed")); - if (this.connected) return Promise.resolve(); - return this.connecting ??= this.connectWorker().finally(() => { - this.connecting = undefined; - }); - } - - private async connectWorker(): Promise { - this.jsonrpc ??= await import("./jsonrpc.ts"); - if (isSpawnOptions(this.options)) { - await this.connectViaSpawn(this.options); - } - else { - await this.connectViaSocket(this.options); - } - } - - private async connectViaSpawn(options: ClientSpawnOptions): Promise { - const { spawn } = nodeProcess().getBuiltinModule("node:child_process"); - - return new Promise((resolve, reject) => { - const args = getAPIProcessArgs(options, true); - const fsConfiguration = configureFileSystemCallbacks(options.fs); - - if (fsConfiguration.arguments.length > 0) { - args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); - } - - this.process = spawn(resolveExePath(options), args, { - stdio: ["pipe", "pipe", "inherit"], - }); - - this.process.once("error", error => { - reject(new Error(`Failed to start tsgo process: ${error.message}`)); - }); - - this.process.once("spawn", () => { - this.connected = true; - resolve(); - }); - - this.connection = this.jsonrpc!.createStreamConnection(this.process.stdout!, this.process.stdin!); - this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); - this.connection.listen(); - }); - } - - private async connectViaSocket(options: ClientSocketOptions): Promise { - const { createConnection } = nodeProcess().getBuiltinModule("node:net"); - - return new Promise((resolve, reject) => { - this.socket = createConnection(options.pipe, () => { - this.connection = this.jsonrpc!.createStreamConnection(this.socket!, this.socket!); - this.connection.listen(); - this.connected = true; - resolve(); - }); - - this.socket.once("error", error => { - reject(new Error(`Socket error: ${error.message}`)); - }); - }); - } - - private registerFSCallbacks( - connection: JsonRpc.MessageConnection, - fs: FileSystemCallbacks | undefined, - configuration: FileSystemCallbackConfiguration, - ): void { - if (!fs) return; - for (const name of configuration.callbackNames) { - switch (name) { - case "readFile": { - const callback = fs.readFile; - if (typeof callback !== "function") throw new Error("Invalid readFile callback configuration"); - connection.onRequest(this.requestType(name), fileName => { - return encodeFileSystemCallbackResult(name, callback(fileName)); - }); - break; - } - case "fileExists": { - const callback = fs.fileExists; - if (typeof callback !== "function") throw new Error("Invalid fileExists callback configuration"); - connection.onRequest(this.requestType(name), fileName => { - return encodeFileSystemCallbackResult(name, callback(fileName)); - }); - break; - } - case "directoryExists": { - const callback = fs.directoryExists; - if (typeof callback !== "function") throw new Error("Invalid directoryExists callback configuration"); - connection.onRequest(this.requestType(name), directoryName => { - return encodeFileSystemCallbackResult(name, callback(directoryName)); - }); - break; - } - case "getAccessibleEntries": { - const callback = fs.getAccessibleEntries; - if (typeof callback !== "function") throw new Error("Invalid getAccessibleEntries callback configuration"); - connection.onRequest(this.requestType(name), directoryName => { - return encodeFileSystemCallbackResult(name, callback(directoryName)); - }); - break; - } - case "realpath": { - const callback = fs.realpath; - if (typeof callback !== "function") throw new Error("Invalid realpath callback configuration"); - connection.onRequest(this.requestType(name), path => { - return encodeFileSystemCallbackResult(name, callback(path)); - }); - break; - } - case "stat": { - const callback = fs.stat; - if (typeof callback !== "function") throw new Error("Invalid stat callback configuration"); - connection.onRequest(this.requestType(name), path => { - return encodeFileSystemCallbackResult(name, callback(path)); - }); - break; - } - case "writeFile": { - const callback = fs.writeFile; - if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); - connection.onRequest(this.requestType<{ path: RootedFilePath; data: string; }, unknown>(name), arg => { - return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); - }); - break; - } - case "removeFile": { - const callback = fs.removeFile; - if (typeof callback !== "function") throw new Error("Invalid removeFile callback configuration"); - connection.onRequest(this.requestType(name), path => { - return encodeFileSystemCallbackResult(name, callback(path)); - }); - break; - } - } - } - } - - private requestType(method: string): JsonRpc.RequestType { - if (!this.jsonrpc) throw new Error("Connection not established"); - return new this.jsonrpc.RequestType(method); - } - - private async sendRequestWithTiming(requestType: JsonRpc.RequestType, params: unknown): Promise { - if (!this.connection) { - throw new Error("Connection not established"); - } - - if (!this.timing) { - return this.connection.sendRequest(requestType, params); - } - - // Round-trip latency is measured here; byte counts approximate the wire - // payload via the serialized JSON. Server-side processing time is not - // carried on the response; it is retrieved separately (via a - // getServerTiming request) and folded in by getTimingInfo(). - const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); - const start = performance.now(); - const result = await this.connection.sendRequest(requestType, params); - const roundTripMs = performance.now() - start; - this.timing.record({ - method: requestType.method, - roundTripMs, - bytesSent, - bytesReceived: result === undefined || result === null - ? 0 - : Buffer.byteLength(JSON.stringify(result), "utf-8"), - }); - return result; - } - - registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { - if (!this.connection) { - throw new Error("Connection not established"); - } - const disposable = this.connection.onRequest(this.requestType(name), callback); - return () => disposable.dispose(); - } - - private async doBatch(): Promise { - this.nextBatch = undefined; - if (!this.batchedRequests.length) return; - const requests = this.batchedRequests; - this.batchedRequests = []; - try { - if (!this.connected) { - await this.connect(); - } - if (!this.connection) { - throw new Error("Connection not established"); - } - - if (requests.length === 1) { - // send single queued requests directly instead of as a batched request - const requestType = this.requestType(requests[0].method); - const response = await this.sendRequestWithTiming(requestType, requests[0].params); - requests[0].resolve(response); - return; - } - - const requestType = this.requestType("batchRequests"); - const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; - if (this.options.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; - } - const response = await this.sendRequestWithTiming(requestType, params); - let responses = response.responses; - let continuationToken = response.continuationToken; - while (continuationToken) { - const pageParams: BatchRequestsParams = { - requests: [], - continuationToken, - }; - if (this.options.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; - } - const page = await this.sendRequestWithTiming(requestType, pageParams); - 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); - } - } - } - catch (error) { - for (const { reject } of requests) reject(error); - } - } - - private scheduleImmediateBatch(): void { - if (this.closed || this.nextBatch) return; - this.nextBatch = setImmediate(this.doBatch.bind(this)); - } - - batchContext(): { [Symbol.dispose](): void; } { - if (this.nextBatch === "manual") { - throw new Error("Already in a manual batch context"); - } - if (this.nextBatch) { - clearImmediate(this.nextBatch); - this.doBatch(); // empty the queue before entering a manual batch context - } - this.nextBatch = "manual"; - return { - [Symbol.dispose]: () => { - this.nextBatch = undefined; - this.scheduleImmediateBatch(); - }, - }; - } - - 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 (!this.connection) { - throw new Error("Connection not established"); - } - - const resultPromise = new Promise((resolve, reject) => { - this.batchedRequests.push({ method, params, resolve, reject }); - this.scheduleImmediateBatch(); - }); - return resultPromise; - } - - async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { - const response = await this.apiRequest(method, params); - if (!response) return undefined; - const buffer = Buffer.from(response.data, "base64"); - return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - } - - /** - * 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; - } - - /** - * 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. - */ - async getTimingInfo(): Promise { - if (!this.timing) { - return disabledTimingInfo(); - } - const local = this.timing.getInfo(); - // No requests have been sent yet: nothing to fetch from the server. - if (!this.connected || !this.connection) { - return local; - } - return combineTimingInfo(local, await this.fetchServerTiming()); - } - - async resetTimingInfo(): Promise { - if (!this.timing) return; - this.timing.reset(); - if (this.connected && this.connection) { - // Keep the server's collection in sync so combined totals stay meaningful. - const requestType = this.requestType("resetServerTiming"); - await this.connection.sendRequest(requestType, undefined); - } - } - - private async fetchServerTiming(): Promise { - if (!this.connection) { - return disabledServerTimingInfo(); - } - // Fetch the server's own timing collection via a dedicated request. This - // bypasses the client-side collector so the query does not pollute it. - const requestType = this.requestType("getServerTiming"); - return this.connection.sendRequest(requestType, undefined); - } - - close(): Promise { - return this.closing ??= this.closeWorker(); - } - - private async closeWorker(): Promise { - this.closed = true; - if (this.nextBatch && this.nextBatch !== "manual") { - clearImmediate(this.nextBatch); - } - this.nextBatch = undefined; - const requests = this.batchedRequests; - this.batchedRequests = []; - for (const { reject } of requests) { - reject(new Error("Client is closed")); - } - await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection - try { - if (this.timingDump && this.timing) { - const local = this.timing.getInfo(); - let server: ServerTimingInfo | undefined; - let serverTimingError: string | undefined; - const connection = this.connection; - // Closing alone does not reject pending JSON-RPC requests. - const onClose = connection?.onClose(() => connection.dispose()); - try { - if (this.connected) server = await this.fetchServerTiming(); - } - catch (error) { - serverTimingError = String(error); - } - finally { - onClose?.dispose(); - } - this.timingDump.write(local, server, serverTimingError); - } - } - finally { - if (this.connection) { - this.connection.dispose(); - this.connection = undefined; - } - if (this.socket) { - this.socket.destroy(); - this.socket = undefined; - } - if (this.process) { - // Close stdin to unblock the server's read loop, allowing it to exit cleanly. - // The server is blocked on stdin.Read(), so just sending SIGTERM would deadlock: - // - Node won't exit while child is alive - // - Child can't process SIGTERM while blocked on read - // - Read won't error until stdin is closed - this.process.stdin?.end(); - this.process = undefined; - } - this.connected = false; - } - } -} +import type { ChildProcess } from "node:child_process"; +import type { Socket } from "node:net"; +import type { + RootedDirectoryPath, + RootedFilePath, + RootedPath, +} from "../../ast/index.ts"; +import type { FileSystemCallbacks } from "../fs.ts"; +import { + configureFileSystemCallbacks, + encodeFileSystemCallbackResult, + type FileSystemCallbackConfiguration, +} from "../fsCallbacks.ts"; +import { nodeProcess } from "../nodeProcess.ts"; +import { + type AsyncClientOptions, + type ClientOptions, + type ClientSocketOptions, + type ClientSpawnOptions, + getAPIProcessArgs, + isSpawnOptions, + isTransportOptions, + resolveExePath, +} from "../options.ts"; +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledServerTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import { TimingDump } from "../timingDump.ts"; +import type * as JsonRpc from "./jsonrpc.ts"; +import { + createTransportClient, + type TransportClient, +} from "./transportClient.ts"; + +export type { AsyncClientOptions as ClientOptions, AsyncClientTransportOptions, ClientSocketOptions, ClientSpawnOptions } from "../options.ts"; +export type { AsyncTransport } from "./transport.ts"; + +export type Client = TransportClient | RpcClient; + +export function createClient(options: AsyncClientOptions): Client { + return isTransportOptions(options) ? createTransportClient(options) : new RpcClient(options); +} + +/** + * Client handles communication with the TypeScript API server + * over STDIO (spawned process) or a Unix domain socket using JSON-RPC. + */ +class RpcClient { + private socket: Socket | undefined; + private process: ChildProcess | undefined; + private connection: JsonRpc.MessageConnection | undefined; + // Loaded on connection so that injected-transport clients never load it. + private jsonrpc: typeof JsonRpc | undefined; + private options: ClientOptions; + private connected = false; + private closed = false; + private connecting: Promise | undefined; + private timing: TimingCollector | undefined; + private timingDump: TimingDump | undefined; + private closing: Promise | undefined; + 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) { + this.timingDump = TimingDump.fromEnvironment("async", isSpawnOptions(options) ? "spawn" : "socket"); + if (this.timingDump || isSpawnOptions(options) && options.collectTiming) { + this.timing = new TimingCollector(); + } + if (this.timingDump && isSpawnOptions(options)) { + options = { ...options, collectTiming: true }; + } + this.options = options; + if (isSpawnOptions(options)) { + configureFileSystemCallbacks(options.fs); + } + } + + connect(): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + if (this.connected) return Promise.resolve(); + return this.connecting ??= this.connectWorker().finally(() => { + this.connecting = undefined; + }); + } + + private async connectWorker(): Promise { + this.jsonrpc ??= await import("./jsonrpc.ts"); + if (isSpawnOptions(this.options)) { + await this.connectViaSpawn(this.options); + } + else { + await this.connectViaSocket(this.options); + } + } + + private async connectViaSpawn(options: ClientSpawnOptions): Promise { + const { spawn } = nodeProcess().getBuiltinModule("node:child_process"); + + return new Promise((resolve, reject) => { + const args = getAPIProcessArgs(options, true); + const fsConfiguration = configureFileSystemCallbacks(options.fs); + + if (fsConfiguration.arguments.length > 0) { + args.push(`--callbacks=${fsConfiguration.arguments.join(",")}`); + } + + this.process = spawn(resolveExePath(options), args, { + stdio: ["pipe", "pipe", "inherit"], + }); + + this.process.once("error", error => { + reject(new Error(`Failed to start tsgo process: ${error.message}`)); + }); + + this.process.once("spawn", () => { + this.connected = true; + resolve(); + }); + + this.connection = this.jsonrpc!.createStreamConnection(this.process.stdout!, this.process.stdin!); + this.registerFSCallbacks(this.connection, options.fs, fsConfiguration); + this.connection.listen(); + }); + } + + private async connectViaSocket(options: ClientSocketOptions): Promise { + const { createConnection } = nodeProcess().getBuiltinModule("node:net"); + + return new Promise((resolve, reject) => { + this.socket = createConnection(options.pipe, () => { + this.connection = this.jsonrpc!.createStreamConnection(this.socket!, this.socket!); + this.connection.listen(); + this.connected = true; + resolve(); + }); + + this.socket.once("error", error => { + reject(new Error(`Socket error: ${error.message}`)); + }); + }); + } + + private registerFSCallbacks( + connection: JsonRpc.MessageConnection, + fs: FileSystemCallbacks | undefined, + configuration: FileSystemCallbackConfiguration, + ): void { + if (!fs) return; + for (const name of configuration.callbackNames) { + switch (name) { + case "readFile": { + const callback = fs.readFile; + if (typeof callback !== "function") throw new Error("Invalid readFile callback configuration"); + connection.onRequest(this.requestType(name), fileName => { + return encodeFileSystemCallbackResult(name, callback(fileName)); + }); + break; + } + case "fileExists": { + const callback = fs.fileExists; + if (typeof callback !== "function") throw new Error("Invalid fileExists callback configuration"); + connection.onRequest(this.requestType(name), fileName => { + return encodeFileSystemCallbackResult(name, callback(fileName)); + }); + break; + } + case "directoryExists": { + const callback = fs.directoryExists; + if (typeof callback !== "function") throw new Error("Invalid directoryExists callback configuration"); + connection.onRequest(this.requestType(name), directoryName => { + return encodeFileSystemCallbackResult(name, callback(directoryName)); + }); + break; + } + case "getAccessibleEntries": { + const callback = fs.getAccessibleEntries; + if (typeof callback !== "function") throw new Error("Invalid getAccessibleEntries callback configuration"); + connection.onRequest(this.requestType(name), directoryName => { + return encodeFileSystemCallbackResult(name, callback(directoryName)); + }); + break; + } + case "realpath": { + const callback = fs.realpath; + if (typeof callback !== "function") throw new Error("Invalid realpath callback configuration"); + connection.onRequest(this.requestType(name), path => { + return encodeFileSystemCallbackResult(name, callback(path)); + }); + break; + } + case "stat": { + const callback = fs.stat; + if (typeof callback !== "function") throw new Error("Invalid stat callback configuration"); + connection.onRequest(this.requestType(name), path => { + return encodeFileSystemCallbackResult(name, callback(path)); + }); + break; + } + case "writeFile": { + const callback = fs.writeFile; + if (typeof callback !== "function") throw new Error("Invalid writeFile callback configuration"); + connection.onRequest(this.requestType<{ path: RootedFilePath; data: string; }, unknown>(name), arg => { + return encodeFileSystemCallbackResult(name, callback(arg.path, arg.data)); + }); + break; + } + case "removeFile": { + const callback = fs.removeFile; + if (typeof callback !== "function") throw new Error("Invalid removeFile callback configuration"); + connection.onRequest(this.requestType(name), path => { + return encodeFileSystemCallbackResult(name, callback(path)); + }); + break; + } + } + } + } + + private requestType(method: string): JsonRpc.RequestType { + if (!this.jsonrpc) throw new Error("Connection not established"); + return new this.jsonrpc.RequestType(method); + } + + private async sendRequestWithTiming(requestType: JsonRpc.RequestType, params: unknown): Promise { + if (!this.connection) { + throw new Error("Connection not established"); + } + + if (!this.timing) { + return this.connection.sendRequest(requestType, params); + } + + // Round-trip latency is measured here; byte counts approximate the wire + // payload via the serialized JSON. Server-side processing time is not + // carried on the response; it is retrieved separately (via a + // getServerTiming request) and folded in by getTimingInfo(). + const bytesSent = params === undefined ? 0 : Buffer.byteLength(JSON.stringify(params), "utf-8"); + const start = performance.now(); + const result = await this.connection.sendRequest(requestType, params); + const roundTripMs = performance.now() - start; + this.timing.record({ + method: requestType.method, + roundTripMs, + bytesSent, + bytesReceived: result === undefined || result === null + ? 0 + : Buffer.byteLength(JSON.stringify(result), "utf-8"), + }); + return result; + } + + registerCallback(name: string, callback: (params: unknown) => unknown | Promise): () => void { + if (!this.connection) { + throw new Error("Connection not established"); + } + const disposable = this.connection.onRequest(this.requestType(name), callback); + return () => disposable.dispose(); + } + + private async doBatch(): Promise { + this.nextBatch = undefined; + if (!this.batchedRequests.length) return; + const requests = this.batchedRequests; + this.batchedRequests = []; + try { + if (!this.connected) { + await this.connect(); + } + if (!this.connection) { + throw new Error("Connection not established"); + } + + if (requests.length === 1) { + // send single queued requests directly instead of as a batched request + const requestType = this.requestType(requests[0].method); + const response = await this.sendRequestWithTiming(requestType, requests[0].params); + requests[0].resolve(response); + return; + } + + const requestType = this.requestType("batchRequests"); + const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; + if (this.options.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + } + const response = await this.sendRequestWithTiming(requestType, params); + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.options.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + } + const page = await this.sendRequestWithTiming(requestType, pageParams); + 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); + } + } + } + catch (error) { + for (const { reject } of requests) reject(error); + } + } + + private scheduleImmediateBatch(): void { + if (this.closed || this.nextBatch) return; + this.nextBatch = setImmediate(this.doBatch.bind(this)); + } + + batchContext(): { [Symbol.dispose](): void; } { + if (this.nextBatch === "manual") { + throw new Error("Already in a manual batch context"); + } + if (this.nextBatch) { + clearImmediate(this.nextBatch); + this.doBatch(); // empty the queue before entering a manual batch context + } + this.nextBatch = "manual"; + return { + [Symbol.dispose]: () => { + this.nextBatch = undefined; + this.scheduleImmediateBatch(); + }, + }; + } + + 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 (!this.connection) { + throw new Error("Connection not established"); + } + + const resultPromise = new Promise((resolve, reject) => { + this.batchedRequests.push({ method, params, resolve, reject }); + this.scheduleImmediateBatch(); + }); + return resultPromise; + } + + async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { + const response = await this.apiRequest(method, params); + if (!response) return undefined; + const buffer = Buffer.from(response.data, "base64"); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + } + + /** + * 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; + } + + /** + * 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. + */ + async getTimingInfo(): Promise { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + // No requests have been sent yet: nothing to fetch from the server. + if (!this.connected || !this.connection) { + return local; + } + return combineTimingInfo(local, await this.fetchServerTiming()); + } + + async resetTimingInfo(): Promise { + if (!this.timing) return; + this.timing.reset(); + if (this.connected && this.connection) { + // Keep the server's collection in sync so combined totals stay meaningful. + const requestType = this.requestType("resetServerTiming"); + await this.connection.sendRequest(requestType, undefined); + } + } + + private async fetchServerTiming(): Promise { + if (!this.connection) { + return disabledServerTimingInfo(); + } + // Fetch the server's own timing collection via a dedicated request. This + // bypasses the client-side collector so the query does not pollute it. + const requestType = this.requestType("getServerTiming"); + return this.connection.sendRequest(requestType, undefined); + } + + close(): Promise { + return this.closing ??= this.closeWorker(); + } + + private async closeWorker(): Promise { + this.closed = true; + if (this.nextBatch && this.nextBatch !== "manual") { + clearImmediate(this.nextBatch); + } + this.nextBatch = undefined; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.connecting?.catch(() => {}); // if connection is still in-progress, wait for it to finish before closing the connection + try { + if (this.timingDump && this.timing) { + const local = this.timing.getInfo(); + let server: ServerTimingInfo | undefined; + let serverTimingError: string | undefined; + const connection = this.connection; + // Closing alone does not reject pending JSON-RPC requests. + const onClose = connection?.onClose(() => connection.dispose()); + try { + if (this.connected) server = await this.fetchServerTiming(); + } + catch (error) { + serverTimingError = String(error); + } + finally { + onClose?.dispose(); + } + this.timingDump.write(local, server, serverTimingError); + } + } + finally { + if (this.connection) { + this.connection.dispose(); + this.connection = undefined; + } + if (this.socket) { + this.socket.destroy(); + this.socket = undefined; + } + if (this.process) { + // Close stdin to unblock the server's read loop, allowing it to exit cleanly. + // The server is blocked on stdin.Read(), so just sending SIGTERM would deadlock: + // - Node won't exit while child is alive + // - Child can't process SIGTERM while blocked on read + // - Read won't error until stdin is closed + this.process.stdin?.end(); + this.process = undefined; + } + this.connected = false; + } + } +} diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index 48ce3979d0279..f42ad6a17303a 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -35,10 +35,6 @@ interface PendingBinaryRequest extends PendingRequestBase { type PendingRequest = PendingTextRequest | PendingBinaryRequest; -function isTextRequest(request: PendingRequest): request is PendingTextRequest { - return request.kind === "text"; -} - export function createTransportClient(options: AsyncClientTransportOptions): TransportClient { if (options.fs !== undefined) { options.transport.setFileSystem?.(options.fs); @@ -52,7 +48,6 @@ export class TransportClient { private readonly timing: TimingCollector | undefined; private readonly transport: AsyncTransport; private readonly maxResponseBytesPerPage: number | undefined; - private connected = false; private closed = false; private closing: Promise | undefined; private nextBatch: boolean | "manual" = false; @@ -68,12 +63,6 @@ export class TransportClient { } } - 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"); @@ -96,10 +85,6 @@ export class TransportClient { 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); @@ -115,10 +100,6 @@ export class TransportClient { 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({ @@ -157,7 +138,7 @@ export class TransportClient { return disabledTimingInfo(); } const local = this.timing.getInfo(); - if (!this.connected) { + if (this.closed) { return local; } const response = await this.invokeTransport(() => this.transport.request("getServerTiming", "")); @@ -167,7 +148,7 @@ export class TransportClient { async resetTimingInfo(): Promise { if (!this.timing) return; this.timing.reset(); - if (this.connected) { + if (!this.closed) { await this.invokeTransport(() => this.transport.request("resetServerTiming", "")); } } @@ -190,7 +171,6 @@ export class TransportClient { reject(new Error("Client is closed")); } await this.transport.close(); - this.connected = false; } private scheduleBatch(): void { @@ -221,7 +201,7 @@ export class TransportClient { return; } - if (requests.every(isTextRequest)) { + if (requests.every((request): request is PendingTextRequest => request.kind === "text")) { const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })), }; diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts index e98746548b15a..0c40f20afc5f8 100644 --- a/packages/typescript/test/async/transport.test.ts +++ b/packages/typescript/test/async/transport.test.ts @@ -210,7 +210,6 @@ describe("async transport", () => { close() {}, }, false); - await client.connect(); const first = client.apiRequest("parseCommandLine", { commandLine: [] }); const batch = client.batchContext(); const second = client.apiRequest("parseCommandLine", { commandLine: [] }); @@ -245,7 +244,6 @@ describe("async transport", () => { close() {}, }, false); - await client.connect(); const batch = client.batchContext(); const binary = client.apiRequestBinary("getSourceFile", { snapshot: 1, @@ -282,7 +280,6 @@ describe("async transport", () => { close() {}, }, false); - await client.connect(); const request = client.apiRequest("parseCommandLine", { commandLine: [] }); await Promise.resolve(); await client.close(); @@ -333,7 +330,6 @@ describe("async transport", () => { close() {}, }, true); - await client.connect(); const timing = client.getTimingInfo(); const reset = client.resetTimingInfo(); await client.close(); @@ -342,7 +338,7 @@ describe("async transport", () => { assert.deepStrictEqual(methods, []); }); - test("does not queue requests after closing during connect", async () => { + test("rejects queued requests when closed before they are sent", async () => { const client = new TransportClient({ request() { throw new Error("Unexpected request"); From cde9cb918f47f11450735b406ec8634a4f61cc91 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:47:26 -0700 Subject: [PATCH 108/142] Simplify cleanup after a failed WASM session setup Closing a freshly created session only fails if the module itself has trapped, which is not worth a dedicated aggregate error. --- packages/typescript/src/wasm/transport.ts | 540 +++++++++++----------- 1 file changed, 266 insertions(+), 274 deletions(-) diff --git a/packages/typescript/src/wasm/transport.ts b/packages/typescript/src/wasm/transport.ts index d525c362a0091..3384bbc2488af 100644 --- a/packages/typescript/src/wasm/transport.ts +++ b/packages/typescript/src/wasm/transport.ts @@ -1,274 +1,266 @@ -export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; -import { - encodeWtf8, - Wtf8Decoder, -} from "../api/node/wtf8.ts"; -import { - registerWasmCallback, - setWasmFileSystem, - unregisterWasmCallback, - type WasmFileSystem, -} from "./wasi.ts"; - -export interface WasmReactorExports { - memory: { readonly buffer: ArrayBufferLike; }; - create_session(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 initialized reactor, normally created by `instantiateWasm` or - * `instantiateWasmSync`. Other WASI hosts must call the module's - * `typescript_initialize` export instead of `_start`, which runs the - * command line compiler, and cannot use the `fs` option or host - * callbacks such as custom file systems passed to the API. - */ - instance: WasmReactorInstance; - cwd?: string | undefined; - useCaseSensitiveFileNames?: boolean | undefined; - collectTiming?: boolean | undefined; - fs?: WasmFileSystem | undefined; -} - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); -const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); - -/** - * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. - * - * Host callbacks must complete synchronously and cannot call this transport while - * an outer request is in progress. - */ -export class WasmTransport { - lastBytesSent = 0; - lastBytesReceived = 0; - - private readonly instance: WasmReactorInstance; - private readonly exports: WasmReactorExports; - private closed = false; - private inCallback = false; - private readonly callbackNames = new Set(); - - constructor(options: WasmTransportOptions) { - this.instance = options.instance; - this.exports = getReactorExports(options.instance); - const sessionOptions = encoder.encode(JSON.stringify({ - cwd: options.cwd ?? "/", - useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, - collectTiming: options.collectTiming, - })); - this.writeRequest(sessionOptions); - if (this.exports.create_session(sessionOptions.length) !== 0) { - throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); - } - try { - this.configureFileSystem(options.fs); - } - catch (error) { - try { - this.exports.close_session(); - } - catch (closeError) { - throw new AggregateError( - [error, closeError], - "Failed to configure the TypeScript WASM filesystem and close its session", - ); - } - throw error; - } - } - - setFileSystem(fs: WasmFileSystem | undefined): void { - this.ensureOpen(); - this.configureFileSystem(fs); - } - - requestSync(method: string, payload: string): string { - return decoder.decode(this.call(method, encoder.encode(payload))); - } - - request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } { - const value = this.requestSync(method, payload); - return { - value, - bytesSent: this.lastBytesSent, - bytesReceived: this.lastBytesReceived, - }; - } - - requestBinarySync(method: string, payload: Uint8Array): Uint8Array { - return this.call(method, payload); - } - - requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { - const value = this.requestBinarySync(method, payload); - return { - value, - bytesSent: this.lastBytesSent, - bytesReceived: this.lastBytesReceived, - }; - } - - registerCallback(name: string, callback: (name: string, payload: string) => string): void { - this.ensureOpen(); - registerWasmCallback(this.instance, name, (callbackName, payload) => this.invokeCallback(() => callback(callbackName, payload))); - this.callbackNames.add(name); - } - - unregisterCallback(name: string): void { - if (!this.callbackNames.delete(name)) return; - unregisterWasmCallback(this.instance, name); - } - - setFile(path: string, content: string): void { - this.ensureCanEnterWasm(); - const pathBytes = encoder.encode(path); - const contentBytes = encodeWtf8(content); - this.writeRequest(pathBytes, contentBytes); - if (this.exports.set_file(pathBytes.length, contentBytes.length) !== 0) { - throw new Error(`Failed to write ${path}: ${this.readResponseText()}`); - } - } - - /** Read a file from the reactor's in-memory filesystem. */ - readFile(path: string): string | undefined { - this.ensureCanEnterWasm(); - const pathBytes = encoder.encode(path); - this.writeRequest(pathBytes); - const status = this.exports.read_file(pathBytes.length); - if (status === 2) return undefined; - if (status !== 0) { - throw new Error(`Failed to read ${path}: ${this.readResponseText()}`); - } - return fileContentDecoder.decode(this.readResponseBytes()); - } - - removeFile(path: string): void { - this.ensureCanEnterWasm(); - const pathBytes = encoder.encode(path); - this.writeRequest(pathBytes); - if (this.exports.remove_file(pathBytes.length) !== 0) { - throw new Error(`Failed to remove ${path}: ${this.readResponseText()}`); - } - } - - close(): void { - if (this.closed) return; - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot close the same API transport"); - } - this.closed = true; - try { - this.exports.close_session(); - } - finally { - for (const name of this.callbackNames) { - unregisterWasmCallback(this.instance, name); - } - this.callbackNames.clear(); - setWasmFileSystem(this.instance, undefined); - } - } - - private call(method: string, payload: Uint8Array): Uint8Array { - this.ensureCanEnterWasm(); - const methodBytes = encoder.encode(method); - this.writeRequest(methodBytes, payload); - this.lastBytesSent = payload.length; - if (this.exports.handle_request(methodBytes.length, payload.length) !== 0) { - throw new Error(`TypeScript WASM request "${method}" failed: ${this.readResponseText()}`); - } - this.lastBytesReceived = this.exports.response_len(); - return this.readResponseBytes(); - } - - private writeRequest(first: Uint8Array, second?: Uint8Array): void { - const total = first.length + (second?.length ?? 0); - const requestPointer = this.exports.get_request_buffer(total) >>> 0; - const memory = new Uint8Array(this.exports.memory.buffer); - memory.set(first, requestPointer); - if (second) { - memory.set(second, requestPointer + first.length); - } - } - - private readResponseText(): string { - return decoder.decode(this.readResponseBytes()); - } - - private readResponseBytes(): Uint8Array { - const length = this.exports.response_len(); - if (length === 0) return new Uint8Array(); - return new Uint8Array(this.exports.memory.buffer, this.exports.response_ptr() >>> 0, length).slice(); - } - - private ensureOpen(): void { - if (this.closed) { - throw new Error("The TypeScript WASM transport is closed"); - } - } - - private ensureCanEnterWasm(): void { - this.ensureOpen(); - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot call the same API transport"); - } - } - - private configureFileSystem(fs: WasmFileSystem | undefined): void { - const writeFile = fs?.writeFile; - setWasmFileSystem( - this.instance, - typeof writeFile === "function" - ? { writeFile: (path, data) => this.invokeCallback(() => writeFile(path, data)) } - : fs, - ); - } - - private invokeCallback(callback: () => T): T { - const previous = this.inCallback; - this.inCallback = true; - try { - return callback(); - } - finally { - this.inCallback = previous; - } - } -} - -function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { - const exports = instance.exports as unknown as Partial; - const required = [ - "create_session", - "close_session", - "get_request_buffer", - "handle_request", - "set_file", - "read_file", - "remove_file", - "response_ptr", - "response_len", - ] as const; - const missing: string[] = required.filter(name => typeof exports[name] !== "function"); - if (exports.memory == null || typeof exports.memory !== "object" || !("buffer" in exports.memory)) { - missing.push("memory"); - } - if (missing.length > 0) { - throw new Error(`Invalid TypeScript WASM reactor: missing ${missing.join(", ")}`); - } - return exports as WasmReactorExports; -} +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import { + encodeWtf8, + Wtf8Decoder, +} from "../api/node/wtf8.ts"; +import { + registerWasmCallback, + setWasmFileSystem, + unregisterWasmCallback, + type WasmFileSystem, +} from "./wasi.ts"; + +export interface WasmReactorExports { + memory: { readonly buffer: ArrayBufferLike; }; + create_session(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 initialized reactor, normally created by `instantiateWasm` or + * `instantiateWasmSync`. Other WASI hosts must call the module's + * `typescript_initialize` export instead of `_start`, which runs the + * command line compiler, and cannot use the `fs` option or host + * callbacks such as custom file systems passed to the API. + */ + instance: WasmReactorInstance; + cwd?: string | undefined; + useCaseSensitiveFileNames?: boolean | undefined; + collectTiming?: boolean | undefined; + fs?: WasmFileSystem | undefined; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); + +/** + * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. + * + * Host callbacks must complete synchronously and cannot call this transport while + * an outer request is in progress. + */ +export class WasmTransport { + lastBytesSent = 0; + lastBytesReceived = 0; + + private readonly instance: WasmReactorInstance; + private readonly exports: WasmReactorExports; + private closed = false; + private inCallback = false; + private readonly callbackNames = new Set(); + + constructor(options: WasmTransportOptions) { + this.instance = options.instance; + this.exports = getReactorExports(options.instance); + const sessionOptions = encoder.encode(JSON.stringify({ + cwd: options.cwd ?? "/", + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, + collectTiming: options.collectTiming, + })); + this.writeRequest(sessionOptions); + if (this.exports.create_session(sessionOptions.length) !== 0) { + throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); + } + try { + this.configureFileSystem(options.fs); + } + catch (error) { + this.exports.close_session(); + throw error; + } + } + + setFileSystem(fs: WasmFileSystem | undefined): void { + this.ensureOpen(); + this.configureFileSystem(fs); + } + + requestSync(method: string, payload: string): string { + return decoder.decode(this.call(method, encoder.encode(payload))); + } + + request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } { + const value = this.requestSync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + requestBinarySync(method: string, payload: Uint8Array): Uint8Array { + return this.call(method, payload); + } + + requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { + const value = this.requestBinarySync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + registerCallback(name: string, callback: (name: string, payload: string) => string): void { + this.ensureOpen(); + registerWasmCallback(this.instance, name, (callbackName, payload) => this.invokeCallback(() => callback(callbackName, payload))); + this.callbackNames.add(name); + } + + unregisterCallback(name: string): void { + if (!this.callbackNames.delete(name)) return; + unregisterWasmCallback(this.instance, name); + } + + setFile(path: string, content: string): void { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + const contentBytes = encodeWtf8(content); + this.writeRequest(pathBytes, contentBytes); + if (this.exports.set_file(pathBytes.length, contentBytes.length) !== 0) { + throw new Error(`Failed to write ${path}: ${this.readResponseText()}`); + } + } + + /** Read a file from the reactor's in-memory filesystem. */ + readFile(path: string): string | undefined { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + this.writeRequest(pathBytes); + const status = this.exports.read_file(pathBytes.length); + if (status === 2) return undefined; + if (status !== 0) { + throw new Error(`Failed to read ${path}: ${this.readResponseText()}`); + } + return fileContentDecoder.decode(this.readResponseBytes()); + } + + removeFile(path: string): void { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + this.writeRequest(pathBytes); + if (this.exports.remove_file(pathBytes.length) !== 0) { + throw new Error(`Failed to remove ${path}: ${this.readResponseText()}`); + } + } + + close(): void { + if (this.closed) return; + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot close the same API transport"); + } + this.closed = true; + try { + this.exports.close_session(); + } + finally { + for (const name of this.callbackNames) { + unregisterWasmCallback(this.instance, name); + } + this.callbackNames.clear(); + setWasmFileSystem(this.instance, undefined); + } + } + + private call(method: string, payload: Uint8Array): Uint8Array { + this.ensureCanEnterWasm(); + const methodBytes = encoder.encode(method); + this.writeRequest(methodBytes, payload); + this.lastBytesSent = payload.length; + if (this.exports.handle_request(methodBytes.length, payload.length) !== 0) { + throw new Error(`TypeScript WASM request "${method}" failed: ${this.readResponseText()}`); + } + this.lastBytesReceived = this.exports.response_len(); + return this.readResponseBytes(); + } + + private writeRequest(first: Uint8Array, second?: Uint8Array): void { + const total = first.length + (second?.length ?? 0); + const requestPointer = this.exports.get_request_buffer(total) >>> 0; + const memory = new Uint8Array(this.exports.memory.buffer); + memory.set(first, requestPointer); + if (second) { + memory.set(second, requestPointer + first.length); + } + } + + private readResponseText(): string { + return decoder.decode(this.readResponseBytes()); + } + + private readResponseBytes(): Uint8Array { + const length = this.exports.response_len(); + if (length === 0) return new Uint8Array(); + return new Uint8Array(this.exports.memory.buffer, this.exports.response_ptr() >>> 0, length).slice(); + } + + private ensureOpen(): void { + if (this.closed) { + throw new Error("The TypeScript WASM transport is closed"); + } + } + + private ensureCanEnterWasm(): void { + this.ensureOpen(); + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot call the same API transport"); + } + } + + private configureFileSystem(fs: WasmFileSystem | undefined): void { + const writeFile = fs?.writeFile; + setWasmFileSystem( + this.instance, + typeof writeFile === "function" + ? { writeFile: (path, data) => this.invokeCallback(() => writeFile(path, data)) } + : fs, + ); + } + + private invokeCallback(callback: () => T): T { + const previous = this.inCallback; + this.inCallback = true; + try { + return callback(); + } + finally { + this.inCallback = previous; + } + } +} + +function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { + const exports = instance.exports as unknown as Partial; + const required = [ + "create_session", + "close_session", + "get_request_buffer", + "handle_request", + "set_file", + "read_file", + "remove_file", + "response_ptr", + "response_len", + ] as const; + const missing: string[] = required.filter(name => typeof exports[name] !== "function"); + if (exports.memory == null || typeof exports.memory !== "object" || !("buffer" in exports.memory)) { + missing.push("memory"); + } + if (missing.length > 0) { + throw new Error(`Invalid TypeScript WASM reactor: missing ${missing.join(", ")}`); + } + return exports as WasmReactorExports; +} From e184c02d81dc187b1744452e901f65a1e3f74c6a Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:48:56 -0700 Subject: [PATCH 109/142] Share error reporting between the WASI host channels The callback and writeFile channels wrote errors back to the module in the same way, and stdout and stderr can be decoded chunk by chunk without first copying them into a single buffer. --- packages/typescript/src/wasm/wasi.ts | 943 +++++++++++++-------------- 1 file changed, 467 insertions(+), 476 deletions(-) diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index e503cdb4f93bf..64a5d4e36c488 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -1,476 +1,467 @@ -import type { FileSystemCallbacks } from "../api/fs.ts"; -import { - encodeFileSystemCallbackResult, - serverFS, -} from "../api/fsCallbacks.ts"; -import { Wtf8Decoder } from "../api/node/wtf8.ts"; -import type { RootedFilePath } from "../ast/index.ts"; -import type { WasmReactorInstance } from "./transport.ts"; - -declare namespace WebAssembly { - interface Module {} - - type Imports = Record>; - - interface Instance { - readonly exports: Record; - } - - const Instance: { - new (module: Module, imports?: Imports): Instance; - }; - - interface Memory { - readonly buffer: ArrayBufferLike; - } - - const Memory: { - readonly prototype: Memory; - new (descriptor: object): Memory; - }; - - function instantiate(module: Module, imports?: Imports): Promise; -} - -interface StreamingTextDecoder { - decode( - input?: ArrayBuffer | ArrayBufferView | null, - options?: { stream?: boolean | undefined; }, - ): string; -} - -const errnoSuccess = 0; -const errnoBadFileDescriptor = 8; -const errnoInvalidArgument = 28; -const errnoIo = 29; -const errnoNoBuffers = 42; -const errnoNoSys = 52; -const fileTypeCharacterDevice = 2; -const eventTypeClock = 0; -const subscriptionClockAbstime = 1; -const hostCallbackFD = 0x7fff_fffd; -const hostWriteFileFD = 0x7fff_fffe; -const textDecoder = new TextDecoder(); -const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); - -export interface InstantiateWasmOptions { - stdout?: ((text: string) => void) | undefined; - stderr?: ((text: string) => void) | undefined; -} - -export interface WasmFileSystem { - writeFile?: FileSystemCallbacks["writeFile"] | undefined; -} - -interface WasmHost { - setFileSystem(fs: WasmFileSystem | undefined): void; - registerCallback(name: string, callback: (name: string, payload: string) => string): void; - unregisterCallback(name: string): void; -} - -const wasmHosts = new WeakMap(); - -export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { - const host = wasmHosts.get(instance); - if (!host) { - if (fs === undefined) return; - throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); - } - host.setFileSystem(fs); -} - -export function registerWasmCallback( - instance: WasmReactorInstance, - name: string, - callback: (name: string, payload: string) => string, -): void { - const host = wasmHosts.get(instance); - if (!host) { - throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); - } - host.registerCallback(name, callback); -} - -export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { - wasmHosts.get(instance)?.unregisterCallback(name); -} - -/** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ -export async function instantiateWasm( - module: WebAssembly.Module, - options: InstantiateWasmOptions = {}, -): Promise { - const host = createWasiHost(options); - const instance = await WebAssembly.instantiate(module, host.imports); - return host.initialize(instance); -} - -/** - * Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. - * - * Prefer `instantiateWasm` on browser main threads, where synchronous - * instantiation of large WebAssembly modules may be rejected. - */ -export function instantiateWasmSync( - module: WebAssembly.Module, - options: InstantiateWasmOptions = {}, -): WasmReactorInstance { - const host = createWasiHost(options); - const instance = new WebAssembly.Instance(module, host.imports); - return host.initialize(instance); -} - -function createWasiHost(options: InstantiateWasmOptions): { - imports: WebAssembly.Imports; - initialize(instance: WebAssembly.Instance): WasmReactorInstance; -} { - let instance: WebAssembly.Instance | undefined; - const stdout = options.stdout ?? (text => console.log(text)); - const stderr = options.stderr ?? (text => console.error(text)); - const decoders = new Map(); - const encoder = new TextEncoder(); - const callbacks = new Map string>(); - let pendingCallbackResult: Uint8Array | undefined; - const closedDescriptors = new Set(); - let fileSystem: WasmFileSystem | undefined; - - function getMemory(): WebAssembly.Memory { - const memory = instance?.exports.memory; - if (!(memory instanceof WebAssembly.Memory)) { - throw new Error("TypeScript WASM reactor did not export its memory"); - } - return memory; - } - - function getView(): DataView { - return new DataView(getMemory().buffer); - } - - function argsSizesGet(countPointer: number, sizePointer: number): number { - countPointer >>>= 0; - sizePointer >>>= 0; - const view = getView(); - view.setUint32(countPointer, 0, true); - view.setUint32(sizePointer, 0, true); - return errnoSuccess; - } - - function clockTimeGet(clockId: number, _precision: bigint, timePointer: number): number { - timePointer >>>= 0; - let nanoseconds: bigint; - switch (clockId) { - case 0: - nanoseconds = BigInt(Date.now()) * 1_000_000n; - break; - case 1: - nanoseconds = BigInt(Math.round(performance.now() * 1e6)); - break; - default: - return errnoInvalidArgument; - } - getView().setBigUint64(timePointer, nanoseconds, true); - return errnoSuccess; - } - - function fdFdstatGet(fd: number, statPointer: number): number { - statPointer >>>= 0; - if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; - const memory = getMemory(); - new Uint8Array(memory.buffer, statPointer, 24).fill(0); - const view = new DataView(memory.buffer); - view.setUint8(statPointer, fileTypeCharacterDevice); - const rights = fd === 0 ? 1n << 1n : 1n << 6n; - view.setBigUint64(statPointer + 8, rights, true); - return errnoSuccess; - } - - function fdFdstatSetFlags(fd: number, _flags: number): number { - return isOpenStdioDescriptor(fd) ? errnoSuccess : errnoBadFileDescriptor; - } - - function fdRead(fd: number, _iovsPointer: number, _iovsLength: number, readPointer: number): number { - readPointer >>>= 0; - if (fd !== 0 || closedDescriptors.has(fd)) return errnoBadFileDescriptor; - getView().setUint32(readPointer, 0, true); - return errnoSuccess; - } - - function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { - iovsPointer >>>= 0; - writtenPointer >>>= 0; - if (fd === hostWriteFileFD) { - return hostWriteFile(iovsPointer, iovsLength, writtenPointer); - } - if (fd === hostCallbackFD) { - return hostCallback(iovsPointer, iovsLength, writtenPointer); - } - if ((fd !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; - const memory = getMemory(); - const view = new DataView(memory.buffer); - const chunks: Uint8Array[] = []; - let length = 0; - for (let i = 0; i < iovsLength; i++) { - const iovPointer = iovsPointer + i * 8; - const chunkPointer = view.getUint32(iovPointer, true); - const chunkLength = view.getUint32(iovPointer + 4, true); - chunks.push(new Uint8Array(memory.buffer, chunkPointer, chunkLength)); - length += chunkLength; - } - const bytes = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.length; - } - view.setUint32(writtenPointer, length, true); - if (length === 0) return errnoSuccess; - const decoder = decoders.get(fd) ?? new TextDecoder(); - decoders.set(fd, decoder); - const text = decoder.decode(bytes, { stream: true }); - if (text) { - (fd === 1 ? stdout : stderr)(text); - } - return errnoSuccess; - } - - function fdClose(fd: number): number { - if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; - closedDescriptors.add(fd); - const decoder = decoders.get(fd); - if (decoder) { - decoders.delete(fd); - const text = decoder.decode(); - if (text) { - (fd === 1 ? stdout : stderr)(text); - } - } - return errnoSuccess; - } - - function isOpenStdioDescriptor(fd: number): boolean { - return fd >= 0 && fd <= 2 && !closedDescriptors.has(fd); - } - - function hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { - if (iovsLength !== 1) return errnoInvalidArgument; - const memory = getMemory(); - const view = new DataView(memory.buffer); - const bufferPointer = view.getUint32(iovsPointer, true); - const bufferLength = view.getUint32(iovsPointer + 4, true); - if (bufferLength < 16) return errnoInvalidArgument; - const methodLength = view.getUint32(bufferPointer, true); - const payloadLength = view.getUint32(bufferPointer + 4, true); - const resultCapacity = view.getUint32(bufferPointer + 8, true); - const errorCapacity = view.getUint32(bufferPointer + 12, true); - if (16 + methodLength + payloadLength + resultCapacity + errorCapacity > bufferLength) { - return errnoInvalidArgument; - } - const methodPointer = bufferPointer + 16; - const payloadPointer = methodPointer + methodLength; - const resultPointer = payloadPointer + payloadLength; - const errorPointer = resultPointer + resultCapacity; - const decoder = new TextDecoder(); - const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); - const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); - const pending = pendingCallbackResult; - pendingCallbackResult = undefined; - try { - let result: Uint8Array; - if (methodLength === 0) { - // An empty method fetches a result that did not fit the previous call's buffer. - if (!pending || pending.length > resultCapacity) return errnoInvalidArgument; - result = pending; - } - else { - const callback = callbacks.get(method); - if (!callback) { - throw new Error(`No callback is registered for ${method}`); - } - result = encoder.encode(callback(method, payload)); - if (result.length > resultCapacity) { - pendingCallbackResult = result; - const resultView = new DataView(getMemory().buffer); - resultView.setUint32(bufferPointer + 8, result.length, true); - resultView.setUint32(bufferPointer + 12, 0, true); - resultView.setUint32(writtenPointer, 0, true); - return errnoNoBuffers; - } - } - const resultMemory = getMemory(); - const resultView = new DataView(resultMemory.buffer); - new Uint8Array(resultMemory.buffer, resultPointer, result.length).set(result); - resultView.setUint32(bufferPointer + 8, result.length, true); - resultView.setUint32(bufferPointer + 12, 0, true); - resultView.setUint32(writtenPointer, bufferLength, true); - return errnoSuccess; - } - catch (error) { - const errorBytes = encoder.encode(error instanceof Error ? error.message : String(error)); - const errorLength = Math.min(errorBytes.length, errorCapacity); - const errorMemory = getMemory(); - const errorView = new DataView(errorMemory.buffer); - new Uint8Array(errorMemory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); - errorView.setUint32(bufferPointer + 8, 0, true); - errorView.setUint32(bufferPointer + 12, errorLength, true); - errorView.setUint32(writtenPointer, 0, true); - return errnoIo; - } - } - - function randomGet(bufferPointer: number, bufferLength: number): number { - bufferPointer >>>= 0; - const buffer = new Uint8Array(getMemory().buffer, bufferPointer, bufferLength); - for (let offset = 0; offset < buffer.length; offset += 65_536) { - crypto.getRandomValues(buffer.subarray(offset, Math.min(offset + 65_536, buffer.length))); - } - return errnoSuccess; - } - - function hostWriteFile(iovsPointer: number, iovsLength: number, writtenPointer: number): number { - iovsPointer >>>= 0; - writtenPointer >>>= 0; - const writeFile = fileSystem?.writeFile; - if (writeFile === undefined || writeFile === serverFS.useOS) 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 pathPointer = bufferPointer + 12; - const dataPointer = pathPointer + pathLength; - const errorPointer = dataPointer + dataLength; - const path = textDecoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); - const data = fileContentDecoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); - try { - const result = typeof writeFile === "function" ? writeFile(path as RootedFilePath, data) : writeFile; - const response = encodeFileSystemCallbackResult("writeFile", result); - if (response.kind === "useOS") return errnoBadFileDescriptor; - if (response.kind === "error") throw new Error("filesystem callback returned serverFS.error: writeFile"); - new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); - return errnoSuccess; - } - catch (error) { - const errorBytes = encoder.encode(error instanceof Error ? error.message : String(error)); - const errorLength = Math.min(errorBytes.length, errorCapacity); - const errorMemory = getMemory(); - const errorView = new DataView(errorMemory.buffer); - new Uint8Array(errorMemory.buffer, errorPointer, errorLength).set(errorBytes.subarray(0, errorLength)); - errorView.setUint32(bufferPointer + 8, errorLength, true); - errorView.setUint32(writtenPointer, 0, true); - return errnoIo; - } - } - - function pollOneoff( - subscriptionsPointer: number, - eventsPointer: number, - subscriptionsLength: number, - eventsLengthPointer: number, - ): number { - subscriptionsPointer >>>= 0; - eventsPointer >>>= 0; - eventsLengthPointer >>>= 0; - if (subscriptionsLength === 0) return errnoInvalidArgument; - const memory = getMemory(); - const view = new DataView(memory.buffer); - let selected = -1; - let shortestDelay = Number.POSITIVE_INFINITY; - for (let i = 0; i < subscriptionsLength; i++) { - const subscription = subscriptionsPointer + i * 48; - if (view.getUint8(subscription + 8) !== eventTypeClock) continue; - const clockId = view.getUint32(subscription + 16, true); - if (clockId !== 0 && clockId !== 1) return errnoInvalidArgument; - const timeout = view.getBigUint64(subscription + 24, true); - const flags = view.getUint16(subscription + 40, true); - const now = clockId === 0 ? Date.now() : performance.now(); - const delay = flags & subscriptionClockAbstime - ? Number(timeout) / 1e6 - now - : Number(timeout) / 1e6; - if (delay < shortestDelay) { - selected = subscription; - shortestDelay = delay; - } - } - if (selected < 0) return errnoNoSys; - - const deadline = performance.now() + Math.max(0, shortestDelay); - while (performance.now() < deadline) { - // WASI imports are synchronous, so a clock subscription must spin here. - // The Go scheduler only polls when every goroutine is idle with a - // pending timer, which the reactor does not hit during API requests. - } - - new Uint8Array(memory.buffer, eventsPointer, 32).fill(0); - view.setBigUint64(eventsPointer, view.getBigUint64(selected, true), true); - view.setUint8(eventsPointer + 10, eventTypeClock); - view.setUint32(eventsLengthPointer, 1, true); - return errnoSuccess; - } - - function unsupported(): number { - return errnoNoSys; - } - - const wasi = { - args_get: () => errnoSuccess, - args_sizes_get: argsSizesGet, - clock_time_get: clockTimeGet, - environ_get: () => errnoSuccess, - environ_sizes_get: argsSizesGet, - fd_close: fdClose, - fd_fdstat_get: fdFdstatGet, - fd_fdstat_set_flags: fdFdstatSetFlags, - fd_filestat_get: unsupported, - fd_pread: unsupported, - fd_prestat_dir_name: unsupported, - fd_prestat_get: () => errnoBadFileDescriptor, - fd_read: fdRead, - fd_readdir: unsupported, - fd_write: fdWrite, - path_create_directory: unsupported, - path_filestat_get: unsupported, - path_filestat_set_times: unsupported, - path_open: unsupported, - path_readlink: unsupported, - path_remove_directory: unsupported, - path_unlink_file: unsupported, - poll_oneoff: pollOneoff, - proc_exit: (code: number) => { - throw new Error(`TypeScript WASM runtime exited with code ${code}`); - }, - random_get: randomGet, - sched_yield: () => errnoSuccess, - sock_accept: unsupported, - }; - - return { - imports: { - wasi_snapshot_preview1: wasi, - }, - initialize(value) { - instance = value; - const initialize = instance.exports.typescript_initialize; - if (typeof initialize !== "function") { - throw new Error("TypeScript WASM reactor did not export typescript_initialize"); - } - initialize(); - wasmHosts.set(instance, { - setFileSystem(value) { - fileSystem = value; - }, - registerCallback(name, callback) { - callbacks.set(name, callback); - }, - unregisterCallback(name) { - callbacks.delete(name); - }, - }); - return instance; - }, - }; -} +import type { FileSystemCallbacks } from "../api/fs.ts"; +import { + encodeFileSystemCallbackResult, + serverFS, +} from "../api/fsCallbacks.ts"; +import { Wtf8Decoder } from "../api/node/wtf8.ts"; +import type { RootedFilePath } from "../ast/index.ts"; +import type { WasmReactorInstance } from "./transport.ts"; + +declare namespace WebAssembly { + interface Module {} + + type Imports = Record>; + + interface Instance { + readonly exports: Record; + } + + const Instance: { + new (module: Module, imports?: Imports): Instance; + }; + + interface Memory { + readonly buffer: ArrayBufferLike; + } + + const Memory: { + readonly prototype: Memory; + new (descriptor: object): Memory; + }; + + function instantiate(module: Module, imports?: Imports): Promise; +} + +interface StreamingTextDecoder { + decode( + input?: ArrayBuffer | ArrayBufferView | null, + options?: { stream?: boolean | undefined; }, + ): string; +} + +const errnoSuccess = 0; +const errnoBadFileDescriptor = 8; +const errnoInvalidArgument = 28; +const errnoIo = 29; +const errnoNoBuffers = 42; +const errnoNoSys = 52; +const fileTypeCharacterDevice = 2; +const eventTypeClock = 0; +const subscriptionClockAbstime = 1; +const hostCallbackFD = 0x7fff_fffd; +const hostWriteFileFD = 0x7fff_fffe; +const textDecoder = new TextDecoder(); +const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); + +export interface InstantiateWasmOptions { + stdout?: ((text: string) => void) | undefined; + stderr?: ((text: string) => void) | undefined; +} + +export interface WasmFileSystem { + writeFile?: FileSystemCallbacks["writeFile"] | undefined; +} + +interface WasmHost { + setFileSystem(fs: WasmFileSystem | undefined): void; + registerCallback(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback(name: string): void; +} + +const wasmHosts = new WeakMap(); + +export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { + const host = wasmHosts.get(instance); + if (!host) { + if (fs === undefined) return; + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.setFileSystem(fs); +} + +export function registerWasmCallback( + instance: WasmReactorInstance, + name: string, + callback: (name: string, payload: string) => string, +): void { + const host = wasmHosts.get(instance); + if (!host) { + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.registerCallback(name, callback); +} + +export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { + wasmHosts.get(instance)?.unregisterCallback(name); +} + +/** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ +export async function instantiateWasm( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): Promise { + const host = createWasiHost(options); + const instance = await WebAssembly.instantiate(module, host.imports); + return host.initialize(instance); +} + +/** + * Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. + * + * Prefer `instantiateWasm` on browser main threads, where synchronous + * instantiation of large WebAssembly modules may be rejected. + */ +export function instantiateWasmSync( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): WasmReactorInstance { + const host = createWasiHost(options); + const instance = new WebAssembly.Instance(module, host.imports); + return host.initialize(instance); +} + +function createWasiHost(options: InstantiateWasmOptions): { + imports: WebAssembly.Imports; + initialize(instance: WebAssembly.Instance): WasmReactorInstance; +} { + let instance: WebAssembly.Instance | undefined; + const stdout = options.stdout ?? (text => console.log(text)); + const stderr = options.stderr ?? (text => console.error(text)); + const decoders = new Map(); + const encoder = new TextEncoder(); + const callbacks = new Map string>(); + let pendingCallbackResult: Uint8Array | undefined; + const closedDescriptors = new Set(); + let fileSystem: WasmFileSystem | undefined; + + function getMemory(): WebAssembly.Memory { + const memory = instance?.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error("TypeScript WASM reactor did not export its memory"); + } + return memory; + } + + function getView(): DataView { + return new DataView(getMemory().buffer); + } + + function argsSizesGet(countPointer: number, sizePointer: number): number { + countPointer >>>= 0; + sizePointer >>>= 0; + const view = getView(); + view.setUint32(countPointer, 0, true); + view.setUint32(sizePointer, 0, true); + return errnoSuccess; + } + + function clockTimeGet(clockId: number, _precision: bigint, timePointer: number): number { + timePointer >>>= 0; + let nanoseconds: bigint; + switch (clockId) { + case 0: + nanoseconds = BigInt(Date.now()) * 1_000_000n; + break; + case 1: + nanoseconds = BigInt(Math.round(performance.now() * 1e6)); + break; + default: + return errnoInvalidArgument; + } + getView().setBigUint64(timePointer, nanoseconds, true); + return errnoSuccess; + } + + function fdFdstatGet(fd: number, statPointer: number): number { + statPointer >>>= 0; + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; + const memory = getMemory(); + new Uint8Array(memory.buffer, statPointer, 24).fill(0); + const view = new DataView(memory.buffer); + view.setUint8(statPointer, fileTypeCharacterDevice); + const rights = fd === 0 ? 1n << 1n : 1n << 6n; + view.setBigUint64(statPointer + 8, rights, true); + return errnoSuccess; + } + + function fdFdstatSetFlags(fd: number, _flags: number): number { + return isOpenStdioDescriptor(fd) ? errnoSuccess : errnoBadFileDescriptor; + } + + function fdRead(fd: number, _iovsPointer: number, _iovsLength: number, readPointer: number): number { + readPointer >>>= 0; + if (fd !== 0 || closedDescriptors.has(fd)) return errnoBadFileDescriptor; + getView().setUint32(readPointer, 0, true); + return errnoSuccess; + } + + function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { + iovsPointer >>>= 0; + writtenPointer >>>= 0; + if (fd === hostWriteFileFD) { + return hostWriteFile(iovsPointer, iovsLength, writtenPointer); + } + if (fd === hostCallbackFD) { + return hostCallback(iovsPointer, iovsLength, writtenPointer); + } + if ((fd !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; + const memory = getMemory(); + const view = new DataView(memory.buffer); + let decoder = decoders.get(fd); + if (!decoder) decoders.set(fd, decoder = new TextDecoder()); + let text = ""; + let length = 0; + for (let i = 0; i < iovsLength; i++) { + const iovPointer = iovsPointer + i * 8; + const chunkLength = view.getUint32(iovPointer + 4, true); + text += decoder.decode(new Uint8Array(memory.buffer, view.getUint32(iovPointer, true), chunkLength), { stream: true }); + length += chunkLength; + } + view.setUint32(writtenPointer, length, true); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + return errnoSuccess; + } + + function fdClose(fd: number): number { + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; + closedDescriptors.add(fd); + const decoder = decoders.get(fd); + if (decoder) { + decoders.delete(fd); + const text = decoder.decode(); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + } + return errnoSuccess; + } + + function isOpenStdioDescriptor(fd: number): boolean { + return fd >= 0 && fd <= 2 && !closedDescriptors.has(fd); + } + + function hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { + if (iovsLength !== 1) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const bufferPointer = view.getUint32(iovsPointer, true); + const bufferLength = view.getUint32(iovsPointer + 4, true); + if (bufferLength < 16) return errnoInvalidArgument; + const methodLength = view.getUint32(bufferPointer, true); + const payloadLength = view.getUint32(bufferPointer + 4, true); + const resultCapacity = view.getUint32(bufferPointer + 8, true); + const errorCapacity = view.getUint32(bufferPointer + 12, true); + if (16 + methodLength + payloadLength + resultCapacity + errorCapacity > bufferLength) { + return errnoInvalidArgument; + } + const methodPointer = bufferPointer + 16; + const payloadPointer = methodPointer + methodLength; + const resultPointer = payloadPointer + payloadLength; + const errorPointer = resultPointer + resultCapacity; + const decoder = new TextDecoder(); + const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); + const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); + const pending = pendingCallbackResult; + pendingCallbackResult = undefined; + try { + let result: Uint8Array; + if (methodLength === 0) { + // An empty method fetches a result that did not fit the previous call's buffer. + if (!pending || pending.length > resultCapacity) return errnoInvalidArgument; + result = pending; + } + else { + const callback = callbacks.get(method); + if (!callback) { + throw new Error(`No callback is registered for ${method}`); + } + result = encoder.encode(callback(method, payload)); + if (result.length > resultCapacity) { + pendingCallbackResult = result; + const resultView = new DataView(getMemory().buffer); + resultView.setUint32(bufferPointer + 8, result.length, true); + resultView.setUint32(bufferPointer + 12, 0, true); + resultView.setUint32(writtenPointer, 0, true); + return errnoNoBuffers; + } + } + const resultMemory = getMemory(); + const resultView = new DataView(resultMemory.buffer); + new Uint8Array(resultMemory.buffer, resultPointer, result.length).set(result); + resultView.setUint32(bufferPointer + 8, result.length, true); + resultView.setUint32(bufferPointer + 12, 0, true); + resultView.setUint32(writtenPointer, bufferLength, true); + return errnoSuccess; + } + catch (error) { + const errorView = new DataView(getMemory().buffer); + errorView.setUint32(bufferPointer + 8, 0, true); + errorView.setUint32(bufferPointer + 12, writeHostError(error, errorPointer, errorCapacity), true); + errorView.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + /** Writes as much of the error message as fits and returns its length. */ + function writeHostError(error: unknown, pointer: number, capacity: number): number { + const bytes = encoder.encode(error instanceof Error ? error.message : String(error)); + const length = Math.min(bytes.length, capacity); + new Uint8Array(getMemory().buffer, pointer, length).set(bytes.subarray(0, length)); + return length; + } + + 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; + const writeFile = fileSystem?.writeFile; + if (writeFile === undefined || writeFile === serverFS.useOS) 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 pathPointer = bufferPointer + 12; + const dataPointer = pathPointer + pathLength; + const errorPointer = dataPointer + dataLength; + const path = textDecoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); + const data = fileContentDecoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); + try { + const result = typeof writeFile === "function" ? writeFile(path as RootedFilePath, data) : writeFile; + const response = encodeFileSystemCallbackResult("writeFile", result); + if (response.kind === "useOS") return errnoBadFileDescriptor; + if (response.kind === "error") throw new Error("filesystem callback returned serverFS.error: writeFile"); + new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); + return errnoSuccess; + } + catch (error) { + const errorView = new DataView(getMemory().buffer); + errorView.setUint32(bufferPointer + 8, writeHostError(error, errorPointer, errorCapacity), true); + errorView.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + function pollOneoff( + subscriptionsPointer: number, + eventsPointer: number, + subscriptionsLength: number, + eventsLengthPointer: number, + ): number { + subscriptionsPointer >>>= 0; + eventsPointer >>>= 0; + eventsLengthPointer >>>= 0; + if (subscriptionsLength === 0) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + let selected = -1; + let shortestDelay = Number.POSITIVE_INFINITY; + for (let i = 0; i < subscriptionsLength; i++) { + const subscription = subscriptionsPointer + i * 48; + if (view.getUint8(subscription + 8) !== eventTypeClock) continue; + const clockId = view.getUint32(subscription + 16, true); + if (clockId !== 0 && clockId !== 1) return errnoInvalidArgument; + const timeout = view.getBigUint64(subscription + 24, true); + const flags = view.getUint16(subscription + 40, true); + const now = clockId === 0 ? Date.now() : performance.now(); + const delay = flags & subscriptionClockAbstime + ? Number(timeout) / 1e6 - now + : Number(timeout) / 1e6; + if (delay < shortestDelay) { + selected = subscription; + shortestDelay = delay; + } + } + if (selected < 0) return errnoNoSys; + + const deadline = performance.now() + Math.max(0, shortestDelay); + while (performance.now() < deadline) { + // WASI imports are synchronous, so a clock subscription must spin here. + // The Go scheduler only polls when every goroutine is idle with a + // pending timer, which the reactor does not hit during API requests. + } + + new Uint8Array(memory.buffer, eventsPointer, 32).fill(0); + view.setBigUint64(eventsPointer, view.getBigUint64(selected, true), true); + view.setUint8(eventsPointer + 10, eventTypeClock); + view.setUint32(eventsLengthPointer, 1, true); + return errnoSuccess; + } + + function unsupported(): number { + return errnoNoSys; + } + + const wasi = { + args_get: () => errnoSuccess, + args_sizes_get: argsSizesGet, + clock_time_get: clockTimeGet, + environ_get: () => errnoSuccess, + environ_sizes_get: argsSizesGet, + fd_close: fdClose, + fd_fdstat_get: fdFdstatGet, + fd_fdstat_set_flags: fdFdstatSetFlags, + fd_filestat_get: unsupported, + fd_pread: unsupported, + fd_prestat_dir_name: unsupported, + fd_prestat_get: () => errnoBadFileDescriptor, + fd_read: fdRead, + fd_readdir: unsupported, + fd_write: fdWrite, + path_create_directory: unsupported, + path_filestat_get: unsupported, + path_filestat_set_times: unsupported, + path_open: unsupported, + path_readlink: unsupported, + path_remove_directory: unsupported, + path_unlink_file: unsupported, + poll_oneoff: pollOneoff, + proc_exit: (code: number) => { + throw new Error(`TypeScript WASM runtime exited with code ${code}`); + }, + random_get: randomGet, + sched_yield: () => errnoSuccess, + sock_accept: unsupported, + }; + + return { + imports: { + wasi_snapshot_preview1: wasi, + }, + initialize(value) { + instance = value; + const initialize = instance.exports.typescript_initialize; + if (typeof initialize !== "function") { + throw new Error("TypeScript WASM reactor did not export typescript_initialize"); + } + initialize(); + wasmHosts.set(instance, { + setFileSystem(value) { + fileSystem = value; + }, + registerCallback(name, callback) { + callbacks.set(name, callback); + }, + unregisterCallback(name) { + callbacks.delete(name); + }, + }); + return instance; + }, + }; +} From 3ec9a439b2dcb3a25a5b339aaec5ae77c45ce474 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:52:24 -0700 Subject: [PATCH 110/142] Share file mirroring in the WASM test harness Open files and file change notifications mirror a file into the reactor in exactly the same way. --- packages/typescript/src/wasm/transport.ts | 532 ++++++------ packages/typescript/src/wasm/wasi.ts | 934 +++++++++++----------- packages/typescript/test/wasm.ts | 55 +- 3 files changed, 756 insertions(+), 765 deletions(-) diff --git a/packages/typescript/src/wasm/transport.ts b/packages/typescript/src/wasm/transport.ts index 3384bbc2488af..d688a78348821 100644 --- a/packages/typescript/src/wasm/transport.ts +++ b/packages/typescript/src/wasm/transport.ts @@ -1,266 +1,266 @@ -export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; -import { - encodeWtf8, - Wtf8Decoder, -} from "../api/node/wtf8.ts"; -import { - registerWasmCallback, - setWasmFileSystem, - unregisterWasmCallback, - type WasmFileSystem, -} from "./wasi.ts"; - -export interface WasmReactorExports { - memory: { readonly buffer: ArrayBufferLike; }; - create_session(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 initialized reactor, normally created by `instantiateWasm` or - * `instantiateWasmSync`. Other WASI hosts must call the module's - * `typescript_initialize` export instead of `_start`, which runs the - * command line compiler, and cannot use the `fs` option or host - * callbacks such as custom file systems passed to the API. - */ - instance: WasmReactorInstance; - cwd?: string | undefined; - useCaseSensitiveFileNames?: boolean | undefined; - collectTiming?: boolean | undefined; - fs?: WasmFileSystem | undefined; -} - -const encoder = new TextEncoder(); -const decoder = new TextDecoder(); -const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); - -/** - * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. - * - * Host callbacks must complete synchronously and cannot call this transport while - * an outer request is in progress. - */ -export class WasmTransport { - lastBytesSent = 0; - lastBytesReceived = 0; - - private readonly instance: WasmReactorInstance; - private readonly exports: WasmReactorExports; - private closed = false; - private inCallback = false; - private readonly callbackNames = new Set(); - - constructor(options: WasmTransportOptions) { - this.instance = options.instance; - this.exports = getReactorExports(options.instance); - const sessionOptions = encoder.encode(JSON.stringify({ - cwd: options.cwd ?? "/", - useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, - collectTiming: options.collectTiming, - })); - this.writeRequest(sessionOptions); - if (this.exports.create_session(sessionOptions.length) !== 0) { - throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); - } - try { - this.configureFileSystem(options.fs); - } - catch (error) { - this.exports.close_session(); - throw error; - } - } - - setFileSystem(fs: WasmFileSystem | undefined): void { - this.ensureOpen(); - this.configureFileSystem(fs); - } - - requestSync(method: string, payload: string): string { - return decoder.decode(this.call(method, encoder.encode(payload))); - } - - request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } { - const value = this.requestSync(method, payload); - return { - value, - bytesSent: this.lastBytesSent, - bytesReceived: this.lastBytesReceived, - }; - } - - requestBinarySync(method: string, payload: Uint8Array): Uint8Array { - return this.call(method, payload); - } - - requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { - const value = this.requestBinarySync(method, payload); - return { - value, - bytesSent: this.lastBytesSent, - bytesReceived: this.lastBytesReceived, - }; - } - - registerCallback(name: string, callback: (name: string, payload: string) => string): void { - this.ensureOpen(); - registerWasmCallback(this.instance, name, (callbackName, payload) => this.invokeCallback(() => callback(callbackName, payload))); - this.callbackNames.add(name); - } - - unregisterCallback(name: string): void { - if (!this.callbackNames.delete(name)) return; - unregisterWasmCallback(this.instance, name); - } - - setFile(path: string, content: string): void { - this.ensureCanEnterWasm(); - const pathBytes = encoder.encode(path); - const contentBytes = encodeWtf8(content); - this.writeRequest(pathBytes, contentBytes); - if (this.exports.set_file(pathBytes.length, contentBytes.length) !== 0) { - throw new Error(`Failed to write ${path}: ${this.readResponseText()}`); - } - } - - /** Read a file from the reactor's in-memory filesystem. */ - readFile(path: string): string | undefined { - this.ensureCanEnterWasm(); - const pathBytes = encoder.encode(path); - this.writeRequest(pathBytes); - const status = this.exports.read_file(pathBytes.length); - if (status === 2) return undefined; - if (status !== 0) { - throw new Error(`Failed to read ${path}: ${this.readResponseText()}`); - } - return fileContentDecoder.decode(this.readResponseBytes()); - } - - removeFile(path: string): void { - this.ensureCanEnterWasm(); - const pathBytes = encoder.encode(path); - this.writeRequest(pathBytes); - if (this.exports.remove_file(pathBytes.length) !== 0) { - throw new Error(`Failed to remove ${path}: ${this.readResponseText()}`); - } - } - - close(): void { - if (this.closed) return; - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot close the same API transport"); - } - this.closed = true; - try { - this.exports.close_session(); - } - finally { - for (const name of this.callbackNames) { - unregisterWasmCallback(this.instance, name); - } - this.callbackNames.clear(); - setWasmFileSystem(this.instance, undefined); - } - } - - private call(method: string, payload: Uint8Array): Uint8Array { - this.ensureCanEnterWasm(); - const methodBytes = encoder.encode(method); - this.writeRequest(methodBytes, payload); - this.lastBytesSent = payload.length; - if (this.exports.handle_request(methodBytes.length, payload.length) !== 0) { - throw new Error(`TypeScript WASM request "${method}" failed: ${this.readResponseText()}`); - } - this.lastBytesReceived = this.exports.response_len(); - return this.readResponseBytes(); - } - - private writeRequest(first: Uint8Array, second?: Uint8Array): void { - const total = first.length + (second?.length ?? 0); - const requestPointer = this.exports.get_request_buffer(total) >>> 0; - const memory = new Uint8Array(this.exports.memory.buffer); - memory.set(first, requestPointer); - if (second) { - memory.set(second, requestPointer + first.length); - } - } - - private readResponseText(): string { - return decoder.decode(this.readResponseBytes()); - } - - private readResponseBytes(): Uint8Array { - const length = this.exports.response_len(); - if (length === 0) return new Uint8Array(); - return new Uint8Array(this.exports.memory.buffer, this.exports.response_ptr() >>> 0, length).slice(); - } - - private ensureOpen(): void { - if (this.closed) { - throw new Error("The TypeScript WASM transport is closed"); - } - } - - private ensureCanEnterWasm(): void { - this.ensureOpen(); - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot call the same API transport"); - } - } - - private configureFileSystem(fs: WasmFileSystem | undefined): void { - const writeFile = fs?.writeFile; - setWasmFileSystem( - this.instance, - typeof writeFile === "function" - ? { writeFile: (path, data) => this.invokeCallback(() => writeFile(path, data)) } - : fs, - ); - } - - private invokeCallback(callback: () => T): T { - const previous = this.inCallback; - this.inCallback = true; - try { - return callback(); - } - finally { - this.inCallback = previous; - } - } -} - -function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { - const exports = instance.exports as unknown as Partial; - const required = [ - "create_session", - "close_session", - "get_request_buffer", - "handle_request", - "set_file", - "read_file", - "remove_file", - "response_ptr", - "response_len", - ] as const; - const missing: string[] = required.filter(name => typeof exports[name] !== "function"); - if (exports.memory == null || typeof exports.memory !== "object" || !("buffer" in exports.memory)) { - missing.push("memory"); - } - if (missing.length > 0) { - throw new Error(`Invalid TypeScript WASM reactor: missing ${missing.join(", ")}`); - } - return exports as WasmReactorExports; -} +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import { + encodeWtf8, + Wtf8Decoder, +} from "../api/node/wtf8.ts"; +import { + registerWasmCallback, + setWasmFileSystem, + unregisterWasmCallback, + type WasmFileSystem, +} from "./wasi.ts"; + +export interface WasmReactorExports { + memory: { readonly buffer: ArrayBufferLike; }; + create_session(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 initialized reactor, normally created by `instantiateWasm` or + * `instantiateWasmSync`. Other WASI hosts must call the module's + * `typescript_initialize` export instead of `_start`, which runs the + * command line compiler, and cannot use the `fs` option or host + * callbacks such as custom file systems passed to the API. + */ + instance: WasmReactorInstance; + cwd?: string | undefined; + useCaseSensitiveFileNames?: boolean | undefined; + collectTiming?: boolean | undefined; + fs?: WasmFileSystem | undefined; +} + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); +const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); + +/** + * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. + * + * Host callbacks must complete synchronously and cannot call this transport while + * an outer request is in progress. + */ +export class WasmTransport { + lastBytesSent = 0; + lastBytesReceived = 0; + + private readonly instance: WasmReactorInstance; + private readonly exports: WasmReactorExports; + private closed = false; + private inCallback = false; + private readonly callbackNames = new Set(); + + constructor(options: WasmTransportOptions) { + this.instance = options.instance; + this.exports = getReactorExports(options.instance); + const sessionOptions = encoder.encode(JSON.stringify({ + cwd: options.cwd ?? "/", + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, + collectTiming: options.collectTiming, + })); + this.writeRequest(sessionOptions); + if (this.exports.create_session(sessionOptions.length) !== 0) { + throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); + } + try { + this.configureFileSystem(options.fs); + } + catch (error) { + this.exports.close_session(); + throw error; + } + } + + setFileSystem(fs: WasmFileSystem | undefined): void { + this.ensureOpen(); + this.configureFileSystem(fs); + } + + requestSync(method: string, payload: string): string { + return decoder.decode(this.call(method, encoder.encode(payload))); + } + + request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } { + const value = this.requestSync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + requestBinarySync(method: string, payload: Uint8Array): Uint8Array { + return this.call(method, payload); + } + + requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { + const value = this.requestBinarySync(method, payload); + return { + value, + bytesSent: this.lastBytesSent, + bytesReceived: this.lastBytesReceived, + }; + } + + registerCallback(name: string, callback: (name: string, payload: string) => string): void { + this.ensureOpen(); + registerWasmCallback(this.instance, name, (callbackName, payload) => this.invokeCallback(() => callback(callbackName, payload))); + this.callbackNames.add(name); + } + + unregisterCallback(name: string): void { + if (!this.callbackNames.delete(name)) return; + unregisterWasmCallback(this.instance, name); + } + + setFile(path: string, content: string): void { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + const contentBytes = encodeWtf8(content); + this.writeRequest(pathBytes, contentBytes); + if (this.exports.set_file(pathBytes.length, contentBytes.length) !== 0) { + throw new Error(`Failed to write ${path}: ${this.readResponseText()}`); + } + } + + /** Read a file from the reactor's in-memory filesystem. */ + readFile(path: string): string | undefined { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + this.writeRequest(pathBytes); + const status = this.exports.read_file(pathBytes.length); + if (status === 2) return undefined; + if (status !== 0) { + throw new Error(`Failed to read ${path}: ${this.readResponseText()}`); + } + return fileContentDecoder.decode(this.readResponseBytes()); + } + + removeFile(path: string): void { + this.ensureCanEnterWasm(); + const pathBytes = encoder.encode(path); + this.writeRequest(pathBytes); + if (this.exports.remove_file(pathBytes.length) !== 0) { + throw new Error(`Failed to remove ${path}: ${this.readResponseText()}`); + } + } + + close(): void { + if (this.closed) return; + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot close the same API transport"); + } + this.closed = true; + try { + this.exports.close_session(); + } + finally { + for (const name of this.callbackNames) { + unregisterWasmCallback(this.instance, name); + } + this.callbackNames.clear(); + setWasmFileSystem(this.instance, undefined); + } + } + + private call(method: string, payload: Uint8Array): Uint8Array { + this.ensureCanEnterWasm(); + const methodBytes = encoder.encode(method); + this.writeRequest(methodBytes, payload); + this.lastBytesSent = payload.length; + if (this.exports.handle_request(methodBytes.length, payload.length) !== 0) { + throw new Error(`TypeScript WASM request "${method}" failed: ${this.readResponseText()}`); + } + this.lastBytesReceived = this.exports.response_len(); + return this.readResponseBytes(); + } + + private writeRequest(first: Uint8Array, second?: Uint8Array): void { + const total = first.length + (second?.length ?? 0); + const requestPointer = this.exports.get_request_buffer(total) >>> 0; + const memory = new Uint8Array(this.exports.memory.buffer); + memory.set(first, requestPointer); + if (second) { + memory.set(second, requestPointer + first.length); + } + } + + private readResponseText(): string { + return decoder.decode(this.readResponseBytes()); + } + + private readResponseBytes(): Uint8Array { + const length = this.exports.response_len(); + if (length === 0) return new Uint8Array(); + return new Uint8Array(this.exports.memory.buffer, this.exports.response_ptr() >>> 0, length).slice(); + } + + private ensureOpen(): void { + if (this.closed) { + throw new Error("The TypeScript WASM transport is closed"); + } + } + + private ensureCanEnterWasm(): void { + this.ensureOpen(); + if (this.inCallback) { + throw new Error("TypeScript WASM callbacks cannot call the same API transport"); + } + } + + private configureFileSystem(fs: WasmFileSystem | undefined): void { + const writeFile = fs?.writeFile; + setWasmFileSystem( + this.instance, + typeof writeFile === "function" + ? { writeFile: (path, data) => this.invokeCallback(() => writeFile(path, data)) } + : fs, + ); + } + + private invokeCallback(callback: () => T): T { + const previous = this.inCallback; + this.inCallback = true; + try { + return callback(); + } + finally { + this.inCallback = previous; + } + } +} + +function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { + const exports = instance.exports as unknown as Partial; + const required = [ + "create_session", + "close_session", + "get_request_buffer", + "handle_request", + "set_file", + "read_file", + "remove_file", + "response_ptr", + "response_len", + ] as const; + const missing: string[] = required.filter(name => typeof exports[name] !== "function"); + if (exports.memory == null || typeof exports.memory !== "object" || !("buffer" in exports.memory)) { + missing.push("memory"); + } + if (missing.length > 0) { + throw new Error(`Invalid TypeScript WASM reactor: missing ${missing.join(", ")}`); + } + return exports as WasmReactorExports; +} diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index 64a5d4e36c488..50482ea1f54e5 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -1,467 +1,467 @@ -import type { FileSystemCallbacks } from "../api/fs.ts"; -import { - encodeFileSystemCallbackResult, - serverFS, -} from "../api/fsCallbacks.ts"; -import { Wtf8Decoder } from "../api/node/wtf8.ts"; -import type { RootedFilePath } from "../ast/index.ts"; -import type { WasmReactorInstance } from "./transport.ts"; - -declare namespace WebAssembly { - interface Module {} - - type Imports = Record>; - - interface Instance { - readonly exports: Record; - } - - const Instance: { - new (module: Module, imports?: Imports): Instance; - }; - - interface Memory { - readonly buffer: ArrayBufferLike; - } - - const Memory: { - readonly prototype: Memory; - new (descriptor: object): Memory; - }; - - function instantiate(module: Module, imports?: Imports): Promise; -} - -interface StreamingTextDecoder { - decode( - input?: ArrayBuffer | ArrayBufferView | null, - options?: { stream?: boolean | undefined; }, - ): string; -} - -const errnoSuccess = 0; -const errnoBadFileDescriptor = 8; -const errnoInvalidArgument = 28; -const errnoIo = 29; -const errnoNoBuffers = 42; -const errnoNoSys = 52; -const fileTypeCharacterDevice = 2; -const eventTypeClock = 0; -const subscriptionClockAbstime = 1; -const hostCallbackFD = 0x7fff_fffd; -const hostWriteFileFD = 0x7fff_fffe; -const textDecoder = new TextDecoder(); -const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); - -export interface InstantiateWasmOptions { - stdout?: ((text: string) => void) | undefined; - stderr?: ((text: string) => void) | undefined; -} - -export interface WasmFileSystem { - writeFile?: FileSystemCallbacks["writeFile"] | undefined; -} - -interface WasmHost { - setFileSystem(fs: WasmFileSystem | undefined): void; - registerCallback(name: string, callback: (name: string, payload: string) => string): void; - unregisterCallback(name: string): void; -} - -const wasmHosts = new WeakMap(); - -export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { - const host = wasmHosts.get(instance); - if (!host) { - if (fs === undefined) return; - throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); - } - host.setFileSystem(fs); -} - -export function registerWasmCallback( - instance: WasmReactorInstance, - name: string, - callback: (name: string, payload: string) => string, -): void { - const host = wasmHosts.get(instance); - if (!host) { - throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); - } - host.registerCallback(name, callback); -} - -export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { - wasmHosts.get(instance)?.unregisterCallback(name); -} - -/** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ -export async function instantiateWasm( - module: WebAssembly.Module, - options: InstantiateWasmOptions = {}, -): Promise { - const host = createWasiHost(options); - const instance = await WebAssembly.instantiate(module, host.imports); - return host.initialize(instance); -} - -/** - * Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. - * - * Prefer `instantiateWasm` on browser main threads, where synchronous - * instantiation of large WebAssembly modules may be rejected. - */ -export function instantiateWasmSync( - module: WebAssembly.Module, - options: InstantiateWasmOptions = {}, -): WasmReactorInstance { - const host = createWasiHost(options); - const instance = new WebAssembly.Instance(module, host.imports); - return host.initialize(instance); -} - -function createWasiHost(options: InstantiateWasmOptions): { - imports: WebAssembly.Imports; - initialize(instance: WebAssembly.Instance): WasmReactorInstance; -} { - let instance: WebAssembly.Instance | undefined; - const stdout = options.stdout ?? (text => console.log(text)); - const stderr = options.stderr ?? (text => console.error(text)); - const decoders = new Map(); - const encoder = new TextEncoder(); - const callbacks = new Map string>(); - let pendingCallbackResult: Uint8Array | undefined; - const closedDescriptors = new Set(); - let fileSystem: WasmFileSystem | undefined; - - function getMemory(): WebAssembly.Memory { - const memory = instance?.exports.memory; - if (!(memory instanceof WebAssembly.Memory)) { - throw new Error("TypeScript WASM reactor did not export its memory"); - } - return memory; - } - - function getView(): DataView { - return new DataView(getMemory().buffer); - } - - function argsSizesGet(countPointer: number, sizePointer: number): number { - countPointer >>>= 0; - sizePointer >>>= 0; - const view = getView(); - view.setUint32(countPointer, 0, true); - view.setUint32(sizePointer, 0, true); - return errnoSuccess; - } - - function clockTimeGet(clockId: number, _precision: bigint, timePointer: number): number { - timePointer >>>= 0; - let nanoseconds: bigint; - switch (clockId) { - case 0: - nanoseconds = BigInt(Date.now()) * 1_000_000n; - break; - case 1: - nanoseconds = BigInt(Math.round(performance.now() * 1e6)); - break; - default: - return errnoInvalidArgument; - } - getView().setBigUint64(timePointer, nanoseconds, true); - return errnoSuccess; - } - - function fdFdstatGet(fd: number, statPointer: number): number { - statPointer >>>= 0; - if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; - const memory = getMemory(); - new Uint8Array(memory.buffer, statPointer, 24).fill(0); - const view = new DataView(memory.buffer); - view.setUint8(statPointer, fileTypeCharacterDevice); - const rights = fd === 0 ? 1n << 1n : 1n << 6n; - view.setBigUint64(statPointer + 8, rights, true); - return errnoSuccess; - } - - function fdFdstatSetFlags(fd: number, _flags: number): number { - return isOpenStdioDescriptor(fd) ? errnoSuccess : errnoBadFileDescriptor; - } - - function fdRead(fd: number, _iovsPointer: number, _iovsLength: number, readPointer: number): number { - readPointer >>>= 0; - if (fd !== 0 || closedDescriptors.has(fd)) return errnoBadFileDescriptor; - getView().setUint32(readPointer, 0, true); - return errnoSuccess; - } - - function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { - iovsPointer >>>= 0; - writtenPointer >>>= 0; - if (fd === hostWriteFileFD) { - return hostWriteFile(iovsPointer, iovsLength, writtenPointer); - } - if (fd === hostCallbackFD) { - return hostCallback(iovsPointer, iovsLength, writtenPointer); - } - if ((fd !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; - const memory = getMemory(); - const view = new DataView(memory.buffer); - let decoder = decoders.get(fd); - if (!decoder) decoders.set(fd, decoder = new TextDecoder()); - let text = ""; - let length = 0; - for (let i = 0; i < iovsLength; i++) { - const iovPointer = iovsPointer + i * 8; - const chunkLength = view.getUint32(iovPointer + 4, true); - text += decoder.decode(new Uint8Array(memory.buffer, view.getUint32(iovPointer, true), chunkLength), { stream: true }); - length += chunkLength; - } - view.setUint32(writtenPointer, length, true); - if (text) { - (fd === 1 ? stdout : stderr)(text); - } - return errnoSuccess; - } - - function fdClose(fd: number): number { - if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; - closedDescriptors.add(fd); - const decoder = decoders.get(fd); - if (decoder) { - decoders.delete(fd); - const text = decoder.decode(); - if (text) { - (fd === 1 ? stdout : stderr)(text); - } - } - return errnoSuccess; - } - - function isOpenStdioDescriptor(fd: number): boolean { - return fd >= 0 && fd <= 2 && !closedDescriptors.has(fd); - } - - function hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { - if (iovsLength !== 1) return errnoInvalidArgument; - const memory = getMemory(); - const view = new DataView(memory.buffer); - const bufferPointer = view.getUint32(iovsPointer, true); - const bufferLength = view.getUint32(iovsPointer + 4, true); - if (bufferLength < 16) return errnoInvalidArgument; - const methodLength = view.getUint32(bufferPointer, true); - const payloadLength = view.getUint32(bufferPointer + 4, true); - const resultCapacity = view.getUint32(bufferPointer + 8, true); - const errorCapacity = view.getUint32(bufferPointer + 12, true); - if (16 + methodLength + payloadLength + resultCapacity + errorCapacity > bufferLength) { - return errnoInvalidArgument; - } - const methodPointer = bufferPointer + 16; - const payloadPointer = methodPointer + methodLength; - const resultPointer = payloadPointer + payloadLength; - const errorPointer = resultPointer + resultCapacity; - const decoder = new TextDecoder(); - const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); - const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); - const pending = pendingCallbackResult; - pendingCallbackResult = undefined; - try { - let result: Uint8Array; - if (methodLength === 0) { - // An empty method fetches a result that did not fit the previous call's buffer. - if (!pending || pending.length > resultCapacity) return errnoInvalidArgument; - result = pending; - } - else { - const callback = callbacks.get(method); - if (!callback) { - throw new Error(`No callback is registered for ${method}`); - } - result = encoder.encode(callback(method, payload)); - if (result.length > resultCapacity) { - pendingCallbackResult = result; - const resultView = new DataView(getMemory().buffer); - resultView.setUint32(bufferPointer + 8, result.length, true); - resultView.setUint32(bufferPointer + 12, 0, true); - resultView.setUint32(writtenPointer, 0, true); - return errnoNoBuffers; - } - } - const resultMemory = getMemory(); - const resultView = new DataView(resultMemory.buffer); - new Uint8Array(resultMemory.buffer, resultPointer, result.length).set(result); - resultView.setUint32(bufferPointer + 8, result.length, true); - resultView.setUint32(bufferPointer + 12, 0, true); - resultView.setUint32(writtenPointer, bufferLength, true); - return errnoSuccess; - } - catch (error) { - const errorView = new DataView(getMemory().buffer); - errorView.setUint32(bufferPointer + 8, 0, true); - errorView.setUint32(bufferPointer + 12, writeHostError(error, errorPointer, errorCapacity), true); - errorView.setUint32(writtenPointer, 0, true); - return errnoIo; - } - } - - /** Writes as much of the error message as fits and returns its length. */ - function writeHostError(error: unknown, pointer: number, capacity: number): number { - const bytes = encoder.encode(error instanceof Error ? error.message : String(error)); - const length = Math.min(bytes.length, capacity); - new Uint8Array(getMemory().buffer, pointer, length).set(bytes.subarray(0, length)); - return length; - } - - 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; - const writeFile = fileSystem?.writeFile; - if (writeFile === undefined || writeFile === serverFS.useOS) 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 pathPointer = bufferPointer + 12; - const dataPointer = pathPointer + pathLength; - const errorPointer = dataPointer + dataLength; - const path = textDecoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); - const data = fileContentDecoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); - try { - const result = typeof writeFile === "function" ? writeFile(path as RootedFilePath, data) : writeFile; - const response = encodeFileSystemCallbackResult("writeFile", result); - if (response.kind === "useOS") return errnoBadFileDescriptor; - if (response.kind === "error") throw new Error("filesystem callback returned serverFS.error: writeFile"); - new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); - return errnoSuccess; - } - catch (error) { - const errorView = new DataView(getMemory().buffer); - errorView.setUint32(bufferPointer + 8, writeHostError(error, errorPointer, errorCapacity), true); - errorView.setUint32(writtenPointer, 0, true); - return errnoIo; - } - } - - function pollOneoff( - subscriptionsPointer: number, - eventsPointer: number, - subscriptionsLength: number, - eventsLengthPointer: number, - ): number { - subscriptionsPointer >>>= 0; - eventsPointer >>>= 0; - eventsLengthPointer >>>= 0; - if (subscriptionsLength === 0) return errnoInvalidArgument; - const memory = getMemory(); - const view = new DataView(memory.buffer); - let selected = -1; - let shortestDelay = Number.POSITIVE_INFINITY; - for (let i = 0; i < subscriptionsLength; i++) { - const subscription = subscriptionsPointer + i * 48; - if (view.getUint8(subscription + 8) !== eventTypeClock) continue; - const clockId = view.getUint32(subscription + 16, true); - if (clockId !== 0 && clockId !== 1) return errnoInvalidArgument; - const timeout = view.getBigUint64(subscription + 24, true); - const flags = view.getUint16(subscription + 40, true); - const now = clockId === 0 ? Date.now() : performance.now(); - const delay = flags & subscriptionClockAbstime - ? Number(timeout) / 1e6 - now - : Number(timeout) / 1e6; - if (delay < shortestDelay) { - selected = subscription; - shortestDelay = delay; - } - } - if (selected < 0) return errnoNoSys; - - const deadline = performance.now() + Math.max(0, shortestDelay); - while (performance.now() < deadline) { - // WASI imports are synchronous, so a clock subscription must spin here. - // The Go scheduler only polls when every goroutine is idle with a - // pending timer, which the reactor does not hit during API requests. - } - - new Uint8Array(memory.buffer, eventsPointer, 32).fill(0); - view.setBigUint64(eventsPointer, view.getBigUint64(selected, true), true); - view.setUint8(eventsPointer + 10, eventTypeClock); - view.setUint32(eventsLengthPointer, 1, true); - return errnoSuccess; - } - - function unsupported(): number { - return errnoNoSys; - } - - const wasi = { - args_get: () => errnoSuccess, - args_sizes_get: argsSizesGet, - clock_time_get: clockTimeGet, - environ_get: () => errnoSuccess, - environ_sizes_get: argsSizesGet, - fd_close: fdClose, - fd_fdstat_get: fdFdstatGet, - fd_fdstat_set_flags: fdFdstatSetFlags, - fd_filestat_get: unsupported, - fd_pread: unsupported, - fd_prestat_dir_name: unsupported, - fd_prestat_get: () => errnoBadFileDescriptor, - fd_read: fdRead, - fd_readdir: unsupported, - fd_write: fdWrite, - path_create_directory: unsupported, - path_filestat_get: unsupported, - path_filestat_set_times: unsupported, - path_open: unsupported, - path_readlink: unsupported, - path_remove_directory: unsupported, - path_unlink_file: unsupported, - poll_oneoff: pollOneoff, - proc_exit: (code: number) => { - throw new Error(`TypeScript WASM runtime exited with code ${code}`); - }, - random_get: randomGet, - sched_yield: () => errnoSuccess, - sock_accept: unsupported, - }; - - return { - imports: { - wasi_snapshot_preview1: wasi, - }, - initialize(value) { - instance = value; - const initialize = instance.exports.typescript_initialize; - if (typeof initialize !== "function") { - throw new Error("TypeScript WASM reactor did not export typescript_initialize"); - } - initialize(); - wasmHosts.set(instance, { - setFileSystem(value) { - fileSystem = value; - }, - registerCallback(name, callback) { - callbacks.set(name, callback); - }, - unregisterCallback(name) { - callbacks.delete(name); - }, - }); - return instance; - }, - }; -} +import type { FileSystemCallbacks } from "../api/fs.ts"; +import { + encodeFileSystemCallbackResult, + serverFS, +} from "../api/fsCallbacks.ts"; +import { Wtf8Decoder } from "../api/node/wtf8.ts"; +import type { RootedFilePath } from "../ast/index.ts"; +import type { WasmReactorInstance } from "./transport.ts"; + +declare namespace WebAssembly { + interface Module {} + + type Imports = Record>; + + interface Instance { + readonly exports: Record; + } + + const Instance: { + new (module: Module, imports?: Imports): Instance; + }; + + interface Memory { + readonly buffer: ArrayBufferLike; + } + + const Memory: { + readonly prototype: Memory; + new (descriptor: object): Memory; + }; + + function instantiate(module: Module, imports?: Imports): Promise; +} + +interface StreamingTextDecoder { + decode( + input?: ArrayBuffer | ArrayBufferView | null, + options?: { stream?: boolean | undefined; }, + ): string; +} + +const errnoSuccess = 0; +const errnoBadFileDescriptor = 8; +const errnoInvalidArgument = 28; +const errnoIo = 29; +const errnoNoBuffers = 42; +const errnoNoSys = 52; +const fileTypeCharacterDevice = 2; +const eventTypeClock = 0; +const subscriptionClockAbstime = 1; +const hostCallbackFD = 0x7fff_fffd; +const hostWriteFileFD = 0x7fff_fffe; +const textDecoder = new TextDecoder(); +const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); + +export interface InstantiateWasmOptions { + stdout?: ((text: string) => void) | undefined; + stderr?: ((text: string) => void) | undefined; +} + +export interface WasmFileSystem { + writeFile?: FileSystemCallbacks["writeFile"] | undefined; +} + +interface WasmHost { + setFileSystem(fs: WasmFileSystem | undefined): void; + registerCallback(name: string, callback: (name: string, payload: string) => string): void; + unregisterCallback(name: string): void; +} + +const wasmHosts = new WeakMap(); + +export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { + const host = wasmHosts.get(instance); + if (!host) { + if (fs === undefined) return; + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.setFileSystem(fs); +} + +export function registerWasmCallback( + instance: WasmReactorInstance, + name: string, + callback: (name: string, payload: string) => string, +): void { + const host = wasmHosts.get(instance); + if (!host) { + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.registerCallback(name, callback); +} + +export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { + wasmHosts.get(instance)?.unregisterCallback(name); +} + +/** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ +export async function instantiateWasm( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): Promise { + const host = createWasiHost(options); + const instance = await WebAssembly.instantiate(module, host.imports); + return host.initialize(instance); +} + +/** + * Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. + * + * Prefer `instantiateWasm` on browser main threads, where synchronous + * instantiation of large WebAssembly modules may be rejected. + */ +export function instantiateWasmSync( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): WasmReactorInstance { + const host = createWasiHost(options); + const instance = new WebAssembly.Instance(module, host.imports); + return host.initialize(instance); +} + +function createWasiHost(options: InstantiateWasmOptions): { + imports: WebAssembly.Imports; + initialize(instance: WebAssembly.Instance): WasmReactorInstance; +} { + let instance: WebAssembly.Instance | undefined; + const stdout = options.stdout ?? (text => console.log(text)); + const stderr = options.stderr ?? (text => console.error(text)); + const decoders = new Map(); + const encoder = new TextEncoder(); + const callbacks = new Map string>(); + let pendingCallbackResult: Uint8Array | undefined; + const closedDescriptors = new Set(); + let fileSystem: WasmFileSystem | undefined; + + function getMemory(): WebAssembly.Memory { + const memory = instance?.exports.memory; + if (!(memory instanceof WebAssembly.Memory)) { + throw new Error("TypeScript WASM reactor did not export its memory"); + } + return memory; + } + + function getView(): DataView { + return new DataView(getMemory().buffer); + } + + function argsSizesGet(countPointer: number, sizePointer: number): number { + countPointer >>>= 0; + sizePointer >>>= 0; + const view = getView(); + view.setUint32(countPointer, 0, true); + view.setUint32(sizePointer, 0, true); + return errnoSuccess; + } + + function clockTimeGet(clockId: number, _precision: bigint, timePointer: number): number { + timePointer >>>= 0; + let nanoseconds: bigint; + switch (clockId) { + case 0: + nanoseconds = BigInt(Date.now()) * 1_000_000n; + break; + case 1: + nanoseconds = BigInt(Math.round(performance.now() * 1e6)); + break; + default: + return errnoInvalidArgument; + } + getView().setBigUint64(timePointer, nanoseconds, true); + return errnoSuccess; + } + + function fdFdstatGet(fd: number, statPointer: number): number { + statPointer >>>= 0; + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; + const memory = getMemory(); + new Uint8Array(memory.buffer, statPointer, 24).fill(0); + const view = new DataView(memory.buffer); + view.setUint8(statPointer, fileTypeCharacterDevice); + const rights = fd === 0 ? 1n << 1n : 1n << 6n; + view.setBigUint64(statPointer + 8, rights, true); + return errnoSuccess; + } + + function fdFdstatSetFlags(fd: number, _flags: number): number { + return isOpenStdioDescriptor(fd) ? errnoSuccess : errnoBadFileDescriptor; + } + + function fdRead(fd: number, _iovsPointer: number, _iovsLength: number, readPointer: number): number { + readPointer >>>= 0; + if (fd !== 0 || closedDescriptors.has(fd)) return errnoBadFileDescriptor; + getView().setUint32(readPointer, 0, true); + return errnoSuccess; + } + + function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { + iovsPointer >>>= 0; + writtenPointer >>>= 0; + if (fd === hostWriteFileFD) { + return hostWriteFile(iovsPointer, iovsLength, writtenPointer); + } + if (fd === hostCallbackFD) { + return hostCallback(iovsPointer, iovsLength, writtenPointer); + } + if ((fd !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; + const memory = getMemory(); + const view = new DataView(memory.buffer); + let decoder = decoders.get(fd); + if (!decoder) decoders.set(fd, decoder = new TextDecoder()); + let text = ""; + let length = 0; + for (let i = 0; i < iovsLength; i++) { + const iovPointer = iovsPointer + i * 8; + const chunkLength = view.getUint32(iovPointer + 4, true); + text += decoder.decode(new Uint8Array(memory.buffer, view.getUint32(iovPointer, true), chunkLength), { stream: true }); + length += chunkLength; + } + view.setUint32(writtenPointer, length, true); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + return errnoSuccess; + } + + function fdClose(fd: number): number { + if (!isOpenStdioDescriptor(fd)) return errnoBadFileDescriptor; + closedDescriptors.add(fd); + const decoder = decoders.get(fd); + if (decoder) { + decoders.delete(fd); + const text = decoder.decode(); + if (text) { + (fd === 1 ? stdout : stderr)(text); + } + } + return errnoSuccess; + } + + function isOpenStdioDescriptor(fd: number): boolean { + return fd >= 0 && fd <= 2 && !closedDescriptors.has(fd); + } + + function hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { + if (iovsLength !== 1) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const bufferPointer = view.getUint32(iovsPointer, true); + const bufferLength = view.getUint32(iovsPointer + 4, true); + if (bufferLength < 16) return errnoInvalidArgument; + const methodLength = view.getUint32(bufferPointer, true); + const payloadLength = view.getUint32(bufferPointer + 4, true); + const resultCapacity = view.getUint32(bufferPointer + 8, true); + const errorCapacity = view.getUint32(bufferPointer + 12, true); + if (16 + methodLength + payloadLength + resultCapacity + errorCapacity > bufferLength) { + return errnoInvalidArgument; + } + const methodPointer = bufferPointer + 16; + const payloadPointer = methodPointer + methodLength; + const resultPointer = payloadPointer + payloadLength; + const errorPointer = resultPointer + resultCapacity; + const decoder = new TextDecoder(); + const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); + const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); + const pending = pendingCallbackResult; + pendingCallbackResult = undefined; + try { + let result: Uint8Array; + if (methodLength === 0) { + // An empty method fetches a result that did not fit the previous call's buffer. + if (!pending || pending.length > resultCapacity) return errnoInvalidArgument; + result = pending; + } + else { + const callback = callbacks.get(method); + if (!callback) { + throw new Error(`No callback is registered for ${method}`); + } + result = encoder.encode(callback(method, payload)); + if (result.length > resultCapacity) { + pendingCallbackResult = result; + const resultView = new DataView(getMemory().buffer); + resultView.setUint32(bufferPointer + 8, result.length, true); + resultView.setUint32(bufferPointer + 12, 0, true); + resultView.setUint32(writtenPointer, 0, true); + return errnoNoBuffers; + } + } + const resultMemory = getMemory(); + const resultView = new DataView(resultMemory.buffer); + new Uint8Array(resultMemory.buffer, resultPointer, result.length).set(result); + resultView.setUint32(bufferPointer + 8, result.length, true); + resultView.setUint32(bufferPointer + 12, 0, true); + resultView.setUint32(writtenPointer, bufferLength, true); + return errnoSuccess; + } + catch (error) { + const errorView = new DataView(getMemory().buffer); + errorView.setUint32(bufferPointer + 8, 0, true); + errorView.setUint32(bufferPointer + 12, writeHostError(error, errorPointer, errorCapacity), true); + errorView.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + /** Writes as much of the error message as fits and returns its length. */ + function writeHostError(error: unknown, pointer: number, capacity: number): number { + const bytes = encoder.encode(error instanceof Error ? error.message : String(error)); + const length = Math.min(bytes.length, capacity); + new Uint8Array(getMemory().buffer, pointer, length).set(bytes.subarray(0, length)); + return length; + } + + 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; + const writeFile = fileSystem?.writeFile; + if (writeFile === undefined || writeFile === serverFS.useOS) 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 pathPointer = bufferPointer + 12; + const dataPointer = pathPointer + pathLength; + const errorPointer = dataPointer + dataLength; + const path = textDecoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); + const data = fileContentDecoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); + try { + const result = typeof writeFile === "function" ? writeFile(path as RootedFilePath, data) : writeFile; + const response = encodeFileSystemCallbackResult("writeFile", result); + if (response.kind === "useOS") return errnoBadFileDescriptor; + if (response.kind === "error") throw new Error("filesystem callback returned serverFS.error: writeFile"); + new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); + return errnoSuccess; + } + catch (error) { + const errorView = new DataView(getMemory().buffer); + errorView.setUint32(bufferPointer + 8, writeHostError(error, errorPointer, errorCapacity), true); + errorView.setUint32(writtenPointer, 0, true); + return errnoIo; + } + } + + function pollOneoff( + subscriptionsPointer: number, + eventsPointer: number, + subscriptionsLength: number, + eventsLengthPointer: number, + ): number { + subscriptionsPointer >>>= 0; + eventsPointer >>>= 0; + eventsLengthPointer >>>= 0; + if (subscriptionsLength === 0) return errnoInvalidArgument; + const memory = getMemory(); + const view = new DataView(memory.buffer); + let selected = -1; + let shortestDelay = Number.POSITIVE_INFINITY; + for (let i = 0; i < subscriptionsLength; i++) { + const subscription = subscriptionsPointer + i * 48; + if (view.getUint8(subscription + 8) !== eventTypeClock) continue; + const clockId = view.getUint32(subscription + 16, true); + if (clockId !== 0 && clockId !== 1) return errnoInvalidArgument; + const timeout = view.getBigUint64(subscription + 24, true); + const flags = view.getUint16(subscription + 40, true); + const now = clockId === 0 ? Date.now() : performance.now(); + const delay = flags & subscriptionClockAbstime + ? Number(timeout) / 1e6 - now + : Number(timeout) / 1e6; + if (delay < shortestDelay) { + selected = subscription; + shortestDelay = delay; + } + } + if (selected < 0) return errnoNoSys; + + const deadline = performance.now() + Math.max(0, shortestDelay); + while (performance.now() < deadline) { + // WASI imports are synchronous, so a clock subscription must spin here. + // The Go scheduler only polls when every goroutine is idle with a + // pending timer, which the reactor does not hit during API requests. + } + + new Uint8Array(memory.buffer, eventsPointer, 32).fill(0); + view.setBigUint64(eventsPointer, view.getBigUint64(selected, true), true); + view.setUint8(eventsPointer + 10, eventTypeClock); + view.setUint32(eventsLengthPointer, 1, true); + return errnoSuccess; + } + + function unsupported(): number { + return errnoNoSys; + } + + const wasi = { + args_get: () => errnoSuccess, + args_sizes_get: argsSizesGet, + clock_time_get: clockTimeGet, + environ_get: () => errnoSuccess, + environ_sizes_get: argsSizesGet, + fd_close: fdClose, + fd_fdstat_get: fdFdstatGet, + fd_fdstat_set_flags: fdFdstatSetFlags, + fd_filestat_get: unsupported, + fd_pread: unsupported, + fd_prestat_dir_name: unsupported, + fd_prestat_get: () => errnoBadFileDescriptor, + fd_read: fdRead, + fd_readdir: unsupported, + fd_write: fdWrite, + path_create_directory: unsupported, + path_filestat_get: unsupported, + path_filestat_set_times: unsupported, + path_open: unsupported, + path_readlink: unsupported, + path_remove_directory: unsupported, + path_unlink_file: unsupported, + poll_oneoff: pollOneoff, + proc_exit: (code: number) => { + throw new Error(`TypeScript WASM runtime exited with code ${code}`); + }, + random_get: randomGet, + sched_yield: () => errnoSuccess, + sock_accept: unsupported, + }; + + return { + imports: { + wasi_snapshot_preview1: wasi, + }, + initialize(value) { + instance = value; + const initialize = instance.exports.typescript_initialize; + if (typeof initialize !== "function") { + throw new Error("TypeScript WASM reactor did not export typescript_initialize"); + } + initialize(); + wasmHosts.set(instance, { + setFileSystem(value) { + fileSystem = value; + }, + registerCallback(name, callback) { + callbacks.set(name, callback); + }, + unregisterCallback(name) { + callbacks.delete(name); + }, + }); + return instance; + }, + }; +} diff --git a/packages/typescript/test/wasm.ts b/packages/typescript/test/wasm.ts index fde4e68beb77d..489793fcbec61 100644 --- a/packages/typescript/test/wasm.ts +++ b/packages/typescript/test/wasm.ts @@ -90,18 +90,15 @@ function createWasmAPIOptions(options: WasmAPIOptions): { function wrapFileUpdates(api: T, fs: FileSystemCallbacks | undefined, transport: WasmTransport | undefined): void { if (!fs || !transport) return; - wrapMethod("createSnapshot", args => { - const params = args[0] as SnapshotChanges | undefined; - syncFileChanges(fs, transport, params?.fileNotifications); - syncOpenFiles(fs, transport, params); - }); - wrapMethod("updateSnapshot", args => { - const params = args[1] as SnapshotChanges | undefined; - syncFileChanges(fs, transport, params?.fileNotifications); - syncOpenFiles(fs, transport, params); - }); - - function wrapMethod(name: string, before: (args: unknown[]) => void): void { + wrapMethod("createSnapshot", 0); + wrapMethod("updateSnapshot", 1); + + function wrapMethod(name: string, changesIndex: number): void { + const before = (args: unknown[]) => { + const changes = args[changesIndex] as SnapshotChanges | undefined; + syncFileChanges(fs!, transport!, changes?.fileNotifications); + syncOpenFiles(fs!, transport!, changes); + }; const target = api as Record; const original = target[name] as ((...args: unknown[]) => unknown) & { gen?: (...args: unknown[]) => Generator; @@ -131,16 +128,19 @@ function syncOpenFiles( changes: SnapshotChanges | undefined, ): void { for (const file of changes?.openFiles ?? []) { - const fileName = resolveFileName(file); - const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(fileName, undefined)) : undefined; - if (typeof content === "string") { - transport.setFile(fileName, content); - mirroredFiles.get(transport)?.add(fileName); - } - else { - transport.removeFile(fileName); - mirroredFiles.get(transport)?.delete(fileName); - } + mirrorFile(fs, transport, resolveFileName(file)); + } +} + +function mirrorFile(fs: FileSystemCallbacks, transport: WasmTransport, fileName: string): void { + const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(fileName, undefined)) : undefined; + if (typeof content === "string") { + transport.setFile(fileName, content); + mirroredFiles.get(transport)?.add(fileName); + } + else { + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); } } @@ -183,16 +183,7 @@ function syncFileChanges( synchronizeFileSystem(fs, transport); } for (const file of [...changes?.changed ?? [], ...changes?.created ?? []]) { - const fileName = resolveFileName(file); - const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(fileName, undefined)) : undefined; - if (typeof content === "string") { - transport.setFile(fileName, content); - mirroredFiles.get(transport)?.add(fileName); - } - else { - transport.removeFile(fileName); - mirroredFiles.get(transport)?.delete(fileName); - } + mirrorFile(fs, transport, resolveFileName(file)); } for (const file of changes?.deleted ?? []) { const fileName = resolveFileName(file); From f8aab474409e9d4c5d05e47c24da7680387ffaaa Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:52:58 -0700 Subject: [PATCH 111/142] Use btoa for the base64 fallback Hosts without Uint8Array.prototype.toBase64 or Buffer still have btoa, so a hand-written base64 encoder is unnecessary. --- packages/typescript/src/api/node/encoder.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 7302cfb2fc715..237681f6b7d46 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -388,18 +388,11 @@ export function uint8ArrayToBase64(data: Uint8Array): string { 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]; + let binary = ""; + for (let i = 0; i < data.length; i += 0x8000) { + binary += String.fromCharCode(...data.subarray(i, i + 0x8000)); } - return result; + return btoa(binary); } export function sourceFileResponseToUint8Array(response: { readonly data: string; } | null | undefined): Uint8Array | undefined { From 978fc53c3397aff1bbd97fcebdc31cb3ed206f36 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:53:14 -0700 Subject: [PATCH 112/142] Remove a stale WASM test skip The skipped test no longer exists. --- Herebyfile.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 15fbceb207862..6ea927e28d0f7 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1020,7 +1020,7 @@ async function runTestAPIWasm() { "module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)", // These tests require spawned processes or callback filesystem layering. "readFile callback semantics (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting)", - "updateSnapshot file systems (?:full file system is total and does not invoke host callbacks|full file system accepts paths decoded from VS Code document URIs|file system layer bypasses callbacks on hits and falls back on misses)", + "updateSnapshot file systems (?:full file system is total and does not invoke host callbacks|file system layer bypasses callbacks on hits and falls back on misses)", // The reactor mirrors input files but does not mirror build output changes. "BuildOrchestrator (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)", "parseJsonConfigFileContent accepts non-object JSON|parseConfigFile$|project exposes parsedCommandLine", From 15ad08ff07cfee062c8fcc3634f75c09a28dfbfe Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:55:16 -0700 Subject: [PATCH 113/142] Tidy WASM packaging in the build script The package input can be copied in one step, a few package checks were already implied by stronger ones, and both VSIX targets need the same package inputs. --- Herebyfile.mjs | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 6ea927e28d0f7..4d5e9a252bcc9 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -2609,8 +2609,7 @@ async function buildWasip1Package() { stripSourceConditions(wasmPackageJson); await runBuildWasi(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 cpRecursive(wasmInputDir, wasip1Package.npmDir, p => !p.endsWith("/node_modules")); await fs.promises.writeFile( path.join(wasip1Package.npmDir, "package.json"), JSON.stringify(wasmPackageJson, undefined, 4), @@ -2752,7 +2751,6 @@ async function testNativePreviewPackage(platforms) { const wasmPackageJson = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "package.json"), "utf8")); assert.equal(wasmPackageJson.version, getVersion(), "WASI artifact package must match the main package version"); - assert.equal(wasmPackageJson.peerDependencies, undefined, "WASI artifact package must not depend on the main package"); const libFiles = JSON.parse(await fs.promises.readFile(path.join(wasmPackageDir, "lib", "libFiles.json"), "utf8")); const expectedLibFiles = (await fs.promises.readdir(path.join(wasmPackageDir, "lib"))) .filter(file => file === "lib.d.ts" || file.startsWith("lib.") && file.endsWith(".d.ts")) @@ -2766,8 +2764,6 @@ async function testNativePreviewPackage(platforms) { assert(wasmFiles.has("lib/tsc.wasm"), "WASI artifact package is missing its compiler"); assert(wasmFiles.has("dist/index.js"), "WASI artifact package is missing its URL helper"); assert(wasmFiles.has("lib/libFiles.json"), "WASI artifact package is missing its library file list"); - assert(!wasmFiles.has("dist/transport.js"), "WASI artifact package contains the reactor transport"); - assert(!wasmFiles.has("dist/wasi.js"), "WASI artifact package contains the reactor host"); const binName = publishAsTypescript ? "tsc" : "tsgo"; const binPath = path.join(mainPackageDir, "bin", binName); @@ -2778,8 +2774,6 @@ async function testNativePreviewPackage(platforms) { assert(!listFilesOutput.includes("bundled:///"), "Packaged compiler listed an embedded library path"); const expectedLib = path.resolve(platformPackageDir, "lib", "lib.es5.d.ts"); - const expectedWasmLib = path.resolve(wasmPackageDir, "lib", "lib.es5.d.ts"); - assert(fs.existsSync(expectedWasmLib), `Expected WASM package to contain ${expectedWasmLib}`); const listedFiles = listFilesOutput .split(/\r?\n/) .filter(Boolean) @@ -3175,10 +3169,10 @@ async function runPackVsixExtensions() { await Promise.all(extensions.map(async ({ npmTarball, npmPackageName, nodeOs, vscodeTarget, sourceDir, extensionDir: thisExtensionDir, vsixPath, vsixManifestPath, vsixSignaturePath }) => { await cpWithoutNodeModulesOrTsconfig(sourceDir, thisExtensionDir); + assert(npmTarball); + assert(npmPackageName); let packageJsonDependencies; if (vscodeTarget === "web") { - assert(npmTarball); - assert(npmPackageName); const embeddedWasmPackageDir = path.join(thisExtensionDir, "node_modules", ...npmPackageName.split("/")); await fs.promises.mkdir(embeddedWasmPackageDir, { recursive: true }); await tar.x({ file: npmTarball, cwd: embeddedWasmPackageDir, strip: 1 }); @@ -3188,8 +3182,6 @@ async function runPackVsixExtensions() { }; } else { - assert(npmTarball); - assert(npmPackageName); assert(nodeOs); const nodeModules = path.join(thisExtensionDir, "node_modules"); const embeddedPlatformPackageDir = path.join(nodeModules, ...npmPackageName.split("/")); From bbe31c3cd43d1209015a4e03fcc8f70132f5e332 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:55:26 -0700 Subject: [PATCH 114/142] Move transport file system imports to the top --- packages/typescript/src/api/async/transport.ts | 3 ++- packages/typescript/src/api/sync/transport.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/typescript/src/api/async/transport.ts b/packages/typescript/src/api/async/transport.ts index 85596c76dd6c7..55133c92cc283 100644 --- a/packages/typescript/src/api/async/transport.ts +++ b/packages/typescript/src/api/async/transport.ts @@ -1,3 +1,5 @@ +import type { FileSystemCallbacks } from "../fs.ts"; + /** * An asynchronous request transport connected to a TypeScript API session. */ @@ -23,4 +25,3 @@ export interface AsyncTransport { setFileSystem?(fs: FileSystemCallbacks | undefined): void; close(): void | PromiseLike; } -import type { FileSystemCallbacks } from "../fs.ts"; diff --git a/packages/typescript/src/api/sync/transport.ts b/packages/typescript/src/api/sync/transport.ts index be2bf1b475e0b..b48eb470b643b 100644 --- a/packages/typescript/src/api/sync/transport.ts +++ b/packages/typescript/src/api/sync/transport.ts @@ -1,3 +1,5 @@ +import type { FileSystemCallbacks } from "../fs.ts"; + /** * A synchronous request transport connected to a TypeScript API session. * @@ -18,4 +20,3 @@ export interface SyncTransport { setFileSystem?(fs: FileSystemCallbacks | undefined): void; close(): void; } -import type { FileSystemCallbacks } from "../fs.ts"; From 7d1be8b81aa0636987c483e7b5cf05bb2cdc51fb Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:55:52 -0700 Subject: [PATCH 115/142] Leave Herebyfile type checking out of validation Running check:herebyfile from validate is unrelated to WASI support and can be proposed separately. --- Herebyfile.mjs | 50 +++++++++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 4d5e9a252bcc9..b30b2d32bc4cb 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1378,7 +1378,6 @@ export const validate = task({ await runValidation("test:api:wasm", runTestAPIWasm); } if (options.all) { - await runValidation("check:herebyfile", runCheckHerebyfile); await runValidation("test:tools", runTestTools); await runValidation("test:options", async () => { await run("node", ["--test", "./tools/scripts/tsc/options.test.ts"]); @@ -1420,32 +1419,29 @@ export const checkFormat = task({ export const checkHerebyfile = task({ name: "check:herebyfile", description: "Type-checks Herebyfile.mjs.", - run: runCheckHerebyfile, -}); - -async function runCheckHerebyfile() { - await run("node", [ - "./node_modules/typescript/bin/tsc", - "--noEmit", - "--allowJs", - "--allowImportingTsExtensions", - "--checkJs", - "--target", - "es2022", - "--lib", - "es2024,esnext.array,esnext.collection,esnext.iterator", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - "--types", - "node", - "--strict", - "--esModuleInterop", - "--skipLibCheck", - "Herebyfile.mjs", - ]); -} + run: () => + run("node", [ + "./node_modules/typescript/bin/tsc", + "--noEmit", + "--allowJs", + "--allowImportingTsExtensions", + "--checkJs", + "--target", + "es2022", + "--lib", + "es2024,esnext.array,esnext.collection,esnext.iterator", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--types", + "node", + "--strict", + "--esModuleInterop", + "--skipLibCheck", + "Herebyfile.mjs", + ]), +}); export const checkVsceVersion = task({ name: "check:vsce-version", From f3c2b87c12e584d7ead6ce3df77a9a2891a7f883 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:34:17 -0700 Subject: [PATCH 116/142] Revert "Leave Herebyfile type checking out of validation" This reverts commit 7d1be8b81aa0636987c483e7b5cf05bb2cdc51fb. --- Herebyfile.mjs | 50 +++++++++++++++++++++++++++----------------------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index b30b2d32bc4cb..4d5e9a252bcc9 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1378,6 +1378,7 @@ export const validate = task({ await runValidation("test:api:wasm", runTestAPIWasm); } if (options.all) { + await runValidation("check:herebyfile", runCheckHerebyfile); await runValidation("test:tools", runTestTools); await runValidation("test:options", async () => { await run("node", ["--test", "./tools/scripts/tsc/options.test.ts"]); @@ -1419,29 +1420,32 @@ export const checkFormat = task({ export const checkHerebyfile = task({ name: "check:herebyfile", description: "Type-checks Herebyfile.mjs.", - run: () => - run("node", [ - "./node_modules/typescript/bin/tsc", - "--noEmit", - "--allowJs", - "--allowImportingTsExtensions", - "--checkJs", - "--target", - "es2022", - "--lib", - "es2024,esnext.array,esnext.collection,esnext.iterator", - "--module", - "nodenext", - "--moduleResolution", - "nodenext", - "--types", - "node", - "--strict", - "--esModuleInterop", - "--skipLibCheck", - "Herebyfile.mjs", - ]), -}); + run: runCheckHerebyfile, +}); + +async function runCheckHerebyfile() { + await run("node", [ + "./node_modules/typescript/bin/tsc", + "--noEmit", + "--allowJs", + "--allowImportingTsExtensions", + "--checkJs", + "--target", + "es2022", + "--lib", + "es2024,esnext.array,esnext.collection,esnext.iterator", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--types", + "node", + "--strict", + "--esModuleInterop", + "--skipLibCheck", + "Herebyfile.mjs", + ]); +} export const checkVsceVersion = task({ name: "check:vsce-version", From 47016ec8472a77c495a0f52a1e77e09905d9c2a0 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:40:02 -0700 Subject: [PATCH 117/142] Report compiler output when the WASI realpath test fails The test fails on Windows with only an exit code, which does not say what the compiler rejected. --- packages/typescript/test/sync/wasm.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index b70c170ccda99..141dc10a774c4 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -131,6 +131,7 @@ describe("API over WebAssembly", () => { for (const project of [...projects, ...projects.map(project => `/${project}`)]) { const stdoutPath = path.join(directory, "stdout"); const stdout = await open(stdoutPath, "w+"); + let exitCode: number | undefined; try { const wasi = new WASI({ version: "preview1", @@ -143,13 +144,15 @@ describe("API over WebAssembly", () => { const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: wasi.wasiImport, }); - assert.strictEqual(wasi.start(instance), 0); + exitCode = wasi.start(instance); } finally { await stdout.close(); } + const output = await readFile(stdoutPath, "utf8"); + assert.strictEqual(exitCode, 0, `tsc -p ${project} failed:\n${output}`); assert.deepStrictEqual( - (await readFile(stdoutPath, "utf8")).trim().split("\n").sort(), + output.trim().split("\n").sort(), [`${project}/index.ts`, `${project}/node_modules/pkg/index.d.ts`], ); } From 0116833af20f66ca98879a7149e15b9209d428a1 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:55:25 -0700 Subject: [PATCH 118/142] List the WASI realpath test's input explicitly Node's WASI implementation cannot read directories on Windows, so the default include pattern found no inputs there. Module resolution still realpaths the dependency, which is what the test covers. --- packages/typescript/test/sync/wasm.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 141dc10a774c4..b42d879908a15 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -121,7 +121,8 @@ describe("API over WebAssembly", () => { for (const project of projects) { const projectDir = path.join(root, project); await mkdir(path.join(projectDir, "node_modules", "pkg"), { recursive: true }); - await writeFile(path.join(projectDir, "tsconfig.json"), `{ "compilerOptions": { "noEmit": true, "noLib": true, "types": [] } }`); + // Node's WASI cannot list directories on Windows. + await writeFile(path.join(projectDir, "tsconfig.json"), `{ "compilerOptions": { "noEmit": true, "noLib": true, "types": [] }, "files": ["index.ts"] }`); await writeFile(path.join(projectDir, "index.ts"), `import { x } from "pkg";`); await writeFile(path.join(projectDir, "node_modules", "pkg", "package.json"), `{ "name": "pkg", "types": "index.d.ts" }`); await writeFile(path.join(projectDir, "node_modules", "pkg", "index.d.ts"), `export declare const x: number;`); From dd8038b6c002dafc7b2ea918eabe506c09f37a49 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:02:58 -0700 Subject: [PATCH 119/142] Remove the Node WASI fallback from the CLI Node's WASI implementation is experimental, needs recent Node versions, and cannot list directories on Windows, so it is not a dependable way to run the compiler. Native binaries cover the supported platforms; a fallback can come back later with a host we control. --- packages/typescript/lib/getExePath.d.ts | 1 - packages/typescript/lib/getExePath.js | 47 +---------- packages/typescript/lib/tsc.js | 86 +++---------------- packages/typescript/test/version.test.ts | 102 +---------------------- 4 files changed, 17 insertions(+), 219 deletions(-) diff --git a/packages/typescript/lib/getExePath.d.ts b/packages/typescript/lib/getExePath.d.ts index d76a8ccc3dfbb..8cf164d03ed66 100644 --- a/packages/typescript/lib/getExePath.d.ts +++ b/packages/typescript/lib/getExePath.d.ts @@ -6,4 +6,3 @@ export interface NodeModules { } declare function getExePath(modules: NodeModules): string; export default getExePath; -export declare function getWasmPath(modules: NodeModules): string; diff --git a/packages/typescript/lib/getExePath.js b/packages/typescript/lib/getExePath.js index ab98049508889..98fee2cc0890f 100644 --- a/packages/typescript/lib/getExePath.js +++ b/packages/typescript/lib/getExePath.js @@ -5,7 +5,10 @@ // NOTE: Keep VS Code extension's resolveTsdkPathToExe in sync with this function. /** @param {NodeModules} modules */ export default function getExePath({ fs, module, path, url }) { - const { __dirname, normalizedDirname, pkg } = getPackageInfo({ fs, path, url }); + const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + const normalizedDirname = __dirname.replace(/\\/g, "/"); + + const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); const pkgName = pkg.name; const baseName = pkgName.startsWith("@") ? pkgName.split("/")[1] : pkgName; const expectedBinName = baseName === "typescript" ? "tsc" : "tsgo"; @@ -65,45 +68,3 @@ export default function getExePath({ fs, module, path, url }) { return exe; } - -/** @param {NodeModules} modules */ -export function getWasmPath({ fs, module, path, url }) { - const { __dirname, normalizedDirname, pkg } = getPackageInfo({ fs, path, url }); - - let wasmDir; - if ( - normalizedDirname.endsWith("/packages/typescript/lib") - || normalizedDirname.endsWith("/built/npm/typescript/lib") - || normalizedDirname.endsWith("/built/npm/native-preview/lib") - ) { - wasmDir = path.resolve(__dirname, "..", "..", "typescript-wasip1-wasm", "lib"); - } - else { - const require = module.createRequire(import.meta.url); - const packageJson = require.resolve("@typescript/typescript-wasip1-wasm/package.json"); - const wasmPackage = JSON.parse(fs.readFileSync(packageJson, "utf8")); - if (wasmPackage.version !== pkg.version) { - throw Object.assign( - new Error( - `WebAssembly compiler package version ${wasmPackage.version} is incompatible with ${pkg.name}@${pkg.version}. Install @typescript/typescript-wasip1-wasm@${pkg.version}.`, - ), - { code: "ERR_TYPESCRIPT_WASM_PACKAGE" }, - ); - } - wasmDir = path.join(path.dirname(packageJson), "lib"); - } - - const wasmPath = path.join(wasmDir, "tsc.wasm"); - if (!fs.existsSync(wasmPath)) { - throw new Error("WebAssembly compiler not found: " + wasmPath); - } - return wasmPath; -} - -/** @param {Pick} modules */ -function getPackageInfo({ fs, path, url }) { - const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); - const normalizedDirname = __dirname.replace(/\\/g, "/"); - const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8")); - return { __dirname, normalizedDirname, pkg }; -} diff --git a/packages/typescript/lib/tsc.js b/packages/typescript/lib/tsc.js index 433291e60f537..c5cd47db2a06c 100644 --- a/packages/typescript/lib/tsc.js +++ b/packages/typescript/lib/tsc.js @@ -1,34 +1,15 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import fs, { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import nodeModule from "node:module"; +import fs from "node:fs"; +import module from "node:module"; import path from "node:path"; import url from "node:url"; -import getExePath, { getWasmPath } from "./getExePath.js"; +import getExePath from "./getExePath.js"; -const nodeModules = { fs, module: nodeModule, path, url }; +const exe = getExePath({ fs, module, path, url }); -let exe; -try { - exe = getExePath(nodeModules); -} -catch (nativeError) { - let wasmPath; - try { - wasmPath = getWasmPath(nodeModules); - } - catch (wasmError) { - if (wasmError?.code === "ERR_TYPESCRIPT_WASM_PACKAGE") { - throw wasmError; - } - throw nativeError; - } - await runWasi(wasmPath); -} - -if (exe && process.platform !== "win32" && typeof process.execve === "function") { +if (process.platform !== "win32" && typeof process.execve === "function") { // > v22.15.0 try { process.execve(exe, [exe, ...process.argv.slice(2)]); @@ -38,57 +19,14 @@ if (exe && process.platform !== "win32" && typeof process.execve === "function") } } -if (exe) { - try { - execFileSync(exe, process.argv.slice(2), { stdio: "inherit" }); - } - catch (e) { - if (e.status) { - process.exitCode = e.status; - } - else { - throw e; - } - } -} - -async function runWasi(wasmPath) { - const nodeMajor = Number(process.versions.node.split(".")[0]); - if (nodeMajor < 23) { - throw new Error("The WASI fallback requires Node.js 23 or newer."); - } - - const { WASI } = await import("node:wasi"); - - const { args, cwd, preopens } = 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); +try { + execFileSync(exe, process.argv.slice(2), { stdio: "inherit" }); } - -function getWasiPaths(wasmPath) { - const args = [wasmPath, ...process.argv.slice(2)]; - if (process.platform !== "win32") { - return { args, cwd: process.cwd(), preopens: { "/": "/" } }; +catch (e) { + if (e.status) { + process.exitCode = e.status; } - - // WASI paths have no drive roots, so the compiler maps each drive to - // /mnt/ when its working directory is drive-rooted. - const preopens = {}; - for (let code = 97; code <= 122; code++) { - const drive = `${String.fromCharCode(code)}:\\`; - if (existsSync(drive)) { - preopens[`/mnt/${drive[0]}`] = drive; - } + else { + throw e; } - return { args, cwd: process.cwd().replaceAll("\\", "/"), preopens }; } diff --git a/packages/typescript/test/version.test.ts b/packages/typescript/test/version.test.ts index 163a8b769eaaa..0b650ca81a8a3 100644 --- a/packages/typescript/test/version.test.ts +++ b/packages/typescript/test/version.test.ts @@ -1,20 +1,8 @@ import { versionMajorMinor } from "@typescript/typescript"; import assert from "node:assert"; -import { - execFileSync, - spawnSync, -} from "node:child_process"; +import { execFileSync } from "node:child_process"; import fs, { readFileSync } from "node:fs"; -import { - cp, - mkdir, - mkdtemp, - rm, - symlink, - writeFile, -} from "node:fs/promises"; import module from "node:module"; -import { tmpdir } from "node:os"; import path from "node:path"; import { test } from "node:test"; import url from "node:url"; @@ -32,91 +20,3 @@ test("versionMajorMinor runtime and declaration match the compiler version", () assert.ok(declarationMatch, "versionMajorMinor declaration not found"); assert.strictEqual(declarationMatch[1], majorMinor); }); - -test("the CLI falls back to the WASI package", { - skip: Number(process.versions.node.split(".")[0]) < 23 ? "Node.js 23 or newer is required" : undefined, -}, async () => { - const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasi-fallback-")); - try { - const packageDirectory = path.join(directory, "node_modules", "typescript"); - const libraryDirectory = path.join(packageDirectory, "lib"); - await mkdir(libraryDirectory, { recursive: true }); - const responseFile = path.join(directory, "args.rsp"); - const sourceFile = path.join(directory, "input.ts"); - await Promise.all([ - cp(new URL("../lib/tsc.js", import.meta.url), path.join(libraryDirectory, "tsc.js")), - cp(new URL("../lib/getExePath.js", import.meta.url), path.join(libraryDirectory, "getExePath.js")), - writeFile(responseFile, "--version"), - writeFile(sourceFile, "export const value: string = 'value';\n"), - writeFile( - path.join(packageDirectory, "package.json"), - JSON.stringify({ - name: "typescript", - version: "0.0.0", - type: "module", - bin: { tsc: "./lib/tsc.js" }, - }), - ), - ]); - - const missingArtifact = spawnSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { - cwd: directory, - encoding: "utf8", - }); - assert.notStrictEqual(missingArtifact.status, 0); - assert.match( - missingArtifact.stderr, - /Unable to resolve @typescript\/typescript-[\w-]+\. Either your platform is unsupported/, - ); - - const scopeDirectory = path.join(directory, "node_modules", "@typescript"); - await mkdir(scopeDirectory, { recursive: true }); - const artifactDirectory = path.join(scopeDirectory, "typescript-wasip1-wasm"); - await mkdir(artifactDirectory); - await symlink( - new URL("../../typescript-wasip1-wasm/lib", import.meta.url), - path.join(artifactDirectory, "lib"), - process.platform === "win32" ? "junction" : "dir", - ); - const artifactPackageJson = { - name: "@typescript/typescript-wasip1-wasm", - version: "1.0.0", - exports: { "./package.json": "./package.json" }, - }; - await writeFile(path.join(artifactDirectory, "package.json"), JSON.stringify(artifactPackageJson)); - - const mismatchedArtifact = spawnSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { - cwd: directory, - encoding: "utf8", - }); - assert.notStrictEqual(mismatchedArtifact.status, 0); - assert.match( - mismatchedArtifact.stderr, - /WebAssembly compiler package version 1\.0\.0 is incompatible with typescript@0\.0\.0/, - ); - artifactPackageJson.version = "0.0.0"; - await writeFile(path.join(artifactDirectory, "package.json"), JSON.stringify(artifactPackageJson)); - - const output = execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { - cwd: directory, - encoding: "utf8", - }); - assert.match(output, /^Version \d+\.\d+\.\d+/); - - await writeFile(responseFile, "\uFEFF@inner.rsp\n"); - await writeFile(path.join(directory, "inner.rsp"), Buffer.from(`\uFEFF"${sourceFile}"\n--noEmit\n`, "utf16le")); - execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), `@${responseFile}`], { - cwd: directory, - stdio: "inherit", - }); - - await writeFile(path.join(directory, "tsconfig.json"), JSON.stringify({ files: [sourceFile], compilerOptions: { noEmit: true } })); - execFileSync(process.execPath, [path.join(libraryDirectory, "tsc.js"), "-p", "."], { - cwd: directory, - stdio: "inherit", - }); - } - finally { - await rm(directory, { recursive: true }); - } -}); From d8eabdec3bcc0e7df7b2be9c9376bda4ab2e8c1a Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:03:29 -0700 Subject: [PATCH 120/142] Remove WASI drive mounting Mapping drive letters to /mnt/ preopens existed only for the Node CLI fallback on Windows, which is gone. The remaining hosts run with drive-rooted and dynamic paths resolved against the working directory. --- tsc/internal/vfs/osvfs/os.go | 15 +++--- tsc/internal/vfs/osvfs/ospath.go | 4 -- tsc/internal/vfs/osvfs/ospath_wasip1.go | 62 ++++++++----------------- 3 files changed, 27 insertions(+), 54 deletions(-) diff --git a/tsc/internal/vfs/osvfs/os.go b/tsc/internal/vfs/osvfs/os.go index 59b813642f7df..9f3f954be6989 100644 --- a/tsc/internal/vfs/osvfs/os.go +++ b/tsc/internal/vfs/osvfs/os.go @@ -3,7 +3,6 @@ package osvfs import ( "errors" "fmt" - "io/fs" "os" "path/filepath" "runtime" @@ -35,7 +34,7 @@ func FS() vfs.FS { var osVFS vfs.FS = &osFS{ common: internal.Common{ - RootFor: func(root string) fs.FS { return os.DirFS(toOSPath(root)) }, + RootFor: os.DirFS, IsReparsePoint: isReparsePoint, }, } @@ -145,7 +144,7 @@ func osFSRealpath(path tspath.RootedPath) tspath.RootedPath { if nativepath.IsNetworkPath(orig.AsString()) { return orig } - nativePath := filepath.FromSlash(toOSPath(path.AsString())) + nativePath := filepath.FromSlash(path.AsString()) nativePath, err := nativepath.Realpath(nativePath) if err != nil { return orig @@ -166,13 +165,13 @@ func isReparsePoint(path string) bool { if nativepath.IsNetworkPath(path) { return false } - return nativepath.IsSymlinkOrReparsePoint(filepath.FromSlash(toOSPath(path))) + return nativepath.IsSymlinkOrReparsePoint(filepath.FromSlash(path)) } func (vfs *osFS) writeFileWithFlag(path string, content string, flag int) error { defer writeSema.Acquire()() - file, err := os.OpenFile(toOSPath(path), flag, 0o666) + file, err := os.OpenFile(path, flag, 0o666) if err != nil { return err } @@ -187,7 +186,7 @@ func (vfs *osFS) writeFileWithFlag(path string, content string, flag int) error func (vfs *osFS) ensureDirectoryExists(directoryPath string) error { defer blockingOpSema.Acquire()() - return os.MkdirAll(toOSPath(directoryPath), 0o777) + return os.MkdirAll(directoryPath, 0o777) } func (vfs *osFS) writeFileEnsuringDir(path tspath.RootedFilePath, content string, flag int) error { @@ -218,7 +217,7 @@ func (vfs *osFS) Remove(path tspath.RootedPath) error { } defer blockingOpSema.Acquire()() // todo: #701 add retry mechanism? - return os.RemoveAll(toOSPath(path.AsString())) + return os.RemoveAll(path.AsString()) } func (vfs *osFS) Chtimes(path tspath.RootedPath, aTime time.Time, mTime time.Time) error { @@ -226,7 +225,7 @@ func (vfs *osFS) Chtimes(path tspath.RootedPath, aTime time.Time, mTime time.Tim return errNetworkPath } defer blockingOpSema.Acquire()() - return os.Chtimes(toOSPath(path.AsString()), aTime, mTime) + return os.Chtimes(path.AsString(), aTime, mTime) } func GetGlobalTypingsCacheLocation() tspath.RootedDirectoryPath { diff --git a/tsc/internal/vfs/osvfs/ospath.go b/tsc/internal/vfs/osvfs/ospath.go index ffec82343f6db..2934b7dc8b17a 100644 --- a/tsc/internal/vfs/osvfs/ospath.go +++ b/tsc/internal/vfs/osvfs/ospath.go @@ -2,10 +2,6 @@ package osvfs -func toOSPath(path string) string { - return path -} - func fromOSPath(path string, original string) string { return path } diff --git a/tsc/internal/vfs/osvfs/ospath_wasip1.go b/tsc/internal/vfs/osvfs/ospath_wasip1.go index b24e597c116b7..d5aae77bfe2dc 100644 --- a/tsc/internal/vfs/osvfs/ospath_wasip1.go +++ b/tsc/internal/vfs/osvfs/ospath_wasip1.go @@ -7,60 +7,38 @@ import ( "strings" ) -// A Windows host runs the compiler with a drive-rooted working directory and -// preopens each drive at /mnt/, since WASI paths have no drive roots. -var mapDrives = isDriveRooted(os.Getenv("PWD")) - -const driveMountPrefix = "/mnt/" - -// Dynamic URI paths (^/...) and, without drive mapping, drive-rooted paths -// are relative in WASI, so they resolve against the working directory. The -// browser host mounts workspaces there for them. -var relativeRoot = relativeRootFor(os.Getenv("PWD")) - -func relativeRootFor(pwd string) string { - if pwd == "" { - pwd = "/" - } - root := toOSPath(pwd) - if !strings.HasSuffix(root, "/") { - root += "/" - } - return root -} - -func toOSPath(path string) string { - if !mapDrives || !isDriveRooted(path) { - return path +// Dynamic URI paths (^/...) and drive-rooted paths are relative in WASI, so +// they resolve against the working directory. The browser host mounts +// workspaces there for them. +var relativeRoot = func() string { + pwd := os.Getenv("PWD") + if !strings.HasSuffix(pwd, "/") { + pwd += "/" } - return driveMountPrefix + strings.ToLower(path[:1]) + path[2:] -} + return pwd +}() // fromOSPath converts a path the OS resolved from original back to a // compiler path. func fromOSPath(path string, original string) string { - if isRelativeInWASI(original) { - if rel, ok := strings.CutPrefix(path, relativeRoot); ok { - if len(rel) == 2 && isDriveLetter(rel[0]) && rel[1] == ':' { - rel += "/" - } - if isRelativeInWASI(rel) { - return rel - } - } + if !isRelativeInWASI(original) { + return path } - rest, ok := strings.CutPrefix(path, driveMountPrefix) - if !mapDrives || !ok || len(rest) == 0 || !isDriveLetter(rest[0]) || len(rest) > 1 && rest[1] != '/' { + rel, ok := strings.CutPrefix(path, relativeRoot) + if !ok { return path } - if len(rest) == 1 { - rest += "/" + if len(rel) == 2 && isDriveLetter(rel[0]) && rel[1] == ':' { + rel += "/" + } + if !isRelativeInWASI(rel) { + return path } - return strings.ToUpper(rest[:1]) + ":" + rest[1:] + return rel } func isRelativeInWASI(path string) bool { - return strings.HasPrefix(path, "^/") || !mapDrives && isDriveRooted(path) + return strings.HasPrefix(path, "^/") || isDriveRooted(path) } func isDriveRooted(path string) bool { From 0aada634fbc8739ee876de8c7d278ec227bb6c18 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:57:54 -0700 Subject: [PATCH 121/142] Decode Buffer base64 responses without copying Source file responses can be large, and the Buffer path is what Node versions without Uint8Array.fromBase64 use. Wrapping the Buffer in a new Uint8Array copied the whole payload a second time; a view over the same memory avoids that. --- packages/typescript/src/api/node/encoder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 237681f6b7d46..7ebae32c2b9f3 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -409,7 +409,8 @@ export function sourceFileResponseToUint8Array(response: { readonly data: string }; }).Buffer; if (bufferConstructor) { - return new Uint8Array(bufferConstructor.from(response.data, "base64")); + const buffer = bufferConstructor.from(response.data, "base64"); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); } const decoded = atob(response.data); return Uint8Array.from(decoded, character => character.charCodeAt(0)); From 1b95a4a6ab116a6cd671cc1ddda4b89061af83e2 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:58:29 -0700 Subject: [PATCH 122/142] Keep the slash when mapping a root workspace back to its URI A workspace rooted at memfs:/ maps to a single empty path segment, which was turned back into memfs: with no path. The file system provider then received a URI that some providers treat differently from the root, so the workspace could not be read. --- packages/vscode-typescript/src/webMountPoint.ts | 3 ++- packages/vscode-typescript/test/webMountPoint.test.ts | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/vscode-typescript/src/webMountPoint.ts b/packages/vscode-typescript/src/webMountPoint.ts index 77389f32b2ecd..5b9adaa267d1b 100644 --- a/packages/vscode-typescript/src/webMountPoint.ts +++ b/packages/vscode-typescript/src/webMountPoint.ts @@ -79,7 +79,8 @@ export function wasmPathToUriText(path: string): string | undefined { if (segment === undefined) return undefined; uriPath += i === 0 ? segment : `/${segment}`; } - if (authority === nulAuthority) return `${scheme}:${uriPath}`; + // The root path "/" is a single empty segment. + if (authority === nulAuthority) return `${scheme}:${uriPath || (segments.length ? "/" : "")}`; const decodedAuthority = decodeSegment(authority); return decodedAuthority === undefined ? undefined : `${scheme}://${decodedAuthority}/${uriPath}`; } diff --git a/packages/vscode-typescript/test/webMountPoint.test.ts b/packages/vscode-typescript/test/webMountPoint.test.ts index 98938673dfe7d..85a1fda813ff5 100644 --- a/packages/vscode-typescript/test/webMountPoint.test.ts +++ b/packages/vscode-typescript/test/webMountPoint.test.ts @@ -46,6 +46,7 @@ describe("web WASI mount points", () => { assert.strictEqual(virtual("vscode-vfs://github/microsoft/TypeScript"), "/^/~ts-uri~/vscode-vfs/github/microsoft/TypeScript"); assert.strictEqual(virtual("memfs:/work/a%5Cb"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/a%5Cb"); assert.strictEqual(virtual("memfs:/work/a\\b/"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~"); + assert.strictEqual(virtual("memfs:/"), "/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~"); assert.strictEqual(virtual("memfs:///x"), "/^/~ts-uri~/memfs/~ts-uri-escape~~/x"); assert.strictEqual(virtual("memfs://c:/work"), "/^/~ts-uri~/memfs/~ts-uri-escape~633a~/work"); assert.strictEqual(virtual("memfs://x:8080/work"), "/^/~ts-uri~/memfs/x:8080/work"); @@ -61,6 +62,7 @@ describe("web WASI mount points", () => { assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/vscode-test-web/mount"), "vscode-test-web://mount/"); assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/work/~ts-uri-escape~615c62~.ts"), "memfs:/work/a\\b.ts"); assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/~ts-uri-escape~~/x"), "memfs:///x"); + assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~"), "memfs:/"); assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/a\\b"), undefined); assert.strictEqual(wasmPathToUriText("/^/~ts-uri~/memfs/ts-nul-authority/~ts-uri-escape~~/~ts-uri-escape~6100713d31~.ts"), undefined); assert.strictEqual(wasmPathToUriText("/work/a.ts"), undefined); From c4bd38df91005993a1d55f7028471e7968b35499 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:18:58 -0700 Subject: [PATCH 123/142] Mark the WebAssembly host as experimental The wasip1 package's warning was written when that package carried the host API. It now ships only artifacts, so the warning described APIs it no longer has, and typescript/wasm, where the API moved, carried no warning at all. --- packages/typescript-wasip1-wasm/README.md | 4 ++-- packages/typescript/src/wasm/index.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/typescript-wasip1-wasm/README.md b/packages/typescript-wasip1-wasm/README.md index 6c0268c02d89c..08f63e69e9d5e 100644 --- a/packages/typescript-wasip1-wasm/README.md +++ b/packages/typescript-wasip1-wasm/README.md @@ -1,8 +1,8 @@ # `@typescript/typescript-wasip1-wasm` > [!WARNING] -> This package is experimental. Its APIs, behavior, and package layout may -> change without notice. +> This package is experimental. Its contents and layout, and the +> `typescript/wasm` host that loads it, may change without notice. This package contains the TypeScript compiler and language server artifacts for `wasip1/wasm`, plus a list of the bundled library files. diff --git a/packages/typescript/src/wasm/index.ts b/packages/typescript/src/wasm/index.ts index c051713a83026..6636a58156297 100644 --- a/packages/typescript/src/wasm/index.ts +++ b/packages/typescript/src/wasm/index.ts @@ -1 +1,9 @@ +/** + * Runs the TypeScript API on the `wasip1/wasm` build of the compiler. + * + * @experimental This module and the WebAssembly build it loads may change + * without notice. + * @packageDocumentation + */ + export * from "./transport.ts"; From 6fe408c173dfc6109d330dc94aa95792b65ce42b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:54:39 -0700 Subject: [PATCH 124/142] Scan for replacement characters with indexOf Every encoded string longer than 64 characters, including whole source files, is checked for U+FFFD. Walking the bytes in JavaScript made that check several times slower than the Buffer search it replaced; jumping between native indexOf hits keeps it browser compatible without the cost. --- packages/typescript/src/api/node/wtf8.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/typescript/src/api/node/wtf8.ts b/packages/typescript/src/api/node/wtf8.ts index 5159ec1546b8a..bfaebcb8c1a8f 100644 --- a/packages/typescript/src/api/node/wtf8.ts +++ b/packages/typescript/src/api/node/wtf8.ts @@ -30,12 +30,8 @@ function hasSurrogateLeadByte(bytes: Uint8Array): boolean { } function hasReplacementCharacter(bytes: Uint8Array): boolean { - for (let i = 0; i <= bytes.length - replacementCharacterUtf8.length; i++) { - if ( - bytes[i] === replacementCharacterUtf8[0] - && bytes[i + 1] === replacementCharacterUtf8[1] - && bytes[i + 2] === replacementCharacterUtf8[2] - ) { + for (let i = bytes.indexOf(replacementCharacterUtf8[0]); i !== -1 && i + 2 < bytes.length; i = bytes.indexOf(replacementCharacterUtf8[0], i + 1)) { + if (bytes[i + 1] === replacementCharacterUtf8[1] && bytes[i + 2] === replacementCharacterUtf8[2]) { return true; } } From 142c6f223fd0883fa4e34a084932e536c40020aa Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:26:56 -0700 Subject: [PATCH 125/142] Choose base64 implementations once The platform cannot gain toBase64, fromBase64, or Buffer while running, so checking for them on every call only added work and repeated the Buffer lookup in two places. --- packages/typescript/src/api/node/encoder.ts | 71 +++++++++------------ packages/typescript/test/encoder.test.ts | 10 +-- 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 7ebae32c2b9f3..4c93c5357df38 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -368,50 +368,41 @@ export function encodeNode(node: Node): Uint8Array { return result; } +interface NodeBuffer extends Uint8Array { + toString(encoding?: "base64"): string; +} + +const nodeBuffer = (globalThis as { + Buffer?: { + from(buffer: ArrayBufferLike, byteOffset: number, length: number): NodeBuffer; + from(data: string, encoding: "base64"): NodeBuffer; + }; +}).Buffer; + /** * Encode a Uint8Array to a base64 string. */ -export function uint8ArrayToBase64(data: Uint8Array): string { - 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"); - } - - let binary = ""; - for (let i = 0; i < data.length; i += 0x8000) { - binary += String.fromCharCode(...data.subarray(i, i + 0x8000)); +export const uint8ArrayToBase64: (data: Uint8Array) => string = "toBase64" in Uint8Array.prototype + ? data => (data as Uint8Array & { toBase64(): string; }).toBase64() + : nodeBuffer + ? data => nodeBuffer.from(data.buffer, data.byteOffset, data.byteLength).toString("base64") + : data => { + let binary = ""; + for (let i = 0; i < data.length; i += 0x8000) { + binary += String.fromCharCode(...data.subarray(i, i + 0x8000)); + } + return btoa(binary); + }; + +const base64ToUint8Array: (data: string) => Uint8Array = "fromBase64" in Uint8Array + ? (Uint8Array as unknown as { fromBase64(data: string): Uint8Array; }).fromBase64 + : nodeBuffer + ? data => { + const buffer = nodeBuffer.from(data, "base64"); + return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); } - return btoa(binary); -} + : data => Uint8Array.from(atob(data), character => character.charCodeAt(0)); export function sourceFileResponseToUint8Array(response: { readonly data: string; } | null | undefined): Uint8Array | undefined { - if (!response) return undefined; - const fromBase64 = (Uint8Array as { - fromBase64?: (data: string) => Uint8Array; - }).fromBase64; - if (fromBase64) { - return fromBase64(response.data); - } - const bufferConstructor = (globalThis as { - Buffer?: { - from(data: string, encoding: "base64"): Uint8Array; - }; - }).Buffer; - if (bufferConstructor) { - const buffer = bufferConstructor.from(response.data, "base64"); - return new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); - } - const decoded = atob(response.data); - return Uint8Array.from(decoded, character => character.charCodeAt(0)); + return response ? base64ToUint8Array(response.data) : undefined; } diff --git a/packages/typescript/test/encoder.test.ts b/packages/typescript/test/encoder.test.ts index 5595dac1beba4..2d95145cda8eb 100644 --- a/packages/typescript/test/encoder.test.ts +++ b/packages/typescript/test/encoder.test.ts @@ -75,14 +75,8 @@ function decode(data: Uint8Array): RemoteSourceFile { } describe("Encoder", { concurrency }, () => { - test("encodes base64 using the available platform implementation", () => { - const data = new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4); - assert.strictEqual(uint8ArrayToBase64(data), "AQID"); - - Object.defineProperty(data, "toBase64", { - value: () => "native", - }); - assert.strictEqual(uint8ArrayToBase64(data), "native"); + test("encodes base64 from a subarray", () => { + assert.strictEqual(uint8ArrayToBase64(new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4)), "AQID"); }); test("encodes empty source file", () => { From 77c2dea323405346d33b750fa4486b77fff2c2f7 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:39:15 -0700 Subject: [PATCH 126/142] Trim redundant WASM and transport tests Several tests repeated coverage that other tests already provide, mocked the reactor protocol instead of running it, or checked internal state and error message wording. Folding the close scenarios together and dropping those checks keeps the regressions covered with less to maintain. --- .../typescript/test/async/transport.test.ts | 212 +++++------------- .../typescript/test/browser/smoke.test.ts | 7 - .../typescript/test/sync/transport.test.ts | 90 -------- packages/typescript/test/sync/wasm.test.ts | 54 +---- 4 files changed, 59 insertions(+), 304 deletions(-) diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts index 0c40f20afc5f8..1a4e8980621b6 100644 --- a/packages/typescript/test/async/transport.test.ts +++ b/packages/typescript/test/async/transport.test.ts @@ -95,81 +95,58 @@ describe("async transport", () => { assert.strictEqual(closes, 1); }); - 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"); + test("rejects queued requests when closed", async () => { + const emptyCommandLine: ParsedCommandLine = { options: {}, fileNames: [], errors: [] }; + const respond = (value: unknown) => ({ value: JSON.stringify(value), bytesSent: 0, bytesReceived: 0 }); + // Requests held in a manual batch, both before and after initialization. + for (const initializeFirst of [false, true]) { + const api = new API({ + transport: { + request(method) { + switch (method) { + case "initialize": + return respond({ currentDirectory: "/", useCaseSensitiveFileNames: true }); + case "parseCommandLine": + return respond(emptyCommandLine); + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, }, - 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; - }); + }); + if (initializeFirst) { + await api.parseCommandLine([]); + } + const batch = api.batchContext(); + const rejected = assert.rejects(api.parseCommandLine([]), /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + } - 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() {}, + // Text and binary requests closed before their microtask sends them. + const client = new TransportClient({ + request() { + throw new Error("Unexpected request"); }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: 1, + file: "/index.ts", }); - - const batch = api.batchContext(); - const request = api.parseCommandLine([]); - const rejected = assert.rejects(request, /Client is closed/); - await api.close(); - batch[Symbol.dispose](); - await rejected; + await client.close(); + await assert.rejects(text, /Client is closed/); + await assert.rejects(binary, /Client is closed/); }); test("can close a process client while initialization is manually batched", async () => { @@ -260,58 +237,26 @@ describe("async transport", () => { 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({ + test("rejects in-flight requests when closed", async () => { + const neverResolves = { request() { - return new Promise(resolve => { - resolveRequest = resolve; - }); + return new Promise(() => {}); }, requestBinary() { throw new Error("Unexpected binary request"); }, close() {}, - }, false); + }; + const client = new TransportClient(neverResolves, false); const request = client.apiRequest("parseCommandLine", { commandLine: [] }); await Promise.resolve(); await client.close(); await assert.rejects(request, /Client is closed/); - 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/); + // The API's own initialize request is in flight here. + const api = new API({ transport: neverResolves }); + const rejected = assert.rejects(api.parseCommandLine([]), /Client is closed/); await Promise.resolve(); await api.close(); await rejected; @@ -337,49 +282,4 @@ describe("async transport", () => { await assert.rejects(reset, /Client is closed/); assert.deepStrictEqual(methods, []); }); - - test("rejects queued requests when closed before they are sent", 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: 1, - file: "/index.ts", - }); - await client.close(); - await assert.rejects(text, /Client is closed/); - await assert.rejects(binary, /Client is closed/); - }); - - test("rejects thenable callback results", () => { - let registeredCallback: ((name: string, payload: string) => string) | undefined; - const client = new TransportClient({ - request() { - throw new Error("Unexpected request"); - }, - requestBinary() { - throw new Error("Unexpected binary request"); - }, - close() {}, - registerCallback(_name, callback) { - registeredCallback = callback; - }, - unregisterCallback() {}, - }, false); - - client.registerCallback("callback", () => ({ then() {} })); - assert.throws( - () => registeredCallback?.("callback", "null"), - /Injected transport callbacks must complete synchronously/, - ); - }); }); diff --git a/packages/typescript/test/browser/smoke.test.ts b/packages/typescript/test/browser/smoke.test.ts index 5d67ed123f366..9a4a74fb8fc25 100644 --- a/packages/typescript/test/browser/smoke.test.ts +++ b/packages/typescript/test/browser/smoke.test.ts @@ -167,11 +167,4 @@ describe("API over WebAssembly in a browser", () => { const result = await page.evaluate(() => Reflect.get(globalThis, "result")); assert.deepStrictEqual(result, ["42", "42", 1]); }); - - test("loads the transport entry without package resolution", async () => { - const page = await browser.newPage(); - await page.goto(origin); - const exports = await page.evaluate(async origin => Object.keys(await import(`${origin}/dist/wasm/index.js`)), origin); - assert.deepStrictEqual(exports, ["WasmTransport", "instantiateWasm", "instantiateWasmSync"]); - }); }); diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts index ac8ddaea2730f..8b5aa482a4130 100644 --- a/packages/typescript/test/sync/transport.test.ts +++ b/packages/typescript/test/sync/transport.test.ts @@ -2,10 +2,6 @@ import { API, type ParsedCommandLine, } from "@typescript/typescript/sync"; -import { - type WasmReactorExports, - WasmTransport, -} from "@typescript/typescript/wasm"; import assert from "node:assert"; import { describe, @@ -87,90 +83,4 @@ describe("sync transport", () => { 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 index b42d879908a15..d387a0b8b5983 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -163,39 +163,6 @@ describe("API over WebAssembly", () => { } }); - test("exits successfully when LSP stdin closes", async () => { - const WebAssembly = (globalThis as any).WebAssembly; - const directory = await mkdtemp(path.join(tmpdir(), "typescript-wasip1-")); - try { - const stdin = await open(path.join(directory, "stdin"), "w+"); - const stdout = await open(path.join(directory, "stdout"), "w+"); - const stderr = await open(path.join(directory, "stderr"), "w+"); - try { - const wasi = new WASI({ - version: "preview1", - args: ["tsc.wasm", "--lsp", "--stdio"], - env: { PWD: "/" }, - preopens: { "/": process.cwd() }, - stdin: stdin.fd, - stdout: stdout.fd, - stderr: stderr.fd, - returnOnExit: true, - }); - const module = await WebAssembly.compile(await readFile(wasmURL)); - const instance = await WebAssembly.instantiate(module, { - wasi_snapshot_preview1: wasi.wasiImport, - }); - assert.strictEqual(wasi.start(instance), 0); - } - finally { - await Promise.all([stdin.close(), stdout.close(), stderr.close()]); - } - } - finally { - await rm(directory, { recursive: true }); - } - }); - test("waits for LSP stdin without repeatedly reading", { timeout: 10_000 }, async () => { const childSource = ` import { readFileSync } from "node:fs"; @@ -299,10 +266,8 @@ describe("API over WebAssembly", () => { [...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")); + // Hosts call _initialize automatically, which would break the command entry point. + assert.ok(!WebAssembly.Module.exports(module).some((value: { name: string; }) => value.name === "_initialize")); const instance = await instantiateWasm(module, { stdout: undefined, stderr: undefined }); assert.throws( @@ -324,7 +289,6 @@ describe("API over WebAssembly", () => { transport = new WasmTransport({ instance, cwd: "/", - collectTiming: true, fs: { writeFile(path, data) { memory.grow(0); @@ -340,7 +304,7 @@ describe("API over WebAssembly", () => { }, }, }); - const api = new SyncAPI({ transport, collectTiming: true }); + const api = new SyncAPI({ transport }); assert.strictEqual(transport.requestSync("echo", "text"), "text"); assert.deepStrictEqual( transport.requestBinarySync("echo", new Uint8Array([1, 2, 3])), @@ -351,9 +315,6 @@ describe("API over WebAssembly", () => { transport.setFile("/raw.ts", rawContent); assert.strictEqual(transport.readFile("/raw.ts"), rawContent); transport.removeFile("/raw.ts"); - assert.throws(() => transport.setFile("", "text"), /panic setting file/); - assert.throws(() => transport.removeFile(""), /panic removing file/); - assert.throws(() => transport.readFile(""), /panic reading file/); transport.setFile("/tsconfig.json", `{ "compilerOptions": { "emitBOM": true } }`); transport.setFile("/src/index.ts", "export const value = 42 as const;"); @@ -403,20 +364,11 @@ describe("API over WebAssembly", () => { assert.strictEqual(resolver.resolveModuleName("large", "/src", undefined).resolvedModule?.resolvedFileName, "/large.d.ts"); assert.strictEqual(resolverCalls, 1); - const timing = api.getTimingInfo(); - assert.strictEqual(timing.enabled, true); - assert.ok(timing.totals.requestCount > 0); - assert.ok(timing.totals.serverTimeMs >= 0); - transport.registerCallback("retained", () => ""); api.close(); - assert.deepStrictEqual(Reflect.get(transport, "callbackNames"), new Set()); const secondTransport = new WasmTransport({ instance, cwd: "/" }); const second = new SyncAPI({ transport: secondTransport }); assert.strictEqual(secondTransport.requestSync("echo", "recreated"), "recreated"); - secondTransport.registerCallback("retained", () => ""); - transport.unregisterCallback("retained"); - assert.deepStrictEqual(Reflect.get(secondTransport, "callbackNames"), new Set(["retained"])); second.close(); }); }); From 52beffd303542bd8071aac2b169a9666d62c6cb8 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:03:09 -0700 Subject: [PATCH 127/142] Publish the WASI package with the platform packages The WASI package has no dependency relationship with the main package, so it never needed its own publish stage, artifact, and approval. It belongs with the platform packages instead: if it fails to publish, the main package should not ship pointing users at a tsc.wasm that does not exist. --- Herebyfile.mjs | 13 ++++------- tools/pipelines/typescript-publish.yml | 32 ++++---------------------- 2 files changed, 9 insertions(+), 36 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 4d5e9a252bcc9..eb4c120f8cda1 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -2954,11 +2954,13 @@ async function runPackNativePreviewPackages() { await fs.promises.rename(filename, npmTarball); })); - // Publish platform packages before the main package that references them as - // optionalDependencies. The independent WASI artifact package is published last. + // npm packages need to be published in dependency order: platform packages + // first, then the main package that references them as optionalDependencies. + // The WASI package goes with the platform packages so that typescript/wasm + // never refers to a version without a matching tsc.wasm. const publishManifest = { stages: [ - platforms.map(p => ({ + [...platforms, wasip1Package].map(p => ({ filename: path.basename(p.npmTarball), })), [ @@ -2966,11 +2968,6 @@ async function runPackNativePreviewPackages() { filename: path.basename(mainNativePreviewPackage.npmTarball), }, ], - [ - { - filename: path.basename(wasip1Package.npmTarball), - }, - ], ], }; diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index 0a40eec88aa16..9718afd61fded 100644 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -77,10 +77,6 @@ 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 @@ -102,13 +98,12 @@ extends: src="$(Pipeline.Workspace)/npm" platformDst="$(Build.ArtifactStagingDirectory)/npm-platform" mainDst="$(Build.ArtifactStagingDirectory)/npm-main" - wasmDst="$(Build.ArtifactStagingDirectory)/npm-wasip1" - mkdir -p "$platformDst" "$mainDst" "$wasmDst" + mkdir -p "$platformDst" "$mainDst" manifest="$src/publish-manifest.json" stageCount="$(jq '.stages | length' "$manifest")" - if [ "$stageCount" -ne 3 ]; then - echo "Expected exactly 3 npm publish stages, found $stageCount." >&2 + if [ "$stageCount" -ne 2 ]; then + echo "Expected exactly 2 npm publish stages, found $stageCount." >&2 exit 1 fi @@ -173,20 +168,14 @@ extends: jq -r '.stages[0][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$platformDst/" jq -r '.stages[1][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$mainDst/" - jq -r '.stages[2][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$wasmDst/" platformCount="$(find "$platformDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" mainCount="$(find "$mainDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" - 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)." + echo "##[section]Staged $platformCount platform package(s) and $mainCount main package(s)." if [ "$mainCount" -ne 1 ]; then echo "Expected 1 main package, found $mainCount." >&2 exit 1 fi - if [ "$wasmCount" -ne 1 ]; then - echo "Expected 1 WASM package, found $wasmCount." >&2 - exit 1 - fi name: releaseMetadata displayName: 'Stage npm artifacts' env: @@ -211,9 +200,6 @@ extends: - input: pipelineArtifact artifactName: 'npm-main' targetPath: '$(Pipeline.Workspace)/npm-main' - - input: pipelineArtifact - artifactName: 'npm-wasip1' - targetPath: '$(Pipeline.Workspace)/npm-wasip1' steps: - checkout: none @@ -238,16 +224,6 @@ 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 From 1fcb6b3fb194fffbf3f47190cd1424232d6e9f11 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:11:35 -0700 Subject: [PATCH 128/142] Group the wasip1 cmd/tsc overrides into one file pair The small platform overrides were spread across four file pairs, which made the WebAssembly surface of the command look larger than it is. Keeping them in one pair puts every difference side by side. --- tsc/cmd/tsc/currentdirectory.go | 9 --- tsc/cmd/tsc/currentdirectory_wasip1.go | 17 ------ tsc/cmd/tsc/lsp_process_wasip1.go | 19 ------ tsc/cmd/tsc/lsp_stdin.go | 9 --- tsc/cmd/tsc/lsp_stdin_wasip1.go | 38 ------------ tsc/cmd/tsc/notifycontext.go | 13 ----- tsc/cmd/tsc/notifycontext_wasip1.go | 12 ---- tsc/cmd/tsc/{lsp_process.go => platform.go} | 12 ++++ tsc/cmd/tsc/platform_wasip1.go | 65 +++++++++++++++++++++ 9 files changed, 77 insertions(+), 117 deletions(-) delete mode 100644 tsc/cmd/tsc/currentdirectory.go delete mode 100644 tsc/cmd/tsc/currentdirectory_wasip1.go delete mode 100644 tsc/cmd/tsc/lsp_process_wasip1.go delete mode 100644 tsc/cmd/tsc/lsp_stdin.go delete mode 100644 tsc/cmd/tsc/lsp_stdin_wasip1.go delete mode 100644 tsc/cmd/tsc/notifycontext.go delete mode 100644 tsc/cmd/tsc/notifycontext_wasip1.go rename tsc/cmd/tsc/{lsp_process.go => platform.go} (63%) create mode 100644 tsc/cmd/tsc/platform_wasip1.go diff --git a/tsc/cmd/tsc/currentdirectory.go b/tsc/cmd/tsc/currentdirectory.go deleted file mode 100644 index a132c34ae6837..0000000000000 --- a/tsc/cmd/tsc/currentdirectory.go +++ /dev/null @@ -1,9 +0,0 @@ -//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 deleted file mode 100644 index 96f1adbd201d2..0000000000000 --- a/tsc/cmd/tsc/currentdirectory_wasip1.go +++ /dev/null @@ -1,17 +0,0 @@ -//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_process_wasip1.go b/tsc/cmd/tsc/lsp_process_wasip1.go deleted file mode 100644 index ea9e45e4e3361..0000000000000 --- a/tsc/cmd/tsc/lsp_process_wasip1.go +++ /dev/null @@ -1,19 +0,0 @@ -//go:build wasip1 - -package main - -import ( - "context" - "errors" - "io" -) - -func getNpmInstall() func(context.Context, string, []string) ([]byte, error) { - return func(context.Context, string, []string) ([]byte, error) { - return nil, errors.New("installing npm packages is not supported in WebAssembly") - } -} - -func getLSPSpawn() func(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { - return nil -} diff --git a/tsc/cmd/tsc/lsp_stdin.go b/tsc/cmd/tsc/lsp_stdin.go deleted file mode 100644 index 502a032ad5243..0000000000000 --- a/tsc/cmd/tsc/lsp_stdin.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !wasip1 - -package main - -import ( - "os" -) - -var stdioStdin = os.Stdin diff --git a/tsc/cmd/tsc/lsp_stdin_wasip1.go b/tsc/cmd/tsc/lsp_stdin_wasip1.go deleted file mode 100644 index 3cbaca907d214..0000000000000 --- a/tsc/cmd/tsc/lsp_stdin_wasip1.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build wasip1 - -package main - -import ( - "io" - "syscall" - "time" -) - -const maxStdinPollDelay = 50 * time.Millisecond - -type wasiStdin struct{} - -func (wasiStdin) Read(buffer []byte) (int, error) { - delay := time.Millisecond - for { - n, err := syscall.Read(syscall.Stdin, buffer) - if n > 0 { - return n, nil - } - if err == nil { - return 0, io.EOF - } - if err != syscall.EAGAIN { - return 0, err - } - // Stdin is not registered with Go's WASI poller. - time.Sleep(delay) - delay = min(delay*2, maxStdinPollDelay) - } -} - -func (wasiStdin) Close() error { - return nil -} - -var stdioStdin wasiStdin diff --git a/tsc/cmd/tsc/notifycontext.go b/tsc/cmd/tsc/notifycontext.go deleted file mode 100644 index 0508bcd115e89..0000000000000 --- a/tsc/cmd/tsc/notifycontext.go +++ /dev/null @@ -1,13 +0,0 @@ -//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 deleted file mode 100644 index 807f7b1c9b261..0000000000000 --- a/tsc/cmd/tsc/notifycontext_wasip1.go +++ /dev/null @@ -1,12 +0,0 @@ -//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/lsp_process.go b/tsc/cmd/tsc/platform.go similarity index 63% rename from tsc/cmd/tsc/lsp_process.go rename to tsc/cmd/tsc/platform.go index 79567dfb0f943..f452a211b31d1 100644 --- a/tsc/cmd/tsc/lsp_process.go +++ b/tsc/cmd/tsc/platform.go @@ -5,9 +5,21 @@ package main import ( "context" "io" + "os" "os/exec" + "os/signal" ) +var stdioStdin = os.Stdin + +func getCurrentDirectory() (string, error) { + return os.Getwd() +} + +func notifyContext(parent context.Context, signals ...os.Signal) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, signals...) +} + func getNpmInstall() func(ctx context.Context, cwd string, args []string) ([]byte, error) { return func(ctx context.Context, cwd string, args []string) ([]byte, error) { cmd := exec.CommandContext(ctx, "npm", args...) diff --git a/tsc/cmd/tsc/platform_wasip1.go b/tsc/cmd/tsc/platform_wasip1.go new file mode 100644 index 0000000000000..59496a132c0e0 --- /dev/null +++ b/tsc/cmd/tsc/platform_wasip1.go @@ -0,0 +1,65 @@ +//go:build wasip1 + +package main + +import ( + "context" + "errors" + "io" + "os" + "syscall" + "time" + + "github.com/microsoft/TypeScript/tsc/internal/tspath" +) + +const maxStdinPollDelay = 50 * time.Millisecond + +type wasiStdin struct{} + +func (wasiStdin) Read(buffer []byte) (int, error) { + delay := time.Millisecond + for { + n, err := syscall.Read(syscall.Stdin, buffer) + if n > 0 { + return n, nil + } + if err == nil { + return 0, io.EOF + } + if err != syscall.EAGAIN { + return 0, err + } + // Stdin is not registered with Go's WASI poller. + time.Sleep(delay) + delay = min(delay*2, maxStdinPollDelay) + } +} + +func (wasiStdin) Close() error { + return nil +} + +var stdioStdin wasiStdin + +func getCurrentDirectory() (string, error) { + cwd := os.Getenv("PWD") + if !tspath.IsRootedDiskPath(cwd) { + cwd = "/" + } + return cwd, nil +} + +func notifyContext(parent context.Context, _ ...os.Signal) (context.Context, context.CancelFunc) { + return context.WithCancel(parent) +} + +func getNpmInstall() func(context.Context, string, []string) ([]byte, error) { + return func(context.Context, string, []string) ([]byte, error) { + return nil, errors.New("installing npm packages is not supported in WebAssembly") + } +} + +func getLSPSpawn() func(command []string, dir string, stderr io.Writer) (io.ReadWriteCloser, error) { + return nil +} From 9caacbb660f778f0965bb94c3c0be2ec009a3164 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:13:26 -0700 Subject: [PATCH 129/142] Run the config file name tests under WebAssembly These tests were skipped only because the reactor lists directories in sorted order while the virtual test file system preserves insertion order. The file list order is not what they are checking. --- Herebyfile.mjs | 1 - packages/typescript/test/async/api.test.ts | 6 +++--- packages/typescript/test/sync/api.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index eb4c120f8cda1..1967a22dd389f 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1023,7 +1023,6 @@ async function runTestAPIWasm() { "updateSnapshot file systems (?:full file system is total and does not invoke host callbacks|file system layer bypasses callbacks on hits and falls back on misses)", // The reactor mirrors input files but does not mirror build output changes. "BuildOrchestrator (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)", - "parseJsonConfigFileContent accepts non-object JSON|parseConfigFile$|project exposes parsedCommandLine", "Benchmarks|Parse-clone-emit roundtrip", ].join("|"); for (const mode of ["async", "sync"]) { diff --git a/packages/typescript/test/async/api.test.ts b/packages/typescript/test/async/api.test.ts index 9218b5be8da34..c925852d2aecd 100644 --- a/packages/typescript/test/async/api.test.ts +++ b/packages/typescript/test/async/api.test.ts @@ -396,7 +396,7 @@ describe("API", { concurrency }, () => { await using api = spawnAPI(); const config = await api.parseJsonConfigFileContent(null, { configDirectory: "/src" }); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.errors, []); }); @@ -1443,7 +1443,7 @@ declare module "augmentation" {}`, await using api = spawnAPI(); const config = await api.parseConfigFile("/tsconfig.json"); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.options, { configFilePath: "/tsconfig.json" }); assert.equal(config.compileOnSave, false); assert.equal(config.typeAcquisition, undefined); @@ -2109,7 +2109,7 @@ describe("Snapshot", { concurrency }, () => { const snapshot = await api.createSnapshot({ openProjects: ["/tsconfig.json"] }); const project = snapshot.getConfiguredProject("/tsconfig.json")!; - assert.deepEqual(project.parsedCommandLine.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...project.parsedCommandLine.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(project.parsedCommandLine.options, { configFilePath: "/tsconfig.json" }); assert.equal(project.parsedCommandLine.compileOnSave, true); }); diff --git a/packages/typescript/test/sync/api.test.ts b/packages/typescript/test/sync/api.test.ts index b742434897269..51ae9043f1974 100644 --- a/packages/typescript/test/sync/api.test.ts +++ b/packages/typescript/test/sync/api.test.ts @@ -385,7 +385,7 @@ describe("API", { concurrency }, () => { using api = spawnAPI(); const config = api.parseJsonConfigFileContent(null, { configDirectory: "/src" }); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.errors, []); }); @@ -1430,7 +1430,7 @@ declare module "augmentation" {}`, using api = spawnAPI(); const config = api.parseConfigFile("/tsconfig.json"); - assert.deepEqual(config.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...config.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(config.options, { configFilePath: "/tsconfig.json" }); assert.equal(config.compileOnSave, false); assert.equal(config.typeAcquisition, undefined); @@ -1975,7 +1975,7 @@ describe("Snapshot", { concurrency }, () => { const snapshot = api.createSnapshot({ openProjects: ["/tsconfig.json"] }); const project = snapshot.getConfiguredProject("/tsconfig.json")!; - assert.deepEqual(project.parsedCommandLine.fileNames, ["/src/index.ts", "/src/foo.ts"]); + assert.deepEqual([...project.parsedCommandLine.fileNames].sort(), ["/src/foo.ts", "/src/index.ts"]); assert.deepEqual(project.parsedCommandLine.options, { configFilePath: "/tsconfig.json" }); assert.equal(project.parsedCommandLine.compileOnSave, true); }); From e55c6da8fbf83dadbcd7a63c55e041910ef4c536 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:45:07 -0700 Subject: [PATCH 130/142] Separate MessagePack framing from the tuple protocol The synchronous protocol is a sequence of [type, method, payload] tuples, and only the process transport needs them framed as MessagePack. Splitting the two lets an in-process host exchange the same tuples directly while reusing the protocol's request, call, and response mapping. --- tsc/internal/api/protocol_msgpack.go | 66 ++++++++++++++++++---------- tsc/internal/api/session.go | 2 +- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/tsc/internal/api/protocol_msgpack.go b/tsc/internal/api/protocol_msgpack.go index 848bb9a6e71b6..0167f22f24e9d 100644 --- a/tsc/internal/api/protocol_msgpack.go +++ b/tsc/internal/api/protocol_msgpack.go @@ -37,26 +37,44 @@ const ( msgpackU8 byte = 0xCC ) -// MessagePackProtocol implements the Protocol interface using a custom -// msgpack-based tuple format: [MessageType, method, payload]. -type MessagePackProtocol struct { - r *bufio.Reader - w *bufio.Writer +// TupleTransport carries [MessageType, method, payload] tuples between the +// server and a client. +type TupleTransport interface { + ReadTuple() (MessageType, string, []byte, error) + WriteTuple(msgType MessageType, method string, payload []byte) error +} + +// TupleProtocol implements the Protocol interface over [MessageType, method, +// payload] tuples. Requests and responses are matched by method name. +type TupleProtocol struct { + t TupleTransport } -var _ ipc.Protocol = (*MessagePackProtocol)(nil) +var _ ipc.Protocol = (*TupleProtocol)(nil) + +// NewTupleProtocol creates a protocol handler that exchanges tuples over t. +func NewTupleProtocol(t TupleTransport) *TupleProtocol { + return &TupleProtocol{t: t} +} -// NewMessagePackProtocol creates a new msgpack protocol handler. -func NewMessagePackProtocol(rw io.ReadWriter) *MessagePackProtocol { - return &MessagePackProtocol{ +// NewMessagePackProtocol creates a protocol handler that frames tuples as +// MessagePack arrays on rw. +func NewMessagePackProtocol(rw io.ReadWriter) *TupleProtocol { + return NewTupleProtocol(&messagePackTransport{ r: bufio.NewReader(rw), w: bufio.NewWriter(rw), - } + }) +} + +// messagePackTransport frames tuples as fixed three-element MessagePack arrays. +type messagePackTransport struct { + r *bufio.Reader + w *bufio.Writer } // ReadMessage implements Protocol. -func (p *MessagePackProtocol) ReadMessage() (*ipc.Message, error) { - msgType, method, payload, err := p.readTuple() +func (p *TupleProtocol) ReadMessage() (*ipc.Message, error) { + msgType, method, payload, err := p.t.ReadTuple() if err != nil { return nil, err } @@ -94,7 +112,7 @@ func (p *MessagePackProtocol) ReadMessage() (*ipc.Message, error) { return msg, nil } -func (p *MessagePackProtocol) readTuple() (MessageType, string, []byte, error) { +func (p *messagePackTransport) ReadTuple() (MessageType, string, []byte, error) { // Read fixed array marker (0x93 = 3-element array) t, err := p.r.ReadByte() if err != nil { @@ -143,7 +161,7 @@ func (p *MessagePackProtocol) readTuple() (MessageType, string, []byte, error) { return msgType, method, payload, nil } -func (p *MessagePackProtocol) readBin() ([]byte, error) { +func (p *messagePackTransport) readBin() ([]byte, error) { t, err := p.r.ReadByte() if err != nil { return nil, err @@ -181,23 +199,23 @@ func (p *MessagePackProtocol) readBin() ([]byte, error) { } // WriteRequest implements Protocol. -func (p *MessagePackProtocol) WriteRequest(id *jsonrpc.ID, method string, params any) error { +func (p *TupleProtocol) WriteRequest(id *jsonrpc.ID, method string, params any) error { // For msgpack protocol, requests from server are "Call" type payload, err := json.Marshal(params) if err != nil { return err } - return p.writeTuple(MessageTypeCall, method, payload) + return p.t.WriteTuple(MessageTypeCall, method, payload) } // WriteNotification implements Protocol. -func (p *MessagePackProtocol) WriteNotification(method string, params any) error { +func (p *TupleProtocol) WriteNotification(method string, params any) error { // Msgpack protocol doesn't distinguish notifications from calls return p.WriteRequest(nil, method, params) } // WriteResponse implements Protocol. -func (p *MessagePackProtocol) WriteResponse(id *jsonrpc.ID, result any) error { +func (p *TupleProtocol) WriteResponse(id *jsonrpc.ID, result any) error { method := "" if id != nil { method = id.String() @@ -216,19 +234,19 @@ func (p *MessagePackProtocol) WriteResponse(id *jsonrpc.ID, result any) error { } } - return p.writeTuple(MessageTypeResponse, method, payload) + return p.t.WriteTuple(MessageTypeResponse, method, payload) } // WriteError implements Protocol. -func (p *MessagePackProtocol) WriteError(id *jsonrpc.ID, respErr *jsonrpc.ResponseError) error { +func (p *TupleProtocol) WriteError(id *jsonrpc.ID, respErr *jsonrpc.ResponseError) error { method := "" if id != nil { method = id.String() } - return p.writeTuple(MessageTypeError, method, []byte(respErr.Message)) + return p.t.WriteTuple(MessageTypeError, method, []byte(respErr.Message)) } -func (p *MessagePackProtocol) writeTuple(msgType MessageType, method string, payload []byte) error { +func (p *messagePackTransport) WriteTuple(msgType MessageType, method string, payload []byte) error { // Write fixed array marker if err := p.w.WriteByte(msgpackFixedArray3); err != nil { return err @@ -248,7 +266,7 @@ func (p *MessagePackProtocol) writeTuple(msgType MessageType, method string, pay return p.w.Flush() } -func (p *MessagePackProtocol) writeBin(data []byte) error { +func (p *messagePackTransport) writeBin(data []byte) error { length := len(data) if length < 256 { if err := p.w.WriteByte(msgpackBin8); err != nil { @@ -277,5 +295,5 @@ func (p *MessagePackProtocol) writeBin(data []byte) error { } // RawBinary is a marker type for binary data that should be written -// directly by MessagePackProtocol instead of being JSON-encoded. +// directly by TupleProtocol instead of being JSON-encoded. type RawBinary []byte diff --git a/tsc/internal/api/session.go b/tsc/internal/api/session.go index d600fded72e3e..1990eb616248c 100644 --- a/tsc/internal/api/session.go +++ b/tsc/internal/api/session.go @@ -561,7 +561,7 @@ type Session struct { closeOnce sync.Once - // This is set to true when using MessagePackProtocol. + // This is set to true when using TupleProtocol. useBinaryResponses bool batchResponsePages sync.Map nextBatchResponsePageID atomic.Uint64 From c3943db7c8dcc73932883ed15f4c2e4e6de4ef8b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:45:07 -0700 Subject: [PATCH 131/142] Let the API server take a base file system and tuple transport An embedded server needs the same session, callback file system, and connection handling as the process server, but over an in-memory file system and without a pipe. Accepting both as options keeps a single server setup for every host. --- tsc/internal/api/server.go | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/tsc/internal/api/server.go b/tsc/internal/api/server.go index 75f402ef9b423..02fcbc03f5085 100644 --- a/tsc/internal/api/server.go +++ b/tsc/internal/api/server.go @@ -11,6 +11,7 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/tspath" + "github.com/microsoft/TypeScript/tsc/internal/vfs" "github.com/microsoft/TypeScript/tsc/internal/vfs/osvfs" ) @@ -41,6 +42,12 @@ type StdioServerOptions struct { // RunExternalCode allows configured content mappers to execute. RunExternalCode bool ContentMapperSpawner contentmapper.Spawner + // FS replaces the operating-system file system as the server's base file + // system, which callbacks fall back to. + FS vfs.FS + // Tuples, if set, carries synchronous protocol messages directly instead + // of In, Out, or PipePath. + Tuples TupleTransport } // StdioServer runs an API session over STDIO using MessagePack protocol. @@ -64,7 +71,11 @@ func NewStdioServer(options *StdioServerOptions) *StdioServer { // Run starts the server and blocks until the connection closes. func (s *StdioServer) Run(ctx context.Context) error { var transport ipc.Transport - if s.options.PipePath != "" { + if s.options.Tuples != nil { + if s.options.Async { + panic("StdioServerOptions.Tuples requires the synchronous protocol") + } + } else if s.options.PipePath != "" { t, err := ipc.NewPipeTransport(s.options.PipePath) if err != nil { return fmt.Errorf("failed to create pipe transport: %w", err) @@ -77,7 +88,11 @@ func (s *StdioServer) Run(ctx context.Context) error { transport = t } - fs := bundled.WrapFS(osvfs.FS()) + baseFS := s.options.FS + if baseFS == nil { + baseFS = osvfs.FS() + } + fs := bundled.WrapFS(baseFS) // Wrap the base FS when callbacks or an explicit case-sensitivity setting are requested. var callbackFS *callbackFS @@ -105,15 +120,14 @@ func (s *StdioServer) Run(ctx context.Context) error { }) defer session.Close() - // Accept connection from transport - rwc, err := transport.Accept() - if err != nil { - return fmt.Errorf("failed to accept connection: %w", err) - } - - // Create protocol and connection based on async mode var conn ipc.Conn - if s.options.Async { + if s.options.Tuples != nil { + syncConn := ipc.NewSyncConn(nil, NewTupleProtocol(s.options.Tuples), session) + syncConn.SetCollectTiming(s.options.CollectTiming) + conn = syncConn + } else if rwc, err := transport.Accept(); err != nil { + return fmt.Errorf("failed to accept connection: %w", err) + } else if s.options.Async { protocol := ipc.NewJSONRPCProtocol(rwc) asyncConn := ipc.NewAsyncConnWithProtocol(rwc, protocol, session) asyncConn.SetCollectTiming(s.options.CollectTiming) From e8464841b258660235c47ffd05ea512a977883f2 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:45:08 -0700 Subject: [PATCH 132/142] Allow injected async transports to await callback results The async client already lets module resolution callbacks return promises, but injected transports rejected them outright. A transport that can wait for the client, as a separate server process does, should be able to accept them; transports that cannot remain free to reject them. --- packages/typescript/src/api/async/transport.ts | 8 ++++---- packages/typescript/src/api/async/transportClient.ts | 2 +- packages/typescript/src/api/options.ts | 5 ++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/typescript/src/api/async/transport.ts b/packages/typescript/src/api/async/transport.ts index 55133c92cc283..e968da013e91a 100644 --- a/packages/typescript/src/api/async/transport.ts +++ b/packages/typescript/src/api/async/transport.ts @@ -16,11 +16,11 @@ export interface AsyncTransport { payload: Uint8Array, ): AsyncTransportResponse | PromiseLike>; /** - * Registers a callback invoked synchronously by the transport. - * The callback cannot return a Promise or make a nested request through a - * non-reentrant transport. + * Registers a callback the server invokes during a request. Transports + * that cannot wait for a Promise or accept nested requests from the + * callback must reject them. */ - registerCallback?(name: string, callback: (name: string, payload: string) => string): void; + registerCallback?(name: string, callback: (name: string, payload: string) => string | PromiseLike): void; unregisterCallback?(name: string): void; setFileSystem?(fs: FileSystemCallbacks | undefined): void; close(): void | PromiseLike; diff --git a/packages/typescript/src/api/async/transportClient.ts b/packages/typescript/src/api/async/transportClient.ts index f42ad6a17303a..95f0d1a9e1673 100644 --- a/packages/typescript/src/api/async/transportClient.ts +++ b/packages/typescript/src/api/async/transportClient.ts @@ -122,7 +122,7 @@ export class TransportClient { register.call(this.transport, name, (_, payload) => { const result = callback(JSON.parse(payload)); if (isPromiseLike(result)) { - throw new Error("Injected transport callbacks must complete synchronously"); + return result.then(value => JSON.stringify(value) ?? ""); } return JSON.stringify(result) ?? ""; }); diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 5546d66d1cf61..bb66b498bb926 100644 --- a/packages/typescript/src/api/options.ts +++ b/packages/typescript/src/api/options.ts @@ -76,9 +76,8 @@ export function isTransportOptions Date: Wed, 21 Oct 2026 18:45:08 -0700 Subject: [PATCH 133/142] Run the WebAssembly API server as a message exchange The reactor called back into JavaScript synchronously through a tunneled fd_write, so callbacks could not return promises, could not make nested requests, and only writeFile could be replaced. That made the WebAssembly API a weaker contract than the same client talking to a server process. The reactor now runs the ordinary synchronous server on a goroutine and exposes its messages through send and receive exports. The transport drives it with a stack of requests and callbacks, just as a client reads from a server pipe, so every callback the process server supports works the same way, including asynchronous and re-entrant ones. Receiving only runs Go until its next message and reports when the server is waiting instead, which keeps the single-threaded runtime from deadlocking. --- packages/typescript/src/wasm/transport.ts | 335 ++++++++++++++----- packages/typescript/src/wasm/wasi.ts | 177 ---------- packages/typescript/test/sync/wasm.test.ts | 62 +--- tsc/cmd/tsc/wasmapi_wasip1.go | 225 ++++--------- tsc/internal/api/wasmreactor/reactor.go | 223 ++++++++---- tsc/internal/api/wasmreactor/reactor_test.go | 66 ++-- tsc/internal/ipc/timing.go | 29 -- 7 files changed, 521 insertions(+), 596 deletions(-) diff --git a/packages/typescript/src/wasm/transport.ts b/packages/typescript/src/wasm/transport.ts index d688a78348821..e4721bd1950f0 100644 --- a/packages/typescript/src/wasm/transport.ts +++ b/packages/typescript/src/wasm/transport.ts @@ -1,26 +1,28 @@ export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import type { FileSystemCallbacks } from "../api/fs.ts"; +import { + configureFileSystemCallbacks, + encodeFileSystemCallbackResult, +} from "../api/fsCallbacks.ts"; import { encodeWtf8, Wtf8Decoder, } from "../api/node/wtf8.ts"; -import { - registerWasmCallback, - setWasmFileSystem, - unregisterWasmCallback, - type WasmFileSystem, -} from "./wasi.ts"; export interface WasmReactorExports { memory: { readonly buffer: ArrayBufferLike; }; create_session(optionsLength: number): number; + start_session(callbacksLength: number): number; close_session(): void; get_request_buffer(size: number): number; - handle_request(methodLength: number, payloadLength: number): number; + send_message(type: number, methodLength: number, payloadLength: number): number; + receive_message(): number; set_file(pathLength: number, contentLength: number): number; read_file(pathLength: number): number; remove_file(pathLength: number): number; response_ptr(): number; response_len(): number; + response_method_len(): number; } export interface WasmReactorInstance { @@ -32,38 +34,79 @@ export interface WasmTransportOptions { * An initialized reactor, normally created by `instantiateWasm` or * `instantiateWasmSync`. Other WASI hosts must call the module's * `typescript_initialize` export instead of `_start`, which runs the - * command line compiler, and cannot use the `fs` option or host - * callbacks such as custom file systems passed to the API. + * command line compiler. */ instance: WasmReactorInstance; cwd?: string | undefined; useCaseSensitiveFileNames?: boolean | undefined; collectTiming?: boolean | undefined; - fs?: WasmFileSystem | undefined; + /** + * Callbacks that replace operations on the reactor's in-memory file + * system, which `serverFS.useOS` refers to. + */ + fs?: FileSystemCallbacks | undefined; +} + +type TransportCallback = (name: string, payload: string) => string | PromiseLike; + +const MessageType = { + Request: 1, + CallResponse: 2, + CallError: 3, + Response: 4, + Error: 5, + Call: 6, +} as const; +type MessageType = typeof MessageType[keyof typeof MessageType]; + +const receiveClosed = 0; +const receiveFailed = 1; + +type Settlement = + | { ok: true; value: Uint8Array; } + | { ok: false; error: Error; }; + +interface RequestFrame { + kind: "request"; + method: string; + bytesSent: number; + settlement?: Settlement; + onSettle?: (settlement: Settlement) => void; } +interface CallFrame { + kind: "call"; + method: string; + reply?: { type: typeof MessageType.CallResponse | typeof MessageType.CallError; payload: Uint8Array; }; +} + +type Frame = RequestFrame | CallFrame; + const encoder = new TextEncoder(); const decoder = new TextDecoder(); const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); /** - * Synchronous API transport backed by an in-process TypeScript WebAssembly reactor. + * API transport backed by an in-process TypeScript WebAssembly reactor. * - * Host callbacks must complete synchronously and cannot call this transport while - * an outer request is in progress. + * The reactor runs the same server as a separate process would, so callbacks + * may return promises and may make nested requests through this transport. + * Requests made while callbacks are outstanding are nested within the + * innermost one, which must complete before outer callbacks can reply. */ export class WasmTransport { lastBytesSent = 0; lastBytesReceived = 0; - private readonly instance: WasmReactorInstance; private readonly exports: WasmReactorExports; + private readonly callbacks = new Map(); + private readonly fileSystemCallbacks = new Map(); + private fs: FileSystemCallbacks | undefined; + private readonly stack: Frame[] = []; + private started = false; private closed = false; - private inCallback = false; - private readonly callbackNames = new Set(); constructor(options: WasmTransportOptions) { - this.instance = options.instance; this.exports = getReactorExports(options.instance); const sessionOptions = encoder.encode(JSON.stringify({ cwd: options.cwd ?? "/", @@ -75,7 +118,7 @@ export class WasmTransport { throw new Error(`Failed to create TypeScript WASM session: ${this.readResponseText()}`); } try { - this.configureFileSystem(options.fs); + this.setFileSystem(options.fs); } catch (error) { this.exports.close_session(); @@ -83,50 +126,70 @@ export class WasmTransport { } } - setFileSystem(fs: WasmFileSystem | undefined): void { + /** + * Configures file system callbacks. They are validated immediately and + * read again when the first request starts the session. + */ + setFileSystem(fs: FileSystemCallbacks | undefined): void { this.ensureOpen(); - this.configureFileSystem(fs); + if (this.started) { + throw new Error("The TypeScript WASM file system must be configured before the first request"); + } + configureFileSystemCallbacks(fs); + this.fs = fs; } requestSync(method: string, payload: string): string { - return decoder.decode(this.call(method, encoder.encode(payload))); + return decoder.decode(this.requestBinarySync(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 { + const frame = this.startRequest(method, payload); + const settlement = frame.settlement; + if (!settlement) { + throw new Error(`TypeScript WASM request "${method}" is waiting for an asynchronous callback`); + } + return this.unwrap(frame, settlement); } - requestBinarySync(method: string, payload: Uint8Array): Uint8Array { - return this.call(method, payload); + request(method: string, payload: string): { value: string; bytesSent: number; bytesReceived: number; } | Promise<{ value: string; bytesSent: number; bytesReceived: number; }> { + const response = this.requestBinary(method, encoder.encode(payload)); + const decode = (response: { value: Uint8Array; bytesSent: number; bytesReceived: number; }) => ({ ...response, value: decoder.decode(response.value) }); + return response instanceof Promise ? response.then(decode) : decode(response); } - requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } { - const value = this.requestBinarySync(method, payload); - return { - value, - bytesSent: this.lastBytesSent, - bytesReceived: this.lastBytesReceived, + requestBinary(method: string, payload: Uint8Array): { value: Uint8Array; bytesSent: number; bytesReceived: number; } | Promise<{ value: Uint8Array; bytesSent: number; bytesReceived: number; }> { + const frame = this.startRequest(method, payload); + const toResponse = (settlement: Settlement) => { + const value = this.unwrap(frame, settlement); + return { value, bytesSent: frame.bytesSent, bytesReceived: value.length }; }; + if (frame.settlement) { + return toResponse(frame.settlement); + } + return new Promise((resolve, reject) => { + frame.onSettle = settlement => { + try { + resolve(toResponse(settlement)); + } + catch (error) { + reject(error); + } + }; + }); } - registerCallback(name: string, callback: (name: string, payload: string) => string): void { + registerCallback(name: string, callback: TransportCallback): void { this.ensureOpen(); - registerWasmCallback(this.instance, name, (callbackName, payload) => this.invokeCallback(() => callback(callbackName, payload))); - this.callbackNames.add(name); + this.callbacks.set(name, callback); } unregisterCallback(name: string): void { - if (!this.callbackNames.delete(name)) return; - unregisterWasmCallback(this.instance, name); + this.callbacks.delete(name); } setFile(path: string, content: string): void { - this.ensureCanEnterWasm(); + this.ensureOpen(); const pathBytes = encoder.encode(path); const contentBytes = encodeWtf8(content); this.writeRequest(pathBytes, contentBytes); @@ -137,7 +200,7 @@ export class WasmTransport { /** Read a file from the reactor's in-memory filesystem. */ readFile(path: string): string | undefined { - this.ensureCanEnterWasm(); + this.ensureOpen(); const pathBytes = encoder.encode(path); this.writeRequest(pathBytes); const status = this.exports.read_file(pathBytes.length); @@ -149,7 +212,7 @@ export class WasmTransport { } removeFile(path: string): void { - this.ensureCanEnterWasm(); + this.ensureOpen(); const pathBytes = encoder.encode(path); this.writeRequest(pathBytes); if (this.exports.remove_file(pathBytes.length) !== 0) { @@ -159,32 +222,143 @@ export class WasmTransport { close(): void { if (this.closed) return; - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot close the same API transport"); - } this.closed = true; try { this.exports.close_session(); } finally { - for (const name of this.callbackNames) { - unregisterWasmCallback(this.instance, name); + this.callbacks.clear(); + this.fileSystemCallbacks.clear(); + this.fs = undefined; + this.abort(new Error("The TypeScript WASM transport is closed")); + } + } + + private startRequest(method: string, payload: Uint8Array): RequestFrame { + this.ensureOpen(); + this.ensureStarted(); + const frame: RequestFrame = { kind: "request", method, bytesSent: payload.length }; + this.stack.push(frame); + this.send(MessageType.Request, method, payload); + this.pump(); + return frame; + } + + private unwrap(frame: RequestFrame, settlement: Settlement): Uint8Array { + if (!settlement.ok) throw settlement.error; + this.lastBytesSent = frame.bytesSent; + this.lastBytesReceived = settlement.value.length; + return settlement.value; + } + + /** + * Exchanges messages with the server until the innermost frame is a + * callback that has not yet produced its reply, or no frames remain. + */ + private pump(): void { + while (this.stack.length > 0) { + const top = this.stack[this.stack.length - 1]; + if (top.kind === "call") { + if (!top.reply) return; + this.stack.pop(); + this.send(top.reply.type, top.method, top.reply.payload); + continue; + } + + const type = this.exports.receive_message(); + switch (type) { + case MessageType.Response: + this.settle(top, { ok: true, value: this.readResponsePayload() }); + break; + case MessageType.Error: + this.settle(top, { ok: false, error: new Error(`TypeScript WASM request "${top.method}" failed: ${this.readResponsePayloadText()}`) }); + break; + case MessageType.Call: + this.invokeCallback(this.readResponseMethod(), this.readResponsePayloadText()); + break; + case receiveClosed: + this.abort(new Error("The TypeScript WASM server stopped")); + return; + case receiveFailed: + default: + this.abort(new Error(`TypeScript WASM transport failed: ${type === receiveFailed ? this.readResponseText() : `unexpected message type ${type}`}`)); + return; } - this.callbackNames.clear(); - setWasmFileSystem(this.instance, undefined); } } - private call(method: string, payload: Uint8Array): Uint8Array { - this.ensureCanEnterWasm(); + private settle(frame: RequestFrame, settlement: Settlement): void { + this.stack.splice(this.stack.indexOf(frame), 1); + frame.settlement = settlement; + frame.onSettle?.(settlement); + } + + private invokeCallback(method: string, payload: string): void { + const frame: CallFrame = { kind: "call", method }; + this.stack.push(frame); + const succeed = (result: string) => { + frame.reply = { type: MessageType.CallResponse, payload: encoder.encode(result) }; + }; + const fail = (error: unknown) => { + frame.reply = { type: MessageType.CallError, payload: encoder.encode(error instanceof Error ? error.message : String(error)) }; + }; + let result: string | PromiseLike; + try { + const callback = this.fileSystemCallbacks.get(method) ?? this.callbacks.get(method); + if (!callback) { + throw new Error(`No callback is registered for ${method}`); + } + result = callback(method, payload); + } + catch (error) { + fail(error); + return; + } + if (typeof result === "string") { + succeed(result); + return; + } + result.then(succeed, fail).then(() => { + if (this.stack.includes(frame)) this.pump(); + }); + } + + private abort(error: Error): void { + const frames = this.stack.splice(0); + for (const frame of frames) { + if (frame.kind === "request" && !frame.settlement) { + frame.settlement = { ok: false, error }; + frame.onSettle?.(frame.settlement); + } + } + } + + private ensureStarted(): void { + if (this.started) return; + const fs = this.fs; + const configuration = configureFileSystemCallbacks(fs); + for (const name of configuration.callbackNames) { + const callback = fs![name] as (...args: any[]) => unknown; + this.fileSystemCallbacks.set(name, (_, payload) => { + const arg = JSON.parse(payload); + const result = name === "writeFile" ? callback.call(fs, arg.path, arg.data) : callback.call(fs, arg); + return JSON.stringify(encodeFileSystemCallbackResult(name, result)); + }); + } + const callbacks = encoder.encode(JSON.stringify(configuration.arguments)); + this.writeRequest(callbacks); + if (this.exports.start_session(callbacks.length) !== 0) { + throw new Error(`Failed to start TypeScript WASM session: ${this.readResponseText()}`); + } + this.started = true; + } + + private send(type: MessageType, method: string, payload: Uint8Array): void { const methodBytes = encoder.encode(method); this.writeRequest(methodBytes, payload); - this.lastBytesSent = payload.length; - if (this.exports.handle_request(methodBytes.length, payload.length) !== 0) { - throw new Error(`TypeScript WASM request "${method}" failed: ${this.readResponseText()}`); + if (this.exports.send_message(type, methodBytes.length, payload.length) !== 0) { + this.abort(new Error(`TypeScript WASM transport failed: ${this.readResponseText()}`)); } - this.lastBytesReceived = this.exports.response_len(); - return this.readResponseBytes(); } private writeRequest(first: Uint8Array, second?: Uint8Array): void { @@ -201,6 +375,18 @@ export class WasmTransport { return decoder.decode(this.readResponseBytes()); } + private readResponseMethod(): string { + return decoder.decode(this.readResponseBytes().subarray(0, this.exports.response_method_len())); + } + + private readResponsePayloadText(): string { + return decoder.decode(this.readResponsePayload()); + } + + private readResponsePayload(): Uint8Array { + return this.readResponseBytes().subarray(this.exports.response_method_len()); + } + private readResponseBytes(): Uint8Array { const length = this.exports.response_len(); if (length === 0) return new Uint8Array(); @@ -212,48 +398,23 @@ export class WasmTransport { throw new Error("The TypeScript WASM transport is closed"); } } - - private ensureCanEnterWasm(): void { - this.ensureOpen(); - if (this.inCallback) { - throw new Error("TypeScript WASM callbacks cannot call the same API transport"); - } - } - - private configureFileSystem(fs: WasmFileSystem | undefined): void { - const writeFile = fs?.writeFile; - setWasmFileSystem( - this.instance, - typeof writeFile === "function" - ? { writeFile: (path, data) => this.invokeCallback(() => writeFile(path, data)) } - : fs, - ); - } - - private invokeCallback(callback: () => T): T { - const previous = this.inCallback; - this.inCallback = true; - try { - return callback(); - } - finally { - this.inCallback = previous; - } - } } function getReactorExports(instance: WasmReactorInstance): WasmReactorExports { const exports = instance.exports as unknown as Partial; const required = [ "create_session", + "start_session", "close_session", "get_request_buffer", - "handle_request", + "send_message", + "receive_message", "set_file", "read_file", "remove_file", "response_ptr", "response_len", + "response_method_len", ] as const; const missing: string[] = required.filter(name => typeof exports[name] !== "function"); if (exports.memory == null || typeof exports.memory !== "object" || !("buffer" in exports.memory)) { diff --git a/packages/typescript/src/wasm/wasi.ts b/packages/typescript/src/wasm/wasi.ts index 50482ea1f54e5..24838fe703051 100644 --- a/packages/typescript/src/wasm/wasi.ts +++ b/packages/typescript/src/wasm/wasi.ts @@ -1,10 +1,3 @@ -import type { FileSystemCallbacks } from "../api/fs.ts"; -import { - encodeFileSystemCallbackResult, - serverFS, -} from "../api/fsCallbacks.ts"; -import { Wtf8Decoder } from "../api/node/wtf8.ts"; -import type { RootedFilePath } from "../ast/index.ts"; import type { WasmReactorInstance } from "./transport.ts"; declare namespace WebAssembly { @@ -42,59 +35,16 @@ interface StreamingTextDecoder { const errnoSuccess = 0; const errnoBadFileDescriptor = 8; const errnoInvalidArgument = 28; -const errnoIo = 29; -const errnoNoBuffers = 42; const errnoNoSys = 52; const fileTypeCharacterDevice = 2; const eventTypeClock = 0; const subscriptionClockAbstime = 1; -const hostCallbackFD = 0x7fff_fffd; -const hostWriteFileFD = 0x7fff_fffe; -const textDecoder = new TextDecoder(); -const fileContentDecoder = new Wtf8Decoder("utf-8", { ignoreBOM: true }); export interface InstantiateWasmOptions { stdout?: ((text: string) => void) | undefined; stderr?: ((text: string) => void) | undefined; } -export interface WasmFileSystem { - writeFile?: FileSystemCallbacks["writeFile"] | undefined; -} - -interface WasmHost { - setFileSystem(fs: WasmFileSystem | undefined): void; - registerCallback(name: string, callback: (name: string, payload: string) => string): void; - unregisterCallback(name: string): void; -} - -const wasmHosts = new WeakMap(); - -export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { - const host = wasmHosts.get(instance); - if (!host) { - if (fs === undefined) return; - throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); - } - host.setFileSystem(fs); -} - -export function registerWasmCallback( - instance: WasmReactorInstance, - name: string, - callback: (name: string, payload: string) => string, -): void { - const host = wasmHosts.get(instance); - if (!host) { - throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); - } - host.registerCallback(name, callback); -} - -export function unregisterWasmCallback(instance: WasmReactorInstance, name: string): void { - wasmHosts.get(instance)?.unregisterCallback(name); -} - /** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ export async function instantiateWasm( module: WebAssembly.Module, @@ -128,11 +78,7 @@ function createWasiHost(options: InstantiateWasmOptions): { const stdout = options.stdout ?? (text => console.log(text)); const stderr = options.stderr ?? (text => console.error(text)); const decoders = new Map(); - const encoder = new TextEncoder(); - const callbacks = new Map string>(); - let pendingCallbackResult: Uint8Array | undefined; const closedDescriptors = new Set(); - let fileSystem: WasmFileSystem | undefined; function getMemory(): WebAssembly.Memory { const memory = instance?.exports.memory; @@ -198,12 +144,6 @@ function createWasiHost(options: InstantiateWasmOptions): { function fdWrite(fd: number, iovsPointer: number, iovsLength: number, writtenPointer: number): number { iovsPointer >>>= 0; writtenPointer >>>= 0; - if (fd === hostWriteFileFD) { - return hostWriteFile(iovsPointer, iovsLength, writtenPointer); - } - if (fd === hostCallbackFD) { - return hostCallback(iovsPointer, iovsLength, writtenPointer); - } if ((fd !== 1 && fd !== 2) || closedDescriptors.has(fd)) return errnoBadFileDescriptor; const memory = getMemory(); const view = new DataView(memory.buffer); @@ -242,76 +182,6 @@ function createWasiHost(options: InstantiateWasmOptions): { return fd >= 0 && fd <= 2 && !closedDescriptors.has(fd); } - function hostCallback(iovsPointer: number, iovsLength: number, writtenPointer: number): number { - if (iovsLength !== 1) return errnoInvalidArgument; - const memory = getMemory(); - const view = new DataView(memory.buffer); - const bufferPointer = view.getUint32(iovsPointer, true); - const bufferLength = view.getUint32(iovsPointer + 4, true); - if (bufferLength < 16) return errnoInvalidArgument; - const methodLength = view.getUint32(bufferPointer, true); - const payloadLength = view.getUint32(bufferPointer + 4, true); - const resultCapacity = view.getUint32(bufferPointer + 8, true); - const errorCapacity = view.getUint32(bufferPointer + 12, true); - if (16 + methodLength + payloadLength + resultCapacity + errorCapacity > bufferLength) { - return errnoInvalidArgument; - } - const methodPointer = bufferPointer + 16; - const payloadPointer = methodPointer + methodLength; - const resultPointer = payloadPointer + payloadLength; - const errorPointer = resultPointer + resultCapacity; - const decoder = new TextDecoder(); - const method = decoder.decode(new Uint8Array(memory.buffer, methodPointer, methodLength)); - const payload = decoder.decode(new Uint8Array(memory.buffer, payloadPointer, payloadLength)); - const pending = pendingCallbackResult; - pendingCallbackResult = undefined; - try { - let result: Uint8Array; - if (methodLength === 0) { - // An empty method fetches a result that did not fit the previous call's buffer. - if (!pending || pending.length > resultCapacity) return errnoInvalidArgument; - result = pending; - } - else { - const callback = callbacks.get(method); - if (!callback) { - throw new Error(`No callback is registered for ${method}`); - } - result = encoder.encode(callback(method, payload)); - if (result.length > resultCapacity) { - pendingCallbackResult = result; - const resultView = new DataView(getMemory().buffer); - resultView.setUint32(bufferPointer + 8, result.length, true); - resultView.setUint32(bufferPointer + 12, 0, true); - resultView.setUint32(writtenPointer, 0, true); - return errnoNoBuffers; - } - } - const resultMemory = getMemory(); - const resultView = new DataView(resultMemory.buffer); - new Uint8Array(resultMemory.buffer, resultPointer, result.length).set(result); - resultView.setUint32(bufferPointer + 8, result.length, true); - resultView.setUint32(bufferPointer + 12, 0, true); - resultView.setUint32(writtenPointer, bufferLength, true); - return errnoSuccess; - } - catch (error) { - const errorView = new DataView(getMemory().buffer); - errorView.setUint32(bufferPointer + 8, 0, true); - errorView.setUint32(bufferPointer + 12, writeHostError(error, errorPointer, errorCapacity), true); - errorView.setUint32(writtenPointer, 0, true); - return errnoIo; - } - } - - /** Writes as much of the error message as fits and returns its length. */ - function writeHostError(error: unknown, pointer: number, capacity: number): number { - const bytes = encoder.encode(error instanceof Error ? error.message : String(error)); - const length = Math.min(bytes.length, capacity); - new Uint8Array(getMemory().buffer, pointer, length).set(bytes.subarray(0, length)); - return length; - } - function randomGet(bufferPointer: number, bufferLength: number): number { bufferPointer >>>= 0; const buffer = new Uint8Array(getMemory().buffer, bufferPointer, bufferLength); @@ -321,42 +191,6 @@ function createWasiHost(options: InstantiateWasmOptions): { return errnoSuccess; } - function hostWriteFile(iovsPointer: number, iovsLength: number, writtenPointer: number): number { - iovsPointer >>>= 0; - writtenPointer >>>= 0; - const writeFile = fileSystem?.writeFile; - if (writeFile === undefined || writeFile === serverFS.useOS) 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 pathPointer = bufferPointer + 12; - const dataPointer = pathPointer + pathLength; - const errorPointer = dataPointer + dataLength; - const path = textDecoder.decode(new Uint8Array(memory.buffer, pathPointer, pathLength)); - const data = fileContentDecoder.decode(new Uint8Array(memory.buffer, dataPointer, dataLength)); - try { - const result = typeof writeFile === "function" ? writeFile(path as RootedFilePath, data) : writeFile; - const response = encodeFileSystemCallbackResult("writeFile", result); - if (response.kind === "useOS") return errnoBadFileDescriptor; - if (response.kind === "error") throw new Error("filesystem callback returned serverFS.error: writeFile"); - new DataView(getMemory().buffer).setUint32(writtenPointer, bufferLength, true); - return errnoSuccess; - } - catch (error) { - const errorView = new DataView(getMemory().buffer); - errorView.setUint32(bufferPointer + 8, writeHostError(error, errorPointer, errorCapacity), true); - errorView.setUint32(writtenPointer, 0, true); - return errnoIo; - } - } - function pollOneoff( subscriptionsPointer: number, eventsPointer: number, @@ -450,17 +284,6 @@ function createWasiHost(options: InstantiateWasmOptions): { throw new Error("TypeScript WASM reactor did not export typescript_initialize"); } initialize(); - wasmHosts.set(instance, { - setFileSystem(value) { - fileSystem = value; - }, - registerCallback(name, callback) { - callbacks.set(name, callback); - }, - unregisterCallback(name) { - callbacks.delete(name); - }, - }); return instance; }, }; diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index d387a0b8b5983..2ee631e26e2d0 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -270,36 +270,21 @@ describe("API over WebAssembly", () => { assert.ok(!WebAssembly.Module.exports(module).some((value: { name: string; }) => value.name === "_initialize")); const instance = await instantiateWasm(module, { stdout: undefined, stderr: undefined }); - assert.throws( - () => - new WasmTransport({ - instance: { exports: instance.exports }, - cwd: undefined, - useCaseSensitiveFileNames: undefined, - collectTiming: undefined, - fs: { writeFile() {} }, - }), - /was not created by instantiateWasm/, - ); - const emittedFiles = new Map(); - const memory = Reflect.get(instance.exports, "memory"); - assert.ok(memory instanceof WebAssembly.Memory); - let transport!: WasmTransport; - transport = new WasmTransport({ + const transport: WasmTransport = new WasmTransport({ instance, cwd: "/", fs: { + directoryExists: serverFS.useOS, + fileExists: serverFS.useOS, + getAccessibleEntries: serverFS.useOS, + readFile: serverFS.useOS, + realpath: serverFS.useOS, + stat: serverFS.useOS, + removeFile: serverFS.useOS, writeFile(path, data) { - memory.grow(0); - assert.throws( - () => transport.close(), - /TypeScript WASM callbacks cannot close the same API transport/, - ); - assert.throws( - () => transport.readFile("/src/index.ts"), - /TypeScript WASM callbacks cannot call the same API transport/, - ); + // Callbacks may make nested requests, as with a separate server process. + assert.strictEqual(transport.requestSync("echo", "nested"), "nested"); emittedFiles.set(path, data); }, }, @@ -328,11 +313,7 @@ describe("API over WebAssembly", () => { const project = snapshot.getDefaultProjectForFile("/src/index.ts"); assert.ok(project); assert.throws( - () => - new WasmTransport({ - instance, - fs: { writeFile() {} }, - }), + () => new WasmTransport({ instance }), /session already created/, ); assert.deepStrictEqual(project.program.emit(), { @@ -344,26 +325,11 @@ describe("API over WebAssembly", () => { assert.strictEqual(emittedFiles.get("/src/index.js"), "\uFEFFexport const value = 42;\n"); assert.strictEqual(transport.requestSync("echo", "after callback"), "after callback"); - transport.setFileSystem({ writeFile: () => serverFS.useOS }); - project.program.emit(); - assert.strictEqual(transport.readFile("/src/index.js"), "\uFEFFexport const value = 42;\n"); - transport.setFileSystem({ writeFile: serverFS.error }); - assert.match( - project.program.emit().diagnostics[0]?.text ?? "", - /Could not write file '\/src\/index\.js': filesystem callback returned serverFS\.error/, + assert.throws( + () => transport.setFileSystem(undefined), + /must be configured before the first request/, ); - const largeResolution = { resolvedFileName: "/large.d.ts", padding: "x".repeat(100_000) }; - let resolverCalls = 0; - const resolver = api.createModuleResolver({}, { - resolveModuleName: () => { - resolverCalls++; - return largeResolution; - }, - }); - assert.strictEqual(resolver.resolveModuleName("large", "/src", undefined).resolvedModule?.resolvedFileName, "/large.d.ts"); - assert.strictEqual(resolverCalls, 1); - api.close(); const secondTransport = new WasmTransport({ instance, cwd: "/" }); diff --git a/tsc/cmd/tsc/wasmapi_wasip1.go b/tsc/cmd/tsc/wasmapi_wasip1.go index 0d0cd62b6d355..6bc9207004cfb 100644 --- a/tsc/cmd/tsc/wasmapi_wasip1.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -4,32 +4,25 @@ package main import ( "context" - "encoding/binary" "errors" "fmt" + "io" "os" "runtime/debug" - "slices" - "sync" "unsafe" + "github.com/microsoft/TypeScript/tsc/internal/api" "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" - "github.com/microsoft/TypeScript/tsc/internal/ipc" "github.com/microsoft/TypeScript/tsc/internal/json" - "github.com/microsoft/TypeScript/tsc/internal/tspath" - "github.com/microsoft/TypeScript/tsc/internal/vfs" - "github.com/microsoft/TypeScript/tsc/internal/vfs/wrapvfs" ) +// Statuses returned by exports. receive_message otherwise returns the +// api.MessageType of the message it received. const ( - // API callbacks are tunneled through reserved descriptors so that - // the module only requires standard WASI imports and remains directly runnable. - hostWriteFileFD = 0x7fff_fffe - hostCallbackFD = 0x7fff_fffd - hostErrorCapacity = 16 * 1024 - hostResultCapacity = 64 * 1024 - errnoBadFileNumber = 8 - errnoNoBuffers = 42 + statusOK = 0 + statusFailed = 1 + statusAbsent = 2 + statusClosed = 0 ) var ( @@ -37,15 +30,10 @@ var ( requestBuffer []byte - responseBuffer []byte - responsePtr uint32 - responseLen uint32 - - // hostCallbackMu serializes host callback exchanges, which share - // hostCallbackBuffer and the host's single pending-result slot. - hostCallbackMu sync.Mutex - hostCallbackBuffer []byte - inCall bool + responseBuffer []byte + responsePtr uint32 + responseLen uint32 + responseMethodLen uint32 ) //go:wasmexport create_session @@ -67,21 +55,24 @@ func createSession(optionsLen uint32) (status uint32) { if err := json.Unmarshal(requestBuffer[:optionsLen], &options); err != nil { return fail(fmt.Sprintf("invalid session options: %v", err)) } - options.Conn = hostCallbackConn{} - options.WrapFS = func(files vfs.FS) vfs.FS { - return wrapvfs.Wrap(files, wrapvfs.Replacements{ - WriteFile: func(path tspath.RootedFilePath, data string) error { - handled, err := writeFileToHost(path.AsString(), data) - if !handled { - return files.WriteFile(path, data) - } - return err - }, - }) - } reactor = wasmreactor.New(context.Background(), options) setResponse(nil) - return 0 + return statusOK +} + +//go:wasmexport start_session +func startSession(callbacksLen uint32) uint32 { + return guarded("starting session", callbacksLen, 0, func() uint32 { + var callbacks []string + if err := json.Unmarshal(requestBuffer[:callbacksLen], &callbacks); err != nil { + return fail(fmt.Sprintf("invalid callbacks: %v", err)) + } + if err := reactor.Start(callbacks); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return statusOK + }) } //go:wasmexport close_session @@ -106,17 +97,39 @@ func getRequestBuffer(size uint32) uint32 { return bytesPointer(requestBuffer) } -//go:wasmexport handle_request -func handleRequest(methodLen uint32, payloadLen uint32) uint32 { - return guarded("handling request", methodLen, payloadLen, func() uint32 { - method := string(requestBuffer[:methodLen]) - payload := append([]byte(nil), requestBuffer[methodLen:methodLen+payloadLen]...) - response, err := reactor.HandleRequest(method, payload) +//go:wasmexport send_message +func sendMessage(messageType uint32, methodLen uint32, payloadLen uint32) uint32 { + return guarded("sending message", methodLen, payloadLen, func() uint32 { + message := wasmreactor.Message{ + Type: api.MessageType(messageType), + Method: string(requestBuffer[:methodLen]), + Payload: append([]byte(nil), requestBuffer[methodLen:methodLen+payloadLen]...), + } + if err := reactor.Send(message); err != nil { + return fail(err.Error()) + } + setResponse(nil) + return statusOK + }) +} + +//go:wasmexport receive_message +func receiveMessage() uint32 { + return guarded("receiving message", 0, 0, func() uint32 { + message, err := reactor.Receive() + if errors.Is(err, io.EOF) { + setResponse(nil) + return statusClosed + } if err != nil { return fail(err.Error()) } - setResponse(response) - return 0 + buffer := make([]byte, 0, len(message.Method)+len(message.Payload)) + buffer = append(buffer, message.Method...) + buffer = append(buffer, message.Payload...) + setResponse(buffer) + responseMethodLen = uint32(len(message.Method)) + return uint32(message.Type) }) } @@ -129,7 +142,7 @@ func setFile(pathLen uint32, contentLen uint32) uint32 { return fail(err.Error()) } setResponse(nil) - return 0 + return statusOK }) } @@ -139,10 +152,10 @@ func readFile(pathLen uint32) uint32 { content, ok := reactor.ReadFile(string(requestBuffer[:pathLen])) if !ok { setResponse(nil) - return 2 + return statusAbsent } setResponse([]byte(content)) - return 0 + return statusOK }) } @@ -153,26 +166,20 @@ func removeFile(pathLen uint32) uint32 { return fail(err.Error()) } setResponse(nil) - return 0 + return statusOK }) } // guarded runs an export that operates on the session and the first+second -// bytes of the request buffer, rejecting re-entrant calls and reporting panics -// as failures. +// bytes of the request buffer, reporting panics as failures. func guarded(operation string, first uint32, second uint32, run func() uint32) (status uint32) { - if inCall { - return fail(operation + ": re-entrant call while a request is in flight") - } if reactor == nil { return fail("no session: create_session must be called first") } if !withinRequest(first, second) { return fail(operation + ": lengths exceed the request buffer") } - inCall = true defer func() { - inCall = false if recovered := recover(); recovered != nil { status = fail(fmt.Sprintf("panic %s: %v\n%s", operation, recovered, debug.Stack())) } @@ -190,6 +197,11 @@ func responseLength() uint32 { return responseLen } +//go:wasmexport response_method_len +func responseMethodLength() uint32 { + return responseMethodLen +} + //go:wasmexport __typescript_cli_start func cliStart() { os.Exit(runMain()) @@ -197,13 +209,14 @@ func cliStart() { func fail(message string) uint32 { setResponse([]byte(message)) - return 1 + return statusFailed } func setResponse(data []byte) { responseBuffer = data responsePtr = bytesPointer(responseBuffer) responseLen = uint32(len(responseBuffer)) + responseMethodLen = 0 } func withinRequest(first uint32, second uint32) bool { @@ -216,101 +229,3 @@ func bytesPointer(bytes []byte) uint32 { } return uint32(uintptr(unsafe.Pointer(&bytes[0]))) } - -type hostCallbackConn struct{} - -var _ ipc.Conn = hostCallbackConn{} - -func (hostCallbackConn) Run(ctx context.Context) error { - <-ctx.Done() - return ctx.Err() -} - -func (hostCallbackConn) Call(_ context.Context, method string, params any) (json.Value, error) { - paramsBytes, err := json.Marshal(params) - if err != nil { - return nil, err - } - hostCallbackMu.Lock() - defer hostCallbackMu.Unlock() - bufferSize := 16 + len(method) + len(paramsBytes) + hostResultCapacity + hostErrorCapacity - if cap(hostCallbackBuffer) < bufferSize { - hostCallbackBuffer = make([]byte, bufferSize) - } - result, errno, err := callHost(hostCallbackBuffer[:bufferSize], method, paramsBytes, hostResultCapacity) - if errno == errnoNoBuffers { - // The host retains a result that did not fit; fetch it into a buffer of - // the reported size rather than invoking the callback again. - size := int(binary.LittleEndian.Uint32(hostCallbackBuffer[8:])) - result, _, err = callHost(make([]byte, 16+size+hostErrorCapacity), "", nil, size) - } - if err != nil { - return nil, err - } - return json.Value(slices.Clone(result)), nil -} - -func callHost(buffer []byte, method string, params []byte, resultCapacity int) ([]byte, uint32, error) { - binary.LittleEndian.PutUint32(buffer, uint32(len(method))) - binary.LittleEndian.PutUint32(buffer[4:], uint32(len(params))) - binary.LittleEndian.PutUint32(buffer[8:], uint32(resultCapacity)) - binary.LittleEndian.PutUint32(buffer[12:], hostErrorCapacity) - copy(buffer[16:], method) - copy(buffer[16+len(method):], params) - - iov := [2]uint32{bytesPointer(buffer), uint32(len(buffer))} - var written uint32 - errno := hostFDWrite(hostCallbackFD, unsafe.Pointer(&iov[0]), 1, &written) - switch errno { - case 0: - resultStart := 16 + len(method) + len(params) - resultLength := min(int(binary.LittleEndian.Uint32(buffer[8:])), resultCapacity) - return buffer[resultStart : resultStart+resultLength], 0, nil - case errnoNoBuffers: - return nil, errno, nil - case errnoBadFileNumber: - return nil, errno, errors.New("host callbacks are not supported by this WebAssembly host") - } - return nil, errno, hostError(buffer[16+len(method)+len(params)+resultCapacity:], binary.LittleEndian.Uint32(buffer[12:]), "host callback failed") -} - -// hostError reads the error message a host wrote into the error region of an -// exchange buffer. -func hostError(region []byte, length uint32, fallback string) error { - message := string(region[:min(int(length), hostErrorCapacity)]) - if message == "" { - message = fallback - } - return errors.New(message) -} - -func (hostCallbackConn) Notify(context.Context, string, any) error { - return errors.New("host callback notifications are not supported") -} - -func writeFileToHost(path string, data string) (bool, error) { - pathBytes := []byte(path) - dataBytes := []byte(data) - buffer := make([]byte, 12+len(pathBytes)+len(dataBytes)+hostErrorCapacity) - binary.LittleEndian.PutUint32(buffer, uint32(len(pathBytes))) - binary.LittleEndian.PutUint32(buffer[4:], uint32(len(dataBytes))) - binary.LittleEndian.PutUint32(buffer[8:], hostErrorCapacity) - copy(buffer[12:], pathBytes) - copy(buffer[12+len(pathBytes):], dataBytes) - - iov := [2]uint32{bytesPointer(buffer), uint32(len(buffer))} - var written uint32 - errno := hostFDWrite(hostWriteFileFD, unsafe.Pointer(&iov[0]), 1, &written) - if errno == errnoBadFileNumber { - return false, nil - } - if errno == 0 { - return true, nil - } - - return true, hostError(buffer[12+len(pathBytes)+len(dataBytes):], binary.LittleEndian.Uint32(buffer[8:]), "host filesystem callback failed") -} - -//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 index b0bda014bb27a..c161e1c0bf07a 100644 --- a/tsc/internal/api/wasmreactor/reactor.go +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -1,110 +1,153 @@ -// Package wasmreactor hosts an API session without an IPC transport. +// Package wasmreactor runs an API server inside the current process and +// exchanges its protocol messages through direct calls instead of a pipe. package wasmreactor import ( "context" - "time" + "errors" + "io" + "sync" "github.com/microsoft/TypeScript/tsc/internal/api" "github.com/microsoft/TypeScript/tsc/internal/bundled" - "github.com/microsoft/TypeScript/tsc/internal/ipc" - "github.com/microsoft/TypeScript/tsc/internal/json" - "github.com/microsoft/TypeScript/tsc/internal/lsp/lsproto" - "github.com/microsoft/TypeScript/tsc/internal/project" "github.com/microsoft/TypeScript/tsc/internal/tspath" - "github.com/microsoft/TypeScript/tsc/internal/vfs" ) type Options struct { - Cwd string `json:"cwd"` - UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` - CollectTiming bool `json:"collectTiming"` - WrapFS func(vfs.FS) vfs.FS `json:"-"` - Conn ipc.Conn `json:"-"` + Cwd string `json:"cwd"` + UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` + CollectTiming bool `json:"collectTiming"` } +// Message is a protocol tuple exchanged with the server. +type Message struct { + Type api.MessageType + Method string + Payload []byte +} + +// ErrIdle is returned by Receive when the server is waiting for a message +// instead of producing one. +var ErrIdle = errors.New("the server is waiting for a message") + +// Reactor hosts an API server whose base file system is in memory. The +// server runs on its own goroutine and is driven by Send and Receive, so the +// client can serve callbacks and make nested requests exactly as it would +// over a pipe, even on single-threaded hosts where blocking with no runnable +// goroutine would be fatal. type Reactor struct { ctx context.Context cancel context.CancelFunc - session *api.Session - files vfs.FS + options Options + files *memFS cwd tspath.RootedDirectoryPath - timing *ipc.ServerTimingCollector + + mu sync.Mutex + inbox chan Message + outbox chan Message + idle chan struct{} + closed chan struct{} + started bool + done chan struct{} + err error } func New(ctx context.Context, options Options) *Reactor { if options.Cwd == "" { options.Cwd = "/" } - cwd := tspath.RootedDirectoryPathFromAbsolute(options.Cwd) caseSensitivity := tspath.CaseSensitive if options.UseCaseSensitiveFileNames != nil && !*options.UseCaseSensitiveFileNames { caseSensitivity = tspath.CaseInsensitive } - files := newMemFS(caseSensitivity) - var projectFiles vfs.FS = files - if options.WrapFS != nil { - projectFiles = options.WrapFS(projectFiles) - } ctx, cancel := context.WithCancel(ctx) - sessionInit := &project.SessionInit{ - BackgroundCtx: ctx, - FS: bundled.WrapFS(projectFiles), - Options: &project.SessionOptions{ - CurrentDirectory: cwd, - DefaultLibraryPath: bundled.LibPath(), - PositionEncoding: lsproto.PositionEncodingKindUTF8, - LoggingEnabled: false, - }, - } - session := api.NewStandaloneSession(sessionInit, &api.SessionOptions{UseBinaryResponses: true}) - if options.Conn != nil { - session.SetConnection(options.Conn) - } - reactor := &Reactor{ + return &Reactor{ ctx: ctx, cancel: cancel, - session: session, - files: files, - cwd: cwd, - } - if options.CollectTiming { - reactor.timing = ipc.NewServerTimingCollector() + options: options, + files: newMemFS(caseSensitivity), + cwd: tspath.RootedDirectoryPathFromAbsolute(options.Cwd), + inbox: make(chan Message, 1), + outbox: make(chan Message), + idle: make(chan struct{}, 1), + closed: make(chan struct{}), + done: make(chan struct{}), } - return reactor } -func (r *Reactor) HandleRequest(method string, payload []byte) ([]byte, 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 runs the server, delegating the named file system operations to the +// client. It must be called once, before the first Send. +func (r *Reactor) Start(callbacks []string) error { + if r.started { + return errors.New("the server has already started") } + r.started = true + server := api.NewStdioServer(&api.StdioServerOptions{ + Cwd: r.cwd, + DefaultLibraryPath: bundled.LibPath(), + Callbacks: callbacks, + UseCaseSensitiveFileNames: r.options.UseCaseSensitiveFileNames, + CollectTiming: r.options.CollectTiming, + FS: r.files, + Tuples: (*tupleTransport)(r), + }) + go func() { + defer close(r.done) + r.err = server.Run(r.ctx) + }() + return nil +} + +// Started reports whether Start has been called. +func (r *Reactor) Started() bool { + return r.started +} - 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)) +// Send delivers a message to the server without running it. +func (r *Reactor) Send(message Message) error { + if !r.started { + return errors.New("the server has not started") } - if err != nil { - return nil, err + r.mu.Lock() + defer r.mu.Unlock() + select { + case <-r.idle: + // The server is about to receive this message, so it is no longer idle. + default: + // The server has not reported being idle. } - if raw, ok := result.(api.RawBinary); ok { - return []byte(raw), nil + select { + case r.inbox <- message: + return nil + default: + return errors.New("a message is already waiting for the server") } - return marshalResponse(result) } -func marshalResponse(result any) ([]byte, error) { - encoded, err := json.Marshal(result) - if err != nil { - return nil, err +// Receive runs the server until it produces its next message. It returns +// ErrIdle if the server needs a message first, or io.EOF with the server's +// result once it has stopped. +func (r *Reactor) Receive() (Message, error) { + if !r.started { + return Message{}, errors.New("the server has not started") + } + select { + case message := <-r.outbox: + return message, nil + default: + // Run the server until it writes, waits, or stops. + } + select { + case message := <-r.outbox: + return message, nil + case <-r.idle: + return Message{}, ErrIdle + case <-r.done: + if r.err != nil { + return Message{}, r.err + } + return Message{}, io.EOF } - return encoded, nil } func (r *Reactor) SetFile(path string, content string) error { @@ -119,7 +162,53 @@ func (r *Reactor) RemoveFile(path string) error { return r.files.Remove(tspath.ToRootedPath(path, r.cwd)) } +// Close stops the server and waits for it to exit. func (r *Reactor) Close() { + select { + case <-r.closed: + return + default: + // Not yet closed. + } r.cancel() - r.session.Close() + close(r.closed) + if r.started { + <-r.done + } +} + +// tupleTransport is the server's end of the reactor's message channels. +type tupleTransport Reactor + +func (t *tupleTransport) ReadTuple() (api.MessageType, string, []byte, error) { + t.mu.Lock() + select { + case message := <-t.inbox: + t.mu.Unlock() + return message.Type, message.Method, message.Payload, nil + default: + // No message is waiting, so the server is idle. + } + select { + case t.idle <- struct{}{}: + // Receive may now report that the server is waiting. + default: + // An idle signal is already pending. + } + t.mu.Unlock() + select { + case message := <-t.inbox: + return message.Type, message.Method, message.Payload, nil + case <-t.closed: + return 0, "", nil, io.EOF + } +} + +func (t *tupleTransport) WriteTuple(msgType api.MessageType, method string, payload []byte) error { + select { + case t.outbox <- Message{Type: msgType, Method: method, Payload: payload}: + return nil + case <-t.closed: + return io.ErrClosedPipe + } } diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go index ffd117c967c0a..0d06abf977988 100644 --- a/tsc/internal/api/wasmreactor/reactor_test.go +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -1,10 +1,11 @@ package wasmreactor import ( - "bytes" "context" + "io" "testing" + "github.com/microsoft/TypeScript/tsc/internal/api" "github.com/microsoft/TypeScript/tsc/internal/json" "github.com/microsoft/TypeScript/tsc/internal/tspath" "gotest.tools/v3/assert" @@ -16,45 +17,44 @@ func TestReactor(t *testing.T) { 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") - } + assert.NilError(t, reactor.SetFile("/src/index.ts", "export const value = 1;")) + contents, ok := reactor.ReadFile("/src/index.ts") + assert.Assert(t, ok) + assert.Equal(t, contents, "export const value = 1;") + _, ok = reactor.ReadFile("/src/missing.ts") + assert.Assert(t, !ok) - initialize, err := reactor.HandleRequest("initialize", nil) - if err != nil { - t.Fatal(err) + assert.Assert(t, reactor.Send(Message{Type: api.MessageTypeRequest, Method: "echo"}) != nil) + assert.NilError(t, reactor.Start(nil)) + assert.Assert(t, reactor.Start(nil) != nil) + + request := func(method string, payload []byte) Message { + t.Helper() + assert.NilError(t, reactor.Send(Message{Type: api.MessageTypeRequest, Method: method, Payload: payload})) + response, err := reactor.Receive() + assert.NilError(t, err) + assert.Equal(t, response.Type, api.MessageTypeResponse) + assert.Equal(t, response.Method, method) + return response } + var initializeResponse struct { CurrentDirectory string `json:"currentDirectory"` } - if unmarshalErr := json.Unmarshal(initialize, &initializeResponse); unmarshalErr != nil { - t.Fatal(unmarshalErr) - } - if initializeResponse.CurrentDirectory != "/" { - t.Fatalf("currentDirectory = %q, want /", initializeResponse.CurrentDirectory) - } + assert.NilError(t, json.Unmarshal(request("initialize", nil).Payload, &initializeResponse)) + assert.Equal(t, initializeResponse.CurrentDirectory, "/") + assert.DeepEqual(t, request("echo", []byte("reactor")).Payload, []byte("reactor")) - payload := []byte("reactor") - echo, err := reactor.HandleRequest("echo", payload) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(echo, payload) { - t.Fatalf("echo = %q", echo) - } + _, err := reactor.Receive() + assert.Equal(t, err, ErrIdle) - 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") - } + assert.NilError(t, reactor.RemoveFile("/src/index.ts")) + _, ok = reactor.ReadFile("/src/index.ts") + assert.Assert(t, !ok) + + reactor.Close() + _, err = reactor.Receive() + assert.Equal(t, err, io.EOF) } func TestMemFS(t *testing.T) { diff --git a/tsc/internal/ipc/timing.go b/tsc/internal/ipc/timing.go index 5a6148c5aea24..4114a548eae99 100644 --- a/tsc/internal/ipc/timing.go +++ b/tsc/internal/ipc/timing.go @@ -60,35 +60,6 @@ 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{} } From a02c3f1110cbbefa1ff01a42a5f43c4827f54a40 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:45:08 -0700 Subject: [PATCH 134/142] Run the callback tests under WebAssembly The WebAssembly transport now supports every file system and module resolution callback, so the test harness passes callbacks through instead of mirroring files into the reactor, and only the tests that need the real disk remain skipped. --- Herebyfile.mjs | 14 +-- packages/typescript/test/wasm.ts | 150 ++----------------------------- 2 files changed, 9 insertions(+), 155 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 1967a22dd389f..ee3b100526f70 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1015,23 +1015,15 @@ async function runTestAPI() { } async function runTestAPIWasm() { - const skip = [ - // Reactor callbacks cannot yield to promises or reenter Go. - "module resolver callbacks (?:can delegate to another resolver|can resolve against the in-progress snapshot filesystem|preserve retained and live filesystem context)", - // These tests require spawned processes or callback filesystem layering. - "readFile callback semantics (?:callback configurations require every operation at runtime|invalid callback results do not fall through to the server OS|callback configuration and implementations are read together when connecting)", - "updateSnapshot file systems (?:full file system is total and does not invoke host callbacks|file system layer bypasses callbacks on hits and falls back on misses)", - // The reactor mirrors input files but does not mirror build output changes. - "BuildOrchestrator (?:returns build response information after clean|returns deleted files from a clean response|rebuilds projects after multiple file system changes|clean removes build outputs|builds and cleans selected projects after file system changes|cleans only references of a selected project|handles invalidated projects and cleans the last built configuration)", - "Benchmarks|Parse-clone-emit roundtrip", - ].join("|"); + // These need the API to read and write the real disk. + const skip = "Benchmarks|Parse-clone-emit roundtrip"; for (const mode of ["async", "sync"]) { await run("node", [ "--conditions", "@typescript/source", "--test", "--test-skip-pattern", - mode === "async" ? `${skip}|source imports do not request module resolution` : skip, + skip, `./test/${mode}/api.test.ts`, ...mode === "sync" ? ["./test/sync/ast.test.ts", "./test/sync/api-generators.test.ts"] : [], ], { cwd: "./packages/typescript", env: { TS_API_TEST_WASM: "1" } }); diff --git a/packages/typescript/test/wasm.ts b/packages/typescript/test/wasm.ts index 489793fcbec61..3df0c34658778 100644 --- a/packages/typescript/test/wasm.ts +++ b/packages/typescript/test/wasm.ts @@ -1,10 +1,6 @@ import { wasmURL } from "@typescript/typescript-wasip1-wasm"; import type { API as AsyncAPI } from "@typescript/typescript/async"; import type { FileSystemCallbacks } from "@typescript/typescript/fs"; -import { - toRootedDirectoryPath, - toRootedFilePath, -} from "@typescript/typescript/path"; import type { API as SyncAPI } from "@typescript/typescript/sync"; import { instantiateWasmSync, @@ -12,11 +8,6 @@ import { WasmTransport, } from "@typescript/typescript/wasm"; import { readFileSync } from "node:fs"; -import { - type DocumentIdentifier, - type FileNotifications, - resolveFileName, -} from "../src/api/proto.ts"; declare const WebAssembly: { Module: new (bytes: Uint8Array) => WebAssembly.Module; @@ -31,13 +22,7 @@ interface WasmAPIOptions { transport?: object | undefined; } -interface SnapshotChanges { - fileNotifications?: FileNotifications | undefined; - openFiles?: readonly DocumentIdentifier[] | undefined; -} - const availableInstances: WasmReactorInstance[] = []; -const mirroredFiles = new WeakMap>(); let module: WebAssembly.Module | undefined; class TestWasmTransport extends WasmTransport { @@ -60,150 +45,27 @@ class TestWasmTransport extends WasmTransport { } } -function createWasmAPIOptions(options: WasmAPIOptions): { - options: WasmAPIOptions; - transport?: WasmTransport | undefined; -} { - if (options.transport) return { options }; +function createWasmAPIOptions(options: WasmAPIOptions): WasmAPIOptions { + if (options.transport) return options; const instance = availableInstances.pop() ?? instantiateWasmSync(module ??= new WebAssembly.Module(readFileSync(wasmURL))); - const writeFile = typeof options.fs?.writeFile === "function" ? options.fs.writeFile : undefined; const transport = new TestWasmTransport(instance, { instance, cwd: options.cwd ?? "/", - fs: writeFile === undefined ? undefined : { writeFile: writeFile.bind(options.fs) }, useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, collectTiming: options.collectTiming, }); - if (options.fs) { - synchronizeFileSystem(options.fs, transport); - } return { - options: { - transport, - fs: options.fs, - collectTiming: options.collectTiming, - maxResponseBytesPerPage: options.maxResponseBytesPerPage, - }, transport, + fs: options.fs, + collectTiming: options.collectTiming, + maxResponseBytesPerPage: options.maxResponseBytesPerPage, }; } -function wrapFileUpdates(api: T, fs: FileSystemCallbacks | undefined, transport: WasmTransport | undefined): void { - if (!fs || !transport) return; - wrapMethod("createSnapshot", 0); - wrapMethod("updateSnapshot", 1); - - function wrapMethod(name: string, changesIndex: number): void { - const before = (args: unknown[]) => { - const changes = args[changesIndex] as SnapshotChanges | undefined; - syncFileChanges(fs!, transport!, changes?.fileNotifications); - syncOpenFiles(fs!, transport!, changes); - }; - const target = api as Record; - const original = target[name] as ((...args: unknown[]) => unknown) & { - gen?: (...args: unknown[]) => Generator; - }; - const bound = original.bind(api); - const wrapped = (...args: unknown[]) => { - before(args); - return bound(...args); - }; - if (original.gen) { - const boundGen = original.gen.bind(api); - wrapped.gen = (...args: unknown[]) => { - before(args); - return boundGen(...args); - }; - } - Object.defineProperty(api, name, { - configurable: true, - value: wrapped, - }); - } -} - -function syncOpenFiles( - fs: FileSystemCallbacks, - transport: WasmTransport, - changes: SnapshotChanges | undefined, -): void { - for (const file of changes?.openFiles ?? []) { - mirrorFile(fs, transport, resolveFileName(file)); - } -} - -function mirrorFile(fs: FileSystemCallbacks, transport: WasmTransport, fileName: string): void { - const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(fileName, undefined)) : undefined; - if (typeof content === "string") { - transport.setFile(fileName, content); - mirroredFiles.get(transport)?.add(fileName); - } - else { - transport.removeFile(fileName); - mirroredFiles.get(transport)?.delete(fileName); - } -} - -function synchronizeFileSystem(fs: FileSystemCallbacks, transport: WasmTransport): void { - const previous = mirroredFiles.get(transport) ?? new Set(); - const current = new Set(); - visit("/"); - for (const file of previous) { - if (!current.has(file)) { - transport.removeFile(file); - } - } - mirroredFiles.set(transport, current); - - function visit(directory: string): void { - const entries = typeof fs.getAccessibleEntries === "function" - ? fs.getAccessibleEntries(toRootedDirectoryPath(directory, undefined)) - : undefined; - if (!entries || typeof entries !== "object") return; - for (const file of entries.files) { - const path = join(directory, file); - current.add(path); - const content = typeof fs.readFile === "function" ? fs.readFile(toRootedFilePath(path, undefined)) : undefined; - if (typeof content === "string") { - transport.setFile(path, content); - } - } - for (const child of entries.directories) { - visit(join(directory, child)); - } - } -} - -function syncFileChanges( - fs: FileSystemCallbacks, - transport: WasmTransport, - changes: FileNotifications | undefined, -): void { - if (changes?.invalidateAll) { - synchronizeFileSystem(fs, transport); - } - for (const file of [...changes?.changed ?? [], ...changes?.created ?? []]) { - mirrorFile(fs, transport, resolveFileName(file)); - } - 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}`; -} - export function withWasm(API: T): T { return new Proxy(API, { construct(target, args) { - const options: WasmAPIOptions = args[0] ?? {}; - const created = createWasmAPIOptions(options); - const api = Reflect.construct(target, [created.options]); - wrapFileUpdates(api, options.fs, created.transport); - return api; + return Reflect.construct(target, [createWasmAPIOptions(args[0] ?? {})]); }, }); } From bfbe05dfd5bae5e6993b47539d4ee6fc2369684a Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:54:37 -0700 Subject: [PATCH 135/142] Run every API test under WebAssembly The remaining skips hid real gaps. The roundtrip test reads fixtures that a server process finds on its own disk, but the reactor's base file system is in memory, and the AST benchmark spawned a native server, so it never exercised WebAssembly at all. The test harness now sends whatever a server process would read or write on its disk to the disk through callbacks, leaving the embedded libraries to the reactor, and the benchmark uses the same API selection as the other tests. --- Herebyfile.mjs | 4 - packages/typescript/test/sync/ast.bench.ts | 2 +- packages/typescript/test/wasm.ts | 93 +++++++++++++++++++++- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index ee3b100526f70..92599e573db5b 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1015,15 +1015,11 @@ async function runTestAPI() { } async function runTestAPIWasm() { - // These need the API to read and write the real disk. - const skip = "Benchmarks|Parse-clone-emit roundtrip"; for (const mode of ["async", "sync"]) { await run("node", [ "--conditions", "@typescript/source", "--test", - "--test-skip-pattern", - skip, `./test/${mode}/api.test.ts`, ...mode === "sync" ? ["./test/sync/ast.test.ts", "./test/sync/api-generators.test.ts"] : [], ], { cwd: "./packages/typescript", env: { TS_API_TEST_WASM: "1" } }); diff --git a/packages/typescript/test/sync/ast.bench.ts b/packages/typescript/test/sync/ast.bench.ts index 5ea08b311cf16..74b2e2567734d 100644 --- a/packages/typescript/test/sync/ast.bench.ts +++ b/packages/typescript/test/sync/ast.bench.ts @@ -20,12 +20,12 @@ import { createIdentifier, createIfStatement, } from "@typescript/typescript/ast/factory"; -import { API } from "@typescript/typescript/sync"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; import { Bench } from "tinybench"; import { createVirtualFileSystem } from "../testUtils.ts"; +import { API } from "./api.testUtils.ts"; const treeDepth = 5; const expressionsPerBlock = 6; diff --git a/packages/typescript/test/wasm.ts b/packages/typescript/test/wasm.ts index 3df0c34658778..291ad8f1da319 100644 --- a/packages/typescript/test/wasm.ts +++ b/packages/typescript/test/wasm.ts @@ -1,13 +1,25 @@ import { wasmURL } from "@typescript/typescript-wasip1-wasm"; import type { API as AsyncAPI } from "@typescript/typescript/async"; -import type { FileSystemCallbacks } from "@typescript/typescript/fs"; +import { + type FileSystemCallbacks, + serverFS, +} from "@typescript/typescript/fs"; import type { API as SyncAPI } from "@typescript/typescript/sync"; import { instantiateWasmSync, type WasmReactorInstance, WasmTransport, } from "@typescript/typescript/wasm"; -import { readFileSync } from "node:fs"; +import { + mkdirSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; declare const WebAssembly: { Module: new (bytes: Uint8Array) => WebAssembly.Module; @@ -45,6 +57,81 @@ class TestWasmTransport extends WasmTransport { } } +/** The disk a native server process would use for `serverFS.useOS`. */ +const diskFileSystem: { [K in keyof FileSystemCallbacks]: Extract; } = { + directoryExists: path => statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false, + fileExists: path => statSync(path, { throwIfNoEntry: false })?.isFile() ?? false, + getAccessibleEntries: path => { + const files: string[] = []; + const directories: string[] = []; + const symlinks: string[] = []; + try { + for (const entry of readdirSync(path, { withFileTypes: true })) { + let stat: { isFile(): boolean; isDirectory(): boolean; } | undefined = entry; + if (entry.isSymbolicLink()) { + symlinks.push(entry.name); + stat = statSync(`${path}/${entry.name}`, { throwIfNoEntry: false }); + } + if (stat?.isFile()) files.push(entry.name); + else if (stat?.isDirectory()) directories.push(entry.name); + } + } + catch { + // Unreadable directories have no accessible entries. + } + return { files, directories, symlinks }; + }, + readFile: path => { + try { + return readFileSync(path, "utf8"); + } + catch { + return undefined; + } + }, + realpath: path => { + try { + return realpathSync.native(path); + } + catch { + return path; + } + }, + stat: path => statSync(path, { throwIfNoEntry: false }), + writeFile: (path, content) => { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + }, + removeFile: path => rmSync(path, { recursive: true, force: true }), +}; + +/** + * Sends operations that a native server would perform on its own disk to + * the disk, since the reactor's base file system is in memory. Members are + * read on access so that the client sees the caller's current configuration. + */ +function withDiskFallback(fs: FileSystemCallbacks | undefined): FileSystemCallbacks { + const result = {} as FileSystemCallbacks; + for (const name of Object.keys(diskFileSystem) as (keyof FileSystemCallbacks)[]) { + const diskOperation = diskFileSystem[name] as (...args: unknown[]) => unknown; + // The reactor's own base file system serves the embedded libraries. + const disk = (...args: unknown[]) => (args[0] as string).startsWith("bundled:///") ? serverFS.useOS : diskOperation(...args); + Object.defineProperty(result, name, { + enumerable: true, + get() { + const value = fs ? fs[name] : serverFS.useOS; + if (value === serverFS.useOS) return disk; + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + const result = (value as (...args: unknown[]) => unknown).apply(fs, args); + return result === serverFS.useOS ? disk(...args) : result; + }; + }, + }); + } + return result; +} + function createWasmAPIOptions(options: WasmAPIOptions): WasmAPIOptions { if (options.transport) return options; const instance = availableInstances.pop() ?? instantiateWasmSync(module ??= new WebAssembly.Module(readFileSync(wasmURL))); @@ -56,7 +143,7 @@ function createWasmAPIOptions(options: WasmAPIOptions): WasmAPIOptions { }); return { transport, - fs: options.fs, + fs: withDiskFallback(options.fs), collectTiming: options.collectTiming, maxResponseBytesPerPage: options.maxResponseBytesPerPage, }; From 04a02fb99b601461eb4332abd87bf1aa863ce88b Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:58:45 -0700 Subject: [PATCH 136/142] Hide desktop-only commands on the web with when clauses The web VSIX build rewrote package.json against a hardcoded command allowlist, which had to be kept in sync with the extension by hand. VS Code already exposes isWeb as a context key, so the manifest can hide the commands the web extension never registers on its own. --- Herebyfile.mjs | 16 ------------- packages/vscode-typescript/package.json | 30 ++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 92599e573db5b..dfec56213cd8c 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -3204,22 +3204,6 @@ async function runPackVsixExtensions() { "ms-vscode.wasm-wasi-core", ]; packageJson.files.push("dist/extension.web.bundle.js"); - const webCommands = new Set([ - "typescript.native-preview.enable", - "typescript.native-preview.disable", - "typescript.native-preview.restart", - "typescript.native-preview.output.focus", - "typescript.native-preview.goToSourceDefinition", - "typescript.native-preview.sortImports", - "typescript.native-preview.removeUnusedImports", - "typescript.native-preview.codeLens.showLocations", - ]); - const contributes = packageJson.contributes; - contributes.commands = contributes.commands.filter((/** @type {{ command: string }} */ c) => webCommands.has(c.command)); - assert.equal(contributes.commands.length, webCommands.size, "Web command allowlist names a command the extension does not contribute"); - for (const [menu, items] of Object.entries(contributes.menus)) { - contributes.menus[menu] = items.filter((/** @type {{ command: string }} */ item) => webCommands.has(item.command)); - } } } packageJson.dependencies = packageJsonDependencies; diff --git a/packages/vscode-typescript/package.json b/packages/vscode-typescript/package.json index 509c3175d3dd9..f94f9fc026838 100644 --- a/packages/vscode-typescript/package.json +++ b/packages/vscode-typescript/package.json @@ -333,7 +333,35 @@ }, { "command": "typescript.native-preview.selectVersion", - "when": "typescript.native-preview.serverRunning" + "when": "typescript.native-preview.serverRunning && !isWeb" + }, + { + "command": "typescript.native-preview.reportIssue", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.runGC", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.saveHeapProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.saveAllocProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.startCPUProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.dev.stopCPUProfile", + "when": "!isWeb" + }, + { + "command": "typescript.native-preview.initializeAPIConnection.ui", + "when": "!isWeb" }, { "command": "typescript.native-preview.goToSourceDefinition", From d7c28f24c2aca92aa5d242c96ccd0e72eaf257dd Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 21 Oct 2026 19:00:51 -0700 Subject: [PATCH 137/142] Kill WebAssembly stdin test children on timeout A hung server would otherwise outlive a timed-out test, leaving the child process, its pipes and the LSP polling loop running and keeping the test runner from exiting. --- packages/typescript/test/sync/wasm.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 2ee631e26e2d0..1fa8eb19b6663 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -163,7 +163,7 @@ describe("API over WebAssembly", () => { } }); - test("waits for LSP stdin without repeatedly reading", { timeout: 10_000 }, async () => { + test("waits for LSP stdin without repeatedly reading", { timeout: 10_000 }, async t => { const childSource = ` import { readFileSync } from "node:fs"; import { WASI } from "node:wasi"; @@ -190,6 +190,9 @@ describe("API over WebAssembly", () => { cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"], }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }); let stdout = ""; let stderr = ""; child.stdout.setEncoding("utf8"); @@ -211,7 +214,7 @@ describe("API over WebAssembly", () => { }); child.stdin.write(`Content-Length: ${Buffer.byteLength(request)}\r\n\r\n${request}`); - while (!stdout.includes("Resolved client capabilities") && child.exitCode === null) { + while (!stdout.includes("Resolved client capabilities") && child.exitCode === null && !t.signal.aborted) { await new Promise(resolve => setTimeout(resolve, 10)); } child.stdin.end(); @@ -220,7 +223,7 @@ describe("API over WebAssembly", () => { assert.match(stdout, /Resolved client capabilities/); }); - test("waits for API stdin without repeatedly reading", { timeout: 10_000 }, async () => { + test("waits for API stdin without repeatedly reading", { timeout: 10_000 }, async t => { const childSource = ` import { readFileSync } from "node:fs"; import { WASI } from "node:wasi"; @@ -247,6 +250,9 @@ describe("API over WebAssembly", () => { cwd: process.cwd(), stdio: ["pipe", "pipe", "pipe"], }); + t.after(() => { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + }); let stderr = ""; child.stderr.setEncoding("utf8"); child.stderr.on("data", chunk => stderr += chunk); From c2237204d5c8bc5f7f42e1f9c5a5f613e8ef9168 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:16:54 -0700 Subject: [PATCH 138/142] Only pre-size the printer buffer when printing a whole file Write grew the writer by the full source file length for every call, including when printing a single type node for typeToString or the type baselines. Each resulting string then pinned a buffer the size of the file; for largeControlFlowGraph.ts the types baseline retained over 3GB this way, which exhausts the 4GB wasm address space. --- tsc/internal/printer/printer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsc/internal/printer/printer.go b/tsc/internal/printer/printer.go index a27452d51472d..46bb47c25ebad 100644 --- a/tsc/internal/printer/printer.go +++ b/tsc/internal/printer/printer.go @@ -5095,7 +5095,7 @@ func (p *Printer) Write(node *ast.Node, sourceFile *ast.SourceFile, writer EmitT } p.writer = writer p.writer.Clear() - if sourceFile != nil { + if sourceFile != nil && node == sourceFile.AsNode() { if grower, ok := p.writer.(interface{ Grow(n int) }); ok { grower.Grow(len(sourceFile.Text())) } From 6f577f5ae8f7ef180618b1f33fdfeba8e9038af1 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:16:55 -0700 Subject: [PATCH 139/142] Cache only library source files in the test harness The harness source file cache was global and unbounded, so every test file parsed during a run stayed alive until the process exited. Only lib files are actually shared between tests; caching just those keeps the wasm testrunner within its 4GB memory limit and slightly reduces native peak RSS and CPU time. --- tsc/internal/testutil/harnessutil/harnessutil.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tsc/internal/testutil/harnessutil/harnessutil.go b/tsc/internal/testutil/harnessutil/harnessutil.go index 3b86a398b281b..bca59282a69fa 100644 --- a/tsc/internal/testutil/harnessutil/harnessutil.go +++ b/tsc/internal/testutil/harnessutil/harnessutil.go @@ -489,6 +489,12 @@ func (h *cachedCompilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast panic("Unknown script kind for file " + opts.FileName.AsString()) } + // Only library files are shared across tests; caching test files would retain every test's ASTs. + fileName := opts.FileName.AsString() + if !tspath.ContainsPath(h.DefaultLibraryPath().AsString(), fileName, h.FS().CaseSensitivity()) && !tspath.ContainsPath(testLibFolder, fileName, h.FS().CaseSensitivity()) { + return parser.ParseSourceFile(opts, text, scriptKind) + } + key := GetSourceFileCacheKey(opts, text, scriptKind) if cached, ok := sourceFileCache.Load(key); ok { From 2de488f53c0174184f781f032ae7faf17ad5e3f2 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:30:16 -0700 Subject: [PATCH 140/142] Keep the WebAssembly reactor idle until the next Send Receive consumed a one-shot idle token, but the server only produced another after it received a message. A second Receive while the server was still waiting therefore blocked forever instead of reporting ErrIdle again. Idle is now a state that stays set until Send clears it. --- tsc/internal/api/wasmreactor/reactor.go | 57 ++++++++++++-------- tsc/internal/api/wasmreactor/reactor_test.go | 3 ++ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go index c161e1c0bf07a..c0f649c98a8ea 100644 --- a/tsc/internal/api/wasmreactor/reactor.go +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -42,10 +42,13 @@ type Reactor struct { files *memFS cwd tspath.RootedDirectoryPath - mu sync.Mutex - inbox chan Message - outbox chan Message + mu sync.Mutex + inbox chan Message + outbox chan Message + // idle is closed while the server waits for a message, and replaced + // by Send, so every Receive in between reports ErrIdle. idle chan struct{} + isIdle bool closed chan struct{} started bool done chan struct{} @@ -69,7 +72,7 @@ func New(ctx context.Context, options Options) *Reactor { cwd: tspath.RootedDirectoryPathFromAbsolute(options.Cwd), inbox: make(chan Message, 1), outbox: make(chan Message), - idle: make(chan struct{}, 1), + idle: make(chan struct{}), closed: make(chan struct{}), done: make(chan struct{}), } @@ -111,17 +114,16 @@ func (r *Reactor) Send(message Message) error { r.mu.Lock() defer r.mu.Unlock() select { - case <-r.idle: - // The server is about to receive this message, so it is no longer idle. - default: - // The server has not reported being idle. - } - select { case r.inbox <- message: - return nil default: return errors.New("a message is already waiting for the server") } + if r.isIdle { + // The server is about to receive this message, so it is no longer idle. + r.idle = make(chan struct{}) + r.isIdle = false + } + return nil } // Receive runs the server until it produces its next message. It returns @@ -137,17 +139,30 @@ func (r *Reactor) Receive() (Message, error) { default: // Run the server until it writes, waits, or stops. } + r.mu.Lock() + idle := r.idle + r.mu.Unlock() select { case message := <-r.outbox: return message, nil - case <-r.idle: - return Message{}, ErrIdle - case <-r.done: - if r.err != nil { - return Message{}, r.err + case <-idle: + select { + case <-r.done: + // A stopped server stays idle; report that it stopped instead. + return r.stopped() + default: + return Message{}, ErrIdle } - return Message{}, io.EOF + case <-r.done: + return r.stopped() + } +} + +func (r *Reactor) stopped() (Message, error) { + if r.err != nil { + return Message{}, r.err } + return Message{}, io.EOF } func (r *Reactor) SetFile(path string, content string) error { @@ -189,11 +204,9 @@ func (t *tupleTransport) ReadTuple() (api.MessageType, string, []byte, error) { default: // No message is waiting, so the server is idle. } - select { - case t.idle <- struct{}{}: - // Receive may now report that the server is waiting. - default: - // An idle signal is already pending. + if !t.isIdle { + t.isIdle = true + close(t.idle) } t.mu.Unlock() select { diff --git a/tsc/internal/api/wasmreactor/reactor_test.go b/tsc/internal/api/wasmreactor/reactor_test.go index 0d06abf977988..e28e36b0a7a63 100644 --- a/tsc/internal/api/wasmreactor/reactor_test.go +++ b/tsc/internal/api/wasmreactor/reactor_test.go @@ -47,6 +47,9 @@ func TestReactor(t *testing.T) { _, err := reactor.Receive() assert.Equal(t, err, ErrIdle) + _, err = reactor.Receive() + assert.Equal(t, err, ErrIdle) + assert.DeepEqual(t, request("echo", []byte("again")).Payload, []byte("again")) assert.NilError(t, reactor.RemoveFile("/src/index.ts")) _, ok = reactor.ReadFile("/src/index.ts") From eca1a51bf630c7ade72ec5c0511953067b2327e6 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:01:30 -0700 Subject: [PATCH 141/142] Avoid an empty case in the WebAssembly reactor's Send --- tsc/internal/api/wasmreactor/reactor.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go index c0f649c98a8ea..52843a00ab709 100644 --- a/tsc/internal/api/wasmreactor/reactor.go +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -115,15 +115,15 @@ func (r *Reactor) Send(message Message) error { defer r.mu.Unlock() select { case r.inbox <- message: + if r.isIdle { + // The server is about to receive this message, so it is no longer idle. + r.idle = make(chan struct{}) + r.isIdle = false + } + return nil default: return errors.New("a message is already waiting for the server") } - if r.isIdle { - // The server is about to receive this message, so it is no longer idle. - r.idle = make(chan struct{}) - r.isIdle = false - } - return nil } // Receive runs the server until it produces its next message. It returns From 7ec71415edb94a5c975b82b591898052ec0b61fd Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:08:32 -0700 Subject: [PATCH 142/142] Exercise the WebAssembly LSP past initialization The stdin test stopped as soon as the server logged its client capabilities, so it never showed that the server still answers requests once it has gone idle after initialization. It now waits for the initialize response, answers the server's own requests as a real client would, and requires a shutdown response before closing stdin. The read cap rises because the server polls stdin about twenty times a second while idle, and the test now stays idle for longer; a busy loop would still exceed it within milliseconds. --- packages/typescript/test/sync/wasm.test.ts | 46 ++++++++++++++++------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 1fa8eb19b6663..fe7857126366f 100644 --- a/packages/typescript/test/sync/wasm.test.ts +++ b/packages/typescript/test/sync/wasm.test.ts @@ -178,7 +178,7 @@ describe("API over WebAssembly", () => { const imports = { ...wasi.wasiImport, fd_read(...args) { - if (++reads > 50) throw new Error("stdin was read repeatedly while idle"); + if (++reads > 500) throw new Error("stdin was read repeatedly while idle"); return wasi.wasiImport.fd_read(...args); }, }; @@ -193,16 +193,39 @@ describe("API over WebAssembly", () => { t.after(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }); - let stdout = ""; + let stdout = Buffer.alloc(0); let stderr = ""; - child.stdout.setEncoding("utf8"); + const responses = new Set(); + const send = (message: object) => { + const json = JSON.stringify({ jsonrpc: "2.0", ...message }); + child.stdin.write(`Content-Length: ${Buffer.byteLength(json)}\r\n\r\n${json}`); + }; + child.stdout.on("data", (chunk: Buffer) => { + stdout = Buffer.concat([stdout, chunk]); + while (true) { + const headerEnd = stdout.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const length = Number(/Content-Length: (\d+)/.exec(stdout.subarray(0, headerEnd).toString())![1]); + const bodyStart = headerEnd + 4; + if (stdout.length < bodyStart + length) return; + const message = JSON.parse(stdout.subarray(bodyStart, bodyStart + length).toString()); + stdout = stdout.subarray(bodyStart + length); + if (!("method" in message)) responses.add(message.id); + else if ("id" in message) send({ id: message.id, result: null }); + } + }); child.stderr.setEncoding("utf8"); - child.stdout.on("data", chunk => stdout += chunk); child.stderr.on("data", chunk => stderr += chunk); + const waitForResponse = async (id: number) => { + while (!responses.has(id) && child.exitCode === null && !t.signal.aborted) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + assert.ok(responses.has(id), `no response to request ${id}\n${stderr}`); + }; + // Each request arrives after the server has gone idle waiting for stdin. await new Promise(resolve => setTimeout(resolve, 250)); - const request = JSON.stringify({ - jsonrpc: "2.0", + send({ id: 1, method: "initialize", params: { @@ -212,15 +235,14 @@ describe("API over WebAssembly", () => { workspaceFolders: null, }, }); - child.stdin.write(`Content-Length: ${Buffer.byteLength(request)}\r\n\r\n${request}`); - - while (!stdout.includes("Resolved client capabilities") && child.exitCode === null && !t.signal.aborted) { - await new Promise(resolve => setTimeout(resolve, 10)); - } + await waitForResponse(1); + send({ method: "initialized", params: {} }); + await new Promise(resolve => setTimeout(resolve, 250)); + send({ id: 2, method: "shutdown" }); + await waitForResponse(2); child.stdin.end(); const exitCode = child.exitCode ?? await new Promise(resolve => child.once("exit", resolve)); assert.strictEqual(exitCode, 0, stderr); - assert.match(stdout, /Resolved client capabilities/); }); test("waits for API stdin without repeatedly reading", { timeout: 10_000 }, async t => {