From b8de36512addb032965e91ef96600d0f31e11a93 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 26 Mar 2026 14:15:47 -0400 Subject: [PATCH 1/2] Add http client js to the playground --- .../scripts/upload-bundler-packages.js | 1 + packages/bundler/src/bundler.ts | 82 +++++++++++++++++-- packages/playground-website/package.json | 1 + packages/playground-website/src/config.ts | 1 + pnpm-lock.yaml | 3 + 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/eng/tsp-core/scripts/upload-bundler-packages.js b/eng/tsp-core/scripts/upload-bundler-packages.js index 0d443596bca..dd25eddf096 100644 --- a/eng/tsp-core/scripts/upload-bundler-packages.js +++ b/eng/tsp-core/scripts/upload-bundler-packages.js @@ -22,5 +22,6 @@ await bundleAndUploadPackages({ "@typespec/events", "@typespec/sse", "@typespec/xml", + "@typespec/http-client-js", ], }); diff --git a/packages/bundler/src/bundler.ts b/packages/bundler/src/bundler.ts index 0fc8f5dfc31..52542b49738 100644 --- a/packages/bundler/src/bundler.ts +++ b/packages/bundler/src/bundler.ts @@ -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"; @@ -199,6 +199,8 @@ async function createEsBuildContext( }), ); + const externalPeerDeps = await resolveExternalPeerDependencies(libraryPath, definition); + const virtualPlugin: Plugin = { name: "virtual", setup(build) { @@ -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; @@ -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: { @@ -239,7 +257,12 @@ async function createEsBuildContext( format: "esm", target: "es2024", minify, - plugins: [virtualPlugin, nodeModulesPolyfillPlugin({}), ...plugins], + plugins: [ + virtualPlugin, + alloySingletonPlugin, + nodeModulesPolyfillPlugin({ globals: { process: true } }), + ...plugins, + ], }); } @@ -291,6 +314,53 @@ async function readLibraryPackageJson(path: string): Promise { 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 { + 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 { + // 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. */ diff --git a/packages/playground-website/package.json b/packages/playground-website/package.json index 9e02692cf18..4f78acf5c86 100644 --- a/packages/playground-website/package.json +++ b/packages/playground-website/package.json @@ -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:^", diff --git a/packages/playground-website/src/config.ts b/packages/playground-website/src/config.ts index ee32fdfa694..d1f23fc8a6b 100644 --- a/packages/playground-website/src/config.ts +++ b/packages/playground-website/src/config.ts @@ -15,6 +15,7 @@ export const TypeSpecPlaygroundConfig = { "@typespec/events", "@typespec/sse", "@typespec/xml", + "@typespec/http-client-js", ], samples, } as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e106f99e1d..4e2b80ec9b5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2108,6 +2108,9 @@ importers: '@typespec/http': specifier: workspace:^ version: link:../http + '@typespec/http-client-js': + specifier: workspace:^ + version: link:../http-client-js '@typespec/json-schema': specifier: workspace:^ version: link:../json-schema From af1caa48005aba368daea462d146cb988f1bbc9a Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 26 Mar 2026 12:03:51 -0700 Subject: [PATCH 2/2] Create feature-http-client-js-playground-2026-2-26-18-19-36.md --- ...eature-http-client-js-playground-2026-2-26-18-19-36.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .chronus/changes/feature-http-client-js-playground-2026-2-26-18-19-36.md diff --git a/.chronus/changes/feature-http-client-js-playground-2026-2-26-18-19-36.md b/.chronus/changes/feature-http-client-js-playground-2026-2-26-18-19-36.md new file mode 100644 index 00000000000..c2c4bf451c1 --- /dev/null +++ b/.chronus/changes/feature-http-client-js-playground-2026-2-26-18-19-36.md @@ -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