Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 128 additions & 0 deletions packages/playground-v2/src/compiler-assets.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
url: URL,
read: (response: Response) => Promise<T>,
options: { cache?: RequestCache; timeout?: number; onRetry?(): void } = {}
): Promise<T> {
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<Uint8Array<ArrayBuffer>> {
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<Cache, "match" | "delete" | "put">
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
}
31 changes: 16 additions & 15 deletions packages/playground-v2/src/compiler-distribution.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { prerelease, valid } from "semver"
import { fetchCompilerResource, type CompilerAssetMetadata } from "./compiler-assets"

export type CompilerModules = {
sync: string
Expand All @@ -14,6 +15,7 @@ export type CompilerDistribution = CompilerModules & {
libraries: URL[]
assetBytes: number
estimatedDownloadBytes: number
assets: ReadonlyMap<string, CompilerAssetMetadata>
}

export async function loadPublishedCompilerDistribution(version: string): Promise<CompilerDistribution> {
Expand All @@ -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<string, number>, url: URL) {
const bytes = files.get(url.href)
function size(files: ReadonlyMap<string, CompilerAssetMetadata>, url: URL) {
const bytes = files.get(url.href)?.size
if (bytes === undefined) throw new Error(`Missing compiler file size: ${url.pathname}`)
return bytes
}
Expand All @@ -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 {
Expand All @@ -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 ||
Expand All @@ -87,23 +88,23 @@ async function loadPackage(name: string, version: string) {
}
}

async function loadFileSizes(base: URL, directory = base): Promise<ReadonlyMap<string, number>> {
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<ReadonlyMap<string, CompilerAssetMetadata>> {
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<string, number>()
const files = new Map<string, CompilerAssetMetadata>()
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
}
Expand Down
30 changes: 17 additions & 13 deletions packages/playground-v2/src/compiler-versions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { gte, prerelease, rcompare, valid } from "semver"
import { CompilerDownloadError, fetchCompilerResource } from "./compiler-assets"

export type PublishedCompilerVersions = {
versions: string[]
Expand Down Expand Up @@ -43,13 +44,17 @@ export function isNativeCompilerVersion(version: string | null) {
export async function loadPublishedCompilerVersions(): Promise<PublishedCompilerVersions> {
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 ||
Expand All @@ -70,12 +75,11 @@ export async function loadPublishedCompilerVersions(): Promise<PublishedCompiler
}

export async function loadLegacyCompilerVersions(): Promise<string[]> {
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) ||
Expand Down
2 changes: 1 addition & 1 deletion packages/playground-v2/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
location.replace(url)
}
</script>
<script src="./coi-serviceworker.min.js"></script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
<meta name="color-scheme" content="light dark" />
Expand Down Expand Up @@ -314,6 +313,7 @@ <h2 id="text-input-title">Enter a value</h2>
<p id="loading-message" role="status">Loading TypeScript...</p>
<progress id="loading-progress" max="100" value="0" aria-labelledby="loading-message"></progress>
<small id="loading-detail"></small>
<button id="retry-startup-button" class="action-button" type="button" hidden>Retry</button>
</div>
<section id="download-consent" class="download-consent" aria-labelledby="download-consent-title" hidden>
<h2 id="download-consent-title">Download TypeScript?</h2>
Expand Down
89 changes: 89 additions & 0 deletions packages/playground-v2/src/isolation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const reloadKey = "ts7-playground-isolation-reload"

export async function ensureCrossOriginIsolation() {
if (globalThis.crossOriginIsolated && typeof SharedArrayBuffer !== "undefined") {
try {
sessionStorage.removeItem(reloadKey)
} catch (error) {
console.warn("Could not clear the isolation reload marker", error)
}
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({ type: "coepCredentialless", value: false })
void navigator.serviceWorker
.register(new URL("./coi-serviceworker.min.js", import.meta.url), {
updateViaCache: "none",
})
.catch(error => console.warn("Could not update the TypeScript serviceworker", error))
}
return
}
const unavailable =
"This browser cannot enable the TypeScript language service. Open this page in your system browser, or choose TypeScript 6."
if (!isSecureContext || !navigator.serviceWorker) throw new Error(unavailable)
const controller = new AbortController()
const timeout = setTimeout(
() => controller.abort(new Error("Timed out enabling the TypeScript language service. Retry to reconnect.")),
15_000
)
try {
const registration = await Promise.race([
navigator.serviceWorker.register(new URL("./coi-serviceworker.min.js", import.meta.url), {
updateViaCache: "none",
}),
aborted(controller.signal),
])
const worker = registration.installing ?? registration.waiting ?? registration.active
if (!worker) throw new Error("The TypeScript serviceworker did not start. Retry to reconnect.")
if (worker.state !== "activated") {
await new Promise<void>((resolve, reject) => {
const change = () => {
if (worker.state === "activated") resolve()
else if (worker.state === "redundant")
reject(new Error("The TypeScript serviceworker could not activate. Retry to reconnect."))
}
worker.addEventListener("statechange", change, { signal: controller.signal })
controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true })
change()
})
}
if (navigator.serviceWorker.controller !== worker) {
await new Promise<void>((resolve, reject) => {
const change = () => {
if (navigator.serviceWorker.controller === worker) resolve()
}
navigator.serviceWorker.addEventListener("controllerchange", change, { signal: controller.signal })
controller.signal.addEventListener("abort", () => reject(controller.signal.reason), { once: true })
change()
})
}
// Safari does not reliably support credentialless; all runtime CDNs support CORS.
worker.postMessage({ type: "coepCredentialless", value: false })
if (sessionStorage.getItem(reloadKey) === location.href) throw new Error(unavailable)
sessionStorage.setItem(reloadKey, location.href)
clearTimeout(timeout)
location.reload()
// The next document, not a worker claiming this one, receives COOP/COEP.
await new Promise<never>(() => {})
} catch (error) {
if (error instanceof Error && error.message === unavailable) throw error
throw new Error(
`Could not enable the TypeScript language service: ${error instanceof Error ? error.message : String(error)}`
)
} finally {
clearTimeout(timeout)
controller.abort()
}
}

function aborted(signal: AbortSignal): Promise<never> {
return new Promise((_, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }))
}

export function retryStartup() {
try {
sessionStorage.removeItem(reloadKey)
} catch (error) {
console.warn("Could not clear the isolation reload marker", error)
}
location.reload()
}
Loading
Loading