microsoft/typespec

Public

mirrored from https://github.com/microsoft/typespecAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/create-github-action-issue

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

packages/bundler/src/bundler.ts

347lines · modecode

1import { compile, joinPaths, NodeHost, normalizePath, resolvePath } from "@typespec/compiler";
2import { BuildOptions, BuildResult, context, Plugin } from "esbuild";
3import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill";
4import { mkdir, readFile, realpath, writeFile } from "fs/promises";
5import { basename, join, resolve } from "path";
6import { promisify } from "util";
7import { gzip } from "zlib";
8import { relativeTo } from "./utils.js";
9
10const gzipAsync = promisify(gzip);
11
12export interface BundleManifest {
13 name: string;
14 version: string;
15 imports: Record<string, string>;
16}
17
18export interface TypeSpecBundleDefinition {
19 path: string;
20 main: string;
21 packageJson: PackageJson;
22 exports: Record<string, string | ExportData>;
23}
24
25export interface ExportData {
26 default?: string;
27 import?: string;
28 types?: string;
29}
30
31export interface TypeSpecBundle {
32 /**
33 * Definition
34 */
35 definition: TypeSpecBundleDefinition;
36
37 /**
38 * Bundle content
39 */
40 files: TypeSpecBundleFile[];
41
42 /**
43 * Resolved manifest.
44 */
45 manifest: BundleManifest;
46}
47
48export interface TypeSpecBundleFile {
49 export?: string;
50 filename: string;
51 content: string;
52 /** Gzipped content, only present if gzip option is enabled */
53 gzipContent?: Buffer;
54}
55
56interface PackageJson {
57 name: string;
58 version: string;
59 main: string;
60 tspMain?: string;
61 peerDependencies: string[];
62 dependencies: string[];
63 exports?: Record<string, string>;
64}
65
66export interface CreateTypeSpecBundleOptions {
67 /**
68 * Whether to minify the output bundle.
69 * @default true
70 */
71 minify?: boolean;
72
73 /**
74 * Whether to also generate gzipped versions of the output files.
75 * When enabled, each file will include a gzipContent buffer.
76 * @default false
77 */
78 gzip?: boolean;
79}
80
81export async function createTypeSpecBundle(
82 libraryPath: string,
83 options?: CreateTypeSpecBundleOptions,
84): Promise<TypeSpecBundle> {
85 const definition = await resolveTypeSpecBundleDefinition(libraryPath);
86 const context = await createEsBuildContext(definition, [], options);
87 try {
88 const result = await context.rebuild();
89 return resolveTypeSpecBundle(definition, result, options);
90 } finally {
91 await context.dispose();
92 }
93}
94
95export async function watchTypeSpecBundle(
96 libraryPath: string,
97 onBundle: (bundle: TypeSpecBundle) => void,
98 options?: CreateTypeSpecBundleOptions,
99) {
100 const definition = await resolveTypeSpecBundleDefinition(libraryPath);
101 const context = await createEsBuildContext(
102 definition,
103 [
104 {
105 name: "example",
106 setup(build) {
107 build.onEnd(async (result) => {
108 const bundle = await resolveTypeSpecBundle(definition, result, options);
109 onBundle(bundle);
110 });
111 },
112 },
113 ],
114 options,
115 );
116 await context.watch();
117}
118
119export async function bundleTypeSpecLibrary(
120 libraryPath: string,
121 outputDir: string,
122 options?: CreateTypeSpecBundleOptions,
123) {
124 const bundle = await createTypeSpecBundle(libraryPath, options);
125 await mkdir(outputDir, { recursive: true });
126 for (const file of bundle.files) {
127 await writeFile(joinPaths(outputDir, file.filename), file.content);
128 if (file.gzipContent) {
129 await writeFile(joinPaths(outputDir, file.filename + ".gz"), file.gzipContent);
130 }
131 }
132 const manifest = createManifest(bundle.definition);
133 await writeFile(joinPaths(outputDir, "manifest.json"), JSON.stringify(manifest, null, 2));
134 if (options?.gzip) {
135 const manifestGzip = await gzipAsync(Buffer.from(JSON.stringify(manifest), "utf-8"));
136 await writeFile(joinPaths(outputDir, "manifest.json.gz"), manifestGzip);
137 }
138}
139
140async function resolveTypeSpecBundleDefinition(
141 libraryPath: string,
142): Promise<TypeSpecBundleDefinition> {
143 libraryPath = normalizePath(await realpath(libraryPath));
144 const pkg = await readLibraryPackageJson(libraryPath);
145
146 const exports = pkg.exports
147 ? Object.fromEntries(
148 Object.entries(pkg.exports).filter(
149 ([k, v]) => k !== "." && k !== "./testing" && k !== "./internals",
150 ),
151 )
152 : {};
153
154 return {
155 path: libraryPath,
156 main: pkg.main,
157 exports,
158 packageJson: pkg,
159 };
160}
161
162async function createEsBuildContext(
163 definition: TypeSpecBundleDefinition,
164 plugins: Plugin[] = [],
165 options?: CreateTypeSpecBundleOptions,
166) {
167 const minify = options?.minify ?? true;
168 const libraryPath = definition.path;
169 const program = await compile(NodeHost, libraryPath, {
170 noEmit: true,
171 });
172 const jsFiles = new Set([resolvePath(libraryPath, definition.packageJson.main)]);
173 for (const file of program.jsSourceFiles.keys()) {
174 if (file.startsWith(libraryPath)) {
175 jsFiles.add(file);
176 }
177 }
178 const typespecFiles: Record<string, string> = {
179 [normalizePath(join(libraryPath, "package.json"))]: JSON.stringify(definition.packageJson),
180 };
181
182 for (const [filename, sourceFile] of program.sourceFiles) {
183 typespecFiles[filename] = sourceFile.file.text;
184 }
185
186 const content = createBundleEntrypoint({
187 libraryPath,
188 mainFile: definition.main,
189 jsSourceFileNames: [...jsFiles],
190 typespecSourceFiles: typespecFiles,
191 });
192
193 const extraEntry = Object.fromEntries(
194 Object.entries(definition.exports).map(([key, value]) => {
195 return [
196 key.replace("./", ""),
197 normalizePath(resolve(libraryPath, getExportEntryPoint(value))),
198 ];
199 }),
200 );
201
202 const virtualPlugin: Plugin = {
203 name: "virtual",
204 setup(build) {
205 build.onResolve({ filter: /^virtual:/ }, (args) => {
206 return {
207 path: args.path,
208 namespace: "virtual",
209 };
210 });
211 build.onResolve({ filter: /.*/ }, (args) => {
212 if (
213 definition.packageJson.peerDependencies &&
214 Object.keys(definition.packageJson.peerDependencies).some((x) => args.path.startsWith(x))
215 ) {
216 return { path: args.path, external: true };
217 }
218 return null;
219 });
220
221 build.onLoad({ filter: /^virtual:/, namespace: "virtual" }, async (args) => {
222 return {
223 contents: content,
224 resolveDir: libraryPath,
225 };
226 });
227 },
228 };
229 return await context({
230 write: false,
231 entryPoints: {
232 index: "virtual:entry.js",
233 ...extraEntry,
234 },
235 bundle: true,
236 splitting: true,
237 outdir: "out",
238 platform: "browser",
239 format: "esm",
240 target: "es2024",
241 minify,
242 plugins: [virtualPlugin, nodeModulesPolyfillPlugin({}), ...plugins],
243 });
244}
245
246async function resolveTypeSpecBundle(
247 definition: TypeSpecBundleDefinition,
248 result: BuildResult<BuildOptions>,
249 options?: CreateTypeSpecBundleOptions,
250): Promise<TypeSpecBundle> {
251 const shouldGzip = options?.gzip ?? false;
252
253 const files: TypeSpecBundleFile[] = await Promise.all(
254 result.outputFiles!.map(async (file) => {
255 const entry = definition.exports[basename(file.path)];
256 const content = file.text;
257 const bundleFile: TypeSpecBundleFile = {
258 filename: file.path.replaceAll("\\", "/").split("/out/")[1],
259 content,
260 export: entry ? getExportEntryPoint(entry) : undefined,
261 };
262
263 if (shouldGzip) {
264 bundleFile.gzipContent = await gzipAsync(Buffer.from(content, "utf-8"));
265 }
266
267 return bundleFile;
268 }),
269 );
270
271 return {
272 definition,
273 manifest: createManifest(definition),
274 files,
275 };
276}
277
278function getExportEntryPoint(value: string | ExportData) {
279 const resolved = typeof value === "string" ? value : (value.import ?? value.default);
280
281 if (!resolved) {
282 throw new Error(
283 `Exports ${JSON.stringify(value, null, 2)} is missing import or default entrypoint`,
284 );
285 }
286
287 return resolved;
288}
289async function readLibraryPackageJson(path: string): Promise<PackageJson> {
290 const file = await readFile(join(path, "package.json"));
291 return JSON.parse(file.toString());
292}
293
294/**
295 * Create a virtual JS file being the entrypoint of the bundle.
296 */
297function createBundleEntrypoint({
298 libraryPath,
299 mainFile,
300 jsSourceFileNames,
301 typespecSourceFiles,
302}: {
303 mainFile: string;
304 libraryPath: string;
305 jsSourceFileNames: string[];
306 typespecSourceFiles: Record<string, string>;
307}): string {
308 const absoluteMain = normalizePath(resolve(libraryPath, mainFile));
309
310 const relativeTypeSpecFiles: Record<string, string> = {};
311 for (const [name, content] of Object.entries(typespecSourceFiles)) {
312 relativeTypeSpecFiles[relativeTo(libraryPath, name)] = content;
313 }
314 return [
315 `export * from "${absoluteMain}";`,
316 ...jsSourceFileNames.map((x, i) => `import * as f${i} from "${x}";`),
317 "",
318 `const TypeSpecJSSources = {`,
319 ...jsSourceFileNames.map((x, i) => `"${relativeTo(libraryPath, x)}": f${i},`),
320 "};",
321
322 `const TypeSpecSources = ${JSON.stringify(relativeTypeSpecFiles, null, 2)};`,
323
324 "export const _TypeSpecLibrary_ = {",
325 " jsSourceFiles: TypeSpecJSSources,",
326 " typespecSourceFiles: TypeSpecSources,",
327 "};",
328 ].join("\n");
329}
330
331function createManifest(definition: TypeSpecBundleDefinition): BundleManifest {
332 return {
333 name: definition.packageJson.name,
334 version: definition.packageJson.version,
335 imports: createImportMap(definition),
336 };
337}
338
339function createImportMap(definition: TypeSpecBundleDefinition): Record<string, string> {
340 const imports: Record<string, string> = {};
341 imports["."] = `./index.js`;
342 for (const name of Object.keys(definition.exports)) {
343 imports[name] = "./" + resolvePath(name) + ".js";
344 }
345
346 return imports;
347}
348