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/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/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...

+