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
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"
---

Add support for alloy based emitters
1 change: 1 addition & 0 deletions eng/tsp-core/scripts/upload-bundler-packages.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ await bundleAndUploadPackages({
"@typespec/events",
"@typespec/sse",
"@typespec/xml",
"@typespec/http-client-js",
],
});
82 changes: 76 additions & 6 deletions packages/bundler/src/bundler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { compile, joinPaths, NodeHost, normalizePath, resolvePath } from "@types
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 { basename, dirname, join, resolve } from "path";
import { promisify } from "util";
import { gzip } from "zlib";
import { relativeTo } from "./utils.js";
Expand Down Expand Up @@ -199,6 +199,8 @@ async function createEsBuildContext(
}),
);

const externalPeerDeps = await resolveExternalPeerDependencies(libraryPath, definition);

const virtualPlugin: Plugin = {
name: "virtual",
setup(build) {
Expand All @@ -209,10 +211,7 @@ async function createEsBuildContext(
};
});
build.onResolve({ filter: /.*/ }, (args) => {
if (
definition.packageJson.peerDependencies &&
Object.keys(definition.packageJson.peerDependencies).some((x) => args.path.startsWith(x))
) {
if (externalPeerDeps.some((x) => args.path === x || args.path.startsWith(x + "/"))) {
return { path: args.path, external: true };
}
return null;
Expand All @@ -226,6 +225,25 @@ async function createEsBuildContext(
});
},
};

// When containing alloy-js, namespace its globalThis.__ALLOY__ singleton
// guard so that multiple contained bundles can coexist in the same process.
const alloySingletonPlugin: Plugin = {
name: "alloy-singleton-namespace",
setup(build) {
build.onLoad({ filter: /reactivity\.[jt]s$/ }, async (args) => {
if (!args.path.includes("@alloy-js/core")) return undefined;
const source = await readFile(args.path, "utf-8");
const namespaceKey = `__ALLOY_${definition.packageJson.name.replace(/[^a-zA-Z0-9]/g, "_")}__`;
const patched = source.replaceAll("__ALLOY__", namespaceKey);
return {
contents: patched,
loader: args.path.endsWith(".ts") ? "ts" : "js",
};
});
},
};

return await context({
write: false,
entryPoints: {
Expand All @@ -239,7 +257,12 @@ async function createEsBuildContext(
format: "esm",
target: "es2024",
minify,
plugins: [virtualPlugin, nodeModulesPolyfillPlugin({}), ...plugins],
plugins: [
virtualPlugin,
alloySingletonPlugin,
nodeModulesPolyfillPlugin({ globals: { process: true } }),
...plugins,
],
});
}

Expand Down Expand Up @@ -291,6 +314,53 @@ async function readLibraryPackageJson(path: string): Promise<PackageJson> {
return JSON.parse(file.toString());
}

/**
* Resolve which peer dependencies should be treated as external.
* Only peer dependencies that are TypeSpec libraries (have `tspMain` in their package.json)
* are externalized. Non-TypeSpec peer dependencies (e.g. alloy-js) are bundled inline.
*/
async function resolveExternalPeerDependencies(
libraryPath: string,
definition: TypeSpecBundleDefinition,
): Promise<string[]> {
const peerDeps = definition.packageJson.peerDependencies;
if (!peerDeps) {
return [];
}

const peerDepNames = Object.keys(peerDeps);
const externalDeps: string[] = [];

for (const depName of peerDepNames) {
const isTypeSpec = await isTypeSpecLibrary(libraryPath, depName);
if (isTypeSpec) {
externalDeps.push(depName);
}
}

return externalDeps;
}

async function isTypeSpecLibrary(libraryPath: string, depName: string): Promise<boolean> {
// Walk up from the library path checking node_modules at each level.
// This avoids require.resolve which fails when packages have exports maps
// that don't expose ./package.json.
let dir = libraryPath;
while (true) {
try {
const pkgJsonPath = join(dir, "node_modules", depName, "package.json");
const depPkgJson = JSON.parse((await readFile(pkgJsonPath)).toString());
return !!depPkgJson.tspMain;
} catch {
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
}
// If we can't resolve the package, externalize to be safe (preserves previous behavior)
return true;
}

/**
* Create a virtual JS file being the entrypoint of the bundle.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/playground-website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"@typespec/events": "workspace:^",
"@typespec/html-program-viewer": "workspace:^",
"@typespec/http": "workspace:^",
"@typespec/http-client-js": "workspace:^",
"@typespec/json-schema": "workspace:^",
"@typespec/openapi": "workspace:^",
"@typespec/openapi3": "workspace:^",
Expand Down
1 change: 1 addition & 0 deletions packages/playground-website/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const TypeSpecPlaygroundConfig = {
"@typespec/events",
"@typespec/sse",
"@typespec/xml",
"@typespec/http-client-js",
],
samples,
} as const;
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading