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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
index.ts
+
+
+ Read-only
+
+
+
+
+
+
+
+
+
+
+
+
Emit
+
+
+ Waiting for the compiler
+
+
+
+
+
+
+ Problems
+ 0
+
+
+
+
+
+
+
+
Run output
+
+
+
+
+
+
+
Run the project to see console output.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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