From c844dca12bda82ad134196707c3d8e52c49ebbcd Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Fri, 3 Apr 2026 12:54:19 -0400 Subject: [PATCH] Playground with web workers POC --- packages/bundler/src/index.ts | 1 + packages/bundler/src/vite/vite-plugin.ts | 50 ++- packages/bundler/src/worker-bundler.ts | 175 ++++++++++ .../playground/src/compilation-service.ts | 158 +++++++++ .../react/diagnostic-list/diagnostic-list.tsx | 72 ++++ .../src/react/output-view/file-viewer.tsx | 101 +++++- .../src/react/output-view/output-view.tsx | 79 ++++- .../src/react/problem-pane/header.tsx | 35 +- .../src/react/problem-pane/problem-pane.tsx | 40 ++- packages/playground/src/react/types.ts | 26 +- packages/playground/src/vite/index.ts | 3 + packages/playground/src/vite/types.ts | 6 + .../src/workers/compile-worker-client.ts | 118 +++++++ .../src/workers/compile-worker-handler.ts | 324 ++++++++++++++++++ .../playground/src/workers/serialization.ts | 100 ++++++ packages/playground/src/workers/types.ts | 84 +++++ 16 files changed, 1336 insertions(+), 36 deletions(-) create mode 100644 packages/bundler/src/worker-bundler.ts create mode 100644 packages/playground/src/compilation-service.ts create mode 100644 packages/playground/src/workers/compile-worker-client.ts create mode 100644 packages/playground/src/workers/compile-worker-handler.ts create mode 100644 packages/playground/src/workers/serialization.ts create mode 100644 packages/playground/src/workers/types.ts diff --git a/packages/bundler/src/index.ts b/packages/bundler/src/index.ts index 8acf2351ab4..2b37b92fdf3 100644 --- a/packages/bundler/src/index.ts +++ b/packages/bundler/src/index.ts @@ -6,3 +6,4 @@ export { TypeSpecBundleFile, createTypeSpecBundle, } from "./bundler.js"; +export { createWorkerBundle, WorkerBundleOptions } from "./worker-bundler.js"; diff --git a/packages/bundler/src/vite/vite-plugin.ts b/packages/bundler/src/vite/vite-plugin.ts index 3e581267247..fb6ab75ff9a 100644 --- a/packages/bundler/src/vite/vite-plugin.ts +++ b/packages/bundler/src/vite/vite-plugin.ts @@ -8,6 +8,7 @@ import { createTypeSpecBundle, watchTypeSpecBundle, } from "../bundler.js"; +import { createWorkerBundle } from "../worker-bundler.js"; export interface TypeSpecBundlePluginOptions { readonly folderName: string; @@ -16,12 +17,37 @@ export interface TypeSpecBundlePluginOptions { * Name of libraries to bundle. */ readonly libraries: readonly string[]; + + /** + * Whether to generate a self-contained compile worker bundle. + * When enabled, a `compile-worker.js` file is generated that includes + * all libraries with peer deps inlined, suitable for use in a Web Worker. + * @default false + */ + readonly generateCompileWorker?: boolean; + + /** + * Custom handler code to include in the compile worker. + * This code runs inside the worker after all libraries are loaded. + * The variable `self.__typespec_libraries` is available with all library modules. + */ + readonly compileWorkerHandlerCode?: string; } export function typespecBundlePlugin(options: TypeSpecBundlePluginOptions): Plugin { let config: ResolvedConfig; const definitions: Record = {}; const bundles: Record = {}; + let workerBundleContent: string | undefined; + + async function buildWorkerBundleIfNeeded(minify: boolean) { + if (!options.generateCompileWorker) return; + workerBundleContent = await createWorkerBundle({ + bundles, + workerHandlerCode: options.compileWorkerHandlerCode, + minify, + }); + } return { name: "typespec-bundle", @@ -37,6 +63,7 @@ export function typespecBundlePlugin(options: TypeSpecBundlePluginOptions): Plug bundles[name] = bundle; definitions[name] = bundle.definition; } + await buildWorkerBundleIfNeeded(minify); }, async configureServer(server) { server.middlewares.use((req, res, next) => { @@ -47,6 +74,16 @@ export function typespecBundlePlugin(options: TypeSpecBundlePluginOptions): Plug } const start = `/${options.folderName}/`; + // Serve the compile worker bundle + if (options.generateCompileWorker && id === `${start}compile-worker.js`) { + if (workerBundleContent) { + res.writeHead(200, "Ok", { "Content-Type": "application/javascript" }); + res.write(workerBundleContent); + res.end(); + return; + } + } + const resolveFilename = (path: string) => { if (path === "") { return "index.js"; @@ -86,9 +123,11 @@ export function typespecBundlePlugin(options: TypeSpecBundlePluginOptions): Plug void watchBundleLibrary( config.root, library, - (bundle) => { + async (bundle) => { bundles[library] = bundle; definitions[library] = bundle.definition; + // Rebuild worker bundle when any library changes + await buildWorkerBundleIfNeeded(false); server.ws.send({ type: "full-reload" }); }, { minify: false }, @@ -106,6 +145,15 @@ export function typespecBundlePlugin(options: TypeSpecBundlePluginOptions): Plug }); } } + + // Emit the compile worker bundle + if (options.generateCompileWorker && workerBundleContent) { + this.emitFile({ + type: "asset", + fileName: `${options.folderName}/compile-worker.js`, + source: workerBundleContent, + }); + } }, transformIndexHtml: { diff --git a/packages/bundler/src/worker-bundler.ts b/packages/bundler/src/worker-bundler.ts new file mode 100644 index 00000000000..c20bf9012c4 --- /dev/null +++ b/packages/bundler/src/worker-bundler.ts @@ -0,0 +1,175 @@ +import { Plugin as EsbuildPlugin, context as esbuildContext } from "esbuild"; +import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; +import type { TypeSpecBundle } from "./bundler.js"; + +export interface WorkerBundleOptions { + /** + * The built library bundles, keyed by library name. + */ + readonly bundles: Record; + + /** + * Additional code to include in the worker entry point. + * This code runs after all libraries are loaded and can reference + * the `__libraries` and `__compiler` globals set by the generated entry. + */ + readonly workerHandlerCode?: string; + + /** + * Whether to minify the output. + * @default false + */ + readonly minify?: boolean; +} + +/** + * Create a self-contained worker bundle that includes all TypeSpec libraries + * with peer dependencies fully inlined. + * + * The resulting JS module has no bare specifier imports and can be loaded + * in a Web Worker without import maps. + */ +export async function createWorkerBundle(options: WorkerBundleOptions): Promise { + const { bundles, workerHandlerCode, minify = false } = options; + + // Build a mapping of all library files (keyed by virtual path) + const libraryFiles = new Map(); + + for (const [libName, bundle] of Object.entries(bundles)) { + for (const file of bundle.files) { + libraryFiles.set(`${libName}/${file.filename}`, file.content); + } + } + + const libraryNames = Object.keys(bundles); + + // Generate the virtual entry point that imports all libraries + const entryCode = generateWorkerEntry(libraryNames, workerHandlerCode); + + const resolverPlugin: EsbuildPlugin = { + name: "worker-bundle-resolver", + setup(build) { + // Resolve the virtual entry + build.onResolve({ filter: /^virtual:worker-entry$/ }, () => ({ + path: "virtual:worker-entry", + namespace: "worker-entry", + })); + + // Resolve library bare specifier imports (e.g., "@typespec/compiler") + build.onResolve({ filter: /.*/ }, (args) => { + // Check if it's a known library's main entry + for (const libName of libraryNames) { + if (args.path === libName) { + return { path: `${libName}/index.js`, namespace: "lib-bundle" }; + } + // Check for subpath imports (e.g., "@typespec/compiler/src/foo") + if (args.path.startsWith(libName + "/")) { + const subpath = args.path.slice(libName.length + 1); + // Try with .js extension if not present + const filename = subpath.endsWith(".js") ? subpath : `${subpath}.js`; + return { path: `${libName}/${filename}`, namespace: "lib-bundle" }; + } + } + + // Handle relative imports within a library bundle (e.g., "./chunk-ABC.js") + if (args.namespace === "lib-bundle" && args.path.startsWith(".")) { + const importerDir = args.importer.substring(0, args.importer.lastIndexOf("/")); + const resolved = resolveRelativePath(importerDir, args.path); + return { path: resolved, namespace: "lib-bundle" }; + } + + return undefined; + }); + + // Load the virtual entry + build.onLoad({ filter: /.*/, namespace: "worker-entry" }, () => ({ + contents: entryCode, + loader: "js", + })); + + // Load library bundle files from memory + build.onLoad({ filter: /.*/, namespace: "lib-bundle" }, (args) => { + const content = libraryFiles.get(args.path); + if (content !== undefined) { + return { contents: content, loader: "js" }; + } + // Try without .js extension + const withoutExt = args.path.replace(/\.js$/, ""); + const altContent = libraryFiles.get(withoutExt); + if (altContent !== undefined) { + return { contents: altContent, loader: "js" }; + } + return undefined; + }); + }, + }; + + const ctx = await esbuildContext({ + write: false, + entryPoints: { "compile-worker": "virtual:worker-entry" }, + bundle: true, + format: "esm", + platform: "browser", + target: "es2024", + minify, + keepNames: minify, + plugins: [resolverPlugin, nodeModulesPolyfillPlugin({})], + }); + + try { + const result = await ctx.rebuild(); + const outputFile = result.outputFiles?.[0]; + if (!outputFile) { + throw new Error("Worker bundle produced no output"); + } + return outputFile.text; + } finally { + await ctx.dispose(); + } +} + +/** + * Generate the worker entry point code that imports all libraries + * and sets up the worker message handler. + */ +function generateWorkerEntry(libraryNames: string[], handlerCode?: string): string { + const imports = libraryNames.map( + (name, i) => `import * as __lib${i} from "${name}";`, + ); + + const libraryMap = libraryNames.map( + (name, i) => ` ${JSON.stringify(name)}: __lib${i},`, + ); + + return [ + "// Auto-generated worker entry point", + ...imports, + "", + "const __allLibraries = {", + ...libraryMap, + "};", + "", + "// Make libraries available to the handler code", + "self.__typespec_libraries = __allLibraries;", + "", + handlerCode ?? "// No worker handler code provided", + ].join("\n"); +} + +/** + * Resolve a relative path against a base directory. + */ +function resolveRelativePath(base: string, relative: string): string { + const parts = base.split("/").filter(Boolean); + const relParts = relative.split("/"); + + for (const part of relParts) { + if (part === "..") { + parts.pop(); + } else if (part !== ".") { + parts.push(part); + } + } + + return parts.join("/"); +} diff --git a/packages/playground/src/compilation-service.ts b/packages/playground/src/compilation-service.ts new file mode 100644 index 00000000000..1c1cc7434d7 --- /dev/null +++ b/packages/playground/src/compilation-service.ts @@ -0,0 +1,158 @@ +import type { CompilerOptions, Diagnostic, Program } from "@typespec/compiler"; +import type { BrowserHost } from "./types.js"; +import { CompileWorkerClient } from "./workers/compile-worker-client.js"; +import type { WorkerCompilationResult } from "./workers/types.js"; + +/** + * Result of a main-thread compilation (has live Program object). + */ +export interface MainThreadCompilationResult { + readonly kind: "main-thread"; + readonly program: Program; + readonly outputFiles: string[]; +} + +/** + * Result of a worker compilation (has serialized data). + */ +export interface WorkerCompilationResultWithKind extends WorkerCompilationResult { + readonly kind: "worker"; +} + +/** + * Result of a failed compilation. + */ +export interface CompilationError { + readonly kind: "error"; + readonly error: unknown; +} + +/** + * Union of all possible compilation results. + */ +export type CompilationResult = + | MainThreadCompilationResult + | WorkerCompilationResultWithKind + | CompilationError; + +/** + * Interface for a compilation service that can compile TypeSpec code. + */ +export interface CompilationService { + compile( + content: string, + selectedEmitter: string, + options: CompilerOptions, + ): Promise; + dispose(): void; +} + +/** + * Compilation service that runs on the main thread. + * Returns live Program objects (needed for type graph viewer and custom ProgramViewers). + */ +export class MainThreadCompilationService implements CompilationService { + constructor(private host: BrowserHost) {} + + async compile( + content: string, + selectedEmitter: string, + options: CompilerOptions, + ): Promise { + const { resolvePath } = await import("@typespec/compiler"); + const outputDir = resolvePath("/test", "tsp-output"); + + await this.host.writeFile("main.tsp", content); + await this.emptyOutputDir(); + + try { + const program = await this.host.compiler.compile( + this.host, + resolvePath("/test", "main.tsp"), + { + ...options, + options: { + ...options.options, + [selectedEmitter]: { + ...options.options?.[selectedEmitter], + "emitter-output-dir": outputDir, + }, + }, + outputDir, + emit: selectedEmitter ? [selectedEmitter] : [], + }, + ); + + const outputFiles = await this.findOutputFiles(); + return { kind: "main-thread", program, outputFiles }; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Internal compiler error", error); + return { kind: "error", error }; + } + } + + dispose(): void { + // No-op for main thread + } + + private async emptyOutputDir(): Promise { + const dirs = await this.host.readDir("./tsp-output"); + for (const file of dirs) { + await this.host.rm("./tsp-output/" + file); + } + } + + private async findOutputFiles(): Promise { + const { resolvePath } = await import("@typespec/compiler"); + const outputDir = resolvePath("/test", "tsp-output"); + const files: string[] = []; + + const addFiles = async (dir: string) => { + const items = await this.host.readDir(outputDir + dir); + for (const item of items) { + const itemPath = `${dir}/${item}`; + if ((await this.host.stat(outputDir + itemPath)).isDirectory()) { + await addFiles(itemPath); + } else { + files.push(dir === "" ? item : `${dir}/${item}`); + } + } + }; + + await addFiles(""); + return files; + } +} + +/** + * Compilation service that runs in a Web Worker. + * Returns serialized results (no live Program object). + */ +export class WorkerCompilationService implements CompilationService { + private client: CompileWorkerClient; + + constructor(workerUrl: string) { + this.client = new CompileWorkerClient(workerUrl); + } + + async compile( + content: string, + selectedEmitter: string, + options: CompilerOptions, + ): Promise { + try { + const result = await this.client.compile(content, selectedEmitter, options); + if ("internalCompilerError" in result) { + return { kind: "error", error: (result as any).internalCompilerError }; + } + return { ...result, kind: "worker" }; + } catch (error) { + return { kind: "error", error }; + } + } + + dispose(): void { + this.client.dispose(); + } +} diff --git a/packages/playground/src/react/diagnostic-list/diagnostic-list.tsx b/packages/playground/src/react/diagnostic-list/diagnostic-list.tsx index 2f58bf98482..5ec550dcf82 100644 --- a/packages/playground/src/react/diagnostic-list/diagnostic-list.tsx +++ b/packages/playground/src/react/diagnostic-list/diagnostic-list.tsx @@ -6,6 +6,7 @@ import { type NoTarget, } from "@typespec/compiler"; import { memo, useCallback, type FunctionComponent } from "react"; +import type { SerializedDiagnostic } from "../../workers/types.js"; import style from "./diagnostic-list.module.css"; export interface DiagnosticListProps { @@ -35,6 +36,37 @@ export const DiagnosticList: FunctionComponent = ({ ); }; +// ── Serialized Diagnostic List (for worker results) ── + +export interface SerializedDiagnosticListProps { + readonly diagnostics: readonly SerializedDiagnostic[]; + readonly onDiagnosticSelected?: (diagnostic: SerializedDiagnostic) => void; +} + +export const SerializedDiagnosticList: FunctionComponent = ({ + diagnostics, + onDiagnosticSelected, +}) => { + const handleItemSelected = useCallback( + (diagnostic: SerializedDiagnostic) => { + onDiagnosticSelected?.(diagnostic); + }, + [onDiagnosticSelected], + ); + if (diagnostics.length === 0) { + return
No errors
; + } + return ( +
+ {diagnostics.map((x, i) => { + return ( + + ); + })} +
+ ); +}; + interface DiagnosticItemProps { readonly diagnostic: Diagnostic; readonly onItemSelected: (diagnostic: Diagnostic) => void; @@ -85,3 +117,43 @@ const DiagnosticTargetLink = memo(({ target }: { target: DiagnosticTarget | type ); }); + +// ── Serialized Diagnostic Item (for worker results) ── + +interface SerializedDiagnosticItemProps { + readonly diagnostic: SerializedDiagnostic; + readonly onItemSelected: (diagnostic: SerializedDiagnostic) => void; +} + +const SerializedDiagnosticItem: FunctionComponent = ({ + diagnostic, + onItemSelected, +}) => { + const handleClick = useCallback(() => { + onItemSelected(diagnostic); + }, [diagnostic, onItemSelected]); + return ( +
+
+ {diagnostic.severity} +
+
{diagnostic.code}
+
{diagnostic.message}
+
+ {diagnostic.target ? ( + + {diagnostic.target.file === "/test/main.tsp" ? "" : `${diagnostic.target.file}:`} + {diagnostic.target.startLine + 1}:{diagnostic.target.startColumn + 1} + + ) : ( + No target + )} +
+
+ ); +}; diff --git a/packages/playground/src/react/output-view/file-viewer.tsx b/packages/playground/src/react/output-view/file-viewer.tsx index c65c3a02624..39ac3b9fe8a 100644 --- a/packages/playground/src/react/output-view/file-viewer.tsx +++ b/packages/playground/src/react/output-view/file-viewer.tsx @@ -1,6 +1,6 @@ import { FolderListRegular } from "@fluentui/react-icons"; import { Pane, SplitPane } from "@typespec/react-components"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { FileBreadcrumb } from "../breadcrumb/index.js"; import { FileOutput } from "../file-output/file-output.js"; import { FileTreeExplorer } from "../file-tree/index.js"; @@ -103,3 +103,102 @@ export function createFileViewer(fileViewers: FileOutputViewer[]): ProgramViewer }, }; } + +/** + * File viewer for worker results that reads from a content map instead of program.host. + */ +export interface WorkerFileViewerProps { + readonly outputFiles: string[]; + readonly outputContents: Record; +} + +interface WorkerFileViewerDef { + readonly key: string; + readonly label: string; + readonly render: (props: WorkerFileViewerProps) => ReactNode; +} + +const WorkerFileViewerComponent = ({ + outputFiles, + outputContents, + fileViewers, +}: WorkerFileViewerProps & { fileViewers: Record }) => { + const [filename, setFilename] = useState(""); + + const showFileTree = useMemo( + () => + outputFiles.length > 1 && + (outputFiles.some((f) => f.includes("/")) || outputFiles.length >= 3), + [outputFiles], + ); + + const content = useMemo(() => { + return outputContents[filename] ?? ""; + }, [outputContents, filename]); + + useEffect(() => { + if (outputFiles.length > 0) { + const fileStillThere = outputFiles.find((x) => x === filename); + setFilename(fileStillThere ?? outputFiles[0]); + } else { + setFilename(""); + } + }, [outputFiles, filename]); + + const handleFileSelection = useCallback( + (newFilename: string) => { + if (outputFiles.includes(newFilename)) { + setFilename(newFilename); + } + }, + [outputFiles], + ); + + if (outputFiles.length === 0) { + return <>No files emitted.; + } + + if (showFileTree) { + return ( +
+ + + + + +
+ +
+ +
+
+
+
+
+ ); + } + + return ( +
+ +
+ +
+
+ ); +}; + +export function createWorkerFileViewer(fileViewers: FileOutputViewer[]): WorkerFileViewerDef { + const viewerMap = Object.fromEntries(fileViewers.map((x) => [x.key, x])); + return { + key: "worker-file-output", + label: "Output explorer", + render: (props) => { + return ; + }, + }; +} diff --git a/packages/playground/src/react/output-view/output-view.tsx b/packages/playground/src/react/output-view/output-view.tsx index c71808e73d9..689386f26f9 100644 --- a/packages/playground/src/react/output-view/output-view.tsx +++ b/packages/playground/src/react/output-view/output-view.tsx @@ -2,8 +2,17 @@ import { Button, Tab, TabList, type SelectTabEventHandler } from "@fluentui/reac import { useCallback, useMemo, useState, type FunctionComponent } from "react"; import { ErrorBoundary, type FallbackProps } from "react-error-boundary"; import type { PlaygroundEditorsOptions } from "../playground.js"; -import type { CompilationState, CompileResult, FileOutputViewer, ProgramViewer } from "../types.js"; -import { createFileViewer } from "./file-viewer.js"; +import { + hasProgram, + isCrashed, + isWorkerResult, + type CompilationState, + type CompileResult, + type FileOutputViewer, + type ProgramViewer, + type WorkerCompileResult, +} from "../types.js"; +import { createFileViewer, createWorkerFileViewer } from "./file-viewer.js"; import { TypeGraphViewer } from "./type-graph-viewer.js"; import style from "./output-view.module.css"; @@ -51,19 +60,35 @@ export const OutputView: FunctionComponent = ({ if (compilationState === undefined) { return <>; } - if ("internalCompilerError" in compilationState) { + if (isCrashed(compilationState)) { return <>; } - return ( - - ); + + if (hasProgram(compilationState)) { + return ( + + ); + } + + if (isWorkerResult(compilationState)) { + return ( + + ); + } + + return <>; }; function resolveViewers( @@ -164,3 +189,31 @@ function fallbackRender({ error, resetErrorBoundary }: FallbackProps) { ); } + +/** + * Simple output view for worker results - renders output files directly + * from the serialized file content map (no Program object needed). + */ +const WorkerOutputView: FunctionComponent<{ + compilationResult: WorkerCompileResult; + fileViewers: FileOutputViewer[]; + selectedViewer?: string; + onViewerChange?: (viewerKey: string) => void; +}> = ({ compilationResult, fileViewers }) => { + const workerFileViewer = useMemo( + () => createWorkerFileViewer(fileViewers), + [fileViewers], + ); + const outputFiles = Object.keys(compilationResult.outputFiles); + const outputContents = compilationResult.outputFiles; + + return ( +
+
+ + + +
+
+ ); +}; diff --git a/packages/playground/src/react/problem-pane/header.tsx b/packages/playground/src/react/problem-pane/header.tsx index ecddc475888..7b51e5ab6e7 100644 --- a/packages/playground/src/react/problem-pane/header.tsx +++ b/packages/playground/src/react/problem-pane/header.tsx @@ -1,7 +1,7 @@ import { mergeClasses } from "@fluentui/react-components"; import { ChevronDown16Regular, ErrorCircle16Filled, Warning16Filled } from "@fluentui/react-icons"; import { memo, type MouseEventHandler, type ReactNode } from "react"; -import type { CompilationState } from "../types.js"; +import { hasProgram, isCrashed, isWorkerResult, type CompilationState } from "../types.js"; import style from "./header.module.css"; export interface ProblemPaneHeaderProps { @@ -19,29 +19,42 @@ export const ProblemPaneHeader = memo(({ compilationState, ...props }: ProblemPa if (compilationState === undefined) { return noProblem; } - if ("internalCompilerError" in compilationState) { + if (isCrashed(compilationState)) { return ( Internal Compiler Error ); } - const diagnostics = compilationState.program.diagnostics; - if (diagnostics.length === 0) { + + // Get diagnostics from either main-thread or worker result + let errorCount = 0; + let warningCount = 0; + if (hasProgram(compilationState)) { + for (const d of compilationState.program.diagnostics) { + if (d.severity === "error") errorCount++; + else warningCount++; + } + } else if (isWorkerResult(compilationState)) { + for (const d of compilationState.diagnostics) { + if (d.severity === "error") errorCount++; + else warningCount++; + } + } + + if (errorCount === 0 && warningCount === 0) { return noProblem; } - const errors = diagnostics.filter((x) => x.severity === "error"); - const warnings = diagnostics.filter((x) => x.severity === "warning"); return ( - 0 ? "error" : "warning"} {...props}> - {errors.length > 0 ? ( + 0 ? "error" : "warning"} {...props}> + {errorCount > 0 ? ( <> - {errors.length} errors + {errorCount} errors ) : null} - {warnings.length > 0 ? ( + {warningCount > 0 ? ( <> - {warnings.length} warnings + {warningCount} warnings ) : null} diff --git a/packages/playground/src/react/problem-pane/problem-pane.tsx b/packages/playground/src/react/problem-pane/problem-pane.tsx index 91eea9ab67c..140f1f6a7b6 100644 --- a/packages/playground/src/react/problem-pane/problem-pane.tsx +++ b/packages/playground/src/react/problem-pane/problem-pane.tsx @@ -1,7 +1,8 @@ import type { Diagnostic } from "@typespec/compiler"; import type { FunctionComponent, MouseEventHandler } from "react"; -import { DiagnosticList } from "../diagnostic-list/diagnostic-list.js"; -import type { CompilationState } from "../types.js"; +import type { SerializedDiagnostic } from "../../workers/types.js"; +import { DiagnosticList, SerializedDiagnosticList } from "../diagnostic-list/diagnostic-list.js"; +import { hasProgram, isCrashed, isWorkerResult, type CompilationState } from "../types.js"; import { ProblemPaneHeader } from "./header.js"; import style from "./problem-pane.module.css"; @@ -10,12 +11,14 @@ export interface ProblemPaneProps { readonly compilationState: CompilationState | undefined; readonly onHeaderClick?: MouseEventHandler; readonly onDiagnosticSelected?: (diagnostic: Diagnostic) => void; + readonly onSerializedDiagnosticSelected?: (diagnostic: SerializedDiagnostic) => void; } export const ProblemPane: FunctionComponent = ({ collapsed, compilationState, onHeaderClick, onDiagnosticSelected, + onSerializedDiagnosticSelected, }) => { return (
@@ -28,6 +31,7 @@ export const ProblemPane: FunctionComponent = ({
@@ -37,25 +41,43 @@ export const ProblemPane: FunctionComponent = ({ interface ProblemPaneContentProps { readonly compilationState: CompilationState | undefined; readonly onDiagnosticSelected?: (diagnostic: Diagnostic) => void; + readonly onSerializedDiagnosticSelected?: (diagnostic: SerializedDiagnostic) => void; } const ProblemPaneContent: FunctionComponent = ({ compilationState, onDiagnosticSelected, + onSerializedDiagnosticSelected, }) => { if (compilationState === undefined) { return <>; } - if ("internalCompilerError" in compilationState) { + if (isCrashed(compilationState)) { return (
         {String(compilationState.internalCompilerError)}
       
); } - const diagnostics = compilationState.program.diagnostics; - return diagnostics.length === 0 ? ( -
No problems
- ) : ( - - ); + + if (hasProgram(compilationState)) { + const diagnostics = compilationState.program.diagnostics; + return diagnostics.length === 0 ? ( +
No problems
+ ) : ( + + ); + } + + if (isWorkerResult(compilationState)) { + return compilationState.diagnostics.length === 0 ? ( +
No problems
+ ) : ( + + ); + } + + return <>; }; diff --git a/packages/playground/src/react/types.ts b/packages/playground/src/react/types.ts index 876c218e2a1..e753f12becb 100644 --- a/packages/playground/src/react/types.ts +++ b/packages/playground/src/react/types.ts @@ -1,15 +1,39 @@ import type { Program } from "@typespec/compiler"; import type { ReactNode } from "react"; +import type { SerializedDiagnostic } from "../workers/types.js"; export type CompilationCrashed = { readonly internalCompilerError: any; }; +/** Result from main-thread compilation (has live Program). */ export type CompileResult = { readonly program: Program; readonly outputFiles: string[]; }; -export type CompilationState = CompileResult | CompilationCrashed; + +/** Result from worker compilation (has serialized data). */ +export type WorkerCompileResult = { + readonly outputFiles: Record; + readonly diagnostics: readonly SerializedDiagnostic[]; +}; + +export type CompilationState = CompileResult | WorkerCompileResult | CompilationCrashed; + +/** Type guard: check if result has a live Program */ +export function hasProgram(state: CompilationState): state is CompileResult { + return "program" in state; +} + +/** Type guard: check if result is from worker */ +export function isWorkerResult(state: CompilationState): state is WorkerCompileResult { + return "diagnostics" in state && !("program" in state); +} + +/** Type guard: check if result is a crash */ +export function isCrashed(state: CompilationState): state is CompilationCrashed { + return "internalCompilerError" in state; +} export type EmitterOptions = Record>; diff --git a/packages/playground/src/vite/index.ts b/packages/playground/src/vite/index.ts index 9d81d898c4c..8a18a5cc28d 100644 --- a/packages/playground/src/vite/index.ts +++ b/packages/playground/src/vite/index.ts @@ -1,6 +1,7 @@ import { typespecBundlePlugin } from "@typespec/bundler/vite"; import react from "@vitejs/plugin-react"; import type { Plugin, ResolvedConfig, UserConfig } from "vite"; +import { getCompileWorkerHandlerCode } from "../workers/compile-worker-handler.js"; import type { PlaygroundUserConfig } from "./types.js"; export function definePlaygroundViteConfig(config: PlaygroundUserConfig): UserConfig { @@ -31,6 +32,8 @@ export function definePlaygroundViteConfig(config: PlaygroundUserConfig): UserCo ? typespecBundlePlugin({ folderName: "libs", libraries: config.libraries, + generateCompileWorker: config.enableCompileWorker ?? true, + compileWorkerHandlerCode: getCompileWorkerHandlerCode(), }) : undefined, ], diff --git a/packages/playground/src/vite/types.ts b/packages/playground/src/vite/types.ts index 34bff44b122..a8433948d3b 100644 --- a/packages/playground/src/vite/types.ts +++ b/packages/playground/src/vite/types.ts @@ -7,6 +7,12 @@ export interface PlaygroundUserConfig extends Omit */ readonly skipBundleLibraries?: boolean; readonly samples?: Record; + + /** + * Whether to generate a compile worker for off-main-thread compilation. + * @default true + */ + readonly enableCompileWorker?: boolean; } export interface PlaygroundConfig { diff --git a/packages/playground/src/workers/compile-worker-client.ts b/packages/playground/src/workers/compile-worker-client.ts new file mode 100644 index 00000000000..707a9e9583a --- /dev/null +++ b/packages/playground/src/workers/compile-worker-client.ts @@ -0,0 +1,118 @@ +import type { CompilerOptions } from "@typespec/compiler"; +import type { + WorkerCompileMessage, + WorkerCompileResponse, + WorkerCompilationResult, + WorkerErrorResponse, + WorkerInitMessage, + WorkerInitResponse, +} from "./types.js"; + +/** + * Client for communicating with the compile worker from the main thread. + */ +export class CompileWorkerClient { + private worker: Worker; + private nextId = 1; + private pendingRequests = new Map< + number, + { resolve: (value: any) => void; reject: (error: Error) => void } + >(); + private initialized = false; + private initPromise: Promise | undefined; + + constructor(workerUrl: string) { + this.worker = new Worker(workerUrl, { type: "module" }); + this.worker.onmessage = (e) => this.handleMessage(e.data); + this.worker.onerror = (e) => this.handleError(e); + } + + /** + * Initialize the worker. Must be called before compile(). + */ + async init(): Promise< + Record + > { + if (this.initPromise) { + await this.initPromise; + return {}; + } + + let resolveInit: () => void; + this.initPromise = new Promise((r) => { + resolveInit = r; + }); + + const response = await this.sendRequest({ + type: "init", + id: this.nextId++, + }); + + this.initialized = true; + resolveInit!(); + return response.libraries; + } + + /** + * Compile TypeSpec content using the worker. + * Cancels any in-flight compilation. + */ + async compile( + content: string, + selectedEmitter: string, + options: CompilerOptions, + ): Promise { + if (!this.initialized) { + await this.init(); + } + + const response = await this.sendRequest({ + type: "compile", + id: this.nextId++, + content, + selectedEmitter, + options, + }); + + return response.result; + } + + /** + * Terminate the worker. + */ + dispose(): void { + this.worker.terminate(); + for (const { reject } of this.pendingRequests.values()) { + reject(new Error("Worker terminated")); + } + this.pendingRequests.clear(); + } + + private sendRequest(request: TReq): Promise { + return new Promise((resolve, reject) => { + this.pendingRequests.set(request.id, { resolve, reject }); + this.worker.postMessage(request); + }); + } + + private handleMessage(data: WorkerInitResponse | WorkerCompileResponse | WorkerErrorResponse) { + const pending = this.pendingRequests.get(data.id); + if (!pending) return; + + this.pendingRequests.delete(data.id); + + if (data.type === "error") { + pending.reject(new Error(data.error)); + } else { + pending.resolve(data); + } + } + + private handleError(event: ErrorEvent) { + // Reject all pending requests on worker error + for (const { reject } of this.pendingRequests.values()) { + reject(new Error(`Worker error: ${event.message}`)); + } + this.pendingRequests.clear(); + } +} diff --git a/packages/playground/src/workers/compile-worker-handler.ts b/packages/playground/src/workers/compile-worker-handler.ts new file mode 100644 index 00000000000..a5ad1bda406 --- /dev/null +++ b/packages/playground/src/workers/compile-worker-handler.ts @@ -0,0 +1,324 @@ +/** + * This module generates the worker handler code that will be inlined + * into the self-contained compile worker bundle. + * + * The generated code runs inside a Web Worker and expects + * `self.__typespec_libraries` to be set with all library modules. + */ + +/** + * Returns the JavaScript source code for the compile worker message handler. + * This code is intended to be bundled into the worker entry by the Vite plugin. + */ +export function getCompileWorkerHandlerCode(): string { + // This string is the actual worker handler code. + // It references `self.__typespec_libraries` which is set by the generated entry. + return ` +// ── Compile Worker Handler ── + +const libs = self.__typespec_libraries; + +// State +let browserHost = null; +let compilerModule = null; + +/** + * Initialize: create BrowserHost from the loaded libraries. + */ +function init() { + // Find the compiler module (always "@typespec/compiler") + compilerModule = libs["@typespec/compiler"]; + if (!compilerModule) { + throw new Error("@typespec/compiler not found in worker libraries"); + } + + const { createSourceFile, getSourceFileKindFromExt, resolvePath } = compilerModule; + + function resolveVirtualPath(path, ...paths) { + return resolvePath("/test", path, ...paths); + } + + // Build virtual FS and JS imports from all libraries + const virtualFs = new Map(); + const jsImports = new Map(); + const libraryMetadata = {}; + + for (const [libName, libModule] of Object.entries(libs)) { + const tspLib = libModule._TypeSpecLibrary_; + if (!tspLib) continue; + + // Register TypeSpec source files + for (const [key, value] of Object.entries(tspLib.typespecSourceFiles || {})) { + virtualFs.set("/test/node_modules/" + libName + "/" + key, value); + } + + // Register JS source files + for (const [key, value] of Object.entries(tspLib.jsSourceFiles || {})) { + const path = "/test/node_modules/" + libName + "/" + key; + virtualFs.set(path, ""); + jsImports.set(path, value); + } + + // Store metadata for the init response + const pkgJson = tspLib.typespecSourceFiles?.["package.json"] + ? JSON.parse(tspLib.typespecSourceFiles["package.json"]) + : { name: libName, version: "0.0.0" }; + + libraryMetadata[libName] = { + name: libName, + isEmitter: !!libModule.$lib?.emitter, + packageJson: pkgJson, + }; + } + + // Create package.json + virtualFs.set( + "/test/package.json", + JSON.stringify({ + name: "playground-pkg", + dependencies: Object.fromEntries( + Object.entries(libraryMetadata).map(([name, meta]) => [name, meta.packageJson.version || "0.0.0"]) + ), + }) + ); + + // Build the host + browserHost = { + compiler: compilerModule, + libraries: libraryMetadata, + + async readUrl(url) { + const contents = virtualFs.get(url); + if (contents === undefined) { + const e = new Error("File " + url + " not found."); + e.code = "ENOENT"; + throw e; + } + return createSourceFile(contents, url); + }, + async readFile(path) { + path = resolveVirtualPath(path); + const contents = virtualFs.get(path); + if (contents === undefined) { + const e = new Error("File " + path + " not found."); + e.code = "ENOENT"; + throw e; + } + return createSourceFile(contents, path); + }, + async writeFile(path, content) { + path = resolveVirtualPath(path); + virtualFs.set(path, content); + }, + async readDir(path) { + path = resolveVirtualPath(path); + const fileFolder = [...virtualFs.keys()] + .filter((x) => x.startsWith(path + "/")) + .map((x) => x.replace(path + "/", "")) + .map((x) => { + const index = x.indexOf("/"); + return index !== -1 ? x.substring(0, index) : x; + }); + return [...new Set(fileFolder)]; + }, + async rm(path) { + path = resolveVirtualPath(path); + for (const key of virtualFs.keys()) { + if (key === path || key.startsWith(path + "/")) { + virtualFs.delete(key); + } + } + }, + getLibDirs() { + if (virtualFs.has(resolveVirtualPath("/test/node_modules/@typespec/compiler/lib/std/main.tsp"))) { + return [resolveVirtualPath("/test/node_modules/@typespec/compiler/lib/std")]; + } else { + return [resolveVirtualPath("/test/node_modules/@typespec/compiler/lib")]; + } + }, + getExecutionRoot() { + return resolveVirtualPath("/test/node_modules/@typespec/compiler"); + }, + async getJsImport(path) { + path = resolveVirtualPath(path); + const mod = await jsImports.get(path); + if (mod === undefined) { + const e = new Error("Module " + path + " not found"); + e.code = "MODULE_NOT_FOUND"; + throw e; + } + return mod; + }, + async stat(path) { + path = resolveVirtualPath(path); + if (virtualFs.has(path)) { + return { isDirectory() { return false; }, isFile() { return true; } }; + } + for (const fsPath of virtualFs.keys()) { + if (fsPath.startsWith(path) && fsPath !== path) { + return { isDirectory() { return true; }, isFile() { return false; } }; + } + } + const e = new Error("File " + path + " not found."); + e.code = "ENOENT"; + throw e; + }, + async realpath(path) { return path; }, + getSourceFileKind: getSourceFileKindFromExt, + logSink: console, + mkdirp: async (path) => path, + fileURLToPath(path) { return path.replace("inmemory:/", ""); }, + pathToFileURL(path) { return "inmemory:/" + resolveVirtualPath(path); }, + }; + + return libraryMetadata; +} + +/** + * Serialize a diagnostic target to a source location. + */ +function serializeTarget(target) { + if (target === undefined || typeof target === "symbol") return undefined; + const location = compilerModule.getSourceLocation(target, { locateId: true }); + if (!location) return undefined; + const start = location.file.getLineAndCharacterOfPosition(location.pos); + const end = location.file.getLineAndCharacterOfPosition(location.end); + return { + file: location.file.path, + pos: location.pos, + end: location.end, + startLine: start.line, + startColumn: start.character, + endLine: end.line, + endColumn: end.character, + }; +} + +/** + * Pre-resolve codefixes for a diagnostic. + */ +async function serializeCodefixes(diagnostic) { + if (!diagnostic.codefixes?.length) return []; + const result = []; + for (const fix of diagnostic.codefixes) { + try { + const edits = await compilerModule.resolveCodeFix(fix); + const serializedEdits = edits.map((edit) => { + const start = edit.file.getLineAndCharacterOfPosition(edit.pos); + const endPos = edit.kind === "insert-text" ? edit.pos : edit.end; + const end = edit.file.getLineAndCharacterOfPosition(endPos); + return { + file: edit.file.path, + range: { + startLine: start.line, startColumn: start.character, + endLine: end.line, endColumn: end.character, + }, + text: edit.text, + }; + }); + result.push({ label: fix.label, edits: serializedEdits }); + } catch (e) { + // Skip codefixes that fail to resolve + } + } + return result; +} + +/** + * Run compilation and return serialized results. + */ +async function doCompile(content, selectedEmitter, options) { + const { resolvePath } = compilerModule; + const outputDir = resolvePath("/test", "tsp-output"); + + // Write content + await browserHost.writeFile("main.tsp", content); + + // Clear output dir + const outputDirItems = await browserHost.readDir("./tsp-output"); + for (const item of outputDirItems) { + await browserHost.rm("./tsp-output/" + item); + } + + try { + const program = await compilerModule.compile(browserHost, resolvePath("/test", "main.tsp"), { + ...options, + options: { + ...(options.options || {}), + [selectedEmitter]: { + ...(options.options?.[selectedEmitter] || {}), + "emitter-output-dir": outputDir, + }, + }, + outputDir, + emit: selectedEmitter ? [selectedEmitter] : [], + }); + + // Collect output files + const outputFiles = {}; + async function collectFiles(dir) { + const items = await browserHost.readDir(outputDir + dir); + for (const item of items) { + const itemPath = dir + "/" + item; + const stat = await browserHost.stat(outputDir + itemPath); + if (stat.isDirectory()) { + await collectFiles(itemPath); + } else { + const fileContent = await browserHost.readFile("tsp-output" + itemPath); + const key = dir === "" ? item : dir.slice(1) + "/" + item; + outputFiles[key] = fileContent.text; + } + } + } + await collectFiles(""); + + // Serialize diagnostics + const diagnostics = await Promise.all( + program.diagnostics.map(async (d) => ({ + severity: d.severity, + code: String(d.code), + message: d.message, + target: serializeTarget(d.target), + codefixes: await serializeCodefixes(d), + })) + ); + + return { outputFiles, diagnostics }; + } catch (error) { + console.error("Internal compiler error in worker", error); + return { internalCompilerError: String(error) }; + } +} + +// ── Message Handler ── + +self.onmessage = async (e) => { + const msg = e.data; + + try { + if (msg.type === "init") { + const libraryMetadata = init(); + self.postMessage({ + type: "init-result", + id: msg.id, + success: true, + libraries: libraryMetadata, + }); + } else if (msg.type === "compile") { + const result = await doCompile(msg.content, msg.selectedEmitter, msg.options); + self.postMessage({ + type: "compile-result", + id: msg.id, + result, + }); + } + } catch (error) { + self.postMessage({ + type: "error", + id: msg.id, + error: String(error), + }); + } +}; +`; +} diff --git a/packages/playground/src/workers/serialization.ts b/packages/playground/src/workers/serialization.ts new file mode 100644 index 00000000000..2c942879819 --- /dev/null +++ b/packages/playground/src/workers/serialization.ts @@ -0,0 +1,100 @@ +import type { Diagnostic, DiagnosticTarget, NoTarget, Program } from "@typespec/compiler"; +import type { + SerializedCodefix, + SerializedDiagnostic, + SerializedRange, + SerializedSourceLocation, + SerializedTextEdit, + WorkerCompilationResult, +} from "./types.js"; + +/** + * Serialize diagnostics from a compiled program into a transferable format. + */ +export async function serializeCompilationResult( + compiler: typeof import("@typespec/compiler"), + program: Program, + outputFiles: Record, +): Promise { + const diagnostics = await Promise.all( + program.diagnostics.map((d) => serializeDiagnostic(compiler, d)), + ); + return { outputFiles, diagnostics }; +} + +async function serializeDiagnostic( + compiler: typeof import("@typespec/compiler"), + diagnostic: Diagnostic, +): Promise { + const target = serializeTarget(compiler, diagnostic.target); + const codefixes = await serializeCodefixes(compiler, diagnostic); + return { + severity: diagnostic.severity, + code: typeof diagnostic.code === "string" ? diagnostic.code : String(diagnostic.code), + message: diagnostic.message, + target, + codefixes, + }; +} + +function serializeTarget( + compiler: typeof import("@typespec/compiler"), + target: DiagnosticTarget | typeof NoTarget, +): SerializedSourceLocation | undefined { + if (target === undefined || typeof target === "symbol") { + return undefined; + } + const location = compiler.getSourceLocation(target, { locateId: true }); + if (!location) { + return undefined; + } + const start = location.file.getLineAndCharacterOfPosition(location.pos); + const end = location.file.getLineAndCharacterOfPosition(location.end); + return { + file: location.file.path, + pos: location.pos, + end: location.end, + startLine: start.line, + startColumn: start.character, + endLine: end.line, + endColumn: end.character, + }; +} + +async function serializeCodefixes( + compiler: typeof import("@typespec/compiler"), + diagnostic: Diagnostic, +): Promise { + if (!diagnostic.codefixes?.length) { + return []; + } + const result: SerializedCodefix[] = []; + for (const fix of diagnostic.codefixes) { + try { + const edits = await compiler.resolveCodeFix(fix); + const serializedEdits: SerializedTextEdit[] = edits.map((edit) => { + const start = edit.file.getLineAndCharacterOfPosition(edit.pos); + const endPos = + edit.kind === "insert-text" + ? edit.pos + : (edit as { pos: number; end: number }).end; + const end = edit.file.getLineAndCharacterOfPosition(endPos); + const range: SerializedRange = { + startLine: start.line, + startColumn: start.character, + endLine: end.line, + endColumn: end.character, + }; + return { + file: edit.file.path, + range, + text: edit.text, + }; + }); + result.push({ label: fix.label, edits: serializedEdits }); + } catch { + // Skip codefixes that fail to resolve + } + } + return result; +} diff --git a/packages/playground/src/workers/types.ts b/packages/playground/src/workers/types.ts new file mode 100644 index 00000000000..5f76751a833 --- /dev/null +++ b/packages/playground/src/workers/types.ts @@ -0,0 +1,84 @@ +import type { CompilerOptions } from "@typespec/compiler"; + +// ── Worker message protocol ── + +/** Messages sent from the main thread to the worker. */ +export type WorkerMessage = WorkerInitMessage | WorkerCompileMessage; + +export interface WorkerInitMessage { + readonly type: "init"; + readonly id: number; +} + +export interface WorkerCompileMessage { + readonly type: "compile"; + readonly id: number; + readonly content: string; + readonly selectedEmitter: string; + readonly options: CompilerOptions; +} + +/** Messages sent from the worker back to the main thread. */ +export type WorkerResponse = WorkerInitResponse | WorkerCompileResponse | WorkerErrorResponse; + +export interface WorkerInitResponse { + readonly type: "init-result"; + readonly id: number; + readonly success: true; + readonly libraries: Record; +} + +export interface WorkerCompileResponse { + readonly type: "compile-result"; + readonly id: number; + readonly result: WorkerCompilationResult; +} + +export interface WorkerErrorResponse { + readonly type: "error"; + readonly id: number; + readonly error: string; +} + +// ── Serialized compilation result ── + +export interface WorkerCompilationResult { + readonly outputFiles: Record; + readonly diagnostics: readonly SerializedDiagnostic[]; +} + +export interface SerializedDiagnostic { + readonly severity: "error" | "warning"; + readonly code: string; + readonly message: string; + readonly target: SerializedSourceLocation | undefined; + readonly codefixes: readonly SerializedCodefix[]; +} + +export interface SerializedSourceLocation { + readonly file: string; + readonly pos: number; + readonly end: number; + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + readonly endColumn: number; +} + +export interface SerializedCodefix { + readonly label: string; + readonly edits: readonly SerializedTextEdit[]; +} + +export interface SerializedTextEdit { + readonly file: string; + readonly range: SerializedRange; + readonly text: string; +} + +export interface SerializedRange { + readonly startLine: number; + readonly startColumn: number; + readonly endLine: number; + readonly endColumn: number; +}