microsoft/typespec
Publicmirrored from https://github.com/microsoft/typespecAvailable
packages/bundler/src/bundler.ts
347lines · modecode
| 1 | import { compile, joinPaths, NodeHost, normalizePath, resolvePath } from "@typespec/compiler"; |
| 2 | import { BuildOptions, BuildResult, context, Plugin } from "esbuild"; |
| 3 | import { nodeModulesPolyfillPlugin } from "esbuild-plugins-node-modules-polyfill"; |
| 4 | import { mkdir, readFile, realpath, writeFile } from "fs/promises"; |
| 5 | import { basename, join, resolve } from "path"; |
| 6 | import { promisify } from "util"; |
| 7 | import { gzip } from "zlib"; |
| 8 | import { relativeTo } from "./utils.js"; |
| 9 | |
| 10 | const gzipAsync = promisify(gzip); |
| 11 | |
| 12 | export interface BundleManifest { |
| 13 | name: string; |
| 14 | version: string; |
| 15 | imports: Record<string, string>; |
| 16 | } |
| 17 | |
| 18 | export interface TypeSpecBundleDefinition { |
| 19 | path: string; |
| 20 | main: string; |
| 21 | packageJson: PackageJson; |
| 22 | exports: Record<string, string | ExportData>; |
| 23 | } |
| 24 | |
| 25 | export interface ExportData { |
| 26 | default?: string; |
| 27 | import?: string; |
| 28 | types?: string; |
| 29 | } |
| 30 | |
| 31 | export 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 | |
| 48 | export 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 | |
| 56 | interface 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 | |
| 66 | export 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 | |
| 81 | export 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 | |
| 95 | export 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 | |
| 119 | export 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 | |
| 140 | async 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 | |
| 162 | async 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 | |
| 246 | async 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 | |
| 278 | function 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 | } |
| 289 | async 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 | */ |
| 297 | function 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 | |
| 331 | function createManifest(definition: TypeSpecBundleDefinition): BundleManifest { |
| 332 | return { |
| 333 | name: definition.packageJson.name, |
| 334 | version: definition.packageJson.version, |
| 335 | imports: createImportMap(definition), |
| 336 | }; |
| 337 | } |
| 338 | |
| 339 | function 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 | |