Skip to content
Open
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
8 changes: 8 additions & 0 deletions .chronus/changes/minify-bundled-code-2026-0-30-17-53-27.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
# Change versionKind to one of: internal, fix, dependencies, feature, deprecation, breaking
changeKind: feature
packages:
- "@typespec/bundler"
---

Minify bundler output
108 changes: 84 additions & 24 deletions packages/bundler/src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { BuildOptions, BuildResult, context, Plugin } from "esbuild";
import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill";
import { mkdir, readFile, realpath, writeFile } from "fs/promises";
import { basename, join, resolve } from "path";
import { promisify } from "util";
import { gzip } from "zlib";
import { relativeTo } from "./utils.js";

const gzipAsync = promisify(gzip);

export interface BundleManifest {
name: string;
version: string;
Expand Down Expand Up @@ -45,6 +49,8 @@ export interface TypeSpecBundleFile {
export?: string;
filename: string;
content: string;
/** Gzipped content, only present if gzip option is enabled */
gzipContent?: Buffer;
}

interface PackageJson {
Expand All @@ -57,12 +63,30 @@ interface PackageJson {
exports?: Record<string, string>;
}

export async function createTypeSpecBundle(libraryPath: string): Promise<TypeSpecBundle> {
export interface CreateTypeSpecBundleOptions {
/**
* Whether to minify the output bundle.
* @default true
*/
minify?: boolean;

/**
* Whether to also generate gzipped versions of the output files.
* When enabled, each file will include a gzipContent buffer.
* @default false
*/
gzip?: boolean;
}

export async function createTypeSpecBundle(
libraryPath: string,
options?: CreateTypeSpecBundleOptions,
): Promise<TypeSpecBundle> {
const definition = await resolveTypeSpecBundleDefinition(libraryPath);
const context = await createEsBuildContext(definition);
const context = await createEsBuildContext(definition, [], options);
try {
const result = await context.rebuild();
return resolveTypeSpecBundle(definition, result);
return resolveTypeSpecBundle(definition, result, options);
} finally {
await context.dispose();
}
Expand All @@ -71,30 +95,46 @@ export async function createTypeSpecBundle(libraryPath: string): Promise<TypeSpe
export async function watchTypeSpecBundle(
libraryPath: string,
onBundle: (bundle: TypeSpecBundle) => void,
options?: CreateTypeSpecBundleOptions,
) {
const definition = await resolveTypeSpecBundleDefinition(libraryPath);
const context = await createEsBuildContext(definition, [
{
name: "example",
setup(build) {
build.onEnd((result) => {
const bundle = resolveTypeSpecBundle(definition, result);
onBundle(bundle);
});
const context = await createEsBuildContext(
definition,
[
{
name: "example",
setup(build) {
build.onEnd(async (result) => {
const bundle = await resolveTypeSpecBundle(definition, result, options);
onBundle(bundle);
});
},
},
},
]);
],
options,
);
await context.watch();
}

export async function bundleTypeSpecLibrary(libraryPath: string, outputDir: string) {
const bundle = await createTypeSpecBundle(libraryPath);
export async function bundleTypeSpecLibrary(
libraryPath: string,
outputDir: string,
options?: CreateTypeSpecBundleOptions,
) {
const bundle = await createTypeSpecBundle(libraryPath, options);
await mkdir(outputDir, { recursive: true });
for (const file of bundle.files) {
await writeFile(joinPaths(outputDir, file.filename), file.content);
if (file.gzipContent) {
await writeFile(joinPaths(outputDir, file.filename + ".gz"), file.gzipContent);
}
}
const manifest = createManifest(bundle.definition);
await writeFile(joinPaths(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
if (options?.gzip) {
const manifestGzip = await gzipAsync(Buffer.from(JSON.stringify(manifest), "utf-8"));
await writeFile(joinPaths(outputDir, "manifest.json.gz"), manifestGzip);
}
}

async function resolveTypeSpecBundleDefinition(
Expand All @@ -119,7 +159,12 @@ async function resolveTypeSpecBundleDefinition(
};
}

async function createEsBuildContext(definition: TypeSpecBundleDefinition, plugins: Plugin[] = []) {
async function createEsBuildContext(
definition: TypeSpecBundleDefinition,
plugins: Plugin[] = [],
options?: CreateTypeSpecBundleOptions,
) {
const minify = options?.minify ?? true;
const libraryPath = definition.path;
const program = await compile(NodeHost, libraryPath, {
noEmit: true,
Expand Down Expand Up @@ -193,25 +238,40 @@ async function createEsBuildContext(definition: TypeSpecBundleDefinition, plugin
platform: "browser",
format: "esm",
target: "es2024",
minify,
plugins: [virtualPlugin, nodeModulesPolyfillPlugin({}), ...plugins],
});
}

function resolveTypeSpecBundle(
async function resolveTypeSpecBundle(
definition: TypeSpecBundleDefinition,
result: BuildResult<BuildOptions>,
): TypeSpecBundle {
return {
definition,
manifest: createManifest(definition),
files: result.outputFiles!.map((file) => {
options?: CreateTypeSpecBundleOptions,
): Promise<TypeSpecBundle> {
const shouldGzip = options?.gzip ?? false;

const files: TypeSpecBundleFile[] = await Promise.all(
result.outputFiles!.map(async (file) => {
const entry = definition.exports[basename(file.path)];
return {
const content = file.text;
const bundleFile: TypeSpecBundleFile = {
filename: file.path.replaceAll("\\", "/").split("/out/")[1],
content: file.text,
content,
export: entry ? getExportEntryPoint(entry) : undefined,
};

if (shouldGzip) {
bundleFile.gzipContent = await gzipAsync(Buffer.from(content, "utf-8"));
}

return bundleFile;
}),
);

return {
definition,
manifest: createManifest(definition),
files,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/bundler/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
BundleManifest,
CreateTypeSpecBundleOptions,
TypeSpecBundle,
TypeSpecBundleDefinition,
TypeSpecBundleFile,
Expand Down
32 changes: 23 additions & 9 deletions packages/bundler/src/vite/vite-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { resolvePath } from "@typespec/compiler";
import { resolve } from "path";
import type { IndexHtmlTransformContext, Plugin, ResolvedConfig } from "vite";
import {
CreateTypeSpecBundleOptions,
TypeSpecBundle,
TypeSpecBundleDefinition,
createTypeSpecBundle,
Expand Down Expand Up @@ -29,8 +30,10 @@ export function typespecBundlePlugin(options: TypeSpecBundlePluginOptions): Plug
config = c;
},
async buildStart() {
// Minify only in production mode
const minify = config.command === "build";
for (const name of options.libraries) {
const bundle = await bundleLibrary(config.root, name);
const bundle = await bundleLibrary(config.root, name, { minify });
bundles[name] = bundle;
definitions[name] = bundle.definition;
}
Expand Down Expand Up @@ -79,11 +82,17 @@ export function typespecBundlePlugin(options: TypeSpecBundlePluginOptions): Plug
});

for (const library of options.libraries) {
void watchBundleLibrary(config.root, library, (bundle) => {
bundles[library] = bundle;
definitions[library] = bundle.definition;
server.ws.send({ type: "full-reload" });
});
// Don't minify in dev/watch mode for faster rebuilds
void watchBundleLibrary(
config.root,
library,
(bundle) => {
bundles[library] = bundle;
definitions[library] = bundle.definition;
server.ws.send({ type: "full-reload" });
},
{ minify: false },
);
}
},

Expand Down Expand Up @@ -132,13 +141,18 @@ function createImportMap(

return importMap;
}
async function bundleLibrary(projectRoot: string, name: string) {
return await createTypeSpecBundle(resolve(projectRoot, "node_modules", name));
async function bundleLibrary(
projectRoot: string,
name: string,
options?: CreateTypeSpecBundleOptions,
) {
return await createTypeSpecBundle(resolve(projectRoot, "node_modules", name), options);
}
async function watchBundleLibrary(
projectRoot: string,
name: string,
onChange: (bundle: TypeSpecBundle) => void,
options?: CreateTypeSpecBundleOptions,
) {
return await watchTypeSpecBundle(resolve(projectRoot, "node_modules", name), onChange);
return await watchTypeSpecBundle(resolve(projectRoot, "node_modules", name), onChange, options);
}
Loading