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 01/36] 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 02/36] 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 03/36] 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 04/36] 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 05/36] 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 06/36] 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 07/36] 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 08/36] 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 09/36] 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 10/36] 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 11/36] 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 12/36] 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 13/36] 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 14/36] 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 15/36] 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 16/36] 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 17/36] 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 18/36] 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 19/36] 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 20/36] 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 21/36] 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 22/36] 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 23/36] 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 24/36] 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 25/36] 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 26/36] 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 27/36] 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 28/36] 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 29/36] 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 30/36] 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 31/36] 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 32/36] 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 33/36] 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 34/36] 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 35/36] 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 36/36] 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 = {},