From 2d87788d5f825e9cedc261519fd002feaff88939 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:34:12 -0800 Subject: [PATCH 1/2] Recover from incomplete Playground compiler downloads Interrupted transfers must not poison future page loads. Validate both old cached assets and new downloads against published sizes and hashes before caching, and bypass HTTP caches when recovering invalid bytes. Retry transient fetch failures and incomplete responses with bounded backoff. Cache-storage failures should not prevent a validated compiler from starting. --- packages/playground-v2/src/compiler-assets.ts | 128 ++++++++++++++++++ .../src/compiler-distribution.ts | 31 +++-- .../playground-v2/src/compiler-versions.ts | 30 ++-- packages/playground-v2/src/main.ts | 72 +++------- .../playground-v2/src/native-compiler.test.ts | 89 ++++++++++++ 5 files changed, 270 insertions(+), 80 deletions(-) create mode 100644 packages/playground-v2/src/compiler-assets.ts diff --git a/packages/playground-v2/src/compiler-assets.ts b/packages/playground-v2/src/compiler-assets.ts new file mode 100644 index 000000000000..b903a4bf5267 --- /dev/null +++ b/packages/playground-v2/src/compiler-assets.ts @@ -0,0 +1,128 @@ +export type CompilerAssetMetadata = { size: number; integrity: string } + +export class CompilerDownloadError extends Error { + constructor(url: string, readonly status: number) { + super(`Unable to load ${url}: HTTP ${status}`) + } +} + +export async function fetchCompilerResource( + url: URL, + read: (response: Response) => Promise, + options: { cache?: RequestCache; timeout?: number; onRetry?(): void } = {} +): Promise { + for (let attempt = 0; ; attempt++) { + let response: Response | undefined + try { + response = await fetch(url, { + signal: AbortSignal.timeout(options.timeout ?? 30_000), + cache: attempt === 0 ? options.cache : "no-store", + }) + if (!response.ok) throw new CompilerDownloadError(url.href, response.status) + return await read(response) + } catch (error) { + await response?.body?.cancel().catch(() => {}) + const permanent = + error instanceof CompilerDownloadError && error.status !== 408 && error.status !== 429 && error.status < 500 + if (attempt === 2 || permanent) throw error + console.warn(`Retrying compiler download (${attempt + 2}/3): ${url.href}`, error) + options.onRetry?.() + await new Promise(resolve => setTimeout(resolve, 300 * 2 ** attempt)) + } + } +} + +export async function readResponseBytes( + response: Response, + onProgress: (length: number) => void, + maximum = Infinity +): Promise> { + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()) + if (bytes.length > maximum) throw new Error("Compiler download is larger than expected") + onProgress(bytes.length) + return bytes + } + const chunks: Uint8Array[] = [] + const reader = response.body.getReader() + let length = 0 + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + length += value.length + if (length > maximum) throw new Error("Compiler download is larger than expected") + chunks.push(value) + onProgress(length) + } + } catch (error) { + await reader.cancel().catch(() => {}) + 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 + } + return bytes +} + +export async function downloadCompilerAsset( + url: URL, + metadata: CompilerAssetMetadata, + options: { + cache?: Pick + onProgress(length: number): void + onSource(cached: boolean): void + onRetry(): void + } +) { + const request = new Request(url) + const validate = async (response: Response) => { + if (!response.ok) throw new CompilerDownloadError(url.href, response.status) + const bytes = await readResponseBytes(response, options.onProgress, metadata.size) + if (bytes.length !== metadata.size) { + throw new Error(`Incomplete compiler download: ${url.pathname} (${bytes.length}/${metadata.size} bytes)`) + } + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)) + const integrity = `sha256-${btoa(String.fromCharCode(...digest))}` + if (integrity !== metadata.integrity) + throw new Error(`Compiler download failed integrity validation: ${url.pathname}`) + return bytes + } + let cached: Response | undefined + try { + cached = await options.cache?.match(request) + } catch (error) { + console.warn("Could not read the TypeScript asset cache", error) + } + if (cached) { + options.onSource(true) + try { + return await validate(cached) + } catch (error) { + console.warn(`Discarding invalid cached compiler asset: ${url.pathname}`, error) + try { + await options.cache?.delete(request) + } catch (cacheError) { + console.warn("Could not remove the invalid TypeScript asset", cacheError) + } + } + } + options.onSource(false) + const bytes = await fetchCompilerResource(url, validate, { + // A previous browser HTTP cache entry may be as incomplete as the app cache. + cache: cached ? "no-store" : "default", + timeout: 120_000, + onRetry: options.onRetry, + }) + try { + await options.cache?.put(request, new Response(bytes)) + } catch (error) { + console.warn("Could not cache the validated TypeScript asset", error) + } + return bytes +} diff --git a/packages/playground-v2/src/compiler-distribution.ts b/packages/playground-v2/src/compiler-distribution.ts index 58ea1d79f838..3772eb9ced54 100644 --- a/packages/playground-v2/src/compiler-distribution.ts +++ b/packages/playground-v2/src/compiler-distribution.ts @@ -1,4 +1,5 @@ import { prerelease, valid } from "semver" +import { fetchCompilerResource, type CompilerAssetMetadata } from "./compiler-assets" export type CompilerModules = { sync: string @@ -14,6 +15,7 @@ export type CompilerDistribution = CompilerModules & { libraries: URL[] assetBytes: number estimatedDownloadBytes: number + assets: ReadonlyMap } export async function loadPublishedCompilerDistribution(version: string): Promise { @@ -28,11 +30,11 @@ export async function loadPublishedCompilerDistribution(version: string): Promis 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), + loadFileMetadata(main.base), + loadFileMetadata(artifact.base, libraryDirectory), ]) - function size(files: ReadonlyMap, url: URL) { - const bytes = files.get(url.href) + function size(files: ReadonlyMap, url: URL) { + const bytes = files.get(url.href)?.size if (bytes === undefined) throw new Error(`Missing compiler file size: ${url.pathname}`) return bytes } @@ -47,7 +49,7 @@ export async function loadPublishedCompilerDistribution(version: string): Promis 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) + const moduleBytes = [...mainFiles].reduce((sum, [url, file]) => sum + (url.endsWith(".js") ? file.size : 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 { @@ -61,15 +63,14 @@ export async function loadPublishedCompilerDistribution(version: string): Promis libraries, assetBytes: wasmBytes + schemaBytes + libraryBytes, estimatedDownloadBytes, + assets: new Map([...mainFiles, ...artifactFiles]), } } 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() + const metadata: unknown = await fetchCompilerResource(url, response => response.json()) if ( !isRecord(metadata) || metadata.name !== name || @@ -87,23 +88,23 @@ async function loadPackage(name: string, version: string) { } } -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() +async function loadFileMetadata(base: URL, directory = base): Promise> { + const metadata: unknown = await fetchCompilerResource(new URL("?meta", directory), response => response.json()) if (!isRecord(metadata) || !Array.isArray(metadata.files)) throw new Error("Invalid compiler file sizes") - const files = new Map() + 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 + file.size < 0 || + typeof file.integrity !== "string" || + !/^sha256-[A-Za-z0-9+/]{43}=$/.test(file.integrity) ) { throw new Error("Invalid compiler file size") } - files.set(new URL(file.path.replace(/^\//, ""), base).href, file.size) + files.set(new URL(file.path.replace(/^\//, ""), base).href, { size: file.size, integrity: file.integrity }) } return files } diff --git a/packages/playground-v2/src/compiler-versions.ts b/packages/playground-v2/src/compiler-versions.ts index 020e227eb972..aa3a384e60d4 100644 --- a/packages/playground-v2/src/compiler-versions.ts +++ b/packages/playground-v2/src/compiler-versions.ts @@ -1,4 +1,5 @@ import { gte, prerelease, rcompare, valid } from "semver" +import { CompilerDownloadError, fetchCompilerResource } from "./compiler-assets" export type PublishedCompilerVersions = { versions: string[] @@ -43,13 +44,17 @@ export function isNativeCompilerVersion(version: string | null) { 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() + let metadata: unknown + try { + metadata = await fetchCompilerResource( + new URL(`https://unpkg.com/typescript@${tag}/package.json`), + response => response.json(), + { cache: "no-cache", timeout: 15_000 } + ) + } catch (error) { + if (error instanceof CompilerDownloadError && error.status === 404) return [tag, undefined] as const + throw error + } const version: unknown = metadata && typeof metadata === "object" ? Reflect.get(metadata, "version") : undefined if ( !metadata || @@ -70,12 +75,11 @@ export async function loadPublishedCompilerVersions(): 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 metadata: unknown = await fetchCompilerResource( + new URL("https://playgroundcdn.typescriptlang.org/indexes/releases.json"), + response => response.json(), + { cache: "no-cache", timeout: 15_000 } + ) const versions: unknown = metadata && typeof metadata === "object" ? Reflect.get(metadata, "versions") : undefined if ( !Array.isArray(versions) || diff --git a/packages/playground-v2/src/main.ts b/packages/playground-v2/src/main.ts index d9f49fca6c3c..4d18e89a76ce 100644 --- a/packages/playground-v2/src/main.ts +++ b/packages/playground-v2/src/main.ts @@ -1,4 +1,5 @@ import type { Diagnostic as CompilerDiagnostic } from "@typescript/typescript/sync" +import { downloadCompilerAsset, fetchCompilerResource, readResponseBytes } from "./compiler-assets" import { legacyCompilerBaseUrl, loadPublishedCompilerDistribution, @@ -976,13 +977,11 @@ async function initializeStradaCompiler(requestedVersion: string) { 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`) + await fetchCompilerResource(compilerUrl, response => + readResponseBytes(response, length => + setLoadingIndeterminate("Downloading TypeScript...", `${formatBytes(length)} received`) + ) ) ) setLoadingIndeterminate("Starting TypeScript...", "") @@ -1908,54 +1907,23 @@ async function downloadLibraries(distribution: CompilerDistribution) { } async function downloadAsset(name: string, url: URL): Promise> { - const request = new Request(url) const cache = await getAssetCache() - let response = await cache?.match(url) - cachedAssets.set(name, response !== undefined) - let cacheWrite: Promise | undefined - if (!response) { - response = await fetch(request) - } - if (!response.ok) { - throw new Error(`Unable to load ${url.pathname}: ${response.status} ${response.statusText}`) - } - if (cache && !cachedAssets.get(name)) cacheWrite = cache.put(request, response.clone()) - const bytes = await readResponseBytes(response, length => { - downloadedAssets.set(name, length) - updateDownloadProgress() + const metadata = nativeDistribution?.assets.get(url.href) + if (!metadata) throw new Error(`Compiler asset metadata is missing: ${url.pathname}`) + return downloadCompilerAsset(url, metadata, { + cache, + onSource(cached) { + cachedAssets.set(name, cached) + }, + onRetry() { + downloadedAssets.delete(name) + setLoadingIndeterminate("Retrying TypeScript download...", "") + }, + onProgress(length) { + downloadedAssets.set(name, length) + updateDownloadProgress() + }, }) - await cacheWrite - return bytes -} - -async function readResponseBytes( - response: Response, - onProgress: (length: number) => void -): Promise> { - if (!response.body) { - const bytes = new Uint8Array(await response.arrayBuffer()) - onProgress(bytes.length) - return bytes - } - - const chunks: Uint8Array[] = [] - const reader = response.body.getReader() - let length = 0 - for (;;) { - const { done, value } = await reader.read() - if (done) break - chunks.push(value) - length += value.length - onProgress(length) - } - - const bytes = new Uint8Array(length) - let offset = 0 - for (const chunk of chunks) { - bytes.set(chunk, offset) - offset += chunk.length - } - return bytes } function getAssetCache() { diff --git a/packages/playground-v2/src/native-compiler.test.ts b/packages/playground-v2/src/native-compiler.test.ts index bc12142d73e8..c30ec6c9cd42 100644 --- a/packages/playground-v2/src/native-compiler.test.ts +++ b/packages/playground-v2/src/native-compiler.test.ts @@ -23,6 +23,95 @@ import { import { createStradaFileSystem, parseStradaConfig } from "./strada-project" import { createPlaygroundV2Query } from "../../typescriptlang-org/src/lib/createPlaygroundV2Query" import { decodeCompressedHash, projectMarkdown, type ExportProject } from "./exporter" +import { downloadCompilerAsset } from "./compiler-assets" + +test("compiler downloads discard corrupt cached bytes and retry interrupted or incomplete responses", async () => { + const url = new URL("https://example.test/tsc.wasm") + const bytes = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0]) + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)) + const metadata = { size: bytes.length, integrity: `sha256-${Buffer.from(digest).toString("base64")}` } + const originalFetch = globalThis.fetch + const requests: RequestCache[] = [] + const writes: Response[] = [] + let deleted = 0 + let retries = 0 + const corrupt = new Response(new Uint8Array(bytes.length)) + const cache = { + match: async () => corrupt, + delete: async () => { + deleted++ + return true + }, + put: async (_url: unknown, response: Response) => { + writes.push(response) + }, + } + globalThis.fetch = async (_url, init) => { + requests.push(init?.cache ?? "default") + if (requests.length === 1) throw new TypeError("Connection interrupted") + return new Response(requests.length === 2 ? bytes.slice(0, -1) : bytes, { + headers: { "content-encoding": "gzip" }, + }) + } + try { + const result = await downloadCompilerAsset(url, metadata, { + cache, + onProgress() {}, + onSource() {}, + onRetry() { + retries++ + }, + }) + assert.deepEqual(result, bytes) + assert.equal(deleted, 1) + assert.equal(retries, 2) + assert.deepEqual(requests, ["no-store", "no-store", "no-store"]) + assert.equal(writes.length, 1) + assert.deepEqual(new Uint8Array(await writes[0].arrayBuffer()), bytes) + assert.equal(writes[0].headers.get("content-encoding"), null) + } finally { + globalThis.fetch = originalFetch + } +}) + +test("compiler download retries are bounded and never cache a bad transfer", async () => { + const originalFetch = globalThis.fetch + let requests = 0 + let writes = 0 + globalThis.fetch = async () => { + requests++ + return new Response(new Uint8Array(1)) + } + try { + await assert.rejects( + () => + downloadCompilerAsset( + new URL("https://example.test/tsc.wasm"), + { + size: 2, + integrity: `sha256-${"A".repeat(43)}=`, + }, + { + cache: { + match: async () => undefined, + delete: async () => false, + put: async () => { + writes++ + }, + }, + onProgress() {}, + onSource() {}, + onRetry() {}, + } + ), + /Incomplete compiler download/ + ) + assert.equal(requests, 3) + assert.equal(writes, 0) + } finally { + globalThis.fetch = originalFetch + } +}) test("the legacy handoff serializes effective options with its own compiler and retains the requested channel", () => { const ts: typeof import("typescript") = createRequire(import.meta.url)("typescript") From b0b68840dbbe713918f6b249a6334872659060d8 Mon Sep 17 00:00:00 2001 From: Jake Bailey <5341706+jakebailey@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:34:12 -0800 Subject: [PATCH 2/2] Wait for reliable Playground isolation before native startup Safari and embedded webviews should not race serviceworker activation or depend on credentialless isolation. Wait for control, use require-corp and bound the activation reload so unsupported browsers do not loop. Keep network rejections as rejections instead of null worker responses, and offer an actionable error and Retry when startup cannot recover. --- package.json | 3 +- packages/playground-v2/src/index.html | 2 +- packages/playground-v2/src/isolation.ts | 89 +++++++++++++++++++++++++ packages/playground-v2/src/main.ts | 8 +++ packages/playground-v2/src/styles.css | 9 ++- packages/playground-v2/src/tsgo-lsp.ts | 4 +- patches/coi-serviceworker@0.1.7.patch | 21 ++++++ pnpm-lock.yaml | 7 +- 8 files changed, 136 insertions(+), 7 deletions(-) create mode 100644 packages/playground-v2/src/isolation.ts create mode 100644 patches/coi-serviceworker@0.1.7.patch diff --git a/package.json b/package.json index ceeaec032342..1ad418b9a127 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ "patchedDependencies": { "@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" + "monaco-editor-core@0.56.0": "patches/monaco-editor-core@0.56.0.patch", + "coi-serviceworker@0.1.7": "patches/coi-serviceworker@0.1.7.patch" }, "ignoredBuiltDependencies": [ "@parcel/watcher", diff --git a/packages/playground-v2/src/index.html b/packages/playground-v2/src/index.html index cb0ff74cd56a..3c93de346730 100644 --- a/packages/playground-v2/src/index.html +++ b/packages/playground-v2/src/index.html @@ -10,7 +10,6 @@ location.replace(url) } - @@ -314,6 +313,7 @@

Enter a value

Loading TypeScript...

+