From 858787da846bc65f77a775f53c381ee4ef59213f 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 1/7] 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 | 57 +++ packages/api-wasm/README.md | 21 ++ packages/api-wasm/package.json | 30 ++ packages/api-wasm/src/index.ts | 166 ++++++++ packages/api-wasm/src/wasi.ts | 164 ++++++++ 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 | 37 +- .../typescript/src/api/async/transport.ts | 17 + .../src/api/async/transportClient.ts | 283 ++++++++++++++ packages/typescript/src/api/node/encoder.ts | 38 +- 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 | 148 +------- packages/typescript/src/api/sync/transport.ts | 16 + .../src/api/sync/transportClient.ts | 121 ++++++ .../typescript/test/async/transport.test.ts | 353 ++++++++++++++++++ .../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 | 89 +++++ tools/pipelines/typescript-publish.yml | 32 +- tsc/cmd/tsc-api-wasm/main.go | 179 +++++++++ tsc/internal/api/wasmreactor/reactor.go | 113 ++++++ tsc/internal/api/wasmreactor/reactor_test.go | 53 +++ tsc/internal/ipc/timing.go | 29 ++ 35 files changed, 2373 insertions(+), 171 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 9a864147929cf..96faa11126278 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,10 @@ jobs: - run: npx hereby test:extension - run: npx hereby test:tools - run: npx hereby test:api + - run: npx playwright install --with-deps chromium + if: ${{ matrix.config.main }} + - run: npx hereby test:api:browser + if: ${{ matrix.config.main }} - run: git add . - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 if: ${{ always() && matrix.config.coverage && github.event_name != 'merge_group' }} diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 381b13808b5e4..95770e852b63e 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1214,7 +1214,15 @@ async function runTestTools() { async function runTestAPI() { // Running the package script doesn't work on Windows; some path escaping isn't done correctly and the test runner runs no tests. - await run("node", ["--conditions", "@typescript/source", "--test", "./test/**/*.test.ts"], { cwd: "./packages/typescript" }); + await run( + "node", + ["--conditions", "@typescript/source", "--test", "./test/*.test.ts", "./test/async/**/*.test.ts", "./test/sync/**/*.test.ts"], + { cwd: "./packages/typescript" }, + ); +} + +async function runTestAPIBrowser() { + await run("node", ["--conditions", "@typescript/source", "--test", "./test/browser/**/*.test.ts"], { cwd: "./packages/typescript" }); } export const testTools = task({ @@ -1237,10 +1245,33 @@ export const buildAPI = task({ }, }); +/** + * @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: async () => { await run("npm", ["run", "-w", "@typescript/typescript", "build:test"]); }, @@ -1253,6 +1284,13 @@ export const testAPI = task({ run: runTestAPI, }); +export const testAPIBrowser = task({ + name: "test:api:browser", + description: "Runs the @typescript/typescript browser API tests.", + dependencies: [buildAPITests], + run: runTestAPIBrowser, +}); + export const testAll = task({ name: "test:all", description: "Runs ALL tests in the repo, including benchmarks, tools, and the API tests.", @@ -1264,6 +1302,7 @@ export const testAll = task({ await runTestBenchmarks(); await runTestTools(); await runTestAPI(); + await runTestAPIBrowser(); }, }); @@ -1934,6 +1973,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", @@ -2289,6 +2334,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(); @@ -2349,7 +2401,7 @@ async function runBuildNativePreviewPackages() { await fs.promises.copyFile("LICENSE.txt", path.join(mainPackageDir, "LICENSE")); await fs.promises.copyFile("NOTICE.txt", path.join(mainPackageDir, "NOTICE.txt")); - // Build JS API and copy dist into the package. + // Build the JS API and copy dist into the main package. await run("npm", ["run", "-w", "@typescript/typescript", "build"]); await cpRecursive(path.join(inputDir, "dist"), path.join(mainPackageDir, "dist")); @@ -2377,7 +2429,33 @@ 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 = 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 = { @@ -2587,14 +2665,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 => ({ @@ -2605,6 +2683,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 6bfed937d972a..7bd21e599c4f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1521,6 +1521,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", @@ -3117,6 +3121,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -4326,6 +4345,38 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -5487,6 +5538,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", @@ -5496,6 +5552,7 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "playwright": "^1.62.1", "tinybench": "^6.1.3", "vscode-jsonrpc": "^9.0.1" }, 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..82db98dbbc2ad --- /dev/null +++ b/packages/api-wasm/src/index.ts @@ -0,0 +1,166 @@ +export { wasmURL } from "#wasmURL"; +export { instantiateWasm, type InstantiateWasmOptions } 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 exports: WasmReactorExports; + private requestPointer = 0; + private closed = false; + + constructor(options: WasmTransportOptions) { + this.exports = getReactorExports(options.instance); + const sessionOptions = encoder.encode(JSON.stringify({ + cwd: options.cwd ?? "/", + useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, + collectTiming: options.collectTiming, + })); + this.writeRequest(sessionOptions); + if (this.exports.create_session(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, + }; + } + + 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..a6de390a52d2c --- /dev/null +++ b/packages/api-wasm/src/wasi.ts @@ -0,0 +1,164 @@ +import type { WasmReactorInstance } from "./index.ts"; + +const errnoSuccess = 0; +const errnoBadFileDescriptor = 8; +const errnoInvalidArgument = 28; +const errnoNoSys = 52; +const fileTypeCharacterDevice = 2; + +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(); + + 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 !== 1 && fd !== 2) return errnoBadFileDescriptor; + const memory = getMemory(); + const view = new DataView(memory.buffer); + const chunks: Uint8Array[] = []; + let length = 0; + for (let i = 0; i < iovsLength; i++) { + const iovPointer = iovsPointer + i * 8; + const chunkPointer = view.getUint32(iovPointer, true); + const chunkLength = view.getUint32(iovPointer + 4, true); + chunks.push(new Uint8Array(memory.buffer, chunkPointer, chunkLength)); + length += chunkLength; + } + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + view.setUint32(writtenPointer, length, true); + const decoder = decoders.get(fd) ?? new TextDecoder(); + decoders.set(fd, decoder); + (fd === 1 ? stdout : stderr)(decoder.decode(bytes, { stream: true })); + return errnoSuccess; + } + + function randomGet(bufferPointer: number, bufferLength: number): number { + bufferPointer >>>= 0; + const buffer = new Uint8Array(getMemory().buffer, bufferPointer, bufferLength); + for (let offset = 0; offset < buffer.length; offset += 65_536) { + crypto.getRandomValues(buffer.subarray(offset, Math.min(offset + 65_536, buffer.length))); + } + return errnoSuccess; + } + + function 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(); + 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 9cebd17e7a436..c634fec93babd 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.3", "vscode-jsonrpc": "^9.0.1" } diff --git a/packages/typescript/src/api/async/api.ts b/packages/typescript/src/api/async/api.ts index 208313dac3502..1db3ca653ddae 100644 --- a/packages/typescript/src/api/async/api.ts +++ b/packages/typescript/src/api/async/api.ts @@ -1,3 +1,8 @@ +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "#asyncClient"; // @sync: } from "#syncClient"; import { CheckFlags } from "#enums/checkFlags"; import { CompletionItemKind } from "#enums/completionItemKind"; import { DiagnosticCategory } from "#enums/diagnosticCategory"; @@ -46,7 +51,7 @@ import { } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; import type { - APIOptions, + APIOptions, // @sync: SyncAPIOptions as APIOptions, LSPConnectionOptions, } from "../options.ts"; import { @@ -93,11 +98,6 @@ import type { TimingAccumulators, TimingInfo, } from "../timing.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, @@ -391,6 +391,15 @@ export class API implements FormatDiagnosticsHo } async close(): Promise { + // @sync-skip-block-start + if (this.initializing && !this.initialized) { + const initializing = this.initializing; + await this.client.close(); + await initializing.catch(() => {}); + this.sourceFileCache.clear(); + return; + } + // @sync-skip-block-end await this.initializing?.catch(() => {}); // @sync-skip // Dispose all active snapshots try { diff --git a/packages/typescript/src/api/async/browserClient.ts b/packages/typescript/src/api/async/browserClient.ts new file mode 100644 index 0000000000000..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 03809285c9b4d..be16da8109351 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); } @@ -222,8 +237,9 @@ export class Client { const requestType = new RequestType("batchRequests"); const params: BatchRequestsParams = { requests: requests.map(request => ({ method: request.method, params: request.params })) }; - if (this.options.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + const maxResponseBytesPerPage = this.options?.maxResponseBytesPerPage; + if (maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = maxResponseBytesPerPage; } const response = await this.sendRequestWithTiming(requestType, params); let responses = response.responses; @@ -233,8 +249,8 @@ export class Client { requests: [], continuationToken, }; - if (this.options.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.options.maxResponseBytesPerPage; + if (maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = maxResponseBytesPerPage; } const page = await this.sendRequestWithTiming(requestType, pageParams); responses = responses.concat(page.responses); @@ -262,6 +278,7 @@ export class Client { } batchContext(): { [Symbol.dispose](): void; } { + if (this.transportClient) return this.transportClient.batchContext(); if (this.nextBatch === "manual") { throw new Error("Already in a manual batch context"); } @@ -279,6 +296,7 @@ export class Client { } async apiRequest(method: K, params: APIMethodInfo[K]["params"]): Promise { + if (this.transportClient) return this.transportClient.apiRequest(method, params); if (this.closed) throw new Error("Client is closed"); if (!this.connected) { await this.connect(); @@ -295,6 +313,7 @@ export class Client { } async apiRequestBinary(method: K, params: APIMethodInfo[K]["params"]): Promise { + if (this.transportClient) return this.transportClient.apiRequestBinary(method, params); const response = await this.apiRequest(method, params); if (!response) return undefined; const buffer = Buffer.from(response.data, "base64"); @@ -308,6 +327,7 @@ export class Client { * materialization totals surface alongside request timings. */ getTimingCollector(): TimingCollector | undefined { + if (this.transportClient) return this.transportClient.getTimingCollector(); return this.timing; } @@ -317,6 +337,7 @@ export class Client { * (fetched via a getServerTiming request) and estimated transport overhead. */ async getTimingInfo(): Promise { + if (this.transportClient) return this.transportClient.getTimingInfo(); if (!this.timing) { return disabledTimingInfo(); } @@ -329,6 +350,7 @@ export class Client { } async resetTimingInfo(): Promise { + if (this.transportClient) return this.transportClient.resetTimingInfo(); if (!this.timing) return; this.timing.reset(); if (this.connected && this.connection) { @@ -349,6 +371,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..aaa5d719ed879 --- /dev/null +++ b/packages/typescript/src/api/async/transport.ts @@ -0,0 +1,17 @@ +/** + * 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>; + 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..fd1ac99507246 --- /dev/null +++ b/packages/typescript/src/api/async/transportClient.ts @@ -0,0 +1,283 @@ +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import type { AsyncTransport } from "./transport.ts"; + +interface PendingRequestBase { + reject: (reason?: unknown) => void; +} + +interface PendingTextRequest extends PendingRequestBase { + kind: "text"; + method: APIRequest["method"]; + params: APIRequest["params"]; + resolve: (value: unknown) => void; +} + +interface PendingBinaryRequest extends PendingRequestBase { + kind: "binary"; + method: SourceFileResponseMethod; + payload: Uint8Array; + resolve: (value: Uint8Array | undefined) => void; +} + +type PendingRequest = PendingTextRequest | PendingBinaryRequest; + +function isTextRequest(request: PendingRequest): request is PendingTextRequest { + return request.kind === "text"; +} + +/** Protocol client for an injected asynchronous transport. */ +export class TransportClient { + private readonly encoder = new TextEncoder(); + private readonly timing: TimingCollector | undefined; + private readonly transport: AsyncTransport; + private readonly maxResponseBytesPerPage: number | undefined; + private connected = false; + private closed = false; + private nextBatch: boolean | "manual" = false; + private batchGeneration = 0; + private batchedRequests: PendingRequest[] = []; + private readonly closedPromise: Promise; + private rejectClosed!: (reason: Error) => void; + + constructor(transport: AsyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + this.closedPromise = new Promise((_, reject) => { + this.rejectClosed = reject; + }); + void this.closedPromise.catch(() => {}); + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + connect(): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + this.connected = true; + return Promise.resolve(); + } + + batchContext(): { [Symbol.dispose](): void; } { + if (this.nextBatch === "manual") { + throw new Error("Already in a manual batch context"); + } + if (this.nextBatch) { + this.batchGeneration++; + this.nextBatch = false; + void this.doBatch(); + } + this.nextBatch = "manual"; + return { + [Symbol.dispose]: () => { + this.nextBatch = false; + this.scheduleBatch(); + }, + }; + } + + async apiRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + if (method === "initialize") { + return this.sendRequest(method, params); + } + const result = new Promise((resolve, reject) => { + this.batchedRequests.push({ kind: "text", method, params, resolve, reject }); + this.scheduleBatch(); + }); + return result; + } + + async apiRequestBinary( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + if (this.closed) throw new Error("Client is closed"); + if (!this.connected) { + await this.connect(); + } + if (this.closed) throw new Error("Client is closed"); + return new Promise((resolve, reject) => { + this.batchedRequests.push({ + kind: "binary", + method, + payload: this.encoder.encode(JSON.stringify(params)), + resolve, + reject, + }); + this.scheduleBatch(); + }); + } + + getTimingCollector(): TimingCollector | undefined { + return this.timing; + } + + async getTimingInfo(): Promise { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + if (!this.connected) { + return local; + } + const response = await this.invokeTransport(() => this.transport.request("getServerTiming", "")); + return combineTimingInfo(local, JSON.parse(response.value) as ServerTimingInfo); + } + + async resetTimingInfo(): Promise { + if (!this.timing) return; + this.timing.reset(); + if (this.connected) { + await this.invokeTransport(() => this.transport.request("resetServerTiming", "")); + } + } + + async close(): Promise { + this.closed = true; + this.rejectClosed(new Error("Client is closed")); + this.batchGeneration++; + this.nextBatch = false; + const requests = this.batchedRequests; + this.batchedRequests = []; + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + await this.transport.close(); + this.connected = false; + } + + private scheduleBatch(): void { + if (this.closed || this.nextBatch) return; + this.nextBatch = true; + const generation = ++this.batchGeneration; + queueMicrotask(() => { + if (this.batchGeneration !== generation || this.nextBatch !== true) return; + this.nextBatch = false; + void this.doBatch(); + }); + } + + private async doBatch(): Promise { + if (!this.batchedRequests.length) return; + const requests = this.batchedRequests; + this.batchedRequests = []; + if (this.closed) { + for (const { reject } of requests) { + reject(new Error("Client is closed")); + } + return; + } + try { + if (requests.length === 1 && requests[0].kind === "text") { + const request = requests[0]; + request.resolve(await this.sendRequest(request.method, request.params)); + return; + } + + if (requests.every(isTextRequest)) { + const params: BatchRequestsParams = { + requests: requests.map(request => ({ method: request.method, params: request.params })), + }; + if (this.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const response = await this.sendRequest("batchRequests", params) as BatchRequestsResponse; + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const page = await this.sendRequest("batchRequests", pageParams) as BatchRequestsResponse; + responses = responses.concat(page.responses); + continuationToken = page.continuationToken; + } + for (let i = 0; i < requests.length; i++) { + const { resolve, reject } = requests[i]; + const item = responses[i]; + if (item.error !== undefined) { + reject(new Error(item.error)); + } + else { + resolve(item.result); + } + } + return; + } + + for (const request of requests) { + if (request.kind === "text") { + request.resolve(await this.sendRequest(request.method, request.params)); + } + else { + request.resolve(await this.sendBinaryRequest(request.method, request.payload)); + } + } + } + catch (error) { + for (const { reject } of requests) reject(error); + } + } + + private async sendRequest( + method: K, + params: APIMethodInfo[K]["params"], + ): Promise { + const payload = JSON.stringify(params) ?? ""; + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.request(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length + ? JSON.parse(response.value) as APIMethodInfo[K]["result"] + : undefined as APIMethodInfo[K]["result"]; + } + + private async sendBinaryRequest(method: SourceFileResponseMethod, payload: Uint8Array): Promise { + const start = performance.now(); + const response = await this.invokeTransport(() => this.transport.requestBinary(method, payload)); + this.recordTiming(method, start, response.bytesSent, response.bytesReceived); + return response.value.length === 0 ? undefined : response.value; + } + + private invokeTransport(operation: () => T | PromiseLike): Promise { + if (this.closed) return Promise.reject(new Error("Client is closed")); + return Promise.race([ + Promise.resolve().then(operation), + this.closedPromise, + ]); + } + + private recordTiming(method: string, start: number, bytesSent: number, bytesReceived: number): void { + if (!this.timing) return; + this.timing.record({ + method, + roundTripMs: performance.now() - start, + bytesSent, + bytesReceived, + }); + } +} diff --git a/packages/typescript/src/api/node/encoder.ts b/packages/typescript/src/api/node/encoder.ts index 5ed4bdbf71d86..c70cec94b1e1b 100644 --- a/packages/typescript/src/api/node/encoder.ts +++ b/packages/typescript/src/api/node/encoder.ts @@ -1,4 +1,3 @@ -import { TextEncoder } from "node:util"; import type { FileReference, LiteralLikeNode, @@ -80,8 +79,12 @@ class StringTable { } } -let _encoder: TextEncoder | undefined; -function cachedEncoder(): TextEncoder { +interface Encoder { + encode(input?: string): Uint8Array; +} + +let _encoder: Encoder | undefined; +function cachedEncoder(): Encoder { return _encoder ??= new TextEncoder(); } @@ -376,5 +379,32 @@ export function encodeNode(node: Node): Uint8Array { * Encode a Uint8Array to a base64 string. */ export function uint8ArrayToBase64(data: Uint8Array): string { - return Buffer.from(data).toString("base64"); + const nativeToBase64 = (data as Uint8Array & { toBase64?: () => string; }).toBase64; + if (nativeToBase64) { + return nativeToBase64.call(data); + } + + const bufferConstructor = (globalThis as { + Buffer?: { + from(buffer: ArrayBufferLike, byteOffset: number, length: number): { + toString(encoding: "base64"): string; + }; + }; + }).Buffer; + if (bufferConstructor) { + return bufferConstructor.from(data.buffer, data.byteOffset, data.byteLength).toString("base64"); + } + + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let result = ""; + for (let i = 0; i < data.length; i += 3) { + const first = data[i]; + const second = data[i + 1]; + const third = data[i + 2]; + result += alphabet[first >> 2]; + result += alphabet[((first & 0x03) << 4) | ((second ?? 0) >> 4)]; + result += second === undefined ? "=" : alphabet[((second & 0x0F) << 2) | ((third ?? 0) >> 6)]; + result += third === undefined ? "=" : alphabet[third & 0x3F]; + } + return result; } diff --git a/packages/typescript/src/api/node/wtf8.ts b/packages/typescript/src/api/node/wtf8.ts index afd8eb2ade201..507e098a995d3 100644 --- a/packages/typescript/src/api/node/wtf8.ts +++ b/packages/typescript/src/api/node/wtf8.ts @@ -1,5 +1,3 @@ -import { Buffer } from "node:buffer"; - const surrogateLeadByte = 0xED; const surrogateSecondByteMin = 0xA0; const surrogateSecondByteMax = 0xBF; @@ -24,7 +22,7 @@ function getSurrogateCodeUnit(bytes: Uint8Array, index: number): number { } function hasSurrogateLeadByte(bytes: Uint8Array): boolean { - return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).indexOf(surrogateLeadByte) >= 0; + return bytes.includes(surrogateLeadByte); } function toUint8Array(input: Exclude): Uint8Array { diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 7e67f739a686c..8338f6084db30 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 4ec27f4805f98..2c48c0f6a80db 100644 --- a/packages/typescript/src/api/sync/api.ts +++ b/packages/typescript/src/api/sync/api.ts @@ -32,6 +32,11 @@ import { SymbolFlags } from "#enums/symbolFlags"; import { TypeFlags } from "#enums/typeFlags"; import { TypeFormatFlags } from "#enums/typeFormatFlags"; import { TypePredicateKind } from "#enums/typePredicateKind"; +import { + Client, + type ClientSocketOptions, + type ClientSpawnOptions, +} from "#syncClient"; import { type __String, type Declaration, @@ -63,8 +68,8 @@ import { } from "../node/node.ts"; import { Wtf8Decoder } from "../node/wtf8.ts"; import type { - APIOptions, LSPConnectionOptions, + SyncAPIOptions as APIOptions, } from "../options.ts"; import { createGetCanonicalFileName, @@ -110,11 +115,6 @@ import type { TimingAccumulators, TimingInfo, } from "../timing.ts"; -import { - Client, - type ClientSocketOptions, - type ClientSpawnOptions, -} from "./client.ts"; import type { AssertsIdentifierTypePredicate, AssertsThisTypePredicate, diff --git a/packages/typescript/src/api/sync/browserClient.ts b/packages/typescript/src/api/sync/browserClient.ts new file mode 100644 index 0000000000000..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 b174784d7006e..1c3fea4ce013b 100644 --- a/packages/typescript/src/api/sync/client.ts +++ b/packages/typescript/src/api/sync/client.ts @@ -1,43 +1,34 @@ import { fsCallbackNames } from "../fs.ts"; import { - type ClientOptions, type ClientSocketOptions, type ClientSpawnOptions, getAPIProcessArgs, isSpawnOptions, + isTransportOptions, resolveExePath, + type SyncClientOptions, } from "../options.ts"; -import type { - APIMethodInfo, - APIRequest, - BatchRequestsParams, - BatchRequestsResponse, - SourceFileResponseMethod, -} from "../proto.ts"; import { SyncRpcChannel } from "../syncChannel.ts"; -import { - combineTimingInfo, - disabledTimingInfo, - type ServerTimingInfo, - TimingCollector, - type TimingInfo, -} from "../timing.ts"; - -export type { ClientOptions, ClientSocketOptions, ClientSpawnOptions }; +import { TransportClient } from "./transportClient.ts"; -export class Client { - private channel: SyncRpcChannel; - private encoder = new TextEncoder(); - private timing: TimingCollector | undefined; - private maxResponseBytesPerPage: number | undefined; +export type { ClientSocketOptions, ClientSpawnOptions }; +export type { ClientTransportOptions, SyncClientOptions as ClientOptions } from "../options.ts"; +export type { SyncTransport } from "./transport.ts"; - constructor(options: ClientOptions) { +export class Client extends TransportClient { + constructor(options: SyncClientOptions) { + if (isTransportOptions(options)) { + if (options.fs !== undefined) { + options.transport.setFileSystem?.(options.fs); + } + super(options.transport, options.collectTiming ?? false, options.maxResponseBytesPerPage); + return; + } if (!isSpawnOptions(options)) { throw new Error("Socket connections are not yet supported in the sync client"); } const args = getAPIProcessArgs(options, false); - this.maxResponseBytesPerPage = options.maxResponseBytesPerPage; // Enable virtual FS callbacks for each provided FS function const enabledCallbacks: (typeof fsCallbackNames[number])[] = []; @@ -53,12 +44,8 @@ export class Client { } const collectTiming = options.collectTiming ?? false; - if (collectTiming) { - this.timing = new TimingCollector(); - } - const channel = new SyncRpcChannel(resolveExePath(options), args, collectTiming); - this.channel = channel; + super(channel, collectTiming, options.maxResponseBytesPerPage); if (options.fs) { for (const name of enabledCallbacks) { @@ -89,107 +76,4 @@ export class Client { } } } - - apiRequest(method: K, params?: APIMethodInfo[K]["params"]): APIMethodInfo[K]["result"] { - const encodedPayload = JSON.stringify(params); - const start = performance.now(); - const result = this.channel.requestSync(method, encodedPayload); - this.recordTiming(method, start); - if (result.length) { - return JSON.parse(result) as APIMethodInfo[K]["result"]; - } - return undefined as APIMethodInfo[K]["result"]; - } - - batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { - const params: BatchRequestsParams = { requests }; - if (this.maxResponseBytesPerPage !== undefined) { - params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const response = this.apiRequest("batchRequests", params); - let responses = response.responses; - let continuationToken = response.continuationToken; - while (continuationToken) { - const pageParams: BatchRequestsParams = { - requests: [], - continuationToken, - }; - if (this.maxResponseBytesPerPage !== undefined) { - pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; - } - const page = this.apiRequest("batchRequests", pageParams); - if (page.responses.length < 200) { - responses.push(...page.responses); - } - else { - // If the number of responses is approaching the max argument length, we need to concat instead of push - responses = responses.concat(page.responses); - } - continuationToken = page.continuationToken; - } - return { responses }; - } - - apiRequestBinary(method: K, params?: APIMethodInfo[K]["params"]): Uint8Array | undefined { - const start = performance.now(); - const result = this.channel.requestBinarySync(method, this.encoder.encode(JSON.stringify(params))); - this.recordTiming(method, start); - if (result.length === 0) return undefined; - return result; - } - - echo(payload: string): string { - return this.channel.requestSync("echo", payload); - } - - echoBinary(payload: Uint8Array): Uint8Array { - return this.channel.requestBinarySync("echo", payload); - } - - /** - * Returns a combined timing snapshot: client-measured round-trip and byte - * counts folded together with the server's own per-request processing time - * (fetched via a getServerTiming request) and estimated transport overhead. - */ - getTimingInfo(): TimingInfo { - if (!this.timing) { - return disabledTimingInfo(); - } - const local = this.timing.getInfo(); - // requestSync bypasses recordTiming, so this query does not pollute the - // client-side collector. - const result = this.channel.requestSync("getServerTiming", ""); - return combineTimingInfo(local, JSON.parse(result) as ServerTimingInfo); - } - - resetTimingInfo(): void { - if (!this.timing) return; - this.timing.reset(); - // Keep the server's collection in sync so combined totals stay meaningful. - this.channel.requestSync("resetServerTiming", ""); - } - - /** - * Returns the timing collector that per-node materialization is reported - * into, or undefined when timing collection is disabled. The returned - * collector is the same one folded into {@link getTimingInfo}, so - * materialization totals surface alongside request timings. - */ - getTimingCollector(): TimingCollector | undefined { - return this.timing; - } - - private recordTiming(method: string, start: number): void { - if (!this.timing) return; - this.timing.record({ - method, - roundTripMs: performance.now() - start, - bytesSent: this.channel.lastBytesSent, - bytesReceived: this.channel.lastBytesReceived, - }); - } - - close(): void { - this.channel.close(); - } } diff --git a/packages/typescript/src/api/sync/transport.ts b/packages/typescript/src/api/sync/transport.ts new file mode 100644 index 0000000000000..ecb20495d1eb2 --- /dev/null +++ b/packages/typescript/src/api/sync/transport.ts @@ -0,0 +1,16 @@ +/** + * 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; + 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..733c4deafc59d --- /dev/null +++ b/packages/typescript/src/api/sync/transportClient.ts @@ -0,0 +1,121 @@ +import type { + APIMethodInfo, + APIRequest, + BatchRequestsParams, + BatchRequestsResponse, + SourceFileResponseMethod, +} from "../proto.ts"; +import { + combineTimingInfo, + disabledTimingInfo, + type ServerTimingInfo, + TimingCollector, + type TimingInfo, +} from "../timing.ts"; +import type { SyncTransport } from "./transport.ts"; + +/** Protocol client shared by the process and embedded synchronous transports. */ +export class TransportClient { + private readonly encoder = new TextEncoder(); + private readonly timing: TimingCollector | undefined; + private readonly transport: SyncTransport; + private readonly maxResponseBytesPerPage: number | undefined; + + constructor(transport: SyncTransport, collectTiming: boolean, maxResponseBytesPerPage?: number) { + this.transport = transport; + this.maxResponseBytesPerPage = maxResponseBytesPerPage; + if (collectTiming) { + this.timing = new TimingCollector(); + } + } + + apiRequest(method: K, params?: APIMethodInfo[K]["params"]): APIMethodInfo[K]["result"] { + const encodedPayload = JSON.stringify(params); + const start = performance.now(); + const result = this.transport.requestSync(method, encodedPayload); + this.recordTiming(method, start); + if (result.length) { + return JSON.parse(result) as APIMethodInfo[K]["result"]; + } + return undefined as APIMethodInfo[K]["result"]; + } + + batchRequests(requests: readonly APIRequest[]): BatchRequestsResponse { + const params: BatchRequestsParams = { requests }; + if (this.maxResponseBytesPerPage !== undefined) { + params.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const response = this.apiRequest("batchRequests", params); + let responses = response.responses; + let continuationToken = response.continuationToken; + while (continuationToken) { + const pageParams: BatchRequestsParams = { + requests: [], + continuationToken, + }; + if (this.maxResponseBytesPerPage !== undefined) { + pageParams.maxResponseBytesPerPage = this.maxResponseBytesPerPage; + } + const page = this.apiRequest("batchRequests", pageParams); + if (page.responses.length < 200) { + responses.push(...page.responses); + } + else { + responses = responses.concat(page.responses); + } + continuationToken = page.continuationToken; + } + return { responses }; + } + + echo(payload: string): string { + return this.transport.requestSync("echo", payload); + } + + echoBinary(payload: Uint8Array): Uint8Array { + return this.transport.requestBinarySync("echo", payload); + } + + apiRequestBinary( + method: K, + params?: APIMethodInfo[K]["params"], + ): Uint8Array | undefined { + const start = performance.now(); + const result = this.transport.requestBinarySync(method, this.encoder.encode(JSON.stringify(params))); + this.recordTiming(method, start); + return result.length === 0 ? undefined : result; + } + + getTimingCollector(): TimingCollector | undefined { + return this.timing; + } + + getTimingInfo(): TimingInfo { + if (!this.timing) { + return disabledTimingInfo(); + } + const local = this.timing.getInfo(); + const result = this.transport.requestSync("getServerTiming", ""); + return combineTimingInfo(local, JSON.parse(result) as ServerTimingInfo); + } + + resetTimingInfo(): void { + if (!this.timing) return; + this.timing.reset(); + this.transport.requestSync("resetServerTiming", ""); + } + + close(): void { + this.transport.close(); + } + + private recordTiming(method: string, start: number): void { + if (!this.timing) return; + this.timing.record({ + method, + roundTripMs: performance.now() - start, + bytesSent: this.transport.lastBytesSent, + bytesReceived: this.transport.lastBytesReceived, + }); + } +} diff --git a/packages/typescript/test/async/transport.test.ts b/packages/typescript/test/async/transport.test.ts new file mode 100644 index 0000000000000..6993fc4c9e29b --- /dev/null +++ b/packages/typescript/test/async/transport.test.ts @@ -0,0 +1,353 @@ +import { + API, + type ParsedCommandLine, +} from "@typescript/typescript/unstable/async"; +import assert from "node:assert"; +import { + describe, + test, +} from "node:test"; +import { TransportClient } from "../../src/api/async/transportClient.ts"; +import type { + BatchRequestsParams, + BatchRequestsResponse, +} from "../../src/api/proto.ts"; + +describe("async transport", () => { + test("constructs an API over an injected transport", async () => { + const methods: string[] = []; + let batchParams: BatchRequestsParams | undefined; + let closed = false; + const api = new API({ + maxResponseBytesPerPage: 1, + transport: { + request(method, payload) { + methods.push(method); + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + case "batchRequests": { + batchParams = JSON.parse(payload) as BatchRequestsParams; + assert.ok(batchParams.requests); + const response: BatchRequestsResponse = { + responses: batchParams.requests.map(request => ({ + method: request.method, + result: { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + })), + }; + return { + value: JSON.stringify(response), + bytesSent: 0, + bytesReceived: 0, + }; + } + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() { + closed = true; + }, + }, + }); + + assert.deepStrictEqual(await api.parseCommandLine([]), { + options: {}, + fileNames: [], + errors: [], + }); + let batchRequests: Promise[]; + { + using _ = api.batchContext(); + batchRequests = [api.parseCommandLine([]), api.parseCommandLine([])]; + } + await Promise.all(batchRequests); + assert.deepStrictEqual(methods, ["initialize", "parseCommandLine", "batchRequests"]); + assert.strictEqual(batchParams?.maxResponseBytesPerPage, 1); + await api.close(); + assert.strictEqual(closed, true); + }); + + test("rejects requests queued when the transport closes", async () => { + const api = new API({ + transport: { + request(method) { + switch (method) { + case "initialize": + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + case "parseCommandLine": + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + default: + throw new Error(`Unexpected method: ${method}`); + } + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + await api.parseCommandLine([]); + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("can close while initialization is manually batched", async () => { + const api = new API({ + transport: { + request(method) { + if (method !== "initialize") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify({ + currentDirectory: "/", + useCaseSensitiveFileNames: true, + }), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const batch = api.batchContext(); + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await api.close(); + batch[Symbol.dispose](); + await rejected; + }); + + test("does not flush a manual batch from a stale microtask", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + if (method !== "parseCommandLine") { + throw new Error(`Unexpected method: ${method}`); + } + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const first = client.apiRequest("parseCommandLine", { commandLine: [] }); + const batch = client.batchContext(); + const second = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + assert.deepStrictEqual(methods, ["parseCommandLine"]); + batch[Symbol.dispose](); + await Promise.all([first, second]); + await client.close(); + }); + + test("keeps binary requests ordered inside a manual batch", async () => { + const methods: string[] = []; + const client = new TransportClient({ + request(method) { + methods.push(method); + return { + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }; + }, + requestBinary(method, payload) { + methods.push(method); + return { + value: payload, + bytesSent: payload.length, + bytesReceived: payload.length, + }; + }, + close() {}, + }, false); + + await client.connect(); + const batch = client.batchContext(); + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project", + file: "/index.ts", + }); + await Promise.resolve(); + assert.deepStrictEqual(methods, []); + batch[Symbol.dispose](); + await Promise.all([text, binary]); + assert.deepStrictEqual(methods, ["parseCommandLine", "getSourceFile"]); + await client.close(); + }); + + test("rejects requests that are still active when closed", async () => { + let resolveRequest: + | ((value: { + value: string; + bytesSent: number; + bytesReceived: number; + }) => void) + | undefined; + const client = new TransportClient({ + request() { + return new Promise(resolve => { + resolveRequest = resolve; + }); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + await client.connect(); + const request = client.apiRequest("parseCommandLine", { commandLine: [] }); + await Promise.resolve(); + await client.close(); + await assert.rejects(request, /Client is closed/); + resolveRequest?.({ + value: JSON.stringify( + { + options: {}, + fileNames: [], + errors: [], + } satisfies ParsedCommandLine, + ), + bytesSent: 0, + bytesReceived: 0, + }); + }); + + test("can close while initialization is in flight", async () => { + const api = new API({ + transport: { + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, + }); + + const request = api.parseCommandLine([]); + const rejected = assert.rejects(request, /Client is closed/); + await Promise.resolve(); + await api.close(); + await rejected; + }); + + test("rejects timing requests when closed", async () => { + const client = new TransportClient({ + request() { + return new Promise(() => {}); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, true); + + await client.connect(); + const timing = client.getTimingInfo(); + const reset = client.resetTimingInfo(); + await client.close(); + await assert.rejects(timing, /Client is closed/); + await assert.rejects(reset, /Client is closed/); + }); + + test("does not queue requests after closing during connect", async () => { + const client = new TransportClient({ + request() { + throw new Error("Unexpected request"); + }, + requestBinary() { + throw new Error("Unexpected binary request"); + }, + close() {}, + }, false); + + const text = client.apiRequest("parseCommandLine", { commandLine: [] }); + const binary = client.apiRequestBinary("getSourceFile", { + snapshot: 1, + project: "project", + file: "/index.ts", + }); + await client.close(); + await assert.rejects(text, /Client is closed/); + await assert.rejects(binary, /Client is closed/); + }); +}); diff --git a/packages/typescript/test/browser/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 c214e0675f56e..6e88571865a80 100644 --- a/packages/typescript/test/encoder.test.ts +++ b/packages/typescript/test/encoder.test.ts @@ -35,6 +35,7 @@ import { import { encodeNode, encodeSourceFile, + uint8ArrayToBase64, } from "../src/api/node/encoder.ts"; import { RemoteNode, @@ -57,6 +58,16 @@ function decode(data: Uint8Array): RemoteSourceFile { } describe("Encoder", () => { + test("encodes base64 using the available platform implementation", () => { + const data = new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4); + assert.strictEqual(uint8ArrayToBase64(data), "AQID"); + + Object.defineProperty(data, "toBase64", { + value: () => "native", + }); + assert.strictEqual(uint8ArrayToBase64(data), "native"); + }); + test("encodes empty source file", () => { const sf = makeSF("", "/test.ts", []); diff --git a/packages/typescript/test/sync/transport.test.ts b/packages/typescript/test/sync/transport.test.ts new file mode 100644 index 0000000000000..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..8d331a43636c5 --- /dev/null +++ b/packages/typescript/test/sync/wasm.test.ts @@ -0,0 +1,89 @@ +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.updateSnapshot({ + openFiles: ["/src/index.ts"], + fileChanges: { + changed: ["/tsconfig.json", "/src/index.ts"], + deleted: [], + }, + }); + const project = snapshot.getDefaultProjectForFile("/src/index.ts"); + assert.ok(project); + const sourceFile = project.program.getSourceFile("/src/index.ts"); + assert.ok(sourceFile); + const statement = sourceFile.statements[0]; + assert.ok(isVariableStatement(statement)); + const name = statement.declarationList.declarations[0].name; + assert.ok(isIdentifier(name)); + const type = project.checker.getTypeAtLocation(name); + assert.strictEqual(project.checker.typeToString(type), "42"); + + const timing = api.getTimingInfo(); + assert.strictEqual(timing.enabled, true); + assert.ok(timing.totals.requestCount > 0); + assert.ok(timing.totals.serverTimeMs >= 0); + api.close(); + + const secondTransport = new WasmTransport({ instance, cwd: "/" }); + const second = new SyncAPI({ transport: secondTransport }); + assert.strictEqual(secondTransport.requestSync("echo", "recreated"), "recreated"); + second.close(); + + const asyncTransport = new WasmTransport({ instance, cwd: "/" }); + const asyncAPI = new AsyncAPI({ transport: asyncTransport }); + asyncTransport.setFile("/tsconfig.json", "{}"); + asyncTransport.setFile("/src/async.ts", "export const value = 42 as const;"); + const asyncSnapshot = await asyncAPI.updateSnapshot({ + openFiles: ["/src/async.ts"], + fileChanges: { + changed: ["/tsconfig.json", "/src/async.ts"], + deleted: [], + }, + }); + const asyncProject = await asyncSnapshot.getDefaultProjectForFile("/src/async.ts"); + assert.ok(asyncProject); + const asyncSourceFile = await asyncProject.program.getSourceFile("/src/async.ts"); + assert.ok(asyncSourceFile); + const asyncStatement = asyncSourceFile.statements[0]; + assert.ok(isVariableStatement(asyncStatement)); + const asyncName = asyncStatement.declarationList.declarations[0].name; + assert.ok(isIdentifier(asyncName)); + const asyncType = await asyncProject.checker.getTypeAtLocation(asyncName); + assert.strictEqual(await asyncProject.checker.typeToString(asyncType), "42"); + await asyncAPI.close(); + }); +}); diff --git a/tools/pipelines/typescript-publish.yml b/tools/pipelines/typescript-publish.yml index b85898c865e99..621b72e1e5e11 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -73,6 +73,10 @@ extends: targetPath: '$(Build.ArtifactStagingDirectory)/npm-main' artifactName: 'npm-main' displayName: 'Publish npm-main artifact' + - output: pipelineArtifact + targetPath: '$(Build.ArtifactStagingDirectory)/npm-api-wasm' + artifactName: 'npm-api-wasm' + displayName: 'Publish npm-api-wasm artifact' steps: - checkout: none @@ -83,24 +87,31 @@ extends: src="$(Pipeline.Workspace)/npm" platformDst="$(Build.ArtifactStagingDirectory)/npm-platform" mainDst="$(Build.ArtifactStagingDirectory)/npm-main" - mkdir -p "$platformDst" "$mainDst" + wasmDst="$(Build.ArtifactStagingDirectory)/npm-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 jq -r '.stages[0][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$platformDst/" jq -r '.stages[1][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$mainDst/" + jq -r '.stages[2][].filename' "$manifest" | xargs -I {} cp -v "$src/{}" "$wasmDst/" platformCount="$(find "$platformDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" mainCount="$(find "$mainDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" - echo "##[section]Staged $platformCount platform package(s) and $mainCount main package(s)." + wasmCount="$(find "$wasmDst" -maxdepth 1 -name '*.tgz' -type f | wc -l)" + echo "##[section]Staged $platformCount platform package(s), $mainCount main package(s), and $wasmCount WASM package(s)." if [ "$mainCount" -ne 1 ]; then echo "Expected 1 main package, found $mainCount." >&2 exit 1 fi + if [ "$wasmCount" -ne 1 ]; then + echo "Expected 1 WASM package, found $wasmCount." >&2 + exit 1 + fi displayName: 'Stage npm artifacts' - stage: Publish_npm @@ -122,6 +133,9 @@ extends: - input: pipelineArtifact artifactName: 'npm-main' targetPath: '$(Pipeline.Workspace)/npm-main' + - input: pipelineArtifact + artifactName: 'npm-api-wasm' + targetPath: '$(Pipeline.Workspace)/npm-api-wasm' steps: - checkout: none @@ -146,6 +160,16 @@ extends: owners: 'jabaile@microsoft.com' approvers: 'alexchi@microsoft.com' + - template: MicroBuild.Publish.yml@MicroBuildTemplate + parameters: + intent: 'PackageDistribution' + contentType: 'npm' + contentSource: 'Folder' + folderLocation: '$(Pipeline.Workspace)/npm-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..f5b277d43524a --- /dev/null +++ b/tsc/cmd/tsc-api-wasm/main.go @@ -0,0 +1,179 @@ +//go:build wasip1 + +// Command tsc-api-wasm builds the TypeScript API server as a WebAssembly reactor. +package main + +import ( + "context" + "fmt" + "runtime/debug" + "unsafe" + + "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" + "github.com/microsoft/TypeScript/tsc/internal/json" +) + +func main() {} + +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)) + } + 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]))) +} diff --git a/tsc/internal/api/wasmreactor/reactor.go b/tsc/internal/api/wasmreactor/reactor.go new file mode 100644 index 0000000000000..785fde36694a8 --- /dev/null +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -0,0 +1,113 @@ +// 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"` +} + +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, + }, + } + reactor := &Reactor{ + ctx: ctx, + cancel: cancel, + session: api.NewStandaloneSession(sessionInit, &api.SessionOptions{UseBinaryResponses: true}), + files: files, + } + if options.CollectTiming { + reactor.timing = ipc.NewServerTimingCollector() + } + return reactor +} + +func (r *Reactor) HandleRequest(method string, payload []byte) (Response, error) { + switch method { + case ipc.MethodGetServerTiming: + return marshalResponse(ipc.ServerTimingSnapshot(r.timing)) + case ipc.MethodResetServerTiming: + if r.timing != nil { + r.timing.Reset() + } + return marshalResponse(nil) + } + + start := time.Now() + result, err := r.session.HandleRequest(r.ctx, method, json.Value(payload)) + if r.timing != nil { + r.timing.Record(method, time.Since(start)) + } + if err != nil { + return Response{}, err + } + if raw, ok := result.(api.RawBinary); ok { + return Response{Data: []byte(raw)}, nil + } + return marshalResponse(result) +} + +func marshalResponse(result any) (Response, error) { + encoded, err := json.Marshal(result) + if err != nil { + return Response{}, err + } + return Response{Data: encoded}, nil +} + +func (r *Reactor) SetFile(path string, content string) error { + return r.files.WriteFile(path, content) +} + +func (r *Reactor) 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 458d24f16a3803ab19be5a23f6c49b08bbdbaac1 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 2/7] 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 | 371 +++++++++++++++++- packages/api-wasm/src/index.ts | 41 +- packages/api-wasm/src/wasi.ts | 137 ++++++- 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/options.ts | 4 + .../typescript/src/api/sync/browserClient.ts | 1 + packages/typescript/src/api/sync/transport.ts | 2 + .../typescript/test/browser/apiWrapper.ts | 169 ++++++++ packages/typescript/test/browser/asyncAPI.ts | 23 ++ packages/typescript/test/browser/harness.ts | 103 +++++ .../typescript/test/browser/processShim.ts | 11 + .../typescript/test/browser/suite.test.ts | 259 ++++++++++++ packages/typescript/test/browser/syncAPI.ts | 23 ++ .../typescript/test/sync/transport.test.ts | 16 + tsc/cmd/tsc-api-wasm/main.go | 69 ++++ tsc/internal/api/wasmreactor/reactor.go | 17 +- tsc/internal/api/wasmreactor/reactor_test.go | 5 +- 20 files changed, 1237 insertions(+), 20 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 7bd21e599c4f6..7902ccb8f420a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2224,6 +2224,20 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2241,6 +2255,22 @@ "dev": true, "license": "MIT" }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -2410,6 +2440,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", @@ -2687,6 +2736,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", @@ -2700,6 +2767,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3081,6 +3166,22 @@ "node": ">=8" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -3146,6 +3247,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3288,6 +3399,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3500,8 +3624,7 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "ISC", - "optional": true + "license": "ISC" }, "node_modules/ini": { "version": "1.3.8", @@ -3511,6 +3634,36 @@ "license": "ISC", "optional": true }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -3547,6 +3700,26 @@ "node": ">=8" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3579,6 +3752,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -3589,6 +3779,41 @@ "node": ">=0.12.0" } }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4154,6 +4379,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4387,6 +4660,16 @@ "node": ">=4" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -4416,6 +4699,16 @@ "node": ">=10" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4661,6 +4954,24 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4712,6 +5023,24 @@ "node": ">=10" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -5339,6 +5668,20 @@ "dev": true, "license": "MIT" }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5457,6 +5800,28 @@ "node": ">=18" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -5552,7 +5917,9 @@ }, "devDependencies": { "@types/node": "^22.20.1", + "assert": "^2.1.0", "playwright": "^1.62.1", + "process": "^0.11.10", "tinybench": "^6.1.3", "vscode-jsonrpc": "^9.0.1" }, diff --git a/packages/api-wasm/src/index.ts b/packages/api-wasm/src/index.ts index 82db98dbbc2ad..42ef613ab987d 100644 --- a/packages/api-wasm/src/index.ts +++ b/packages/api-wasm/src/index.ts @@ -1,5 +1,9 @@ export { wasmURL } from "#wasmURL"; -export { instantiateWasm, type InstantiateWasmOptions } from "./wasi.ts"; +export { instantiateWasm, type InstantiateWasmOptions, instantiateWasmSync } from "./wasi.ts"; +import { + setWasmFileSystem, + type WasmFileSystem, +} from "./wasi.ts"; export interface WasmReactorExports { memory: { readonly buffer: ArrayBufferLike; }; @@ -8,6 +12,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; @@ -26,6 +31,7 @@ export interface WasmTransportOptions { cwd?: string; useCaseSensitiveFileNames?: boolean; collectTiming?: boolean; + fs?: WasmFileSystem; } const encoder = new TextEncoder(); @@ -37,11 +43,14 @@ export class WasmTransport { lastBytesReceived = 0; private readonly exports: WasmReactorExports; + private readonly instance: WasmReactorInstance; private requestPointer = 0; private closed = false; constructor(options: WasmTransportOptions) { + this.instance = options.instance; this.exports = getReactorExports(options.instance); + setWasmFileSystem(options.instance, options.fs); const sessionOptions = encoder.encode(JSON.stringify({ cwd: options.cwd ?? "/", useCaseSensitiveFileNames: options.useCaseSensitiveFileNames, @@ -49,10 +58,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))); } @@ -89,6 +105,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); @@ -101,7 +130,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 { @@ -151,6 +185,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 a6de390a52d2c..2b27712fdd9d9 100644 --- a/packages/api-wasm/src/wasi.ts +++ b/packages/api-wasm/src/wasi.ts @@ -5,21 +5,64 @@ const errnoBadFileDescriptor = 8; const errnoInvalidArgument = 28; const errnoNoSys = 52; const fileTypeCharacterDevice = 2; +const eventTypeClock = 0; +const subscriptionClockAbstime = 1; export interface InstantiateWasmOptions { stdout?: (text: string) => void; stderr?: (text: string) => void; } +export interface WasmFileSystem { + writeFile?(path: string, data: string): void; +} + +interface WasmHost { + setFileSystem(fs: WasmFileSystem | undefined): void; +} + +const wasmHosts = new WeakMap(); + +export function setWasmFileSystem(instance: WasmReactorInstance, fs: WasmFileSystem | undefined): void { + const host = wasmHosts.get(instance); + if (!host) { + if (fs === undefined) return; + throw new Error("The TypeScript WASM reactor was not created by instantiateWasm"); + } + host.setFileSystem(fs); +} + /** Instantiate and initialize the TypeScript reactor with its minimal WASI host. */ export async function instantiateWasm( module: WebAssembly.Module, options: InstantiateWasmOptions = {}, ): Promise { + const host = createWasiHost(options); + const instance = await WebAssembly.instantiate(module, host.imports); + return host.initialize(instance); +} + +/** Synchronously instantiate and initialize the TypeScript reactor with its minimal WASI host. */ +export function instantiateWasmSync( + module: WebAssembly.Module, + options: InstantiateWasmOptions = {}, +): WasmReactorInstance { + const host = createWasiHost(options); + const instance = new WebAssembly.Instance(module, host.imports); + return host.initialize(instance); +} + +function createWasiHost(options: InstantiateWasmOptions): { + imports: WebAssembly.Imports; + initialize(instance: WebAssembly.Instance): WasmReactorInstance; +} { let instance: WebAssembly.Instance | undefined; const stdout = options.stdout ?? (text => console.log(text)); const stderr = options.stderr ?? (text => console.error(text)); const decoders = new Map(); + const encoder = new TextEncoder(); + let fileSystem: WasmFileSystem | undefined; + let hostError = new Uint8Array(); function getMemory(): WebAssembly.Memory { const memory = instance?.exports.memory; @@ -119,6 +162,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; } @@ -144,7 +244,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}`); }, @@ -152,13 +252,30 @@ 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(); - 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; + }, + }); + return instance; + }, + }; } diff --git a/packages/typescript/package.json b/packages/typescript/package.json index c634fec93babd..7884b4d4d5aa8 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.3", "vscode-jsonrpc": "^9.0.1" } 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 be16da8109351..fb773264725ca 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 aaa5d719ed879..28fd716f2f96d 100644 --- a/packages/typescript/src/api/async/transport.ts +++ b/packages/typescript/src/api/async/transport.ts @@ -13,5 +13,7 @@ export interface AsyncTransport { method: string, payload: Uint8Array, ): AsyncTransportResponse | PromiseLike>; + setFileSystem?(fs: FileSystem | undefined): void; close(): void | PromiseLike; } +import type { FileSystem } from "../fs.ts"; diff --git a/packages/typescript/src/api/options.ts b/packages/typescript/src/api/options.ts index 8338f6084db30..0cf87286dca2e 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 ecb20495d1eb2..589eb35914966 100644 --- a/packages/typescript/src/api/sync/transport.ts +++ b/packages/typescript/src/api/sync/transport.ts @@ -12,5 +12,7 @@ export interface SyncTransport { requestSync(method: string, payload: string): string; requestBinarySync(method: string, payload: Uint8Array): Uint8Array; + setFileSystem?(fs: FileSystem | undefined): void; close(): void; } +import type { FileSystem } from "../fs.ts"; diff --git a/packages/typescript/test/browser/apiWrapper.ts b/packages/typescript/test/browser/apiWrapper.ts new file mode 100644 index 0000000000000..e162d71e566e5 --- /dev/null +++ b/packages/typescript/test/browser/apiWrapper.ts @@ -0,0 +1,169 @@ +import { + instantiateWasm, + type WasmReactorInstance, + WasmTransport, +} from "@typescript/api-wasm"; +import type { FileSystem } from "@typescript/typescript/unstable/fs"; +import { + type APIFileChanges, + resolveFileName, +} from "../../src/api/proto.ts"; + +interface BrowserAPIOptions { + cwd?: string; + fs?: FileSystem; + collectTiming?: boolean; + maxResponseBytesPerPage?: number; + transport?: object; +} + +const availableInstances: WasmReactorInstance[] = []; +const mirroredFiles = new WeakMap>(); + +export async function initializeBrowserAPIInstances(module: WebAssembly.Module, count: number): Promise { + availableInstances.push( + ...await Promise.all( + Array.from({ length: count }, () => instantiateWasm(module)), + ), + ); +} + +class BrowserWasmTransport extends WasmTransport { + private returned = false; + + constructor( + private readonly reactorInstance: WasmReactorInstance, + options: ConstructorParameters[0], + ) { + super(options); + } + + override close(): void { + if (this.returned) return; + super.close(); + this.returned = true; + availableInstances.push(this.reactorInstance); + } +} + +export function createBrowserAPIOptions(options: BrowserAPIOptions): { + options: BrowserAPIOptions; + transport?: WasmTransport; +} { + if (options.transport) return { options }; + const instance = availableInstances.pop(); + if (!instance) { + throw new Error("No initialized TypeScript WASM reactor is available"); + } + const transport = new BrowserWasmTransport(instance, { + instance, + cwd: options.cwd ?? "/", + ...options.fs === undefined ? {} : { fs: options.fs }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + }); + if (options.fs) { + synchronizeFileSystem(options.fs, transport); + } + return { + options: { + transport, + ...options.fs === undefined ? {} : { fs: options.fs }, + ...options.collectTiming === undefined ? {} : { collectTiming: options.collectTiming }, + ...options.maxResponseBytesPerPage === undefined ? {} : { maxResponseBytesPerPage: options.maxResponseBytesPerPage }, + }, + transport, + }; +} + +export function wrapFileUpdates(api: T, fs: FileSystem | undefined, transport: WasmTransport | undefined): void { + if (!fs || !transport) return; + wrapMethod("updateSnapshot", args => { + const params = args[0] as { fileChanges?: APIFileChanges; } | undefined; + syncFileChanges(fs, transport, params?.fileChanges); + }); + wrapMethod("createProgram", args => { + syncFileChanges(fs, transport, args[3] as APIFileChanges | undefined); + }); + + function wrapMethod(name: string, before: (args: unknown[]) => void): void { + const target = api as Record; + const original = target[name] as ((...args: unknown[]) => unknown) & { + gen?: (...args: unknown[]) => Generator; + }; + const bound = original.bind(api); + const wrapped = (...args: unknown[]) => { + before(args); + return bound(...args); + }; + if (original.gen) { + const boundGen = original.gen.bind(api); + wrapped.gen = (...args: unknown[]) => { + before(args); + return boundGen(...args); + }; + } + Object.defineProperty(api, name, { + configurable: true, + value: wrapped, + }); + } +} + +function synchronizeFileSystem(fs: FileSystem, transport: WasmTransport): void { + const previous = mirroredFiles.get(transport) ?? new Set(); + const current = new Set(); + visit("/"); + for (const file of previous) { + if (!current.has(file)) { + transport.removeFile(file); + } + } + mirroredFiles.set(transport, current); + + function visit(directory: string): void { + const entries = fs.getAccessibleEntries?.(directory); + if (!entries) return; + for (const file of entries.files) { + const path = join(directory, file); + current.add(path); + const content = fs.readFile?.(path); + if (typeof content === "string") { + transport.setFile(path, content); + } + } + for (const child of entries.directories) { + visit(join(directory, child)); + } + } +} + +function syncFileChanges( + fs: FileSystem, + transport: WasmTransport, + changes: APIFileChanges | undefined, +): void { + if (changes?.invalidateAll) { + synchronizeFileSystem(fs, transport); + } + for (const file of [...changes?.changed ?? [], ...changes?.created ?? []]) { + const fileName = resolveFileName(file); + const content = fs.readFile?.(fileName); + if (typeof content === "string") { + transport.setFile(fileName, content); + mirroredFiles.get(transport)?.add(fileName); + } + else { + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); + } + } + for (const file of changes?.deleted ?? []) { + const fileName = resolveFileName(file); + transport.removeFile(fileName); + mirroredFiles.get(transport)?.delete(fileName); + } +} + +function join(directory: string, name: string): string { + return directory === "/" ? `/${name}` : `${directory}/${name}`; +} diff --git a/packages/typescript/test/browser/asyncAPI.ts b/packages/typescript/test/browser/asyncAPI.ts new file mode 100644 index 0000000000000..efd76e0e5475e --- /dev/null +++ b/packages/typescript/test/browser/asyncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/async/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/async/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as APIOptions & { fs?: import("../../src/api/fs.ts").FileSystem; }; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/browser/harness.ts b/packages/typescript/test/browser/harness.ts new file mode 100644 index 0000000000000..89c0d3c2207d9 --- /dev/null +++ b/packages/typescript/test/browser/harness.ts @@ -0,0 +1,103 @@ +interface RegisteredTest { + name: string; + run: () => unknown; +} + +export interface BrowserTestFailure { + name: string; + message: string; + stack?: string; +} + +export interface BrowserTestSkip { + name: string; + reason: string; +} + +export interface BrowserTestResults { + passed: number; + skipped: BrowserTestSkip[]; + failures: BrowserTestFailure[]; +} + +export interface BrowserTestExclusion { + pattern: RegExp; + reason: string; +} + +const tests: RegisteredTest[] = []; +const suites: string[] = []; + +export function describe(name: string, run: () => void): void { + suites.push(name); + try { + run(); + } + finally { + suites.pop(); + } +} + +export function test(name: string, run: () => unknown): void { + tests.push({ + name: [...suites, name].join(" > "), + run, + }); +} + +export async function runRegisteredTests(exclusions: readonly BrowserTestExclusion[], timeoutMs = 10_000): Promise { + const failures: BrowserTestFailure[] = []; + const skipped: BrowserTestSkip[] = []; + let passed = 0; + for (const registered of tests) { + const exclusion = exclusions.find(entry => entry.pattern.test(registered.name)); + if (exclusion) { + skipped.push({ + name: registered.name, + reason: exclusion.reason, + }); + continue; + } + try { + Reflect.set(globalThis, "browserTestProgress", registered.name); + let timer: ReturnType | undefined; + try { + await Promise.race([ + Promise.resolve().then(registered.run), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Test timed out after ${timeoutMs}ms`)), timeoutMs); + }), + ]); + } + finally { + clearTimeout(timer); + } + passed++; + } + catch (error) { + const normalized = normalizeError(error); + failures.push({ + name: registered.name, + message: normalized.message, + ...normalized.stack === undefined ? {} : { stack: normalized.stack }, + }); + } + } + return { passed, skipped, failures }; +} + +function normalizeError(error: unknown): Error { + if ( + error instanceof Error + && error.name === "SuppressedError" + && "error" in error + && "suppressed" in error + ) { + const primary = normalizeError(error.error); + const suppressed = normalizeError(error.suppressed); + return new Error(`${primary.message}\nSuppressed during disposal: ${suppressed.message}`, { + cause: primary, + }); + } + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/packages/typescript/test/browser/processShim.ts b/packages/typescript/test/browser/processShim.ts new file mode 100644 index 0000000000000..ecb611abf6d32 --- /dev/null +++ b/packages/typescript/test/browser/processShim.ts @@ -0,0 +1,11 @@ +import process from "process"; + +export { process }; + +export function setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): ReturnType { + return setTimeout(callback, 0, ...args); +} + +export function clearImmediate(handle: ReturnType): void { + clearTimeout(handle); +} diff --git a/packages/typescript/test/browser/suite.test.ts b/packages/typescript/test/browser/suite.test.ts new file mode 100644 index 0000000000000..4a3fca2c93ada --- /dev/null +++ b/packages/typescript/test/browser/suite.test.ts @@ -0,0 +1,259 @@ +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: 335, + sync: 386, +} 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.", + }, +] 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$/, + reason: "Benchmarks require Node.js process and filesystem APIs.", + }, + { + pattern: /Parse-clone-emit roundtrip$/, + reason: "The roundtrip fixture discovers source files through Node.js glob and filesystem APIs.", + }, + { + pattern: /parseJsonConfigFileContent accepts non-object JSON$|parseConfigFile$|project exposes parsedCommandLine$/, + reason: "These assertions depend on insertion-ordered callback filesystem directory listings.", + }, + ]); + } + catch (error) { + globalThis.browserTestError = { + message: String(error), + stack: error instanceof Error ? error.stack : undefined, + }; + } + `, + loader: "ts", + resolveDir: packageDir, + }, + alias: { + typescript: "@typescript/typescript", + }, + bundle: true, + conditions: ["@typescript/source", "browser"], + format: "esm", + inject: [path.join(browserDir, "processShim.ts")], + platform: "browser", + plugins: [browserTestPlugin(mode)], + write: false, + }); + assert.strictEqual(result.outputFiles.length, 1); + bundles.set(mode, result.outputFiles[0].contents); + } + const wasm = await readFile(wasmURL); + + server.on("request", (request, response) => { + if (request.url === "/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$/ }, args => { + return build.resolve("assert", { + kind: args.kind, + resolveDir: packageDir, + }); + }); + build.onResolve({ filter: /^node:fs$/ }, () => ({ path: "node-fs", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:path$/ }, () => ({ path: "node-path", namespace: "browser-shim" })); + build.onResolve({ filter: /^node:url$/ }, () => ({ path: "node-url", namespace: "browser-shim" })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/async$/ }, () => ({ + path: mode === "async" ? api : path.join(browserDir, "asyncAPI.ts"), + })); + build.onResolve({ filter: /^@typescript\/typescript\/unstable\/sync$/ }, () => ({ + path: mode === "sync" ? api : path.join(browserDir, "syncAPI.ts"), + })); + build.onResolve({ filter: /^\.\/api\.bench\.ts$/ }, () => ({ path: "api-bench", namespace: "browser-shim" })); + build.onLoad({ filter: /.*/, namespace: "browser-shim" }, args => { + switch (args.path) { + case "api-bench": + return { contents: "export function runBenchmarks() { throw new Error('Benchmarks are not available in a browser'); }" }; + case "node-fs": + return { contents: "export function globSync() { throw new Error('globSync is not available in a browser'); }" }; + case "node-path": + return { + contents: ` + export function resolve(...parts) { + const path = parts.join("/"); + const normalized = []; + for (const part of path.split("/")) { + if (!part || part === ".") continue; + if (part === "..") normalized.pop(); + else normalized.push(part); + } + return "/" + normalized.join("/"); + } + `, + }; + case "node-url": + return { contents: "export function fileURLToPath(url) { return new URL(url).pathname; }" }; + } + return undefined; + }); + }, + }; +} diff --git a/packages/typescript/test/browser/syncAPI.ts b/packages/typescript/test/browser/syncAPI.ts new file mode 100644 index 0000000000000..08c385a4e22e6 --- /dev/null +++ b/packages/typescript/test/browser/syncAPI.ts @@ -0,0 +1,23 @@ +import { + API as BaseAPI, + type APIOptions, +} from "../../src/api/sync/api.ts"; +import { + createBrowserAPIOptions, + wrapFileUpdates, +} from "./apiWrapper.ts"; + +export * from "../../src/api/sync/api.ts"; + +export type API = BaseAPI; + +export const API: typeof BaseAPI = new Proxy(BaseAPI, { + construct(_target, args) { + const options = (args[0] ?? {}) as APIOptions; + const browserOptions = options as APIOptions & { fs?: import("../../src/api/fs.ts").FileSystem; }; + const created = createBrowserAPIOptions(browserOptions); + const api = new BaseAPI(created.options as APIOptions); + wrapFileUpdates(api, browserOptions.fs, created.transport); + return api; + }, +}); diff --git a/packages/typescript/test/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 f5b277d43524a..ed6f55c4f5f58 100644 --- a/tsc/cmd/tsc-api-wasm/main.go +++ b/tsc/cmd/tsc-api-wasm/main.go @@ -11,6 +11,8 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/api/wasmreactor" "github.com/microsoft/TypeScript/tsc/internal/json" + "github.com/microsoft/TypeScript/tsc/internal/vfs" + "github.com/microsoft/TypeScript/tsc/internal/vfs/wrapvfs" ) func main() {} @@ -43,6 +45,22 @@ func createSession(optionsPtr uint32, optionsLen uint32) (status uint32) { if err := json.Unmarshal(readMemory(optionsPtr, optionsLen), &options); err != nil { return fail(fmt.Sprintf("invalid session options: %v", err)) } + options.WrapFS = func(files vfs.FS) vfs.FS { + return wrapvfs.Wrap(files, wrapvfs.Replacements{ + WriteFile: func(path string, data string) error { + 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 @@ -121,6 +139,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 { @@ -177,3 +220,29 @@ func bytesPointer(bytes []byte) uint32 { } return uint32(uintptr(unsafe.Pointer(&bytes[0]))) } + +func bytePointer(bytes []byte) *byte { + if len(bytes) == 0 { + return nil + } + return &bytes[0] +} + +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 785fde36694a8..bbf1d75ba48ce 100644 --- a/tsc/internal/api/wasmreactor/reactor.go +++ b/tsc/internal/api/wasmreactor/reactor.go @@ -17,9 +17,10 @@ import ( ) type Options struct { - Cwd string `json:"cwd"` - UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` - CollectTiming bool `json:"collectTiming"` + Cwd string `json:"cwd"` + UseCaseSensitiveFileNames *bool `json:"useCaseSensitiveFileNames"` + CollectTiming bool `json:"collectTiming"` + WrapFS func(vfs.FS) vfs.FS `json:"-"` } type Response struct { @@ -41,10 +42,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(), @@ -101,6 +106,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 fa87883ef56305d6978cb78306950bcf6ba6dc66 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 3/7] 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. --- .../typescript/src/api/async/browserClient.ts | 4 +++- packages/typescript/src/api/async/client.ts | 4 +++- .../typescript/src/api/sync/browserClient.ts | 4 +++- packages/typescript/test/browser/suite.test.ts | 5 +++++ packages/typescript/test/sync/wasm.test.ts | 18 +++++++++++++++++- 5 files changed, 31 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 fb773264725ca..64ad7291f699c 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 4a3fca2c93ada..e1ec3872cac58 100644 --- a/packages/typescript/test/browser/suite.test.ts +++ b/packages/typescript/test/browser/suite.test.ts @@ -194,6 +194,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 8d331a43636c5..3d9cc88bd6367 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( @@ -51,6 +61,12 @@ describe("API over WebAssembly", () => { 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"], + }); + assert.strictEqual(emittedFiles.get("/src/index.js"), "export const value = 42;\n"); const timing = api.getTimingInfo(); assert.strictEqual(timing.enabled, true); From e029149331b9fee41e12f446fd285370a1f8c0cd 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 4/7] 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 42ef613ab987d..4f1a9754bddaa 100644 --- a/packages/api-wasm/src/index.ts +++ b/packages/api-wasm/src/index.ts @@ -50,7 +50,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, @@ -58,10 +57,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 3d9cc88bd6367..2544a8db9dc96 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 7d4a62dcc49bdc350e8ad0c51d6d242a5ec37897 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 5/7] 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 4f1a9754bddaa..7d9b7d699aa52 100644 --- a/packages/api-wasm/src/index.ts +++ b/packages/api-wasm/src/index.ts @@ -59,7 +59,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 2544a8db9dc96..224e337dd634c 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 3f51be7ba12af1bff3d6ca54c375a3b50b3ec0a8 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 6/7] 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 | 94 ++++++++++++---- package-lock.json | 37 +++++-- 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 | 50 ++++++--- .../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} | 75 ++++++++----- 37 files changed, 605 insertions(+), 155 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 (81%) 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} (76%) diff --git a/Herebyfile.mjs b/Herebyfile.mjs index 95770e852b63e..bdfaeb3ddfd67 100644 --- a/Herebyfile.mjs +++ b/Herebyfile.mjs @@ -1,6 +1,7 @@ // @ts-check import AdmZip from "adm-zip"; +import binaryen from "binaryen"; import chokidar from "chokidar"; import { task } from "hereby"; import assert from "node:assert"; @@ -1249,29 +1250,74 @@ export const buildAPI = task({ * @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: async () => { await run("npm", ["run", "-w", "@typescript/typescript", "build:test"]); }, @@ -1973,10 +2019,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 = [ @@ -2335,7 +2381,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 }), @@ -2429,7 +2475,7 @@ 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 = getReleaseBuildFlags(options.setPrerelease || nativePreviewReleaseVersion ? getVersion() : undefined); const wasmPackageJson = JSON.parse(fs.readFileSync(path.join(wasmInputDir, "package.json"), "utf8")); wasmPackageJson.version = getVersion(); @@ -2446,16 +2492,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 = { @@ -2665,14 +2711,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 => ({ @@ -2685,7 +2731,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 7902ccb8f420a..b9868ca2b55a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^22.20.1", "@unicode/unicode-15.1.0": "^1.6.17", "adm-zip": "^0.6.0", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.56.1", "dprint-plugin-yaml": "^0.6.0", @@ -1521,10 +1522,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", @@ -1871,6 +1868,10 @@ "node": ">=16.20.0" } }, + "node_modules/@typescript/typescript-wasip1-wasm": { + "resolved": "packages/typescript-wasip1-wasm", + "link": true + }, "node_modules/@typescript/typescript-win32-arm64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", @@ -2313,6 +2314,24 @@ "license": "MIT", "optional": true }, + "node_modules/binaryen": { + "version": "132.0.0", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-132.0.0.tgz", + "integrity": "sha512-4Usg7yEvwO3FMso3Oov6qkOOHSod1IEv+boKkh77kTljM+eNBouqCTbqWswGzbG+vDNO/b/v2C4gfEh1WgB9Lg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-as": "bin/wasm-as", + "wasm-ctor-eval": "bin/wasm-ctor-eval", + "wasm-dis": "bin/wasm-dis", + "wasm-merge": "bin/wasm-merge", + "wasm-metadce": "bin/wasm-metadce", + "wasm-opt": "bin/wasm-opt", + "wasm-reduce": "bin/wasm-reduce", + "wasm-shell": "bin/wasm-shell", + "wasm2js": "bin/wasm2js" + } + }, "node_modules/binaryextensions": { "version": "6.11.0", "resolved": "https://registry.npmjs.org/binaryextensions/-/binaryextensions-6.11.0.tgz", @@ -5903,11 +5922,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", @@ -5927,6 +5941,11 @@ "node": ">=16.20.0" } }, + "packages/typescript-wasip1-wasm": { + "name": "@typescript/typescript-wasip1-wasm", + "version": "0.0.0", + "license": "Apache-2.0" + }, "packages/vscode-typescript": { "name": "native-preview", "version": "0.0.0", diff --git a/package.json b/package.json index d391ed5032502..14901d739de92 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@types/node": "^22.20.1", "@unicode/unicode-15.1.0": "^1.6.17", "adm-zip": "^0.6.0", + "binaryen": "^132.0.0", "chokidar": "^5.0.0", "dprint": "^0.56.1", "dprint-plugin-yaml": "^0.6.0", diff --git a/packages/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 81% rename from packages/api-wasm/src/wasi.ts rename to packages/typescript-wasip1-wasm/src/wasi.ts index 2b27712fdd9d9..4b33f75737a91 100644 --- a/packages/api-wasm/src/wasi.ts +++ b/packages/typescript-wasip1-wasm/src/wasi.ts @@ -3,10 +3,12 @@ import type { WasmReactorInstance } from "./index.ts"; const errnoSuccess = 0; const errnoBadFileDescriptor = 8; const errnoInvalidArgument = 28; +const errnoIo = 29; const errnoNoSys = 52; const fileTypeCharacterDevice = 2; const eventTypeClock = 0; const subscriptionClockAbstime = 1; +const hostWriteFileFD = 0x7fff_fffe; export interface InstantiateWasmOptions { stdout?: (text: string) => void; @@ -62,7 +64,6 @@ function createWasiHost(options: InstantiateWasmOptions): { const decoders = new Map(); const encoder = new TextEncoder(); let fileSystem: WasmFileSystem | undefined; - let hostError = new Uint8Array(); function getMemory(): WebAssembly.Memory { const memory = instance?.exports.memory; @@ -128,6 +129,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 !== 1 && fd !== 2) return errnoBadFileDescriptor; const memory = getMemory(); const view = new DataView(memory.buffer); @@ -162,19 +166,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; } } @@ -184,6 +207,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); @@ -241,7 +267,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, @@ -250,24 +278,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 e162d71e566e5..ac37b98c7530c 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 APIFileChanges, 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 e1ec3872cac58..de915744540b5 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.", }, ] as const; @@ -79,7 +79,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([ { @@ -123,7 +123,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 4f71992a04dc8..c59c983257382 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 224e337dd634c..8a1c5d0d214e4 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 621b72e1e5e11..d215dd3907de9 100755 --- a/tools/pipelines/typescript-publish.yml +++ b/tools/pipelines/typescript-publish.yml @@ -74,9 +74,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 @@ -87,7 +87,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" @@ -134,8 +134,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 @@ -165,7 +165,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 76% rename from tsc/cmd/tsc-api-wasm/main.go rename to tsc/cmd/tsc/wasmapi_wasip1.go index ed6f55c4f5f58..afb7ce87703eb 100644 --- a/tsc/cmd/tsc-api-wasm/main.go +++ b/tsc/cmd/tsc/wasmapi_wasip1.go @@ -1,11 +1,13 @@ //go:build wasip1 -// Command tsc-api-wasm builds the TypeScript API server as a WebAssembly reactor. package main import ( "context" + "encoding/binary" + "errors" "fmt" + "os" "runtime/debug" "unsafe" @@ -15,7 +17,13 @@ import ( "github.com/microsoft/TypeScript/tsc/internal/vfs/wrapvfs" ) -func main() {} +const ( + // API filesystem callbacks are tunneled through a reserved descriptor so that + // the module only requires standard WASI imports and remains directly runnable. + hostWriteFileFD = 0x7fff_fffe + hostErrorCapacity = 16 * 1024 + errnoBadFileNumber = 8 +) var ( reactor *wasmreactor.Reactor @@ -48,16 +56,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 }, }) } @@ -192,6 +195,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 @@ -228,21 +236,38 @@ func bytePointer(bytes []byte) *byte { return &bytes[0] } -func readHostError() string { - length := hostErrorLength() - if length == 0 { - return "host filesystem callback failed" - } - buffer := make([]byte, length) - hostErrorCopy(&buffer[0], length) - return string(buffer) -} +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) -//go:wasmimport typescript_host write_file -func hostWriteFile(path *byte, pathLen uint32, data *byte, dataLen uint32) uint32 + 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 + } -//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 cdbe79f28e3d11db981cad298e7915f7a3dd59b3 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 7/7] 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 de915744540b5..72f16b7e84b64 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.", }, ] as const; diff --git a/packages/typescript/test/sync/wasm.test.ts b/packages/typescript/test/sync/wasm.test.ts index 8a1c5d0d214e4..16795b748a911 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 }