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