From df1c1a3f3f3c42510e1e904cecebb7e08a6bbd25 Mon Sep 17 00:00:00 2001 From: Oliver Eyton-Williams Date: Tue, 14 Oct 2025 11:44:52 +0200 Subject: [PATCH] refactor: modularize typescript worker (#62668) --- .../{ => modules}/react-types.json | 0 .../modules/typescript-compiler.ts | 88 ++++++++++++++++++ .../browser-scripts/typescript-worker.ts | 92 +++---------------- 3 files changed, 102 insertions(+), 78 deletions(-) rename tools/client-plugins/browser-scripts/{ => modules}/react-types.json (100%) create mode 100644 tools/client-plugins/browser-scripts/modules/typescript-compiler.ts diff --git a/tools/client-plugins/browser-scripts/react-types.json b/tools/client-plugins/browser-scripts/modules/react-types.json similarity index 100% rename from tools/client-plugins/browser-scripts/react-types.json rename to tools/client-plugins/browser-scripts/modules/react-types.json diff --git a/tools/client-plugins/browser-scripts/modules/typescript-compiler.ts b/tools/client-plugins/browser-scripts/modules/typescript-compiler.ts new file mode 100644 index 00000000000..80c1d45c917 --- /dev/null +++ b/tools/client-plugins/browser-scripts/modules/typescript-compiler.ts @@ -0,0 +1,88 @@ +import type { VirtualTypeScriptEnvironment } from '@typescript/vfs'; +import type { CompilerHost, CompilerOptions } from 'typescript'; + +import reactTypes from './react-types.json'; + +type TS = typeof import('typescript'); +type TSVFS = typeof import('@typescript/vfs'); + +export class Compiler { + ts: TS; + tsvfs: TSVFS; + tsEnv?: VirtualTypeScriptEnvironment; + compilerHost?: CompilerHost; + constructor( + ts: typeof import('typescript'), + tsvfs: typeof import('@typescript/vfs') + ) { + this.ts = ts; + this.tsvfs = tsvfs; + } + + async setup() { + const ts = this.ts; + const tsvfs = this.tsvfs; + + const compilerOptions: CompilerOptions = { + target: ts.ScriptTarget.ES2015, + module: ts.ModuleKind.Preserve, // Babel is handling module transformation, so TS should leave them alone. + skipLibCheck: true, // TODO: look into why this is needed. Are we doing something wrong? Could it be that it's not "synced" with this TS version? + // from the docs: "Note: it's possible for this list to get out of + // sync with TypeScript over time. It was last synced with TypeScript + // 3.8.0-rc." + jsx: ts.JsxEmit.Preserve, // Babel will handle JSX, + allowUmdGlobalAccess: true // Necessary because React is loaded via a UMD script. + }; + const fsMap = await tsvfs.createDefaultMapFromCDN( + compilerOptions, + ts.version, + false, // TODO: cache this. It needs a store that's available to workers and implements https://github.com/microsoft/TypeScript-Website/blob/ac68b8b8e4a621113c4ee45c4051002fd55ede24/packages/typescript-vfs/src/index.ts#L11 + ts + ); + + // This can be any path, but doing this means import React from 'react' works, if we ever need it. + const reactTypesPath = `/node_modules/@types/react/index.d.ts`; + + // It may be necessary to get all the types (global.d.ts etc) + fsMap.set(reactTypesPath, reactTypes['react-18'] || ''); + + const system = tsvfs.createSystem(fsMap); + // TODO: if passed an invalid compiler options object (e.g. { module: + // ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.NodeNext + // }), this will throw. When we allow users to set compiler options, we should + // show them the diagnostics from this function. + this.tsEnv = tsvfs.createVirtualTypeScriptEnvironment( + system, + [reactTypesPath], + ts, + compilerOptions + ); + + this.compilerHost = tsvfs.createVirtualCompilerHost( + system, + compilerOptions, + ts + ).compilerHost; + } + + compile(code: string, fileName: string) { + if (!this.tsEnv || !this.compilerHost) { + throw Error('TypeScript environment not set up'); + } + // TODO: If creating the file fresh each time is too slow, we can try checking + // if the file exists and updating it if it does. + this.tsEnv.createFile(fileName, code); + + const program = this.tsEnv.languageService.getProgram()!; + + const emitOutput = this.tsEnv.languageService.getEmitOutput(fileName); + const result = emitOutput.outputFiles[0].text; + + const error = this.ts.formatDiagnostics( + this.ts.getPreEmitDiagnostics(program), + this.compilerHost + ); + + return { result, error }; + } +} diff --git a/tools/client-plugins/browser-scripts/typescript-worker.ts b/tools/client-plugins/browser-scripts/typescript-worker.ts index 2c6397d134f..d2d13d7610e 100644 --- a/tools/client-plugins/browser-scripts/typescript-worker.ts +++ b/tools/client-plugins/browser-scripts/typescript-worker.ts @@ -1,14 +1,10 @@ -import { type VirtualTypeScriptEnvironment } from '@typescript/vfs'; -import type { CompilerOptions, CompilerHost } from 'typescript'; -import reactTypes from './react-types.json'; +import { Compiler } from './modules/typescript-compiler'; // Most of the ts types are only a guideline. This is because we're not bundling // TS in this worker. The specific TS version is going to be determined by the // challenge (in general - it will be hardcoded in the MVP). So, the vfs types // should be correct, but ts may not be. declare const tsvfs: typeof import('@typescript/vfs'); -declare const createDefaultMapFromCDN: typeof import('@typescript/vfs').createDefaultMapFromCDN; -declare const createVirtualCompilerHost: typeof import('@typescript/vfs').createVirtualCompilerHost; declare const ts: typeof import('typescript'); const ctx: Worker & typeof globalThis = self as unknown as Worker & @@ -43,13 +39,11 @@ interface CancelEvent extends MessageEvent { // Pin at the latest TS version available as cdnjs doesn't support version range. const TS_VERSION = '5.9.2'; -let tsEnv: VirtualTypeScriptEnvironment | null = null; -let compilerHost: CompilerHost | null = null; let cachedVersion: string | null = null; // NOTE: vfs.globals must only be imported once, otherwise it will throw. importScripts( - 'https://cdn.jsdelivr.net/npm/@typescript/vfs@1.6.0/dist/vfs.globals.js' + 'https://cdnjs.cloudflare.com/ajax/libs/typescript-vfs/1.6.1/vfs.globals.js' ); function importTS(version: string) { @@ -65,57 +59,6 @@ function importTS(version: string) { cachedVersion = version; } -async function setupTypeScript() { - importTS(TS_VERSION); - const compilerOptions: CompilerOptions = { - target: ts.ScriptTarget.ES2015, - module: ts.ModuleKind.Preserve, // Babel is handling module transformation, so TS should leave them alone. - skipLibCheck: true, // TODO: look into why this is needed. Are we doing something wrong? Could it be that it's not "synced" with this TS version? - // from the docs: "Note: it's possible for this list to get out of - // sync with TypeScript over time. It was last synced with TypeScript - // 3.8.0-rc." - jsx: ts.JsxEmit.Preserve, // Babel will handle JSX, - allowUmdGlobalAccess: true // Necessary because React is loaded via a UMD script. - }; - const fsMap = await createDefaultMapFromCDN( - compilerOptions, - ts.version, - false, // TODO: cache this. It needs a store that's available to workers and implements https://github.com/microsoft/TypeScript-Website/blob/ac68b8b8e4a621113c4ee45c4051002fd55ede24/packages/typescript-vfs/src/index.ts#L11 - ts - ); - - // This can be any path, but doing this means import React from 'react' works, if we ever need it. - const reactTypesPath = `/node_modules/@types/react/index.d.ts`; - - // It may be necessary to get all the types (global.d.ts etc) - fsMap.set(reactTypesPath, reactTypes['react-18'] || ''); - - const system = tsvfs.createSystem(fsMap); - // TODO: if passed an invalid compiler options object (e.g. { module: - // ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.NodeNext - // }), this will throw. When we allow users to set compiler options, we should - // show them the diagnostics from this function. - const env = tsvfs.createVirtualTypeScriptEnvironment( - system, - [reactTypesPath], - ts, - compilerOptions - ); - - compilerHost = createVirtualCompilerHost( - system, - compilerOptions, - ts - ).compilerHost; - - tsEnv = env; - - // We freeze this to prevent learners from getting the worker into a - // weird state. - Object.freeze(self); - return env; -} - ctx.onmessage = ( e: TSCompileEvent | CheckIsReadyRequestEvent | CancelEvent ) => { @@ -129,7 +72,10 @@ ctx.onmessage = ( } }; -const isTSSetup = setupTypeScript(); +importTS(TS_VERSION); + +const compiler = new Compiler(ts, tsvfs); +const isSetup = compiler.setup(); // This lets the client know that there is nothing to cancel. function handleCancelRequest({ value }: { value: number }) { @@ -137,7 +83,11 @@ function handleCancelRequest({ value }: { value: number }) { } async function handleCheckIsReadyRequest(port: MessagePort) { - await isTSSetup; + await isSetup; + // We freeze this to prevent learners from getting the worker into a weird + // state. + Object.freeze(self); + port.postMessage({ type: 'ready' }); } @@ -146,25 +96,11 @@ function handleCompileRequest(data: TSCompileEvent['data'], port: MessagePort) { // permanently unable to interact with that file. The workaround is to create // a file with a single newline character. const code = (data.code || '').slice() || '\n'; - - // TODO: If creating the file fresh each time is too slow, we can try checking - // if the file exists and updating it if it does. - // TODO: make sure the .tsx extension doesn't cause issues with vanilla TS. - tsEnv?.createFile('/index.tsx', code); - - const program = tsEnv!.languageService.getProgram()!; - - const emitOutput = tsEnv!.languageService.getEmitOutput('index.tsx'); - const compiled = emitOutput.outputFiles[0].text; - + const { result, error } = compiler.compile(code, 'index.tsx'); const message: TSCompiledMessage = { type: 'compiled', - value: compiled, - // TODO: stop forcing the non-null assertions here. - error: ts.formatDiagnostics( - ts.getPreEmitDiagnostics(program), - compilerHost! - ) + value: result, + error: error }; port.postMessage(message);