Skip to content
Draft
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
1 change: 1 addition & 0 deletions packages/bundler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export {
TypeSpecBundleFile,
createTypeSpecBundle,
} from "./bundler.js";
export { createWorkerBundle, WorkerBundleOptions } from "./worker-bundler.js";
50 changes: 49 additions & 1 deletion packages/bundler/src/vite/vite-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createTypeSpecBundle,
watchTypeSpecBundle,
} from "../bundler.js";
import { createWorkerBundle } from "../worker-bundler.js";

export interface TypeSpecBundlePluginOptions {
readonly folderName: string;
Expand All @@ -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<string, TypeSpecBundleDefinition> = {};
const bundles: Record<string, TypeSpecBundle> = {};
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",
Expand All @@ -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) => {
Expand All @@ -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";
Expand Down Expand Up @@ -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 },
Expand All @@ -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: {
Expand Down
175 changes: 175 additions & 0 deletions packages/bundler/src/worker-bundler.ts
Original file line number Diff line number Diff line change
@@ -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<string, TypeSpecBundle>;

/**
* 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<string> {
const { bundles, workerHandlerCode, minify = false } = options;

// Build a mapping of all library files (keyed by virtual path)
const libraryFiles = new Map<string, string>();

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("/");
}
Loading
Loading