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
Original file line number Diff line number Diff line change
@@ -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.
138 changes: 120 additions & 18 deletions packages/playground/src/browser-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ export function resolveVirtualPath(path: string, ...paths: string[]) {
export interface BrowserHostCreateOptions {
readonly compiler: typeof import("@typespec/compiler");
readonly libraries: Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_: any }>;

/**
* 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<string, () => Promise<LoadedPlaygroundTspLibrary>>;
}

/**
Expand All @@ -21,10 +27,24 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br
const virtualFs = new Map<string, string>();
const jsImports = new Map<string, Promise<any>>();

const libraries: Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_: any }> = {
const libraries: Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_?: any }> = {
...options.libraries,
};

const deferredLoaders = new Map(Object.entries(options.deferredLibraries ?? {}));
const pendingLoads = new Map<string, Promise<void>>();

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 },
Expand All @@ -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<void> {
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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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<LoadedPlaygroundTspLibrary> {
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.
Expand All @@ -191,43 +263,73 @@ export function createBrowserHostInternal(options: BrowserHostCreateOptions): Br
export async function loadLibraries(
libsToLoad: readonly string[],
importOptions: LibraryImportOptions = {},
): Promise<Record<string, PlaygroundTspLibrary & { _TypeSpecLibrary_: any }>> {
): Promise<Record<string, LoadedPlaygroundTspLibrary>> {
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<BrowserHost> {
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,
});
}
2 changes: 1 addition & 1 deletion packages/playground/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
8 changes: 8 additions & 0 deletions packages/playground/src/react/compilation/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,21 @@ 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, {
cwd: resolveVirtualPath("."),
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: {
Expand Down
12 changes: 10 additions & 2 deletions packages/playground/src/react/standalone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ import {
export interface ReactPlaygroundConfig extends Partial<PlaygroundProps> {
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;
}
Expand All @@ -51,15 +57,17 @@ function useStandalonePlaygroundContext(
const [context, setContext] = useState<StandalonePlaygroundContext | undefined>();
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();
const initialState = stateStorage.load();
setContext({ host, initialState, stateStorage });
};
void load();
}, [config.importConfig, config.libraries]);
}, [config.importConfig, config.libraries, config.deferredEmitters]);
return context;
}

Expand Down
14 changes: 14 additions & 0 deletions packages/playground/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,23 @@ export interface PlaygroundTspLibrary {
isEmitter: boolean;
definition?: TypeSpecLibrary<any>;
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<string, PlaygroundTspLibrary>;

/**
* 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<void>;
}
Loading
Loading