diff --git a/.chronus/changes/defer-playground-emitter-loading-2026-7-31-10-30-0.md b/.chronus/changes/defer-playground-emitter-loading-2026-7-31-10-30-0.md new file mode 100644 index 00000000000..fba6bf40c51 --- /dev/null +++ b/.chronus/changes/defer-playground-emitter-loading-2026-7-31-10-30-0.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/playground" +--- + +Add support for deferring the loading of emitter libraries until they are selected. Configure with the new `deferredEmitters` option to avoid downloading and evaluating large emitters on startup. diff --git a/packages/playground/src/browser-host.ts b/packages/playground/src/browser-host.ts index 3e4b23250e9..5791e3bc9b7 100644 --- a/packages/playground/src/browser-host.ts +++ b/packages/playground/src/browser-host.ts @@ -12,6 +12,12 @@ export function resolveVirtualPath(path: string, ...paths: string[]) { export interface BrowserHostCreateOptions { readonly compiler: typeof import("@typespec/compiler"); readonly libraries: Record; + + /** + * Libraries that are known to the playground but have not been imported yet, keyed by name. + * Each entry imports the library when called. See {@link BrowserHost.loadLibrary}. + */ + readonly deferredLibraries?: Record Promise>; } /** @@ -21,10 +27,24 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br const virtualFs = new Map(); const jsImports = new Map>(); - const libraries: Record = { + const libraries: Record = { ...options.libraries, }; + const deferredLoaders = new Map(Object.entries(options.deferredLibraries ?? {})); + const pendingLoads = new Map>(); + + for (const name of deferredLoaders.keys()) { + // A placeholder keeps the emitter visible in the UI (dropdown, settings) without paying the + // cost of importing it. It is replaced by the real library on the first `loadLibrary` call. + libraries[name] = { + name, + isEmitter: true, + deferred: true, + packageJson: { name, version: "" } as any, + }; + } + function registerLibraryFiles( libName: string, lib: PlaygroundTspLibrary & { _TypeSpecLibrary_: any }, @@ -43,13 +63,42 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br JSON.stringify({ name: "playground-pkg", dependencies: Object.fromEntries( - Object.values(libraries).map((x) => [x.name, x.packageJson.version]), + // Deferred libraries have no files registered yet, so listing them would make the + // compiler resolve a dependency it cannot read. + Object.values(libraries) + .filter((x) => !x.deferred) + .map((x) => [x.name, x.packageJson.version]), ), }), ); } - for (const [libName, lib] of Object.entries(libraries)) { + function loadLibrary(name: string): Promise { + const loader = deferredLoaders.get(name); + if (loader === undefined) { + return Promise.resolve(); + } + let pending = pendingLoads.get(name); + if (pending === undefined) { + pending = loader().then( + (lib) => { + libraries[name] = lib; + registerLibraryFiles(name, lib); + updatePackageJson(); + deferredLoaders.delete(name); + }, + (error) => { + // Drop the cached promise so a later compilation can retry after a transient failure. + pendingLoads.delete(name); + throw error; + }, + ); + pendingLoads.set(name, pending); + } + return pending; + } + + for (const [libName, lib] of Object.entries(options.libraries)) { registerLibraryFiles(libName, lib); } updatePackageJson(); @@ -61,6 +110,7 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br return { compiler: options.compiler, libraries, + loadLibrary, async readUrl(url: string) { const contents = virtualFs.get(url); if (contents === undefined) { @@ -183,6 +233,28 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br }; } +/** + * A library that has been imported, along with the raw bundle payload used to populate the + * in-memory file system. + * @internal + */ +export type LoadedPlaygroundTspLibrary = PlaygroundTspLibrary & { _TypeSpecLibrary_: any }; + +async function importPlaygroundLibrary( + libName: string, + importOptions: LibraryImportOptions, +): Promise { + const { _TypeSpecLibrary_, $lib, $linter } = (await importLibrary(libName, importOptions)) as any; + return { + name: libName, + isEmitter: $lib?.emitter, + definition: $lib, + packageJson: JSON.parse(_TypeSpecLibrary_.typespecSourceFiles["package.json"]), + linter: $linter, + _TypeSpecLibrary_, + }; +} + /** * Load libraries in parallel from the given list. * @param libsToLoad List of library names. Must be available in the webpage importmap. @@ -191,43 +263,73 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br export async function loadLibraries( libsToLoad: readonly string[], importOptions: LibraryImportOptions = {}, -): Promise> { +): Promise> { const entries = await Promise.all( libsToLoad.map(async (libName) => { - const { _TypeSpecLibrary_, $lib, $linter } = (await importLibrary( - libName, - importOptions, - )) as any; - const lib: PlaygroundTspLibrary & { _TypeSpecLibrary_: any } = { - name: libName, - isEmitter: $lib?.emitter, - definition: $lib, - packageJson: JSON.parse(_TypeSpecLibrary_.typespecSourceFiles["package.json"]), - linter: $linter, - _TypeSpecLibrary_, - }; - return [libName, lib] as const; + return [libName, await importPlaygroundLibrary(libName, importOptions)] as const; }), ); return Object.fromEntries(entries); } +/** + * Options for creating the browser host. + */ +export interface BrowserHostOptions { + /** + * Emitters that should not be imported until they are used. + * + * Importing a library evaluates its module, which for some emitters means downloading a large + * runtime up front. Names listed here are shown in the emitter list right away but are only + * imported once selected, which keeps the initial load cheap. + * + * Only use this for pure emitters: a deferred library cannot be referenced by an `import` + * statement in the TypeSpec source, since its files are not registered until it is loaded. + */ + readonly deferredEmitters?: readonly string[]; +} + +/** + * Split the libraries to load into the ones to import now and the ones to import on demand. + * @internal + */ +export function splitDeferredLibraries( + libsToLoad: readonly string[], + deferredEmitters: readonly string[] = [], +): { eager: string[]; deferred: string[] } { + const deferredSet = new Set(deferredEmitters); + return { + eager: libsToLoad.filter((x) => !deferredSet.has(x)), + deferred: libsToLoad.filter((x) => deferredSet.has(x)), + }; +} + /** * Create the browser host from the list of libraries. * @param libsToLoad List of libraries to load. Those must be set in the webpage importmap. * @param importOptions Import configuration. + * @param options Additional host options. * @returns */ export async function createBrowserHost( libsToLoad: readonly string[], importOptions: LibraryImportOptions = {}, + options: BrowserHostOptions = {}, ): Promise { + const { eager, deferred } = splitDeferredLibraries(libsToLoad, options.deferredEmitters); + const [libraries, compiler] = await Promise.all([ - loadLibraries(libsToLoad, importOptions), + loadLibraries(eager, importOptions), importTypeSpecCompiler(importOptions), ]); + + const deferredLibraries = Object.fromEntries( + deferred.map((name) => [name, () => importPlaygroundLibrary(name, importOptions)] as const), + ); + return createBrowserHostInternal({ compiler, libraries, + deferredLibraries, }); } diff --git a/packages/playground/src/index.ts b/packages/playground/src/index.ts index cf7e6db4e43..17646519115 100644 --- a/packages/playground/src/index.ts +++ b/packages/playground/src/index.ts @@ -1,4 +1,4 @@ -export { createBrowserHost } from "./browser-host.js"; +export { createBrowserHost, type BrowserHostOptions } from "./browser-host.js"; export { registerMonacoDefaultWorkersForVite } from "./monaco-worker.js"; export { registerMonacoLanguage } from "./services.js"; export { createUrlStateStorage, type StateStorage, type UrlStateStorage } from "./state-storage.js"; diff --git a/packages/playground/src/react/compilation/compile.ts b/packages/playground/src/react/compilation/compile.ts index 3b793f5812d..89b62b1fc88 100644 --- a/packages/playground/src/react/compilation/compile.ts +++ b/packages/playground/src/react/compilation/compile.ts @@ -18,6 +18,11 @@ export async function compile( try { const typespecCompiler = host.compiler; + // Deferred emitters are only imported once they are actually used. + if (selectedEmitter) { + await host.loadLibrary(selectedEmitter); + } + // Resolve the compiler options natively from the tspconfig.yaml so the playground // honors the full config (emit, options, linter, imports, warn-as-error, ...). const [resolvedOptions] = await typespecCompiler.resolveCompilerOptions(host, { @@ -25,6 +30,9 @@ export async function compile( entrypoint: resolveVirtualPath("main.tsp"), }); + // The tspconfig.yaml can request emitters other than the selected one. + await Promise.all((resolvedOptions.emit ?? []).map((name) => host.loadLibrary(name))); + const options: CompilerOptions = { ...resolvedOptions, options: { diff --git a/packages/playground/src/react/standalone.tsx b/packages/playground/src/react/standalone.tsx index efb951526f0..38a21a2e518 100644 --- a/packages/playground/src/react/standalone.tsx +++ b/packages/playground/src/react/standalone.tsx @@ -36,6 +36,12 @@ import { export interface ReactPlaygroundConfig extends Partial { readonly libraries: readonly string[]; readonly importConfig?: LibraryImportOptions; + /** + * Emitters from {@link libraries} that should only be imported once selected, rather than when + * the playground starts. Only valid for pure emitters, which are never referenced by an `import` + * statement in the TypeSpec source. + */ + readonly deferredEmitters?: readonly string[]; /** Content to show while the playground data is loading(Libraries) */ readonly fallback?: ReactNode; } @@ -51,7 +57,9 @@ function useStandalonePlaygroundContext( const [context, setContext] = useState(); useEffect(() => { const load = async () => { - const host = await createBrowserHost(config.libraries, config.importConfig); + const host = await createBrowserHost(config.libraries, config.importConfig, { + deferredEmitters: config.deferredEmitters, + }); await registerMonacoLanguage(host); const stateStorage = createStandalonePlaygroundStateStorage(); @@ -59,7 +67,7 @@ function useStandalonePlaygroundContext( setContext({ host, initialState, stateStorage }); }; void load(); - }, [config.importConfig, config.libraries]); + }, [config.importConfig, config.libraries, config.deferredEmitters]); return context; } diff --git a/packages/playground/src/types.ts b/packages/playground/src/types.ts index f665e097bec..28a2e6d6be9 100644 --- a/packages/playground/src/types.ts +++ b/packages/playground/src/types.ts @@ -33,9 +33,23 @@ export interface PlaygroundTspLibrary { isEmitter: boolean; definition?: TypeSpecLibrary; linter?: LinterDefinition; + + /** + * Whether the library module has been declared but not imported yet. + * + * Deferred emitters are only imported the first time they are used, so their `definition`, + * `linter` and `packageJson` are placeholders until then. + */ + deferred?: boolean; } export interface BrowserHost extends CompilerHost { compiler: typeof import("@typespec/compiler"); libraries: Record; + + /** + * Import a library that was registered as a deferred emitter and make its files available to the + * compiler. Resolves immediately for libraries that are already loaded. + */ + loadLibrary(name: string): Promise; } diff --git a/packages/playground/test/browser-host-deferred.test.ts b/packages/playground/test/browser-host-deferred.test.ts new file mode 100644 index 00000000000..1cf0d614552 --- /dev/null +++ b/packages/playground/test/browser-host-deferred.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + createBrowserHostInternal, + splitDeferredLibraries, + type LoadedPlaygroundTspLibrary, +} from "../src/browser-host.js"; + +function fakeLibrary(name: string): LoadedPlaygroundTspLibrary { + return { + name, + isEmitter: true, + definition: { emitter: true } as any, + packageJson: { name, version: "1.2.3" } as any, + _TypeSpecLibrary_: { + typespecSourceFiles: { + "package.json": JSON.stringify({ name, version: "1.2.3" }), + "main.tsp": `// ${name}`, + }, + jsSourceFiles: {}, + }, + }; +} + +const emitterName = "@typespec/emitter"; + +describe("splitDeferredLibraries", () => { + it("keeps everything eager when nothing is deferred", () => { + expect(splitDeferredLibraries(["@typespec/http", emitterName])).toEqual({ + eager: ["@typespec/http", emitterName], + deferred: [], + }); + }); + + it("moves the requested libraries to the deferred list", () => { + expect(splitDeferredLibraries(["@typespec/http", emitterName], [emitterName])).toEqual({ + eager: ["@typespec/http"], + deferred: [emitterName], + }); + }); + + it("ignores deferred names that are not loaded at all", () => { + expect(splitDeferredLibraries(["@typespec/http"], ["@typespec/not-in-import-map"])).toEqual({ + eager: ["@typespec/http"], + deferred: [], + }); + }); +}); + +describe("browser host deferred libraries", () => { + let loader: ReturnType; + + beforeEach(() => { + loader = vi.fn(async () => fakeLibrary(emitterName)); + }); + + function createHost() { + return createBrowserHostInternal({ + compiler: {} as any, + libraries: { "@typespec/http": fakeLibrary("@typespec/http") }, + deferredLibraries: { [emitterName]: loader as any }, + }); + } + + it("does not import deferred libraries when the host is created", () => { + const host = createHost(); + + expect(loader).not.toHaveBeenCalled(); + // The emitter is still offered in the UI so it can be selected. + expect(host.libraries[emitterName]).toMatchObject({ + name: emitterName, + isEmitter: true, + deferred: true, + }); + }); + + it("leaves deferred libraries out of the virtual package.json until they are loaded", async () => { + const host = createHost(); + + const before = JSON.parse((await host.readFile("/test/package.json")).text); + expect(Object.keys(before.dependencies)).toEqual(["@typespec/http"]); + + await host.loadLibrary(emitterName); + + const after = JSON.parse((await host.readFile("/test/package.json")).text); + expect(Object.keys(after.dependencies).sort()).toEqual([emitterName, "@typespec/http"]); + }); + + it("registers the library files on loadLibrary", async () => { + const host = createHost(); + + await expect( + host.readFile(`/test/node_modules/${emitterName}/main.tsp`), + ).rejects.toThrowError(); + + await host.loadLibrary(emitterName); + + const file = await host.readFile(`/test/node_modules/${emitterName}/main.tsp`); + expect(file.text).toEqual(`// ${emitterName}`); + expect(host.libraries[emitterName].deferred).toBeUndefined(); + expect(host.libraries[emitterName].packageJson.version).toEqual("1.2.3"); + }); + + it("imports a deferred library only once across concurrent calls", async () => { + const host = createHost(); + + await Promise.all([host.loadLibrary(emitterName), host.loadLibrary(emitterName)]); + await host.loadLibrary(emitterName); + + expect(loader).toHaveBeenCalledTimes(1); + }); + + it("retries a deferred library whose import failed", async () => { + loader.mockRejectedValueOnce(new Error("network boom")); + const host = createHost(); + + await expect(host.loadLibrary(emitterName)).rejects.toThrowError("network boom"); + + await host.loadLibrary(emitterName); + expect(host.libraries[emitterName].deferred).toBeUndefined(); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it("resolves immediately for libraries that are not deferred", async () => { + const host = createHost(); + + await host.loadLibrary("@typespec/http"); + expect(loader).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/playground/test/deferred-emitter-compile.test.ts b/packages/playground/test/deferred-emitter-compile.test.ts new file mode 100644 index 00000000000..30bd4462e11 --- /dev/null +++ b/packages/playground/test/deferred-emitter-compile.test.ts @@ -0,0 +1,122 @@ +import { readFile, readdir } from "fs/promises"; +import { createRequire } from "module"; +import { dirname, join, relative } from "path"; +import { pathToFileURL } from "url"; +import { describe, expect, it } from "vitest"; +import type { LoadedPlaygroundTspLibrary } from "../src/browser-host.js"; +import { createBrowserHostInternal, resolveVirtualPath } from "../src/browser-host.js"; + +const compilerRoot = dirname( + createRequire(import.meta.url).resolve("@typespec/compiler/package.json"), +); + +async function collectFiles(dir: string, root: string): Promise> { + const result: Record = {}; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + Object.assign(result, await collectFiles(full, root)); + } else if (entry.name.endsWith(".tsp")) { + result[relative(root, full).replaceAll("\\", "/")] = await readFile(full, "utf8"); + } + } + return result; +} + +/** Build a bundle-shaped payload for the real on-disk compiler package. */ +async function realCompilerLibrary( + compiler: typeof import("@typespec/compiler"), +): Promise { + const packageJson = await readFile(join(compilerRoot, "package.json"), "utf8"); + return { + name: "@typespec/compiler", + isEmitter: false, + packageJson: JSON.parse(packageJson), + _TypeSpecLibrary_: { + typespecSourceFiles: { + "package.json": packageJson, + ...(await collectFiles(join(compilerRoot, "lib"), compilerRoot)), + }, + jsSourceFiles: { + [JSON.parse(packageJson).main]: compiler, + // The std library `.tsp` files reference these for their extern decorator implementations. + ...Object.fromEntries( + await Promise.all( + ["dist/src/lib/tsp-index.js", "dist/src/lib/intrinsic/tsp-index.js"].map( + async (path) => + [ + path, + await import(/* @vite-ignore */ pathToFileURL(join(compilerRoot, path)).href), + ] as const, + ), + ), + ), + }, + }, + }; +} + +/** A minimal emitter in the shape the playground bundler produces. */ +function fakeEmitterLibrary( + name: string, + onEmit: (outputDir: string) => void, +): LoadedPlaygroundTspLibrary { + const packageJson = JSON.stringify({ name, version: "1.0.0", main: "index.js" }); + return { + name, + isEmitter: true, + packageJson: JSON.parse(packageJson), + _TypeSpecLibrary_: { + typespecSourceFiles: { "package.json": packageJson }, + jsSourceFiles: { + "index.js": { + $onEmit: async (context: any) => { + onEmit(context.emitterOutputDir); + await context.program.host.writeFile(`${context.emitterOutputDir}/out.txt`, "emitted"); + }, + }, + }, + }, + }; +} + +describe("compiling with a deferred emitter", () => { + it("runs an emitter that was imported after the host was created", async () => { + const compiler = await import("@typespec/compiler"); + let emitted = 0; + let emitterOutputDir: string | undefined; + const emitterName = "@test/deferred-emitter"; + + const host = createBrowserHostInternal({ + compiler, + libraries: { "@typespec/compiler": await realCompilerLibrary(compiler) }, + deferredLibraries: { + [emitterName]: async () => + fakeEmitterLibrary(emitterName, (dir) => { + emitted++; + emitterOutputDir = dir; + }), + }, + }); + + await host.writeFile("main.tsp", "model Foo { name: string }"); + + // The emitter is advertised but not imported yet, so the compiler cannot resolve it. + const before = await compiler.compile(host, resolveVirtualPath("main.tsp"), { + emit: [emitterName], + outputDir: resolveVirtualPath("tsp-output"), + }); + expect(before.diagnostics.map((x) => x.code)).toContain("import-not-found"); + expect(emitted).toBe(0); + + await host.loadLibrary(emitterName); + + const after = await compiler.compile(host, resolveVirtualPath("main.tsp"), { + emit: [emitterName], + outputDir: resolveVirtualPath("tsp-output"), + }); + expect(after.diagnostics.map((x) => `${x.code}: ${x.message}`)).toEqual([]); + expect(emitted).toBe(1); + expect((await host.readFile(`${emitterOutputDir!}/out.txt`)).text).toEqual("emitted"); + }); +}); diff --git a/website/src/components/playground-component/playground.tsx b/website/src/components/playground-component/playground.tsx index 14bab6ced4d..b22c983733a 100644 --- a/website/src/components/playground-component/playground.tsx +++ b/website/src/components/playground-component/playground.tsx @@ -20,6 +20,12 @@ export interface WebsitePlaygroundProps { versionData: VersionData; } +/** + * Emitters that ship a heavy runtime and are not referenced by `import` statements in TypeSpec + * source, so they can be imported the first time they are selected instead of on page load. + */ +const deferredEmitters = ["@typespec/http-client-csharp", "@typespec/http-client-python"]; + export const WebsitePlayground = ({ versionData }: WebsitePlaygroundProps) => { const theme = useTheme(); @@ -35,6 +41,7 @@ export const WebsitePlayground = ({ versionData }: WebsitePlaygroundProps) => {