diff --git a/.gitattributes b/.gitattributes index fa1385d99a31..2bf8ec0623f1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ * -text +patches/*.patch -whitespace diff --git a/.gitignore b/.gitignore index 5cc7faa8b479..2eae35f85e5d 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,9 @@ packages/documentation/output/attribution.json packages/sandbox/src/releases.json packages/sandbox/src/release_data.ts packages/typescriptlang-org/src/lib/documentationNavigation.ts +packages/typescriptlang-org/static/play/v2/ +packages/typescriptlang-org/static/play/7/ +packages/typescriptlang-org/static/v2/ .idea/ diff --git a/package.json b/package.json index 200cf3cd8336..c06611e1113d 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "webpack": "^5.105.3" }, "patchedDependencies": { - "gatsby-remark-shiki-twoslash@3.0.38": "patches/gatsby-remark-shiki-twoslash@3.0.38.patch" + "@vscode/monaco-lsp-client@0.1.0": "patches/@vscode__monaco-lsp-client@0.1.0.patch", + "gatsby-remark-shiki-twoslash@3.0.38": "patches/gatsby-remark-shiki-twoslash@3.0.38.patch", + "monaco-editor-core@0.56.0": "patches/monaco-editor-core@0.56.0.patch" }, "ignoredBuiltDependencies": [ "@parcel/watcher", diff --git a/packages/playground-v2/README.md b/packages/playground-v2/README.md new file mode 100644 index 000000000000..b8dd02bf4d7e --- /dev/null +++ b/packages/playground-v2/README.md @@ -0,0 +1,18 @@ +# Playground v2 + +The application served at `/play/v2/`. + +Run the normal repository bootstrap first. From the repository root: + +```sh +pnpm --filter @typescript/playground-v2 dev +pnpm --filter @typescript/playground-v2 typecheck +pnpm --filter @typescript/playground-v2 test +pnpm --filter @typescript/playground-v2 build +``` + +Compiler code is fetched at runtime, never bundled. +Installed compiler packages are development dependencies for types and tests, +not the default runtime version. + +See [package type acquisition](src/ata/README.md) for its API and limitations. diff --git a/packages/playground-v2/package.json b/packages/playground-v2/package.json new file mode 100644 index 000000000000..83210a88f7f2 --- /dev/null +++ b/packages/playground-v2/package.json @@ -0,0 +1,33 @@ +{ + "name": "@typescript/playground-v2", + "private": true, + "type": "module", + "scripts": { + "build": "node scripts/build.mjs", + "dev": "node scripts/build.mjs --serve", + "test": "node scripts/test-ata.mjs && node scripts/smoke-test.mjs", + "typecheck": "node node_modules/typescript/bin/tsc --noEmit && node node_modules/typescript/bin/tsc -p tsconfig.test.json" + }, + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.4.2", + "@fontsource/nunito-sans": "^5.2.6", + "@vscode/monaco-lsp-client": "github:microsoft/monaco-editor#13f0c872dcf352815cc28d92dfff496c9839ea5c&path:/monaco-lsp-client", + "coi-serviceworker": "^0.1.7", + "hack-font": "^3.3.0", + "jsonc-parser": "^3.3.1", + "lz-string": "^1.5.0", + "monaco-editor": "0.56.0", + "monaco-editor-core": "0.56.0", + "reflect-metadata": "^0.2.2", + "semver": "7.7.4", + "vscode-json-languageservice": "^5.3.11", + "vscode-languageserver-textdocument": "^1.0.11" + }, + "devDependencies": { + "@typescript/typescript": "npm:typescript@7.1.0-dev.20261030.1", + "@typescript/typescript-wasip1-wasm": "7.1.0-dev.20261030.1", + "@types/semver": "^7.7.1", + "esbuild": "^0.27.3", + "typescript": "*" + } +} diff --git a/packages/playground-v2/scripts/build.mjs b/packages/playground-v2/scripts/build.mjs new file mode 100644 index 000000000000..33dae9adbcc5 --- /dev/null +++ b/packages/playground-v2/scripts/build.mjs @@ -0,0 +1,82 @@ +import { context } from "esbuild" +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises" +import { dirname, resolve } from "node:path" +import { fileURLToPath } from "node:url" +import { compatibilityHtml, compatibilityPaths, playgroundPath } from "./routes.mjs" + +const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), "..") +const websiteDirectory = resolve(packageDirectory, "../..") +const outputDirectory = resolve(packageDirectory, "dist") +const websitePlayDirectory = resolve(websiteDirectory, "packages/typescriptlang-org/static/play") +const websiteStaticDirectory = resolve(websitePlayDirectory, "v2") +const compatibilityDirectories = compatibilityPaths.map(path => + resolve(websiteDirectory, "packages/typescriptlang-org/static", path) +) +const serve = process.argv.includes("--serve") +const playgroundBase = serve ? "/" : process.env.PLAYGROUND_BASE ?? playgroundPath + +const editorWorker = fileURLToPath(import.meta.resolve("monaco-editor/editor/editor.worker")) +const coiServiceWorker = fileURLToPath(import.meta.resolve("coi-serviceworker/coi-serviceworker.min.js")) + +const indexHtml = (await readFile(resolve(packageDirectory, "src/index.html"), "utf8")).replaceAll( + "__PLAYGROUND_BASE__", + playgroundBase +) + +await rm(outputDirectory, { force: true, recursive: true }) +await mkdir(outputDirectory, { recursive: true }) +await Promise.all([ + writeFile(resolve(outputDirectory, "index.html"), indexHtml), + cp(coiServiceWorker, resolve(outputDirectory, "coi-serviceworker.min.js")), +]) + +const buildContext = await context({ + absWorkingDir: packageDirectory, + bundle: true, + conditions: ["browser", "default"], + entryNames: "[name]", + entryPoints: { + main: resolve(packageDirectory, "src/main.ts"), + "editor.worker": editorWorker, + "strada.worker": resolve(packageDirectory, "src/strada.worker.ts"), + "native.worker": resolve(packageDirectory, "src/native.worker.ts"), + "tsgo-lsp.worker": resolve(packageDirectory, "src/tsgo-lsp.worker.ts"), + }, + format: "esm", + loader: { + ".ttf": "file", + ".woff": "file", + ".woff2": "file", + }, + outdir: outputDirectory, + metafile: true, + platform: "browser", + sourcemap: true, + target: ["es2022"], +}) + +if (serve) { + await buildContext.watch() + const server = await buildContext.serve({ + host: "127.0.0.1", + port: 4173, + servedir: outputDirectory, + }) + console.log(`TypeScript Playground v2: http://${server.host}:${server.port}`) +} else { + const result = await buildContext.rebuild() + const compilerInputs = Object.keys(result.metafile.inputs).filter(path => + /node_modules\/(?:@typescript\/)?typescript(?:-wasip1-wasm)?\//.test(path) + ) + if (compilerInputs.length) throw new Error(`Compiler implementation was bundled: ${compilerInputs.join(", ")}`) + await buildContext.dispose() + await rm(websitePlayDirectory, { force: true, recursive: true }) + await cp(outputDirectory, websiteStaticDirectory, { recursive: true }) + await Promise.all( + compatibilityDirectories.map(async directory => { + await mkdir(directory, { recursive: true }) + await writeFile(resolve(directory, "index.html"), compatibilityHtml) + }) + ) + console.log(`Built Playground v2 in ${outputDirectory}`) +} diff --git a/packages/playground-v2/scripts/routes.mjs b/packages/playground-v2/scripts/routes.mjs new file mode 100644 index 000000000000..c0734c7d1bd3 --- /dev/null +++ b/packages/playground-v2/scripts/routes.mjs @@ -0,0 +1,14 @@ +export const playgroundPath = "/play/v2/" +export const compatibilityPaths = ["play/7", "v2"] +export const compatibilityScript = `location.replace(${JSON.stringify( + playgroundPath +)} + location.search + location.hash)` + +export const compatibilityHtml = ` + +TypeScript Playground v2 + + +` diff --git a/packages/playground-v2/scripts/smoke-test.mjs b/packages/playground-v2/scripts/smoke-test.mjs new file mode 100644 index 000000000000..21aa1638e57e --- /dev/null +++ b/packages/playground-v2/scripts/smoke-test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict" +import { readFile, readdir } from "node:fs/promises" +import { runInNewContext } from "node:vm" +import { compatibilityHtml, compatibilityPaths, compatibilityScript, playgroundPath } from "./routes.mjs" +import { API } from "@typescript/typescript/sync" +import { instantiateWasm, WasmTransport } from "@typescript/typescript/wasm" +import { wasmURL } from "@typescript/typescript-wasip1-wasm" + +const wasm = await readFile(wasmURL) +const module = await WebAssembly.compile(wasm) +const instance = await instantiateWasm(module) +const transport = new WasmTransport({ instance, cwd: "/workspace" }) +const api = new API({ transport }) + +try { + assert(compatibilityHtml.includes(``)) + for (const path of compatibilityPaths) { + const original = new URL(`https://example.test/${path}/?ts=6.0.3&target=99#code/v2/encoded+project`) + let redirected + runInNewContext(compatibilityScript, { + location: { + search: original.search, + hash: original.hash, + replace(value) { + redirected = value + }, + }, + }) + assert.equal(redirected, `${playgroundPath}${original.search}${original.hash}`) + } + + const libListURL = new URL(import.meta.resolve("@typescript/typescript-wasip1-wasm/lib/libFiles.json")) + const libFileNames = JSON.parse(await readFile(libListURL, "utf8")) + assert.deepEqual( + libFileNames, + (await readdir(new URL("./", libListURL))).filter(fileName => /^lib(?:\..+)?\.d\.ts$/.test(fileName)).sort() + ) + for (const fileName of libFileNames) { + transport.setFile(`/${fileName}`, await readFile(new URL(fileName, libListURL), "utf8")) + } + + const files = { + "/workspace/tsconfig.json": JSON.stringify({ + compilerOptions: { + declaration: true, + module: "CommonJS", + strict: true, + target: "ES2022", + }, + include: ["./src/**/*"], + }), + "/workspace/src/greet.ts": "export const greet = (name: string) => `Hello, ${name}!`;", + "/workspace/src/index.ts": 'import { greet } from "./greet"; console.log(greet("TS7"));', + } + for (const [fileName, source] of Object.entries(files)) { + transport.setFile(fileName, source) + } + + const config = api.readConfigFile("/workspace/tsconfig.json") + assert.equal(config.error, undefined) + const parsed = api.parseJsonConfigFileContent(config.config, { + configFileName: "/workspace/tsconfig.json", + }) + assert.deepEqual(parsed.fileNames, ["/workspace/src/greet.ts", "/workspace/src/index.ts"]) + const program = api.createProgram(parsed.fileNames, parsed.options, { + projectReferences: parsed.projectReferences, + configFileParsingDiagnostics: parsed.errors, + }) + try { + assert.equal(program.getSyntacticDiagnostics().length, 0) + assert.equal(program.getSemanticDiagnostics().length, 0) + const emit = program.emitToString() + assert.equal(emit.emitSkipped, false) + assert.deepEqual( + [...emit.outputFiles.keys()], + ["/workspace/src/greet.d.ts", "/workspace/src/greet.js", "/workspace/src/index.d.ts", "/workspace/src/index.js"] + ) + assert.match(emit.outputFiles.get("/workspace/src/index.js").text, /require\("\.\/greet"\)/) + } finally { + program.dispose() + } +} finally { + api.close() +} + +console.log("TypeScript WASM API smoke test passed") diff --git a/packages/playground-v2/scripts/test-ata.mjs b/packages/playground-v2/scripts/test-ata.mjs new file mode 100644 index 000000000000..1cd2f7a8cf5b --- /dev/null +++ b/packages/playground-v2/scripts/test-ata.mjs @@ -0,0 +1,32 @@ +import { build } from "esbuild" +import { spawnSync } from "node:child_process" +import { mkdir, rm } from "node:fs/promises" +import { resolve } from "node:path" + +const packageDirectory = resolve(import.meta.dirname, "..") +const testFiles = ["ata.test.mjs", "native-compiler.test.mjs", "lsp-filesystem.test.mjs"].map(name => + resolve(packageDirectory, "dist", name) +) +await mkdir(resolve(packageDirectory, "dist"), { recursive: true }) +try { + await build({ + absWorkingDir: packageDirectory, + bundle: true, + conditions: ["browser"], + entryPoints: ["src/ata/ata.test.ts", "src/native-compiler.test.ts", "src/lsp-filesystem.test.ts"], + entryNames: "[name]", + outExtension: { ".js": ".mjs" }, + external: ["jsonc-parser"], + format: "esm", + outdir: resolve(packageDirectory, "dist"), + platform: "node", + target: ["es2022"], + }) + const result = spawnSync(process.execPath, ["--test", ...testFiles], { + stdio: "inherit", + }) + if (result.error) throw result.error + if (result.status !== 0) process.exitCode = result.status ?? 1 +} finally { + await Promise.all(testFiles.map(file => rm(file, { force: true }))) +} diff --git a/packages/playground-v2/src/ata/README.md b/packages/playground-v2/src/ata/README.md new file mode 100644 index 000000000000..8d0590712551 --- /dev/null +++ b/packages/playground-v2/src/ata/README.md @@ -0,0 +1,18 @@ +# Package type acquisition + +`PackageTypeAcquirer.acquire` accepts source files, a parser-backed `discover` +callback, acquisition options, and an abort signal. It returns a complete declaration +snapshot and per-package errors. See [types.ts](types.ts) for the interfaces. + +Only declarations and package metadata are downloaded, never package JavaScript. +Packages use their own declarations when available, falling back to +DefinitelyTyped. Node builtins use `@types/node`. Source annotations of the form +`// types: VERSION` select a package version. + +The Playground's automatic-types setting disables all acquisition. Otherwise, +the root configuration's `typeAcquisition` options apply; exclusions win. +Inherited acquisition settings and filename-based inference are not supported. +An explicit root `compilerOptions.types` list is preserved. + +Only one version of each package can be loaded at a time. DefinitelyTyped +fallback uses its latest version, which may not support historical compilers. diff --git a/packages/playground-v2/src/ata/ata.test.ts b/packages/playground-v2/src/ata/ata.test.ts new file mode 100644 index 000000000000..856e30f3c2c2 --- /dev/null +++ b/packages/playground-v2/src/ata/ata.test.ts @@ -0,0 +1,523 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import { createRequire } from "node:module" +import { test } from "node:test" +import { API } from "@typescript/typescript/sync" +import * as ast from "@typescript/typescript/ast" +import { instantiateWasm, WasmTransport } from "@typescript/typescript/wasm" +import { acquisitionOptions } from "./config" +import { PackageTypeCache } from "./cache" +import { + corsaDiscovery, + stradaDiscovery, + PackageTypeAcquirer, + type AcquisitionProgress, + type Dependency, +} from "./index" +import { packageName, safePackagePath } from "./packages" + +const require = createRequire(import.meta.url) +const ts: typeof import("typescript") = require("typescript") +const discover = stradaDiscovery(ts) + +type Fixture = { files: Record; dependencies?: Record } +function registry(fixtures: Record) { + const calls: string[] = [] + const failures = new Set() + let active = 0 + let maximum = 0 + const fetcher: typeof fetch = async (input, init) => { + const url = String(input) + calls.push(url) + active++ + maximum = Math.max(active, maximum) + try { + init?.signal?.throwIfAborted() + await new Promise(resolve => setTimeout(resolve, 1)) + init?.signal?.throwIfAborted() + if (failures.delete(url)) return new Response("", { status: 503 }) + const tree = /^https:\/\/unpkg\.com\/(.+)@([^/]+)\/\?meta$/.exec(url) + const file = /^https:\/\/unpkg\.com\/(.+)@1\.0\.0(\/.+)$/.exec(url) + const name = tree?.[1] ?? file?.[1] + const fixture = name ? fixtures[name] : undefined + if (!fixture) return new Response("", { status: 404 }) + if (tree) { + return Response.json({ + package: name, + version: "1.0.0", + files: ["/package.json", ...Object.keys(fixture.files)].map(path => ({ path })), + }) + } + if (file?.[2] === "/package.json") { + return Response.json({ name, version: "1.0.0", types: "./index.d.ts", dependencies: fixture.dependencies }) + } + const text = file && fixture.files[file[2]] + return text === undefined ? new Response("", { status: 404 }) : new Response(text) + } finally { + active-- + } + } + return { fetcher, calls, failures, maximum: () => maximum } +} + +function source(text: string) { + return [{ path: "/workspace/src/index.ts", text }] +} + +test("compiler parsers agree on dependencies, references, and version comments", async () => { + const module = await WebAssembly.compile( + await readFile(new URL(import.meta.resolve("@typescript/typescript-wasip1-wasm/lib/tsc.wasm"))) + ) + const api = new API({ transport: new WasmTransport({ instance: await instantiateWasm(module), cwd: "/workspace" }) }) + try { + const file = { + path: "/workspace/input.ts", + text: `/// +/// // types: 5.0.0 +/// \rconst refNote = "// types: 9.9.9"; +/// +/// +import { x } from "pkg"; // types: 1.2.3 +import "string-note"; const note = "// types: 1.2.3"; +import "other-statement"; const unrelated = 1; // types: 9.9.9 +import "block-note"; /* // types: 1.2.3 */ +import "versioned-after-block"; /* note */ // types: 2.0.0 +type Versioned = import("versioned-type").Value; // types: 3.0.0 +const versionedRequire = require("versioned-require"); // types: 4.0.0 +import "hereby"; +import "node:url"; +export { y } from "exports"; +import z = require("import-equals"); +const a = import("dynamic"); +type T = import("type-import").T; +const b = require("required"); +const c = require(variable); +// import "comment"; +const text = 'import "text"'; +declare module "ambient" {} +`, + } + const sort = (items: readonly Dependency[]) => [...items].sort((a, b) => a.specifier.localeCompare(b.specifier)) + const expected: Dependency[] = [ + { kind: "types", specifier: "node" }, + { kind: "types", specifier: "annotated-reference", version: "5.0.0" }, + { kind: "types", specifier: "cr-reference" }, + { kind: "import", specifier: "pkg", version: "1.2.3" }, + { kind: "import", specifier: "versioned-after-block", version: "2.0.0" }, + { kind: "import", specifier: "versioned-type", version: "3.0.0" }, + { kind: "import", specifier: "versioned-require", version: "4.0.0" }, + ...["string-note", "other-statement", "block-note"].map( + (specifier): Dependency => ({ kind: "import", specifier }) + ), + ...["hereby", "node:url", "exports", "import-equals", "dynamic", "type-import", "required"].map( + (specifier): Dependency => ({ + kind: "import", + specifier, + }) + ), + ] + assert.deepEqual(sort(await discover(file)), sort(expected)) + assert.deepEqual(sort(await corsaDiscovery(api, ast)(file)), sort(expected)) + for (const path of ["/workspace/component.tsx", "/workspace/component.jsx"]) { + const jsxFile = { + path, + text: 'export const element = <>
{require("inside-jsx")}
;', + jsxRuntime: "preact/jsx-runtime", + } + const jsxExpected: Dependency[] = [ + { kind: "import", specifier: "inside-jsx" }, + { kind: "import", specifier: "preact/jsx-runtime" }, + ] + assert.deepEqual(sort(await discover(jsxFile)), sort(jsxExpected)) + assert.deepEqual(sort(await corsaDiscovery(api, ast)(jsxFile)), sort(jsxExpected)) + const plainFile = { ...jsxFile, text: 'export const text = "
";' } + assert.deepEqual(await discover(plainFile), []) + assert.deepEqual(await corsaDiscovery(api, ast)(plainFile), []) + } + } finally { + api.close() + } +}) + +test("normalizes Node builtins, DT scopes, and ignores local/URL imports", () => { + for (const name of ["node:url", "readline/promises", "stream/iter"]) assert.equal(packageName(name), "@types/node") + for (const name of ["./local", "/local", "#alias", "https://example.test/code", "data:text/plain,x"]) { + assert.equal(packageName(name), undefined) + } + assert.equal(packageName("@scope/pkg/subpath"), "@scope/pkg") + assert.equal(packageName("test"), "test") + for (const path of ["/../outside", "/folder/../outside", "/%2e%2e/outside", "/file?x", "/\\file"]) { + assert.equal(safePackagePath(path), false) + } +}) + +test("accepts asynchronous compiler discovery adapters", async () => { + const mock = registry({ pkg: { files: { "/index.d.ts": "export {}" } } }) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('import "pkg"'), + discover: async file => discover(file), + }) + + assert.equal(result.packages.length, 1) + assert.equal(result.errors.length, 0) +}) + +test("project aliases do not abort acquisition for unrelated package imports", async () => { + const mock = registry({ pkg: { files: { "/index.d.ts": "export const value: number;" } } }) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('import "@/components/B"; import "~/utils"; import "$lib/x"; import "pkg";'), + discover, + }) + assert.deepEqual(result.errors, []) + assert.deepEqual( + result.packages.map(pkg => pkg.name), + ["pkg"] + ) + assert(!mock.calls.some(url => url.includes("components") || url.includes("utils") || url.includes("$lib"))) +}) + +test("reports pending metadata, declaration downloads, and cached package completion", async () => { + const mock = registry({ pkg: { files: { "/index.d.ts": "export {}", "/extra.d.ts": "export {}" } } }) + let release!: () => void + let started!: () => void + const gate = new Promise(resolve => { + release = resolve + }) + const metadataStarted = new Promise(resolve => { + started = resolve + }) + const engine = new PackageTypeAcquirer({ + fetcher: async (input, init) => { + if (String(input).endsWith("/?meta")) { + started() + await gate + } + return mock.fetcher(input, init) + }, + }) + const progress: AcquisitionProgress[] = [] + const input = { + files: source('import "pkg"'), + discover, + onProgress: (value: AcquisitionProgress) => progress.push(value), + } + const acquisition = engine.acquire(input) + try { + await metadataStarted + assert.deepEqual(progress.at(-1), { + downloaded: 0, + total: 0, + completedPackages: 0, + totalPackages: 1, + pendingPackages: ["pkg"], + }) + } finally { + release() + } + await acquisition + assert.deepEqual(progress.at(-1), { + downloaded: 2, + total: 2, + completedPackages: 1, + totalPackages: 1, + pendingPackages: [], + }) + const calls = mock.calls.length + await engine.acquire(input) + assert.equal(mock.calls.length, calls) + assert.deepEqual(progress.at(-1), { + downloaded: 0, + total: 0, + completedPackages: 1, + totalPackages: 1, + pendingPackages: [], + }) +}) + +test("reuses declaration discovery for unchanged packages but not different compilers", async () => { + const mock = registry({ + pkg: { files: { "/index.d.ts": 'export * from "dep"' } }, + dep: { files: { "/index.d.ts": "export const value: string" } }, + }) + const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher }) + const parsed: string[] = [] + const parser = async (file: { path: string; text: string }) => { + parsed.push(file.path) + return discover(file) + } + const input = { files: source('import "pkg"'), discover: parser } + await engine.acquire(input) + const declarations = parsed.filter(path => path.includes("/node_modules/")).length + assert.equal(declarations, 2) + await engine.acquire({ ...input, files: source('import "pkg"; const edited = true;') }) + assert.equal(parsed.filter(path => path.includes("/node_modules/")).length, declarations) + await engine.acquire({ ...input, discover: file => parser(file) }) + assert.equal(parsed.filter(path => path.includes("/node_modules/")).length, declarations * 2) +}) + +test("downloads declarations and package metadata, never JavaScript", async () => { + const mock = registry({ + pkg: { + files: { + "/index.d.ts": "export const value: string", + "/esm/index.d.mts": "export const value: string", + "/cjs/index.d.cts": "export const value: string", + "/esm/package.json": '{"type":"module"}', + "/index.js": "doNotRun()", + }, + }, + }) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('import "pkg/subpath"'), + discover, + }) + assert.equal(result.errors.length, 0) + assert.equal(result.files.size, 5) + assert(result.files.has("/workspace/node_modules/pkg/esm/package.json")) + assert(!mock.calls.some(url => url.endsWith(".js"))) +}) + +test("type-reference subpaths can refer to packages shipping their own declarations", async () => { + const mock = registry({ vite: { files: { "/client.d.ts": "declare const environment: string;" } } }) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('/// '), + discover, + }) + assert.equal(result.errors.length, 0) + assert(result.files.has("/workspace/node_modules/vite/client.d.ts")) + assert(!mock.calls.some(url => url.includes("@types/vite"))) +}) + +test("falls back to DT with the actual declaration package metadata", async () => { + const mock = registry({ + plain: { files: { "/index.js": "runtime" } }, + "@types/plain": { files: { "/index.d.ts": 'declare module "plain" {}' } }, + "@types/scope__pkg": { files: { "/index.d.ts": "export const value: string" } }, + }) + const progress: AcquisitionProgress[] = [] + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('import "plain"; import "@scope/pkg"'), + discover, + onProgress: value => progress.push(value), + }) + assert.deepEqual(result.ambientTypes, ["plain", "scope__pkg"]) + assert(result.files.has("/workspace/node_modules/@types/plain/package.json")) + assert(!result.files.has("/workspace/node_modules/plain/package.json")) + assert.equal(progress.at(-1)?.totalPackages, 2) + assert.equal(progress.at(-1)?.completedPackages, 2) +}) + +test("Node requests go directly to @types/node and do not recurse into builtins", async () => { + const mock = registry({ + "@types/node": { files: { "/index.d.ts": 'import "readline/promises"; import "stream/iter"' } }, + }) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('import "node:url"'), + discover, + }) + assert.deepEqual(result.ambientTypes, ["node"]) + assert.equal(result.packages.length, 1) + assert(!mock.calls.some(url => /^https:\/\/unpkg\.com\/(?:node|readline|stream)@/.test(url))) +}) + +test("persistent package caching never pins mutable tags or ranges, including scoped packages", async () => { + const original = Object.getOwnPropertyDescriptor(globalThis, "caches") + const stored = new Map() + Object.defineProperty(globalThis, "caches", { + configurable: true, + value: { + open: async () => ({ + match: async (url: string) => stored.get(url)?.clone(), + put: async (url: string, response: Response) => { + stored.set(url, response) + }, + }), + }, + }) + try { + const cache = new PackageTypeCache() + const signal = new AbortController().signal + for (const selector of ["latest", "next", "22", "%5E22", "1.2.3", "1.2.3-beta.1"]) { + const url = `https://unpkg.com/@types/pkg@${selector}/?meta` + await cache.write(url, selector, signal) + assert.equal(await cache.read(url, signal), selector.startsWith("1.2.3") ? selector : undefined) + } + assert.equal(stored.size, 2) + } finally { + if (original) Object.defineProperty(globalThis, "caches", original) + else Reflect.deleteProperty(globalThis, "caches") + } +}) + +test("honors include, exclude, and enable with complete snapshots", async () => { + const mock = registry({ + included: { files: { "/index.d.ts": "export {}" } }, + blocked: { files: { "/index.d.ts": "export {}" } }, + }) + const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher }) + const result = await engine.acquire({ + files: source('import "blocked"'), + discover, + options: { include: ["included"], exclude: ["blocked"] }, + }) + assert.deepEqual( + result.packages.map(pkg => pkg.name), + ["included"] + ) + const disabled = await engine.acquire({ files: [], discover, options: { enable: false, include: ["included"] } }) + assert.equal(disabled.files.size, 0) + const empty = await engine.acquire({ files: [], discover }) + assert.equal(empty.files.size, 0) +}) + +test("preserves package dependency ranges, deduplicates cycles, and bounds concurrency", async () => { + const mock = registry({ + pkg: { files: { "/index.d.ts": 'export * from "dep"' }, dependencies: { dep: "^2.0.0" } }, + dep: { files: { "/index.d.ts": 'export * from "pkg"' } }, + }) + const progress: AcquisitionProgress[] = [] + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { concurrency: 2 } }).acquire({ + files: source('import "pkg"'), + discover, + onProgress: value => progress.push(value), + }) + assert.equal(result.packages.length, 2) + assert(mock.calls.some(url => url.includes("dep@%5E2.0.0"))) + assert(mock.maximum() <= 2) + assert.equal(progress.at(-1)?.completedPackages, 2) + assert.equal(progress.at(-1)?.totalPackages, 2) + assert.deepEqual(progress.at(-1)?.pendingPackages, []) +}) + +test("source version annotations take precedence over an unversioned include", async () => { + const mock = registry({ pkg: { files: { "/index.d.ts": "export {}" } } }) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('import "pkg"; // types: 1.2.3'), + discover, + options: { include: ["pkg"] }, + }) + assert.equal(result.packages.length, 1) + assert(mock.calls.some(url => url.includes("pkg@1.2.3"))) + assert(!mock.calls.some(url => url.includes("pkg@latest"))) +}) + +test("isolates package errors, caches successes, and retries failures", async () => { + const mock = registry({ + good: { files: { "/index.d.ts": "export {}" } }, + retry: { files: { "/index.d.ts": "export {}" } }, + }) + mock.failures.add("https://unpkg.com/retry@1.0.0/index.d.ts") + const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher }) + const progress: AcquisitionProgress[] = [] + const input = { + files: source('import "good"; import "retry"'), + discover, + onProgress: (value: AcquisitionProgress) => progress.push(value), + } + const first = await engine.acquire(input) + assert.equal(first.errors[0].packageName, "retry") + assert(first.files.has("/workspace/node_modules/good/index.d.ts")) + assert(!first.files.has("/workspace/node_modules/retry/index.d.ts")) + assert(!mock.calls.some(url => url.includes("@types/retry"))) + assert.equal(progress.at(-1)?.completedPackages, 2) + assert.deepEqual(progress.at(-1)?.pendingPackages, []) + const calls = mock.calls.filter(url => url.includes("good@")).length + const second = await engine.acquire(input) + assert.equal(second.errors.length, 0) + assert.equal(mock.calls.filter(url => url.includes("good@")).length, calls) +}) + +test("aborted acquisition cannot return a stale success or poison retries", async () => { + const mock = registry({ pkg: { files: { "/index.d.ts": "export {}" } } }) + const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher }) + const controller = new AbortController() + const promise = engine.acquire({ files: source('import "pkg"'), discover, signal: controller.signal }) + controller.abort() + await assert.rejects(promise, { name: "AbortError" }) + const result = await engine.acquire({ files: source('import "pkg"'), discover }) + assert.equal(result.errors.length, 0) +}) + +test("queued downloads release their permits when an acquisition is cancelled", async () => { + const fixtures: Record = {} + for (const name of ["one", "two", "three", "four"]) { + fixtures[name] = { files: { "/index.d.ts": "export {}", "/extra.d.ts": "export {}" } } + } + const mock = registry(fixtures) + const engine = new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { concurrency: 2 } }) + const controller = new AbortController() + const input = { files: source('import "one"; import "two"; import "three"; import "four";'), discover } + let progressCount = 0 + let countAtAbort = 0 + const cancelled = engine.acquire({ + ...input, + signal: controller.signal, + onProgress: () => { + progressCount++ + }, + }) + setTimeout(() => { + controller.abort() + countAtAbort = progressCount + }, 5) + await assert.rejects(cancelled, { name: "AbortError" }) + assert(progressCount > 0) + assert.equal(progressCount, countAtAbort) + const retried = await engine.acquire(input) + assert.equal(retried.errors.length, 0) + assert.equal(retried.packages.length, 4) + assert(mock.maximum() <= 2) +}) + +test("a DT exclusion also suppresses scoped runtime package requests", async () => { + const mock = registry({}) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher }).acquire({ + files: source('import "@scope/pkg"'), + discover, + options: { exclude: ["@types/scope__pkg"] }, + }) + assert.equal(result.files.size, 0) + assert.equal(mock.calls.length, 0) +}) + +test("enforces graph-wide package and declaration byte budgets", async () => { + const mock = registry({ + one: { files: { "/index.d.ts": "x".repeat(500) } }, + two: { files: { "/index.d.ts": "x".repeat(500) } }, + }) + const input = { files: source('import "one"; import "two"'), discover } + await assert.rejects( + new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { packages: 1 } }).acquire(input), + /package limit/ + ) + await assert.rejects( + new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { bytes: 900 } }).acquire(input), + /byte limit/ + ) +}) + +test("rejects oversized responses and unsafe package paths", async () => { + const mock = registry({ huge: { files: { "/index.d.ts": "x".repeat(4000) } } }) + const result = await new PackageTypeAcquirer({ fetcher: mock.fetcher, limits: { responseBytes: 1024 } }).acquire({ + files: source('import "huge"'), + discover, + }) + assert.match(result.errors[0].message, /too large/) + const unsafe = registry({ bad: { files: { "/../bad.d.ts": "export {}" } } }) + const invalid = await new PackageTypeAcquirer({ fetcher: unsafe.fetcher }).acquire({ + files: source('import "bad"'), + discover, + }) + assert.match(invalid.errors[0].message, /Invalid file path/) +}) + +test("project options accept JSONC, preserve explicit choices, and report invalid values", () => { + assert.deepEqual(acquisitionOptions('{// hi\n"typeAcquisition":{"enable":false,"include":["node"],},}', true), { + enable: false, + include: ["node"], + exclude: undefined, + }) + assert.deepEqual(acquisitionOptions("invalid", false), { enable: false }) + assert.throws(() => acquisitionOptions('{"typeAcquisition":{"exclude":[1]}}', true), /array of package names/) + assert.throws(() => acquisitionOptions("invalid", true), /syntax/) +}) diff --git a/packages/playground-v2/src/ata/cache.ts b/packages/playground-v2/src/ata/cache.ts new file mode 100644 index 000000000000..a1aaa80bddbb --- /dev/null +++ b/packages/playground-v2/src/ata/cache.ts @@ -0,0 +1,53 @@ +import { valid } from "semver" + +const cacheName = "playground-v2-package-types-v2" + +export class PackageTypeCache { + private cachePromise: Promise | undefined + + private getCache() { + this.cachePromise ??= (async () => { + if (!("caches" in globalThis)) return undefined + try { + return await caches.open(cacheName) + } catch (error) { + console.warn("Could not open the package type cache", error) + return undefined + } + })() + return this.cachePromise + } + + private immutable(url: string) { + const version = /@(\d[^/]*)\//.exec(new URL(url).pathname)?.[1] + return version !== undefined && valid(version) !== null + } + + async read(url: string, signal: AbortSignal) { + const cache = this.immutable(url) ? await this.getCache() : undefined + if (cache) { + try { + const cached = await cache.match(url) + signal.throwIfAborted() + return cached ? await cached.text() : undefined + } catch (error) { + signal.throwIfAborted() + console.warn("Could not read the package type cache", error) + } + } + return undefined + } + + async write(url: string, text: string, signal: AbortSignal) { + const cache = this.immutable(url) ? await this.getCache() : undefined + signal.throwIfAborted() + if (cache) { + try { + await cache.put(url, new Response(text)) + } catch (error) { + signal.throwIfAborted() + console.warn("Could not cache package types", error) + } + } + } +} diff --git a/packages/playground-v2/src/ata/config.ts b/packages/playground-v2/src/ata/config.ts new file mode 100644 index 000000000000..c4519a9db052 --- /dev/null +++ b/packages/playground-v2/src/ata/config.ts @@ -0,0 +1,38 @@ +import { parse, type ParseError } from "jsonc-parser" +import type { AcquisitionOptions } from "./types" + +function object(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +export function acquisitionOptions(configText: string, globalEnable: boolean): AcquisitionOptions { + if (!globalEnable) return { enable: false } + const errors: ParseError[] = [] + const config: unknown = parse(configText, errors, { allowTrailingComma: true, disallowComments: false }) + if (errors.length) throw new Error("Fix TSConfig syntax errors before acquiring package types") + if (!object(config)) { + throw new Error("TSConfig must be an object") + } + const value: unknown = "typeAcquisition" in config ? config.typeAcquisition : undefined + if (value === undefined || value === null) return {} + if (!object(value)) { + throw new Error("typeAcquisition must be an object") + } + const enable = "enable" in value ? value.enable : undefined + if (enable !== undefined && enable !== null && typeof enable !== "boolean") { + throw new Error("typeAcquisition.enable must be a boolean") + } + const list = (name: "include" | "exclude"): string[] | undefined => { + const items: unknown = name in value ? value[name] : undefined + if (items === undefined || items === null) return undefined + if (!Array.isArray(items) || items.some(item => typeof item !== "string")) { + throw new Error(`typeAcquisition.${name} must be an array of package names`) + } + return items + } + return { + enable: typeof enable === "boolean" ? enable : undefined, + include: list("include"), + exclude: list("exclude"), + } +} diff --git a/packages/playground-v2/src/ata/discovery.ts b/packages/playground-v2/src/ata/discovery.ts new file mode 100644 index 000000000000..3e3e7c0ee0e3 --- /dev/null +++ b/packages/playground-v2/src/ata/discovery.ts @@ -0,0 +1,155 @@ +import type { API } from "@typescript/typescript/sync" +import type * as Ast from "@typescript/typescript/ast" +import type { Node } from "@typescript/typescript/ast" +import type { Dependency, DiscoverDependencies, SourceFile } from "./types" + +type TrailingComments = (text: string, position: number) => readonly { pos: number; end: number }[] | undefined + +function dependency( + specifier: string, + kind: Dependency["kind"], + text: string, + end: number, + comments?: TrailingComments +): Dependency { + const tails = comments + ? (comments(text, end) ?? []).map(range => text.slice(range.pos, range.end)) + : [ + text + .slice(end) + .split(/[\r\n\u2028\u2029]/, 1)[0] + .replace(/^["']?\s*\/>\s*/, ""), + ] + const pattern = /^\/\/\s*types:\s*(\S+)/ + const version = tails.map(tail => pattern.exec(tail)?.[1]).find(value => value !== undefined) + return { kind, specifier, ...(version ? { version } : {}) } +} + +function statementEnd(node: T, isStatement: (node: T) => boolean) { + while (!isStatement(node) && node.parent) node = node.parent + return node.end +} + +function unique(dependencies: readonly Dependency[]) { + return [...new Map(dependencies.map(item => [`${item.kind}:${item.specifier}:${item.version ?? ""}`, item])).values()] +} + +export function corsaDiscovery( + api: API, + { + getTrailingCommentRanges, + isCallExpression, + isIdentifier, + isJsxElement, + isJsxFragment, + isJsxSelfClosingElement, + isStatement, + isStringLiteral, + }: typeof Ast +): (file: SourceFile) => readonly Dependency[] { + return file => { + const retained = api.createSourceFile(file.path, file.text) + try { + const source = retained.sourceFile + const dependencies: Dependency[] = source.typeReferenceDirectives.map(reference => + dependency(reference.fileName, "types", file.text, reference.end) + ) + for (const node of source.imports) { + if (isStringLiteral(node)) { + dependencies.push( + dependency(node.text, "import", file.text, statementEnd(node, isStatement), getTrailingCommentRanges) + ) + } + } + const visit = (node: Node): void => { + if (file.jsxRuntime && (isJsxElement(node) || isJsxFragment(node) || isJsxSelfClosingElement(node))) { + dependencies.push({ kind: "import", specifier: file.jsxRuntime }) + } + if ( + isCallExpression(node) && + isIdentifier(node.expression) && + node.expression.text === "require" && + node.arguments.length === 1 && + isStringLiteral(node.arguments[0]) + ) { + dependencies.push( + dependency( + node.arguments[0].text, + "import", + file.text, + statementEnd(node, isStatement), + getTrailingCommentRanges + ) + ) + } + node.forEachChild(visit) + } + visit(source) + return unique(dependencies) + } finally { + retained.dispose() + } + } +} + +export function stradaDiscovery(ts: typeof import("typescript")): DiscoverDependencies { + return file => { + const source = ts.createSourceFile(file.path, file.text, ts.ScriptTarget.Latest, true) + const dependencies: Dependency[] = source.typeReferenceDirectives.map(reference => + dependency(reference.fileName, "types", file.text, reference.end) + ) + const add = (node: import("typescript").Node | undefined) => { + if (node?.kind === ts.SyntaxKind.StringLiteral) { + dependencies.push( + dependency( + (node as import("typescript").StringLiteral).text, + "import", + file.text, + statementEnd(node, ts.isStatement), + ts.getTrailingCommentRanges + ) + ) + } + } + const visit = (node: import("typescript").Node): void => { + if ( + file.jsxRuntime && + (node.kind === ts.SyntaxKind.JsxElement || + node.kind === ts.SyntaxKind.JsxFragment || + node.kind === ts.SyntaxKind.JsxSelfClosingElement) + ) { + dependencies.push({ kind: "import", specifier: file.jsxRuntime }) + } + switch (node.kind) { + case ts.SyntaxKind.ImportDeclaration: + case ts.SyntaxKind.ExportDeclaration: + add((node as import("typescript").ImportDeclaration | import("typescript").ExportDeclaration).moduleSpecifier) + break + case ts.SyntaxKind.ExternalModuleReference: + add((node as import("typescript").ExternalModuleReference).expression) + break + case ts.SyntaxKind.ImportType: { + const argument = (node as import("typescript").ImportTypeNode).argument + if (argument.kind === ts.SyntaxKind.LiteralType) { + add((argument as import("typescript").LiteralTypeNode).literal) + } + break + } + case ts.SyntaxKind.CallExpression: { + const call = node as import("typescript").CallExpression + if ( + call.arguments.length === 1 && + (call.expression.kind === ts.SyntaxKind.ImportKeyword || + (call.expression.kind === ts.SyntaxKind.Identifier && + (call.expression as import("typescript").Identifier).text === "require")) + ) { + add(call.arguments[0]) + } + } + } + ts.forEachChild(node, visit) + } + visit(source) + return unique(dependencies) + } +} diff --git a/packages/playground-v2/src/ata/index.ts b/packages/playground-v2/src/ata/index.ts new file mode 100644 index 000000000000..16cbadcfa4e5 --- /dev/null +++ b/packages/playground-v2/src/ata/index.ts @@ -0,0 +1,444 @@ +import { PackageTypeCache } from "./cache" +import { definitelyTypedName, isDeclaration, packageName, runtimePackageName, safePackagePath } from "./packages" +import type { + AcquisitionOptions, + AcquisitionProgress, + AcquisitionResult, + AcquiredPackage, + Dependency, + DiscoverDependencies, + SourceFile, +} from "./types" + +export type { + AcquisitionOptions, + AcquisitionProgress, + AcquisitionResult, + Dependency, + DiscoverDependencies, + SourceFile, +} from "./types" +export { corsaDiscovery, stradaDiscovery } from "./discovery" + +type PackageRequest = { name: string; version: string; requestedBy: string } +type PackageData = { + name: string + version: string + files: ReadonlyMap + dependencies: ReadonlyMap +} + +export type AcquisitionLimits = { + concurrency: number + packages: number + files: number + bytes: number + responseBytes: number +} + +const defaultLimits: AcquisitionLimits = { + concurrency: 6, + packages: 100, + files: 4000, + bytes: 32 * 1024 * 1024, + responseBytes: 8 * 1024 * 1024, +} + +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +class HttpError extends Error { + constructor(readonly status: number, url: string) { + super(`HTTP ${status} fetching ${url}`) + } +} + +class DownloadPool { + private active = 0 + private readonly waiting: Array<() => void> = [] + + constructor(private readonly concurrency: number) {} + + async run(signal: AbortSignal, operation: () => Promise): Promise { + signal.throwIfAborted() + if (this.active >= this.concurrency) { + await new Promise((resolve, reject) => { + const abort = () => { + const index = this.waiting.indexOf(resume) + if (index !== -1) this.waiting.splice(index, 1) + reject(signal.reason) + } + const resume = () => { + signal.removeEventListener("abort", abort) + resolve() + } + this.waiting.push(resume) + signal.addEventListener("abort", abort, { once: true }) + }) + } else { + this.active++ + } + try { + signal.throwIfAborted() + return await operation() + } finally { + const next = this.waiting.shift() + if (next) next() + else this.active-- + } + } +} + +export class PackageTypeAcquirer { + private readonly fetcher: typeof fetch + private readonly limits: AcquisitionLimits + private readonly pool: DownloadPool + private readonly packages = new Map() + private cachedBytes = 0 + private readonly cache = new PackageTypeCache() + private readonly discoveries = new WeakMap>>() + + constructor(options: { fetcher?: typeof fetch; limits?: Partial } = {}) { + this.fetcher = options.fetcher ?? ((input, init) => fetch(input, init)) + this.limits = { ...defaultLimits, ...options.limits } + if (Object.values(this.limits).some(value => !Number.isSafeInteger(value) || value < 1)) { + throw new Error("ATA limits must be positive integers") + } + this.pool = new DownloadPool(this.limits.concurrency) + } + + async acquire(input: { + files: readonly SourceFile[] + discover: DiscoverDependencies + options?: AcquisitionOptions + signal?: AbortSignal + onProgress?(progress: AcquisitionProgress): void + }): Promise { + const signal = input.signal ?? new AbortController().signal + signal.throwIfAborted() + const files = new Map() + const ambientTypes = new Set() + const packages: AcquiredPackage[] = [] + const errors: Array<{ packageName: string; requestedBy: string; message: string }> = [] + if (input.options?.enable === false) return { files, ambientTypes: [], packages, errors } + + const excluded = new Set() + for (const specifier of input.options?.exclude ?? []) { + const name = packageName(specifier) + if (name) { + excluded.add(name) + excluded.add(definitelyTypedName(name)) + if (name.startsWith("@types/")) excluded.add(name.slice("@types/".length)) + } + } + const queued = new Set() + const queue: PackageRequest[] = [] + const pendingPackages = new Set() + let completedPackages = 0 + let totalPackages = 0 + let downloaded = 0 + let total = 0 + const progress = () => { + if (!signal.aborted) { + input.onProgress?.({ + downloaded, + total, + completedPackages, + totalPackages, + pendingPackages: [...pendingPackages], + }) + } + } + const enqueue = (dependency: Dependency, requestedBy: string, versions?: ReadonlyMap) => { + const name = packageName(dependency.specifier) + if (!name || excluded.has(name) || excluded.has(definitelyTypedName(name)) || queued.has(name)) return + if (queued.size >= this.limits.packages) throw new Error(`ATA exceeded its ${this.limits.packages}-package limit`) + queued.add(name) + queue.push({ name, requestedBy, version: dependency.version ?? versions?.get(name) ?? "latest" }) + totalPackages++ + progress() + } + for (const file of [...input.files].sort((a, b) => a.path.localeCompare(b.path))) { + const dependencies = await input.discover(file) + signal.throwIfAborted() + for (const dependency of dependencies) enqueue(dependency, file.path) + } + for (const specifier of input.options?.include ?? []) + enqueue({ kind: "import", specifier }, "typeAcquisition.include") + + let bytes = 0 + if (queue.length) progress() + while (queue.length) { + signal.throwIfAborted() + // Resolve a deterministic wave before adding its transitive dependencies. + const wave = queue.splice(0, this.limits.concurrency) + const results = await Promise.all( + wave.map( + async ( + request + ): Promise<{ request: PackageRequest; data: PackageData } | { request: PackageRequest; error: string }> => { + pendingPackages.add(request.name) + progress() + try { + const data = await this.loadPackage( + request, + signal, + count => { + total += count + progress() + }, + () => { + downloaded++ + progress() + } + ) + return { request, data } + } catch (error) { + signal.throwIfAborted() + return { request, error: error instanceof Error ? error.message : String(error) } + } finally { + pendingPackages.delete(request.name) + completedPackages++ + progress() + } + } + ) + ) + for (const result of results) { + if ("error" in result) { + errors.push({ + packageName: result.request.name, + requestedBy: result.request.requestedBy, + message: result.error, + }) + continue + } + const { data, request } = result + queued.add(data.name) + packages.push({ name: data.name, version: data.version, requestedBy: request.requestedBy }) + if (data.name.startsWith("@types/")) { + ambientTypes.add(data.name.slice("@types/".length)) + queued.add(runtimePackageName(data.name)) + } + for (const [path, text] of data.files) { + const fileName = `/workspace/node_modules/${data.name}${path}` + if (files.has(fileName)) continue + bytes += new TextEncoder().encode(text).byteLength + if (files.size >= this.limits.files || bytes > this.limits.bytes) { + throw new Error("ATA exceeded the project declaration file or byte limit") + } + files.set(fileName, text) + if (isDeclaration(path)) { + let cachedPackages = this.discoveries.get(input.discover) + if (!cachedPackages) { + cachedPackages = new WeakMap() + this.discoveries.set(input.discover, cachedPackages) + } + let declarations = cachedPackages.get(data) + if (!declarations) { + declarations = new Map() + cachedPackages.set(data, declarations) + } + let dependencies = declarations.get(path) + if (!dependencies) { + dependencies = await input.discover({ path: fileName, text }) + signal.throwIfAborted() + declarations.set(path, dependencies) + } + signal.throwIfAborted() + for (const dependency of dependencies) { + enqueue(dependency, `${data.name}@${data.version}${path}`, data.dependencies) + } + } + } + } + } + signal.throwIfAborted() + return { + files, + ambientTypes: [...ambientTypes].sort(), + packages, + errors, + } + } + + private async request(url: string, signal: AbortSignal, cacheResult = true) { + return this.pool.run(signal, async () => { + const cached = await this.cache.read(url, signal) + signal.throwIfAborted() + if (cached !== undefined) { + if (new TextEncoder().encode(cached).byteLength > this.limits.responseBytes) { + throw new Error(`ATA response is too large: ${url}`) + } + return cached + } + const response = await this.fetcher(url, { signal }) + if (!response.ok) { + await response.body?.cancel() + throw new HttpError(response.status, url) + } + if (Number(response.headers.get("content-length")) > this.limits.responseBytes) { + await response.body?.cancel() + throw new Error(`ATA response is too large: ${url}`) + } + if (!response.body) { + const text = await response.text() + if (new TextEncoder().encode(text).byteLength > this.limits.responseBytes) { + throw new Error(`ATA response is too large: ${url}`) + } + if (cacheResult) await this.cache.write(url, text, signal) + return text + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let length = 0 + try { + for (;;) { + signal.throwIfAborted() + const { done, value } = await reader.read() + if (done) break + length += value.byteLength + if (length > this.limits.responseBytes) throw new Error(`ATA response is too large: ${url}`) + chunks.push(value) + } + } catch (error) { + await reader.cancel() + throw error + } finally { + reader.releaseLock() + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.length + } + const text = new TextDecoder().decode(bytes) + if (cacheResult) await this.cache.write(url, text, signal) + return text + }) + } + + private async json(url: string, signal: AbortSignal): Promise { + const text = await this.request(url, signal, false) + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + throw new Error(`Invalid JSON from ${url}`, { cause: error }) + } + await this.cache.write(url, text, signal) + return value + } + + private async loadPackage( + request: PackageRequest, + signal: AbortSignal, + onFiles: (count: number) => void, + onFile: () => void + ): Promise { + const key = `${request.name}@${request.version}` + const cached = this.packages.get(key) + if (cached) return cached.data + const data = await this.downloadPackage(request.name, request.version, signal, onFiles, onFile) + if (data) { + this.remember(key, data) + return data + } + if (request.name.startsWith("@types/")) throw new Error(`${request.name} does not contain declarations`) + const fallback = await this.downloadPackage(definitelyTypedName(request.name), "latest", signal, onFiles, onFile) + if (!fallback) throw new Error(`No declarations found for ${request.name}`) + this.remember(key, fallback) + return fallback + } + + private remember(key: string, data: PackageData) { + const bytes = [...data.files.values()].reduce((sum, text) => sum + new TextEncoder().encode(text).byteLength, 0) + while (this.cachedBytes + bytes > this.limits.bytes || this.packages.size >= this.limits.packages) { + const oldest = this.packages.entries().next().value + if (!oldest) return + this.packages.delete(oldest[0]) + this.cachedBytes -= oldest[1].bytes + } + this.packages.set(key, { data, bytes }) + this.cachedBytes += bytes + } + + private async downloadPackage( + name: string, + selector: string, + signal: AbortSignal, + onFiles: (count: number) => void, + onFile: () => void + ): Promise { + let tree: unknown + try { + tree = await this.json(`https://unpkg.com/${name}@${encodeURIComponent(selector)}/?meta`, signal) + } catch (error) { + signal.throwIfAborted() + if (error instanceof HttpError && error.status === 404 && !name.startsWith("@types/")) return undefined + throw error + } + if ( + !record(tree) || + tree.package !== name || + typeof tree.version !== "string" || + !/^\d+\.\d+\.\d+[-+\w.]*$/.test(tree.version) + ) { + throw new Error(`Could not resolve ${name}@${selector} to an exact version`) + } + const version = tree.version + const base = `https://unpkg.com/${name}@${version}` + if (!Array.isArray(tree.files)) throw new Error(`Invalid file index for ${name}@${version}`) + const paths = tree.files + .map(entry => { + if (!record(entry) || typeof entry.path !== "string" || !safePackagePath(entry.path)) { + throw new Error(`Invalid file path in ${name}@${version}`) + } + return entry.path + }) + .filter(path => !path.includes("/node_modules/")) + if (!paths.some(isDeclaration)) return undefined + const metadata = await this.json(`${base}/package.json`, signal) + if (!record(metadata)) throw new Error(`Invalid package.json for ${name}@${version}`) + const dependencies = new Map() + for (const field of ["peerDependencies", "dependencies"]) { + const entries = metadata[field] + if (entries !== undefined && entries !== null) { + if (!record(entries)) throw new Error(`Invalid ${field} in ${name}@${version}`) + for (const [dependency, value] of Object.entries(entries)) { + if (typeof value !== "string") throw new Error(`Invalid dependency ${dependency} in ${name}@${version}`) + dependencies.set(dependency, value) + } + } + } + const declarationPaths = [...new Set(paths.filter(path => isDeclaration(path) || path.endsWith("/package.json")))] + .filter(path => path !== "/package.json") + .sort() + if (declarationPaths.length > this.limits.files) throw new Error(`Too many declaration files in ${name}@${version}`) + onFiles(declarationPaths.length) + const files = new Map([["/package.json", JSON.stringify(metadata)]]) + let bytes = new TextEncoder().encode(JSON.stringify(metadata)).byteLength + // Keep fan-out bounded even before requests enter the shared network pool. + for (let start = 0; start < declarationPaths.length; start += this.limits.concurrency) { + const results = await Promise.allSettled( + declarationPaths.slice(start, start + this.limits.concurrency).map(async path => { + const text = await this.request(`${base}${path}`, signal) + onFile() + return [path, text] as const + }) + ) + const entries = results.map(result => { + if (result.status === "rejected") throw result.reason + return result.value + }) + for (const [path, text] of entries) { + bytes += new TextEncoder().encode(text).byteLength + if (bytes > this.limits.bytes) throw new Error(`Declarations in ${name}@${version} exceed the ATA byte limit`) + files.set(path, text) + } + } + return { name, version, files, dependencies } + } +} diff --git a/packages/playground-v2/src/ata/packages.ts b/packages/playground-v2/src/ata/packages.ts new file mode 100644 index 000000000000..ff6e5214ae4a --- /dev/null +++ b/packages/playground-v2/src/ata/packages.ts @@ -0,0 +1,81 @@ +const nodeModules = new Set([ + "assert", + "async_hooks", + "buffer", + "child_process", + "cluster", + "console", + "constants", + "crypto", + "dgram", + "diagnostics_channel", + "dns", + "domain", + "events", + "fs", + "http", + "http2", + "https", + "inspector", + "module", + "net", + "os", + "path", + "perf_hooks", + "process", + "punycode", + "querystring", + "readline", + "repl", + "stream", + "string_decoder", + "sys", + "timers", + "tls", + "trace_events", + "tty", + "url", + "util", + "v8", + "vm", + "wasi", + "worker_threads", + "zlib", +]) + +export function packageName(specifier: string): string | undefined { + if (specifier === "node" || specifier.startsWith("node:") || nodeModules.has(specifier.split("/")[0])) + return "@types/node" + if (/^(?:[./#]|[a-z][a-z\d+.-]*:)/i.test(specifier)) return undefined + const parts = specifier.split("/") + const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] + if (!/^(?:@[a-z\d._-]+\/)?[a-z\d_][a-z\d._-]*$/i.test(name)) { + return undefined + } + return name +} + +export function definitelyTypedName(name: string) { + if (name.startsWith("@types/")) return name + return `@types/${name.startsWith("@") ? name.slice(1).replace("/", "__") : name}` +} + +export function runtimePackageName(typesName: string) { + const name = typesName.slice("@types/".length) + return name.includes("__") ? `@${name.replace("__", "/")}` : name +} + +export function isDeclaration(path: string) { + return /\.d\.(?:ts|mts|cts)$/i.test(path) +} + +export function safePackagePath(path: string) { + return ( + path.startsWith("/") && + !/[\\%?#\u0000-\u001f]/.test(path) && + !path + .slice(1) + .split("/") + .some(part => part === ".." || part === "." || part === "") + ) +} diff --git a/packages/playground-v2/src/ata/types.ts b/packages/playground-v2/src/ata/types.ts new file mode 100644 index 000000000000..6bf36df6650e --- /dev/null +++ b/packages/playground-v2/src/ata/types.ts @@ -0,0 +1,46 @@ +export type SourceFile = { + path: string + text: string + jsxRuntime?: string +} + +export type Dependency = { + specifier: string + kind: "import" | "types" + version?: string +} + +export type DiscoverDependencies = (file: SourceFile) => readonly Dependency[] | Promise + +export type AcquisitionOptions = { + enable?: boolean + include?: readonly string[] + exclude?: readonly string[] +} + +export type AcquiredPackage = { + name: string + version: string + requestedBy: string +} + +export type AcquisitionError = { + packageName: string + requestedBy: string + message: string +} + +export type AcquisitionResult = { + files: ReadonlyMap + ambientTypes: readonly string[] + packages: readonly AcquiredPackage[] + errors: readonly AcquisitionError[] +} + +export type AcquisitionProgress = { + downloaded: number + total: number + completedPackages: number + totalPackages: number + pendingPackages: readonly string[] +} diff --git a/packages/playground-v2/src/compiler-distribution.ts b/packages/playground-v2/src/compiler-distribution.ts new file mode 100644 index 000000000000..58ea1d79f838 --- /dev/null +++ b/packages/playground-v2/src/compiler-distribution.ts @@ -0,0 +1,145 @@ +import { prerelease, valid } from "semver" + +export type CompilerModules = { + sync: string + async: string + ast: string + host: string +} + +export type CompilerDistribution = CompilerModules & { + version: string + wasm: URL + schema: URL + libraries: URL[] + assetBytes: number + estimatedDownloadBytes: number +} + +export async function loadPublishedCompilerDistribution(version: string): Promise { + const [main, artifact] = await Promise.all([ + loadPackage("typescript", version), + loadPackage("@typescript/typescript-wasip1-wasm", version), + ]) + if (main.revision !== artifact.revision) { + throw new Error(`TypeScript API and WASI artifact revisions do not match for ${version}`) + } + const wasm = packageEntry(artifact, "./lib/tsc.wasm") + const schema = packageEntry(main, "./schemas/tsconfig.schema.json") + const libraryDirectory = new URL("./", wasm) + const [mainFiles, artifactFiles] = await Promise.all([ + loadFileSizes(main.base), + loadFileSizes(artifact.base, libraryDirectory), + ]) + function size(files: ReadonlyMap, url: URL) { + const bytes = files.get(url.href) + if (bytes === undefined) throw new Error(`Missing compiler file size: ${url.pathname}`) + return bytes + } + const libraries = [...artifactFiles.keys()] + .map(url => new URL(url)) + .filter( + url => + url.href.startsWith(libraryDirectory.href) && + /^lib(?:\.[\w.]+)?\.d\.ts$/.test(url.pathname.slice(url.pathname.lastIndexOf("/") + 1)) + ) + if (libraries.length === 0) throw new Error("Compiler libraries are missing") + const wasmBytes = size(artifactFiles, wasm) + const libraryBytes = libraries.reduce((sum, url) => sum + size(artifactFiles, url), 0) + const schemaBytes = size(mainFiles, schema) + const moduleBytes = [...mainFiles].reduce((sum, [url, bytes]) => sum + (url.endsWith(".js") ? bytes : 0), 0) + // CDN samples: WASM ~25%, JavaScript ~12%, JSON ~10%; declarations are uncompressed. + const estimatedDownloadBytes = Math.ceil(wasmBytes * 0.25 + moduleBytes * 0.12 + schemaBytes * 0.1 + libraryBytes) + return { + version, + sync: packageEntry(main, "./sync").href, + async: packageEntry(main, "./async").href, + ast: packageEntry(main, "./ast").href, + host: packageEntry(main, "./wasm").href, + wasm, + schema, + libraries, + assetBytes: wasmBytes + schemaBytes + libraryBytes, + estimatedDownloadBytes, + } +} + +async function loadPackage(name: string, version: string) { + const base = new URL(`https://unpkg.com/${name}@${version}/`) + const url = new URL("package.json", base) + const response = await fetch(url, { signal: AbortSignal.timeout(30_000) }) + if (!response.ok) throw new Error(`Could not load ${name}@${version}: ${response.status}`) + const metadata: unknown = await response.json() + if ( + !isRecord(metadata) || + metadata.name !== name || + metadata.version !== version || + typeof metadata.gitHead !== "string" || + !metadata.gitHead || + !isRecord(metadata.exports) + ) { + throw new Error(`Invalid package metadata for ${name}@${version}`) + } + return { + base, + revision: metadata.gitHead, + exports: metadata.exports, + } +} + +async function loadFileSizes(base: URL, directory = base): Promise> { + const response = await fetch(new URL("?meta", directory), { signal: AbortSignal.timeout(30_000) }) + if (!response.ok) throw new Error(`Could not load compiler file sizes: ${response.status}`) + const metadata: unknown = await response.json() + if (!isRecord(metadata) || !Array.isArray(metadata.files)) throw new Error("Invalid compiler file sizes") + const files = new Map() + for (const file of metadata.files) { + if ( + !isRecord(file) || + typeof file.path !== "string" || + typeof file.size !== "number" || + !Number.isSafeInteger(file.size) || + file.size < 0 + ) { + throw new Error("Invalid compiler file size") + } + files.set(new URL(file.path.replace(/^\//, ""), base).href, file.size) + } + return files +} + +function packageEntry(packageInfo: Awaited>, subpath: string) { + const value = packageInfo.exports[subpath] + const path = isRecord(value) ? value.default : value + if (typeof path !== "string" || !path.startsWith("./")) throw new Error(`Missing package export: ${subpath}`) + return new URL(path, packageInfo.base) +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} +// These fixed CDN releases contain a different compiler than their URL claims. +const incorrectLegacyReleases = new Set([ + "4.0.2", + "4.1.5", + "4.2.2", + "4.2.3", + "4.3.2", + "4.3.3", + "4.3.4", + "4.3.5", + "4.4.2", + "4.4.3", + "4.4.4", +]) + +export function legacyCompilerBaseUrl(version: string, mirroredVersions: readonly string[] = []) { + const channel = valid(version) ? prerelease(version)?.[0] : undefined + const published = + valid(version) !== null && (channel === undefined || ["dev", "beta", "rc"].includes(String(channel))) + return new URL( + published && (!mirroredVersions.includes(version) || incorrectLegacyReleases.has(version)) + ? `https://unpkg.com/typescript@${version}/lib/` + : `https://playgroundcdn.typescriptlang.org/cdn/${encodeURIComponent(version)}/typescript/lib/` + ) +} diff --git a/packages/playground-v2/src/compiler-overrides.ts b/packages/playground-v2/src/compiler-overrides.ts new file mode 100644 index 000000000000..11cccded8806 --- /dev/null +++ b/packages/playground-v2/src/compiler-overrides.ts @@ -0,0 +1,269 @@ +import { applyEdits, findNodeAtLocation, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser" + +export type CompilerOverride = { + applied: boolean + end: number + fileName: string + lineNumber: number + option: string + rawValue: string + start: number + tsconfigValue: unknown + value: unknown +} + +export type OverrideDiagnostic = { + end: number + fileName: string + message: string + start: number +} + +export type CompilerOverrideState = { + diagnostics: OverrideDiagnostic[] + effectiveConfigText: string + overrides: CompilerOverride[] +} + +const reservedDirectives = new Set([ + "errors", + "filename", + "noerrors", + "showemit", + "showemittedfile", + "showoutput", + "ts-check", + "ts-expect-error", + "ts-ignore", + "ts-nocheck", +]) +const directivePattern = /^(\s*\/\/\s*)@([A-Za-z][\w-]*):\s*(.*?)\s*$/ +const stringListOptions = new Set(["lib", "rootDirs", "typeRoots", "types", "moduleSuffixes", "customConditions"]) +const formattingOptions = { + insertSpaces: true, + tabSize: 2, +} + +export function computeCompilerOverrides( + files: ReadonlyMap, + configText: string +): CompilerOverrideState { + const configErrors: ParseError[] = [] + const config = parse(configText, configErrors, { allowTrailingComma: true, disallowComments: false }) ?? {} + const compilerOptions = + config.compilerOptions && typeof config.compilerOptions === "object" ? config.compilerOptions : {} + const overrides: CompilerOverride[] = [] + const diagnostics: OverrideDiagnostic[] = [] + + for (const [fileName, text] of [...files].sort(([left], [right]) => left.localeCompare(right))) { + if (!/\.[cm]?[jt]sx?$/i.test(fileName)) continue + let lineNumber = 0 + for (const lineMatch of text.matchAll(/[^\r\n]*(?:\r\n|\r|\n|$)/g)) { + if (lineMatch[0] === "") break + lineNumber++ + const offset = lineMatch.index + const line = lineMatch[0].replace(/\r\n$|\r$|\n$/, "") + const match = directivePattern.exec(line) + const lineEnd = offset + line.length + if (match) { + const option = match[2] + if (!reservedDirectives.has(option.toLowerCase())) { + const rawValue = match[3] + if (rawValue === "") { + diagnostics.push({ + end: lineEnd, + fileName, + message: `Compiler override @${option} requires a value.`, + start: offset, + }) + } else { + overrides.push({ + applied: false, + end: lineEnd, + fileName, + lineNumber, + option, + rawValue, + start: offset, + tsconfigValue: compilerOptions[option], + value: parseDirectiveValue(option, rawValue), + }) + } + } + } + } + } + + const byOption = new Map() + for (const override of overrides) { + const key = override.option.toLowerCase() + const entries = byOption.get(key) ?? [] + entries.push(override) + byOption.set(key, entries) + } + let effectiveConfigText = configText + for (const duplicates of byOption.values()) { + if (duplicates.length > 1) { + for (const duplicate of duplicates) { + diagnostics.push({ + end: duplicate.end, + fileName: duplicate.fileName, + message: `Compiler option @${duplicate.option} is overridden in multiple files. Keep only one project-wide directive.`, + start: duplicate.start, + }) + } + continue + } + + const override = duplicates[0] + try { + effectiveConfigText = setCompilerOption(effectiveConfigText, override.option, override.value) + override.applied = true + } catch { + diagnostics.push({ + end: override.end, + fileName: override.fileName, + message: `Compiler override @${override.option} cannot be applied while tsconfig.json is invalid.`, + start: override.start, + }) + } + } + + return { + diagnostics, + effectiveConfigText, + overrides, + } +} + +export function setCompilerOption(configText: string, option: string, value: unknown) { + return applyEdits( + configText, + modify(configText, ["compilerOptions", option], value, { + formattingOptions, + }) + ) +} + +export function configOptionNode(configText: string, option: string): Node | undefined { + const tree = parseTree(configText, [], { allowTrailingComma: true, disallowComments: false }) + return tree ? findNodeAtLocation(tree, ["compilerOptions", option]) : undefined +} + +export function compilerOptionsNode(configText: string): Node | undefined { + const tree = parseTree(configText, [], { allowTrailingComma: true, disallowComments: false }) + return tree ? findNodeAtLocation(tree, ["compilerOptions"]) : undefined +} + +export function compilerOverrideForDiagnostic( + state: CompilerOverrideState, + diagnostic: { fileName?: string; pos: number; end: number; text: string }, + configFileName: string +) { + if (diagnostic.fileName !== undefined && diagnostic.fileName !== configFileName) return undefined + return state.overrides.find(override => { + if (!override.applied) return false + const node = configOptionNode(state.effectiveConfigText, override.option) + if ( + diagnostic.fileName === configFileName && + node && + diagnostic.pos <= node.offset + node.length && + diagnostic.end >= node.offset + ) + return true + if (!/^(?:compiler )?option\b|^unknown compiler option\b|^argument for\b/i.test(diagnostic.text)) return false + return ( + diagnostic.text.toLowerCase().includes(`'${override.option.toLowerCase()}'`) || + diagnostic.text.toLowerCase().includes(`'--${override.option.toLowerCase()}'`) || + diagnostic.text.toLowerCase().includes(`"--${override.option.toLowerCase()}"`) + ) + }) +} +export function canSetCompilerOptions(configText: string) { + const errors: ParseError[] = [] + const root = parseTree(configText, errors, { allowTrailingComma: true, disallowComments: false }) + const options = root && findNodeAtLocation(root, ["compilerOptions"]) + return errors.length === 0 && root?.type === "object" && (!options || options.type === "object") +} + +export function applyLegacyCompilerOptions(configText: string, search: string, optionNames: Iterable) { + if (!canSetCompilerOptions(configText)) return configText + const params = new URLSearchParams(search) + const allowed = new Set(optionNames) + const snapshot = params.has("tsconfig") + for (const [key, rawValue] of params) { + if (!allowed.has(key) || (snapshot && configOptionNode(configText, key))) continue + configText = setCompilerOption(configText, key, parseLegacyCompilerOption(key, rawValue)) + } + if (params.has("useJavaScript") || params.get("filetype")?.startsWith("js")) { + configText = setCompilerOption(configText, "allowJs", true) + configText = setCompilerOption(configText, "checkJs", true) + } + return configText +} + +function parseLegacyCompilerOption(key: string, rawValue: string): unknown { + if (rawValue === "") return true + if (rawValue === "true") return true + if (rawValue === "false") return false + if (stringListOptions.has(key) || /^[{[]/.test(rawValue)) return parseDirectiveValue(key, rawValue) + const number = Number(rawValue) + if (!Number.isFinite(number)) return rawValue + const enumMaps: Record> = { + jsx: { + 1: "preserve", + 2: "react", + 3: "react-native", + 4: "react-jsx", + 5: "react-jsxdev", + }, + module: { + 0: "none", + 1: "commonjs", + 2: "amd", + 3: "umd", + 4: "system", + 5: "es2015", + 6: "es2020", + 7: "es2022", + 99: "esnext", + 100: "node16", + 101: "node18", + 102: "node20", + 199: "nodenext", + 200: "preserve", + }, + moduleResolution: { 1: "classic", 2: "node", 3: "node16", 99: "nodenext", 100: "bundler" }, + newLine: { 0: "crlf", 1: "lf" }, + target: { + 0: "es3", + 1: "es5", + 2: "es2015", + 3: "es2016", + 4: "es2017", + 5: "es2018", + 6: "es2019", + 7: "es2020", + 8: "es2021", + 9: "es2022", + 10: "es2023", + 11: "es2024", + 12: "es2025", + 99: "esnext", + }, + } + if (key === "jsx" && number === 0) return undefined + return enumMaps[key]?.[number] ?? number +} + +function parseDirectiveValue(option: string, rawValue: string) { + if (/^(?:true|false|null|-?\d+(?:\.\d+)?)$/.test(rawValue) || /^["[{]/.test(rawValue)) { + try { + return JSON.parse(rawValue) + } catch { + // Treat invalid JSON-like values as compiler option strings so the selected compiler reports them. + } + } + if (stringListOptions.has(option)) return rawValue.split(",").map(value => value.trim()) + return rawValue +} diff --git a/packages/playground-v2/src/compiler-versions.ts b/packages/playground-v2/src/compiler-versions.ts new file mode 100644 index 000000000000..020e227eb972 --- /dev/null +++ b/packages/playground-v2/src/compiler-versions.ts @@ -0,0 +1,180 @@ +import { gte, prerelease, rcompare, valid } from "semver" + +export type PublishedCompilerVersions = { + versions: string[] + tags: { latest?: string; next?: string; beta?: string; rc?: string } +} + +export type CompilerVersionChoice = { + version: string + label: string +} + +export function isWasmCompilerVersion(version: string) { + return valid(version) !== null && gte(version, "7.1.0-dev.20261030.1") +} + +export function isUnsupportedNativeCompilerVersion(version: string) { + return valid(version) !== null && gte(version, "7.0.0-0") && !isWasmCompilerVersion(version) +} + +export function isDefaultNativeCompilerVersion(version: string | null) { + return ( + version === null || + version === "" || + version === "native" || + version === "latest" || + version === "7" || + version === "7.1" + ) +} + +export function isNativeCompilerVersion(version: string | null) { + return ( + isDefaultNativeCompilerVersion(version) || + (version !== null && + (version === "next" || + version === "Nightly" || + isWasmCompilerVersion(version) || + isUnsupportedNativeCompilerVersion(version))) + ) +} + +export async function loadPublishedCompilerVersions(): Promise { + const entries = await Promise.all( + (["latest", "next", "beta", "rc"] as const).map(async tag => { + const response = await fetch(`https://unpkg.com/typescript@${tag}/package.json`, { + cache: "no-cache", + signal: AbortSignal.timeout(15_000), + }) + if (response.status === 404) return [tag, undefined] as const + if (!response.ok) throw new Error(`Could not resolve TypeScript ${tag}: ${response.status}`) + const metadata: unknown = await response.json() + const version: unknown = metadata && typeof metadata === "object" ? Reflect.get(metadata, "version") : undefined + if ( + !metadata || + typeof metadata !== "object" || + Reflect.get(metadata, "name") !== "typescript" || + typeof version !== "string" || + valid(version) === null + ) { + throw new Error(`Invalid TypeScript ${tag} package metadata`) + } + return [tag, version] as const + }) + ) + return { + versions: [...new Set(entries.flatMap(([, version]) => (version === undefined ? [] : [version])))], + tags: Object.fromEntries(entries), + } +} + +export async function loadLegacyCompilerVersions(): Promise { + const response = await fetch("https://playgroundcdn.typescriptlang.org/indexes/releases.json", { + cache: "no-cache", + signal: AbortSignal.timeout(15_000), + }) + if (!response.ok) throw new Error(`Could not load historical TypeScript versions: ${response.status}`) + const metadata: unknown = await response.json() + const versions: unknown = metadata && typeof metadata === "object" ? Reflect.get(metadata, "versions") : undefined + if ( + !Array.isArray(versions) || + versions.length === 0 || + !versions.every((version: unknown): version is string => typeof version === "string" && valid(version) !== null) + ) { + throw new Error("Invalid historical TypeScript versions") + } + return versions +} + +export function publishedNativeCompilerSelection(published: PublishedCompilerVersions, channel: "default" | "nightly") { + const stable = published.tags.latest + const useStable = channel === "default" && stable && isWasmCompilerVersion(stable) && !prerelease(stable) + const version = useStable ? stable : published.tags.next + if (!version || !isWasmCompilerVersion(version) || !published.versions.includes(version)) { + throw new Error(`No published TypeScript version supports the native ${channel} channel`) + } + return { version, channel: useStable ? ("stable" as const) : ("nightly" as const) } +} + +export function compilerVersionChoices( + legacyVersions: readonly string[], + published?: PublishedCompilerVersions, + selectedVersion?: string +): CompilerVersionChoice[] { + const versions = new Map() + function add(version: string, label = version) { + versions.set(version, { version, label }) + } + const unsupported = new Set(["3.1.6", "3.0.1", "2.8.1", "2.7.2", "2.4.1"]) + const legacyReleases = legacyVersions.filter( + version => + valid(version) !== null && + !prerelease(version) && + !isUnsupportedNativeCompilerVersion(version) && + !unsupported.has(version) + ) + const nativeReleases = + published?.versions.filter(version => isWasmCompilerVersion(version) && !prerelease(version)) ?? [] + const releases = latestMinorVersions([...nativeReleases, ...legacyReleases]) + const stableChoices = new Set(releases) + if ( + selectedVersion && + valid(selectedVersion) !== null && + !prerelease(selectedVersion) && + !isUnsupportedNativeCompilerVersion(selectedVersion) && + !unsupported.has(selectedVersion) + ) { + stableChoices.add(selectedVersion) + } + for (const version of [...stableChoices].sort(rcompare)) add(version) + const candidates: CompilerVersionChoice[] = [] + for (const [tag, label] of [ + ["rc", "RC"], + ["beta", "Beta"], + ] as const) { + const version = published?.tags[tag] + if ( + version && + valid(version) !== null && + !isUnsupportedNativeCompilerVersion(version) && + prerelease(version) && + published?.versions.includes(version) && + (!releases[0] || rcompare(version, releases[0]) < 0) + ) { + candidates.push({ version, label: `${label} (${version})` }) + } + } + const candidate = candidates.sort((a, b) => rcompare(a.version, b.version))[0] + if (candidate) add(candidate.version, candidate.label) + if ( + selectedVersion && + selectedVersion !== "next" && + !isUnsupportedNativeCompilerVersion(selectedVersion) && + !unsupported.has(selectedVersion) && + !versions.has(selectedVersion) + ) { + add(selectedVersion) + } + const nightly = published?.tags.next + if (nightly && isWasmCompilerVersion(nightly) && published?.versions.includes(nightly)) { + add("next", `Nightly (${nightly})`) + } + return [...versions.values()].sort((left, right) => { + const leftVersion = valid(left.version === "next" ? nightly : left.version) + const rightVersion = valid(right.version === "next" ? nightly : right.version) + if (!leftVersion) return rightVersion ? 1 : 0 + if (!rightVersion) return -1 + return rcompare(leftVersion, rightVersion) + }) +} + +function latestMinorVersions(versions: readonly string[]) { + const minors = new Set() + return [...versions].sort(rcompare).filter(version => { + const minor = version.split(".").slice(0, 2).join(".") + if (minors.has(minor)) return false + minors.add(minor) + return true + }) +} diff --git a/packages/playground-v2/src/config-schema.ts b/packages/playground-v2/src/config-schema.ts new file mode 100644 index 000000000000..2614d27c2487 --- /dev/null +++ b/packages/playground-v2/src/config-schema.ts @@ -0,0 +1,209 @@ +import { + CompletionItemKind, + DiagnosticSeverity, + getLanguageService, + InsertTextFormat, + MarkupKind, + type CompletionItem, + type Diagnostic, + type Hover, + type JSONSchema, + type MarkedString, + type MarkupContent, + type Range, +} from "vscode-json-languageservice" +import { TextDocument } from "vscode-languageserver-textdocument" +import { monaco } from "./tsgo-lsp" + +const schemaUri = "https://json.schemastore.org/tsconfig" +const markerOwner = "tsconfig-schema" + +export function registerConfigSchema(schema: JSONSchema) { + const service = getLanguageService({}) + service.configure({ + allowComments: true, + schemas: [ + { + fileMatch: ["**/tsconfig.json", "**/jsconfig.json"], + schema, + uri: schemaUri, + }, + ], + validate: true, + }) + + monaco.languages.registerCompletionItemProvider("json", { + triggerCharacters: ['"', ":"], + async provideCompletionItems(model, position) { + if (!isConfigModel(model)) return { suggestions: [] } + const document = createDocument(model) + const jsonDocument = service.parseJSONDocument(document) + const completions = await service.doComplete(document, toLspPosition(position), jsonDocument) + return { + incomplete: completions?.isIncomplete, + suggestions: (completions?.items ?? []).map(item => toMonacoCompletion(model, position, item)), + } + }, + }) + + monaco.languages.registerHoverProvider("json", { + async provideHover(model, position) { + if (!isConfigModel(model)) return undefined + const document = createDocument(model) + const hover = await service.doHover(document, toLspPosition(position), service.parseJSONDocument(document)) + return hover ? toMonacoHover(hover) : undefined + }, + }) + + const timers = new Map() + const registerModel = (model: monaco.editor.ITextModel) => { + if (!isConfigModel(model)) return + const validate = () => { + window.clearTimeout(timers.get(model.uri.toString())) + timers.set( + model.uri.toString(), + window.setTimeout(async () => { + if (model.isDisposed()) return + const document = createDocument(model) + const diagnostics = ( + await service.doValidation(document, service.parseJSONDocument(document), { + comments: "ignore", + trailingCommas: "ignore", + }) + ).filter(diagnostic => !isCaseInsensitiveEnumMatch(document, diagnostic)) + monaco.editor.setModelMarkers(model, markerOwner, diagnostics.map(toMonacoDiagnostic)) + }, 150) + ) + } + model.onDidChangeContent(validate) + validate() + } + + monaco.editor.getModels().forEach(registerModel) + monaco.editor.onDidCreateModel(registerModel) +} + +function isCaseInsensitiveEnumMatch(document: TextDocument, diagnostic: Diagnostic) { + const message = typeof diagnostic.message === "string" ? diagnostic.message : diagnostic.message.value + if (!message.startsWith("Value is not accepted. Valid values:")) return false + const source = document.getText(diagnostic.range) + let value: unknown + try { + value = JSON.parse(source) + } catch { + return false + } + if (typeof value !== "string") return false + const allowed = [...message.matchAll(/"([^"]+)"/g)].map(match => match[1]) + return allowed.some(candidate => candidate.toLowerCase() === value.toLowerCase()) +} + +function isConfigModel(model: monaco.editor.ITextModel) { + return /\/(?:js|ts)config\.json$/i.test(model.uri.path) +} + +function createDocument(model: monaco.editor.ITextModel) { + return TextDocument.create(model.uri.toString(), "json", model.getVersionId(), model.getValue()) +} + +function toMonacoCompletion( + model: monaco.editor.ITextModel, + position: monaco.Position, + item: CompletionItem +): monaco.languages.CompletionItem { + const word = model.getWordUntilPosition(position) + const fallbackRange = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) + const textEdit = item.textEdit && "range" in item.textEdit ? item.textEdit : undefined + return { + detail: item.detail, + documentation: toMarkdown(item.documentation), + filterText: item.filterText, + insertText: textEdit?.newText ?? item.insertText ?? item.label, + insertTextRules: + item.insertTextFormat === InsertTextFormat.Snippet + ? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet + : undefined, + kind: toMonacoCompletionKind(item.kind), + label: item.label, + range: textEdit ? toMonacoRange(textEdit.range) : fallbackRange, + sortText: item.sortText, + } +} + +function toMonacoCompletionKind(kind: CompletionItemKind | undefined) { + switch (kind) { + case CompletionItemKind.Value: + return monaco.languages.CompletionItemKind.Value + case CompletionItemKind.Enum: + case CompletionItemKind.EnumMember: + return monaco.languages.CompletionItemKind.Enum + case CompletionItemKind.Keyword: + return monaco.languages.CompletionItemKind.Keyword + case CompletionItemKind.Property: + case CompletionItemKind.Field: + return monaco.languages.CompletionItemKind.Property + default: + return monaco.languages.CompletionItemKind.Text + } +} + +function toMonacoHover(hover: Hover): monaco.languages.Hover { + return { + contents: Array.isArray(hover.contents) + ? hover.contents.map(toMarkdown).filter(isMarkdown) + : [toMarkdown(hover.contents)].filter(isMarkdown), + range: hover.range ? toMonacoRange(hover.range) : undefined, + } +} + +function toMarkdown(value: string | MarkedString | MarkupContent | undefined): monaco.IMarkdownString | undefined { + if (value === undefined) return undefined + if (typeof value === "string") return { value } + if ("language" in value) { + return { value: `\`\`\`${value.language}\n${value.value}\n\`\`\`` } + } + return { + value: value.kind === MarkupKind.Markdown ? value.value : value.value.replace(/[\\`*_{}[\]()#+\-.!]/g, "\\$&"), + } +} + +function isMarkdown(value: monaco.IMarkdownString | undefined): value is monaco.IMarkdownString { + return value !== undefined +} + +function toMonacoDiagnostic(diagnostic: Diagnostic): monaco.editor.IMarkerData { + return { + code: diagnostic.code?.toString(), + endColumn: diagnostic.range.end.character + 1, + endLineNumber: diagnostic.range.end.line + 1, + message: typeof diagnostic.message === "string" ? diagnostic.message : diagnostic.message.value, + severity: toMonacoSeverity(diagnostic.severity), + source: diagnostic.source ?? "TSConfig", + startColumn: diagnostic.range.start.character + 1, + startLineNumber: diagnostic.range.start.line + 1, + } +} + +function toMonacoSeverity(severity: DiagnosticSeverity | undefined) { + switch (severity) { + case DiagnosticSeverity.Error: + return monaco.MarkerSeverity.Error + case DiagnosticSeverity.Warning: + return monaco.MarkerSeverity.Warning + case DiagnosticSeverity.Information: + return monaco.MarkerSeverity.Info + default: + return monaco.MarkerSeverity.Hint + } +} + +function toLspPosition(position: monaco.Position) { + return { + character: position.column - 1, + line: position.lineNumber - 1, + } +} + +function toMonacoRange(range: Range) { + return new monaco.Range(range.start.line + 1, range.start.character + 1, range.end.line + 1, range.end.character + 1) +} diff --git a/packages/playground-v2/src/editor-languages.ts b/packages/playground-v2/src/editor-languages.ts new file mode 100644 index 000000000000..d850185bb614 --- /dev/null +++ b/packages/playground-v2/src/editor-languages.ts @@ -0,0 +1,13 @@ +export const sourceLanguages = ["typescript", "typescriptreact", "javascript", "javascriptreact"] as const + +export function isSourceLanguage(language: string) { + return sourceLanguages.some(candidate => candidate === language) +} + +export function languageForFile(fileName: string) { + if (/\.json$/i.test(fileName)) return "json" + if (/\.tsx$/i.test(fileName)) return "typescriptreact" + if (/\.jsx$/i.test(fileName)) return "javascriptreact" + if (/\.[cm]?js$/i.test(fileName)) return "javascript" + return "typescript" +} diff --git a/packages/playground-v2/src/editor-touch-scroll.ts b/packages/playground-v2/src/editor-touch-scroll.ts new file mode 100644 index 000000000000..348f1a276588 --- /dev/null +++ b/packages/playground-v2/src/editor-touch-scroll.ts @@ -0,0 +1,96 @@ +import type * as monaco from "monaco-editor-core" +import { EventType } from "monaco-editor-core/esm/vs/base/browser/touch.js" + +export function configureEditorTouchScroll(editor: monaco.editor.IStandaloneCodeEditor, mobileLayout: MediaQueryList) { + const element = editor.getDomNode() + if (!element) throw new Error("Cannot configure touch scrolling before the editor is mounted") + const listeners = new AbortController() + const options = { capture: true, passive: true, signal: listeners.signal } + let pointerType = "" + let chainGesture = false + let scrollPage = false + let dragged = false + let startX = 0 + let startY = 0 + const recordDrag = (touch: Touch) => { + if (Math.hypot(touch.clientX - startX, touch.clientY - startY) >= 8) dragged = true + } + + element.addEventListener( + "pointerdown", + event => { + pointerType = event.pointerType + }, + options + ) + element.addEventListener( + "touchstart", + event => { + scrollPage = false + dragged = false + startX = event.touches[0].clientX + startY = event.touches[0].clientY + chainGesture = + event.touches.length === 1 && + pointerType !== "pen" && + event.target instanceof Element && + event.target.closest(".lines-content") !== null && + event.target.closest(".contentWidgets, .view-zones") === null + }, + options + ) + element.addEventListener( + "touchmove", + event => { + if (!chainGesture || event.touches.length !== 1) return + recordDrag(event.touches[0]) + }, + options + ) + element.addEventListener( + "touchend", + event => { + if (chainGesture && event.changedTouches.length === 1) recordDrag(event.changedTouches[0]) + }, + options + ) + element.addEventListener( + EventType.Tap, + event => { + // Monaco treats movements under 30px as taps, even after scrolling. + if (chainGesture && dragged) event.stopPropagation() + }, + options + ) + element.addEventListener( + EventType.Change, + event => { + if ( + !mobileLayout.matches || + !chainGesture || + !(event.target instanceof Element) || + !event.target.classList.contains("lines-content") || + !("translationY" in event) || + typeof event.translationY !== "number" + ) + return + const delta = -event.translationY + if (!scrollPage) { + const bounds = element.getBoundingClientRect() + scrollPage = (delta < 0 && bounds.top < 0) || (delta > 0 && bounds.bottom > window.innerHeight) + } + if (scrollPage) { + event.stopPropagation() + window.scrollBy(0, delta) + return + } + const top = editor.getScrollTop() + const maximum = Math.max(0, editor.getScrollHeight() - editor.getLayoutInfo().height) + const consumed = Math.min(maximum, Math.max(0, top + delta)) - top + const remainder = delta - consumed + if (remainder !== 0) window.scrollBy(0, remainder) + }, + options + ) + editor.onDidDispose(() => listeners.abort()) +} diff --git a/packages/playground-v2/src/exporter.ts b/packages/playground-v2/src/exporter.ts new file mode 100644 index 000000000000..87a887f008d5 --- /dev/null +++ b/packages/playground-v2/src/exporter.ts @@ -0,0 +1,175 @@ +import LZString from "lz-string" +import type { Symlinks } from "./symlinks" + +export type ExportProject = { + activeFile: string + files: Readonly> + outputFiles: Readonly> + symlinks: Symlinks + url: string +} + +export function decodeCompressedHash(encoded: string) { + try { + return LZString.decompressFromEncodedURIComponent(decodeURIComponent(encoded)) + } catch { + return null + } +} + +function codeBlock(text: string, language = "") { + let length = 3 + for (const match of text.matchAll(/`+/g)) length = Math.max(length, match[0].length + 1) + const fence = "`".repeat(length) + return `${fence}${language}\n${text}\n${fence}\n` +} + +export function projectMarkdown(project: ExportProject) { + const sections = Object.entries(project.files).map(([path, text]) => { + const extension = path.split(".").at(-1) ?? "" + return `### ${JSON.stringify(path)}\n\n${codeBlock(text, /^\w+$/.test(extension) ? extension : "")}` + }) + if (Object.keys(project.symlinks).length) { + sections.push(`### Symbolic links\n\n${codeBlock(JSON.stringify(project.symlinks, undefined, 2), "json")}`) + } + if (Object.keys(project.outputFiles).length) { + const output = Object.entries(project.outputFiles).map( + ([path, text]) => `### ${JSON.stringify(path)}\n\n${codeBlock(text, path.endsWith(".js") ? "js" : "ts")}` + ) + sections.push(`
Output\n\n${output.join("\n")}\n
`) + } + return `${sections.join("\n")}\n[Playground Link](${project.url})\n` +} + +export function configureExports(options: { project(): ExportProject; compile(): Promise | void }) { + const menu = element("exports") + const summary = element("exports-button") + const items = [...menu.querySelectorAll("[data-export]")] + const dialog = element("export-dialog") + const title = element("export-title") + const text = element("export-text") + const error = element("export-error") + const showText = (heading: string, value: string) => { + title.textContent = heading + text.value = value + error.hidden = true + dialog.showModal() + text.focus() + text.select() + } + const closeMenu = () => { + menu.open = false + summary.setAttribute("aria-expanded", "false") + } + const focusItem = (index: number) => { + items.forEach((item, current) => (item.tabIndex = current === index ? 0 : -1)) + items[index].focus() + } + items.forEach(item => (item.tabIndex = -1)) + menu.addEventListener("toggle", () => summary.setAttribute("aria-expanded", String(menu.open))) + summary.addEventListener("click", event => { + event.preventDefault() + menu.open = !menu.open + summary.setAttribute("aria-expanded", String(menu.open)) + if (menu.open) focusItem(0) + }) + summary.addEventListener("keydown", event => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return + event.preventDefault() + event.stopPropagation() + menu.open = true + focusItem(event.key === "ArrowDown" ? 0 : items.length - 1) + }) + menu.addEventListener("keydown", event => { + const index = items.indexOf(document.activeElement as HTMLButtonElement | HTMLAnchorElement) + if (event.key === "Escape") { + event.preventDefault() + closeMenu() + summary.focus() + } else if (event.key === "Tab") { + closeMenu() + summary.focus() + } else if (index >= 0 && ["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { + event.preventDefault() + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? items.length - 1 + : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length + focusItem(next) + } + }) + document.addEventListener("pointerdown", event => { + if (event.target instanceof Node && !menu.contains(event.target)) closeMenu() + }) + for (const item of items) { + if (item instanceof HTMLAnchorElement) { + const updateLink = () => { + const project = options.project() + const source = project.files[project.activeFile] ?? "" + item.href = `https://ts-ast-viewer.com/#code/${LZString.compressToEncodedURIComponent(source)}` + } + item.addEventListener("pointerdown", updateLink) + item.addEventListener("focus", updateLink) + item.addEventListener("contextmenu", updateLink) + item.addEventListener("click", updateLink) + item.addEventListener("keydown", event => { + if (event.key !== " ") return + event.preventDefault() + item.click() + }) + } + item.addEventListener("click", () => { + closeMenu() + summary.focus() + if (item instanceof HTMLAnchorElement) return + void perform(item.dataset.export!).catch(cause => + showText("Export failed", cause instanceof Error ? cause.message : String(cause)) + ) + }) + } + element("export-close-button").addEventListener("click", () => dialog.close()) + element("export-copy-button").addEventListener("click", async () => { + try { + await navigator.clipboard.writeText(text.value) + dialog.close() + } catch (cause) { + error.textContent = `Could not copy: ${ + cause instanceof Error ? cause.message : String(cause) + }. Select the text and copy it manually.` + error.hidden = false + text.focus() + text.select() + } + }) + + async function perform(action: string) { + if (action === "issue") await options.compile() + const project = options.project() + const source = project.files[project.activeFile] ?? "" + const language = /\.(jsx?|tsx?)$/i.exec(project.activeFile)?.[1] ?? "" + const link = `[Playground Link](${project.url})` + switch (action) { + case "link": + return showText("Playground link", project.url) + case "issue": + return showText("Markdown Issue", projectMarkdown(project)) + case "markdown-link": + return showText("Markdown Link", link) + case "preview": + return showText( + "Markdown Link with Preview", + `${codeBlock(source.length > 200 ? `${source.slice(0, 200)}...` : source, language)}\n${link}` + ) + default: + throw new Error(`Unknown export: ${action}`) + } + } +} + +function element(id: string) { + const result = document.getElementById(id) + if (!result) throw new Error(`Missing #${id}`) + return result as T +} diff --git a/packages/playground-v2/src/global.d.ts b/packages/playground-v2/src/global.d.ts new file mode 100644 index 000000000000..e4fb1ae047a1 --- /dev/null +++ b/packages/playground-v2/src/global.d.ts @@ -0,0 +1,20 @@ +declare module "*.css" +declare module "*.json" { + const value: any + export default value +} +declare module "monaco-editor/editor/contrib/links/browser/links.js" + +declare module "monaco-editor-core/esm/vs/base/browser/touch.js" { + export const EventType: { Change: string; Tap: string } +} + +declare module "monaco-editor/languages/definitions/javascript/javascript.js" { + export const conf: import("monaco-editor-core").languages.LanguageConfiguration + export const language: import("monaco-editor-core").languages.IMonarchLanguage +} + +declare module "monaco-editor/languages/definitions/typescript/typescript.js" { + export const conf: import("monaco-editor-core").languages.LanguageConfiguration + export const language: import("monaco-editor-core").languages.IMonarchLanguage +} diff --git a/packages/playground-v2/src/index.html b/packages/playground-v2/src/index.html new file mode 100644 index 000000000000..cb0ff74cd56a --- /dev/null +++ b/packages/playground-v2/src/index.html @@ -0,0 +1,344 @@ + + + + + + + + + + + TypeScript Playground v2 + + + +
+
+
+

TypeScript Playground v2

+
+
+
+ + +
+
+ + +
+ + + + +
+
+
+ + +
+
+
+ + +

index.ts

+
+
+ + + +
+
+
+
+ +
+
+
+ +

Emit

+
+
+ Waiting for the compiler + +
+
+
+
+ + Problems + 0 + +
+

No problems found.

+
+
+
+
+
+ +

Run output

+
+
+ +
+
+
+

Run the project to see console output.

+
+
+
+
+ +
+
+

Add to project

+
+
+ Type + + +
+ + + +
+ + +
+
+
+ +
+
+

Playground settings

+
+
+ Behavior + + +
+
+ Editor + + + + + + +
+
+ + + + +
+
+
+ +
+
+

Confirm

+
+

+
+ + +
+
+
+ +
+
+

Enter a value

+
+ + +
+ + +
+
+
+ +

Export

+ + +
+ + +
+
+
+
+ +
+

Loading TypeScript...

+ + +
+ +
+
+
+ + + + diff --git a/packages/playground-v2/src/lsp-filesystem.test.ts b/packages/playground-v2/src/lsp-filesystem.test.ts new file mode 100644 index 000000000000..196114e6a8ff --- /dev/null +++ b/packages/playground-v2/src/lsp-filesystem.test.ts @@ -0,0 +1,173 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { Directory, File, OpenDirectory, wasi } from "@bjorn3/browser_wasi_shim" +import { + frameFileSystemUpdate, + LspFileSystem, + LspStdinRecords, + normalizeWorkspaceEditUris, + type FileSystemUpdate, +} from "./lsp-filesystem" +import { resolveSymlinks, validateProjectSymlinks } from "./symlinks" + +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +test("workspace edits resolve alias URIs and merge duplicate edits without changing the source response", () => { + const alias = "file:///workspace/src/value.ts" + const target = "file:///workspace/shared/value.ts" + const edit = { range: { start: { line: 0, character: 13 }, end: { line: 0, character: 18 } }, newText: "renamed" } + const original = { + changes: { [alias]: [edit], [target]: [edit] }, + documentChanges: [{ textDocument: { uri: alias, version: null }, edits: [edit] }], + } + const normalized = normalizeWorkspaceEditUris(original, uri => (uri === alias ? target : uri)) + assert.deepEqual(normalized, { + changes: { [target]: [edit] }, + documentChanges: [{ textDocument: { uri: target, version: null }, edits: [edit] }], + }) + assert.equal(original.documentChanges[0].textDocument.uri, alias) +}) + +test("stdin forwards LSP frames and applies split filesystem records before the next message", () => { + const updates: FileSystemUpdate[] = [] + const records = new LspStdinRecords(update => updates.push(update)) + const update = { + id: 1, + files: { "/workspace/node_modules/pkg/index.d.ts": "export const café: string;" }, + deleted: [], + } + const control = frameFileSystemUpdate(update) + const frame = encoder.encode("Content-Length: 2\r\n\r\n{}") + const input = new Uint8Array(control.length + frame.length) + input.set(control) + input.set(frame, control.length) + for (let start = 0; start < input.length; start += 7) { + records.push(input.subarray(start, start + 7)) + } + assert.deepEqual(updates, []) + assert.equal(records.readable, true) + assert.equal(decoder.decode(records.read(5)), "Conte") + assert.deepEqual(updates, [update]) + assert.equal(decoder.decode(records.read(100)), "nt-Length: 2\r\n\r\n{}") + assert.equal(records.readable, false) + assert.equal(records.read(1), undefined) +}) + +test("filesystem records can exceed the ring size without becoming LSP data", () => { + let applied = 0 + const update = { + id: 2, + files: { "/workspace/node_modules/pkg/large.d.ts": "x".repeat(4 * 1024 * 1024 + 1) }, + deleted: [], + } + const records = new LspStdinRecords(result => { + assert.deepEqual(result, update) + applied++ + }) + const input = frameFileSystemUpdate(update) + for (let start = 0; start < input.length; start += 64 * 1024) { + records.push(input.subarray(start, start + 64 * 1024)) + } + assert.equal(records.read(1), undefined) + assert.equal(applied, 1) + assert.equal(records.readable, false) +}) + +test("filesystem updates preserve ordering with preceding and following LSP messages", () => { + const updates: number[] = [] + const records = new LspStdinRecords(update => updates.push(update.id)) + const before = encoder.encode("Content-Length: 2\r\n\r\n[]") + const after = encoder.encode("Content-Length: 2\r\n\r\n{}") + const update = frameFileSystemUpdate({ id: 3, files: {}, deleted: [] }) + const data = new Uint8Array(before.length + update.length + after.length) + data.set(before) + data.set(update, before.length) + data.set(after, before.length + update.length) + records.push(data) + assert.deepEqual(updates, []) + assert.equal(decoder.decode(records.read(before.length)), decoder.decode(before)) + assert.deepEqual(updates, []) + assert.equal(decoder.decode(records.read(after.length)), decoder.decode(after)) + assert.deepEqual(updates, [3]) +}) + +test("virtual package files are created, changed, and removed without replacing existing directory handles", () => { + const root = new LspFileSystem({ "/workspace/src/index.ts": 'import "pkg";' }) + const workspace = root.preopen.dir.contents.get("workspace") + assert(workspace instanceof Directory) + root.update( + { + "/workspace/node_modules/pkg/index.d.ts": "export const value: string;", + "/workspace/node_modules/pkg/package.json": '{"types":"index.d.ts"}', + }, + [] + ) + const modules = workspace.contents.get("node_modules") + assert(modules instanceof Directory) + const pkg = modules.contents.get("pkg") + assert(pkg instanceof Directory) + const file = pkg.contents.get("index.d.ts") + assert(file instanceof File) + root.update({ "/workspace/node_modules/pkg/index.d.ts": "export const value: number;" }, []) + assert.equal(pkg.contents.get("index.d.ts"), file) + assert.equal(decoder.decode(file.data), "export const value: number;") + root.update({}, ["/workspace/node_modules/pkg/index.d.ts", "/workspace/node_modules/pkg/package.json"]) + assert.equal(pkg.contents.size, 0) + assert.equal(root.preopen.dir.contents.get("workspace"), workspace) + assert.throws(() => root.update({ "/workspace/../bad.ts": "" }, []), /Invalid/) +}) + +test("stdin rejects malformed and oversized record headers explicitly", () => { + const records = () => new LspStdinRecords(() => {}) + assert.throws(() => records().push(encoder.encode("Unknown: 2\r\n\r\n{}")), /Invalid/) + assert.throws(() => records().push(encoder.encode("Content-Length: 50331649\r\n\r\n")), /Invalid/) + assert.throws(() => records().push(encoder.encode("x".repeat(257))), /header/) +}) + +test("symbolic links resolve relative targets before parent components and reject conflicting project paths", () => { + const links = { "/workspace/node_modules/pkg": "../packages/pkg", "/workspace/src/value.ts": "../shared/value.ts" } + assert.equal(resolveSymlinks("/workspace/node_modules/pkg/../other", links), "/workspace/packages/other") + assert.equal(resolveSymlinks("/workspace/src/value.ts", links, false), "/workspace/src/value.ts") + assert.equal(resolveSymlinks("/workspace/src/value.ts", links), "/workspace/shared/value.ts") + validateProjectSymlinks({}, links) + assert.throws(() => validateProjectSymlinks({ "/workspace/src/value.ts": "" }, links), /conflicts/) + assert.throws(() => validateProjectSymlinks({}, { "/workspace/link": "../../outside" }), /inside/) + assert.throws(() => validateProjectSymlinks({}, { "/workspace/a": "b", "/workspace/b": "a" }), /cycle/) +}) + +test("WASI follows file and directory symlinks, exposes readlink and lstat, and preserves open target handles", () => { + const root = new LspFileSystem( + { "/workspace/packages/pkg/pkg/index.ts": "before" }, + { + "/workspace/node_modules/pkg": "../packages/pkg", + "/workspace/source.ts": "packages/pkg/pkg/index.ts", + "/workspace/dangling": "missing", + "/workspace/loop": "loop", + } + ) + assert.equal( + root.preopen.path_filestat_get(0, "workspace/node_modules/pkg").filestat?.filetype, + wasi.FILETYPE_SYMBOLIC_LINK + ) + assert.equal( + root.preopen.path_filestat_get(1, "workspace/node_modules/pkg").filestat?.filetype, + wasi.FILETYPE_DIRECTORY + ) + assert.equal(root.preopen.path_readlink("workspace/node_modules/pkg").data, "../packages/pkg") + assert.equal(root.preopen.path_readlink("workspace/source.ts").data, "packages/pkg/pkg/index.ts") + const opened = root.preopen.path_open(1, "workspace/node_modules/pkg/pkg", wasi.OFLAGS_DIRECTORY, 0n, 0n, 0).fd_obj + assert(opened instanceof OpenDirectory) + assert.equal( + opened.path_lookup("index.ts", 1).inode_obj, + root.preopen.path_lookup("workspace/source.ts", 1).inode_obj + ) + const changes = root.update({ "/workspace/packages/pkg/pkg/index.ts": "after" }, []) + assert(changes.some(change => change.path === "/workspace/source.ts" && change.type === 2)) + assert(changes.some(change => change.path === "/workspace/node_modules/pkg/pkg/index.ts" && change.type === 2)) + const file = opened.path_lookup("index.ts", 1).inode_obj + assert(file instanceof File) + assert.equal(decoder.decode(file.data), "after") + assert.equal(root.preopen.path_filestat_get(1, "workspace/dangling").ret, wasi.ERRNO_NOENT) + assert.equal(root.preopen.path_filestat_get(1, "workspace/loop").ret, wasi.ERRNO_LOOP) +}) diff --git a/packages/playground-v2/src/lsp-filesystem.ts b/packages/playground-v2/src/lsp-filesystem.ts new file mode 100644 index 000000000000..4c8a3ad88de3 --- /dev/null +++ b/packages/playground-v2/src/lsp-filesystem.ts @@ -0,0 +1,282 @@ +import { Directory, File, Inode, OpenDirectory, wasi } from "@bjorn3/browser_wasi_shim" +import { normalizeVirtualPath, resolveSymlinks, type Symlinks } from "./symlinks" + +export type FileSystemUpdate = { + id: number + files: Record + deleted: string[] +} + +export function normalizeWorkspaceEditUris(value: unknown, normalize: (uri: string) => string): unknown { + if (!value || typeof value !== "object") return value + const edit = value as { changes?: Record; documentChanges?: unknown[] } + const changes: Record = {} + if (edit.changes) { + for (const [uri, edits] of Object.entries(edit.changes)) { + const target = normalize(uri) + const existing = (changes[target] ??= []) + for (const item of edits) { + if (!existing.some(previous => JSON.stringify(previous) === JSON.stringify(item))) existing.push(item) + } + } + } + return { + ...edit, + ...(edit.changes ? { changes } : {}), + ...(edit.documentChanges + ? { + documentChanges: edit.documentChanges.map(change => { + if (!change || typeof change !== "object" || !("textDocument" in change)) return change + const document = change.textDocument + if (!document || typeof document !== "object" || !("uri" in document) || typeof document.uri !== "string") + return change + return { ...change, textDocument: { ...document, uri: normalize(document.uri) } } + }), + } + : {}), + } +} + +const encoder = new TextEncoder() +const decoder = new TextDecoder() +const maxRecordBytes = 48 * 1024 * 1024 +const filesHeader = "Playground-Files-Length" +// The shim does not export WASI's lookupflags.SYMLINK_FOLLOW constant. +const symlinkFollow = 1 + +export function frameFileSystemUpdate(update: FileSystemUpdate) { + const body = encoder.encode(JSON.stringify(update)) + if (body.length > maxRecordBytes) throw new Error("Package filesystem update exceeds the 48 MiB stdin limit") + const header = encoder.encode(`${filesHeader}: ${body.length}\r\n\r\n`) + const record = new Uint8Array(header.length + body.length) + record.set(header) + record.set(body, header.length) + return record +} + +// Filesystem control records share stdin because WASI blocks the worker's event loop. +export class LspStdinRecords { + private readonly header: number[] = [] + private current: { data: Uint8Array; offset: number; headerLength: number; files: boolean } | undefined + private readonly output: Array<{ data: Uint8Array } | { files: FileSystemUpdate }> = [] + private outputOffset = 0 + + constructor(private readonly onFiles: (update: FileSystemUpdate) => void) {} + + get readable() { + return this.output.length > 0 + } + + push(data: Uint8Array) { + let offset = 0 + while (offset < data.length) { + if (!this.current) { + this.header.push(data[offset++]) + const length = this.header.length + if (length > 256) throw new Error("Invalid playground stdin record header") + if ( + length < 4 || + this.header[length - 4] !== 13 || + this.header[length - 3] !== 10 || + this.header[length - 2] !== 13 || + this.header[length - 1] !== 10 + ) + continue + const header = new Uint8Array(this.header) + const match = /^(Content-Length|Playground-Files-Length): (\d+)\r\n\r\n$/i.exec(decoder.decode(header)) + if (!match) throw new Error("Invalid playground stdin record") + const size = Number(match[2]) + if (!Number.isSafeInteger(size) || size < 1 || size > maxRecordBytes) { + throw new Error("Invalid playground stdin record length") + } + const record = new Uint8Array(header.length + size) + record.set(header) + this.current = { + data: record, + offset: header.length, + headerLength: header.length, + files: match[1].toLowerCase() === filesHeader.toLowerCase(), + } + this.header.length = 0 + } + const current = this.current + const length = Math.min(data.length - offset, current.data.length - current.offset) + current.data.set(data.subarray(offset, offset + length), current.offset) + current.offset += length + offset += length + if (current.offset !== current.data.length) continue + this.current = undefined + if (current.files) { + const update: FileSystemUpdate = JSON.parse(decoder.decode(current.data.subarray(current.headerLength))) + this.output.push({ files: update }) + } else { + this.output.push({ data: current.data }) + } + } + } + + read(size: number) { + let next = this.output[0] + while (next && "files" in next) { + this.onFiles(next.files) + this.output.shift() + next = this.output[0] + } + if (!next || !("data" in next)) return undefined + const data = next.data + const result = data.subarray(this.outputOffset, this.outputOffset + size) + this.outputOffset += result.length + if (this.outputOffset === data.length) { + this.output.shift() + this.outputOffset = 0 + } + return result + } +} + +export class LspFileSystem { + readonly preopen: OpenDirectory + private readonly aliases = new Map>() + + constructor(files: Record, private readonly symlinks: Symlinks = {}) { + this.preopen = new LspOpenDirectory(new Directory(new Map()), this, "/") + for (const parts of [["workspace"], ["tmp"], ["typescript", "lib"]]) this.directory(parts, true) + this.update(files, []) + for (const [path, target] of Object.entries(symlinks)) { + const parts = this.parts(path) + this.directory(parts.slice(0, -1), true)!.contents.set(parts.at(-1)!, new Symlink(target)) + } + } + + lookup(path: string, followFinal: boolean): { ret: number; inode_obj: Inode | null } { + const resolved = resolveSymlinks(path, this.symlinks, followFinal) + if (resolved === undefined) return { ret: wasi.ERRNO_LOOP, inode_obj: null } + const original = normalizeVirtualPath(path) + if (original !== resolved) { + let aliases = this.aliases.get(resolved) + if (!aliases) this.aliases.set(resolved, (aliases = new Set())) + aliases.add(original) + } + let inode: Inode = this.preopen.dir + for (const part of resolved.split("/").filter(Boolean)) { + if (!(inode instanceof Directory)) return { ret: wasi.ERRNO_NOTDIR, inode_obj: null } + const child = inode.contents.get(part) + if (!child) return { ret: wasi.ERRNO_NOENT, inode_obj: null } + inode = child + } + return { ret: wasi.ERRNO_SUCCESS, inode_obj: inode } + } + + update(files: Record, deleted: readonly string[]) { + const aliases: Array<{ path: string; type: number }> = [] + const entries = Object.entries(files).map(([path, text]) => ({ parts: this.parts(path), text })) + const removed = deleted.map(path => this.parts(path)) + for (const { parts, text } of entries) { + const parent = this.directory(parts.slice(0, -1), true)! + const name = parts.at(-1)! + const file = parent.contents.get(name) + for (const path of this.aliases.get(`/${parts.join("/")}`) ?? []) { + aliases.push({ path, type: file instanceof File ? 2 : 1 }) + } + if (file instanceof File) file.data = encoder.encode(text) + else parent.contents.set(name, new File(encoder.encode(text))) + } + for (const parts of removed) { + this.directory(parts.slice(0, -1), false)?.contents.delete(parts.at(-1)!) + for (const path of this.aliases.get(`/${parts.join("/")}`) ?? []) aliases.push({ path, type: 3 }) + } + return aliases + } + + private parts(path: string) { + const parts = path.slice(1).split("/") + if (!path.startsWith("/") || parts.some(part => !part || part === "." || part === ".." || /[\\\0]/.test(part))) { + throw new Error(`Invalid language-server filesystem path: ${path}`) + } + return parts + } + + private directory(parts: readonly string[], create: boolean) { + let current = this.preopen.dir + for (const part of parts) { + let child = current.contents.get(part) + if (!child) { + if (!create) return undefined + child = new Directory(new Map()) + current.contents.set(part, child) + } + if (!(child instanceof Directory)) throw new Error(`Filesystem path component is not a directory: ${part}`) + current = child + } + return current + } +} + +class Symlink extends Inode { + constructor(readonly target: string) { + super() + } + + path_open() { + return { ret: wasi.ERRNO_LOOP, fd_obj: null } + } + + stat() { + return new wasi.Filestat(this.ino, wasi.FILETYPE_SYMBOLIC_LINK, BigInt(encoder.encode(this.target).length)) + } +} + +class LspOpenDirectory extends OpenDirectory { + constructor(dir: Directory, private readonly filesystem: LspFileSystem, private readonly path: string) { + super(dir) + } + + override fd_prestat_get() { + return this.path === "/" ? { ret: wasi.ERRNO_SUCCESS, prestat: wasi.Prestat.dir("/") } : super.fd_prestat_get() + } + + override path_lookup(path: string, flags: number) { + return this.filesystem.lookup(`${this.path}/${path}`, (flags & symlinkFollow) !== 0) + } + + override path_filestat_get(flags: number, path: string) { + const result = this.path_lookup(path, flags) + return { ret: result.ret, filestat: result.inode_obj?.stat() ?? null } + } + + override path_readlink(path: string) { + const result = this.path_lookup(path, 0) + if (!result.inode_obj) return { ret: result.ret, data: null } + return result.inode_obj instanceof Symlink + ? { ret: wasi.ERRNO_SUCCESS, data: result.inode_obj.target } + : { ret: wasi.ERRNO_INVAL, data: null } + } + + override path_open( + flags: number, + path: string, + oflags: number, + rights: bigint, + inheritedRights: bigint, + fdflags: number + ) { + const result = this.path_lookup(path, flags) + if (!result.inode_obj) { + if (result.ret === wasi.ERRNO_NOENT && (oflags & wasi.OFLAGS_CREAT) !== 0) { + return super.path_open(flags, path, oflags, rights, inheritedRights, fdflags) + } + return { ret: result.ret, fd_obj: null } + } + if ((oflags & wasi.OFLAGS_EXCL) !== 0) return { ret: wasi.ERRNO_EXIST, fd_obj: null } + if ((oflags & wasi.OFLAGS_DIRECTORY) !== 0 && !(result.inode_obj instanceof Directory)) { + return { ret: wasi.ERRNO_NOTDIR, fd_obj: null } + } + if (result.inode_obj instanceof Directory) { + return { + ret: wasi.ERRNO_SUCCESS, + fd_obj: new LspOpenDirectory(result.inode_obj, this.filesystem, `${this.path}/${path}`), + } + } + return result.inode_obj.path_open(oflags, rights, fdflags) + } +} diff --git a/packages/playground-v2/src/main.ts b/packages/playground-v2/src/main.ts new file mode 100644 index 000000000000..d9f49fca6c3c --- /dev/null +++ b/packages/playground-v2/src/main.ts @@ -0,0 +1,3209 @@ +import type { Diagnostic as CompilerDiagnostic } from "@typescript/typescript/sync" +import { + legacyCompilerBaseUrl, + loadPublishedCompilerDistribution, + type CompilerDistribution, +} from "./compiler-distribution" +import { + compilerVersionChoices, + isDefaultNativeCompilerVersion, + isNativeCompilerVersion, + isUnsupportedNativeCompilerVersion, + loadPublishedCompilerVersions, + loadLegacyCompilerVersions, + publishedNativeCompilerSelection, +} from "./compiler-versions" +import "monaco-editor/editor/contrib/links/browser/links.js" +import LZString from "lz-string" +import "reflect-metadata" +import { + applyLegacyCompilerOptions, + canSetCompilerOptions, + compilerOverrideForDiagnostic, + compilerOptionsNode, + computeCompilerOverrides, + configOptionNode, + setCompilerOption, + type CompilerOverride, + type CompilerOverrideState, +} from "./compiler-overrides" +import { registerConfigSchema } from "./config-schema" +import { configureEditorTouchScroll } from "./editor-touch-scroll" +import { configureExports, decodeCompressedHash } from "./exporter" +import { resolveSymlinks, validateProjectSymlinks, type Symlinks } from "./symlinks" +import { isSourceLanguage, languageForFile, sourceLanguages } from "./editor-languages" +import { StradaBackend } from "./strada" +import { NativeBackend } from "./native" +import { PackageTypeAcquirer, stradaDiscovery, type AcquisitionResult, type DiscoverDependencies } from "./ata" +import { acquisitionOptions } from "./ata/config" +import { monaco, registerPlaygroundLanguages, startTsgoLsp, type TsgoLspController } from "./tsgo-lsp" +import "./styles.css" + +type Diagnostic = Omit & { fileName?: string } + +function remapProjectDiagnostics(diagnostics: readonly Diagnostic[]) { + return diagnostics.map(diagnostic => { + const fileName = diagnostic.fileName && resolveSymlinks(diagnostic.fileName, projectSymlinks) + if (fileName) diagnostic = { ...diagnostic, fileName } + const matchingOverride = compilerOverrideForDiagnostic( + compilerOverrideState, + diagnostic, + resolveSymlinks(configFileName, projectSymlinks) ?? configFileName + ) + if (!matchingOverride) return diagnostic + return { + ...diagnostic, + end: matchingOverride.end, + fileName: matchingOverride.fileName, + pos: matchingOverride.start, + source: "Playground", + } + }) +} + +type TypeQuery = { + lineNumber: number + column: number + label: string +} + +type ProjectFile = { + path: string + language: ReturnType + text: string +} + +type ProjectState = { + activeFile?: string + files: Record + selection?: { + positionColumn: number + positionLineNumber: number + selectionStartColumn: number + selectionStartLineNumber: number + } + useDefaults?: boolean + symlinks?: Symlinks + legacyQuery?: string +} + +type VersionedProjectState = ProjectState & { + version: 2 +} + +type LayoutState = { + emitVisible: boolean + filesVisible: boolean + filesWidth: number + outputVisible: boolean + outputWidth: number + runVisible: boolean +} + +type PlaygroundSettings = { + automaticTypeAcquisition: boolean + fontLigatures: boolean + fontSize: number + minimap: boolean + saveToUrl: boolean + tabSize: number + theme: "dark" | "light" | "system" + wordWrap: boolean +} + +type RuntimeLog = { + level: "debug" | "error" | "info" | "log" | "warn" + text: string +} + +declare global { + interface Window { + ts: any + } +} + +;( + self as typeof self & { + MonacoEnvironment: { getWorker(): Worker } + } +).MonacoEnvironment = { + getWorker() { + return new Worker(new URL("./editor.worker.js", import.meta.url), { type: "module" }) + }, +} + +const projectRoot = "/workspace" +const configFileName = `${projectRoot}/tsconfig.json` +const entryFileName = `${projectRoot}/src/index.ts` +const storageKey = "ts7-playground-project" +const pendingReloadKey = "ts7-playground-pending-reload" +const projectHashPrefix = "#code/v2/" +const legacyCodeHashPrefix = "#code/" +const projectStateVersion = 2 +const selectedCompiler = new URLSearchParams(location.search).get("ts") +const useNativeCompiler = isNativeCompilerVersion(selectedCompiler) +const defaultFiles: ProjectFile[] = [ + { + path: configFileName, + language: "json", + text: `{ + "compilerOptions": { + "module": "nodenext", + "target": "esnext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "moduleDetection": "force", + "declaration": true, + "skipLibCheck": true + }, + "include": ["./src/**/*"] +} +`, + }, + { + path: entryFileName, + language: "typescript", + text: `const message: string = "Hello, TypeScript Playground v2!" +// ^? + +console.log(message) +`, + }, +] + +const inputElement = getElement("input-editor") +const workspace = document.querySelector(".workspace")! +const fileExplorer = getElement("file-explorer") +const fileList = getElement("file-list") +const fileResizer = getElement("file-resizer") +const outputResizer = getElement("output-resizer") +const compilerVersion = getElement("compiler-version") +const compilerVersionButton = getElement("compiler-version-button") +const compilerVersionLabel = getElement("compiler-version-label") +const compilerVersionMenu = getElement("compiler-version-menu") +const addButton = getElement("add-button") +const clearProjectButton = getElement("clear-project-button") +const applyCompilerOverridesButton = getElement("apply-compiler-overrides-button") +const toggleFilesButton = getElement("toggle-files-button") +const toggleOutputButton = getElement("toggle-output-button") +const showFilesButton = getElement("show-files-button") +const showOutputButton = getElement("show-output-button") +const toggleEmitButton = getElement("toggle-emit-button") +const toggleRunOutputButton = getElement("toggle-run-output-button") +const navigateBackButton = getElement("navigate-back-button") +const currentFile = getElement("current-file") +const editorHint = getElement("editor-hint") +const emitOutput = getElement("emit-output") +const emitSummary = getElement("emit-summary") +const diagnosticsPanel = getElement("diagnostics-panel") +const diagnosticsSummary = getElement("diagnostics-summary") +const diagnosticsList = getElement("diagnostics-list") +const runButton = getElement("run-button") +const settingsButton = getElement("settings-button") +const settingsDialog = getElement("settings-dialog") +const settingsForm = getElement("settings-form") +const settingsCancelButton = getElement("settings-cancel-button") +const settingsResetButton = getElement("settings-reset-button") +const settingAta = getElement("setting-ata") +const settingSaveUrl = getElement("setting-save-url") +const settingFontSize = getElement("setting-font-size") +const settingTabSize = getElement("setting-tab-size") +const settingTheme = getElement("setting-theme") +const settingWordWrap = getElement("setting-word-wrap") +const settingMinimap = getElement("setting-minimap") +const settingLigatures = getElement("setting-ligatures") +const confirmationDialog = getElement("confirmation-dialog") +const confirmationForm = getElement("confirmation-form") +const confirmationTitle = getElement("confirmation-title") +const confirmationMessage = getElement("confirmation-message") +const confirmationCancelButton = getElement("confirmation-cancel-button") +const confirmationSubmitButton = getElement("confirmation-submit-button") +const textInputDialog = getElement("text-input-dialog") +const textInputForm = getElement("text-input-form") +const textInputTitle = getElement("text-input-title") +const textInputLabel = getElement("text-input-label") +const textInputValue = getElement("text-input-value") +const textInputError = getElement("text-input-error") +const textInputCancelButton = getElement("text-input-cancel-button") +const textInputSubmitButton = getElement("text-input-submit-button") +const newFileDialog = getElement("new-file-dialog") +const newFileForm = getElement("new-file-form") +const newFilePath = getElement("new-file-path") +const newFileKindFile = getElement("new-file-kind-file") +const newFileKindSymlink = getElement("new-file-kind-symlink") +const newSymlinkTargetLabel = getElement("new-symlink-target-label") +const newSymlinkTarget = getElement("new-symlink-target") +const newFileError = getElement("new-file-error") +const newFileCancelButton = getElement("new-file-cancel-button") +const clearRunOutput = getElement("clear-run-output") +const runOutput = getElement("run-output") +const runLog = getElement("run-log") +const status = getElement("status") +const loader = getElement("loader") +const loadingAnimation = getElement("loading-animation") +const loadingCopy = getElement("loading-copy") +const loadingMessage = getElement("loading-message") +const loadingProgress = getElement("loading-progress") +const loadingDetail = getElement("loading-detail") +const downloadConsent = getElement("download-consent") +const downloadSize = getElement("download-size") +const rememberDownloadConsent = getElement("remember-download-consent") +const confirmDownloadButton = getElement("confirm-download-button") + +let compilerReady = false +let lspReady = false +let compilerFailure: string | undefined +let lspFailure: string | undefined +let projectFailure: string | undefined +let languageServer: TsgoLspController | undefined +let stradaBackend: StradaBackend | undefined +let loadedLegacyCompilerVersion: string | undefined +let nativeBackend: NativeBackend | undefined +let nativeCompileVersion = 0 +let DiagnosticCategory: typeof import("@typescript/typescript/sync").DiagnosticCategory +let compilerDistributionPromise: Promise | undefined +let publishedCompilerVersionsPromise: ReturnType | undefined +let legacyCompilerVersionsPromise: ReturnType | undefined +let nativeDistribution: CompilerDistribution | undefined +let projectPersistTimer = 0 +let projectPersistenceEnabled = true +let lastStoredProject: string | undefined +let lastUrlProject: string | undefined +let compileActiveProject: (() => Promise | void) | undefined +let emittedFiles = new Map() +let emitRenderVersion = 0 +let hasShownDiagnostics = false +let typeAcquisitionFailure: string | undefined +const typeAcquirer = new PackageTypeAcquirer() +let discoverDependencies: DiscoverDependencies | undefined +let typeAcquisitionController: AbortController | undefined +let typeAcquisitionTimer = 0 +const acquiredTypeFiles = new Map() +const acquiredTypePackages = new Set() +const acquiredTypeModels = new Map() +let confirmationResolver: ((value: boolean) => void) | undefined +let textInputResolver: ((value: string | null) => void) | undefined +const downloadedAssets = new Map() +const cachedAssets = new Map() +const assetCachePrefix = "ts7-playground-assets-" +let assetCachePromise: Promise | undefined +const layoutStorageKey = "ts7-playground-layout" +const downloadConsentStorageKey = "ts7-playground-skip-download-warning" +const settingsStorageKey = "ts7-playground-settings" +let compilerOverrideState: CompilerOverrideState +let playgroundSettings = loadPlaygroundSettings() + +const systemTheme = matchMedia("(prefers-color-scheme: dark)") +function configureEditorTheme() { + const darkMode = playgroundSettings.theme === "dark" || (playgroundSettings.theme === "system" && systemTheme.matches) + monaco.editor.defineTheme("typescript-playground", { + base: darkMode ? "vs-dark" : "vs", + inherit: true, + rules: [ + { token: "comment", foreground: darkMode ? "7caf3d" : "6c6f2d" }, + { token: "keyword", foreground: darkMode ? "7fc7ff" : "2b45c7" }, + { token: "number", foreground: darkMode ? "b5cea8" : "087f53" }, + { token: "type", foreground: darkMode ? "4ec9b0" : "1142af" }, + { token: "class", foreground: darkMode ? "4ec9b0" : "267f99" }, + { token: "enum", foreground: darkMode ? "4ec9b0" : "267f99" }, + { token: "interface", foreground: darkMode ? "4ec9b0" : "267f99" }, + { token: "function", foreground: darkMode ? "dcdcaa" : "795e26" }, + { token: "method", foreground: darkMode ? "dcdcaa" : "795e26" }, + { token: "parameter", foreground: darkMode ? "9cdcfe" : "001080" }, + { token: "property", foreground: darkMode ? "9cdcfe" : "001080" }, + { token: "variable", foreground: darkMode ? "9cdcfe" : "001080" }, + ], + colors: { + "editor.background": darkMode ? "#1e1e1e" : "#fafafa", + "editorLineNumber.foreground": darkMode ? "#a0a0a0" : "#6a737d", + "editorLineNumber.dimmedForeground": darkMode ? "#a0a0a0" : "#6a737d", + "editorLineNumber.activeForeground": darkMode ? "#f3f3f3" : "#333333", + "editor.inactiveSelectionBackground": darkMode ? "#3a3d41" : "#e8e8e8", + "editor.selectionBackground": darkMode ? "#264f78" : "#dbeafe", + "editorInlayHint.background": darkMode ? "#333333" : "#eeeeee", + "editorInlayHint.foreground": darkMode ? "#d4d4d4" : "#333333", + }, + }) +} +configureEditorTheme() +systemTheme.addEventListener("change", () => { + if (playgroundSettings.theme !== "system") return + configureEditorTheme() + monaco.editor.setTheme("typescript-playground") + void renderEmittedFiles() +}) + +registerPlaygroundLanguages() +const initialState = loadProjectState() +let legacyQuery = + initialState.legacyQuery ?? (initialState.useDefaults !== false && location.search ? location.search : undefined) +const projectSymlinks: Record = { ...initialState.symlinks } +const initialFiles = + initialState.useDefaults === false + ? { ...initialState.files } + : { + ...Object.fromEntries(defaultFiles.map(file => [file.path, file.text])), + ...initialState.files, + } +const projectModels = new Map( + Object.entries(initialFiles).map(([fileName, text]) => { + const model = monaco.editor.createModel(text, languageForFile(fileName), monaco.Uri.parse(`file://${fileName}`)) + return [fileName, model] as const + }) +) +compilerOverrideState = computeCompilerOverrides(projectTextMap(), projectConfigModel()?.getValue() ?? "{}") +const fileButtons = new Map() +const directoryButtons = new Map() +const inputEditor = monaco.editor.create(inputElement, { + automaticLayout: true, + fontFamily: "Hack, monospace", + fontLigatures: playgroundSettings.fontLigatures, + fontSize: playgroundSettings.fontSize, + inlayHints: { enabled: "on" }, + minimap: { enabled: playgroundSettings.minimap }, + model: + projectModels.get(initialState.activeFile ?? entryFileName) ?? + projectModels.get(entryFileName) ?? + [...projectModels.values()].find(model => isSourceLanguage(model.getLanguageId())) ?? + [...projectModels.values()][0], + padding: { top: 10 }, + scrollBeyondLastLine: false, + "semanticHighlighting.enabled": true, + tabSize: playgroundSettings.tabSize, + theme: "typescript-playground", +}) +let effectiveConfigModel: monaco.editor.ITextModel | undefined +monaco.editor.registerEditorOpener({ + openCodeEditor(_source, resource, selection) { + if (resource.scheme !== "file") return false + const fileName = resolveSymlinks(resource.path, projectSymlinks) + if (!fileName || !monaco.editor.getModel(monaco.Uri.file(fileName))) return false + const range = selection && ("startLineNumber" in selection ? selection : monaco.Range.fromPositions(selection)) + navigateToModel(fileName, range) + return true + }, +}) + +type EditorLocation = { + selection: monaco.Selection + uri: string +} + +let projectReturnLocation: EditorLocation | undefined +let trackedEditorLocation: EditorLocation | undefined +const mobileLayout = matchMedia("(max-width: 700px), (max-width: 900px) and (max-height: 600px)") +const touchPrimary = matchMedia("(pointer: coarse)") +let lastInputWasTouch = false +document.addEventListener( + "pointerdown", + event => { + lastInputWasTouch = event.pointerType === "touch" + }, + { capture: true, passive: true } +) +document.addEventListener( + "keydown", + () => { + lastInputWasTouch = false + }, + { capture: true } +) +configureEditorTouchScroll(inputEditor, mobileLayout) +const layoutState = loadLayoutState() +applyLayoutState() +setupWorkspaceResizer(fileResizer, "files") +setupWorkspaceResizer(outputResizer, "output") +window.addEventListener("resize", () => { + if (!mobileLayout.matches) applyLayoutState() +}) +updateResponsiveEditorOptions() +mobileLayout.addEventListener("change", updateResponsiveEditorOptions) + +renderFileList() +restoreProjectSelection(initialState) +updateActiveFile() +trackedEditorLocation = getEditorLocation() +updateNavigationButtons() +inputEditor.onDidChangeModel(() => { + const nextLocation = getEditorLocation() + if ( + trackedEditorLocation && + nextLocation && + trackedEditorLocation.uri !== nextLocation.uri && + projectModels.get(monaco.Uri.parse(trackedEditorLocation.uri).path) + ) { + projectReturnLocation = trackedEditorLocation + } + trackedEditorLocation = nextLocation + updateActiveFile() + updateNavigationButtons() +}) +inputEditor.onDidChangeCursorSelection(() => { + trackedEditorLocation = getEditorLocation() + scheduleProjectPersistence() +}) +window.addEventListener("pagehide", () => { + if (playgroundSettings.saveToUrl) persistProjectState() + else flushProjectBeforeUnload() +}) +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") persistProjectState() +}) +inputEditor.addAction({ + id: "run-project", + label: "Run Project", + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], + run: runProject, +}) +const inlayEmitter = new monaco.Emitter() +const typeQueries = new Map() +// Monaco subscribes to refresh events only after its first hint request completes. +let resolveInitialTypeQueries: (() => void) | undefined +const initialTypeQueriesReady = new Promise(resolve => { + resolveInitialTypeQueries = resolve +}) +function completeInitialTypeQueries() { + resolveInitialTypeQueries?.() + resolveInitialTypeQueries = undefined +} +const compilerOverrideHints = new Map() +const overrideCodeLensEmitter = new monaco.Emitter() +let overrideCodeLensProvider: monaco.languages.CodeLensProvider | undefined +registerCompilerOverrideFeatures() +refreshCompilerOverrides() + +function loadLayoutState(): LayoutState { + const defaults: LayoutState = { + emitVisible: true, + filesVisible: true, + filesWidth: 208, + outputVisible: true, + outputWidth: 440, + runVisible: true, + } + try { + const stored = JSON.parse(localStorage.getItem(layoutStorageKey) ?? "{}") + return { + emitVisible: typeof stored.emitVisible === "boolean" ? stored.emitVisible : defaults.emitVisible, + filesVisible: typeof stored.filesVisible === "boolean" ? stored.filesVisible : defaults.filesVisible, + filesWidth: typeof stored.filesWidth === "number" ? stored.filesWidth : defaults.filesWidth, + outputVisible: typeof stored.outputVisible === "boolean" ? stored.outputVisible : defaults.outputVisible, + outputWidth: typeof stored.outputWidth === "number" ? stored.outputWidth : defaults.outputWidth, + runVisible: typeof stored.runVisible === "boolean" ? stored.runVisible : defaults.runVisible, + } + } catch { + return defaults + } +} + +function defaultPlaygroundSettings(): PlaygroundSettings { + return { + automaticTypeAcquisition: true, + fontLigatures: true, + fontSize: 14, + minimap: false, + saveToUrl: true, + tabSize: 2, + theme: "system", + wordWrap: false, + } +} + +function loadPlaygroundSettings() { + const defaults = defaultPlaygroundSettings() + try { + const stored = JSON.parse(localStorage.getItem(settingsStorageKey) ?? "{}") + return { + automaticTypeAcquisition: + typeof stored.automaticTypeAcquisition === "boolean" + ? stored.automaticTypeAcquisition + : defaults.automaticTypeAcquisition, + fontLigatures: typeof stored.fontLigatures === "boolean" ? stored.fontLigatures : defaults.fontLigatures, + fontSize: [12, 14, 16, 18, 20].includes(stored.fontSize) ? stored.fontSize : defaults.fontSize, + minimap: typeof stored.minimap === "boolean" ? stored.minimap : defaults.minimap, + saveToUrl: typeof stored.saveToUrl === "boolean" ? stored.saveToUrl : defaults.saveToUrl, + tabSize: stored.tabSize === 4 ? 4 : defaults.tabSize, + theme: stored.theme === "light" || stored.theme === "dark" ? stored.theme : defaults.theme, + wordWrap: typeof stored.wordWrap === "boolean" ? stored.wordWrap : defaults.wordWrap, + } satisfies PlaygroundSettings + } catch { + return defaults + } +} + +function openSettings() { + populateSettingsForm(playgroundSettings) + settingsDialog.showModal() + settingAta.focus() +} + +function populateSettingsForm(settings: PlaygroundSettings) { + settingAta.checked = settings.automaticTypeAcquisition + settingSaveUrl.checked = settings.saveToUrl + settingFontSize.value = String(settings.fontSize) + settingTabSize.value = String(settings.tabSize) + settingTheme.value = settings.theme + settingWordWrap.checked = settings.wordWrap + settingMinimap.checked = settings.minimap + settingLigatures.checked = settings.fontLigatures +} + +function saveSettings() { + const next: PlaygroundSettings = { + automaticTypeAcquisition: settingAta.checked, + fontLigatures: settingLigatures.checked, + fontSize: Number(settingFontSize.value), + minimap: settingMinimap.checked, + saveToUrl: settingSaveUrl.checked, + tabSize: Number(settingTabSize.value), + theme: settingTheme.value === "light" || settingTheme.value === "dark" ? settingTheme.value : "system", + wordWrap: settingWordWrap.checked, + } + const ataChanged = next.automaticTypeAcquisition !== playgroundSettings.automaticTypeAcquisition + const themeChanged = next.theme !== playgroundSettings.theme + playgroundSettings = next + try { + localStorage.setItem(settingsStorageKey, JSON.stringify(playgroundSettings)) + } catch (error) { + console.warn("Could not save playground settings", error) + } + settingsDialog.close() + applyEditorSettings() + if (playgroundSettings.saveToUrl) persistProjectState() + if (ataChanged || themeChanged) { + if (playgroundSettings.saveToUrl) { + location.reload() + } else { + const url = new URL(location.href) + url.hash = "" + location.replace(url) + } + } +} + +function applyEditorSettings() { + inputEditor.updateOptions({ + fontLigatures: playgroundSettings.fontLigatures, + fontSize: playgroundSettings.fontSize, + minimap: { enabled: playgroundSettings.minimap }, + tabSize: playgroundSettings.tabSize, + }) + updateResponsiveEditorOptions() +} + +function requestConfirmation(options: { + cancelLabel?: string | null + confirmLabel: string + danger?: boolean + message: string + title: string +}) { + if (confirmationResolver) finishConfirmation(false) + confirmationTitle.textContent = options.title + confirmationMessage.textContent = options.message + confirmationCancelButton.hidden = options.cancelLabel === null + confirmationCancelButton.textContent = options.cancelLabel ?? "Cancel" + confirmationSubmitButton.textContent = options.confirmLabel + confirmationSubmitButton.classList.toggle("danger", options.danger === true) + confirmationDialog.showModal() + confirmationSubmitButton.focus() + return new Promise(resolve => { + confirmationResolver = resolve + }) +} + +function finishConfirmation(value: boolean) { + const resolve = confirmationResolver + confirmationResolver = undefined + if (confirmationDialog.open) confirmationDialog.close() + resolve?.(value) +} + +function requestTextInput(options: { initialValue?: string; label: string; submitLabel: string; title: string }) { + if (textInputResolver) finishTextInput(null) + textInputTitle.textContent = options.title + textInputLabel.textContent = options.label + textInputValue.value = options.initialValue ?? "" + textInputError.hidden = true + textInputError.textContent = "" + textInputValue.removeAttribute("aria-invalid") + textInputSubmitButton.textContent = options.submitLabel + textInputDialog.showModal() + textInputValue.focus() + textInputValue.select() + return new Promise(resolve => { + textInputResolver = resolve + }) +} + +function finishTextInput(value: string | null) { + const resolve = textInputResolver + textInputResolver = undefined + if (textInputDialog.open) textInputDialog.close() + resolve?.(value) +} + +function updateResponsiveEditorOptions() { + const mobile = mobileLayout.matches + inputEditor.updateOptions({ + folding: !mobile, + lineNumbersMinChars: mobile ? 3 : 5, + scrollbar: { + horizontal: mobile ? "hidden" : "auto", + horizontalScrollbarSize: mobile ? 0 : 12, + verticalScrollbarSize: mobile ? 8 : 14, + }, + scrollBeyondLastColumn: mobile ? 0 : 5, + wordWrap: mobile || playgroundSettings.wordWrap ? "on" : "off", + wrappingIndent: "indent", + }) + requestAnimationFrame(() => inputEditor.layout()) +} + +function applyLayoutState() { + if (!matchMedia("(max-width: 1000px)").matches) { + layoutState.filesWidth = clampPanelWidth("files", layoutState.filesWidth) + layoutState.outputWidth = clampPanelWidth("output", layoutState.outputWidth) + } + workspace.style.setProperty("--files-width", layoutState.filesVisible ? `${layoutState.filesWidth}px` : "0px") + workspace.style.setProperty("--output-width", layoutState.outputVisible ? `${layoutState.outputWidth}px` : "0px") + workspace.dataset.filesCollapsed = String(!layoutState.filesVisible) + workspace.dataset.outputCollapsed = String(!layoutState.outputVisible) + emitOutput.hidden = !layoutState.emitVisible + runOutput.dataset.collapsed = String(!layoutState.runVisible) + + showFilesButton.hidden = layoutState.filesVisible + showOutputButton.hidden = layoutState.outputVisible + showFilesButton.setAttribute("aria-expanded", String(layoutState.filesVisible)) + showOutputButton.setAttribute("aria-expanded", String(layoutState.outputVisible)) + toggleFilesButton.setAttribute("aria-expanded", String(layoutState.filesVisible)) + toggleOutputButton.setAttribute("aria-expanded", String(layoutState.outputVisible)) + toggleEmitButton.textContent = layoutState.emitVisible ? "▾" : "▸" + toggleEmitButton.title = layoutState.emitVisible ? "Collapse Emit" : "Expand Emit" + toggleEmitButton.setAttribute("aria-label", toggleEmitButton.title) + toggleEmitButton.setAttribute("aria-expanded", String(layoutState.emitVisible)) + toggleRunOutputButton.textContent = layoutState.runVisible ? "▾" : "▸" + toggleRunOutputButton.title = layoutState.runVisible ? "Collapse Run output" : "Expand Run output" + toggleRunOutputButton.setAttribute("aria-label", toggleRunOutputButton.title) + toggleRunOutputButton.setAttribute("aria-expanded", String(layoutState.runVisible)) + + for (const [target, element] of [ + ["files", fileResizer], + ["output", outputResizer], + ] as const) { + const { minimum, maximum } = panelWidthLimits(target) + const width = Math.round(target === "files" ? layoutState.filesWidth : layoutState.outputWidth) + element.setAttribute("aria-valuemin", String(minimum)) + element.setAttribute("aria-valuemax", String(maximum)) + element.setAttribute("aria-valuenow", String(width)) + element.setAttribute("aria-valuetext", `${width} pixels`) + } + requestAnimationFrame(() => inputEditor.layout()) +} + +function persistLayoutState() { + try { + localStorage.setItem(layoutStorageKey, JSON.stringify(layoutState)) + } catch (error) { + console.warn("Could not save playground layout", error) + } +} + +function toggleWorkspacePanel(panel: "files" | "output") { + if (panel === "files") layoutState.filesVisible = !layoutState.filesVisible + else layoutState.outputVisible = !layoutState.outputVisible + applyLayoutState() + persistLayoutState() + const button = + panel === "files" + ? layoutState.filesVisible + ? toggleFilesButton + : showFilesButton + : layoutState.outputVisible + ? toggleOutputButton + : showOutputButton + button.focus() +} + +function setupWorkspaceResizer(element: HTMLElement, target: "files" | "output") { + element.addEventListener("pointerdown", event => { + if (matchMedia("(max-width: 1000px)").matches) return + event.preventDefault() + const startX = event.clientX + const startWidth = target === "files" ? layoutState.filesWidth : layoutState.outputWidth + element.setPointerCapture(event.pointerId) + document.body.dataset.resizing = target + + const move = (moveEvent: PointerEvent) => { + const delta = moveEvent.clientX - startX + const width = target === "files" ? startWidth + delta : startWidth - delta + if (target === "files") layoutState.filesWidth = width + else layoutState.outputWidth = width + applyLayoutState() + } + const stop = () => { + element.removeEventListener("pointermove", move) + element.removeEventListener("pointerup", stop) + element.removeEventListener("pointercancel", stop) + delete document.body.dataset.resizing + persistLayoutState() + } + element.addEventListener("pointermove", move) + element.addEventListener("pointerup", stop) + element.addEventListener("pointercancel", stop) + }) + + element.addEventListener("keydown", event => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return + event.preventDefault() + const direction = event.key === "ArrowRight" ? 1 : -1 + if (target === "files") layoutState.filesWidth += direction * 16 + else layoutState.outputWidth -= direction * 16 + applyLayoutState() + persistLayoutState() + }) +} + +function clampPanelWidth(target: "files" | "output", width: number) { + const { minimum, maximum } = panelWidthLimits(target) + return Math.min(Math.max(width, minimum), maximum) +} + +function panelWidthLimits(target: "files" | "output") { + const workspaceWidth = workspace.clientWidth || innerWidth + const minimum = target === "files" ? 144 : 240 + const otherWidth = + target === "files" + ? layoutState.outputVisible + ? layoutState.outputWidth + : 0 + : layoutState.filesVisible + ? layoutState.filesWidth + : 0 + const editorMinimum = 24 * parseFloat(getComputedStyle(document.documentElement).fontSize) + const handles = fileResizer.offsetWidth + outputResizer.offsetWidth + return { minimum, maximum: Math.max(minimum, workspaceWidth - otherWidth - editorMinimum - handles) } +} + +for (const language of sourceLanguages) { + monaco.languages.registerInlayHintsProvider(language, { + onDidChangeInlayHints: inlayEmitter.event, + async provideInlayHints(model) { + await initialTypeQueriesReady + return { + hints: [ + ...(typeQueries.get(model.uri.toString()) ?? []).map(query => ({ + kind: monaco.languages.InlayHintKind.Type, + position: new monaco.Position(query.lineNumber, query.column), + label: query.label, + paddingLeft: true, + })), + ...(compilerOverrideHints.get(model.uri.toString()) ?? []), + ], + dispose() {}, + } + }, + }) +} +monaco.languages.registerInlayHintsProvider("json", { + onDidChangeInlayHints: inlayEmitter.event, + provideInlayHints(model) { + return { + dispose() {}, + hints: compilerOverrideHints.get(model.uri.toString()) ?? [], + } + }, +}) + +let updateTimer = 0 +configureExports({ + compile: () => compileActiveProject?.(), + project() { + const state = currentProjectState() + const url = new URL(location.href) + url.hash = `${projectHashPrefix.slice(1)}${LZString.compressToEncodedURIComponent(serializeProjectState(state))}` + return { + activeFile: relativeProjectPath( + state.activeFile && state.files[state.activeFile] !== undefined + ? state.activeFile + : Object.keys(state.files).find(path => /\.[cm]?[jt]sx?$/i.test(path)) ?? Object.keys(state.files)[0] + ), + files: Object.fromEntries( + Object.entries({ + ...state.files, + [resolveSymlinks(configFileName, projectSymlinks) ?? configFileName]: + compilerOverrideState.effectiveConfigText, + }).map(([path, text]) => [relativeProjectPath(path), text]) + ), + outputFiles: Object.fromEntries([...emittedFiles].map(([path, text]) => [relativeProjectPath(path), text])), + symlinks: Object.fromEntries( + Object.entries(projectSymlinks).map(([path, target]) => [relativeProjectPath(path), target]) + ), + url: url.href, + } + }, +}) +for (const model of projectModels.values()) { + registerProjectModel(model) +} +addButton.addEventListener("click", createNewFile) +newFileKindFile.addEventListener("change", updateNewFileKind) +newFileKindSymlink.addEventListener("change", updateNewFileKind) +newFileCancelButton.addEventListener("click", () => newFileDialog.close()) +newFileForm.addEventListener("submit", event => { + event.preventDefault() + finishCreatingFile() +}) +clearProjectButton.addEventListener("click", () => void clearProject()) +applyCompilerOverridesButton.addEventListener("click", applyCompilerOverridesToConfig) +toggleFilesButton.addEventListener("click", () => toggleWorkspacePanel("files")) +showFilesButton.addEventListener("click", () => toggleWorkspacePanel("files")) +toggleOutputButton.addEventListener("click", () => toggleWorkspacePanel("output")) +showOutputButton.addEventListener("click", () => toggleWorkspacePanel("output")) +toggleEmitButton.addEventListener("click", () => { + layoutState.emitVisible = !layoutState.emitVisible + applyLayoutState() + persistLayoutState() +}) +toggleRunOutputButton.addEventListener("click", () => { + layoutState.runVisible = !layoutState.runVisible + applyLayoutState() + persistLayoutState() +}) +navigateBackButton.addEventListener("click", navigateBack) +runButton.addEventListener("click", runProject) +settingsButton.addEventListener("click", openSettings) +settingsCancelButton.addEventListener("click", () => settingsDialog.close()) +settingsResetButton.addEventListener("click", () => populateSettingsForm(defaultPlaygroundSettings())) +settingsForm.addEventListener("submit", event => { + event.preventDefault() + saveSettings() +}) +clearRunOutput.addEventListener("click", () => renderRunLogs([])) +confirmationCancelButton.addEventListener("click", () => finishConfirmation(false)) +confirmationForm.addEventListener("submit", event => { + event.preventDefault() + finishConfirmation(true) +}) +confirmationDialog.addEventListener("cancel", event => { + event.preventDefault() + finishConfirmation(false) +}) +textInputCancelButton.addEventListener("click", () => finishTextInput(null)) +textInputForm.addEventListener("submit", event => { + event.preventDefault() + const value = textInputValue.value.trim() + if (value === "") { + textInputError.textContent = "Enter a value." + textInputError.hidden = false + textInputValue.setAttribute("aria-invalid", "true") + textInputValue.focus() + return + } + finishTextInput(value) +}) +textInputDialog.addEventListener("cancel", event => { + event.preventDefault() + finishTextInput(null) +}) + +void initializeVersionSelector() +void (useNativeCompiler ? initializeNativeCompiler() : initializeStradaCompiler(selectedCompiler!)) + +async function initializeNativeCompiler() { + try { + if (isDefaultNativeCompilerVersion(selectedCompiler)) compilerVersionLabel.textContent = "Default" + else if (normalizeRequestedVersion(selectedCompiler!) === "next") compilerVersionLabel.textContent = "Nightly" + const distribution = await getCompilerDistribution() + nativeDistribution = distribution + compilerVersionLabel.textContent = + compilerVersionMenu.querySelector('[aria-checked="true"]')?.textContent ?? distribution.version + await confirmLargeDownloadIfNeeded() + setLoadingProgress(0, "Downloading TypeScript...", "") + const [wasmBytes, libFiles, configSchemaBytes] = await Promise.all([ + downloadAsset("wasm", distribution.wasm), + downloadLibraries(distribution), + downloadAsset("schema", distribution.schema), + ]) + + setLoadingIndeterminate("Starting TypeScript...", "") + const module = await WebAssembly.compile(wasmBytes) + setLoadingProgress(78, "Starting TypeScript...", "") + const configSchema = JSON.parse(new TextDecoder().decode(configSchemaBytes)) + registerConfigSchema(configSchema) + const optionProperties = + configSchema.definitions?.compilerOptions?.properties ?? + configSchema.definitions?.compilerOptionsDefinition?.properties?.compilerOptions?.properties + if (!optionProperties) throw new Error("Compiler option metadata is missing from the TSConfig schema") + const metadata = new Map( + Object.entries(optionProperties).map(([name, value]: [string, any]) => [ + name, + { description: value.markdownDescription ?? value.description ?? "" }, + ]) + ) + compilerOptionMetadataPromise = Promise.resolve(metadata) + consumeLegacyCompilerOptions(metadata.keys()) + nativeBackend = await NativeBackend.create(module, libFiles, distribution) + const backend = nativeBackend + DiagnosticCategory = backend.diagnosticCategories + discoverDependencies = file => backend.discover(file) + compileActiveProject = compileNativeProject + compilerReady = true + startLanguageServer(module, libFiles) + await compileNativeProject() + void refreshTypeAcquisition() + if (!touchPrimary.matches && !lastInputWasTouch) inputEditor.focus() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + compilerFailure = message + completeInitialTypeQueries() + renderStatus() + console.error(error) + } +} + +async function initializeStradaCompiler(requestedVersion: string) { + try { + setLoadingIndeterminate("Loading TypeScript...", "") + const version = await resolveStradaVersion(requestedVersion) + let mirroredVersions: string[] = [] + try { + mirroredVersions = await getLegacyCompilerVersions() + } catch (error) { + console.warn("Could not look up mirrored TypeScript releases; using exact npm packages", error) + } + const baseUrl = legacyCompilerBaseUrl(version, mirroredVersions) + const compilerUrl = new URL("typescript.js", baseUrl) + setLoadingIndeterminate("Downloading TypeScript...", "") + const compilerResponse = await fetch(compilerUrl) + if (!compilerResponse.ok) { + throw new Error(`Unable to load TypeScript ${version}: ${compilerResponse.status} ${compilerResponse.statusText}`) + } + const compilerSource = new TextDecoder().decode( + await readResponseBytes(compilerResponse, length => + setLoadingIndeterminate("Downloading TypeScript...", `${formatBytes(length)} received`) + ) + ) + setLoadingIndeterminate("Starting TypeScript...", "") + // The TypeScript package and custom compiler CDN are published by trusted maintainers. + // Evaluating the selected compiler is intentional; this is not user project code. + const classicTS = new Function(`${compilerSource}\nreturn ts;`)() + if ((baseUrl.hostname === "unpkg.com" || mirroredVersions.includes(version)) && classicTS.version !== version) { + throw new Error(`Requested TypeScript ${version}, but the downloaded compiler is ${classicTS.version}`) + } + loadedLegacyCompilerVersion = classicTS.version + const declarations: readonly { name: string; isCommandLineOnly?: boolean; description?: { message: string } }[] = + classicTS.optionDeclarations + if (!Array.isArray(declarations)) throw new Error("The selected compiler does not provide compiler options") + const metadata = new Map( + declarations + .filter(option => !option.isCommandLineOnly) + .map(option => [option.name, { description: option.description?.message ?? "" }]) + ) + compilerOptionMetadataPromise = Promise.resolve(metadata) + consumeLegacyCompilerOptions(metadata.keys()) + DiagnosticCategory = classicTS.DiagnosticCategory + discoverDependencies = stradaDiscovery(classicTS) + stradaBackend = await StradaBackend.create({ + baseUrl: baseUrl.href, + compilerSource, + files: compilerFileContents, + models: projectModels, + symlinks: projectSymlinks, + onCreateExternalModel(model) { + if (acquiredTypeFiles.has(model.uri.path)) acquiredTypeModels.set(model.uri.path, model) + }, + version, + onProgress(progress) { + if (compilerReady || compilerFailure) return + if (progress.phase === "initializing") { + setLoadingIndeterminate("Starting TypeScript...", "") + } else { + setLoadingProgress( + progress.total ? (progress.completed / progress.total) * 100 : 0, + "Loading standard libraries...", + `${progress.completed}/${progress.total} files` + ) + } + }, + }) + compileActiveProject = compileStradaProject + compilerReady = true + lspReady = true + await compileStradaProject() + void refreshTypeAcquisition() + renderStatus() + if (!touchPrimary.matches && !lastInputWasTouch) inputEditor.focus() + } catch (error) { + compilerFailure = error instanceof Error ? error.message : String(error) + completeInitialTypeQueries() + renderStatus() + console.error(error) + } +} + +function startLanguageServer(module: WebAssembly.Module, libraries: Record) { + try { + languageServer = startTsgoLsp({ + configFileName, + effectiveConfigText: effectiveCompilerConfigText(), + libraries, + models: [...projectModels.values()], + module, + symlinks: projectSymlinks, + onError(message) { + lspFailure = message + renderStatus() + }, + shouldHandleDiagnostics: model => projectModels.get(model.uri.path) === model, + readPackageFile: fileName => acquiredTypeFiles.get(fileName), + onPackageModel: model => acquiredTypeModels.set(model.uri.path, model), + onStatus(nextStatus) { + lspReady = nextStatus === "ready" + if (lspReady) { + void synchronizeLanguageServerFiles().then( + () => languageServer?.refreshDiagnostics(), + error => { + lspFailure = error instanceof Error ? error.message : String(error) + console.error("Could not synchronize package types", error) + renderStatus() + } + ) + } + const progress = { + "mounting files": 92, + "starting tsc.wasm": 95, + "initializing LSP": 98, + ready: 100, + }[nextStatus] + setLoadingProgress(progress, "Starting TypeScript...", "") + renderStatus() + }, + }) + } catch (error) { + lspFailure = error instanceof Error ? error.message : String(error) + renderStatus() + } +} + +async function initializeVersionSelector() { + compilerVersionButton.setAttribute("aria-disabled", "true") + try { + const [legacyResult, publishedResult] = await Promise.allSettled([ + getLegacyCompilerVersions(), + getPublishedCompilerVersions(), + ]) + const legacyVersions = legacyResult.status === "fulfilled" ? legacyResult.value : [] + const published = publishedResult.status === "fulfilled" ? publishedResult.value : undefined + const failures: string[] = [] + for (const result of [legacyResult, publishedResult]) { + if (result.status === "rejected") { + failures.push(result.reason instanceof Error ? result.reason.message : String(result.reason)) + console.warn("Could not initialize TypeScript versions", result.reason) + } + } + const nightly = selectedCompiler !== null && normalizeRequestedVersion(selectedCompiler) === "next" + const floating = isDefaultNativeCompilerVersion(selectedCompiler) || nightly + let selectedValue = floating ? "" : normalizeRequestedVersion(selectedCompiler!) + if (floating && published) { + try { + const selection = publishedNativeCompilerSelection( + published, + isDefaultNativeCompilerVersion(selectedCompiler) ? "default" : "nightly" + ) + selectedValue = selection.channel === "nightly" ? "next" : selection.version + } catch (error) { + failures.push(error instanceof Error ? error.message : String(error)) + console.warn("Could not resolve the default TypeScript version", error) + } + } + const versions = compilerVersionChoices(legacyVersions, published, selectedValue).map(choice => ({ + label: choice.label, + value: choice.version, + })) + compilerVersionMenu.replaceChildren() + for (const version of versions) { + compilerVersionMenu.appendChild(createVersionMenuItem(version.label, version.value, selectedValue)) + } + for (const failure of failures) { + const notice = createText("p", failure, "version-menu-error") + notice.setAttribute("role", "presentation") + compilerVersionMenu.appendChild(notice) + } + const separator = document.createElement("div") + separator.className = "version-menu-separator" + separator.setAttribute("role", "separator") + compilerVersionMenu.appendChild(separator) + compilerVersionMenu.appendChild(createVersionMenuItem("Custom / PR build…", "__custom__", selectedValue)) + + compilerVersionLabel.textContent = + versions.find(version => version.value === selectedValue)?.label ?? + (floating ? (nightly ? "Nightly" : "Default") : selectedCompiler!) + compilerVersion.addEventListener("toggle", () => { + compilerVersionButton.setAttribute("aria-expanded", String(compilerVersion.open)) + }) + compilerVersionButton.addEventListener("click", event => { + event.preventDefault() + setVersionMenuOpen(!compilerVersion.open) + if (compilerVersion.open) { + focusVersionMenuItem(compilerVersionMenu.querySelector('[aria-checked="true"]')) + } + }) + compilerVersionButton.addEventListener("keydown", event => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return + event.preventDefault() + setVersionMenuOpen(true) + const items = compilerVersionMenu.querySelectorAll(".version-menu-item") + focusVersionMenuItem(items[event.key === "ArrowDown" ? 0 : items.length - 1]) + }) + compilerVersionMenu.addEventListener("keydown", handleVersionMenuKeydown) + document.addEventListener("pointerdown", event => { + if (compilerVersion.open && !compilerVersion.contains(event.target as Node)) setVersionMenuOpen(false) + }) + } catch (error) { + console.warn("Could not initialize the compiler version selector", error) + } finally { + compilerVersionButton.removeAttribute("aria-disabled") + } +} + +function createVersionMenuItem(label: string, value: string, selectedValue: string) { + const button = document.createElement("button") + button.type = "button" + button.tabIndex = value === selectedValue ? 0 : -1 + button.className = "version-menu-item" + button.dataset.value = value + button.setAttribute("role", "menuitemradio") + button.setAttribute("aria-checked", String(value === selectedValue)) + button.appendChild(createText("span", label)) + button.addEventListener("click", () => void selectCompilerVersion(value)) + return button +} + +async function selectCompilerVersion(value: string) { + setVersionMenuOpen(false) + compilerVersionButton.focus() + if (value === "__custom__") { + const custom = await requestTextInput({ + label: "Build ID", + submitLabel: "Load build", + title: "Custom TypeScript build", + }) + if (!custom) { + compilerVersionButton.focus() + return + } + value = custom + } + persistProjectState() + const url = new URL(location.href) + url.hash = `${projectHashPrefix.slice(1)}${LZString.compressToEncodedURIComponent( + serializeProjectState(currentProjectState()) + )}` + if (value === "native") url.searchParams.delete("ts") + else url.searchParams.set("ts", value) + location.href = url.href +} + +function setVersionMenuOpen(open: boolean) { + compilerVersion.open = open + compilerVersionButton.setAttribute("aria-expanded", String(open)) +} + +function focusVersionMenuItem(item: HTMLButtonElement | null | undefined) { + for (const button of compilerVersionMenu.querySelectorAll(".version-menu-item")) { + button.tabIndex = button === item ? 0 : -1 + } + item?.focus() +} + +function handleVersionMenuKeydown(event: KeyboardEvent) { + const items = [...compilerVersionMenu.querySelectorAll(".version-menu-item")] + const current = items.indexOf(document.activeElement as HTMLButtonElement) + let next = current + if (event.key === "ArrowDown") next = (current + 1) % items.length + else if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length + else if (event.key === "Home") next = 0 + else if (event.key === "End") next = items.length - 1 + else if (event.key === "Tab") { + setVersionMenuOpen(false) + compilerVersionButton.focus() + return + } else if (event.key === "Escape") { + setVersionMenuOpen(false) + compilerVersionButton.focus() + event.preventDefault() + return + } else { + return + } + focusVersionMenuItem(items[next]) + event.preventDefault() +} + +function normalizeRequestedVersion(version: string) { + return version === "Nightly" ? "next" : version +} + +async function resolveStradaVersion(requestedVersion: string) { + const normalized = normalizeRequestedVersion(requestedVersion) + if (normalized !== "next" && normalized !== "latest") return normalized + const index = normalized === "next" ? "next.json" : "releases.json" + const response = await fetch(`https://playgroundcdn.typescriptlang.org/indexes/${index}`, { cache: "no-cache" }) + if (!response.ok) { + throw new Error(`Could not resolve TypeScript ${requestedVersion}: ${response.status}`) + } + const result = await response.json() + return normalized === "next" + ? (result.version as string) + : [...(result.versions as string[])].sort(compareVersions).at(-1)! +} + +function compareVersions(left: string, right: string) { + const leftParts = left.split(/[.-]/).map(part => Number(part) || 0) + const rightParts = right.split(/[.-]/).map(part => Number(part) || 0) + for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index++) { + const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0) + if (difference !== 0) return difference + } + return 0 +} + +function projectFileContents() { + return Object.fromEntries([...projectModels].map(([fileName, model]) => [fileName, model.getValue()])) +} + +function projectConfigModel() { + const path = resolveSymlinks(configFileName, projectSymlinks) + return path === undefined ? undefined : projectModels.get(path) +} + +function projectTextMap() { + return new Map([...projectModels].map(([fileName, model]) => [fileName, model.getValue()])) +} + +function compilerFileContents() { + const files = { + ...Object.fromEntries(acquiredTypeFiles), + ...projectFileContents(), + } + files[resolveSymlinks(configFileName, projectSymlinks) ?? configFileName] = effectiveCompilerConfigText() + return files +} + +function effectiveCompilerConfigText() { + const configText = compilerOverrideState.effectiveConfigText + const typePackages = implicitTypePackages() + return typePackages.length === 0 || !canSetCompilerOptions(configText) + ? configText + : setCompilerOption(configText, "types", typePackages) +} + +function implicitTypePackages() { + if (acquiredTypePackages.size === 0 || configOptionNode(compilerOverrideState.effectiveConfigText, "types")) { + return [] + } + return [...acquiredTypePackages].sort() +} + +function refreshCompilerOverrides() { + compilerOverrideState = computeCompilerOverrides( + projectTextMap(), + projectConfigModel()?.getValue() ?? defaultFiles[0].text + ) + languageServer?.updateEffectiveConfig(effectiveCompilerConfigText()) + renderCompilerOverrides() +} + +function renderCompilerOverrides() { + const byFile = new Map() + for (const override of compilerOverrideState.overrides) { + const entries = byFile.get(override.fileName) ?? [] + entries.push(override) + byFile.set(override.fileName, entries) + } + + for (const [fileName, model] of projectModels) { + const hints: monaco.languages.InlayHint[] = [] + for (const override of byFile.get(fileName) ?? []) { + if (!override.applied) continue + const end = model.getPositionAt(override.end) + const baseValue = + override.tsconfigValue === undefined + ? "not set in tsconfig" + : `tsconfig: ${formatOverrideValue(override.tsconfigValue)}` + hints.push({ + kind: monaco.languages.InlayHintKind.Type, + label: `· overrides ${baseValue}`, + paddingLeft: true, + position: end, + }) + } + if (fileName === configFileName) { + for (const override of compilerOverrideState.overrides) { + if (!override.applied) continue + const node = configOptionNode(model.getValue(), override.option) + if (!node) continue + const end = model.getPositionAt(node.offset + node.length) + hints.push({ + kind: monaco.languages.InlayHintKind.Type, + label: `· overridden by ${relativeProjectPath(override.fileName)}:${ + override.lineNumber + } → ${formatOverrideValue(override.value)}`, + paddingLeft: true, + position: end, + }) + } + } + compilerOverrideHints.set(model.uri.toString(), hints) + const markers = compilerOverrideState.diagnostics + .filter(diagnostic => diagnostic.fileName === fileName) + .map(diagnostic => { + const start = model.getPositionAt(diagnostic.start) + const end = model.getPositionAt(Math.max(diagnostic.start + 1, diagnostic.end)) + return { + code: "PLAYGROUND_OVERRIDE", + endColumn: end.column, + endLineNumber: end.lineNumber, + message: diagnostic.message, + severity: monaco.MarkerSeverity.Error, + source: "Playground", + startColumn: start.column, + startLineNumber: start.lineNumber, + } + }) + monaco.editor.setModelMarkers(model, "compiler-overrides", markers) + } + + const effectiveConfigText = effectiveCompilerConfigText() + if (effectiveConfigModel && effectiveConfigModel.getValue() !== effectiveConfigText) { + effectiveConfigModel.setValue(effectiveConfigText) + } + const activeOverrides = compilerOverrideState.overrides.filter(override => override.applied) + applyCompilerOverridesButton.hidden = activeOverrides.length === 0 + applyCompilerOverridesButton.textContent = `Apply ${activeOverrides.length} override${ + activeOverrides.length === 1 ? "" : "s" + }` + inlayEmitter.fire() + if (overrideCodeLensProvider) overrideCodeLensEmitter.fire(overrideCodeLensProvider) +} + +function registerCompilerOverrideFeatures() { + monaco.editor.registerCommand("playground.showSettings", openSettings) + monaco.editor.registerCommand("playground.showEffectiveConfig", () => { + const uri = monaco.Uri.parse("playground:///effective-tsconfig.json") + effectiveConfigModel ??= monaco.editor.createModel(effectiveCompilerConfigText(), "json", uri) + inputEditor.setModel(effectiveConfigModel) + focusEditorAfterNavigation() + }) + monaco.editor.registerCommand( + "playground.goToCompilerOverride", + (_accessor, fileName: string, start: number, end: number) => { + const model = projectModels.get(fileName) + if (!model) return + navigateToModel(fileName, spanRange(model, start, end)) + } + ) + monaco.editor.registerCommand("playground.goToConfigOption", (_accessor, option: string) => { + const model = projectConfigModel() + if (!model) return + const node = configOptionNode(model.getValue(), option) ?? compilerOptionsNode(model.getValue()) + const range = node ? spanRange(model, node.offset, node.offset + node.length) : model.getFullModelRange() + navigateToModel(model.uri.path, range) + }) + for (const language of sourceLanguages) { + monaco.languages.registerHoverProvider(language, { + provideHover(model, position) { + const override = compilerOverrideAt(model, model.getOffsetAt(position)) + if (!override) return undefined + return { + contents: [ + { value: `**Playground compiler override**` }, + { + value: `\`${override.option}\`: ${formatOverrideValue(override.tsconfigValue)} → **${formatOverrideValue( + override.value + )}**`, + }, + { value: "This project-wide directive takes precedence over `tsconfig.json`." }, + ], + range: spanRange(model, override.start, override.end), + } + }, + }) + monaco.languages.registerCompletionItemProvider(language, { + triggerCharacters: ["@"], + async provideCompletionItems(model, position) { + const line = model.getLineContent(position.lineNumber).slice(0, position.column - 1) + const match = /\/\/\s*@([\w-]*)$/.exec(line) + if (!match || !compilerReady) return { suggestions: [] } + const metadata = await getCompilerOptionMetadata() + const range = new monaco.Range( + position.lineNumber, + position.column - match[1].length, + position.lineNumber, + position.column + ) + return { + suggestions: [...metadata].map(([name, info]) => ({ + detail: info.description, + insertText: `${name}: `, + kind: monaco.languages.CompletionItemKind.Property, + label: name, + range, + })), + } + }, + }) + monaco.languages.registerCodeActionProvider(language, { + provideCodeActions(model, range) { + const override = compilerOverrideAt(model, model.getOffsetAt(range.getStartPosition())) + if (!override) return { actions: [], dispose() {} } + const removeEdit = removeOverrideEdit(model, override) + const actions: monaco.languages.CodeAction[] = [ + { + edit: { edits: [removeEdit] }, + kind: "quickfix", + title: `Remove @${override.option} override`, + }, + { + command: { + arguments: [override.option], + id: "playground.goToConfigOption", + title: "Go to tsconfig option", + }, + kind: "quickfix", + title: `Go to ${override.option} in tsconfig.json`, + }, + ] + if (override.applied) { + const configModel = projectConfigModel() + if (configModel) { + actions.unshift({ + edit: { + edits: [ + { + resource: configModel.uri, + textEdit: { + range: configModel.getFullModelRange(), + text: setCompilerOption(configModel.getValue(), override.option, override.value), + }, + versionId: configModel.getVersionId(), + }, + removeEdit, + ], + }, + isPreferred: true, + kind: "quickfix", + title: `Move @${override.option} to tsconfig.json`, + }) + } + } + return { actions, dispose() {} } + }, + }) + } + + monaco.languages.registerHoverProvider("json", { + provideHover(model, position) { + if (model.uri.path !== configFileName) return undefined + const offset = model.getOffsetAt(position) + const override = compilerOverrideState.overrides.find(candidate => { + if (!candidate.applied) return false + const node = configOptionNode(model.getValue(), candidate.option) + return node && offset >= node.offset && offset <= node.offset + node.length + }) + if (!override) return undefined + return { + contents: [ + { value: `**Overridden by ${relativeProjectPath(override.fileName)}:${override.lineNumber}**` }, + { + value: `Effective \`${override.option}\`: **${formatOverrideValue(override.value)}**`, + }, + ], + } + }, + }) + overrideCodeLensProvider = { + onDidChange: overrideCodeLensEmitter.event, + provideCodeLenses(model) { + if (model.uri.path !== configFileName) return { lenses: [], dispose() {} } + const active = compilerOverrideState.overrides.filter(override => override.applied) + const typePackages = implicitTypePackages() + const disabledBySettings = !playgroundSettings.automaticTypeAcquisition + if (active.length === 0 && typePackages.length === 0 && !disabledBySettings) return { lenses: [], dispose() {} } + const node = compilerOptionsNode(model.getValue()) + const position = node ? model.getPositionAt(node.offset) : new monaco.Position(1, 1) + const range = new monaco.Range(position.lineNumber, 1, position.lineNumber, 1) + const lenses: monaco.languages.CodeLens[] = [] + if (disabledBySettings) { + lenses.push({ + command: { + id: "playground.showSettings", + title: "Automatic package types disabled · Settings", + }, + range, + }) + } + if (active.length > 0) { + lenses.push({ + command: { + id: "playground.showEffectiveConfig", + title: `${active.length} inline override${active.length === 1 ? "" : "s"} · View effective config`, + }, + range, + }) + } + if (typePackages.length > 0) { + lenses.push({ + command: { + id: "playground.showEffectiveConfig", + title: `Package types: ${typePackages.join(", ")} · View effective config`, + }, + range, + }) + } + return { + dispose() {}, + lenses, + } + }, + } + monaco.languages.registerCodeLensProvider("json", overrideCodeLensProvider) + monaco.languages.registerCodeActionProvider("json", { + provideCodeActions(model, range) { + if (model.uri.path !== configFileName) return { actions: [], dispose() {} } + const offset = model.getOffsetAt(range.getStartPosition()) + const override = compilerOverrideState.overrides.find(candidate => { + if (!candidate.applied) return false + const node = configOptionNode(model.getValue(), candidate.option) + return node && offset >= node.offset && offset <= node.offset + node.length + }) + if (!override) return { actions: [], dispose() {} } + const sourceModel = projectModels.get(override.fileName) + return { + actions: [ + { + command: { + arguments: [override.fileName, override.start, override.end], + id: "playground.goToCompilerOverride", + title: "Go to overriding directive", + }, + kind: "quickfix", + title: `Go to @${override.option} override`, + }, + ...(sourceModel + ? [ + { + edit: { edits: [removeOverrideEdit(sourceModel, override)] }, + kind: "quickfix", + title: `Use tsconfig value and remove @${override.option}`, + } satisfies monaco.languages.CodeAction, + ] + : []), + ], + dispose() {}, + } + }, + }) +} + +let compilerOptionMetadataPromise: Promise> | undefined + +function getCompilerOptionMetadata() { + if (!compilerOptionMetadataPromise) throw new Error("Compiler option metadata is not initialized") + return compilerOptionMetadataPromise +} + +function compilerOverrideAt(model: monaco.editor.ITextModel, offset: number) { + return compilerOverrideState.overrides.find( + override => override.fileName === model.uri.path && offset >= override.start && offset <= override.end + ) +} + +function removeOverrideEdit(model: monaco.editor.ITextModel, override: CompilerOverride) { + const start = model.getPositionAt(override.start) + const end = + start.lineNumber < model.getLineCount() + ? new monaco.Position(start.lineNumber + 1, 1) + : model.getPositionAt(override.end) + return { + resource: model.uri, + textEdit: { + range: new monaco.Range(start.lineNumber, 1, end.lineNumber, end.column), + text: "", + }, + versionId: model.getVersionId(), + } +} + +function applyCompilerOverridesToConfig() { + const overrides = compilerOverrideState.overrides.filter(override => override.applied) + const configModel = projectConfigModel() + if (overrides.length === 0 || !configModel) return + + let configText = configModel.getValue() + for (const override of overrides) { + configText = setCompilerOption(configText, override.option, override.value) + } + configModel.pushEditOperations([], [{ range: configModel.getFullModelRange(), text: configText }], () => null) + + const byFile = new Map() + for (const override of overrides) { + const entries = byFile.get(override.fileName) ?? [] + entries.push(override) + byFile.set(override.fileName, entries) + } + for (const [fileName, fileOverrides] of byFile) { + const model = projectModels.get(fileName) + if (!model) continue + const edits = [...fileOverrides] + .sort((left, right) => right.start - left.start) + .map(override => { + const edit = removeOverrideEdit(model, override).textEdit + return { range: edit.range, text: edit.text } + }) + model.pushEditOperations([], edits, () => null) + } +} + +function spanRange(model: monaco.editor.ITextModel, start: number, end: number) { + const startPosition = model.getPositionAt(start) + const endPosition = model.getPositionAt(end) + return new monaco.Range(startPosition.lineNumber, startPosition.column, endPosition.lineNumber, endPosition.column) +} + +function formatOverrideValue(value: unknown) { + if (value === undefined) return "(unset)" + return typeof value === "string" ? value : JSON.stringify(value) +} + +function compilerOverrideDiagnostics(): Diagnostic[] { + return compilerOverrideState.diagnostics.map((diagnostic, index) => ({ + category: DiagnosticCategory.Error, + code: 98000 + index, + end: diagnostic.end, + fileName: diagnostic.fileName, + pos: diagnostic.start, + source: "Playground", + text: diagnostic.message, + })) +} + +function scheduleTypeAcquisition() { + if (!compilerReady || !discoverDependencies) return + typeAcquisitionController?.abort() + window.clearTimeout(typeAcquisitionTimer) + typeAcquisitionFailure = undefined + renderStatus() + typeAcquisitionTimer = window.setTimeout(() => { + void refreshTypeAcquisition() + }, 900) +} + +async function refreshTypeAcquisition() { + const discover = discoverDependencies + if (!discover || !compilerReady) return + typeAcquisitionController?.abort() + const controller = new AbortController() + typeAcquisitionController = controller + typeAcquisitionFailure = undefined + renderStatus() + const configText = compilerOverrideState.effectiveConfigText + const jsx = configOptionNode(configText, "jsx")?.value + const importSource = configOptionNode(configText, "jsxImportSource")?.value + const jsxRuntime = + typeof jsx === "string" && + /^react-jsx(?:dev)?$/i.test(jsx) && + (importSource === undefined || typeof importSource === "string") + ? `${importSource ?? "react"}/${jsx.toLowerCase() === "react-jsxdev" ? "jsx-dev-runtime" : "jsx-runtime"}` + : undefined + try { + const options = acquisitionOptions(configText, playgroundSettings.automaticTypeAcquisition) + const localPackages = Object.keys(projectSymlinks).flatMap(path => { + const match = /^\/workspace\/node_modules\/((?:@[^/]+\/)?[^/]+)$/.exec(path) + return match ? [match[1]] : [] + }) + const acquired = await typeAcquirer.acquire({ + discover, + files: [...projectModels.values()] + .filter(model => isSourceLanguage(model.getLanguageId())) + .map(model => ({ path: model.uri.path, text: model.getValue(), jsxRuntime })), + options: { ...options, exclude: [...(options.exclude ?? []), ...localPackages] }, + signal: controller.signal, + }) + const result = { + ...acquired, + files: new Map([...acquired.files].map(([path, text]) => [resolveSymlinks(path, projectSymlinks) ?? path, text])), + } + controller.signal.throwIfAborted() + const changed = + result.files.size !== acquiredTypeFiles.size || + [...result.files].some(([path, text]) => acquiredTypeFiles.get(path) !== text) || + result.ambientTypes.length !== acquiredTypePackages.size || + result.ambientTypes.some(name => !acquiredTypePackages.has(name)) + if (changed) applyAcquiredTypes(result) + await synchronizeLanguageServerFiles() + controller.signal.throwIfAborted() + typeAcquisitionFailure = result.errors.length + ? result.errors.map(error => `${error.packageName}: ${error.message}`).join("; ") + : undefined + for (const error of result.errors) console.error("Could not acquire package types", error) + if (changed) { + languageServer?.updateEffectiveConfig(effectiveCompilerConfigText()) + languageServer?.refreshDiagnostics() + renderCompilerOverrides() + await compileActiveProject?.() + } + renderStatus() + } catch (error) { + if (controller.signal.aborted) return + typeAcquisitionFailure = error instanceof Error ? error.message : String(error) + console.error("Could not acquire package types", error) + renderStatus() + } +} + +function applyAcquiredTypes(result: AcquisitionResult) { + for (const fileName of acquiredTypeFiles.keys()) { + if (result.files.has(fileName)) continue + const model = acquiredTypeModels.get(fileName) + if (model && inputEditor.getModel() === model) { + inputEditor.setModel(projectModels.get(entryFileName) ?? [...projectModels.values()][0]) + } + model?.dispose() + acquiredTypeModels.delete(fileName) + } + acquiredTypeFiles.clear() + acquiredTypePackages.clear() + for (const name of result.ambientTypes) acquiredTypePackages.add(name) + for (const [fileName, text] of result.files) { + if (projectModels.has(fileName)) continue + acquiredTypeFiles.set(fileName, text) + const model = acquiredTypeModels.get(fileName) + if (model && model.getValue() !== text) model.setValue(text) + } +} + +function synchronizeLanguageServerFiles() { + if (!lspReady) return Promise.resolve() + const files = Object.keys(projectSymlinks).length + ? new Map(Object.entries(compilerFileContents())) + : acquiredTypeFiles + return languageServer?.updateFiles(files) ?? Promise.resolve() +} + +function synchronizeLinkedProjectFiles() { + if (!Object.keys(projectSymlinks).length) return + void synchronizeLanguageServerFiles().then( + () => languageServer?.refreshDiagnostics(), + error => { + lspFailure = error instanceof Error ? error.message : String(error) + console.error("Could not synchronize linked project files", error) + renderStatus() + } + ) +} + +function navigateToModel(fileName: string, range?: monaco.IRange) { + const model = monaco.editor.getModel(monaco.Uri.file(fileName)) + if (!model) return + if (inputEditor.getModel() === model && !range) { + focusEditorAfterNavigation() + return + } + const current = getEditorLocation() + if (current && projectModels.has(monaco.Uri.parse(current.uri).path)) projectReturnLocation = current + inputEditor.setModel(model) + if (range) { + inputEditor.setSelection(range) + inputEditor.revealRangeInCenter(range, monaco.editor.ScrollType.Immediate) + } + trackedEditorLocation = getEditorLocation() + updateNavigationButtons() + focusEditorAfterNavigation() +} + +function focusEditorAfterNavigation() { + if (!lastInputWasTouch) { + inputEditor.focus() + return + } + const active = document.activeElement + if (active instanceof HTMLElement && inputElement.contains(active)) active.blur() +} + +function getEditorLocation(): EditorLocation | undefined { + const model = inputEditor.getModel() + const selection = inputEditor.getSelection() + return model && selection ? { selection, uri: model.uri.toString() } : undefined +} + +function updateNavigationButtons() { + const model = inputEditor.getModel() + navigateBackButton.hidden = !model || projectModels.get(model.uri.path) === model +} + +function navigateBack() { + if (projectReturnLocation && projectModels.has(monaco.Uri.parse(projectReturnLocation.uri).path)) { + applyEditorLocation(projectReturnLocation) + } else { + const model = projectModels.get(entryFileName) ?? [...projectModels.values()][0] + if (model) navigateToModel(model.uri.path) + } +} + +function applyEditorLocation(location: EditorLocation) { + const model = monaco.editor.getModel(monaco.Uri.parse(location.uri)) + if (!model) return + inputEditor.setModel(model) + inputEditor.setSelection(location.selection) + inputEditor.revealRangeInCenter(location.selection, monaco.editor.ScrollType.Immediate) + trackedEditorLocation = getEditorLocation() + updateNavigationButtons() + focusEditorAfterNavigation() +} + +function getCompilerDistribution() { + compilerDistributionPromise ??= resolveNativeCompilerVersion().then(loadPublishedCompilerDistribution) + return compilerDistributionPromise +} + +function getPublishedCompilerVersions() { + publishedCompilerVersionsPromise ??= loadPublishedCompilerVersions() + return publishedCompilerVersionsPromise +} + +function getLegacyCompilerVersions() { + legacyCompilerVersionsPromise ??= loadLegacyCompilerVersions() + return legacyCompilerVersionsPromise +} + +async function resolveNativeCompilerVersion() { + if (isDefaultNativeCompilerVersion(selectedCompiler)) { + return publishedNativeCompilerSelection(await getPublishedCompilerVersions(), "default").version + } + if (normalizeRequestedVersion(selectedCompiler!) === "next") { + return publishedNativeCompilerSelection(await getPublishedCompilerVersions(), "nightly").version + } + if (selectedCompiler && isUnsupportedNativeCompilerVersion(selectedCompiler)) { + throw new Error(`TypeScript ${selectedCompiler} is not supported by this Playground. Choose an available version.`) + } + return selectedCompiler! +} + +async function downloadLibraries(distribution: CompilerDistribution) { + const files: Record = {} + let next = 0 + await Promise.all( + Array.from({ length: Math.min(8, distribution.libraries.length) }, async () => { + while (next < distribution.libraries.length) { + const url = distribution.libraries[next++] + const name = url.pathname.slice(url.pathname.lastIndexOf("/") + 1) + files[`/${name}`] = new TextDecoder().decode(await downloadAsset(`library:${name}`, url)) + } + }) + ) + return files +} + +async function downloadAsset(name: string, url: URL): Promise> { + const request = new Request(url) + const cache = await getAssetCache() + let response = await cache?.match(url) + cachedAssets.set(name, response !== undefined) + let cacheWrite: Promise | undefined + if (!response) { + response = await fetch(request) + } + if (!response.ok) { + throw new Error(`Unable to load ${url.pathname}: ${response.status} ${response.statusText}`) + } + if (cache && !cachedAssets.get(name)) cacheWrite = cache.put(request, response.clone()) + const bytes = await readResponseBytes(response, length => { + downloadedAssets.set(name, length) + updateDownloadProgress() + }) + await cacheWrite + return bytes +} + +async function readResponseBytes( + response: Response, + onProgress: (length: number) => void +): Promise> { + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()) + onProgress(bytes.length) + return bytes + } + + const chunks: Uint8Array[] = [] + const reader = response.body.getReader() + let length = 0 + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value) + length += value.length + onProgress(length) + } + + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.length + } + return bytes +} + +function getAssetCache() { + assetCachePromise ??= openAssetCache() + return assetCachePromise +} + +async function confirmLargeDownloadIfNeeded() { + if (downloadWarningSuppressed() || (await compilerAssetsAreCached())) return + + if (!nativeDistribution) throw new Error("Compiler distribution is not loaded") + getElement("download-consent-title").textContent = `Download TypeScript ${nativeDistribution.version}?` + downloadSize.textContent = `${Math.ceil(nativeDistribution.estimatedDownloadBytes / (1024 * 1024))} MiB` + rememberDownloadConsent.checked = true + loadingAnimation.hidden = true + loadingCopy.hidden = true + downloadConsent.hidden = false + loader.hidden = false + loader.dataset.state = "consent" + + await new Promise(resolve => { + confirmDownloadButton.addEventListener( + "click", + () => { + try { + if (rememberDownloadConsent.checked) localStorage.setItem(downloadConsentStorageKey, "true") + else localStorage.removeItem(downloadConsentStorageKey) + } catch (error) { + console.warn("Could not save the download warning preference", error) + } + resolve() + }, + { once: true } + ) + confirmDownloadButton.focus() + }) + + downloadConsent.hidden = true + loadingAnimation.hidden = false + loadingCopy.hidden = false + delete loader.dataset.state +} + +function downloadWarningSuppressed() { + try { + return localStorage.getItem(downloadConsentStorageKey) === "true" + } catch { + return false + } +} + +async function compilerAssetsAreCached() { + const cache = await getAssetCache() + if (!cache || !nativeDistribution) return false + const urls = [nativeDistribution.wasm, nativeDistribution.schema, ...nativeDistribution.libraries] + const matches = await Promise.all(urls.map(url => cache.match(url))) + return matches.every(response => response !== undefined) +} + +async function openAssetCache() { + if (!("caches" in globalThis)) return undefined + if (!nativeDistribution) throw new Error("Compiler distribution is not loaded") + const cacheName = `${assetCachePrefix}npm-${nativeDistribution.version}` + try { + const cacheNames = await caches.keys() + await Promise.all( + cacheNames + .filter(name => name.startsWith(assetCachePrefix) && name !== cacheName) + .map(name => caches.delete(name)) + ) + return caches.open(cacheName) + } catch (error) { + console.warn("Could not open the TypeScript asset cache", error) + return undefined + } +} + +function updateDownloadProgress() { + if (compilerFailure) return + if (!nativeDistribution) throw new Error("Compiler distribution is not loaded") + const totalBytes = nativeDistribution.assetBytes + const downloadedBytes = [...downloadedAssets.values()].reduce((total, value) => total + value, 0) + const loadingFromNetwork = [...cachedAssets.values()].some(cached => !cached) + setLoadingProgress( + Math.min(70, (downloadedBytes / totalBytes) * 70), + loadingFromNetwork ? "Downloading TypeScript..." : "Loading cached TypeScript...", + `${Math.min(100, Math.round((downloadedBytes / totalBytes) * 100))}% loaded` + ) +} + +function setLoadingProgress(value: number, message: string, detail: string) { + loadingProgress.value = Math.max(loadingProgress.value, value) + if (loadingMessage.textContent !== message) loadingMessage.textContent = message + loadingDetail.textContent = detail +} + +function setLoadingIndeterminate(message: string, detail: string) { + loadingProgress.removeAttribute("value") + if (loadingMessage.textContent !== message) loadingMessage.textContent = message + loadingDetail.textContent = detail +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MiB` +} + +async function compileNativeProject() { + const backend = nativeBackend + if (!backend) return + const compileVersion = ++nativeCompileVersion + runButton.disabled = true + + try { + const result = await backend.compile({ + files: compilerFileContents(), + configFileName, + symlinks: projectSymlinks, + sourceFiles: [...projectModels] + .filter(([, model]) => isSourceLanguage(model.getLanguageId())) + .map(([fileName]) => fileName), + }) + if (compileVersion !== nativeCompileVersion) return + const diagnostics = deduplicateDiagnostics( + remapProjectDiagnostics([...result.diagnostics, ...compilerOverrideDiagnostics()]) + ) + setDiagnostics(diagnostics) + emittedFiles = new Map(Object.entries(result.outputFiles)) + runButton.disabled = ![...emittedFiles.keys()].some(fileName => fileName.endsWith(".js")) + void renderEmittedFiles() + typeQueries.clear() + for (const [fileName, queries] of Object.entries(result.typeQueries)) { + typeQueries.set(monaco.Uri.file(fileName).toString(), queries) + } + completeInitialTypeQueries() + inlayEmitter.fire() + projectFailure = undefined + compilerFailure = undefined + renderStatus() + } catch (error) { + if (compileVersion !== nativeCompileVersion) return + const message = error instanceof Error ? error.message : String(error) + emittedFiles = new Map() + runButton.disabled = true + setDiagnostics([]) + typeQueries.clear() + completeInitialTypeQueries() + inlayEmitter.fire() + renderEmitError(message) + projectFailure = message + renderStatus() + console.error(error) + } +} + +let stradaCompileVersion = 0 + +async function compileStradaProject() { + const backend = stradaBackend + if (!backend) return + const compileVersion = ++stradaCompileVersion + runButton.disabled = true + try { + const result = await backend.compile() + if (compileVersion !== stradaCompileVersion) return + const diagnostics = remapProjectDiagnostics([ + ...(result.diagnostics as Diagnostic[]), + ...compilerOverrideDiagnostics(), + ]) + setDiagnostics(diagnostics) + emittedFiles = new Map(Object.entries(result.outputFiles)) + runButton.disabled = ![...emittedFiles.keys()].some(fileName => fileName.endsWith(".js")) + void renderEmittedFiles() + + typeQueries.clear() + await Promise.all( + [...projectModels.values()] + .filter(model => isSourceLanguage(model.getLanguageId())) + .map(model => collectStradaTypeQueries(backend, model)) + ) + if (compileVersion !== stradaCompileVersion) return + completeInitialTypeQueries() + inlayEmitter.fire() + projectFailure = undefined + compilerFailure = undefined + renderStatus() + } catch (error) { + if (compileVersion !== stradaCompileVersion) return + emittedFiles = new Map() + runButton.disabled = true + setDiagnostics([]) + typeQueries.clear() + completeInitialTypeQueries() + inlayEmitter.fire() + const message = error instanceof Error ? error.message : String(error) + renderEmitError(message) + projectFailure = message + renderStatus() + console.error(error) + } +} + +async function collectStradaTypeQueries(backend: StradaBackend, model: monaco.editor.ITextModel) { + const source = model.getValue() + const queryPattern = /^\s*\/\/\s*\^\?\s*$/gm + const queries: TypeQuery[] = [] + let match: RegExpExecArray | null + while ((match = queryPattern.exec(source))) { + const queryEnd = match.index + match[0].lastIndexOf("?") + const queryPosition = model.getPositionAt(queryEnd) + if (queryPosition.lineNumber === 1) continue + const inspectedPosition = model.getOffsetAt({ + lineNumber: queryPosition.lineNumber - 1, + column: queryPosition.column, + }) + const info = + (await backend.quickInfo(model.uri.path, inspectedPosition)) ?? + (await backend.quickInfo(model.uri.path, Math.max(0, inspectedPosition - 1))) + if (!info?.displayParts) continue + const text = info.displayParts + .map((part: { text: string }) => part.text) + .join("") + .replace(/\r?\n\s*/g, " ") + queries.push({ + lineNumber: queryPosition.lineNumber, + column: queryPosition.column + 1, + label: truncate(text, 120), + }) + } + typeQueries.set(model.uri.toString(), queries) +} + +function deduplicateDiagnostics(diagnostics: readonly Diagnostic[]) { + const seen = new Set() + return diagnostics.filter(diagnostic => { + const key = [diagnostic.fileName, diagnostic.pos, diagnostic.end, diagnostic.code, diagnostic.text].join(":") + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function setDiagnostics(diagnostics: readonly Diagnostic[]) { + for (const [fileName, model] of projectModels) { + const markers = diagnostics + .filter( + diagnostic => diagnostic.fileName === fileName && (!useNativeCompiler || diagnostic.source === "Playground") + ) + .map(diagnostic => { + const start = model.getPositionAt(Math.max(0, diagnostic.pos)) + const end = model.getPositionAt(Math.max(diagnostic.pos + 1, diagnostic.end)) + return { + code: String(diagnostic.code), + endColumn: end.column, + endLineNumber: end.lineNumber, + message: diagnostic.text, + modelVersionId: model.getVersionId(), + severity: diagnosticSeverity(diagnostic.category), + source: diagnostic.source || "ts", + startColumn: start.column, + startLineNumber: start.lineNumber, + } + }) + monaco.editor.setModelMarkers(model, "typescript-7", markers) + } + renderDiagnostics(diagnostics) +} + +function renderDiagnostics(diagnostics: readonly Diagnostic[]) { + const sorted = [...diagnostics].sort( + (left, right) => + diagnosticSortOrder(left.category) - diagnosticSortOrder(right.category) || + (left.fileName ?? "").localeCompare(right.fileName ?? "") || + left.pos - right.pos || + left.code - right.code + ) + diagnosticsSummary.textContent = String(sorted.length) + diagnosticsList.replaceChildren() + diagnosticsPanel.dataset.empty = String(sorted.length === 0) + if (sorted.length === 0) { + diagnosticsList.appendChild(createText("p", "No problems found.", "empty-message")) + return + } + if (!hasShownDiagnostics) { + diagnosticsPanel.open = true + hasShownDiagnostics = true + } + + for (const diagnostic of sorted) { + const fileName = diagnostic.fileName ?? configFileName + const model = monaco.editor.getModel(monaco.Uri.file(fileName)) + const start = + model?.getPositionAt(Math.max(0, diagnostic.pos)) ?? + new monaco.Position((diagnostic.startPosition?.line ?? 0) + 1, (diagnostic.startPosition?.character ?? 0) + 1) + const button = document.createElement("button") + button.type = "button" + button.className = `diagnostic diagnostic-${diagnosticCategoryName(diagnostic.category)}` + button.appendChild(createText("strong", `TS${diagnostic.code}`)) + button.appendChild(createText("span", diagnostic.text, "diagnostic-message")) + button.appendChild( + createText("small", `${relativeProjectPath(fileName)}:${start.lineNumber}:${start.column}`, "diagnostic-location") + ) + button.addEventListener("click", () => { + const selection = getSelection() + if (selection && !selection.isCollapsed && button.contains(selection.anchorNode)) return + void openDiagnostic(diagnostic, fileName).catch(error => { + projectFailure = error instanceof Error ? error.message : String(error) + console.error("Could not open diagnostic", error) + renderStatus() + }) + }) + diagnosticsList.appendChild(button) + } +} + +async function openDiagnostic(diagnostic: Diagnostic, fileName: string) { + const uri = monaco.Uri.file(fileName) + let model = monaco.editor.getModel(uri) + if (!model) { + const text = + acquiredTypeFiles.get(fileName) ?? + (await (stradaBackend?.readFile(fileName) ?? languageServer?.readFile(fileName))) + if (text === undefined) throw new Error(`Could not read diagnostic file: ${fileName}`) + model = monaco.editor.getModel(uri) ?? monaco.editor.createModel(text, languageForFile(fileName), uri) + if (acquiredTypeFiles.has(fileName)) acquiredTypeModels.set(fileName, model) + } + const start = model.getPositionAt(Math.max(0, diagnostic.pos)) + const end = model.getPositionAt(Math.max(diagnostic.pos + 1, diagnostic.end)) + navigateToModel(fileName, new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column)) +} + +function diagnosticSortOrder(category: number) { + switch (category) { + case DiagnosticCategory.Error: + return 0 + case DiagnosticCategory.Warning: + return 1 + case DiagnosticCategory.Suggestion: + return 2 + default: + return 3 + } +} + +function diagnosticCategoryName(category: number) { + switch (category) { + case DiagnosticCategory.Error: + return "error" + case DiagnosticCategory.Warning: + return "warning" + case DiagnosticCategory.Suggestion: + return "suggestion" + default: + return "message" + } +} + +function diagnosticSeverity(category: number) { + switch (category) { + case DiagnosticCategory.Error: + return monaco.MarkerSeverity.Error + case DiagnosticCategory.Warning: + return monaco.MarkerSeverity.Warning + case DiagnosticCategory.Suggestion: + return monaco.MarkerSeverity.Hint + default: + return monaco.MarkerSeverity.Info + } +} + +async function renderEmittedFiles() { + const renderVersion = ++emitRenderVersion + emitOutput.replaceChildren() + const files = [...emittedFiles].sort(([left], [right]) => left.localeCompare(right)) + emitSummary.textContent = + files.length === 0 ? "No files emitted" : `${files.length} emitted file${files.length === 1 ? "" : "s"}` + + if (files.length === 0) { + emitOutput.appendChild(createText("p", "No files emitted.", "empty-message")) + return + } + + for (const [fileName, text] of files) { + const displayText = text.replace(/(?:\r?\n)+$/, "") + const section = document.createElement("section") + section.className = "emit-file" + section.appendChild(createText("h3", relativeProjectPath(fileName))) + const pre = document.createElement("pre") + pre.tabIndex = 0 + const code = document.createElement("code") + code.textContent = displayText + pre.appendChild(code) + section.appendChild(pre) + emitOutput.appendChild(section) + + const language = fileName.endsWith(".js") ? "javascript" : fileName.endsWith(".json") ? "json" : "typescript" + const highlighted = await monaco.editor.colorize(displayText, language, { tabSize: 2 }) + if (renderVersion !== emitRenderVersion) return + code.innerHTML = highlighted + } +} + +function renderEmitError(message: string) { + emitRenderVersion++ + emitSummary.textContent = "Compile failed" + emitOutput.replaceChildren(createText("p", message, "empty-message")) +} + +function runProject() { + const logs: RuntimeLog[] = [] + const javascriptFiles = new Map([...emittedFiles].filter(([fileName]) => fileName.endsWith(".js"))) + const entryFile = + [...javascriptFiles.keys()].find(fileName => fileName.endsWith("/index.js")) ?? javascriptFiles.keys().next().value + + if (!entryFile) { + renderRunLogs([{ level: "error", text: "No JavaScript entry file was emitted." }]) + return + } + if ([...javascriptFiles.values()].some(code => /^\s*(?:export|import)\b/m.test(code))) { + renderRunLogs([ + { + level: "error", + text: 'Run supports CommonJS output. Set compilerOptions.module to "CommonJS" in tsconfig.json.', + }, + ]) + return + } + + try { + executeCommonJs(entryFile, javascriptFiles, createRuntimeConsole(logs)) + } catch (error) { + logs.push({ + level: "error", + text: error instanceof Error ? error.stack ?? error.message : String(error), + }) + } + renderRunLogs(logs) + runLog.scrollIntoView({ block: "nearest" }) +} + +function executeCommonJs(entryFile: string, files: ReadonlyMap, runtimeConsole: Console) { + const cache = new Map() + + const load = (fileName: string): any => { + const cached = cache.get(fileName) + if (cached) return cached.exports + const code = files.get(fileName) + if (code === undefined) throw new Error(`Cannot find emitted module ${fileName}`) + + const module = { exports: {} as any } + cache.set(fileName, module) + const require = (specifier: string) => { + if (specifier === "reflect-metadata") return {} + if (!specifier.startsWith(".")) { + throw new Error(`Run cannot load package import "${specifier}".`) + } + const resolved = new URL(specifier, `file://${fileName}`).pathname + const candidates = [resolved, `${resolved}.js`, `${resolved}/index.js`] + const target = candidates.find(candidate => files.has(candidate)) + if (!target) throw new Error(`Cannot resolve "${specifier}" from ${fileName}`) + return load(target) + } + const directory = fileName.slice(0, fileName.lastIndexOf("/")) || "/" + const evaluate = new Function("exports", "require", "module", "__filename", "__dirname", "console", code) + evaluate(module.exports, require, module, fileName, directory, runtimeConsole) + return module.exports + } + + load(entryFile) +} + +function createRuntimeConsole(logs: RuntimeLog[]) { + const runtimeConsole = Object.create(console) as Console + for (const level of ["debug", "error", "info", "log", "warn"] as const) { + runtimeConsole[level] = (...values: any[]) => { + logs.push({ level, text: values.map(formatRuntimeValue).join(" ") }) + renderRunLogs(logs) + console[level](...values) + } + } + runtimeConsole.clear = () => { + logs.splice(0) + renderRunLogs(logs) + } + return runtimeConsole +} + +function formatRuntimeValue(value: unknown): string { + if (typeof value === "string") return value + if (typeof value === "bigint") return `${value}n` + if (typeof value === "symbol") return String(value) + if (value instanceof Error) return value.stack ?? value.message + try { + const json = JSON.stringify(value, undefined, 2) + return json ?? String(value) + } catch { + return String(value) + } +} + +function renderRunLogs(logs: readonly RuntimeLog[]) { + runLog.replaceChildren() + if (logs.length === 0) { + runLog.appendChild(createText("p", "Run the project to see console output.", "empty-message")) + return + } + for (const log of logs) { + const row = document.createElement("div") + row.className = `run-log-entry ${log.level}` + row.appendChild(createText("strong", log.level.slice(0, 3).toUpperCase())) + row.appendChild(document.createTextNode(log.text)) + runLog.appendChild(row) + } +} + +function renderFileList() { + type Tree = { + directories: Map + files: string[] + } + + const root: Tree = { directories: new Map(), files: [] } + for (const fileName of [...projectModels.keys(), ...Object.keys(projectSymlinks)].sort()) { + const parts = relativeProjectPath(fileName).split("/") + const basename = parts.pop()! + let tree = root + for (const part of parts) { + let child = tree.directories.get(part) + if (!child) { + child = { directories: new Map(), files: [] } + tree.directories.set(part, child) + } + tree = child + } + tree.files.push(basename) + } + + fileButtons.clear() + directoryButtons.clear() + fileList.replaceChildren(renderTree(root, "")) + updateActiveFile() + + function renderTree(tree: Tree, parentPath: string): HTMLUListElement { + const list = document.createElement("ul") + list.className = "file-tree" + for (const [directory, child] of [...tree.directories].sort(([left], [right]) => left.localeCompare(right))) { + const item = document.createElement("li") + const details = document.createElement("details") + details.open = true + const summary = createText("summary", directory, "file-tree-folder") + directoryButtons.set(`${projectRoot}/${parentPath}${directory}`, summary) + details.appendChild(summary) + details.appendChild(renderTree(child, `${parentPath}${directory}/`)) + item.appendChild(details) + list.appendChild(item) + } + for (const basename of tree.files.sort()) { + const relativePath = `${parentPath}${basename}` + const fileName = `${projectRoot}/${relativePath}` + const button = document.createElement("button") + button.type = "button" + button.className = "file-tree-file-button" + const target = projectSymlinks[fileName] + button.dataset.kind = target === undefined ? fileKind(fileName) : "->" + button.textContent = target === undefined ? basename : `${basename} -> ${target}` + if (target !== undefined) { + button.title = `Symbolic link: ${relativePath} -> ${target}` + button.setAttribute("aria-label", button.title) + } + button.addEventListener("click", () => { + if (target !== undefined) { + void openSymlinkTarget(fileName) + return + } + navigateToModel(fileName) + if (mobileLayout.matches) { + layoutState.filesVisible = false + applyLayoutState() + persistLayoutState() + } + }) + fileButtons.set(fileName, button) + const deleteButton = document.createElement("button") + deleteButton.type = "button" + deleteButton.className = "file-tree-delete" + deleteButton.textContent = "×" + deleteButton.title = `Delete ${relativePath}` + deleteButton.setAttribute("aria-label", `Delete ${relativePath}`) + deleteButton.addEventListener("click", () => void deleteProjectFile(fileName)) + const item = document.createElement("li") + item.className = "file-tree-file" + item.appendChild(button) + item.appendChild(deleteButton) + list.appendChild(item) + } + return list + } +} + +async function openSymlinkTarget(fileName: string) { + const target = resolveSymlinks(fileName, projectSymlinks) + if (target === projectRoot) { + fileExplorer.tabIndex = -1 + fileExplorer.scrollIntoView({ block: "nearest" }) + fileExplorer.focus() + return + } + if (target && projectModels.has(target)) { + navigateToModel(target) + return + } + const directory = target && directoryButtons.get(target) + if (directory) { + let parent = directory.closest("details") + while (parent) { + parent.open = true + parent = parent.parentElement?.closest("details") ?? null + } + directory.scrollIntoView({ block: "nearest" }) + directory.focus() + return + } + await requestConfirmation({ + cancelLabel: null, + confirmLabel: "OK", + message: `The target of ${relativeProjectPath(fileName)} does not exist in this project.`, + title: "Target not found", + }) +} + +function updateActiveFile() { + const model = inputEditor.getModel() + if (!model) return + const projectModel = projectModels.has(model.uri.path) + currentFile.textContent = model.uri.path.startsWith("/typescript/lib/") + ? model.uri.path.slice(model.uri.path.lastIndexOf("/") + 1) + : relativeProjectPath(model.uri.path) + inputEditor.updateOptions({ + ariaLabel: `${currentFile.textContent}${projectModel ? "" : ", read-only"} editor`, + readOnly: !projectModel, + }) + editorHint.hidden = projectModel + for (const [fileName, button] of fileButtons) { + if (fileName === model.uri.path) button.setAttribute("aria-current", "page") + else button.removeAttribute("aria-current") + } + if (projectModel) persistProjectState() +} + +function restoreProjectSelection(state: ProjectState) { + const selection = state.selection + if (!selection) return + inputEditor.setSelection( + new monaco.Selection( + selection.selectionStartLineNumber, + selection.selectionStartColumn, + selection.positionLineNumber, + selection.positionColumn + ) + ) + inputEditor.revealRangeInCenter(inputEditor.getSelection()!, monaco.editor.ScrollType.Immediate) +} + +function renderStatus() { + const failure = compilerFailure ?? lspFailure + const error = failure ?? projectFailure ?? (typeAcquisitionFailure ? `Package types: ${typeAcquisitionFailure}` : "") + status.hidden = !error + if (status.textContent !== error) status.textContent = error + status.title = error + if (failure) { + loader.hidden = false + loader.dataset.state = "error" + loadingMessage.textContent = failure + return + } + if (!compilerReady) { + loadingMessage.textContent = "Loading TypeScript..." + return + } + if (!lspReady) { + loadingMessage.textContent = "Starting TypeScript..." + return + } + + const wasLoading = !loader.hidden + loader.hidden = true + loader.dataset.state = "ready" + if (wasLoading) inputEditor.layout() +} + +function registerProjectModel(model: monaco.editor.ITextModel) { + model.onDidChangeContent(() => { + nativeCompileVersion++ + stradaCompileVersion++ + scheduleProjectPersistence() + refreshCompilerOverrides() + synchronizeLinkedProjectFiles() + scheduleTypeAcquisition() + window.clearTimeout(updateTimer) + updateTimer = window.setTimeout(() => { + void compileActiveProject?.() + }, 220) + }) +} + +function createNewFile() { + newFileKindFile.checked = true + newFilePath.value = "src/new-file.ts" + newSymlinkTarget.value = "" + updateNewFileKind() + newFileDialog.showModal() + newFilePath.focus() + newFilePath.select() +} + +function updateNewFileKind() { + const symlink = newFileKindSymlink.checked + if (newFilePath.value === (symlink ? "src/new-file.ts" : "node_modules/example")) { + newFilePath.value = symlink ? "node_modules/example" : "src/new-file.ts" + } + newSymlinkTargetLabel.hidden = !symlink + newSymlinkTarget.disabled = !symlink + newSymlinkTarget.required = symlink + newFileError.hidden = true + newFileError.textContent = "" + newFilePath.removeAttribute("aria-invalid") +} + +function finishCreatingFile() { + const relativePath = newFilePath.value.trim().replaceAll("\\", "/").replace(/^\/+/, "") + const parts = relativePath.split("/") + if (relativePath === "" || parts.some(part => part === "" || part === "." || part === "..")) { + showNewFileError("Enter a project-relative file path, such as src/example.ts.") + return + } + + const fileName = `${projectRoot}/${relativePath}` + if (projectModels.has(fileName) || projectSymlinks[fileName] !== undefined) { + showNewFileError(`${relativePath} already exists.`) + return + } + + try { + if (newFileKindSymlink.checked) { + const target = newSymlinkTarget.value.trim().replaceAll("\\", "/") + validateProjectSymlinks(projectFileContents(), { ...projectSymlinks, [fileName]: target }) + projectSymlinks[fileName] = target + newFileDialog.close() + reloadProject() + return + } + validateProjectSymlinks({ ...projectFileContents(), [fileName]: "" }, projectSymlinks) + } catch (error) { + showNewFileError(error instanceof Error ? error.message : String(error)) + return + } + + newFileDialog.close() + const model = monaco.editor.createModel("", languageForFile(fileName), monaco.Uri.parse(`file://${fileName}`)) + projectModels.set(fileName, model) + registerProjectModel(model) + refreshCompilerOverrides() + synchronizeLinkedProjectFiles() + renderFileList() + inputEditor.setModel(model) + inputEditor.focus() + persistProjectState() + void compileActiveProject?.() +} + +function showNewFileError(message: string) { + newFileError.textContent = message + newFileError.hidden = false + newFilePath.setAttribute("aria-invalid", "true") + newFilePath.focus() +} + +async function deleteProjectFile(fileName: string) { + if (projectSymlinks[fileName] !== undefined) { + if ( + !(await requestConfirmation({ + confirmLabel: "Delete link", + danger: true, + message: `Delete the symbolic link ${relativeProjectPath(fileName)}? Its target will not be deleted.`, + title: "Delete symbolic link", + })) + ) + return + delete projectSymlinks[fileName] + reloadProject() + return + } + const model = projectModels.get(fileName) + if (!model) return + if (projectModels.size === 1) { + await requestConfirmation({ + cancelLabel: null, + confirmLabel: "OK", + message: "The project must contain at least one file.", + title: "Cannot delete file", + }) + return + } + + const relativePath = relativeProjectPath(fileName) + const confirmed = await requestConfirmation({ + confirmLabel: "Delete file", + danger: true, + message: `Delete ${relativePath}? This cannot be undone.`, + title: "Delete file", + }) + if (!confirmed) return + + const deletedUri = model.uri.toString() + projectModels.delete(fileName) + refreshCompilerOverrides() + if (inputEditor.getModel() === model) { + const remainingFiles = [...projectModels.keys()] + const fallbackFile = + remainingFiles.find(candidate => candidate === entryFileName) ?? + remainingFiles.find(candidate => /\.[cm]?[jt]sx?$/i.test(candidate)) ?? + remainingFiles[0] + inputEditor.setModel(projectModels.get(fallbackFile)!) + } + model.dispose() + if (projectReturnLocation?.uri === deletedUri) projectReturnLocation = undefined + trackedEditorLocation = getEditorLocation() + renderFileList() + reloadProject() +} + +function reloadProject() { + try { + localStorage.setItem(storageKey, serializeProjectState(currentProjectState())) + sessionStorage.setItem(pendingReloadKey, location.href) + } catch (error) { + projectFailure = `Could not save the project before reloading: ${ + error instanceof Error ? error.message : String(error) + }` + console.error(error) + renderStatus() + return + } + projectPersistenceEnabled = false + window.clearTimeout(projectPersistTimer) + location.reload() +} + +async function clearProject() { + const confirmed = await requestConfirmation({ + confirmLabel: "Clear project", + danger: true, + message: "Remove all project files and start with an empty index.ts? The selected compiler version will be kept.", + title: "Clear project", + }) + if (!confirmed) return + const state: ProjectState = { + activeFile: entryFileName, + files: { + [configFileName]: createLegacyConfig("ts"), + [entryFileName]: "", + }, + } + try { + localStorage.setItem(storageKey, serializeProjectState(state)) + sessionStorage.removeItem(pendingReloadKey) + } catch (error) { + projectFailure = `Could not clear the project: ${error instanceof Error ? error.message : String(error)}` + console.error(error) + renderStatus() + return + } + projectPersistenceEnabled = false + window.clearTimeout(projectPersistTimer) + const url = new URL(document.baseURI) + if (selectedCompiler !== null) url.searchParams.set("ts", selectedCompiler) + history.replaceState({}, "", url) + location.reload() +} + +function loadProjectState(): ProjectState { + try { + const pendingReload = sessionStorage.getItem(pendingReloadKey) + sessionStorage.removeItem(pendingReloadKey) + const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined + if (navigation?.type === "reload" && pendingReload === location.href) { + const saved = localStorage.getItem(storageKey) + if (saved) return normalizeVersionedProjectState(JSON.parse(saved)) + } + } catch (error) { + console.warn("Could not restore pending playground edits after reload", error) + } + if (location.hash.startsWith(projectHashPrefix)) { + const decoded = decodeCompressedHash(location.hash.slice(projectHashPrefix.length)) + if (decoded !== null) { + try { + return normalizeVersionedProjectState(JSON.parse(decoded)) + } catch (error) { + console.warn("Could not restore the versioned playground project", error) + return { files: {}, useDefaults: true } + } + } + } + + if (location.hash.startsWith("#src=")) { + try { + return createLegacyProjectState(decodeURIComponent(location.hash.slice("#src=".length))) + } catch (error) { + console.warn("Could not decode the legacy playground source", error) + } + } + + if (location.hash.startsWith(legacyCodeHashPrefix)) { + const decoded = decodeCompressedHash(location.hash.slice(legacyCodeHashPrefix.length)) + if (decoded !== null) { + try { + const state: unknown = JSON.parse(decoded) + if ( + state && + typeof state === "object" && + !Array.isArray(state) && + ("files" in state || Object.keys(state).some(path => path.startsWith(`${projectRoot}/`))) + ) { + return normalizeProjectState(state) + } + } catch { + // Legacy source is usually not a serialized project. + } + return createLegacyProjectState(decoded) + } + } + + const stored = localStorage.getItem(storageKey) + if (!stored) return { files: {}, useDefaults: true } + try { + return normalizeProjectState(JSON.parse(stored)) + } catch (error) { + console.warn("Could not restore the TypeScript Playground v2 project", error) + return { files: {}, useDefaults: true } + } +} + +function serializeProjectState(state: ProjectState) { + const versioned: VersionedProjectState = { + activeFile: state.activeFile, + files: state.files, + selection: state.selection, + version: projectStateVersion, + symlinks: state.symlinks, + legacyQuery: state.legacyQuery, + } + return JSON.stringify(versioned) +} + +function normalizeVersionedProjectState(value: unknown) { + if (!value || typeof value !== "object" || (value as { version?: unknown }).version !== projectStateVersion) { + throw new Error("Unsupported playground project URL version") + } + return normalizeProjectState(value) +} + +function normalizeProjectState(value: unknown): ProjectState { + if (!value || typeof value !== "object") return { files: {}, useDefaults: true } + const candidate = value as { + activeFile?: unknown + files?: unknown + selection?: unknown + version?: unknown + symlinks?: unknown + legacyQuery?: unknown + } + if (candidate.version !== undefined && candidate.version !== projectStateVersion) { + console.warn(`Ignoring unsupported playground project version: ${String(candidate.version)}`) + return { files: {}, useDefaults: true } + } + const filesValue = candidate.files && typeof candidate.files === "object" ? candidate.files : value + const files = Object.fromEntries( + Object.entries(filesValue).filter( + (entry): entry is [string, string] => entry[0].startsWith(`${projectRoot}/`) && typeof entry[1] === "string" + ) + ) + const migrations = new Map( + candidate.version === undefined + ? [ + [`${projectRoot}/index.ts`, entryFileName], + [`${projectRoot}/greet.ts`, `${projectRoot}/src/greet.ts`], + ] + : [] + ) + const hasLegacyRootFiles = [...migrations.keys()].some(fileName => files[fileName] !== undefined) + for (const [oldPath, newPath] of migrations) { + if (files[oldPath] !== undefined && files[newPath] === undefined) { + files[newPath] = files[oldPath] + } + delete files[oldPath] + } + if (hasLegacyRootFiles && files[configFileName]) { + try { + const config = JSON.parse(files[configFileName]) + if (Array.isArray(config.include) && config.include.length === 1 && config.include[0] === "./*.ts") { + config.include = ["./src/**/*"] + } + config.compilerOptions ??= {} + config.compilerOptions.declaration ??= true + files[configFileName] = `${JSON.stringify(config, undefined, 2)}\n` + } catch { + files[configFileName] = files[configFileName].replace( + /"include"\s*:\s*\[\s*"\.\/\*\.ts"\s*\]/, + '"include": ["./src/**/*"]' + ) + } + } + const requestedActiveFile = + typeof candidate.activeFile === "string" ? migrations.get(candidate.activeFile) ?? candidate.activeFile : undefined + const selection = normalizeSelection(candidate.selection) + const symlinks: Record = {} + if (candidate.symlinks !== undefined) { + if (!candidate.symlinks || typeof candidate.symlinks !== "object" || Array.isArray(candidate.symlinks)) { + throw new Error("Invalid project symbolic links") + } + for (const [path, target] of Object.entries(candidate.symlinks)) { + if (typeof target !== "string") throw new Error(`Invalid symbolic link target: ${path}`) + symlinks[path] = target + } + validateProjectSymlinks(files, symlinks) + } + return { + activeFile: requestedActiveFile?.startsWith(`${projectRoot}/`) ? requestedActiveFile : undefined, + files, + selection, + useDefaults: false, + symlinks, + legacyQuery: typeof candidate.legacyQuery === "string" ? candidate.legacyQuery : undefined, + } +} + +function normalizeSelection(value: unknown): ProjectState["selection"] { + if (!value || typeof value !== "object") return undefined + const candidate = value as Record + const keys = ["positionColumn", "positionLineNumber", "selectionStartColumn", "selectionStartLineNumber"] as const + if (keys.some(key => !Number.isInteger(candidate[key]) || Number(candidate[key]) < 1)) return undefined + return { + positionColumn: Number(candidate.positionColumn), + positionLineNumber: Number(candidate.positionLineNumber), + selectionStartColumn: Number(candidate.selectionStartColumn), + selectionStartLineNumber: Number(candidate.selectionStartLineNumber), + } +} + +function createLegacyProjectState(code: string, fileType = getLegacyFileType()): ProjectState { + const params = new URLSearchParams(location.search) + const configText = params.get("tsconfig") ?? createLegacyConfig(fileType) + const selection = normalizeSelection({ + selectionStartLineNumber: Number(params.get("ssl")), + selectionStartColumn: Number(params.get("ssc")), + positionLineNumber: Number(params.get("pln")), + positionColumn: Number(params.get("pc")), + }) + const fileNamePattern = /^\s*\/\/\s*@filename:\s*(.+)$/i + if (!code.split(/\r\n?|\n/g).some(line => fileNamePattern.test(line))) { + const fileName = `${projectRoot}/src/index.${fileType}` + return { + activeFile: fileName, + files: { + [configFileName]: configText, + [fileName]: code, + }, + useDefaults: false, + legacyQuery: location.search, + selection, + } + } + + const fourSlashStyle = /^\s*\/\/\s*@Filename:/m.test(code) + const files: Record = { + [configFileName]: configText, + } + let currentFile = `src/index.${fileType}` + let currentLines: string[] = [] + let hasFileDirective = false + const flush = (allowEmpty = false) => { + if (currentLines.length === 0 && !allowEmpty) return + const relativePath = sanitizeLegacyPath(currentFile) || `src/index.${fileType}` + const fileName = `${projectRoot}/${relativePath}` + files[fileName] = currentLines.join("\n") + } + for (const line of code.split(/\r\n?|\n/g)) { + const match = fileNamePattern.exec(line) + if (match) { + flush(hasFileDirective) + currentFile = match[1].trim() + currentLines = [] + hasFileDirective = true + } else { + currentLines.push(fourSlashStyle ? line.replace(/^(\s*)\/\/\/\//, "$1") : line) + } + } + flush(hasFileDirective) + const sourceFiles = Object.keys(files).filter(fileName => fileName !== configFileName) + return { + activeFile: sourceFiles[0] ?? entryFileName, + files, + useDefaults: false, + legacyQuery: location.search, + selection, + } +} + +function createLegacyConfig(fileType: string) { + if (useNativeCompiler) return defaultFiles[0].text + const [major, minor] = (loadedLegacyCompilerVersion ?? selectedCompiler ?? "").split(".").map(Number) + const javascript = fileType === "js" || fileType === "jsx" + return ( + JSON.stringify( + { + compilerOptions: { + strict: true, + noImplicitReturns: true, + allowUnreachableCode: false, + allowUnusedLabels: false, + esModuleInterop: true, + declaration: true, + module: "esnext", + moduleResolution: "node", + target: "es2017", + jsx: "react", + ...(major > 3 || (major === 3 && minor >= 7) ? { useDefineForClassFields: false } : {}), + ...(major < 5 ? { experimentalDecorators: true, emitDecoratorMetadata: true } : {}), + ...(major >= 6 ? { ignoreDeprecations: "6.0" } : {}), + ...(javascript ? { allowJs: true, checkJs: true, strictNullChecks: false } : {}), + }, + }, + undefined, + 2 + ) + "\n" + ) +} + +function sanitizeLegacyPath(fileName: string) { + const parts = fileName.replaceAll("\\", "/").replace(/^\/+/, "").split("/") + return parts.filter(part => part !== "" && part !== "." && part !== "..").join("/") +} + +function getLegacyFileType() { + const params = new URLSearchParams(location.search) + if (params.has("useJavaScript")) return "js" + const fileType = params.get("filetype") + return fileType && (fileType === "d.ts" || /^[cm]?[jt]sx?$/.test(fileType)) ? fileType : "ts" +} + +function consumeLegacyCompilerOptions(optionNames: Iterable) { + if (legacyQuery === undefined) return + const model = projectConfigModel() + if (model) { + const text = applyLegacyCompilerOptions(model.getValue(), legacyQuery, optionNames) + if (text !== model.getValue()) model.setValue(text) + } + legacyQuery = undefined + persistProjectState() + const url = new URL(document.baseURI) + if (selectedCompiler !== null) url.searchParams.set("ts", selectedCompiler) + url.hash = `${projectHashPrefix.slice(1)}${LZString.compressToEncodedURIComponent( + serializeProjectState(currentProjectState()) + )}` + history.replaceState({}, "", url) +} + +function scheduleProjectPersistence() { + window.clearTimeout(projectPersistTimer) + window.addEventListener("beforeunload", flushProjectBeforeUnload) + projectPersistTimer = window.setTimeout(persistProjectState, 250) +} + +function flushProjectBeforeUnload() { + const previousUrl = location.href + persistProjectState() + if (!projectPersistenceEnabled) return + try { + // Reload can capture the old hash before the pending save updates it. + sessionStorage.setItem(pendingReloadKey, previousUrl) + } catch (error) { + console.warn("Could not preserve pending playground edits for reload", error) + } +} + +function currentProjectState(): ProjectState { + const activeModel = inputEditor.getModel() + const selection = activeModel && projectModels.has(activeModel.uri.path) ? inputEditor.getSelection() : undefined + return { + activeFile: activeModel && projectModels.has(activeModel.uri.path) ? activeModel.uri.path : entryFileName, + files: Object.fromEntries([...projectModels].map(([fileName, model]) => [fileName, model.getValue()])), + symlinks: Object.keys(projectSymlinks).length ? projectSymlinks : undefined, + legacyQuery, + selection: selection + ? { + positionColumn: selection.positionColumn, + positionLineNumber: selection.positionLineNumber, + selectionStartColumn: selection.selectionStartColumn, + selectionStartLineNumber: selection.selectionStartLineNumber, + } + : undefined, + } +} + +function persistProjectState() { + window.clearTimeout(projectPersistTimer) + window.removeEventListener("beforeunload", flushProjectBeforeUnload) + if (!projectPersistenceEnabled) return + try { + const serialized = serializeProjectState(currentProjectState()) + if (serialized !== lastStoredProject) { + localStorage.setItem(storageKey, serialized) + lastStoredProject = serialized + } + if (!playgroundSettings.saveToUrl || legacyQuery !== undefined) return + if (serialized === lastUrlProject) return + const url = new URL(location.href) + url.hash = `${projectHashPrefix.slice(1)}${LZString.compressToEncodedURIComponent(serialized)}` + history.replaceState({}, "", url) + lastUrlProject = serialized + } catch (error) { + console.warn("Could not save the TypeScript Playground v2 project", error) + } +} + +function fileKind(fileName: string) { + const language = languageForFile(fileName) + return language === "json" ? "{}" : language.startsWith("javascript") ? "JS" : "TS" +} + +function relativeProjectPath(fileName: string) { + return fileName.startsWith(`${projectRoot}/`) ? fileName.slice(projectRoot.length + 1) : fileName +} + +function truncate(value: string, maxLength: number) { + return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…` +} + +function createText(tagName: K, text: string, className?: string) { + const element = document.createElement(tagName) + element.textContent = text + if (className) element.className = className + return element +} + +function getElement(id: string) { + const element = document.getElementById(id) + if (!element) throw new Error(`Missing #${id}`) + return element as T +} diff --git a/packages/playground-v2/src/native-compiler.test.ts b/packages/playground-v2/src/native-compiler.test.ts new file mode 100644 index 000000000000..bc12142d73e8 --- /dev/null +++ b/packages/playground-v2/src/native-compiler.test.ts @@ -0,0 +1,763 @@ +import assert from "node:assert/strict" +import { readFile, readdir } from "node:fs/promises" +import { createRequire } from "node:module" +import { test } from "node:test" +import { API } from "@typescript/typescript/sync" +import * as ast from "@typescript/typescript/ast" +import { instantiateWasm, WasmTransport } from "@typescript/typescript/wasm" +import { NativeCompiler, type NativeCompileInput } from "./native-compiler" +import { legacyCompilerBaseUrl } from "./compiler-distribution" +import { + compilerVersionChoices, + isUnsupportedNativeCompilerVersion, + isWasmCompilerVersion, + publishedNativeCompilerSelection, +} from "./compiler-versions" +import { loadStradaLibraries, type StradaLibraryProgress } from "./strada-libraries" +import { + applyLegacyCompilerOptions, + canSetCompilerOptions, + compilerOverrideForDiagnostic, + computeCompilerOverrides, +} from "./compiler-overrides" +import { createStradaFileSystem, parseStradaConfig } from "./strada-project" +import { createPlaygroundV2Query } from "../../typescriptlang-org/src/lib/createPlaygroundV2Query" +import { decodeCompressedHash, projectMarkdown, type ExportProject } from "./exporter" + +test("the legacy handoff serializes effective options with its own compiler and retains the requested channel", () => { + const ts: typeof import("typescript") = createRequire(import.meta.url)("typescript") + let overrides: Record | undefined + const sandbox = { + ts, + getCompilerOptions: () => ({ + strict: false, + strictNullChecks: true, + exactOptionalPropertyTypes: true, + module: 99, + target: 4, + jsx: 4, + lib: ["lib.es2017.d.ts", "lib.dom.d.ts"], + paths: { alias: ["./types/alias"] }, + }), + createURLQueryWithCompilerOptions(_sandbox: unknown, params: Record) { + overrides = params + return "fixture-query" + }, + } + for (const version of ["4.4.2", "6.0.2", "custom-build"]) { + assert.equal(createPlaygroundV2Query(sandbox, version ? `?ts=${version}` : ""), "fixture-query") + assert.equal(overrides?.ts, version) + const config = JSON.parse(String(overrides?.tsconfig)) + const expected = { + strict: false, + strictNullChecks: true, + exactOptionalPropertyTypes: true, + module: "esnext", + target: "es2017", + jsx: "react-jsx", + lib: ["es2017", "dom"], + paths: { alias: ["./types/alias"] }, + } + for (const [option, value] of Object.entries(expected)) { + assert.deepEqual(config.compilerOptions[option], value) + } + } + for (const version of ["next", "Nightly", "native", "7", "7.1", "7.1.0", undefined]) { + assert.equal(createPlaygroundV2Query(sandbox, version ? `?ts=${version}` : ""), "fixture-query") + assert.deepEqual(overrides, { ts: version ?? null }) + } +}) + +test("legacy virtual directory discovery supports compiler config inheritance and file selection", () => { + const ts: typeof import("typescript") = createRequire(import.meta.url)("typescript") + const files = new Map([ + ["/workspace/tsconfig.json", "{}"], + [ + "/workspace/config/base.json", + JSON.stringify({ + compilerOptions: { strict: true, target: "es2020" }, + include: ["../src"], + exclude: ["../src/skip.ts"], + }), + ], + ["/workspace/explicit.ts", "export const explicit = 1;"], + ["/workspace/src/Keep.ts", "export const kept = 1;"], + ["/workspace/src/skip.ts", "export const skipped = 1;"], + ["/workspace/node_modules/package/ignore.ts", "export const ignored = 1;"], + ]) + const host = createStradaFileSystem( + ts, + () => files.keys(), + path => files.get(path) + ) + for (const include of [["src"], undefined]) { + const parsed = ts.parseJsonConfigFileContent( + { extends: "./config/base.json", files: ["./explicit.ts"], ...(include ? { include } : {}) }, + host, + "/workspace", + undefined, + "/workspace/tsconfig.json" + ) + assert.deepEqual(parsed.errors, []) + assert.deepEqual(parsed.fileNames.sort(), ["/workspace/explicit.ts", "/workspace/src/Keep.ts"]) + assert.equal(parsed.options.strict, true) + assert.equal(parsed.options.target, ts.ScriptTarget.ES2020) + } + assert.deepEqual(host.getDirectories("/workspace").sort(), ["config", "node_modules", "src"]) + assert.equal(host.fileExists("/workspace/src/../explicit.ts"), true) + assert.equal(host.fileExists("/workspace/src/keep.ts"), false) +}) + +test("historical compilers use the Playground CDN except for incorrect or unmirrored builds", () => { + for (const version of ["3.3.3", "4.4.2", "5.9.3", "6.0.2", "6.1.0-beta", "6.1.1-rc", "6.1.0-dev.20261030"]) { + const base = legacyCompilerBaseUrl(version) + assert.equal(new URL("typescript.js", base).href, `https://unpkg.com/typescript@${version}/lib/typescript.js`) + assert.equal(new URL("lib.es5.d.ts", base).href, `https://unpkg.com/typescript@${version}/lib/lib.es5.d.ts`) + } + for (const version of ["3.3.3", "5.9.3", "6.0.2"]) { + assert.equal( + legacyCompilerBaseUrl(version, [version]).href, + `https://playgroundcdn.typescriptlang.org/cdn/${version}/typescript/lib/` + ) + } + for (const version of [ + "4.0.2", + "4.1.5", + "4.2.2", + "4.2.3", + "4.3.2", + "4.3.3", + "4.3.4", + "4.3.5", + "4.4.2", + "4.4.3", + "4.4.4", + ]) { + assert.equal(legacyCompilerBaseUrl(version, [version]).href, `https://unpkg.com/typescript@${version}/lib/`) + } + for (const version of ["custom-build", "4.4.0-pr-12345-1"]) { + assert.equal( + legacyCompilerBaseUrl(version).href, + `https://playgroundcdn.typescriptlang.org/cdn/${version}/typescript/lib/` + ) + } +}) + +test("legacy URL ingestion preserves JSONC and accepts only known options with compiler enum values", () => { + const ts: typeof import("typescript") = createRequire(import.meta.url)("typescript") + const names = ["strict", "jsx", "module", "lib", "paths", "noImplicitAny"] + const config = '{\n // keep this comment\n "compilerOptions": { "strict": true, },\n}' + const converted = applyLegacyCompilerOptions( + config, + "?strict=false&q=42&install-plugin=x&lib=es2017,dom&noImplicitAny", + names + ) + assert(converted.includes("// keep this comment")) + assert(converted.includes('"strict": false')) + assert(!converted.includes('"q"')) + assert(!converted.includes("install-plugin")) + const parsed = ts.parseConfigFileTextToJson("tsconfig.json", converted).config + assert.deepEqual(parsed.compilerOptions.lib, ["es2017", "dom"]) + assert.equal(parsed.compilerOptions.noImplicitAny, true) + for (const [value, expected] of [ + [1, "preserve"], + [2, "react"], + [3, "react-native"], + [4, "react-jsx"], + [5, "react-jsxdev"], + ] as const) { + assert.equal(JSON.parse(applyLegacyCompilerOptions("{}", `?jsx=${value}`, names)).compilerOptions.jsx, expected) + } + for (const module of [ts.ModuleKind.Node18, ts.ModuleKind.Node20]) { + const config = JSON.parse(applyLegacyCompilerOptions("{}", `?module=${module}`, names)) + const converted = ts.convertCompilerOptionsFromJson(config.compilerOptions, "/workspace") + assert.deepEqual(converted.errors, []) + assert.equal(converted.options.module, module) + } + assert.equal( + JSON.parse(applyLegacyCompilerOptions('{"compilerOptions":{"jsx":"react"}}', "?jsx=0", names)).compilerOptions.jsx, + undefined + ) + assert.equal( + ts.parseConfigFileTextToJson( + "tsconfig.json", + applyLegacyCompilerOptions(config, "?tsconfig=snapshot&strict=false", names) + ).config.compilerOptions.strict, + true + ) + for (const invalid of ["[]", '{"compilerOptions":null}', '{"compilerOptions":[]}', '{"compilerOptions":']) { + assert.equal(canSetCompilerOptions(invalid), false) + assert.equal(applyLegacyCompilerOptions(invalid, "?strict=true", names), invalid) + } +}) + +test("historical root config selection ignores insertion order and follows config symlinks", () => { + const ts: typeof import("typescript") = createRequire(import.meta.url)("typescript") + const files = new Map([ + ["/workspace/nested/tsconfig.json", '{"compilerOptions":{"strict":false},"files":["nested.ts"]}'], + ["/workspace/config/base.json", '{"compilerOptions":{"strict":true},"files":["src/index.ts"]}'], + ["/workspace/nested/nested.ts", "export const nested = 1;"], + ["/workspace/src/index.ts", "export const root = 1;"], + ]) + const links = { "/workspace/tsconfig.json": "config/base.json" } + const filesystem = createStradaFileSystem( + ts, + () => files.keys(), + path => files.get(path), + links + ) + const parsed = parseStradaConfig(ts, filesystem) + assert.deepEqual(parsed.errors, []) + assert.equal(parsed.options.strict, true) + assert.deepEqual(parsed.rootNames, ["/workspace/src/index.ts"]) +}) + +test("compiler overrides remap option errors but not source errors that mention an option", () => { + const state = computeCompilerOverrides( + new Map([["/workspace/src/index.ts", "// @module: nodenext\nmodule.exports = 1;"]]), + "{}" + ) + assert.equal( + compilerOverrideForDiagnostic( + state, + { + fileName: "/workspace/src/index.ts", + pos: 21, + end: 27, + text: "Cannot find name 'module'.", + }, + "/workspace/tsconfig.json" + ), + undefined + ) + assert.equal( + compilerOverrideForDiagnostic( + state, + { + pos: 0, + end: 0, + text: "Option 'module' must be set to 'NodeNext'.", + }, + "/workspace/tsconfig.json" + ), + state.overrides[0] + ) +}) + +test("legacy compilers resolve linked files and package identity using preserveSymlinks", () => { + const ts: typeof import("typescript") = createRequire(import.meta.url)("typescript") + const files = new Map([ + [ + "/workspace/src/index.ts", + 'import { value } from "./value"; import { Token as Direct } from "../packages/pkg"; import { Token as Linked } from "pkg"; const token: Direct = new Linked(); const result: number = value;', + ], + ["/workspace/shared/value.ts", "export const value = 42;"], + ["/workspace/packages/pkg/index.ts", "export class Token { private brand = 1; }"], + ["/workspace/packages/pkg/package.json", '{"name":"pkg","types":"index.ts"}'], + ]) + const links = { "/workspace/src/value.ts": "../shared/value.ts", "/workspace/node_modules/pkg": "../packages/pkg" } + const filesystem = createStradaFileSystem( + ts, + () => files.keys(), + path => files.get(path), + links + ) + for (const preserveSymlinks of [false, true]) { + const options = { module: ts.ModuleKind.NodeNext, target: ts.ScriptTarget.ESNext, noLib: true, preserveSymlinks } + const program = ts.createProgram(["/workspace/src/index.ts"], options, { + ...ts.createCompilerHost(options), + ...filesystem, + getCurrentDirectory: () => "/workspace", + useCaseSensitiveFileNames: () => true, + getSourceFile: (path, target) => { + const text = filesystem.readFile(path) + return text === undefined ? undefined : ts.createSourceFile(path, text, target, true) + }, + }) + const diagnostics = program.getSemanticDiagnostics() + if (preserveSymlinks) assert(diagnostics.some(diagnostic => diagnostic.code === 2322)) + else assert.deepEqual(diagnostics, []) + } +}) + +test("compressed share links decode percent escapes and preserve empty legacy sources", () => { + const raw = + "N4IgbgpgTgzglgewHYgFwCYA0IBmcA2EMaoA9AO4JQDWMADgIYDGEpMUTpcSAJhAB4A6AC7FUIJshjCABPwDMMgLwz5AbhmlSMgCQyA1DOkBXHDhkatug0eGnzl7XsMmzFzU5uvzIAL6+gA" + const expected = + '{"version":2,"files":{"/workspace/src/index.ts":"const x3 = 3; // $ + stuff ; // $ + stuff ; // $ + stuff ; // $ + stuff "}}' + assert.equal(decodeCompressedHash(raw), expected) + assert.equal(decodeCompressedHash(encodeURIComponent(raw)), expected) + assert.equal(decodeCompressedHash("Q"), "") + assert.equal(decodeCompressedHash("%51"), "") + assert.equal(decodeCompressedHash("%invalid"), null) +}) + +test("Markdown exports retain project files, configuration, symbolic links and output", () => { + const project: ExportProject = { + activeFile: "src/index.ts", + files: { + "src/index.ts": "import './other';", + "src/other.ts": "export const value = `backticks ``` stay in the code`;", + "tsconfig.json": '{"compilerOptions":{"strict":true},"files":["src/index.ts"]}', + "package.json": + '{"name":"existing","dependencies":{"pkg":"^2"},"devDependencies":{"typescript":"6.0.2"},"scripts":{"start":"node src/index.js"}}', + }, + outputFiles: { "src/index.js": "require('./other');" }, + symlinks: { "node_modules/pkg": "../packages/pkg" }, + url: "https://example.test/play/v2/#project", + } + const markdown = projectMarkdown(project) + for (const [path, text] of Object.entries(project.files)) { + assert(markdown.includes(JSON.stringify(path))) + assert(markdown.includes(text)) + } + assert(markdown.includes("````ts\n")) + assert(markdown.includes('"node_modules/pkg": "../packages/pkg"')) + assert(markdown.includes(project.url)) + assert(markdown.includes("require('./other');")) +}) + +test("native TypeScript comment directives are not project-wide compiler overrides", () => { + const config = '{"compilerOptions":{"strict":false}}' + const state = computeCompilerOverrides( + new Map([ + [ + "/workspace/src/index.ts", + [ + "// @ts-expect-error: intentional type error", + "// @ts-ignore: compatibility", + "// @ts-nocheck: generated", + "// @ts-check:", + "// @strict: true", + 'const value: number = "wrong";', + ].join("\n"), + ], + ]), + config + ) + assert.deepEqual(state.diagnostics, []) + assert.deepEqual( + state.overrides.map(override => override.option), + ["strict"] + ) + assert.deepEqual(JSON.parse(state.effectiveConfigText).compilerOptions, { strict: true }) +}) + +test("compiler list directives accept comma-separated values and JSON arrays without splitting scalar options", () => { + const state = computeCompilerOverrides( + new Map([ + [ + "/workspace/src/index.ts", + [ + "// @types: node, jquery", + "// @lib: es2020, dom", + '// @rootDirs: ["src", "generated"]', + "// @moduleSuffixes: .native, ", + "// @customConditions: browser, development", + "// @typeRoots: ./types, ./vendor/types", + "// @outDir: build,output", + ].join("\n"), + ], + ]), + "{}" + ) + assert.deepEqual(state.diagnostics, []) + assert.deepEqual(JSON.parse(state.effectiveConfigText).compilerOptions, { + types: ["node", "jquery"], + lib: ["es2020", "dom"], + rootDirs: ["src", "generated"], + moduleSuffixes: [".native", ""], + customConditions: ["browser", "development"], + typeRoots: ["./types", "./vendor/types"], + outDir: "build,output", + }) +}) + +test("legacy library discovery downloads bounded parallel batches, deduplicates references, and reports progress", async t => { + const source = new Map([ + ["lib.root.d.ts", Array.from({ length: 12 }, (_, index) => `/// `).join("\n")], + ...Array.from({ length: 12 }, (_, index): [string, string] => [ + `lib.part${index}.d.ts`, + '/// \n/// ', + ]), + ["lib.shared.d.ts", "interface Shared {}"], + ]) + let active = 0 + let maximum = 0 + const requests: string[] = [] + t.mock.method(globalThis, "fetch", async (input: URL | RequestInfo) => { + const name = new URL(String(input)).pathname.split("/").at(-1)! + requests.push(name) + active++ + maximum = Math.max(maximum, active) + await new Promise(resolve => setTimeout(resolve, 5)) + active-- + return new Response(source.get(name), { status: source.has(name) ? 200 : 404 }) + }) + const libraries = new Map() + const progress: StradaLibraryProgress[] = [] + await loadStradaLibraries(["root", "lib.root.d.ts"], "https://compiler.example.test/", libraries, value => + progress.push(value) + ) + assert.equal(maximum, 8) + assert.equal(requests.length, source.size) + assert.equal(new Set(requests).size, requests.length) + assert.equal(libraries.size, source.size) + assert.equal(progress[0].completed, 0) + assert.equal(progress[0].total, 1) + assert.equal(progress.at(-1)?.completed, source.size) + assert.equal(progress.at(-1)?.total, source.size) + await loadStradaLibraries(["root"], "https://compiler.example.test/", libraries, () => {}) + assert.equal(requests.length, source.size) +}) + +test("legacy library download failures are reported", async t => { + t.mock.method(globalThis, "fetch", async () => new Response("", { status: 503 })) + await assert.rejects( + loadStradaLibraries(["root"], "https://compiler.example.test/", new Map(), () => {}), + /Could not load lib\.root\.d\.ts: 503/ + ) +}) + +test("legacy library loading retries missing dependencies beneath cached roots", async t => { + const requests: string[] = [] + t.mock.method(globalThis, "fetch", async (input: URL | RequestInfo) => { + const name = new URL(String(input)).pathname.split("/").at(-1)! + requests.push(name) + if (name === "lib.root.d.ts") return new Response('/// ') + return requests.length === 2 ? new Response("", { status: 503 }) : new Response("interface Dependency {}") + }) + const libraries = new Map() + const load = () => loadStradaLibraries(["root"], "https://compiler.example.test/", libraries, () => {}) + await assert.rejects(load(), /Could not load lib\.dependency\.d\.ts: 503/) + assert(libraries.has("/typescript/lib/lib.root.d.ts")) + assert(!libraries.has("/typescript/lib/lib.dependency.d.ts")) + await load() + assert(libraries.has("/typescript/lib/lib.dependency.d.ts")) + assert.deepEqual(requests, ["lib.root.d.ts", "lib.dependency.d.ts", "lib.dependency.d.ts"]) +}) + +test("overlapping legacy library loads wait for dependencies beneath cached roots", async t => { + let release!: () => void + let started!: () => void + const gate = new Promise(resolve => { + release = resolve + }) + const downloading = new Promise(resolve => { + started = resolve + }) + t.mock.method(globalThis, "fetch", async (input: URL | RequestInfo) => { + if (String(input).endsWith("lib.root.d.ts")) return new Response('/// ') + started() + await gate + return new Response("interface Dependency {}") + }) + const libraries = new Map() + const load = () => loadStradaLibraries(["root"], "https://compiler.example.test/", libraries, () => {}) + const first = load() + await downloading + let secondFinished = false + const second = load().then(() => { + secondFinished = true + }) + try { + await new Promise(resolve => setImmediate(resolve)) + assert.equal(secondFinished, false) + } finally { + release() + await Promise.all([first, second]) + } + assert(libraries.has("/typescript/lib/lib.dependency.d.ts")) +}) + +test("native versions start at the WASM nightly and exclude the published beta", () => { + for (const version of ["7.1.0-dev.20261030.1", "7.1.0-dev.20261031.1", "7.1.0-rc", "7.1.0", "7.2.0-beta", "8.0.0"]) { + assert.equal(isWasmCompilerVersion(version), true, version) + } + for (const version of ["7.1.0-beta", "7.1.0-dev.20261029.1", "7.0.2", "6.0.3", "next", "pr-build"]) { + assert.equal(isWasmCompilerVersion(version), false, version) + } +}) + +test("the native default follows Nightly until a supported stable release is published", () => { + const published = { + versions: ["7.0.2", "7.1.0-dev.20261030.1", "7.1.0-dev.20261031.1", "7.1.0", "7.2.0-dev.20261101.1"], + tags: { latest: "7.0.2", next: "7.1.0-dev.20261031.1" }, + } + assert.deepEqual(publishedNativeCompilerSelection(published, "default"), { + version: "7.1.0-dev.20261031.1", + channel: "nightly", + }) + published.tags.next = "7.2.0-dev.20261101.1" + assert.equal(publishedNativeCompilerSelection(published, "default").version, "7.2.0-dev.20261101.1") + published.tags.latest = "7.1.0" + assert.deepEqual(publishedNativeCompilerSelection(published, "default"), { version: "7.1.0", channel: "stable" }) + assert.deepEqual(publishedNativeCompilerSelection(published, "nightly"), { + version: "7.2.0-dev.20261101.1", + channel: "nightly", + }) +}) + +test("native channel resolution rejects missing, incompatible and unlisted versions", () => { + for (const tags of [{}, { next: "7.1.0-beta" }, { next: "7.1.0-dev.20261031.1" }]) { + assert.throws( + () => publishedNativeCompilerSelection({ versions: ["7.0.2"], tags }, "default"), + /No published TypeScript version supports/ + ) + } + assert.throws( + () => publishedNativeCompilerSelection({ versions: ["7.1.0"], tags: { latest: "7.1.0" } }, "nightly"), + /No published TypeScript version supports/ + ) +}) + +test("version choices sort supported releases and the resolved Nightly by descending semver", () => { + const choices = compilerVersionChoices(["7.0.2", "5.9.2", "6.0.2", "3.1.6", "5.9.3", "6.0.3"], { + versions: ["7.0.2", "7.1.0-beta", "7.1.0-dev.20261030.1", "7.1.1", "7.1.2", "7.2.0-dev.20261101.1"], + tags: { next: "7.2.0-dev.20261101.1", beta: "7.1.0-beta", rc: "7.0.1-rc" }, + }) + assert.deepEqual(choices, [ + { version: "next", label: "Nightly (7.2.0-dev.20261101.1)" }, + { version: "7.1.2", label: "7.1.2" }, + { version: "6.0.3", label: "6.0.3" }, + { version: "5.9.3", label: "5.9.3" }, + ]) + const sameNightly = compilerVersionChoices([], { + versions: ["7.1.0-dev.20261030.1"], + tags: { next: "7.1.0-dev.20261030.1" }, + }) + assert.deepEqual(sameNightly, [{ version: "next", label: "Nightly (7.1.0-dev.20261030.1)" }]) +}) + +test("version choices offer the applicable beta or RC without unsupported or superseded candidates", () => { + const published = { + versions: ["7.0.2", "7.1.0-beta", "7.1.0", "7.2.0-beta", "7.2.0-rc", "7.3.0-dev.20261101.1"], + tags: { latest: "7.1.0", beta: "7.2.0-beta", rc: "7.2.0-rc", next: "7.3.0-dev.20261101.1" }, + } + assert.deepEqual(compilerVersionChoices(["7.0.2", "6.0.3"], published), [ + { version: "next", label: "Nightly (7.3.0-dev.20261101.1)" }, + { version: "7.2.0-rc", label: "RC (7.2.0-rc)" }, + { version: "7.1.0", label: "7.1.0" }, + { version: "6.0.3", label: "6.0.3" }, + ]) + assert.deepEqual( + compilerVersionChoices(["6.0.3", "5.9.3"], published, "5.9.2").map(choice => choice.version), + ["next", "7.2.0-rc", "7.1.0", "6.0.3", "5.9.3", "5.9.2"] + ) + published.versions.push("7.2.0") + published.tags.latest = "7.2.0" + assert(!compilerVersionChoices([], published).some(choice => /Beta|RC/.test(choice.label))) + for (const version of ["7.0.2", "7.0.1-rc", "7.1.0-beta", "7.1.0-dev.20261029.1"]) { + assert.equal(isUnsupportedNativeCompilerVersion(version), true) + } + for (const version of ["6.0.3", "7.1.0-dev.20261031.1", "7.1.0", "7.2.0-beta"]) { + assert.equal(isUnsupportedNativeCompilerVersion(version), false) + } +}) + +test("version ordering follows semver even when a release is newer than the Nightly tag", () => { + const choices = compilerVersionChoices( + ["6.0.3", "5.10.1", "5.9.3"], + { + versions: ["7.1.0", "7.1.0-dev.20261030.1"], + tags: { latest: "7.1.0", next: "7.1.0-dev.20261030.1" }, + }, + "custom-build" + ) + assert.deepEqual( + choices.map(choice => choice.version), + ["7.1.0", "next", "6.0.3", "5.10.1", "5.9.3", "custom-build"] + ) + assert.deepEqual(compilerVersionChoices(["6.0.3"], undefined, "7.1.0-dev.20261030.1"), [ + { version: "7.1.0-dev.20261030.1", label: "7.1.0-dev.20261030.1" }, + { version: "6.0.3", label: "6.0.3" }, + ]) +}) + +test("native compiler reuses snapshots across edits, types, and configuration changes", async () => { + const module = await WebAssembly.compile( + await readFile(new URL(import.meta.resolve("@typescript/typescript-wasip1-wasm/lib/tsc.wasm"))) + ) + const transport = new WasmTransport({ instance: await instantiateWasm(module), cwd: "/workspace" }) + const libDirectory = new URL("./", import.meta.resolve("@typescript/typescript-wasip1-wasm/lib/libFiles.json")) + for (const name of await readdir(libDirectory)) { + if (/^lib(?:\..+)?\.d\.ts$/.test(name)) + transport.setFile(`/${name}`, await readFile(new URL(name, libDirectory), "utf8")) + } + const api = new API({ transport }) + const createProgram = api.createProgram + const readConfigFile = api.readConfigFile + const parseConfig = api.parseJsonConfigFileContent + let syntheticPrograms = 0 + let configReads = 0 + let configParses = 0 + Object.defineProperties(api, { + createProgram: { + value: (...args: Parameters) => { + syntheticPrograms++ + return createProgram(...args) + }, + }, + readConfigFile: { + value: (...args: Parameters) => { + configReads++ + return readConfigFile(...args) + }, + }, + parseJsonConfigFileContent: { + value: (...args: Parameters) => { + configParses++ + return parseConfig(...args) + }, + }, + }) + const compiler = new NativeCompiler(api, transport, ast) + const configFileName = "/workspace/tsconfig.json" + const source = "/workspace/src/index.ts" + const config = { + compilerOptions: { strict: true, target: "es2022", module: "commonjs", declaration: true }, + include: ["src/**/*"], + } + const input: NativeCompileInput = { + configFileName, + sourceFiles: [source], + files: { + [configFileName]: JSON.stringify(config), + [source]: "const answer = 42;\n// ^?\n", + }, + } + try { + const first = compiler.compile(input) + assert.equal(first.diagnostics.length, 0) + assert.match(first.outputFiles["/workspace/src/index.js"], /42/) + assert(first.typeQueries[source].some(query => query.label === "42")) + + input.files[source] = 'const answer: number = "wrong";\n// ^?\n' + const changed = compiler.compile(input) + assert(changed.diagnostics.some(diagnostic => diagnostic.code === 2322)) + assert.match(changed.outputFiles["/workspace/src/index.js"], /wrong/) + assert(changed.typeQueries[source].some(query => query.label === "number")) + + input.files[source] = 'import { value } from "pkg";\nconst answer: string = value;' + assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.code === 2307)) + input.files["/workspace/node_modules/pkg/package.json"] = '{"name":"pkg","types":"index.d.ts"}' + input.files["/workspace/node_modules/pkg/index.d.ts"] = "export const value: string;" + assert.equal(compiler.compile(input).diagnostics.length, 0) + delete input.files["/workspace/node_modules/pkg/package.json"] + delete input.files["/workspace/node_modules/pkg/index.d.ts"] + assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.code === 2307)) + + input.files[source] = "const answer = 1;" + input.files[configFileName] = JSON.stringify({ + ...config, + compilerOptions: { ...config.compilerOptions, noEmit: true }, + }) + assert.deepEqual(compiler.compile(input).outputFiles, {}) + input.files[configFileName] = JSON.stringify(config) + assert.match(compiler.compile(input).outputFiles["/workspace/src/index.js"], /1/) + + const unopened = "/workspace/src/unopened.ts" + input.files[unopened] = 'export const unopenedValue: number = "wrong";' + const wholeProject = compiler.compile(input) + assert(wholeProject.diagnostics.some(diagnostic => diagnostic.code === 2322 && diagnostic.fileName === unopened)) + delete input.files[unopened] + assert.equal(compiler.compile(input).diagnostics.length, 0) + + input.files[configFileName] = JSON.stringify({ + ...config, + compilerOptions: { ...config.compilerOptions, unknownCompilerOption: true }, + }) + assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.code === 5023)) + input.files[configFileName] = '{"compilerOptions": { "strict": true ' + assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.fileName === configFileName)) + input.files[configFileName] = JSON.stringify(config) + assert.equal(compiler.compile(input).diagnostics.length, 0) + + input.files[configFileName] = JSON.stringify({ + ...config, + compilerOptions: { ...config.compilerOptions, noLib: true }, + }) + assert( + compiler + .compile(input) + .diagnostics.some(diagnostic => diagnostic.code === 2318 && diagnostic.text.includes("Array")) + ) + input.files[configFileName] = JSON.stringify({ + ...config, + compilerOptions: { ...config.compilerOptions, declaration: false, declarationMap: true }, + }) + assert(compiler.compile(input).diagnostics.some(diagnostic => diagnostic.code === 5069)) + input.files[configFileName] = JSON.stringify(config) + + const imported = "/workspace/src/imported.ts" + input.files[configFileName] = JSON.stringify({ + compilerOptions: config.compilerOptions, + files: ["./src/index.ts"], + }) + input.files[source] = 'import "./imported";\ndeclare global { const configuredGlobal: "configured"; }\n' + input.files[imported] = "const answer = configuredGlobal;\n// ^?\nexport {};\n" + input.sourceFiles = [source, imported] + const configuredImport = compiler.compile(input) + assert.equal(configuredImport.diagnostics.length, 0) + assert(configuredImport.typeQueries[imported].some(query => query.label === '"configured"')) + assert.equal(syntheticPrograms, 0) + delete input.files[imported] + input.sourceFiles = [source] + input.files[source] = "const answer = 1;" + input.files[configFileName] = JSON.stringify(config) + + assert.equal(configReads, 0) + assert.equal(configParses, 0) + const orphan = "/workspace/orphan.ts" + input.sourceFiles = [source, orphan] + input.files[orphan] = "const orphanValue = true;" + assert.equal(compiler.compile(input).typeQueries[orphan], undefined) + assert.equal(syntheticPrograms, 0) + + const orphanHelper = "/workspace/orphan-helper.ts" + input.files[orphanHelper] = "declare const orphanSource: true;" + input.sourceFiles = [source, orphan, orphanHelper] + input.files[orphan] = "const orphanValue = orphanSource;\n// ^?\n" + assert(compiler.compile(input).typeQueries[orphan].some(query => query.label.includes("true"))) + assert.equal(syntheticPrograms, 0) + + const linked: NativeCompileInput = { + configFileName, + sourceFiles: [source], + files: { + [configFileName]: JSON.stringify({ + compilerOptions: { ...config.compilerOptions, module: "nodenext" }, + files: ["src/index.ts"], + }), + [source]: + 'import { value } from "./value"; import { Token as Direct } from "../packages/pkg"; import { Token as Linked } from "pkg"; const token: Direct = new Linked(); const result: number = value;', + "/workspace/shared/value.ts": "export const value = 42;", + "/workspace/packages/pkg/index.ts": "export class Token { private brand = 1; }", + "/workspace/packages/pkg/package.json": '{"name":"pkg","types":"index.ts"}', + }, + symlinks: { "/workspace/src/value.ts": "../shared/value.ts", "/workspace/node_modules/pkg": "../packages/pkg" }, + } + assert.deepEqual(compiler.compile(linked).diagnostics, []) + linked.files["/workspace/shared/value.ts"] = 'export const value = "changed";' + assert( + compiler.compile(linked).diagnostics.some(diagnostic => diagnostic.code === 2322), + "Edits to linked targets must update their importers" + ) + linked.files["/workspace/shared/value.ts"] = "export const value = 42;" + linked.files[configFileName] = JSON.stringify({ + compilerOptions: { ...config.compilerOptions, module: "nodenext", preserveSymlinks: true }, + files: ["src/index.ts"], + }) + assert( + compiler.compile(linked).diagnostics.some(diagnostic => diagnostic.code === 2322), + "Changing preserveSymlinks must update linked package identity" + ) + linked.sourceFiles = [source, orphan] + linked.files[orphan] = 'import { Token } from "pkg";\nconst linkedToken = new Token();\n// ^?\n' + assert(compiler.compile(linked).typeQueries[orphan].some(query => query.label === "Token")) + linked.symlinks = { "/workspace/src/value.ts": "../shared/value.ts" } + assert(compiler.compile(linked).diagnostics.some(diagnostic => diagnostic.code === 2307)) + } finally { + compiler.close() + } +}) diff --git a/packages/playground-v2/src/native-compiler.ts b/packages/playground-v2/src/native-compiler.ts new file mode 100644 index 000000000000..45db1f90548b --- /dev/null +++ b/packages/playground-v2/src/native-compiler.ts @@ -0,0 +1,190 @@ +import type { API, Diagnostic, FileNotifications, Snapshot } from "@typescript/typescript/sync" +import type * as Ast from "@typescript/typescript/ast" +import type { Node } from "@typescript/typescript/ast" +import type { WasmTransport } from "@typescript/typescript/wasm" +import { TextDocument } from "vscode-languageserver-textdocument" +import { corsaDiscovery, type Dependency, type SourceFile } from "./ata" +import type { Symlinks } from "./symlinks" + +export type NativeCompileInput = { + files: Record + configFileName: string + sourceFiles: readonly string[] + symlinks?: Symlinks +} + +export type NativeTypeQuery = { + lineNumber: number + column: number + label: string +} + +export type NativeCompileResult = { + diagnostics: readonly Diagnostic[] + outputFiles: Record + typeQueries: Record +} + +export class NativeCompiler { + private snapshot: Snapshot | undefined + private readonly files = new Map() + private symlinkKey = "" + private readonly discoverDependencies: ReturnType + + constructor(private readonly api: API, private readonly transport: WasmTransport, ast: typeof Ast) { + this.discoverDependencies = corsaDiscovery(api, ast) + } + + discover(file: SourceFile): readonly Dependency[] { + return this.discoverDependencies(file) + } + + compile(input: NativeCompileInput): NativeCompileResult { + const symlinkKey = JSON.stringify(input.symlinks ?? {}) + if (this.snapshot && this.symlinkKey !== symlinkKey) { + this.snapshot.dispose() + this.snapshot = undefined + } + this.symlinkKey = symlinkKey + const symlinks = Object.fromEntries( + Object.entries(input.symlinks ?? {}).map(([path, target]) => [path, { target }]) + ) + const changed: string[] = [] + const created: string[] = [] + const deleted: string[] = [] + for (const [fileName, text] of Object.entries(input.files)) { + if (this.files.get(fileName) === text) continue + this.transport.setFile(fileName, text) + if (this.files.has(fileName)) changed.push(fileName) + else created.push(fileName) + } + for (const fileName of this.files.keys()) { + if (fileName in input.files) continue + this.transport.removeFile(fileName) + deleted.push(fileName) + } + const notifications: FileNotifications = { changed, created, deleted } + const next = this.snapshot + ? this.snapshot.update({ + ensurePrograms: true, + fileNotifications: notifications, + fileSystem: { + kind: "layer", + files: Object.fromEntries([...changed, ...created].map(fileName => [fileName, input.files[fileName]])), + removedPaths: deleted, + }, + }) + : this.api.createSnapshot({ + openProjects: [input.configFileName], + fileSystem: { kind: "layer", files: input.files, symlinks }, + }) + const previous = this.snapshot + this.snapshot = next + if (previous && previous !== next) previous.dispose() + this.files.clear() + for (const [fileName, text] of Object.entries(input.files)) this.files.set(fileName, text) + + const project = next.getConfiguredProject(input.configFileName) + const config = project ? undefined : this.api.readConfigFile(input.configFileName) + const parsed = + project?.parsedCommandLine ?? + this.api.parseJsonConfigFileContent(config?.config, { configFileName: input.configFileName }) + const configuredProgram = project && next.getProgram(project.id) + const program = + configuredProgram ?? + this.api.createProgram(parsed.fileNames, parsed.options, { + projectReferences: parsed.projectReferences, + configFileParsingDiagnostics: parsed.errors, + }) + try { + const emit = program.emitToString() + const diagnostics = [ + ...(config?.error ? [config.error] : []), + ...parsed.errors, + ...program.getProgramDiagnostics(), + ...program.getGlobalDiagnostics(), + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ...program.getConfigFileParsingDiagnostics(), + ...emit.diagnostics, + ] + const typeQueries: Record = {} + const configuredFiles = input.sourceFiles.filter(fileName => program.getSourceFile(fileName) !== undefined) + this.collectTypeQueries(program, configuredFiles, input.files, typeQueries) + const configuredFileNames = new Set(configuredFiles) + const orphanFiles = input.sourceFiles.filter(fileName => !configuredFileNames.has(fileName)) + if (orphanFiles.some(fileName => hasTypeQuery(input.files[fileName]))) { + const inferred = next.update({ + createPrograms: [{ rootFiles: orphanFiles, compilerOptions: { ...parsed.options, allowJs: true } }], + }) + try { + this.collectTypeQueries(inferred.operation.createdPrograms[0], orphanFiles, input.files, typeQueries) + } finally { + inferred.dispose() + } + } + return { + diagnostics, + outputFiles: Object.fromEntries([...emit.outputFiles].map(([fileName, output]) => [fileName, output.text])), + typeQueries, + } + } finally { + if (!configuredProgram) program.dispose() + } + } + + close() { + this.snapshot?.dispose() + this.snapshot = undefined + this.api.close() + } + + private collectTypeQueries( + program: ReturnType, + fileNames: readonly string[], + files: Record, + result: Record + ) { + for (const fileName of fileNames) { + const source = files[fileName] + if (!hasTypeQuery(source)) continue + const sourceFile = program.getSourceFile(fileName) + if (!sourceFile) continue + const document = TextDocument.create(fileName, "typescript", 0, source) + const queries: NativeTypeQuery[] = [] + const pattern = /^\s*\/\/\s*\^\?\s*$/gm + let match: RegExpExecArray | null + while ((match = pattern.exec(source))) { + const position = document.positionAt(match.index + match[0].lastIndexOf("?")) + if (position.line === 0) continue + const offset = document.offsetAt({ line: position.line - 1, character: position.character }) + const node = findNodeAtPosition(sourceFile, offset) ?? findNodeAtPosition(sourceFile, Math.max(0, offset - 1)) + if (!node) continue + const checker = program.getProject().checker + const typeText = checker.typeToString(checker.getTypeAtLocation(node), node).replace(/\r?\n\s*/g, " ") + queries.push({ + lineNumber: position.line + 1, + column: position.character + 2, + label: typeText.length > 120 ? `${typeText.slice(0, 119)}…` : typeText, + }) + } + result[fileName] = queries + } + } +} + +function hasTypeQuery(source: string | undefined): source is string { + return source !== undefined && /^\s*\/\/\s*\^\?\s*$/m.test(source) +} + +function findNodeAtPosition(node: Node, position: number): Node | undefined { + if (position < node.getFullStart() || position > node.getEnd()) return undefined + let match: Node | undefined + node.forEachChild(child => { + const descendant = findNodeAtPosition(child, position) + if (!descendant) return undefined + match = descendant + return true + }) + return match ?? node +} diff --git a/packages/playground-v2/src/native.ts b/packages/playground-v2/src/native.ts new file mode 100644 index 000000000000..384b40ea3ab6 --- /dev/null +++ b/packages/playground-v2/src/native.ts @@ -0,0 +1,109 @@ +import type { Dependency, SourceFile } from "./ata" +import type { NativeCompileInput, NativeCompileResult } from "./native-compiler" +import type { CompilerModules } from "./compiler-distribution" + +type PendingRequest = { + resolve(value: unknown): void + reject(error: Error): void +} + +type QueuedCompile = { + input: NativeCompileInput + waiters: Array<{ resolve(value: NativeCompileResult): void; reject(error: Error): void }> +} + +export class NativeBackend { + diagnosticCategories!: typeof import("@typescript/typescript/sync").DiagnosticCategory + private readonly worker = new Worker(new URL("./native.worker.js", import.meta.url), { type: "module" }) + private readonly pending = new Map() + private nextId = 0 + private compiling = false + private queuedCompile: QueuedCompile | undefined + private failure: Error | undefined + + private constructor() { + this.worker.addEventListener("message", (event: MessageEvent<{ id: number; result?: unknown; error?: string }>) => { + const pending = this.pending.get(event.data.id) + if (!pending) return + this.pending.delete(event.data.id) + if (event.data.error !== undefined) pending.reject(new Error(event.data.error)) + else pending.resolve(event.data.result) + }) + this.worker.addEventListener("error", event => { + this.failure = new Error(event.message || "Native compiler worker failed") + for (const request of this.pending.values()) request.reject(this.failure) + this.pending.clear() + }) + this.worker.addEventListener("messageerror", () => { + this.failure = new Error("Could not decode the native compiler worker response") + for (const request of this.pending.values()) request.reject(this.failure) + this.pending.clear() + }) + } + + static async create(module: WebAssembly.Module, libraries: Record, modules: CompilerModules) { + const backend = new NativeBackend() + try { + const { sync, async, ast, host } = modules + const result = await backend.request<{ diagnosticCategories: NativeBackend["diagnosticCategories"] }>("init", { + module, + libraries, + modules: { sync, async, ast, host }, + }) + backend.diagnosticCategories = result.diagnosticCategories + return backend + } catch (error) { + backend.worker.terminate() + throw error + } + } + + discover(file: SourceFile): Promise { + return this.request("discover", file) + } + + compile(input: NativeCompileInput): Promise { + return new Promise((resolve, reject) => { + if (this.queuedCompile) { + this.queuedCompile.input = input + this.queuedCompile.waiters.push({ resolve, reject }) + } else { + this.queuedCompile = { input, waiters: [{ resolve, reject }] } + } + if (!this.compiling) void this.runCompiles() + }) + } + + private async runCompiles() { + this.compiling = true + try { + while (this.queuedCompile) { + const compile = this.queuedCompile + this.queuedCompile = undefined + try { + const result = await this.request("compile", compile.input) + for (const waiter of compile.waiters) waiter.resolve(result) + } catch (error) { + const failure = error instanceof Error ? error : new Error(String(error)) + for (const waiter of compile.waiters) waiter.reject(failure) + } + } + } finally { + this.compiling = false + } + } + + private request(method: string, args: unknown): Promise { + if (this.failure) return Promise.reject(this.failure) + const id = ++this.nextId + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve: value => resolve(value as T), reject }) + try { + this.worker.postMessage({ id, method, args }) + } catch (error) { + this.pending.delete(id) + reject(error) + } + }) + } +} diff --git a/packages/playground-v2/src/native.worker.ts b/packages/playground-v2/src/native.worker.ts new file mode 100644 index 000000000000..38b8c0632ff5 --- /dev/null +++ b/packages/playground-v2/src/native.worker.ts @@ -0,0 +1,46 @@ +import type * as Sync from "@typescript/typescript/sync" +import type * as Host from "@typescript/typescript/wasm" +import type * as Ast from "@typescript/typescript/ast" +import type { CompilerModules } from "./compiler-distribution" +import type { SourceFile } from "./ata" +import { NativeCompiler, type NativeCompileInput } from "./native-compiler" + +type Message = + | { + id: number + method: "init" + args: { module: WebAssembly.Module; libraries: Record; modules: CompilerModules } + } + | { id: number; method: "compile"; args: NativeCompileInput } + | { id: number; method: "discover"; args: SourceFile } + +let compiler: NativeCompiler | undefined + +self.addEventListener("message", (event: MessageEvent) => { + void handle(event.data) +}) + +async function handle(message: Message) { + try { + let result: unknown + if (message.method === "init") { + const [sync, host, ast]: [typeof Sync, typeof Host, typeof Ast] = await Promise.all([ + import(message.args.modules.sync), + import(message.args.modules.host), + import(message.args.modules.ast), + ]) + const { API } = sync + const { instantiateWasm, WasmTransport } = host + const transport = new WasmTransport({ instance: await instantiateWasm(message.args.module), cwd: "/workspace" }) + for (const [fileName, text] of Object.entries(message.args.libraries)) transport.setFile(fileName, text) + compiler = new NativeCompiler(new API({ transport }), transport, ast) + result = { diagnosticCategories: sync.DiagnosticCategory } + } else { + if (!compiler) throw new Error("Native compiler worker is not initialized") + result = message.method === "compile" ? compiler.compile(message.args) : compiler.discover(message.args) + } + self.postMessage({ id: message.id, result }) + } catch (error) { + self.postMessage({ id: message.id, error: error instanceof Error ? error.stack ?? error.message : String(error) }) + } +} diff --git a/packages/playground-v2/src/strada-libraries.ts b/packages/playground-v2/src/strada-libraries.ts new file mode 100644 index 000000000000..ff5596445fca --- /dev/null +++ b/packages/playground-v2/src/strada-libraries.ts @@ -0,0 +1,51 @@ +export type StradaLibraryProgress = { + completed: number + total: number + fileName?: string +} + +export async function loadStradaLibraries( + roots: readonly string[], + baseUrl: string, + libraries: Map, + onProgress: (progress: StradaLibraryProgress) => void +) { + const queue: string[] = [] + const queued = new Set() + let completed = 0 + function enqueue(fileName: string) { + const normalized = fileName.startsWith("lib.") ? fileName : `lib.${fileName}.d.ts` + if (queued.has(normalized)) return + queued.add(normalized) + queue.push(normalized) + } + for (const root of roots) enqueue(root) + onProgress({ completed, total: queue.length }) + for (let next = 0; next < queue.length; ) { + const batch = queue.slice(next, next + 8) + next += batch.length + await Promise.all( + batch.map(async fileName => { + const path = `/typescript/lib/${fileName}` + let text = libraries.get(path) + if (text === undefined) { + const response = await fetch(`${baseUrl}${fileName}`) + if (!response.ok) { + if (response.status !== 404) throw new Error(`Could not load ${fileName}: ${response.status}`) + } else { + text = await response.text() + libraries.set(path, text) + } + } + if (text !== undefined) { + for (const match of text.matchAll(/ { files: string[]; directories: string[] }, + realpath: (path: string) => string, + directoryExists: (path: string) => boolean + ): string[] +} + +function hasFileMatcher(compiler: Compiler): compiler is CompilerWithFileMatcher { + return "matchFiles" in compiler && typeof compiler.matchFiles === "function" +} + +export const normalizeStradaPath = normalizeVirtualPath + +export function parseStradaConfig( + compiler: Compiler, + filesystem: ReturnType, + configFileName = "/workspace/tsconfig.json" +) { + const config = compiler.parseConfigFileTextToJson(configFileName, filesystem.readFile(configFileName) ?? "{}") + const parsed = compiler.parseJsonConfigFileContent( + config.config ?? {}, + filesystem, + configFileName.slice(0, configFileName.lastIndexOf("/")), + undefined, + configFileName + ) + return { + errors: [...(config.error ? [config.error] : []), ...parsed.errors], + options: parsed.options, + rootNames: parsed.fileNames, + projectReferences: parsed.projectReferences, + } +} + +export function createStradaFileSystem( + compiler: Compiler, + fileNames: () => Iterable, + readFile: (fileName: string) => string | undefined, + symlinks: Symlinks = {} +) { + if (!hasFileMatcher(compiler)) throw new Error("The selected compiler does not provide directory matching") + const matchFiles = compiler.matchFiles + const realpath = (path: string) => resolveSymlinks(path, symlinks) ?? normalizeVirtualPath(path) + function directoryExists(directory: string) { + const resolved = resolveSymlinks(directory, symlinks) + if (!resolved) return false + const prefix = `${resolved.replace(/\/$/, "")}/` + return [...fileNames(), ...Object.keys(symlinks)].some(path => path.startsWith(prefix)) + } + function entries(directory: string) { + const prefix = `${realpath(directory).replace(/\/$/, "")}/` + const files = new Set() + const directories = new Set() + for (const name of [...fileNames(), ...Object.keys(symlinks)]) { + const path = normalizeStradaPath(name) + if (!path.startsWith(prefix)) continue + const relative = path.slice(prefix.length) + const slash = relative.indexOf("/") + if (slash < 0 && symlinks[path] !== undefined && directoryExists(path)) directories.add(relative) + else if (slash < 0) files.add(relative) + else directories.add(relative.slice(0, slash)) + } + return { files: [...files].sort(), directories: [...directories].sort() } + } + const readDirectory = ( + directory: string, + extensions?: readonly string[], + excludes?: readonly string[], + includes?: readonly string[], + depth?: number + ): string[] => + matchFiles( + normalizeStradaPath(directory), + extensions, + excludes, + includes, + true, + "/workspace", + depth, + entries, + realpath, + directoryExists + ) + return { + useCaseSensitiveFileNames: true, + fileExists: (fileName: string) => { + const path = resolveSymlinks(fileName, symlinks) + return path !== undefined && readFile(path) !== undefined + }, + readFile: (fileName: string) => { + const path = resolveSymlinks(fileName, symlinks) + return path === undefined ? undefined : readFile(path) + }, + readDirectory, + directoryExists, + getDirectories: (directory: string) => entries(directory).directories, + realpath, + } +} diff --git a/packages/playground-v2/src/strada.ts b/packages/playground-v2/src/strada.ts new file mode 100644 index 000000000000..ee2fe0cd1649 --- /dev/null +++ b/packages/playground-v2/src/strada.ts @@ -0,0 +1,689 @@ +import { monaco } from "./tsgo-lsp" +import type { StradaLibraryProgress } from "./strada-libraries" +import { isSourceLanguage, languageForFile, sourceLanguages } from "./editor-languages" +import { resolveSymlinks, type Symlinks } from "./symlinks" + +type CompilerDiagnostic = { + category: number + code: number + end: number + fileName?: string + pos: number + text: string +} + +type CompileResult = { + diagnostics: CompilerDiagnostic[] + emitSkipped: boolean + outputFiles: Record + rootNames: string[] +} + +export type StradaLoadingProgress = { phase: "initializing" } | ({ phase: "libraries" } & StradaLibraryProgress) + +type WorkerMessage = + | { id: number; result?: unknown; error?: string } + | { type: "progress"; progress: StradaLoadingProgress } + +type StradaBackendOptions = { + compilerSource: string + files(): Record + models: Map + onCreateExternalModel(model: monaco.editor.ITextModel): void + onProgress?(progress: StradaLoadingProgress): void + version: string + baseUrl: string + symlinks?: Symlinks +} + +export class StradaBackend { + readonly version: string + readonly #worker = new Worker(new URL("./strada.worker.js", import.meta.url), { + type: "module", + }) + readonly #pending = new Map< + number, + { + resolve(value: any): void + reject(error: Error): void + } + >() + readonly #disposables: monaco.IDisposable[] = [] + readonly #options: StradaBackendOptions + #nextId = 1 + #failure: Error | undefined + #files = new Map() + #sourceFiles: string[] = [] + #synchronization = Promise.resolve() + + private constructor(options: StradaBackendOptions) { + this.#options = options + this.version = options.version + this.#worker.addEventListener("message", (event: MessageEvent) => { + if (this.#failure) return + const message = event.data + if ("type" in message) { + this.#options.onProgress?.(message.progress) + return + } + const pending = this.#pending.get(message.id) + if (!pending) return + this.#pending.delete(message.id) + if (message.error !== undefined) pending.reject(new Error(message.error)) + else pending.resolve(message.result) + }) + this.#worker.addEventListener("error", event => { + this.#fail(new Error(event.message || "Legacy compiler worker failed")) + }) + this.#worker.addEventListener("messageerror", () => { + this.#fail(new Error("Could not decode the legacy compiler worker response")) + }) + } + + static async create(options: StradaBackendOptions) { + const backend = new StradaBackend(options) + try { + const files = { ...options.files() } + const sourceFiles = backend.#sourceFileNames() + await backend.#request("init", { + baseUrl: options.baseUrl, + compilerSource: options.compilerSource, + files, + sourceFiles, + version: options.version, + symlinks: options.symlinks, + }) + backend.#files = new Map(Object.entries(files)) + backend.#sourceFiles = sourceFiles + backend.#registerLanguageFeatures() + return backend + } catch (error) { + backend.dispose() + throw error + } + } + + async compile() { + await this.updateFiles() + return this.#request("compile", {}) + } + + updateFiles() { + const synchronize = () => this.#synchronizeFiles() + this.#synchronization = this.#synchronization.then(synchronize, synchronize) + return this.#synchronization + } + + async #synchronizeFiles() { + if (this.#failure) throw this.#failure + const nextFiles = { ...this.#options.files() } + const sourceFiles = this.#sourceFileNames() + const files: Record = {} + const deleted: string[] = [] + for (const [fileName, text] of Object.entries(nextFiles)) { + if (this.#files.get(fileName) !== text) files[fileName] = text + } + for (const fileName of this.#files.keys()) { + if (!(fileName in nextFiles)) deleted.push(fileName) + } + if ( + Object.keys(files).length === 0 && + deleted.length === 0 && + sourceFiles.length === this.#sourceFiles.length && + sourceFiles.every((fileName, index) => fileName === this.#sourceFiles[index]) + ) + return + await this.#request("updateFiles", { files, deleted, sourceFiles }) + this.#files = new Map(Object.entries(nextFiles)) + this.#sourceFiles = sourceFiles + } + + #sourceFileNames() { + return [...this.#options.models.values()] + .filter(model => isSourceLanguage(model.getLanguageId())) + .map(model => model.uri.path) + } + + async quickInfo(fileName: string, position: number) { + return this.#request("quickInfo", { fileName, position }) + } + + async readFile(fileName: string) { + return this.#request("readFile", { fileName }) + } + + async #formatOptions(model: monaco.editor.ITextModel) { + const options = model.getOptions() + return { + ConvertTabsToSpaces: options.insertSpaces, + IndentSize: options.indentSize, + IndentStyle: 2, + InsertSpaceAfterCommaDelimiter: true, + InsertSpaceAfterFunctionKeywordForAnonymousFunctions: true, + InsertSpaceAfterKeywordsInControlFlowStatements: true, + InsertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true, + InsertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false, + InsertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false, + InsertSpaceAfterSemicolonInForStatements: true, + InsertSpaceBeforeAndAfterBinaryOperators: true, + NewLineCharacter: model.getEOL(), + PlaceOpenBraceOnNewLineForControlBlocks: false, + PlaceOpenBraceOnNewLineForFunctions: false, + TabSize: options.tabSize, + baseIndentSize: 0, + convertTabsToSpaces: options.insertSpaces, + indentSize: options.indentSize, + indentStyle: 2, + insertSpaceAfterCommaDelimiter: true, + insertSpaceAfterConstructor: false, + insertSpaceAfterFunctionKeywordForAnonymousFunctions: true, + insertSpaceAfterKeywordsInControlFlowStatements: true, + insertSpaceAfterOpeningAndBeforeClosingEmptyBraces: false, + insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true, + insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false, + insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false, + insertSpaceAfterSemicolonInForStatements: true, + insertSpaceAfterTypeAssertion: false, + insertSpaceBeforeAndAfterBinaryOperators: true, + insertSpaceBeforeFunctionParenthesis: false, + newLineCharacter: model.getEOL(), + placeOpenBraceOnNewLineForControlBlocks: false, + placeOpenBraceOnNewLineForFunctions: false, + semicolons: "ignore", + tabSize: options.tabSize, + trimTrailingWhitespace: true, + } + } + + dispose() { + this.#fail(new Error("Legacy compiler worker was disposed")) + this.#disposables.forEach(disposable => disposable.dispose()) + this.#worker.terminate() + } + + #fail(error: Error) { + this.#failure ??= error + for (const pending of this.#pending.values()) pending.reject(this.#failure) + this.#pending.clear() + } + + #registerLanguageFeatures() { + for (const language of sourceLanguages) { + this.#disposables.push( + monaco.languages.registerCompletionItemProvider(language, { + triggerCharacters: [".", '"', "'", "/", "@", "<", "#"], + provideCompletionItems: async (model, position, context) => { + await this.updateFiles() + const result = await this.#request("completions", { + fileName: model.uri.path, + options: { triggerCharacter: context.triggerCharacter }, + position: model.getOffsetAt(position), + }) + const word = model.getWordUntilPosition(position) + const range = new monaco.Range(position.lineNumber, word.startColumn, position.lineNumber, word.endColumn) + const suggestions = (result?.entries ?? []).map((entry: any) => { + const replacementSpan = entry.replacementSpan ?? result.optionalReplacementSpan + const suggestion: monaco.languages.CompletionItem & { + strada?: { + data: any + fileName: string + name: string + position: number + source: string | undefined + } + } = { + detail: entry.labelDetails?.description, + filterText: entry.filterText, + insertText: entry.insertText ?? entry.name, + kind: completionKind(entry.kind), + label: entry.name, + range: replacementSpan ? spanToRange(model, replacementSpan) : range, + sortText: entry.sortText, + } + suggestion.strada = { + data: entry.data, + fileName: model.uri.path, + name: entry.name, + position: model.getOffsetAt(position), + source: entry.source, + } + return suggestion + }) + return { + incomplete: result?.isIncomplete, + suggestions, + } + }, + resolveCompletionItem: async item => { + const metadata = ( + item as typeof item & { + strada?: { + data: any + fileName: string + name: string + position: number + source: string | undefined + } + } + ).strada + if (!metadata) return item + const details = await this.#request("completionDetails", metadata) + if (!details) return item + item.detail = displayParts(details.displayParts) + item.documentation = { + value: displayParts(details.documentation), + } + const changes = (details.codeActions ?? []).flatMap((action: any) => action.changes ?? []) + item.additionalTextEdits = changes + .filter((change: any) => normalizePath(change.fileName) === normalizePath(metadata.fileName)) + .flatMap((change: any) => + change.textChanges.map((textChange: any) => ({ + range: spanToRange(this.#options.models.get(metadata.fileName)!, textChange.span), + text: textChange.newText, + })) + ) + return item + }, + }), + monaco.languages.registerHoverProvider(language, { + provideHover: async (model, position) => { + await this.updateFiles() + const info = await this.quickInfo(model.uri.path, model.getOffsetAt(position)) + if (!info) return undefined + return { + contents: [ + { + value: `\`\`\`typescript\n${displayParts(info.displayParts)}\n\`\`\``, + }, + { + value: displayParts(info.documentation), + }, + ], + range: spanToRange(model, info.textSpan), + } + }, + }), + monaco.languages.registerDefinitionProvider(language, { + provideDefinition: async (model, position) => { + await this.updateFiles() + const result = await this.#request("definitions", { + fileName: model.uri.path, + position: model.getOffsetAt(position), + }) + if (!result?.definitions) return undefined + const locations = [] + for (const definition of result.definitions) { + const target = await this.#ensureModel(definition.fileName) + if (!target) continue + locations.push({ + range: spanToRange(target, definition.textSpan), + uri: target.uri, + }) + } + return locations + }, + }), + monaco.languages.registerSignatureHelpProvider(language, { + signatureHelpTriggerCharacters: ["(", ",", "<"], + provideSignatureHelp: async (model, position) => { + await this.updateFiles() + const help = await this.#request("signatureHelp", { + fileName: model.uri.path, + options: { + triggerReason: { + kind: "invoked", + }, + }, + position: model.getOffsetAt(position), + }) + if (!help) return undefined + return { + dispose() {}, + value: { + activeParameter: help.argumentIndex, + activeSignature: help.selectedItemIndex, + signatures: help.items.map((item: any) => ({ + documentation: displayParts(item.documentation), + label: [ + displayParts(item.prefixDisplayParts), + item.parameters + .map((parameter: any) => displayParts(parameter.displayParts)) + .join(displayParts(item.separatorDisplayParts)), + displayParts(item.suffixDisplayParts), + ].join(""), + parameters: item.parameters.map((parameter: any) => ({ + documentation: displayParts(parameter.documentation), + label: displayParts(parameter.displayParts), + })), + })), + }, + } + }, + }), + monaco.languages.registerReferenceProvider(language, { + provideReferences: async (model, position) => { + await this.updateFiles() + const references = await this.#request("references", { + fileName: model.uri.path, + position: model.getOffsetAt(position), + }) + if (!references) return [] + const locations = [] + for (const reference of references) { + const target = await this.#ensureModel(reference.fileName) + if (!target) continue + locations.push({ + range: spanToRange(target, reference.textSpan), + uri: target.uri, + }) + } + return locations + }, + }), + monaco.languages.registerDocumentHighlightProvider(language, { + provideDocumentHighlights: async (model, position) => { + await this.updateFiles() + const results = await this.#request("occurrences", { + fileName: model.uri.path, + position: model.getOffsetAt(position), + }) + return (results ?? []).map((occurrence: any) => ({ + kind: occurrence.isWriteAccess + ? monaco.languages.DocumentHighlightKind.Write + : monaco.languages.DocumentHighlightKind.Text, + range: spanToRange(model, occurrence.textSpan), + })) + }, + }), + monaco.languages.registerDocumentSymbolProvider(language, { + provideDocumentSymbols: async model => { + await this.updateFiles() + const items = await this.#request("navigationBarItems", { + fileName: model.uri.path, + }) + return items?.flatMap((item: any) => navigationSymbols(model, item)) ?? [] + }, + }), + monaco.languages.registerRenameProvider(language, { + resolveRenameLocation: async (model, position) => { + await this.updateFiles() + const info = await this.#request("renameInfo", { + fileName: model.uri.path, + position: model.getOffsetAt(position), + }) + if (!info?.canRename) { + return { + range: new monaco.Range(position.lineNumber, position.column, position.lineNumber, position.column), + rejectReason: info?.localizedErrorMessage ?? "This symbol cannot be renamed.", + text: "", + } + } + return { + range: spanToRange(model, info.triggerSpan), + text: model.getValueInRange(spanToRange(model, info.triggerSpan)), + } + }, + provideRenameEdits: async (model, position, newName) => { + await this.updateFiles() + const locations = await this.#request("renameLocations", { + fileName: model.uri.path, + position: model.getOffsetAt(position), + }) + if (!locations) return { edits: [], rejectReason: "This symbol cannot be renamed." } + const edits = [] + for (const location of locations) { + const target = await this.#ensureModel(location.fileName) + if (!target) continue + edits.push({ + resource: target.uri, + textEdit: { + range: spanToRange(target, location.textSpan), + text: `${location.prefixText ?? ""}${newName}${location.suffixText ?? ""}`, + }, + versionId: target.getVersionId(), + }) + } + return { edits } + }, + }), + monaco.languages.registerDocumentFormattingEditProvider(language, { + provideDocumentFormattingEdits: async model => { + await this.updateFiles() + const edits = await this.#request("formatDocument", { + fileName: model.uri.path, + options: await this.#formatOptions(model), + }) + return edits.map(edit => ({ + range: spanToRange(model, edit.span), + text: edit.newText, + })) + }, + }), + monaco.languages.registerDocumentRangeFormattingEditProvider(language, { + provideDocumentRangeFormattingEdits: async (model, range) => { + await this.updateFiles() + const edits = await this.#request("formatRange", { + end: model.getOffsetAt(range.getEndPosition()), + fileName: model.uri.path, + options: await this.#formatOptions(model), + start: model.getOffsetAt(range.getStartPosition()), + }) + return edits.map(edit => ({ + range: spanToRange(model, edit.span), + text: edit.newText, + })) + }, + }), + monaco.languages.registerOnTypeFormattingEditProvider(language, { + autoFormatTriggerCharacters: [";", "}", "\n"], + provideOnTypeFormattingEdits: async (model, position, ch) => { + await this.updateFiles() + const edits = await this.#request("formatOnType", { + fileName: model.uri.path, + key: ch, + options: await this.#formatOptions(model), + position: model.getOffsetAt(position), + }) + return edits.map(edit => ({ + range: spanToRange(model, edit.span), + text: edit.newText, + })) + }, + }), + monaco.languages.registerCodeActionProvider(language, { + provideCodeActions: async (model, _range, context, token) => { + const empty = { actions: [], dispose() {} } + const version = model.getVersionId() + const markers = context.markers.filter(marker => marker.modelVersionId === version) + if (markers.length === 0) return empty + await this.updateFiles() + const formatOptions = await this.#formatOptions(model) + const actions = [] + for (const marker of markers) { + if (token.isCancellationRequested || model.isDisposed() || model.getVersionId() !== version) return empty + const code = typeof marker.code === "object" ? marker.code.value : marker.code + const errorCode = Number(String(code ?? "").replace(/^TS/, "")) + if (!Number.isFinite(errorCode)) continue + const fixes = await this.#request("codeFixes", { + end: model.getOffsetAt(new monaco.Position(marker.endLineNumber, marker.endColumn)), + errorCodes: [errorCode], + fileName: model.uri.path, + formatOptions, + preferences: {}, + start: model.getOffsetAt(new monaco.Position(marker.startLineNumber, marker.startColumn)), + }) + for (const fix of fixes) { + const edits = [] + for (const change of fix.changes ?? []) { + const target = await this.#ensureModel(change.fileName) + if (!target) continue + for (const textChange of change.textChanges) { + edits.push({ + resource: target.uri, + textEdit: { + range: spanToRange(target, textChange.span), + text: textChange.newText, + }, + versionId: target.getVersionId(), + }) + } + } + actions.push({ + diagnostics: [marker], + edit: { edits }, + isPreferred: fix.fixId !== undefined, + kind: "quickfix", + title: fix.description, + }) + } + } + if (token.isCancellationRequested || model.isDisposed() || model.getVersionId() !== version) return empty + return { actions, dispose() {} } + }, + }), + monaco.languages.registerInlayHintsProvider(language, { + provideInlayHints: async (model, range) => { + await this.updateFiles() + const start = model.getOffsetAt(range.getStartPosition()) + const end = model.getOffsetAt(range.getEndPosition()) + const hints = await this.#request("inlayHints", { + fileName: model.uri.path, + preferences: { + includeInlayEnumMemberValueHints: true, + includeInlayFunctionLikeReturnTypeHints: true, + includeInlayFunctionParameterTypeHints: true, + includeInlayParameterNameHints: "literals", + includeInlayParameterNameHintsWhenArgumentMatchesName: false, + includeInlayPropertyDeclarationTypeHints: true, + includeInlayVariableTypeHints: true, + includeInlayVariableTypeHintsWhenTypeMatchesName: false, + }, + span: { length: end - start, start }, + }) + return { + dispose() {}, + hints: hints.map(hint => ({ + kind: + hint.kind === "Type" ? monaco.languages.InlayHintKind.Type : monaco.languages.InlayHintKind.Parameter, + label: hint.text, + paddingLeft: hint.whitespaceBefore, + paddingRight: hint.whitespaceAfter, + position: model.getPositionAt(hint.position), + })), + } + }, + }) + ) + } + } + + async #ensureModel(fileName: string) { + const normalized = resolveSymlinks(normalizePath(fileName), this.#options.symlinks ?? {}) + if (normalized === undefined) return undefined + const existing = monaco.editor.getModel(monaco.Uri.file(normalized)) + if (existing) return existing + const text = await this.readFile(normalized) + const resolved = monaco.editor.getModel(monaco.Uri.file(normalized)) + if (resolved) return resolved + if (text === undefined) return undefined + const model = monaco.editor.createModel(text, languageForFile(normalized), monaco.Uri.file(normalized)) + this.#options.onCreateExternalModel(model) + return model + } + + #request(method: string, args: any): Promise { + if (this.#failure) return Promise.reject(this.#failure) + const id = this.#nextId++ + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }) + try { + this.#worker.postMessage({ id, method, args }) + } catch (error) { + this.#pending.delete(id) + reject(error) + } + }) + } +} + +function displayParts(parts: readonly { text: string }[] | undefined) { + return parts?.map(part => part.text).join("") ?? "" +} + +function spanToRange(model: monaco.editor.ITextModel, span: { start: number; length: number }) { + const start = model.getPositionAt(span.start) + const end = model.getPositionAt(span.start + span.length) + return new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column) +} + +function navigationSymbols(model: monaco.editor.ITextModel, item: any): monaco.languages.DocumentSymbol[] { + const range = spanToRange(model, item.spans?.[0] ?? { start: 0, length: 0 }) + return [ + { + children: item.childItems?.flatMap((child: any) => navigationSymbols(model, child)) ?? [], + detail: "", + kind: symbolKind(item.kind), + name: item.text, + range, + selectionRange: range, + tags: [], + }, + ] +} + +function symbolKind(kind: string | undefined) { + switch (kind) { + case "class": + return monaco.languages.SymbolKind.Class + case "const": + case "let": + case "var": + return monaco.languages.SymbolKind.Variable + case "enum": + return monaco.languages.SymbolKind.Enum + case "enum member": + return monaco.languages.SymbolKind.EnumMember + case "function": + return monaco.languages.SymbolKind.Function + case "interface": + return monaco.languages.SymbolKind.Interface + case "method": + return monaco.languages.SymbolKind.Method + case "module": + return monaco.languages.SymbolKind.Module + case "property": + return monaco.languages.SymbolKind.Property + case "type": + return monaco.languages.SymbolKind.TypeParameter + default: + return monaco.languages.SymbolKind.Object + } +} + +function completionKind(kind: string | undefined) { + switch (kind) { + case "class": + return monaco.languages.CompletionItemKind.Class + case "const": + case "let": + case "var": + return monaco.languages.CompletionItemKind.Variable + case "function": + return monaco.languages.CompletionItemKind.Function + case "interface": + return monaco.languages.CompletionItemKind.Interface + case "method": + return monaco.languages.CompletionItemKind.Method + case "property": + return monaco.languages.CompletionItemKind.Property + case "keyword": + return monaco.languages.CompletionItemKind.Keyword + default: + return monaco.languages.CompletionItemKind.Text + } +} + +function normalizePath(fileName: string) { + return fileName.replaceAll("\\", "/").replace(/^file:\/\//, "") +} diff --git a/packages/playground-v2/src/strada.worker.ts b/packages/playground-v2/src/strada.worker.ts new file mode 100644 index 000000000000..4ca72ded9527 --- /dev/null +++ b/packages/playground-v2/src/strada.worker.ts @@ -0,0 +1,369 @@ +import type { StradaLoadingProgress } from "./strada" +import { loadStradaLibraries } from "./strada-libraries" +import { createStradaFileSystem, normalizeStradaPath as normalizePath, parseStradaConfig } from "./strada-project" +import { resolveSymlinks, type Symlinks } from "./symlinks" + +type TS = typeof import("typescript") + +type InitMessage = { + id: number + method: "init" + args: { + compilerSource: string + files: Record + sourceFiles: string[] + version: string + baseUrl: string + symlinks?: Symlinks + } +} + +type RequestMessage = { + id: number + method: string + args: any +} + +type FileEntry = { + text: string + version: number + libReferences: string[] +} + +let ts: TS +let version = "" +let baseUrl = "" +let parsedConfig: ReturnType +let projectVersion = 0 +const files = new Map() +const libraries = new Map() +let sourceFiles: string[] = [] +let symlinks: Symlinks = {} +let filesystem: ReturnType +let languageService: import("typescript").LanguageService +let inferredLanguageService: import("typescript").LanguageService | undefined +const requestedInferredFiles = new Set() + +self.addEventListener("message", (event: MessageEvent) => { + void handleMessage(event.data) +}) + +async function handleMessage(message: InitMessage | RequestMessage) { + try { + const result = + message.method === "init" ? await initialize(message.args) : await dispatch(message.method, message.args) + self.postMessage({ id: message.id, result }) + } catch (error) { + self.postMessage({ + id: message.id, + error: error instanceof Error ? error.stack ?? error.message : String(error), + }) + } +} + +async function initialize(args: InitMessage["args"]) { + reportProgress({ phase: "initializing" }) + // The owning page supplies compiler code from our trusted compiler CDN over + // this dedicated worker's private channel. Evaluating that compiler is intentional. + ts = new Function(`${args.compilerSource}\nreturn ts;`)() as TS + symlinks = args.symlinks ?? {} + filesystem = createStradaFileSystem(ts, () => [...files.keys(), ...libraries.keys()], readFile, symlinks) + version = args.version + baseUrl = args.baseUrl + updateFiles(args.files, [], args.sourceFiles) + refreshConfig() + await loadLibraries(parsedConfig.options) + reportProgress({ phase: "initializing" }) + languageService = ts.createLanguageService(createLanguageServiceHost()) + return { version: ts.version } +} + +async function dispatch(method: string, args: any) { + let service = languageService + if (args.fileName && method !== "readFile") { + const target = languageServiceForFile(args.fileName) + service = target.service + args = { ...args, fileName: target.fileName } + } + switch (method) { + case "updateFiles": + if (updateFiles(args.files, args.deleted, args.sourceFiles)) refreshConfig() + await loadLibraries(parsedConfig.options) + projectVersion++ + return + case "compile": + return compile() + case "quickInfo": + return service.getQuickInfoAtPosition(args.fileName, args.position) + case "completions": { + const result = service.getCompletionsAtPosition(args.fileName, args.position, args.options) + if ( + !result || + result.optionalReplacementSpan || + !result.entries.some(entry => entry.kind === ts.ScriptElementKind.string) + ) { + return result + } + // Older compilers omit the replacement span for string-literal completions. + const source = service.getProgram()?.getSourceFile(normalizePath(args.fileName)) + return { + ...result, + optionalReplacementSpan: source && stringLiteralSpan(source, args.position), + } + } + case "completionDetails": + return service.getCompletionEntryDetails(args.fileName, args.position, args.name, {}, args.source, {}, args.data) + case "definitions": + return service.getDefinitionAndBoundSpan + ? service.getDefinitionAndBoundSpan(args.fileName, args.position) + : { + definitions: service.getDefinitionAtPosition(args.fileName, args.position), + } + case "references": + return service.getReferencesAtPosition(args.fileName, args.position) + case "occurrences": { + const serviceWithOccurrences = service as import("typescript").LanguageService & { + getOccurrencesAtPosition?(fileName: string, position: number): readonly any[] | undefined + } + if (serviceWithOccurrences.getOccurrencesAtPosition) { + return serviceWithOccurrences.getOccurrencesAtPosition(args.fileName, args.position) + } + const highlights = service.getDocumentHighlights(args.fileName, args.position, [...files.keys()]) + return ( + highlights + ?.find(result => normalizePath(result.fileName) === normalizePath(args.fileName)) + ?.highlightSpans.map(highlight => ({ + isWriteAccess: highlight.kind === "writtenReference", + textSpan: highlight.textSpan, + })) ?? [] + ) + } + case "signatureHelp": + return service.getSignatureHelpItems(args.fileName, args.position, args.options) + case "renameInfo": + return service.getRenameInfo(args.fileName, args.position, { + allowRenameOfImportPath: false, + providePrefixAndSuffixTextForRename: true, + }) + case "renameLocations": + return service.findRenameLocations(args.fileName, args.position, false, false, true) + case "navigationBarItems": + return service.getNavigationBarItems(args.fileName) + case "formatDocument": + return service.getFormattingEditsForDocument(args.fileName, args.options) + case "formatRange": + return service.getFormattingEditsForRange(args.fileName, args.start, args.end, args.options) + case "formatOnType": + return service.getFormattingEditsAfterKeystroke(args.fileName, args.position, args.key, args.options) + case "codeFixes": + return service.getCodeFixesAtPosition( + args.fileName, + args.start, + args.end, + args.errorCodes, + args.formatOptions, + args.preferences + ) + case "inlayHints": { + const serviceWithHints = service as import("typescript").LanguageService & { + provideInlayHints?( + fileName: string, + span: import("typescript").TextSpan, + preferences: import("typescript").UserPreferences + ): readonly any[] + } + return serviceWithHints.provideInlayHints?.(args.fileName, args.span, args.preferences) ?? [] + } + case "readFile": + return readFile(args.fileName) + default: + throw new Error(`Unknown Strada request: ${method}`) + } +} + +function updateFiles( + nextFiles: Record, + deleted: readonly string[], + nextSourceFiles: readonly string[] +) { + let changed = + sourceFiles.length !== nextSourceFiles.length || + sourceFiles.some((fileName, index) => fileName !== nextSourceFiles[index]) + sourceFiles = nextSourceFiles.map(normalizePath) + for (const fileName of deleted) { + if (files.delete(fileName)) changed = true + } + for (const [fileName, text] of Object.entries(nextFiles)) { + const current = files.get(fileName) + if (!current || current.text !== text) { + files.set(fileName, { + text, + version: projectVersion + 1, + libReferences: /\.[cm]?[jt]sx?$/i.test(fileName) + ? (ts.preProcessFile(text).libReferenceDirectives ?? []).map(reference => reference.fileName.toLowerCase()) + : [], + }) + changed = true + } + } + if (changed) projectVersion++ + return changed +} + +function refreshConfig() { + parsedConfig = parseConfig() + projectVersion++ +} + +function parseConfig() { + const parsed = parseStradaConfig(ts, filesystem) + return { + ...parsed, + errors: parsed.errors.map(serializeDiagnostic), + } +} + +async function loadLibraries(options: import("typescript").CompilerOptions) { + if (options.noLib) return + const roots = options.lib?.length + ? options.lib.map(lib => (lib.toLowerCase().startsWith("lib.") ? lib : `lib.${lib}.d.ts`)) + : [ts.getDefaultLibFileName(options)] + for (const { libReferences } of files.values()) { + roots.push(...libReferences) + } + await loadStradaLibraries(roots, baseUrl, libraries, progress => reportProgress({ phase: "libraries", ...progress })) +} + +function reportProgress(progress: StradaLoadingProgress) { + self.postMessage({ type: "progress", progress }) +} + +function compile() { + const parsed = parsedConfig + const outputFiles: Record = {} + const host = createCompilerHost(outputFiles) + const program = ts.createProgram({ + rootNames: parsed.rootNames, + options: parsed.options, + host, + projectReferences: parsed.projectReferences, + }) + const emit = program.emit() + const diagnostics = [ + ...parsed.errors, + ...ts.getPreEmitDiagnostics(program).map(serializeDiagnostic), + ...emit.diagnostics.map(serializeDiagnostic), + ] + return { + diagnostics, + emitSkipped: emit.emitSkipped, + outputFiles, + rootNames: parsed.rootNames, + } +} + +function createCompilerHost(outputFiles: Record) { + const host: import("typescript").CompilerHost = { + ...filesystem, + getCanonicalFileName: fileName => fileName, + getCurrentDirectory: () => "/workspace", + getDefaultLibFileName: options => `/typescript/lib/${ts.getDefaultLibFileName(options)}`, + getNewLine: () => "\n", + getSourceFile: (fileName, languageVersion) => { + const text = readFile(fileName) + return text === undefined + ? undefined + : ts.createSourceFile(fileName, text, languageVersion, true, scriptKind(fileName)) + }, + readFile, + useCaseSensitiveFileNames: () => true, + writeFile: (fileName, text) => { + outputFiles[normalizePath(fileName)] = text + }, + } + return host +} + +function languageServiceForFile(fileName: string) { + const program = languageService.getProgram() + const source = + program?.getSourceFile(normalizePath(fileName)) ?? + program?.getSourceFiles().find(source => filesystem.realpath(source.fileName) === filesystem.realpath(fileName)) + if (source) return { service: languageService, fileName: source.fileName } + if (!requestedInferredFiles.has(fileName)) { + requestedInferredFiles.add(fileName) + projectVersion++ + } + inferredLanguageService ??= ts.createLanguageService(createLanguageServiceHost(true)) + return { service: inferredLanguageService, fileName } +} + +function stringLiteralSpan(source: import("typescript").SourceFile, position: number) { + function visit(node: import("typescript").Node): import("typescript").TextSpan | undefined { + if (position < node.getFullStart() || position > node.getEnd()) return undefined + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + const start = node.getStart(source) + 1 + const end = node.getEnd() + if (position < start) return undefined + return { start, length: end - start - (node.isUnterminated ? 0 : 1) } + } + return ts.forEachChild(node, visit) + } + return visit(source) +} + +function createLanguageServiceHost(inferred = false): import("typescript").LanguageServiceHost { + return { + ...filesystem, + getCompilationSettings: () => ({ + ...parsedConfig.options, + ...(inferred ? { allowJs: true } : {}), + }), + getCurrentDirectory: () => "/workspace", + getDefaultLibFileName: options => `/typescript/lib/${ts.getDefaultLibFileName(options)}`, + getProjectVersion: () => String(projectVersion), + getScriptFileNames: () => { + if (!inferred) return parsedConfig.rootNames.slice() + const configuredProgram = languageService.getProgram() + return [...new Set([...sourceFiles, ...requestedInferredFiles])].filter( + fileName => readFile(fileName) !== undefined && !configuredProgram?.getSourceFile(fileName) + ) + }, + getProjectReferences: () => (inferred ? undefined : parsedConfig.projectReferences), + getScriptKind: scriptKind, + getScriptSnapshot: fileName => { + const text = readFile(fileName) + return text === undefined ? undefined : ts.ScriptSnapshot.fromString(text) + }, + getScriptVersion: fileName => files.get(filesystem.realpath(fileName))?.version.toString() ?? "1", + readFile, + useCaseSensitiveFileNames: () => true, + } +} + +function readFile(fileName: string) { + const normalized = resolveSymlinks(fileName, symlinks) + if (normalized === undefined) return undefined + return files.get(normalized)?.text ?? libraries.get(normalized) +} + +function scriptKind(fileName: string) { + if (/\.tsx$/i.test(fileName)) return ts.ScriptKind.TSX + if (/\.jsx$/i.test(fileName)) return ts.ScriptKind.JSX + if (/\.[cm]?js$/i.test(fileName)) return ts.ScriptKind.JS + if (/\.json$/i.test(fileName)) return ts.ScriptKind.JSON + return ts.ScriptKind.TS +} + +function serializeDiagnostic(diagnostic: import("typescript").Diagnostic) { + return { + category: diagnostic.category, + code: diagnostic.code, + end: (diagnostic.start ?? 0) + (diagnostic.length ?? 1), + fileName: diagnostic.file?.fileName, + pos: diagnostic.start ?? 0, + startPosition: diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0), + endPosition: diagnostic.file?.getLineAndCharacterOfPosition((diagnostic.start ?? 0) + (diagnostic.length ?? 0)), + text: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + } +} diff --git a/packages/playground-v2/src/styles.css b/packages/playground-v2/src/styles.css new file mode 100644 index 000000000000..013312f2f5a9 --- /dev/null +++ b/packages/playground-v2/src/styles.css @@ -0,0 +1,1743 @@ +@import "@fontsource/nunito-sans/400.css"; +@import "@fontsource/nunito-sans/600.css"; +@import "hack-font/build/web/hack.css"; + +:root { + color: #1f1f1f; + background: #fafafa; + font-family: "Nunito Sans", sans-serif; + font-synthesis: none; + --accent: #1d5f9e; + --border: #d6d6d6; + --error: #c63131; + --hover: #eeeeee; + --menu-text: #1f1f1f; + --muted: #5f6368; + --output: #f5f5f5; + --panel: #ffffff; + --selected: #e4e6f1; + --sidebar: #f3f3f3; + --toolbar: #3178c6; + --tree-guide: #b7b7b7; + --warning: #8a5700; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; +} + +body { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + overflow: hidden; +} + +:where(button, a, summary, input, select, pre, [role="separator"]):focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.toolbar :is(button, summary, a):focus-visible { + outline-color: white; +} + +.playground { + width: 100%; + height: auto; + min-height: 0; + margin: 0; + position: relative; + display: grid; + grid-template-rows: auto minmax(0, 1fr); +} + +.toolbar { + min-height: 3.5rem; + padding: 0.5rem 1rem; + color: white; + background: var(--toolbar); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.toolbar h1, +.toolbar p, +.panel-heading h2 { + margin: 0; +} + +.toolbar h1 { + font-size: 1.25rem; + font-weight: 600; +} + +.toolbar h1 a { + color: inherit; + text-decoration: none; +} + +.toolbar-brand { + min-width: 0; +} + +.site-footer { + min-height: 2.4rem; + padding: 0.45rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + border-top: 1px solid var(--border); + color: var(--muted); + background: var(--sidebar); + font-size: 0.72rem; +} + +.site-footer > a { + color: inherit; + font-weight: 600; + text-decoration: none; +} + +.site-footer nav { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.75rem; +} + +.site-footer nav a { + color: inherit; +} + +.toolbar p { + margin-top: 0.125rem; + font-size: 0.875rem; + opacity: 0.85; +} + +.toolbar-actions { + min-width: 0; + max-width: 100%; + display: grid; + grid-template-columns: minmax(7rem, 1fr) repeat(4, max-content); + align-items: center; + gap: 0.75rem; +} + +.version-control { + position: relative; + flex: 0 0 auto; + min-width: 7rem; + max-width: min(26rem, calc(100vw - 2rem)); +} + +.export-control { + min-width: 0; +} + +.export-control .version-menu { + left: auto; + right: 0; +} + +.export-control .version-menu-item { + text-align: left; +} + +.form-dialog.export-dialog { + width: min(56rem, calc(100vw - 2rem)); +} + +.export-dialog textarea { + width: 100%; + height: min(60svh, 32rem); + padding: 0.75rem; + border: 1px solid var(--border); + color: inherit; + background: var(--panel); + font: 0.8rem Hack, monospace; + resize: vertical; +} + +.version-control summary { + min-height: 2.15rem; + padding: 0.4rem 0.65rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + overflow: hidden; + border: 1px solid rgb(255 255 255 / 45%); + border-radius: 0.25rem; + color: white; + background: rgb(0 0 0 / 16%); + cursor: pointer; + font: inherit; + font-weight: 600; + list-style: none; + white-space: nowrap; +} + +.version-control summary::-webkit-details-marker { + display: none; +} + +#compiler-version-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.version-control summary:hover, +.version-control[open] summary { + background: rgb(0 0 0 / 24%); +} + +.version-control summary[aria-disabled="true"] { + pointer-events: none; + opacity: 0.55; +} + +.version-caret { + width: 0.8rem; + height: 0.8rem; + flex: 0 0 auto; + display: grid; + place-items: center; + transition: transform 120ms ease; +} + +.version-caret::before { + content: "▾"; + line-height: 1; +} + +.version-control[open] .version-caret { + transform: rotate(180deg); +} + +.version-menu { + position: absolute; + top: calc(100% + 0.3rem); + left: 0; + z-index: 60; + width: max-content; + min-width: 100%; + max-width: calc(100vw - 2rem); + max-height: min(30rem, calc(100dvh - 6rem)); + padding: 0.3rem 0; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 0.3rem; + color: var(--menu-text); + background: var(--panel); + box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 28%); +} + +.version-menu-item { + width: 100%; + min-height: 2.4rem; + padding: 0.45rem 1.9rem 0.45rem 0.7rem !important; + position: relative; + display: block; + overflow: hidden; + border: 0 !important; + border-radius: 0 !important; + color: inherit !important; + background: transparent !important; + font: inherit; + font-weight: 600; + font-size: 0.78rem !important; + text-align: center; + text-overflow: ellipsis; + text-decoration: none; + white-space: nowrap; +} + +.version-menu-item:hover, +.version-menu-item:focus { + background: var(--hover) !important; + outline: 0; +} + +.version-menu-item[aria-checked="true"] { + color: var(--accent) !important; + background: var(--selected) !important; + font-weight: 700; +} + +.version-menu .version-menu-item:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -3px; +} + +.version-menu-item[aria-checked="true"]::after { + position: absolute; + top: 50%; + right: 0.65rem; + content: "✓"; + transform: translateY(-50%); +} + +.version-menu-separator { + height: 1px; + margin: 0.3rem 0; + background: var(--border); +} + +.version-menu .version-menu-error { + margin: 0; + padding: 0.45rem 0.7rem; + color: var(--error); + font-size: 0.78rem; + opacity: 1; +} + +.action-button { + min-height: 2.25rem; + padding: 0.4rem 0.8rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + color: inherit; + background: var(--panel); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.action-button:hover:not(:disabled) { + background: var(--hover); +} + +.action-button:disabled { + cursor: default; + opacity: 0.5; +} + +.toolbar .action-button { + border-color: rgb(255 255 255 / 45%); + background: rgb(0 0 0 / 12%); +} + +.toolbar .action-button:hover:not(:disabled) { + background: rgb(0 0 0 / 22%); +} + +.toolbar .action-button:disabled { + cursor: wait; +} + +.status { + grid-column: 1 / -1; + background: #a1260d; + padding: 0.35rem 0.65rem; + border: 1px solid rgb(255 255 255 / 35%); + border-radius: 0.25rem; + font-family: Hack, monospace; + font-size: 0.75rem; + overflow-wrap: anywhere; + white-space: normal; +} + +.form-dialog { + width: min(28rem, calc(100vw - 2rem)); + padding: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.4rem; + color: inherit; + background: var(--panel); + box-shadow: 0 1rem 3rem rgb(0 0 0 / 35%); +} + +.form-dialog::backdrop { + background: rgb(0 0 0 / 45%); +} + +.form-dialog form { + display: grid; + gap: 0.8rem; +} + +.form-dialog header { + padding: 0.75rem 0.9rem; + border-bottom: 1px solid var(--border); + background: var(--sidebar); +} + +.form-dialog h2 { + margin: 0; + font-size: 1rem; +} + +.form-dialog label { + padding: 0 0.9rem; + display: grid; + gap: 0.35rem; + color: var(--muted); + font-size: 0.78rem; + font-weight: 600; +} + +.form-dialog label[hidden] { + display: none; +} + +.form-dialog input { + width: 100%; + min-height: 2.5rem; + padding: 0 0.6rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + color: inherit; + background: var(--panel); + font-family: Hack, monospace; +} + +.file-kind-options { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1rem; + margin: 0; + padding: 0 0.9rem; + border: 0; +} + +.file-kind-options legend { + color: var(--muted); + font-size: 0.78rem; + font-weight: 600; +} + +.form-dialog .file-kind-options label { + display: flex; + align-items: center; + min-height: 2.25rem; + padding: 0; + color: inherit; + cursor: pointer; +} + +.form-dialog .file-kind-options input { + width: 1rem; + height: 1rem; + min-height: 0; + margin: 0; + padding: 0; + accent-color: var(--accent); +} + +.form-dialog footer { + padding: 0.7rem 0.9rem; + display: flex; + justify-content: flex-end; + gap: 0.5rem; + border-top: 1px solid var(--border); +} + +.form-dialog button { + min-height: 2.25rem; + padding: 0 0.75rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + color: inherit; + background: var(--panel); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.form-dialog button[type="submit"] { + color: white; + background: var(--toolbar); +} + +.form-dialog button.danger { + color: white; + background: #a1260d; +} + +.form-error { + margin: 0; + padding: 0 0.9rem; + color: var(--error); + font-size: 0.78rem; +} + +.settings-dialog { + width: min(34rem, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + overflow: auto; +} + +.settings-dialog fieldset { + margin: 0 0.9rem; + padding: 0.75rem; + display: grid; + gap: 0.7rem; + border: 1px solid var(--border); + border-radius: 0.3rem; +} + +.settings-dialog legend { + padding: 0 0.35rem; + font-size: 0.8rem; + font-weight: 600; +} + +.settings-dialog .checkbox-setting { + padding: 0; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 0.55rem; + color: inherit; + font-weight: 400; +} + +.settings-dialog .checkbox-setting input { + width: 1rem; + height: 1rem; + min-height: 0; + margin-top: 0.15rem; +} + +.settings-dialog .checkbox-setting span { + display: grid; + gap: 0.1rem; +} + +.settings-dialog .checkbox-setting small { + color: var(--muted); + font-size: 0.72rem; +} + +.settings-dialog .select-setting { + padding: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + color: inherit; + font-weight: 400; +} + +.settings-dialog select { + min-height: 2.25rem; + padding: 0 0.45rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + color: inherit; + background: var(--panel); + font: inherit; +} + +.settings-dialog footer span { + flex: 1; +} + +.settings-dialog .form-secondary-action { + margin-right: auto; +} + +.confirmation-dialog p { + margin: 0; + padding: 0.2rem 0.9rem 0.4rem; + color: var(--muted); + line-height: 1.5; +} + +.workspace { + --files-handle-width: 0.3rem; + --files-width: 13rem; + --output-handle-width: 0.3rem; + --output-width: 27.5rem; + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: + var(--files-width) + var(--files-handle-width) + minmax(24rem, 1fr) + var(--output-handle-width) + var(--output-width); +} + +.workspace[data-files-collapsed="true"] { + --files-handle-width: 0rem; + --files-width: 0rem; +} + +.workspace[data-output-collapsed="true"] { + --output-handle-width: 0rem; + --output-width: 0rem; +} + +.workspace[data-files-collapsed="true"] .file-explorer, +.workspace[data-files-collapsed="true"] .file-resizer, +.workspace[data-output-collapsed="true"] .output-panel, +.workspace[data-output-collapsed="true"] .output-resizer { + display: none; +} + +.file-explorer { + grid-column: 1; + min-width: 0; + overflow: auto; + border-right: 1px solid var(--border); + background: var(--sidebar); +} + +.file-explorer-heading, +.panel-heading { + min-height: 2.75rem; + border-bottom: 1px solid var(--border); +} + +.file-explorer-heading { + padding: 0.5rem 0.75rem; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.file-explorer-heading h2 { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.file-explorer-actions { + display: flex; + gap: 0.25rem; +} + +.file-list { + padding: 0.4rem 0; +} + +.file-tree, +.file-tree ul { + margin: 0; + padding: 0; + list-style: none; +} + +.file-tree .file-tree { + margin-left: 1rem; + border-left: 1px solid var(--tree-guide); +} + +.file-tree .file-tree > li { + position: relative; +} + +.file-tree .file-tree > li::before { + position: absolute; + top: 1rem; + left: 0; + width: 0.65rem; + border-top: 1px solid var(--tree-guide); + content: ""; +} + +.file-tree-folder { + padding: 0.35rem 0.65rem 0.35rem 0.85rem; + color: inherit; + cursor: pointer; + font-size: 0.76rem; + font-weight: 600; + list-style: none; + user-select: none; +} + +.file-tree-folder::before { + display: inline-block; + width: 0.8rem; + margin-right: 0.35rem; + color: var(--muted); + content: "▾"; +} + +.file-tree details:not([open]) > .file-tree-folder::before { + content: "▸"; +} + +.file-tree-file { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; +} + +.file-tree-file-button { + width: 100%; + padding: 0.42rem 0.65rem 0.42rem 0.85rem; + overflow: hidden; + border: 0; + color: inherit; + background: transparent; + cursor: pointer; + font: inherit; + font-size: 0.8rem; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-tree-file-button::before { + display: inline-block; + width: 1.65rem; + color: var(--muted); + font-family: Hack, monospace; + font-size: 0.65rem; + content: attr(data-kind); +} + +.file-tree-file-button:hover { + background: var(--hover); +} + +.file-tree-file-button[aria-current="page"] { + background: var(--selected); + font-weight: 600; +} + +.file-tree-delete { + width: 1.7rem; + height: 1.7rem; + margin-right: 0.3rem; + padding: 0; + border: 0; + border-radius: 0.2rem; + color: var(--muted); + background: transparent; + cursor: pointer; + font: inherit; + font-size: 1rem; + line-height: 1; + opacity: 0; +} + +.file-tree-file:hover .file-tree-delete, +.file-tree-file:focus-within .file-tree-delete { + opacity: 1; +} + +.file-tree-delete:hover { + color: var(--error); + background: var(--hover); +} + +.editor-panel { + grid-column: 3; + min-width: 0; + min-height: 0; + display: grid; + grid-template-rows: auto minmax(0, 1fr); + background: var(--panel); +} + +.output-panel { + grid-column: 5; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + border-left: 1px solid var(--border); + background: var(--panel); +} + +.workspace-resizer { + position: relative; + z-index: 4; + min-width: 0; + background: var(--border); + cursor: col-resize; + touch-action: none; +} + +.workspace-resizer:hover, +.workspace-resizer:focus, +body[data-resizing] .workspace-resizer { + background: var(--toolbar); + outline: 0; +} + +.file-resizer { + grid-column: 2; +} + +.output-resizer { + grid-column: 4; +} + +body[data-resizing] { + cursor: col-resize; + user-select: none; +} + +.diagnostics-panel { + flex: 0 0 auto; + min-height: 0; + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.diagnostics-panel summary { + min-height: 2.75rem; + padding: 0.45rem 0.65rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.35rem; + background: var(--sidebar); + cursor: pointer; + font-size: 0.8rem; + font-weight: 600; + list-style: none; +} + +.diagnostics-panel summary::-webkit-details-marker { + display: none; +} + +.diagnostics-panel summary::marker { + content: ""; +} + +.diagnostics-panel summary::before { + flex: 0 0 1.4rem; + color: var(--muted); + content: "▸"; + font-size: 0.85rem; + line-height: 1; + text-align: center; +} + +.diagnostics-panel[open] summary::before { + content: "▾"; +} + +.diagnostics-panel summary > :first-child { + margin-right: auto; +} + +.diagnostics-panel summary > :last-child { + min-width: 1.5rem; + padding: 0.1rem 0.35rem; + border-radius: 1rem; + background: var(--selected); + font-family: Hack, monospace; + font-size: 0.68rem; + text-align: center; +} + +.diagnostics-list { + max-height: 13rem; + overflow: auto; +} + +.diagnostics-list > .empty-message { + padding: 0.65rem; +} + +.diagnostic { + width: 100%; + padding: 0.55rem 0.65rem; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.15rem 0.55rem; + border: 0; + border-bottom: 1px solid var(--border); + color: inherit; + background: transparent; + cursor: pointer; + font: inherit; + text-align: left; + user-select: text; +} + +.diagnostic:last-child { + border-bottom: 0; +} + +.diagnostic:hover { + background: var(--hover); +} + +.diagnostic strong { + color: var(--muted); + font-family: Hack, monospace; + font-size: 0.68rem; +} + +.diagnostic-error strong { + color: var(--error); +} + +.diagnostic-warning strong { + color: var(--warning); +} + +.diagnostic-message { + min-width: 0; + font-size: 0.76rem; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.diagnostic-location { + grid-column: 2; + color: var(--muted); + font-family: Hack, monospace; + font-size: 0.65rem; +} + +.panel-heading { + padding: 0.55rem 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.output-panel > .panel-heading { + flex: 0 0 auto; + padding: 0.45rem 0.65rem; +} + +.output-panel > .panel-heading h2 { + font-size: 0.8rem; +} + +.panel-heading h2 { + font-size: 0.95rem; + font-weight: 600; +} + +.editor-heading-title, +.editor-heading-actions { + min-width: 0; + display: flex; + align-items: center; +} + +.editor-heading-title { + gap: 0.55rem; +} + +.editor-heading-title h2 { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editor-heading-actions { + justify-content: flex-end; + gap: 0.55rem; +} + +.pane-toggle, +.output-section-toggle { + flex: 0 0 auto; + width: 2.3rem; + padding-right: 0; + padding-left: 0; +} + +.panel-heading span { + color: var(--muted); + font-size: 0.75rem; + text-align: right; +} + +.output-heading-actions, +.output-section-title, +.run-output header > div { + min-width: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.4rem; +} + +.output-section-title { + justify-content: flex-start; + gap: 0.35rem; +} + +code { + font-family: Hack, monospace; +} + +.editor { + min-width: 0; + min-height: 0; +} + +.emit-output { + flex: 1 1 auto; + min-height: 0; + padding: 0.75rem; + overflow: auto; + background: var(--output); +} + +.emit-file { + margin-bottom: 0.75rem; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 0.25rem; + background: var(--panel); +} + +.emit-file:last-child { + margin-bottom: 0; +} + +.emit-file h3 { + position: sticky; + top: 0; + z-index: 1; + margin: 0; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--border); + background: var(--sidebar); + font-family: Hack, monospace; + font-size: 0.72rem; + font-weight: 600; +} + +.emit-file pre { + margin: 0; + padding: 0.75rem; + overflow: auto; + font-family: Hack, monospace; + font-size: 0.75rem; + line-height: 1.5; + tab-size: 2; +} + +.empty-message { + margin: 0; + color: var(--muted); + font-size: 0.8rem; +} + +.run-output { + flex: 0 0 auto; + max-height: 12rem; + overflow: auto; + border-top: 1px solid var(--border); + background: var(--panel); +} + +.run-output[data-collapsed="true"] .run-log { + display: none; +} + +.output-panel:has(.emit-output[hidden]) .run-output:not([data-collapsed="true"]) { + max-height: none; + flex: 1 1 auto; +} + +.run-output header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: space-between; + min-height: 2.75rem; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid var(--border); + background: var(--sidebar); +} + +.run-output h2 { + margin: 0; + font-size: 0.8rem; + font-weight: 600; +} + +.run-log { + padding: 0.65rem; + font-family: Hack, monospace; + font-size: 0.72rem; + line-height: 1.45; + white-space: pre-wrap; +} + +.run-log-entry + .run-log-entry { + margin-top: 0.35rem; + padding-top: 0.35rem; + border-top: 1px dashed var(--border); +} + +.run-log-entry strong { + display: inline-block; + width: 2.5rem; + color: var(--muted); +} + +.run-log-entry.warn strong { + color: var(--warning); +} + +.run-log-entry.error strong { + color: var(--error); +} + +.loading-overlay { + position: absolute; + top: 5.25rem; + right: 1rem; + z-index: 20; + pointer-events: none; +} + +.loading-overlay[data-state="consent"] { + position: fixed; + inset: 0; + padding: 1rem; + display: grid; + place-items: center; + pointer-events: none; + transform: none; +} + +.loading-overlay[data-state="consent"] .loading-card { + width: min(30rem, calc(100vw - 2rem)); + pointer-events: auto; +} + +.loading-card { + display: flex; + align-items: center; + gap: 0.85rem; + width: min(25rem, calc(100vw - 2rem)); + padding: 0.8rem 0.9rem; + border: 1px solid var(--border); + border-radius: 0.4rem; + background: var(--panel); + box-shadow: 0 0.45rem 1.5rem rgb(0 0 0 / 22%); +} + +.loading-overlay[hidden] { + display: none; +} + +.loading-overlay p { + margin: 0 0 0.45rem; + font-size: 0.82rem; + font-weight: 600; +} + +.loading-copy { + min-width: 0; + flex: 1; + overflow-wrap: anywhere; +} + +.download-consent { + flex: 1; +} + +.download-consent h2 { + margin: 0 0 0.45rem; + font-size: 1rem; +} + +.download-consent p { + margin: 0 0 0.75rem; + color: var(--muted); + font-size: 0.8rem; + line-height: 1.4; +} + +.download-consent label { + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.75rem; +} + +.download-consent input { + width: 1rem; + height: 1rem; +} + +.download-consent button { + min-height: 2.25rem; + padding: 0 0.75rem; + border: 0; + border-radius: 0.25rem; + color: white; + background: var(--toolbar); + cursor: pointer; + font: inherit; + font-weight: 600; +} + +.loading-overlay progress { + display: block; + width: 100%; + height: 0.65rem; + accent-color: #3178c6; +} + +.loading-overlay[data-state="error"] progress { + accent-color: #c63131; +} + +.loading-overlay small { + display: block; + min-height: 1rem; + margin-top: 0.35rem; + overflow: hidden; + color: var(--muted); + font-family: Hack, monospace; + font-size: 0.68rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lds-grid { + position: relative; + width: 48px; + height: 48px; + flex: 0 0 48px; +} + +.lds-grid div { + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + background: #3178c6; + animation: lds-grid 1.2s linear infinite; +} + +.loading-overlay[data-state="error"] .lds-grid div { + background: #c63131; + animation: none; +} + +.lds-grid div:nth-child(1) { + top: 3px; + left: 3px; + animation-delay: 0s; +} +.lds-grid div:nth-child(2) { + top: 3px; + left: 19px; + animation-delay: -0.4s; +} +.lds-grid div:nth-child(3) { + top: 3px; + left: 35px; + animation-delay: -0.8s; +} +.lds-grid div:nth-child(4) { + top: 19px; + left: 3px; + animation-delay: -0.4s; +} +.lds-grid div:nth-child(5) { + top: 19px; + left: 19px; + animation-delay: -0.8s; +} +.lds-grid div:nth-child(6) { + top: 19px; + left: 35px; + animation-delay: -1.2s; +} +.lds-grid div:nth-child(7) { + top: 35px; + left: 3px; + animation-delay: -0.8s; +} +.lds-grid div:nth-child(8) { + top: 35px; + left: 19px; + animation-delay: -1.2s; +} +.lds-grid div:nth-child(9) { + top: 35px; + left: 35px; + animation-delay: -1.6s; +} + +@keyframes lds-grid { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +@media (max-width: 1000px) { + .toolbar { + min-height: 0; + flex-direction: column; + align-items: stretch; + gap: 0.55rem; + } + + .toolbar-actions { + width: 100%; + } + + .workspace { + grid-template-columns: 11rem minmax(20rem, 1fr); + grid-template-rows: minmax(20rem, 1fr) minmax(16rem, 0.8fr); + } + + .file-explorer { + grid-column: 1; + grid-row: 1; + } + + .workspace-resizer { + display: none; + } + + .editor-panel { + grid-column: 2; + grid-row: 1; + } + + .output-panel { + grid-column: 1 / -1; + grid-row: 2; + border-top: 1px solid var(--border); + border-left: 0; + } + + .workspace[data-files-collapsed="true"] { + grid-template-columns: minmax(20rem, 1fr); + } + + .workspace[data-files-collapsed="true"] .editor-panel { + grid-column: 1; + } + + .workspace[data-output-collapsed="true"] { + grid-template-rows: minmax(20rem, 1fr); + } +} + +@media (max-width: 700px), (max-width: 900px) and (max-height: 600px) { + html, + body { + height: auto; + min-height: 100%; + } + + body { + overflow: auto; + } + + .playground { + height: auto; + min-height: calc(100svh - 2.4rem); + grid-template-rows: auto auto; + } + + .toolbar { + min-height: 0; + padding: calc(0.65rem + env(safe-area-inset-top)) calc(0.75rem + env(safe-area-inset-right)) 0.65rem + calc(0.75rem + env(safe-area-inset-left)); + align-items: stretch; + flex-direction: column; + gap: 0.55rem; + } + + .toolbar h1 { + font-size: 1.05rem; + } + + .site-footer { + align-items: flex-start; + flex-direction: column; + gap: 0.35rem; + } + + .site-footer nav { + justify-content: flex-start; + } + + .toolbar-actions { + width: 100%; + flex: none; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + } + + .version-control { + min-width: 0; + grid-column: 1 / -1; + width: 100%; + max-width: none; + min-height: 0; + padding: 0; + } + + .version-control summary { + min-height: 2.75rem; + } + + .version-menu { + width: 100%; + } + + .action-button, + .version-menu-item, + .form-dialog .file-kind-options label { + min-height: 2.75rem; + } + + .action-button { + min-width: 0; + } + + .status { + min-width: 0; + grid-column: 1 / -1; + line-height: 1.35; + overflow-wrap: anywhere; + white-space: normal; + } + + .form-dialog { + width: calc(100vw - 1.5rem); + } + + .settings-dialog { + max-height: calc(100dvh - 1.5rem); + } + + .form-dialog input, + .form-dialog button { + min-height: 2.75rem; + } + + .download-consent button { + min-height: 2.75rem; + } + + .workspace { + min-height: 0; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(28rem, 70svh) auto; + } + + .file-explorer { + max-height: min(16rem, 40svh); + grid-column: 1; + grid-row: 1; + overflow: auto; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + .file-explorer-heading { + position: sticky; + top: 0; + z-index: 2; + min-height: 2.75rem; + background: var(--sidebar); + } + + .file-tree-folder { + min-height: 2.75rem; + display: flex; + align-items: center; + } + + .file-tree-file-button { + min-height: 2.75rem; + padding-top: 0.65rem; + padding-bottom: 0.65rem; + } + + .file-tree-delete { + width: 2.75rem; + height: 2.75rem; + opacity: 1; + } + + .editor-panel { + min-height: 28rem; + grid-column: 1; + grid-row: 2; + border-bottom: 1px solid var(--border); + } + + .output-panel { + min-height: max(32rem, 70svh); + grid-column: 1; + grid-row: 3; + border-top: 0; + border-left: 0; + } + + .panel-heading { + align-items: center; + } + + .panel-heading span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .editor-heading-title { + flex: 1 1 auto; + } + + .editor-heading-actions { + flex: 0 1 auto; + } + + .pane-toggle, + .output-section-toggle, + #add-button, + #clear-run-output { + min-width: 2.75rem; + } + + .diagnostics-panel summary { + min-height: 3.65rem; + } + + .diagnostics-panel summary::before { + flex-basis: 2.75rem; + } + + .emit-output { + min-height: 18rem; + padding: 0.5rem; + } + + .emit-file pre { + overflow-wrap: anywhere; + white-space: pre-wrap; + } + + .monaco-editor .scrollbar.horizontal { + display: none !important; + } + + .run-output { + max-height: 16rem; + padding-bottom: env(safe-area-inset-bottom); + } + + .run-log { + overflow-wrap: anywhere; + } + + .loading-overlay { + position: fixed; + top: 50%; + right: 0.5rem; + left: 0.5rem; + transform: translateY(-50%); + } + + .loading-card { + width: 100%; + } +} + +@media (max-width: 420px) { + .file-explorer-heading { + padding-right: 0.5rem; + padding-left: 0.5rem; + } + + .file-explorer-actions { + gap: 0.15rem; + } + + .editor-panel .panel-heading span { + display: none; + } + + .editor-heading-title { + gap: 0.35rem; + } + + .editor-heading-actions { + flex: 0 0 auto; + } + + .loading-card { + gap: 0.65rem; + } + + .lds-grid { + width: 40px; + height: 40px; + flex-basis: 40px; + transform: scale(0.8); + transform-origin: center; + } +} + +@media (pointer: coarse) { + button, + select, + summary { + touch-action: manipulation; + } + + .action-button, + .version-menu-item, + .form-dialog .file-kind-options label { + min-height: 2.75rem; + } + + .action-button { + min-width: 2.75rem; + } + + .file-tree-delete { + width: 2.75rem; + height: 2.75rem; + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .lds-grid div, + .monaco-hover.fade-in { + animation: none; + } +} + +:root[data-theme="light"] { + color-scheme: light; +} + +:root[data-theme="dark"] { + color: #f3f3f3; + color-scheme: dark; + background: #1e1e1e; + --accent: #75b7f0; + --border: #414141; + --error: #ff8f8f; + --hover: #292929; + --menu-text: #f3f3f3; + --muted: #b7b7b7; + --output: #171717; + --panel: #1e1e1e; + --selected: #37373d; + --sidebar: #252526; + --toolbar: #235a97; + --tree-guide: #5c5c5c; + --warning: #e5bf61; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme]) { + color: #f3f3f3; + background: #1e1e1e; + --accent: #75b7f0; + --border: #414141; + --error: #ff8f8f; + --hover: #292929; + --menu-text: #f3f3f3; + --muted: #b7b7b7; + --output: #171717; + --panel: #1e1e1e; + --selected: #37373d; + --sidebar: #252526; + --toolbar: #235a97; + --tree-guide: #5c5c5c; + --warning: #e5bf61; + } +} + +@media (forced-colors: active) { + :root[data-theme], + :root:not([data-theme]) { + color: CanvasText; + background: Canvas; + --accent: Highlight; + --border: CanvasText; + --error: CanvasText; + --hover: Canvas; + --menu-text: CanvasText; + --muted: CanvasText; + --output: Canvas; + --panel: Canvas; + --selected: Canvas; + --sidebar: Canvas; + --toolbar: Canvas; + --tree-guide: CanvasText; + --warning: CanvasText; + } + + .toolbar { + color: CanvasText; + } + + .status { + color: CanvasText; + background: Canvas; + } + + .version-control summary, + .action-button, + .toolbar .action-button, + .form-dialog button[type="submit"], + .form-dialog button.danger, + .download-consent button { + color: ButtonText; + background: ButtonFace; + border: 1px solid ButtonText; + } + + .toolbar :is(button, summary, a):focus-visible { + outline-color: Highlight; + } + + .version-menu-item[aria-checked="true"] { + color: HighlightText !important; + background: Highlight !important; + } + + .version-menu-item[aria-checked="true"]:focus-visible { + outline-color: HighlightText; + } + + .emit-output code, + .emit-output code span { + color: CanvasText !important; + } +} diff --git a/packages/playground-v2/src/symlinks.ts b/packages/playground-v2/src/symlinks.ts new file mode 100644 index 000000000000..90e9abc8d9a3 --- /dev/null +++ b/packages/playground-v2/src/symlinks.ts @@ -0,0 +1,64 @@ +export type Symlinks = Readonly> + +export function normalizeVirtualPath(fileName: string) { + let path = fileName.replaceAll("\\", "/").replace(/^file:\/\//, "") + if (!path.startsWith("/")) path = `/workspace/${path}` + const parts: string[] = [] + for (const part of path.split("/")) { + if (part === "..") parts.pop() + else if (part && part !== ".") parts.push(part) + } + return `/${parts.join("/")}` +} + +export function resolveSymlinks(fileName: string, symlinks: Symlinks, followFinal = true): string | undefined { + let path = fileName.replaceAll("\\", "/").replace(/^file:\/\//, "") + if (!path.startsWith("/")) path = `/workspace/${path}` + const remaining = path.split("/") + const parts: string[] = [] + let followed = 0 + for (let index = 0; index < remaining.length; index++) { + const part = remaining[index] + if (!part || part === ".") continue + if (part === "..") { + parts.pop() + continue + } + const link = symlinks[`/${[...parts, part].join("/")}`] + if (link !== undefined && (followFinal || index < remaining.length - 1)) { + if (++followed > 40) return undefined + if (link.startsWith("/")) parts.length = 0 + remaining.splice(index, 1, ...link.split("/")) + index-- + } else { + parts.push(part) + } + } + return `/${parts.join("/")}` +} + +export function validateProjectSymlinks(files: Readonly>, symlinks: Symlinks) { + const paths = [...Object.keys(files), ...Object.keys(symlinks)] + for (const [path, target] of Object.entries(symlinks)) { + if ( + !path.startsWith("/workspace/") || + normalizeVirtualPath(path) !== path || + /[\\\0]/.test(path) || + !target || + /[\\\0]/.test(target) + ) { + throw new Error(`Invalid symbolic link: ${path}`) + } + if ( + files[path] !== undefined || + paths.some(other => other !== path && (other.startsWith(`${path}/`) || path.startsWith(`${other}/`))) + ) { + throw new Error(`Symbolic link conflicts with another project path: ${path}`) + } + const resolved = resolveSymlinks(path, symlinks) + if (!resolved) throw new Error(`Symbolic link cycle: ${path}`) + if (resolved !== "/workspace" && !resolved.startsWith("/workspace/")) { + throw new Error(`Symbolic link targets must stay inside the project: ${path}`) + } + } +} diff --git a/packages/playground-v2/src/tsgo-lsp.ts b/packages/playground-v2/src/tsgo-lsp.ts new file mode 100644 index 000000000000..28ca98c2e222 --- /dev/null +++ b/packages/playground-v2/src/tsgo-lsp.ts @@ -0,0 +1,496 @@ +import { createTransportToWorker, MonacoLspClient } from "@vscode/monaco-lsp-client/src/index" +import * as monaco from "monaco-editor-core" +import { + conf as javascriptConfiguration, + language as javascriptLanguage, +} from "monaco-editor/languages/definitions/javascript/javascript.js" +import { + conf as typescriptConfiguration, + language as typescriptLanguage, +} from "monaco-editor/languages/definitions/typescript/typescript.js" +import { frameFileSystemUpdate, normalizeWorkspaceEditUris } from "./lsp-filesystem" +import { languageForFile } from "./editor-languages" +import { resolveSymlinks, type Symlinks } from "./symlinks" + +const headerWords = 4 +const readPosition = 0 +const writePosition = 1 +const closed = 2 +const signal = 3 +const bufferSize = 4 * 1024 * 1024 + +export type TsgoStatus = "mounting files" | "starting tsc.wasm" | "initializing LSP" | "ready" + +type WorkerMessage = { + type: "lsp" | "drain" | "status" | "stderr" | "error" | "filesystem" + id?: number + message?: any + status?: TsgoStatus + aliases?: Array<{ path: string; type: number }> +} + +type StartTsgoLspOptions = { + configFileName: string + effectiveConfigText: string + libraries: Record + models: readonly monaco.editor.ITextModel[] + module: WebAssembly.Module + onError(message: string): void + shouldHandleDiagnostics(model: monaco.editor.ITextModel): boolean + readPackageFile(fileName: string): string | undefined + onPackageModel(model: monaco.editor.ITextModel): void + onStatus(status: TsgoStatus, serverInfo?: string): void + symlinks?: Symlinks +} + +export type TsgoLspController = { + updateEffectiveConfig(text: string): void + refreshDiagnostics(): void + updateFiles(files: ReadonlyMap): Promise + readFile(fileName: string): Promise +} + +class RingBufferWorker { + readonly #worker = new Worker(new URL("./tsgo-lsp.worker.js", import.meta.url), { + type: "module", + }) + readonly #state: Int32Array + readonly #data: Uint8Array + readonly #queue: Uint8Array[] = [] + readonly #listeners = new Map() + readonly #pendingRequests = new Map() + readonly #configFileName: string + readonly #mountedFiles = new Map() + readonly #fileUpdates = new Map() + #nextFileUpdateId = 0 + #lastFileUpdate = Promise.resolve() + #failure: Error | undefined + #configVersion = 1 + #effectiveConfigText: string + #queueOffset = 0 + + onStatus?: (status: TsgoStatus) => void + onError?: (message: string) => void + onServerInfo?: (serverInfo: string) => void + + constructor(stdin: SharedArrayBuffer, configFileName: string, effectiveConfigText: string) { + this.#state = new Int32Array(stdin, 0, headerWords) + this.#data = new Uint8Array(stdin, headerWords * Int32Array.BYTES_PER_ELEMENT) + this.#configFileName = configFileName + this.#effectiveConfigText = effectiveConfigText + this.#worker.addEventListener("message", (event: MessageEvent) => { + if (event.data.type === "drain") { + this.#flush() + } else if (event.data.type === "status" && event.data.status) { + this.onStatus?.(event.data.status) + } else if (event.data.type === "error") { + this.#failure = new Error(event.data.message ?? "Language-server worker failed") + for (const pending of this.#fileUpdates.values()) pending.reject(this.#failure) + this.#fileUpdates.clear() + this.onError?.(event.data.message) + } else if (event.data.type === "filesystem" && event.data.id !== undefined) { + // A watcher on a symlink observes target changes under the link's path. + if (event.data.aliases?.length) { + this.postMessage({ + jsonrpc: "2.0", + method: "workspace/didChangeWatchedFiles", + params: { + changes: event.data.aliases.map(({ path, type }) => ({ uri: monaco.Uri.file(path).toString(), type })), + }, + }) + } + this.#fileUpdates.get(event.data.id)?.resolve() + this.#fileUpdates.delete(event.data.id) + } else if (event.data.type === "stderr") { + console.warn("[tsgo]", event.data.message) + } else if (event.data.type === "lsp") { + void this.#handleLspMessage(event.data.message) + } + }) + this.#worker.addEventListener("error", event => { + const error = new Error(event.message || "Language-server worker failed") + this.#failure = error + for (const pending of this.#fileUpdates.values()) pending.reject(error) + this.#fileUpdates.clear() + this.onError?.(error.message) + }) + } + + start( + stdin: SharedArrayBuffer, + module: WebAssembly.Module, + libraries: Record, + files: Record, + symlinks: Symlinks + ) { + if (Object.keys(symlinks).length) { + for (const [path, text] of Object.entries(files)) this.#mountedFiles.set(path, text) + } + this.#worker.postMessage({ + type: "init", + stdin, + libraries, + module, + files, + symlinks, + }) + } + + postMessage(message: unknown) { + message = this.#rewriteConfigMessage(message) + const lspMessage = message as { id?: string | number; method?: string } + if (lspMessage.id !== undefined && lspMessage.method) { + this.#pendingRequests.set(lspMessage.id, lspMessage.method) + } + + this.#queue.push(this.#frameMessage(message)) + this.#flush() + } + + #frameMessage(message: unknown) { + const body = new TextEncoder().encode(JSON.stringify(message)) + const header = new TextEncoder().encode(`Content-Length: ${body.length}\r\n\r\n`) + const framed = new Uint8Array(header.length + body.length) + framed.set(header) + framed.set(body, header.length) + if (framed.length > this.#data.length) { + throw new Error(`LSP message exceeds the ${this.#data.length}-byte stdin buffer`) + } + return framed + } + + updateEffectiveConfig(text: string) { + if (text === this.#effectiveConfigText) return false + this.#effectiveConfigText = text + this.postMessage({ + jsonrpc: "2.0", + method: "textDocument/didChange", + params: { + contentChanges: [{ text }], + textDocument: { + uri: monaco.Uri.file(this.#configFileName).toString(), + version: this.#configVersion + 1, + }, + }, + }) + return true + } + + updateFiles(files: ReadonlyMap) { + if (this.#failure) return Promise.reject(this.#failure) + const changed: Record = {} + const deleted: string[] = [] + const changes: Array<{ uri: string; type: number }> = [] + for (const [path, text] of files) { + if (this.#mountedFiles.get(path) === text) continue + changed[path] = text + changes.push({ uri: monaco.Uri.file(path).toString(), type: this.#mountedFiles.has(path) ? 2 : 1 }) + } + for (const path of this.#mountedFiles.keys()) { + if (files.has(path)) continue + deleted.push(path) + changes.push({ uri: monaco.Uri.file(path).toString(), type: 3 }) + } + if (!changes.length) return this.#lastFileUpdate + const id = ++this.#nextFileUpdateId + const record = frameFileSystemUpdate({ id, files: changed, deleted }) + const notification = this.#frameMessage({ + jsonrpc: "2.0", + method: "workspace/didChangeWatchedFiles", + params: { changes }, + }) + const completed = new Promise((resolve, reject) => this.#fileUpdates.set(id, { resolve, reject })) + this.#lastFileUpdate = completed + this.#mountedFiles.clear() + for (const [path, text] of files) this.#mountedFiles.set(path, text) + this.#queue.push(record, notification) + this.#flush() + return completed + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject) { + if (type !== "message") return + const callback: EventListener = typeof listener === "function" ? listener : event => listener.handleEvent(event) + this.#listeners.set(listener, callback) + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject) { + if (type === "message") this.#listeners.delete(listener) + } + + #rewriteConfigMessage(message: unknown) { + if (!message || typeof message !== "object") return message + const candidate = message as { + method?: string + params?: { + contentChanges?: Array<{ text?: string }> + textDocument?: { text?: string; uri?: string; version?: number } + } + } + const document = candidate.params?.textDocument + if (!document?.uri || monaco.Uri.parse(document.uri).path !== this.#configFileName) return message + if (candidate.method === "textDocument/didOpen") { + this.#configVersion = Math.max(this.#configVersion, document.version ?? this.#configVersion) + return { + ...candidate, + params: { + ...candidate.params, + textDocument: { + ...document, + text: this.#effectiveConfigText, + }, + }, + } + } + if (candidate.method === "textDocument/didChange") { + this.#configVersion = Math.max(this.#configVersion + 1, document.version ?? 0) + return { + ...candidate, + params: { + ...candidate.params, + contentChanges: [{ text: this.#effectiveConfigText }], + textDocument: { + ...document, + version: this.#configVersion, + }, + }, + } + } + return message + } + + #flush() { + while (this.#queue.length > 0) { + const readPos = Atomics.load(this.#state, readPosition) >>> 0 + const writePos = Atomics.load(this.#state, writePosition) >>> 0 + const free = this.#data.length - ((writePos - readPos) >>> 0) + if (free <= 0) return + + const current = this.#queue[0] + const length = Math.min(free, current.length - this.#queueOffset) + const start = writePos % this.#data.length + const first = Math.min(length, this.#data.length - start) + this.#data.set(current.subarray(this.#queueOffset, this.#queueOffset + first), start) + if (first < length) { + this.#data.set(current.subarray(this.#queueOffset + first, this.#queueOffset + length), 0) + } + this.#queueOffset += length + Atomics.store(this.#state, writePosition, writePos + length) + Atomics.add(this.#state, signal, 1) + Atomics.notify(this.#state, signal) + + if (this.#queueOffset === current.length) { + this.#queue.shift() + this.#queueOffset = 0 + } + } + } + + async #handleLspMessage(message: any) { + const method = message?.method + const label = method ?? this.#pendingRequests.get(message?.id) + if (method === "textDocument/publishDiagnostics") return + if (method === "workspace/applyEdit" && message.params?.edit) { + message.params.edit = normalizeWorkspaceEditUris(message.params.edit, normalizeLibraryUri) + } + if (label === "textDocument/rename") { + message.result = normalizeWorkspaceEditUris(message.result, normalizeLibraryUri) + } else if (label === "textDocument/codeAction") { + message.result = message.result?.map((action: { edit?: unknown }) => ({ + ...action, + ...(action.edit ? { edit: normalizeWorkspaceEditUris(action.edit, normalizeLibraryUri) } : {}), + })) + } else if (label === "codeAction/resolve" && message.result?.edit) { + message.result.edit = normalizeWorkspaceEditUris(message.result.edit, normalizeLibraryUri) + } + if ( + label && + [ + "textDocument/definition", + "textDocument/declaration", + "textDocument/typeDefinition", + "textDocument/implementation", + "textDocument/references", + ].includes(label) + ) { + message.result = normalizeLibraryLocations(message?.result) + await ensureDefinitionModels(message.result) + } + if (message?.id !== undefined && !method) { + this.#pendingRequests.delete(message.id) + } + if (message?.result?.capabilities) { + const info = message.result.serverInfo + if (info?.name) { + const name = info.name === "typescript-go" ? "TypeScript" : info.name + this.onServerInfo?.(`${name}${info.version ? ` ${info.version}` : ""}`) + } + this.onStatus?.("ready") + } + + const forwarded = new MessageEvent("message", { data: message }) + for (const listener of this.#listeners.values()) listener(forwarded) + } +} + +let languageRegistered = false +let definitionFilesPromise: Promise> | undefined +let readPackageFile: StartTsgoLspOptions["readPackageFile"] | undefined +let onPackageModel: StartTsgoLspOptions["onPackageModel"] | undefined +let definitionSymlinks: Symlinks = {} + +export function registerPlaygroundLanguages() { + if (languageRegistered) return + languageRegistered = true + for (const [id, extensions] of [ + ["typescript", [".ts", ".mts", ".cts"]], + ["typescriptreact", [".tsx"]], + ["javascript", [".js", ".mjs", ".cjs"]], + ["javascriptreact", [".jsx"]], + ] as const) { + const javascript = id.startsWith("javascript") + monaco.languages.register({ id, extensions: [...extensions] }) + monaco.languages.setLanguageConfiguration(id, javascript ? javascriptConfiguration : typescriptConfiguration) + monaco.languages.setMonarchTokensProvider(id, javascript ? javascriptLanguage : typescriptLanguage) + } + monaco.languages.register({ id: "json", extensions: [".json"] }) + monaco.languages.setLanguageConfiguration("json", { + brackets: [ + ["{", "}"], + ["[", "]"], + ], + comments: { lineComment: "//", blockComment: ["/*", "*/"] }, + }) + monaco.languages.setMonarchTokensProvider("json", { + tokenizer: { + root: [ + [/"(?:\\.|[^"\\])*"(?=\s*:)/, "string.key.json"], + [/"(?:\\.|[^"\\])*"/, "string.value.json"], + [/\b(?:true|false|null)\b/, "keyword.json"], + [/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/, "number"], + [/[{}\[\],:]/, "delimiter"], + [/\/\/.*$/, "comment"], + ], + }, + }) +} + +export function startTsgoLsp(options: StartTsgoLspOptions) { + if (!crossOriginIsolated) { + throw new Error("The TypeScript language server requires cross-origin isolation; reload once to activate it.") + } + + readPackageFile = options.readPackageFile + onPackageModel = options.onPackageModel + definitionSymlinks = options.symlinks ?? {} + definitionFilesPromise = Promise.resolve( + Object.fromEntries( + Object.entries(options.libraries).map(([fileName, text]) => [ + `/typescript/lib/${fileName.slice(fileName.lastIndexOf("/") + 1)}`, + text, + ]) + ) + ) + const stdin = new SharedArrayBuffer(headerWords * Int32Array.BYTES_PER_ELEMENT + bufferSize) + const configModelPath = resolveSymlinks(options.configFileName, options.symlinks ?? {}) ?? options.configFileName + const worker = new RingBufferWorker(stdin, configModelPath, options.effectiveConfigText) + let serverInfo: string | undefined + worker.onStatus = status => options.onStatus(status, serverInfo) + worker.onServerInfo = info => { + serverInfo = info + } + worker.onError = options.onError + // Package files are mounted in batches; only project files are open LSP documents. + worker.start( + stdin, + options.module, + options.libraries, + { + ...Object.fromEntries(options.models.map(model => [model.uri.path, model.getValue()])), + [resolveSymlinks(options.configFileName, options.symlinks ?? {}) ?? options.configFileName]: + options.effectiveConfigText, + }, + options.symlinks ?? {} + ) + + const transport = createTransportToWorker(worker as unknown as Worker) + const client = new MonacoLspClient(transport, { + shouldHandleDiagnostics: options.shouldHandleDiagnostics, + shouldSynchronizeModel: options.shouldHandleDiagnostics, + }) + return { + updateEffectiveConfig(text: string) { + if (worker.updateEffectiveConfig(text)) client.refreshDiagnostics() + }, + refreshDiagnostics() { + client.refreshDiagnostics() + }, + updateFiles(files: ReadonlyMap) { + return worker.updateFiles(files) + }, + async readFile(fileName: string) { + const libraries = await getDefinitionFiles() + return ( + readPackageFile?.(fileName) ?? + libraries[fileName] ?? + (/^\/lib(?:\.[\w.]+)?\.d\.ts$/.test(fileName) ? libraries[`/typescript/lib${fileName}`] : undefined) + ) + }, + } satisfies TsgoLspController +} + +function normalizeLibraryLocations(result: unknown): unknown { + if (Array.isArray(result)) return result.map(normalizeLibraryLocations) + if (!result || typeof result !== "object") return result + + const location = result as { + uri?: string + targetUri?: string + } + const uri = location.targetUri ?? location.uri + if (!uri) return result + const normalized = normalizeLibraryUri(uri) + if (normalized === uri) return result + return "targetUri" in location ? { ...location, targetUri: normalized } : { ...location, uri: normalized } +} + +function normalizeLibraryUri(uri: string) { + const parsed = monaco.Uri.parse(uri) + if (parsed.scheme === "file") { + const resolved = resolveSymlinks(parsed.path, definitionSymlinks) + return resolved === undefined || resolved === parsed.path ? uri : parsed.with({ path: resolved }).toString() + } + if (parsed.scheme !== "bundled" || !/^\/libs\/lib(?:\..*)?\.d\.ts$/i.test(parsed.path)) { + return uri + } + return monaco.Uri.file(`/typescript/lib/${parsed.path.slice("/libs/".length)}`).toString() +} + +async function ensureDefinitionModels(result: unknown) { + const locations = Array.isArray(result) ? result : [result] + const uris = new Set() + for (const location of locations) { + if (!location || typeof location !== "object") continue + const candidate = location as { uri?: string; targetUri?: string } + const uri = candidate.targetUri ?? candidate.uri + if (uri && monaco.Uri.parse(uri).scheme === "file") uris.add(uri) + } + + const definitionFiles = uris.size > 0 ? await getDefinitionFiles() : {} + for (const uri of uris) { + const monacoUri = monaco.Uri.parse(uri) + if (monaco.editor.getModel(monacoUri)) continue + const packageContents = readPackageFile?.(monacoUri.path) + const contents = packageContents ?? definitionFiles[monacoUri.path] + if (contents === undefined) continue + const model = monaco.editor.createModel(contents, languageForFile(monacoUri.path), monacoUri) + if (packageContents !== undefined) onPackageModel?.(model) + } +} + +function getDefinitionFiles() { + if (!definitionFilesPromise) throw new Error("Language-server libraries are not initialized") + return definitionFilesPromise +} + +export { monaco } diff --git a/packages/playground-v2/src/tsgo-lsp.worker.ts b/packages/playground-v2/src/tsgo-lsp.worker.ts new file mode 100644 index 000000000000..8dbe2d2f97ec --- /dev/null +++ b/packages/playground-v2/src/tsgo-lsp.worker.ts @@ -0,0 +1,293 @@ +import { Fd, Inode, WASI, wasi, WASIProcExit } from "@bjorn3/browser_wasi_shim" +import { LspFileSystem, LspStdinRecords } from "./lsp-filesystem" +import type { Symlinks } from "./symlinks" + +const headerWords = 4 +const readPosition = 0 +const writePosition = 1 +const closed = 2 +const signal = 3 +const subscriptionSize = 48 +const eventSize = 32 + +type InitMessage = { + type: "init" + stdin: SharedArrayBuffer + libraries: Record + module: WebAssembly.Module + files: Record + symlinks: Symlinks +} + +class BlockingStdin extends Fd { + readonly #state: Int32Array + readonly #data: Uint8Array + readonly #records: LspStdinRecords + + constructor(buffer: SharedArrayBuffer, filesystem: LspFileSystem) { + super() + this.#state = new Int32Array(buffer, 0, headerWords) + this.#data = new Uint8Array(buffer, headerWords * Int32Array.BYTES_PER_ELEMENT) + this.#records = new LspStdinRecords(update => { + const aliases = filesystem.update(update.files, update.deleted) + self.postMessage({ type: "filesystem", id: update.id, aliases }) + }) + } + + get readable() { + return this.#records.readable + } + + override fd_fdstat_get() { + const fdstat = new wasi.Fdstat(wasi.FILETYPE_CHARACTER_DEVICE, wasi.FDFLAGS_NONBLOCK) + fdstat.fs_rights_base = BigInt(wasi.RIGHTS_FD_READ | wasi.RIGHTS_POLL_FD_READWRITE) + return { ret: wasi.ERRNO_SUCCESS, fdstat } + } + + override fd_filestat_get() { + return { + ret: wasi.ERRNO_SUCCESS, + filestat: new wasi.Filestat(Inode.issue_ino(), wasi.FILETYPE_CHARACTER_DEVICE, 0n), + } + } + + override fd_fdstat_set_flags() { + return wasi.ERRNO_SUCCESS + } + + override fd_read(size: number) { + const buffered = this.#records.read(size) + if (buffered) return { ret: wasi.ERRNO_SUCCESS, data: buffered } + let readPos = Atomics.load(this.#state, readPosition) + let writePos = Atomics.load(this.#state, writePosition) + if (readPos === writePos && !Atomics.load(this.#state, closed)) { + const currentSignal = Atomics.load(this.#state, signal) + Atomics.wait(this.#state, signal, currentSignal, 50) + readPos = Atomics.load(this.#state, readPosition) + writePos = Atomics.load(this.#state, writePosition) + } + while (readPos !== writePos) { + const available = (writePos - readPos) >>> 0 + const length = Math.min(64 * 1024, available) + const result = new Uint8Array(length) + const start = (readPos >>> 0) % this.#data.length + const first = Math.min(length, this.#data.length - start) + result.set(this.#data.subarray(start, start + first)) + if (first < length) { + result.set(this.#data.subarray(0, length - first), first) + } + Atomics.store(this.#state, readPosition, readPos + length) + self.postMessage({ type: "drain" }) + this.#records.push(result) + const output = this.#records.read(size) + if (output) return { ret: wasi.ERRNO_SUCCESS, data: output } + readPos = Atomics.load(this.#state, readPosition) + writePos = Atomics.load(this.#state, writePosition) + } + if (Atomics.load(this.#state, closed)) { + return { ret: wasi.ERRNO_SUCCESS, data: new Uint8Array() } + } + return { ret: wasi.ERRNO_AGAIN, data: new Uint8Array() } + } +} + +class LspStdout extends Fd { + #buffer = new Uint8Array() + + override fd_fdstat_get() { + const fdstat = new wasi.Fdstat(wasi.FILETYPE_CHARACTER_DEVICE, 0) + fdstat.fs_rights_base = BigInt(wasi.RIGHTS_FD_WRITE) + return { ret: wasi.ERRNO_SUCCESS, fdstat } + } + + override fd_filestat_get() { + return { + ret: wasi.ERRNO_SUCCESS, + filestat: new wasi.Filestat(Inode.issue_ino(), wasi.FILETYPE_CHARACTER_DEVICE, 0n), + } + } + + override fd_write(data: Uint8Array) { + const combined = new Uint8Array(this.#buffer.length + data.length) + combined.set(this.#buffer) + combined.set(data, this.#buffer.length) + this.#buffer = combined + this.#flushMessages() + return { ret: wasi.ERRNO_SUCCESS, nwritten: data.length } + } + + #flushMessages() { + for (;;) { + const headerEnd = findHeaderEnd(this.#buffer) + if (headerEnd < 0) return + const header = new TextDecoder().decode(this.#buffer.subarray(0, headerEnd)) + const match = /(?:^|\r\n)Content-Length:\s*(\d+)/i.exec(header) + if (!match) { + self.postMessage({ type: "error", message: `Invalid LSP header: ${header}` }) + this.#buffer = new Uint8Array() + return + } + const contentLength = Number(match[1]) + const bodyStart = headerEnd + 4 + const bodyEnd = bodyStart + contentLength + if (this.#buffer.length < bodyEnd) return + const body = new TextDecoder().decode(this.#buffer.subarray(bodyStart, bodyEnd)) + try { + self.postMessage({ type: "lsp", message: JSON.parse(body) }) + } catch (error) { + self.postMessage({ + type: "error", + message: `Invalid LSP JSON: ${String(error)}`, + }) + } + this.#buffer = this.#buffer.slice(bodyEnd) + } + } +} + +class Stderr extends Fd { + readonly #decoder = new TextDecoder() + + override fd_write(data: Uint8Array) { + const message = this.#decoder.decode(data, { stream: true }).trim() + if (message) self.postMessage({ type: "stderr", message }) + return { ret: wasi.ERRNO_SUCCESS, nwritten: data.length } + } +} + +function findHeaderEnd(data: Uint8Array) { + for (let i = 0; i <= data.length - 4; i++) { + if (data[i] === 13 && data[i + 1] === 10 && data[i + 2] === 13 && data[i + 3] === 10) { + return i + } + } + return -1 +} + +function installPollOneoff(wasiRuntime: WASI, state: Int32Array, stdin: BlockingStdin) { + wasiRuntime.wasiImport.poll_oneoff = ( + inputPointer: number, + outputPointer: number, + subscriptionCount: number, + eventCountPointer: number + ) => { + const memory = new DataView(wasiRuntime.inst.exports.memory.buffer) + const subscriptions = Array.from({ length: subscriptionCount }, (_, index) => + wasi.Subscription.read_bytes(memory, inputPointer + index * subscriptionSize) + ) + const clockDeadlines = new Map() + for (const subscription of subscriptions) { + if (subscription.eventtype !== wasi.EVENTTYPE_CLOCK) continue + const clockNow = + subscription.clockid === wasi.CLOCKID_REALTIME + ? BigInt(Date.now()) * 1_000_000n + : BigInt(Math.round(performance.now() * 1e6)) + clockDeadlines.set( + subscription, + (subscription.flags & wasi.SUBCLOCKFLAGS_SUBSCRIPTION_CLOCK_ABSTIME) !== 0 + ? subscription.timeout + : clockNow + subscription.timeout + ) + } + + for (;;) { + const now = { + [wasi.CLOCKID_MONOTONIC]: BigInt(Math.round(performance.now() * 1e6)), + [wasi.CLOCKID_REALTIME]: BigInt(Date.now()) * 1_000_000n, + } + const ready = subscriptions.filter(subscription => { + if (subscription.eventtype === wasi.EVENTTYPE_FD_READ) { + return ( + stdin.readable || + Atomics.load(state, readPosition) !== Atomics.load(state, writePosition) || + Atomics.load(state, closed) !== 0 + ) + } + if (subscription.eventtype === wasi.EVENTTYPE_FD_WRITE) return true + if (subscription.eventtype !== wasi.EVENTTYPE_CLOCK) return false + const clockNow = now[subscription.clockid as keyof typeof now] ?? 0n + return (clockDeadlines.get(subscription) ?? 0n) <= clockNow + }) + + if (ready.length > 0) { + ready.forEach((subscription, index) => { + const eventPointer = outputPointer + index * eventSize + new wasi.Event(subscription.userdata, wasi.ERRNO_SUCCESS, subscription.eventtype).write_bytes( + memory, + eventPointer + ) + memory.setBigUint64(eventPointer + 16, 0n, true) + memory.setUint16(eventPointer + 24, 0, true) + }) + memory.setUint32(eventCountPointer, ready.length, true) + return wasi.ERRNO_SUCCESS + } + + const currentSignal = Atomics.load(state, signal) + const nextDeadline = Math.min( + 50, + ...subscriptions + .filter(subscription => subscription.eventtype === wasi.EVENTTYPE_CLOCK) + .map(subscription => { + const clockNow = now[subscription.clockid as keyof typeof now] ?? 0n + const remaining = (clockDeadlines.get(subscription) ?? clockNow) - clockNow + return Math.max(1, Math.ceil(Number(remaining) / 1e6)) + }) + ) + Atomics.wait(state, signal, currentSignal, nextDeadline) + } + } +} + +async function start(message: InitMessage) { + self.postMessage({ type: "status", status: "mounting files" }) + const files = { ...message.files } + for (const [name, contents] of Object.entries(message.libraries)) { + const filename = name.slice(name.lastIndexOf("/") + 1) + files[`/typescript/lib/${filename}`] = contents + } + + const filesystem = new LspFileSystem(files, message.symlinks) + const stdin = new BlockingStdin(message.stdin, filesystem) + const fds = [stdin, new LspStdout(), new Stderr(), filesystem.preopen] + const wasiRuntime = new WASI(["tsc", "--lsp", "--stdio"], ["HOME=/workspace", "TMPDIR=/tmp"], fds, { debug: false }) + installPollOneoff(wasiRuntime, new Int32Array(message.stdin, 0, headerWords), stdin) + const instance = await WebAssembly.instantiate(message.module, { + wasi_snapshot_preview1: wasiRuntime.wasiImport, + }) + self.postMessage({ type: "status", status: "starting tsc.wasm" }) + self.postMessage({ type: "status", status: "initializing LSP" }) + try { + wasiRuntime.start( + instance as unknown as { + exports: { + memory: WebAssembly.Memory + _start(): unknown + } + } + ) + } catch (error) { + if (error instanceof WASIProcExit) { + self.postMessage({ + type: "error", + message: `tsc exited with status ${error.code}`, + }) + } else { + throw error + } + } +} + +self.addEventListener( + "message", + (event: MessageEvent) => { + if (event.data.type !== "init") return + start(event.data).catch(error => { + self.postMessage({ + type: "error", + message: error instanceof Error ? error.stack ?? error.message : String(error), + }) + }) + }, + { once: true } +) diff --git a/packages/playground-v2/tsconfig.json b/packages/playground-v2/tsconfig.json new file mode 100644 index 000000000000..0c00ec6d4998 --- /dev/null +++ b/packages/playground-v2/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "ES2022"], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/playground-v2/tsconfig.test.json b/packages/playground-v2/tsconfig.test.json new file mode 100644 index 000000000000..1930bf763a6f --- /dev/null +++ b/packages/playground-v2/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src/ata/**/*.ts", "src/native-compiler.test.ts", "src/lsp-filesystem.test.ts"], + "exclude": [] +} diff --git a/packages/typescriptlang-org/gatsby-config.js b/packages/typescriptlang-org/gatsby-config.js index 1f205be2467e..c896ea07552e 100644 --- a/packages/typescriptlang-org/gatsby-config.js +++ b/packages/typescriptlang-org/gatsby-config.js @@ -66,6 +66,8 @@ module.exports = { options: { // Skip handbook v2 from appearing in search excludes: [`*/glossary`, `*/vo/*`], + // The new playground is static HTML rather than a Gatsby page. + resolvePages: ({ allSitePage }) => [...allSitePage.nodes, { path: "/play/v2/" }], }, }, // Lets you edit the head from inside a react tree diff --git a/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygroundExamplePages.ts b/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygroundExamplePages.ts index d260a748d5c1..ce12fe75d77a 100644 --- a/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygroundExamplePages.ts +++ b/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygroundExamplePages.ts @@ -97,8 +97,9 @@ const hrefForExample = ( .map(key => key + "=" + params[key]) .join("&") - const langURL = lang === "en" ? "" : lang - return `${langURL}/play/?${prefix + queryParams}#${hash}` + const query = [prefix, queryParams].filter(Boolean).join("&") + const langPrefix = lang === "en" ? "" : `/${lang}` + return `${langPrefix}/play/?${query}#${hash}` } const getCompilerDetailsFromCode = (contents: string) => { diff --git a/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygrounds.ts b/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygrounds.ts index a093a18a2ce0..78919d8e7332 100644 --- a/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygrounds.ts +++ b/packages/typescriptlang-org/lib/bootup/ingestion/createPlaygrounds.ts @@ -36,7 +36,7 @@ export const createPlaygrounds = async ( const docs = anyData.allFile.nodes docs.forEach(lang => { - if (!isMultiLingual && lang !== "en") return + if (!isMultiLingual && lang.name !== "en") return const appRoot = path.join(__dirname, "..", "..", "..", "..") diff --git a/packages/typescriptlang-org/package.json b/packages/typescriptlang-org/package.json index 5e09f4d12c18..f2207020f71d 100644 --- a/packages/typescriptlang-org/package.json +++ b/packages/typescriptlang-org/package.json @@ -5,6 +5,7 @@ "version": "0.0.0", "license": "MIT", "scripts": { + "prebuild": "pnpm --filter @typescript/playground-v2 build", "build": "GATSBY_EXPERIMENTAL_PAGE_BUILD_ON_DATA_CHANGES=true gatsby build", "clean": "gatsby clean", "bootstrap": "pnpm update-versions", @@ -13,6 +14,7 @@ "setup-playground-cache-bust": "node scripts/cacheBustPlayground.mjs", "create-lighthouse-json": "node scripts/createLighthouseJSON.js", "compile-index-examples": "twoslash --reactAlso src/components/index/twoslash/*.ts src/components/index/twoslash/*.js src/components/index/twoslash/*.tsx src/components/index/twoslash/generated && node scripts/updateIndexTwoslashExamples.js", + "prestart": "pnpm --filter @typescript/playground-v2 build", "start": "gatsby develop", "serve": "gatsby serve", "test": "pnpm tsc && jest" @@ -21,6 +23,7 @@ "@babel/core": "^7.29.0", "@types/react-helmet": "^6.1.11", "@typescript/playground": "workspace:*", + "@typescript/playground-v2": "workspace:*", "@typescript/sandbox": "workspace:*", "@typescript/twoslash": "workspace:*", "@typescript/vfs": "workspace:*", diff --git a/packages/typescriptlang-org/src/components/IntlLink.tsx b/packages/typescriptlang-org/src/components/IntlLink.tsx index 46c74b7fdeaf..943a0e3be641 100644 --- a/packages/typescriptlang-org/src/components/IntlLink.tsx +++ b/packages/typescriptlang-org/src/components/IntlLink.tsx @@ -34,4 +34,3 @@ export const createIntlLink = (currentLocale: string) => { } } - diff --git a/packages/typescriptlang-org/src/components/ShowExamples.tsx b/packages/typescriptlang-org/src/components/ShowExamples.tsx index 92443572919d..9d4104a6fe0f 100644 --- a/packages/typescriptlang-org/src/components/ShowExamples.tsx +++ b/packages/typescriptlang-org/src/components/ShowExamples.tsx @@ -37,7 +37,7 @@ const hrefForExample = (example: Example, lang: string) => { const params = example.compilerSettings || {} params.q = Math.floor(Math.random() * 512) const queryParams = Object.keys(params).map(key => key + '=' + params[key]).join('&'); - const langPrefix = lang === "en" ? "" : lang + const langPrefix = lang === "en" ? "" : `/${lang}` return withPrefix(`${langPrefix}/play/?${prefix + queryParams}#${hash}`) } diff --git a/packages/typescriptlang-org/src/copy/en/playground.ts b/packages/typescriptlang-org/src/copy/en/playground.ts index 6936d5ea6cf7..d99443584835 100644 --- a/packages/typescriptlang-org/src/copy/en/playground.ts +++ b/packages/typescriptlang-org/src/copy/en/playground.ts @@ -1,5 +1,8 @@ export const playCopy = { play_subnav_title: "Playground", + play_new_playground_description: + "TypeScript 7 and multi-file projects are now available.", + play_new_playground_link: "Try the new Playground!", play_subnav_config: "TS Config", play_config_language_blurb: "Which language should be used in the editor", play_subnav_handbook: "Help", diff --git a/packages/typescriptlang-org/src/lib/createPlaygroundV2Query.ts b/packages/typescriptlang-org/src/lib/createPlaygroundV2Query.ts new file mode 100644 index 000000000000..17bae28f0111 --- /dev/null +++ b/packages/typescriptlang-org/src/lib/createPlaygroundV2Query.ts @@ -0,0 +1,52 @@ +import type { Sandbox } from "@typescript/sandbox" +import { isNativeCompilerVersion } from "../../../playground-v2/src/compiler-versions" + +type HandoffSandbox = Pick< + Sandbox, + "ts" | "getCompilerOptions" | "createURLQueryWithCompilerOptions" +> + +type ConfigSerializer = { + convertToTSConfig( + config: { + options: ReturnType + fileNames: string[] + errors: unknown[] + }, + fileName: string, + host: { useCaseSensitiveFileNames: boolean; getCurrentDirectory(): string } + ): { compilerOptions: Record } +} + +function hasConfigSerializer( + compiler: Sandbox["ts"] +): compiler is Sandbox["ts"] & ConfigSerializer { + return ( + "convertToTSConfig" in compiler && + typeof compiler.convertToTSConfig === "function" + ) +} + +export function createPlaygroundV2Query( + sandbox: HandoffSandbox, + search: string +) { + const requested = new URLSearchParams(search).get("ts") + if (isNativeCompilerVersion(requested)) { + return sandbox.createURLQueryWithCompilerOptions(sandbox, { ts: requested }) + } + const compiler = sandbox.ts + if (!hasConfigSerializer(compiler)) + throw new Error( + "The selected compiler cannot serialize its Playground settings" + ) + const config = compiler.convertToTSConfig( + { options: sandbox.getCompilerOptions(), fileNames: [], errors: [] }, + "/workspace/tsconfig.json", + { useCaseSensitiveFileNames: true, getCurrentDirectory: () => "/workspace" } + ) + return sandbox.createURLQueryWithCompilerOptions(sandbox, { + ts: requested, + tsconfig: JSON.stringify({ compilerOptions: config.compilerOptions }), + }) +} diff --git a/packages/typescriptlang-org/src/templates/play.scss b/packages/typescriptlang-org/src/templates/play.scss index 01bb9350935e..cd8ecda1f8cf 100644 --- a/packages/typescriptlang-org/src/templates/play.scss +++ b/packages/typescriptlang-org/src/templates/play.scss @@ -1,5 +1,16 @@ @import "../style/globals.scss"; +.playground-preview { + padding: 0.75rem 1rem; + background-color: var(--raised-background-color); + border-bottom: 1px solid var(--border-color); + + a { + font-weight: 600; + white-space: nowrap; + } +} + // The subnav beginning with "Playground" .navbar-sub { clear: both; diff --git a/packages/typescriptlang-org/src/templates/play.tsx b/packages/typescriptlang-org/src/templates/play.tsx index d16385be233c..a62a0dad06b2 100644 --- a/packages/typescriptlang-org/src/templates/play.tsx +++ b/packages/typescriptlang-org/src/templates/play.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react" +import React, { useEffect, useRef } from "react" import ReactDOM from "react-dom" import { Layout } from "../components/layout" import { withPrefix } from "gatsby" @@ -17,6 +17,7 @@ import "reflect-metadata" import playgroundReleases from "../../../sandbox/src/releases.json" import { getPlaygroundUrls } from "../lib/playgroundURLs" +import { createPlaygroundV2Query } from "../lib/createPlaygroundV2Query" import type * as playgroundPackage from "../../static/js/playground"; @@ -34,8 +35,30 @@ type Props = { const Play: React.FC = (props) => { const i = createInternational(useIntl()) + const sandboxRef = useRef() + + const updateNewPlaygroundLink = (event: React.SyntheticEvent) => { + const sandbox = sandboxRef.current + const query = sandbox + ? createPlaygroundV2Query(sandbox, location.search) + : location.search + location.hash + event.currentTarget.href = withPrefix("/play/v2/") + query + } useEffect(() => { + const redirectProject = () => { + if (!location.hash.startsWith("#code/v2/")) return false + location.replace(withPrefix("/play/v2/") + location.search + location.hash) + return true + } + if (redirectProject()) return + + const controller = navigator.serviceWorker?.controller + if (controller && new URL(controller.scriptURL).pathname === withPrefix("/play/coi-serviceworker.min.js")) { + controller.postMessage({ type: "deregister" }) + return + } + if (!document.getElementById("monaco-editor-embed")) return if (document.getElementById("monaco-editor-embed")!.childElementCount > 0) { return console.log("Playground already loaded") @@ -45,7 +68,7 @@ const Play: React.FC = (props) => { // a page reload to ensure the playground is full set up let leftPlayground = false window.addEventListener('popstate', (event) => { - const onPlayground = document.location.pathname.endsWith("/play/") || document.location.pathname.endsWith("/play") + const onPlayground = /\/play\/?$/.test(document.location.pathname) if (leftPlayground && onPlayground) { document.location.reload() } else if (!leftPlayground && !onPlayground) { @@ -118,11 +141,11 @@ const Play: React.FC = (props) => { em.textContent = `(${tsVersion})` const latestReleaseLink = document.createElement('a'); - latestReleaseLink.href = `/play?ts=${latestRelease}${document.location.hash}`; + latestReleaseLink.href = `${document.location.pathname}?ts=${latestRelease}${document.location.hash}`; latestReleaseLink.textContent = latestRelease; const nightlyLink = document.createElement('a'); - nightlyLink.href = `/play?ts=next${document.location.hash}`; + nightlyLink.href = `${document.location.pathname}?ts=next${document.location.hash}`; nightlyLink.textContent = 'Nightly'; loadingMessage.appendChild(document.createTextNode("This version of TypeScript ")) @@ -207,6 +230,7 @@ const Play: React.FC = (props) => { supportCustomPlugins: true } + sandboxRef.current = sandboxEnv playground.setupPlayground(sandboxEnv, main, playgroundConfig, i as any, React) // Dark mode faff @@ -221,12 +245,27 @@ const Play: React.FC = (props) => { } document.body.appendChild(getLoaderScript); + window.addEventListener("hashchange", redirectProject) + return () => window.removeEventListener("hashchange", redirectProject) }, []) return (

{i("head_playground_title")}

+ {/** This is the top nav, which is outside of the editor */}