From 9c1cc8ec6b05b6fdede1fcd091f2ad206bc6d039 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 15:32:47 -0400 Subject: [PATCH 1/5] fix: rewrite bun interpolator macro for robustness in nested macro contexts The Expr pattern matching approach (.matches, Varargs) broke when bun"..." was used inside another macro's generated code (e.g., utest Tests{}). Rewrite to direct TASTy tree inspection which handles Inlined/Typed/Repeated wrappers from macro expansion. Also fix validation bug: @/bad (empty scope name) was not caught because only the package name after the slash was checked. Co-Authored-By: Claude Opus 4.6 (1M context) --- millbun/src/mill/bun/BunDep.scala | 64 ++++++++++++++++----- millbun/test/src/mill/bun/BunDepTests.scala | 7 +-- 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/millbun/src/mill/bun/BunDep.scala b/millbun/src/mill/bun/BunDep.scala index 8aca3a5..e4a62b7 100644 --- a/millbun/src/mill/bun/BunDep.scala +++ b/millbun/src/mill/bun/BunDep.scala @@ -24,23 +24,56 @@ private object BunDepMacro: import scala.quoted.* def validateImpl(sc: Expr[StringContext], args: Expr[Seq[Any]])(using Quotes): Expr[String] = - import quotes.reflect.* - - sc match - case '{ StringContext(${ Varargs(parts) }*) } => - // For a no-interpolation literal like bun"react@19.0.0", parts has 1 element - parts match - case Seq(Expr(literal: String)) if args.matches('{ Seq() }) || args.matches('{ Nil }) => - validateLiteral(literal) - Expr(literal) - case _ => - // Has interpolated parts — build at runtime, skip compile-time validation - '{ $sc.s($args*) } + literalParts(sc) match + case Some(Seq(literal)) if isEmptyInterpolation(args) => + validateLiteral(literal) + Expr(literal) case _ => - // Inside another macro (e.g., utest's Tests{}) the pattern may not match. - // Fall back to runtime string construction. + // Has interpolated parts, a non-literal StringContext, or runs inside another + // macro-generated context — build at runtime and skip compile-time validation. '{ $sc.s($args*) } + private def literalParts(sc: Expr[StringContext])(using Quotes): Option[Seq[String]] = + import quotes.reflect.* + + def extractRepeatedStrings(term: Term): Option[Seq[String]] = + term match + case Typed(Repeated(partTerms, _), _) => extractStringTerms(partTerms) + case Repeated(partTerms, _) => extractStringTerms(partTerms) + case Inlined(_, _, inner) => extractRepeatedStrings(inner) + case _ => None + + def extractStringTerms(partTerms: Seq[Term]): Option[Seq[String]] = + partTerms.foldRight(Option(List.empty[String])) { (term, acc) => + val part = term match + case Literal(StringConstant(value)) => Some(value) + case Inlined(_, _, inner) => + inner match + case Literal(StringConstant(value)) => Some(value) + case _ => None + case _ => None + for + values <- acc + value <- part + yield value :: values + } + + sc.asTerm.underlyingArgument match + case Apply(_, List(repeatedParts)) => extractRepeatedStrings(repeatedParts) + case _ => None + + private def isEmptyInterpolation(args: Expr[Seq[Any]])(using Quotes): Boolean = + import quotes.reflect.* + + def extractRepeatedArgs(term: Term): Option[Seq[Term]] = + term match + case Typed(Repeated(argTerms, _), _) => Some(argTerms) + case Repeated(argTerms, _) => Some(argTerms) + case Inlined(_, _, inner) => extractRepeatedArgs(inner) + case _ => None + + extractRepeatedArgs(args.asTerm.underlyingArgument).contains(Nil) + private def validateLiteral(dep: String)(using Quotes): Unit = import quotes.reflect.* if dep.isEmpty then @@ -54,6 +87,9 @@ private object BunDepMacro: s"Invalid scoped package: '$dep'. Expected @scope/name or @scope/name@version" ) val slashIdx = afterScope.indexOf('/') + val scopeName = afterScope.take(slashIdx) + if scopeName.isEmpty then + report.errorAndAbort(s"Invalid scoped package: '$dep'. Scope name is empty.") val afterSlash = afterScope.drop(slashIdx + 1) val nameOnly = if afterSlash.contains('@') then afterSlash.take(afterSlash.indexOf('@')) else afterSlash if nameOnly.isEmpty then diff --git a/millbun/test/src/mill/bun/BunDepTests.scala b/millbun/test/src/mill/bun/BunDepTests.scala index 94ae94a..1ecd98f 100644 --- a/millbun/test/src/mill/bun/BunDepTests.scala +++ b/millbun/test/src/mill/bun/BunDepTests.scala @@ -50,10 +50,7 @@ object BunDepTests extends TestSuite { assert(deps.head.startsWith("@anthropic-ai")) } - // Note: compile-time validation errors can't be tested at runtime. - // The following would fail to compile: - // bun"" // empty - // bun"@/bad" // empty name after scope - // bun"@noSlash" // missing slash in scoped name + // Invalid literal coverage lives in integration tests so the interpolator + // is compiled in a normal build.mill context rather than inside another macro. } } From 991b4b202e8291ab9a62637963ef2b78d907f44a Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 15:33:10 -0400 Subject: [PATCH 2/5] test: add integration tests for dependency manifests and bun literal validation - invalid-bun-literal: verifies bun"" fails compilation in a real build.mill context (validates macro error reporting end-to-end) - scalajs-dependency-manifests: tests manifest embedding in published JARs, local optional dep flow, and classpath manifest scanning for dev + optional deps - StubBunModule pattern overrides runBun to avoid needing real Bun Co-Authored-By: Claude Opus 4.6 (1M context) --- .../resources/invalid-bun-literal/build.mill | 21 +++++++ .../invalid-bun-literal/src/App.scala | 1 + .../app-local/AppLocal.scala | 1 + .../app-published/AppPublished.scala | 1 + .../scalajs-dependency-manifests/build.mill | 51 +++++++++++++++ .../local-lib/LocalLib.scala | 1 + .../published-lib/PublishedLib.scala | 1 + ...unDependencyManifestIntegrationTests.scala | 62 +++++++++++++++++++ 8 files changed, 139 insertions(+) create mode 100644 millbun/integration/resources/invalid-bun-literal/build.mill create mode 100644 millbun/integration/resources/invalid-bun-literal/src/App.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/build.mill create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala create mode 100644 millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala create mode 100644 millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala diff --git a/millbun/integration/resources/invalid-bun-literal/build.mill b/millbun/integration/resources/invalid-bun-literal/build.mill new file mode 100644 index 0000000..d67f057 --- /dev/null +++ b/millbun/integration/resources/invalid-bun-literal/build.mill @@ -0,0 +1,21 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.bun.bun +import mill.scalajslib.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +object app extends BunScalaJSModule { + override def moduleDir = build.moduleDir + def scalaVersion = "3.8.2" + + override def moduleKind = Task { ModuleKind.CommonJSModule } + override def sources = Task.Sources(moduleDir / "src") + override def bunDeps = Task { Seq(bun"") } +} diff --git a/millbun/integration/resources/invalid-bun-literal/src/App.scala b/millbun/integration/resources/invalid-bun-literal/src/App.scala new file mode 100644 index 0000000..047460e --- /dev/null +++ b/millbun/integration/resources/invalid-bun-literal/src/App.scala @@ -0,0 +1 @@ +object App diff --git a/millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala b/millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala new file mode 100644 index 0000000..4d67766 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/app-local/AppLocal.scala @@ -0,0 +1 @@ +object AppLocal diff --git a/millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala b/millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala new file mode 100644 index 0000000..03f26b6 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/app-published/AppPublished.scala @@ -0,0 +1 @@ +object AppPublished diff --git a/millbun/integration/resources/scalajs-dependency-manifests/build.mill b/millbun/integration/resources/scalajs-dependency-manifests/build.mill new file mode 100644 index 0000000..9b7908a --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -0,0 +1,51 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.api.PathRef +import mill.scalajslib.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +trait StubBunModule extends BunScalaJSModule { + override def moduleDir = build.moduleDir + def scalaVersion = "3.8.2" + override def moduleKind = Task { ModuleKind.CommonJSModule } + override def bunExecutable = Task { "stub-bun" } + + override protected def runBun( + bunExe: String, + args: Seq[String], + cwd: os.Path, + env: Map[String, String] + ): os.CommandResult = { + os.call(Seq("true"), cwd = cwd, env = env) + } +} + +object localLib extends StubBunModule { + override def sources = Task.Sources(moduleDir / "local-lib") + override def bunOptionalDeps = Task { Seq("optional-local@^1.0.0") } +} + +object publishedLib extends StubBunModule with BunPublishModule { + override def sources = Task.Sources(moduleDir / "published-lib") + override def bunDevDeps = Task { Seq("dev-only@^2.0.0") } + override def bunOptionalDeps = Task { Seq("optional-published@^3.0.0") } +} + +object appLocal extends StubBunModule { + override def sources = Task.Sources(moduleDir / "app-local") + override def moduleDeps = Seq(localLib) +} + +object appPublished extends StubBunModule { + override def sources = Task.Sources(moduleDir / "app-published") + override def runClasspath: T[Seq[PathRef]] = Task { + super.runClasspath() ++ Seq(publishedLib.jar()) + } +} diff --git a/millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala b/millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala new file mode 100644 index 0000000..5cdb8a9 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/local-lib/LocalLib.scala @@ -0,0 +1 @@ +object LocalLib diff --git a/millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala b/millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala new file mode 100644 index 0000000..f610582 --- /dev/null +++ b/millbun/integration/resources/scalajs-dependency-manifests/published-lib/PublishedLib.scala @@ -0,0 +1 @@ +object PublishedLib diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala new file mode 100644 index 0000000..1171c43 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -0,0 +1,62 @@ +package mill.bun + +import mill.api.PathRef +import mill.testkit.IntegrationTester +import utest.* + +object BunDependencyManifestIntegrationTests extends TestSuite { + val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + private def tester(resource: String): IntegrationTester = + new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / resource, + millExecutable = millExe, + useInMemory = true + ) + + private def outputPath(tester: IntegrationTester, selector: String): os.Path = + tester.out(selector).value[PathRef].path + + def tests: Tests = Tests { + + test("invalid bun literal fails in build definitions") { + val tester = this.tester("invalid-bun-literal") + val res = tester.eval("app.bunDeps") + assert(!res.isSuccess) + } + + test("published manifests include dev-only modules") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("publishedLib.jar") + assert(res.isSuccess) + + val jar = outputPath(tester, "publishedLib.jar") + val manifest = BunManifest.readFromJar(jar) + assert(manifest.isDefined) + assert(manifest.get.dependencies.isEmpty) + assert(manifest.get.devDependencies == Map("dev-only" -> "^2.0.0")) + assert(manifest.get.optionalDependencies == Map("optional-published" -> "^3.0.0")) + } + + test("local optional deps flow into generated package.json") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("appLocal.bunInstall") + assert(res.isSuccess) + + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appLocal" / "bunInstall.dest" / "package.json")) + assert(packageJson("optionalDependencies").obj("optional-local").str == "^1.0.0") + } + + test("classpath manifests flow dev and optional deps into generated package.json") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("appPublished.bunInstall") + assert(res.isSuccess) + + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "appPublished" / "bunInstall.dest" / "package.json")) + assert(packageJson("devDependencies").obj("dev-only").str == "^2.0.0") + assert(packageJson("optionalDependencies").obj("optional-published").str == "^3.0.0") + } + } +} From 19c744501057c479c5b2003247028e9782a586ff Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 16:43:16 -0400 Subject: [PATCH 3/5] feat: vendored runtime node_modules in published JARs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BunPublishModule now embeds resolved node_modules at META-INF/bun/node_modules/ in published JARs. Consumers receive exact packages automatically — no lockfile management or bun install needed for transitive published deps. - Add BunVendoredNodeModules for symlink-dereferencing copy and JAR/dir merge - Add bunPublishedRuntimeInstall and bunVendoredRuntimeBundle tasks - Skip BunPublishModule instances in local dep traversal (vendored instead) - Conditionally skip bun install when no deps declared Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 12 +- millbun/src/mill/bun/BunManifest.scala | 8 +- millbun/src/mill/bun/BunToolchainModule.scala | 6 +- .../src/mill/bun/BunVendoredNodeModules.scala | 127 ++++++++++++++++++ .../bun/BunTypeScriptModule.scala | 1 - .../scalajslib/bun/BunPublishModule.scala | 84 +++++++++--- .../scalajslib/bun/BunScalaJSModule.scala | 54 +++++--- .../bun/BunVendoredNodeModulesTests.scala | 116 ++++++++++++++++ 8 files changed, 361 insertions(+), 47 deletions(-) create mode 100644 millbun/src/mill/bun/BunVendoredNodeModules.scala create mode 100644 millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala diff --git a/README.md b/README.md index e3120f9..dfc2d4b 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ object app extends BunScalaJSModule { `BunScalaJSModule` inherits Mill's bundled current Scala.js version, so you configure `scalaVersion` on the module but do not override `scalaJSVersion`. If you keep Scala.js sources at the build root such as `src/`, override `moduleDir = build.moduleDir`; otherwise Mill will look under `/src`. `BunScalaJSTests` runs the Scala.js test bridge on Bun as the JS runtime. For ESM apps, the test linker falls back to CommonJS so Bun can execute the Scala.js test bridge without the temporary `file:` importer failure that affects `bun run -`. +For published Scala.js libraries that must carry JS runtime dependencies to downstream consumers, mix in `BunPublishModule`. It embeds `META-INF/bun/bun-dependencies.json` plus a vendored runtime `node_modules` tree in the published artifact so consumer builds do not need to manage those transitive Bun packages separately. ### TypeScript @@ -74,7 +75,6 @@ Base trait providing Bun discovery and execution helpers. | `bunExecutableName` | `"bun"` | Command name for PATH lookup | | `managedBunExecutable` | `None` | Hook for a downloaded/managed Bun binary | | `bunEnv` | `Map.empty` | Environment variables for Bun subprocesses | -| `bunFrozenLockfile` | `false` | Enforce an existing lockfile | | `bunLinker` | `"hoisted"` | Bun linker strategy | | `bunInstallArgs` | `--save-text-lockfile --linker hoisted` | Default install flags | @@ -129,6 +129,16 @@ Overrides: `npmInstall` (bun install), `compile` (bun x tsc), `run` (bun run), ` Bundle outputs preserve the compiled workspace layout, including `resources/`, and `bunCompileResources` keep their relative paths beneath the module directory. Ambient typings are selected from `bunBundleTarget`: `bun` installs pinned `@types/bun`, `node` installs pinned `@types/node`, and `browser` installs neither. +### `BunPublishModule` + +Mix into a published `BunScalaJSModule` when downstream consumers should receive its runtime JS closure automatically. + +| Task | Default | Description | +|------|---------|-------------| +| `bunDependencyManifest` | — | Writes `META-INF/bun/bun-dependencies.json` for this module's direct runtime JS deps | +| `bunPublishedRuntimeInstall` | — | Resolves this module's direct runtime JS closure in an isolated install workspace | +| `bunVendoredRuntimeBundle` | — | Emits `META-INF/bun/node_modules/**` for deterministic downstream consumption | + ## Examples See `example-scalajs/` and `example-typescript/` for complete consumer projects, and `examples/build.mill` for the broader multi-module example matrix used during development. diff --git a/millbun/src/mill/bun/BunManifest.scala b/millbun/src/mill/bun/BunManifest.scala index 2e8680b..c585a55 100644 --- a/millbun/src/mill/bun/BunManifest.scala +++ b/millbun/src/mill/bun/BunManifest.scala @@ -4,11 +4,9 @@ import java.util.jar.JarFile /** Bun dependency manifest embedded in published JARs. * - * When a Scala.js library declares JS package dependencies via `npmDeps`, - * this manifest is generated and included in the JAR at publish time. - * Consumer builds scan classpath JARs for these manifests and merge them - * into their `package.json`, making JS deps transitive — just like - * Coursier resolves JVM deps from POMs. + * When a Scala.js library declares direct runtime JS package dependencies via + * `npmDeps` / `bunDeps`, this manifest is generated and included in the JAR + * alongside vendored runtime `node_modules`. * * Layout inside JAR: * {{{ diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 8551d0f..0e65db9 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -82,15 +82,11 @@ trait BunToolchainModule extends Module { /** Lockfile names that Bun may produce. */ def bunLockfiles: T[Seq[String]] = Task { Seq("bun.lock", "bun.lockb") } - /** Use Bun's text lockfile by default; optionally enforce an existing lockfile. */ - def bunFrozenLockfile: T[Boolean] = Task { false } - /** Hoisted installs are the safest default for Node-compatible resolution. */ def bunLinker: T[String] = Task { "hoisted" } def bunInstallArgs: T[Seq[String]] = Task { - Seq("--save-text-lockfile", "--linker", bunLinker()) ++ - (if (bunFrozenLockfile()) Seq("--frozen-lockfile") else Nil) + Seq("--save-text-lockfile", "--linker", bunLinker()) } /** diff --git a/millbun/src/mill/bun/BunVendoredNodeModules.scala b/millbun/src/mill/bun/BunVendoredNodeModules.scala new file mode 100644 index 0000000..e3c12a8 --- /dev/null +++ b/millbun/src/mill/bun/BunVendoredNodeModules.scala @@ -0,0 +1,127 @@ +package mill.bun + +import java.nio.file.attribute.BasicFileAttributes +import java.nio.file.{FileVisitOption, FileVisitResult, Files, Path as NioPath, SimpleFileVisitor, StandardCopyOption} +import java.util.EnumSet +import java.util.jar.JarFile + +import scala.jdk.CollectionConverters.* + +/** Helpers for publishing and consuming vendored Bun runtime dependencies. */ +object BunVendoredNodeModules: + val BundleRoot = "META-INF/bun/node_modules" + + /** Copy a node_modules tree while dereferencing symlinks into plain files/directories. */ + def copyResolvedTree(source: os.Path, dest: os.Path): Unit = + if !os.exists(source) then return + + val sourceNio = source.toNIO + val destNio = dest.toNIO + + Files.walkFileTree( + sourceNio, + EnumSet.of(FileVisitOption.FOLLOW_LINKS), + Int.MaxValue, + new SimpleFileVisitor[NioPath]: + override def preVisitDirectory(dir: NioPath, attrs: BasicFileAttributes): FileVisitResult = + val rel = sourceNio.relativize(dir) + if shouldSkip(rel) then FileVisitResult.SKIP_SUBTREE + else + Files.createDirectories(resolveDest(destNio, rel)) + FileVisitResult.CONTINUE + + override def visitFile(file: NioPath, attrs: BasicFileAttributes): FileVisitResult = + val rel = sourceNio.relativize(file) + if !shouldSkip(rel) then + val target = resolveDest(destNio, rel) + Option(target.getParent).foreach(Files.createDirectories(_)) + Files.copy( + file, + target, + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.COPY_ATTRIBUTES + ) + FileVisitResult.CONTINUE + ) + + /** Merge vendored node_modules from a classpath entry into a destination. */ + def mergeFromClasspathEntry(entry: os.Path, destNodeModules: os.Path): Boolean = + if os.isDir(entry) then mergeFromDir(entry, destNodeModules, entry.toString) + else if os.exists(entry) && entry.ext == "jar" then mergeFromJar(entry, destNodeModules) + else false + + private def mergeFromDir(root: os.Path, destNodeModules: os.Path, sourceLabel: String): Boolean = + val bundleRoot = root / os.RelPath(BundleRoot) + if !os.exists(bundleRoot) then return false + + os.walk(bundleRoot).foreach { path => + if path != bundleRoot then + val rel = path.relativeTo(bundleRoot) + if !shouldSkip(rel.toNIO) then + val dest = destNodeModules / rel + if os.isDir(path) then ensureDir(dest.toNIO, sourceLabel) + else mergeFile(path, dest, s"$sourceLabel!/$rel") + } + + true + + private def mergeFromJar(jarPath: os.Path, destNodeModules: os.Path): Boolean = + val prefix = BundleRoot + "/" + val jar = new JarFile(jarPath.toIO) + + try + val entries = jar.entries().asScala.toVector.filter(_.getName.startsWith(prefix)) + if entries.isEmpty then return false + + entries.sortBy(_.getName).foreach { entry => + val relString = entry.getName.stripPrefix(prefix) + if relString.nonEmpty then + val rel = os.RelPath(relString) + if !shouldSkip(rel.toNIO) then + val dest = destNodeModules / rel + val sourceLabel = s"$jarPath!/${entry.getName}" + + if entry.isDirectory then ensureDir(dest.toNIO, sourceLabel) + else + val input = jar.getInputStream(entry) + try mergeBytes(input.readAllBytes(), dest, sourceLabel) + finally input.close() + } + + true + finally jar.close() + + private def ensureDir(dest: NioPath, sourceLabel: String): Unit = + if Files.exists(dest) && !Files.isDirectory(dest) then + throw new RuntimeException(s"Vendored Bun bundle conflict at $dest from $sourceLabel: expected a directory.") + Files.createDirectories(dest) + + private def mergeFile(source: os.Path, dest: os.Path, sourceLabel: String): Unit = + if os.exists(dest) then + if os.isDir(dest) then + throw new RuntimeException(s"Vendored Bun bundle conflict at $dest from $sourceLabel: expected a file.") + if Files.mismatch(source.toNIO, dest.toNIO) != -1L then + throw new RuntimeException(s"Vendored Bun bundle conflict at $dest while merging $sourceLabel.") + else + Option(dest.toNIO.getParent).foreach(Files.createDirectories(_)) + Files.copy( + source.toNIO, + dest.toNIO, + StandardCopyOption.COPY_ATTRIBUTES + ) + + private def mergeBytes(bytes: Array[Byte], dest: os.Path, sourceLabel: String): Unit = + if os.exists(dest) then + if os.isDir(dest) then + throw new RuntimeException(s"Vendored Bun bundle conflict at $dest from $sourceLabel: expected a file.") + val existing = os.read.bytes(dest) + if existing.length != bytes.length || !java.util.Arrays.equals(existing, bytes) then + throw new RuntimeException(s"Vendored Bun bundle conflict at $dest while merging $sourceLabel.") + else + os.write.over(dest, bytes, createFolders = true) + + private def resolveDest(destRoot: NioPath, rel: NioPath): NioPath = + if rel.getNameCount == 0 then destRoot else destRoot.resolve(rel) + + private def shouldSkip(rel: NioPath): Boolean = + rel.iterator().asScala.exists(_.toString == ".bin") diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index c1983ab..eece4db 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -127,7 +127,6 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out PathRef(dest) } - /** * Preserve Mill's compile sandbox preparation, but invoke TypeScript through * Bun instead of a Node-shebang script. diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index b5f49a3..bd419e2 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -2,12 +2,15 @@ package mill.scalajslib package bun import mill.* -import mill.bun.{BunManifest, BunToolchainModule} +import mill.api.BuildCtx +import mill.bun.{BunManifest, BunToolchainModule, BunVendoredNodeModules} +import mill.scalajslib.api.ModuleKind /** Opt-in trait for Scala.js libraries that publish JARs with embedded bun dependency manifests. * * Mix this into modules whose JARs should carry `META-INF/bun/bun-dependencies.json` - * so that consumers automatically resolve JS package dependencies via `classpathBunDeps`. + * plus vendored runtime `node_modules` so consumers automatically receive the + * exact JS packages required by the published library. * * {{{ * object myLib extends BunScalaJSModule with BunPublishModule { @@ -17,34 +20,83 @@ import mill.bun.{BunManifest, BunToolchainModule} */ trait BunPublishModule extends BunScalaJSModule { - /** Generate bun dependency manifest + lockfile for inclusion in published JARs. + /** Generate bun dependency manifest for inclusion in published JARs. * - * The manifest declares this library's JS package requirements so that - * consumers automatically get them via `classpathBunDeps`. The lockfile - * is embedded alongside for deterministic resolution seeding. + * The manifest describes this library's direct runtime JS requirements. */ def bunDependencyManifest: T[PathRef] = Task { val allDeps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val allDevDeps = (npmDevDeps() ++ bunDevDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap val optDeps = bunOptionalDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val manifest = BunManifest(allDeps, allDevDeps, optDeps) + val manifest = BunManifest(allDeps, Map.empty, optDeps) val metaDir = Task.dest / "META-INF" / "bun" os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true) PathRef(Task.dest) } + /** Install this module's direct runtime JS closure for vendoring in published artifacts. */ + def bunPublishedRuntimeInstall: T[PathRef] = Task { + val dest = Task.dest + os.makeDir.all(dest) + + val npmRc = BuildCtx.workspaceRoot / ".npmrc" + if (os.exists(npmRc)) os.copy.over(npmRc, dest / ".npmrc", createFolders = true) + bunfigFiles().foreach { cfg => + os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) + } + + val deps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep) + val optional = bunOptionalDeps().map(BunToolchainModule.splitDep) + val base = ujson.Obj( + "name" -> defaultPackageName, + "private" -> true, + "version" -> "0.0.0", + "dependencies" -> ujson.Obj.from(deps) + ) + if optional.nonEmpty then + base("optionalDependencies") = ujson.Obj.from(optional) + + moduleKind() match + case ModuleKind.ESModule => base("type") = "module" + case _ => () + + val merged = ujson.Obj.from(base.value.toSeq ++ bunPackageJsonExtras().value.toSeq) + os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) + + val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty + if hasRuntimeInputs then + runBun( + bunExecutable(), + Seq("install") ++ bunInstallArgs() ++ unmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunEnv() + ) + + PathRef(dest) + } + + /** Vendored runtime node_modules for deterministic downstream consumption. */ + def bunVendoredRuntimeBundle: T[PathRef] = Task { + val metaDir = Task.dest / "META-INF" / "bun" + val runtimeNodeModules = bunPublishedRuntimeInstall().path / "node_modules" + + if (os.exists(runtimeNodeModules)) { + BunVendoredNodeModules.copyResolvedTree(runtimeNodeModules, metaDir / "node_modules") + } + + PathRef(Task.dest) + } + /** Resource paths that include the bun dependency manifest. * - * When this module declares any JS deps, the manifest - * is embedded in the published JAR. + * When this module declares any runtime JS deps, the manifest and vendored + * runtime tree are embedded in the published JAR. */ def bunDependencyManifestResources: T[Seq[PathRef]] = Task { - if npmDeps().nonEmpty || bunDeps().nonEmpty || - npmDevDeps().nonEmpty || bunDevDeps().nonEmpty || - bunOptionalDeps().nonEmpty - then - Seq(bunDependencyManifest()) - else Seq.empty + val hasManifest = npmDeps().nonEmpty || bunDeps().nonEmpty || bunOptionalDeps().nonEmpty + val hasVendoredRuntime = hasManifest || unmanagedDeps().nonEmpty + + (if hasManifest then Seq(bunDependencyManifest()) else Seq.empty) ++ + (if hasVendoredRuntime then Seq(bunVendoredRuntimeBundle()) else Seq.empty) } override def resources: T[Seq[PathRef]] = Task { diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 3ac29ff..a6aee12 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -4,7 +4,7 @@ package bun import mill.* import mill.api.BuildCtx import mill.api.JsonFormatters.given -import mill.bun.{BunManifest, BunToolchainModule} +import mill.bun.{BunManifest, BunToolchainModule, BunVendoredNodeModules} import mill.javalib.JavaModule import mill.scalajslib.* import mill.scalajslib.api.* @@ -67,22 +67,23 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out loop(moduleDepsChecked.toList ++ runModuleDepsChecked.toList, Set.empty, Vector.empty) } + private def recursiveInstallBunModuleDeps: Seq[BunScalaJSModule] = + recursiveBunModuleDeps.filterNot(_.isInstanceOf[BunPublishModule]) + def transitiveNpmDeps: T[Seq[String]] = Task { - val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDeps)().flatten - val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDeps)().flatten - val jarDeps = classpathBunDeps() - moduleNpm ++ moduleBun ++ jarDeps ++ npmDeps() ++ bunDeps() + val moduleNpm = Task.traverse(recursiveInstallBunModuleDeps)(_.npmDeps)().flatten + val moduleBun = Task.traverse(recursiveInstallBunModuleDeps)(_.bunDeps)().flatten + moduleNpm ++ moduleBun ++ npmDeps() ++ bunDeps() } def transitiveNpmDevDeps: T[Seq[String]] = Task { - val moduleNpm = Task.traverse(recursiveBunModuleDeps)(_.npmDevDeps)().flatten - val moduleBun = Task.traverse(recursiveBunModuleDeps)(_.bunDevDeps)().flatten - val jarDevDeps = classpathBunDevDeps() - moduleNpm ++ moduleBun ++ jarDevDeps ++ npmDevDeps() ++ bunDevDeps() + val moduleNpm = Task.traverse(recursiveInstallBunModuleDeps)(_.npmDevDeps)().flatten + val moduleBun = Task.traverse(recursiveInstallBunModuleDeps)(_.bunDevDeps)().flatten + moduleNpm ++ moduleBun ++ npmDevDeps() ++ bunDevDeps() } def transitiveUnmanagedDeps: T[Seq[PathRef]] = Task { - Task.traverse(recursiveBunModuleDeps)(_.unmanagedDeps)().flatten ++ unmanagedDeps() + Task.traverse(recursiveInstallBunModuleDeps)(_.unmanagedDeps)().flatten ++ unmanagedDeps() } /** Optional JS packages — installed if available, not fatal if missing. */ @@ -157,9 +158,8 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } def transitiveBunOptionalDeps: T[Seq[String]] = Task { - val moduleOptional = Task.traverse(recursiveBunModuleDeps)(_.bunOptionalDeps)().flatten - val jarOptional = classpathBunOptionalDeps() - moduleOptional ++ jarOptional ++ bunOptionalDeps() + val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(_.bunOptionalDeps)().flatten + moduleOptional ++ bunOptionalDeps() } private def mkBunPackageJson: Task[Unit] = Task.Anon { @@ -187,6 +187,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) } + private def mergeVendoredNodeModules(entries: Seq[os.Path], destNodeModules: os.Path): Unit = + entries.foreach { entry => + BunVendoredNodeModules.mergeFromClasspathEntry(entry, destNodeModules) + } + def bunInstall: T[PathRef] = Task { val dest = Task.dest os.makeDir.all(dest) @@ -199,12 +204,23 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out mkBunPackageJson() - runBun( - bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), - cwd = dest, - env = bunEnv() - ) + val hasInstallInputs = + transitiveNpmDeps().nonEmpty || + transitiveNpmDevDeps().nonEmpty || + transitiveBunOptionalDeps().nonEmpty || + transitiveUnmanagedDeps().nonEmpty + + if hasInstallInputs then + runBun( + bunExecutable(), + Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunEnv() + ) + + val ownResourceRoots = resources().map(_.path).toSet + val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) + mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") PathRef(dest) } diff --git a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala new file mode 100644 index 0000000..4f82c4e --- /dev/null +++ b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala @@ -0,0 +1,116 @@ +package mill.bun + +import java.io.FileOutputStream +import java.util.jar.{JarEntry, JarOutputStream} + +import utest.* + +object BunVendoredNodeModulesTests extends TestSuite { + def tests: Tests = Tests { + + test("copyResolvedTree copies vendored files and skips .bin") { + val source = os.temp.dir() + val dest = os.temp.dir() + + os.makeDir.all(source / "@scope" / "pkg") + os.write(source / "@scope" / "pkg" / "package.json", """{"name":"@scope/pkg","version":"1.0.0"}""") + os.makeDir.all(source / ".bin") + os.write(source / ".bin" / "tool", "#!/usr/bin/env node") + + BunVendoredNodeModules.copyResolvedTree(source, dest) + + assert(os.exists(dest / "@scope" / "pkg" / "package.json")) + assert(!os.exists(dest / ".bin" / "tool")) + } + + test("mergeFromClasspathEntry merges vendored node_modules from directories") { + val classpathDir = os.temp.dir() + val bundleRoot = classpathDir / os.RelPath(BunVendoredNodeModules.BundleRoot) + val dest = os.temp.dir() + + os.makeDir.all(bundleRoot / "react") + os.write(bundleRoot / "react" / "package.json", """{"name":"react","version":"19.1.1"}""") + + val merged = BunVendoredNodeModules.mergeFromClasspathEntry(classpathDir, dest / "node_modules") + + assert(merged) + assert(os.read(dest / "node_modules" / "react" / "package.json").contains("19.1.1")) + } + + test("mergeFromClasspathEntry merges vendored node_modules from jars") { + val jarPath = tempJar( + Map( + s"${BunVendoredNodeModules.BundleRoot}/react/package.json" -> + """{"name":"react","version":"19.1.1"}""" + ) + ) + val dest = os.temp.dir() + + val merged = BunVendoredNodeModules.mergeFromClasspathEntry(jarPath, dest / "node_modules") + + assert(merged) + assert(os.read(dest / "node_modules" / "react" / "package.json").contains("19.1.1")) + } + + test("identical vendored files can be merged repeatedly") { + val first = os.temp.dir() + val second = os.temp.dir() + val dest = os.temp.dir() + + writeVendoredPackage(first, "react", "19.1.1") + writeVendoredPackage(second, "react", "19.1.1") + + assert(BunVendoredNodeModules.mergeFromClasspathEntry(first, dest / "node_modules")) + assert(BunVendoredNodeModules.mergeFromClasspathEntry(second, dest / "node_modules")) + assert(os.read(dest / "node_modules" / "react" / "package.json").contains("19.1.1")) + } + + test("conflicting vendored files fail fast") { + val first = os.temp.dir() + val second = os.temp.dir() + val dest = os.temp.dir() + + writeVendoredPackage(first, "react", "19.1.1") + writeVendoredPackage(second, "react", "19.2.0") + + BunVendoredNodeModules.mergeFromClasspathEntry(first, dest / "node_modules") + + val err = intercept[RuntimeException] { + BunVendoredNodeModules.mergeFromClasspathEntry(second, dest / "node_modules") + } + + assert(err.getMessage.contains("Vendored Bun bundle conflict")) + } + } + + private def writeVendoredPackage(root: os.Path, name: String, version: String): Unit = { + val bundleRoot = root / os.RelPath(BunVendoredNodeModules.BundleRoot) + os.makeDir.all(bundleRoot / name) + os.write(bundleRoot / name / "package.json", s"""{"name":"$name","version":"$version"}""") + } + + private def tempJar(entries: Map[String, String]): os.Path = { + val jarPath = os.temp.dir() / "bundle.jar" + val jarOut = new JarOutputStream(new FileOutputStream(jarPath.toIO)) + try + entries.toSeq.sortBy(_._1).foreach { case (path, content) => + val parentDirs = parentDirectories(path) + parentDirs.foreach { dir => + jarOut.putNextEntry(new JarEntry(dir)) + jarOut.closeEntry() + } + jarOut.putNextEntry(new JarEntry(path)) + jarOut.write(content.getBytes("UTF-8")) + jarOut.closeEntry() + } + finally jarOut.close() + + jarPath + } + + private def parentDirectories(path: String): Seq[String] = + path.split('/').dropRight(1).scanLeft("") { + case ("", segment) => s"$segment/" + case (acc, segment) => s"$acc$segment/" + }.drop(1) +} From 4c52272fb67a73c57326f48510c39b358a2057e3 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 16:57:40 -0400 Subject: [PATCH 4/5] fix: manifest fallback for legacy JARs, extras-aware install guard - Classpath manifest scanning now skips entries with vendored node_modules (handled by mergeVendoredNodeModules) and falls back to manifest-based dep resolution for legacy JARs without a vendored tree - Restore devDependencies in published manifests as metadata - Guard bun install against bunPackageJsonExtras supplying deps outside the typed task API (in both bunInstall and bunPublishedRuntimeInstall) - Add hasVendoredNodeModules detection tests Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/mill/bun/BunVendoredNodeModules.scala | 9 +++++++++ .../scalajslib/bun/BunPublishModule.scala | 6 ++++-- .../scalajslib/bun/BunScalaJSModule.scala | 15 ++++++++++----- .../bun/BunVendoredNodeModulesTests.scala | 19 +++++++++++++++++++ 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/millbun/src/mill/bun/BunVendoredNodeModules.scala b/millbun/src/mill/bun/BunVendoredNodeModules.scala index e3c12a8..623d487 100644 --- a/millbun/src/mill/bun/BunVendoredNodeModules.scala +++ b/millbun/src/mill/bun/BunVendoredNodeModules.scala @@ -123,5 +123,14 @@ object BunVendoredNodeModules: private def resolveDest(destRoot: NioPath, rel: NioPath): NioPath = if rel.getNameCount == 0 then destRoot else destRoot.resolve(rel) + /** Check whether a classpath entry contains vendored node_modules. */ + def hasVendoredNodeModules(entry: os.Path): Boolean = + if os.isDir(entry) then os.exists(entry / os.RelPath(BundleRoot)) + else if os.exists(entry) && entry.ext == "jar" then + val jar = new JarFile(entry.toIO) + try jar.entries().asScala.exists(_.getName.startsWith(BundleRoot + "/")) + finally jar.close() + else false + private def shouldSkip(rel: NioPath): Boolean = rel.iterator().asScala.exists(_.toString == ".bin") diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index bd419e2..05a69c1 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -26,8 +26,9 @@ trait BunPublishModule extends BunScalaJSModule { */ def bunDependencyManifest: T[PathRef] = Task { val allDeps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + val allDevDeps = (npmDevDeps() ++ bunDevDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap val optDeps = bunOptionalDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val manifest = BunManifest(allDeps, Map.empty, optDeps) + val manifest = BunManifest(allDeps, allDevDeps, optDeps) val metaDir = Task.dest / "META-INF" / "bun" os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true) PathRef(Task.dest) @@ -62,7 +63,8 @@ trait BunPublishModule extends BunScalaJSModule { val merged = ujson.Obj.from(base.value.toSeq ++ bunPackageJsonExtras().value.toSeq) os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) - val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty + val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty || + bunPackageJsonExtras().value.nonEmpty if hasRuntimeInputs then runBun( bunExecutable(), diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index a6aee12..98a1706 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -73,13 +73,13 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out def transitiveNpmDeps: T[Seq[String]] = Task { val moduleNpm = Task.traverse(recursiveInstallBunModuleDeps)(_.npmDeps)().flatten val moduleBun = Task.traverse(recursiveInstallBunModuleDeps)(_.bunDeps)().flatten - moduleNpm ++ moduleBun ++ npmDeps() ++ bunDeps() + moduleNpm ++ moduleBun ++ classpathBunDeps() ++ npmDeps() ++ bunDeps() } def transitiveNpmDevDeps: T[Seq[String]] = Task { val moduleNpm = Task.traverse(recursiveInstallBunModuleDeps)(_.npmDevDeps)().flatten val moduleBun = Task.traverse(recursiveInstallBunModuleDeps)(_.bunDevDeps)().flatten - moduleNpm ++ moduleBun ++ npmDevDeps() ++ bunDevDeps() + moduleNpm ++ moduleBun ++ classpathBunDevDeps() ++ npmDevDeps() ++ bunDevDeps() } def transitiveUnmanagedDeps: T[Seq[PathRef]] = Task { @@ -108,10 +108,14 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out classpathBunManifests().flatMap(_.optionalDependencies).map { case (name, version) => s"$name@$version" } } + /** Manifests from classpath entries that do NOT carry vendored node_modules. + * Entries with a vendored tree are handled by `mergeVendoredNodeModules` instead. + */ private def classpathBunManifests: Task[Seq[BunManifest]] = Task.Anon { runClasspath().flatMap { ref => val path = ref.path - if os.exists(path) && path.ext == "jar" then BunManifest.readFromJar(path).toSeq + if BunVendoredNodeModules.hasVendoredNodeModules(path) then Nil + else if os.exists(path) && path.ext == "jar" then BunManifest.readFromJar(path).toSeq else if os.isDir(path) then BunManifest.readFromDir(path).toSeq else Nil } @@ -159,7 +163,7 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out def transitiveBunOptionalDeps: T[Seq[String]] = Task { val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(_.bunOptionalDeps)().flatten - moduleOptional ++ bunOptionalDeps() + moduleOptional ++ classpathBunOptionalDeps() ++ bunOptionalDeps() } private def mkBunPackageJson: Task[Unit] = Task.Anon { @@ -208,7 +212,8 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out transitiveNpmDeps().nonEmpty || transitiveNpmDevDeps().nonEmpty || transitiveBunOptionalDeps().nonEmpty || - transitiveUnmanagedDeps().nonEmpty + transitiveUnmanagedDeps().nonEmpty || + bunPackageJsonExtras().value.nonEmpty if hasInstallInputs then runBun( diff --git a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala index 4f82c4e..54caa89 100644 --- a/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala +++ b/millbun/test/src/mill/bun/BunVendoredNodeModulesTests.scala @@ -65,6 +65,25 @@ object BunVendoredNodeModulesTests extends TestSuite { assert(os.read(dest / "node_modules" / "react" / "package.json").contains("19.1.1")) } + test("hasVendoredNodeModules detects vendored tree in directories") { + val withVendor = os.temp.dir() + writeVendoredPackage(withVendor, "react", "19.1.1") + assert(BunVendoredNodeModules.hasVendoredNodeModules(withVendor)) + + val withoutVendor = os.temp.dir() + assert(!BunVendoredNodeModules.hasVendoredNodeModules(withoutVendor)) + } + + test("hasVendoredNodeModules detects vendored tree in jars") { + val withVendor = tempJar( + Map(s"${BunVendoredNodeModules.BundleRoot}/react/package.json" -> """{"name":"react"}""") + ) + assert(BunVendoredNodeModules.hasVendoredNodeModules(withVendor)) + + val withoutVendor = tempJar(Map("META-INF/bun/bun-dependencies.json" -> "{}")) + assert(!BunVendoredNodeModules.hasVendoredNodeModules(withoutVendor)) + } + test("conflicting vendored files fail fast") { val first = os.temp.dir() val second = os.temp.dir() From 3ca204f0af9db3fb2594ea6f780247784c326710 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Tue, 7 Apr 2026 17:26:55 -0400 Subject: [PATCH 5/5] feat: opt-in vendored runtime, extras-aware manifest, expanded tests Make vendored node_modules opt-in via bunPublishVendoredRuntime (default false) since published JARs are cross-platform but bun installs can materialize host-specific binaries. Manifests are always published. - BunPublishModule reads dep fields from bunPackageJsonExtras with manifestField helper, falling back to typed tasks - resolvedPublishedManifest centralizes manifest construction - Add RecordingStubBunModule for integration tests that simulate installs - New integration tests: extras-only install, vendored opt-in publishing, consumer merging of vendored tree, manifest-only default verification Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +- .../scalajs-dependency-manifests/build.mill | 64 +++++++++++++++++++ ...unDependencyManifestIntegrationTests.scala | 63 ++++++++++++++++++ millbun/src/mill/bun/BunManifest.scala | 2 +- .../scalajslib/bun/BunPublishModule.scala | 61 ++++++++++++++---- 5 files changed, 182 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index dfc2d4b..fb176a7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ object app extends BunScalaJSModule { `BunScalaJSModule` inherits Mill's bundled current Scala.js version, so you configure `scalaVersion` on the module but do not override `scalaJSVersion`. If you keep Scala.js sources at the build root such as `src/`, override `moduleDir = build.moduleDir`; otherwise Mill will look under `/src`. `BunScalaJSTests` runs the Scala.js test bridge on Bun as the JS runtime. For ESM apps, the test linker falls back to CommonJS so Bun can execute the Scala.js test bridge without the temporary `file:` importer failure that affects `bun run -`. -For published Scala.js libraries that must carry JS runtime dependencies to downstream consumers, mix in `BunPublishModule`. It embeds `META-INF/bun/bun-dependencies.json` plus a vendored runtime `node_modules` tree in the published artifact so consumer builds do not need to manage those transitive Bun packages separately. +For published Scala.js libraries that must carry JS runtime dependencies to downstream consumers, mix in `BunPublishModule`. By default it embeds `META-INF/bun/bun-dependencies.json` so consumers keep resolving transitive Bun packages via manifests. If you need to ship a vendored runtime tree as well, set `bunPublishVendoredRuntime = true` and only do so when the resolved closure is platform-independent. ### TypeScript @@ -132,12 +132,14 @@ Ambient typings are selected from `bunBundleTarget`: `bun` installs pinned `@typ ### `BunPublishModule` Mix into a published `BunScalaJSModule` when downstream consumers should receive its runtime JS closure automatically. +Published artifacts stay manifest-only by default; opt into vendored `node_modules` only when you know the closure is safe to ship across platforms. | Task | Default | Description | |------|---------|-------------| +| `bunPublishVendoredRuntime` | `false` | Embed `META-INF/bun/node_modules/**` from a local Bun install | | `bunDependencyManifest` | — | Writes `META-INF/bun/bun-dependencies.json` for this module's direct runtime JS deps | | `bunPublishedRuntimeInstall` | — | Resolves this module's direct runtime JS closure in an isolated install workspace | -| `bunVendoredRuntimeBundle` | — | Emits `META-INF/bun/node_modules/**` for deterministic downstream consumption | +| `bunVendoredRuntimeBundle` | — | Emits `META-INF/bun/node_modules/**` when vendored publishing is enabled | ## Examples diff --git a/millbun/integration/resources/scalajs-dependency-manifests/build.mill b/millbun/integration/resources/scalajs-dependency-manifests/build.mill index 9b7908a..3f7f022 100644 --- a/millbun/integration/resources/scalajs-dependency-manifests/build.mill +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -27,6 +27,35 @@ trait StubBunModule extends BunScalaJSModule { } } +trait RecordingStubBunModule extends StubBunModule { + override protected def runBun( + bunExe: String, + args: Seq[String], + cwd: os.Path, + env: Map[String, String] + ): os.CommandResult = { + os.write.over(cwd / ".stub-bun-ran", args.mkString(" "), createFolders = true) + + val packageJson = cwd / "package.json" + if os.exists(packageJson) then + val parsed = ujson.read(os.read(packageJson)) + Seq("dependencies", "optionalDependencies").foreach { key => + parsed.obj.get(key).foreach { deps => + deps.obj.foreach { case (name, version) => + val pkgDir = cwd / "node_modules" / os.RelPath(name) + os.write.over( + pkgDir / "package.json", + ujson.Obj("name" -> name, "version" -> version.str).render(indent = 2), + createFolders = true + ) + } + } + } + + os.call(Seq("true"), cwd = cwd, env = env) + } +} + object localLib extends StubBunModule { override def sources = Task.Sources(moduleDir / "local-lib") override def bunOptionalDeps = Task { Seq("optional-local@^1.0.0") } @@ -38,6 +67,23 @@ object publishedLib extends StubBunModule with BunPublishModule { override def bunOptionalDeps = Task { Seq("optional-published@^3.0.0") } } +object publishedDevOnlyLib extends StubBunModule with BunPublishModule { + override def sources = Task.Sources(moduleDir / "published-lib") + override def bunDevDeps = Task { Seq("dev-only@^2.0.0") } +} + +object publishedVendoredExtraLib extends RecordingStubBunModule with BunPublishModule { + override def sources = Task.Sources(moduleDir / "published-lib") + override def bunPublishVendoredRuntime = Task { true } + override def bunPackageJsonExtras = Task { + ujson.Obj( + "dependencies" -> ujson.Obj( + "vendored-extra" -> "^4.0.0" + ) + ) + } +} + object appLocal extends StubBunModule { override def sources = Task.Sources(moduleDir / "app-local") override def moduleDeps = Seq(localLib) @@ -49,3 +95,21 @@ object appPublished extends StubBunModule { super.runClasspath() ++ Seq(publishedLib.jar()) } } + +object appExtrasOnly extends RecordingStubBunModule { + override def sources = Task.Sources(moduleDir / "app-local") + override def bunPackageJsonExtras = Task { + ujson.Obj( + "dependencies" -> ujson.Obj( + "extras-only" -> "^5.0.0" + ) + ) + } +} + +object appVendored extends StubBunModule { + override def sources = Task.Sources(moduleDir / "app-published") + override def runClasspath: T[Seq[PathRef]] = Task { + super.runClasspath() ++ Seq(publishedVendoredExtraLib.jar()) + } +} diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index 1171c43..6911ef8 100644 --- a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -27,6 +27,19 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(!res.isSuccess) } + test("published dev-only manifests are still emitted") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("publishedDevOnlyLib.jar") + assert(res.isSuccess) + + val jar = outputPath(tester, "publishedDevOnlyLib.jar") + val manifest = BunManifest.readFromJar(jar) + assert(manifest.isDefined) + assert(manifest.get.dependencies.isEmpty) + assert(manifest.get.devDependencies == Map("dev-only" -> "^2.0.0")) + assert(manifest.get.optionalDependencies.isEmpty) + } + test("published manifests include dev-only modules") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("publishedLib.jar") @@ -40,6 +53,15 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(manifest.get.optionalDependencies == Map("optional-published" -> "^3.0.0")) } + test("published jars stay manifest-only by default") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("publishedLib.jar") + assert(res.isSuccess) + + val jar = outputPath(tester, "publishedLib.jar") + assert(!BunVendoredNodeModules.hasVendoredNodeModules(jar)) + } + test("local optional deps flow into generated package.json") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("appLocal.bunInstall") @@ -58,5 +80,46 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(packageJson("devDependencies").obj("dev-only").str == "^2.0.0") assert(packageJson("optionalDependencies").obj("optional-published").str == "^3.0.0") } + + test("bunInstall runs when bunPackageJsonExtras adds dependencies") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("appExtrasOnly.bunInstall") + assert(res.isSuccess) + + val installDir = tester.workspacePath / "out" / "appExtrasOnly" / "bunInstall.dest" + assert(os.exists(installDir / ".stub-bun-ran")) + assert(os.exists(installDir / "node_modules" / "extras-only" / "package.json")) + } + + test("bunPublishedRuntimeInstall runs for vendored extras-only published deps") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("publishedVendoredExtraLib.bunPublishedRuntimeInstall") + assert(res.isSuccess) + + val installDir = tester.workspacePath / "out" / "publishedVendoredExtraLib" / "bunPublishedRuntimeInstall.dest" + assert(os.exists(installDir / ".stub-bun-ran")) + assert(os.exists(installDir / "node_modules" / "vendored-extra" / "package.json")) + } + + test("opted-in published jars embed vendored runtime") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("publishedVendoredExtraLib.jar") + assert(res.isSuccess) + + val jar = outputPath(tester, "publishedVendoredExtraLib.jar") + val manifest = BunManifest.readFromJar(jar) + assert(manifest.isDefined) + assert(manifest.get.dependencies == Map("vendored-extra" -> "^4.0.0")) + assert(BunVendoredNodeModules.hasVendoredNodeModules(jar)) + } + + test("consumer bunInstall merges opted-in vendored runtime") { + val tester = this.tester("scalajs-dependency-manifests") + val res = tester.eval("appVendored.bunInstall") + assert(res.isSuccess) + + val installDir = tester.workspacePath / "out" / "appVendored" / "bunInstall.dest" + assert(os.exists(installDir / "node_modules" / "vendored-extra" / "package.json")) + } } } diff --git a/millbun/src/mill/bun/BunManifest.scala b/millbun/src/mill/bun/BunManifest.scala index c585a55..53e6834 100644 --- a/millbun/src/mill/bun/BunManifest.scala +++ b/millbun/src/mill/bun/BunManifest.scala @@ -6,7 +6,7 @@ import java.util.jar.JarFile * * When a Scala.js library declares direct runtime JS package dependencies via * `npmDeps` / `bunDeps`, this manifest is generated and included in the JAR - * alongside vendored runtime `node_modules`. + * and may optionally be accompanied by vendored runtime `node_modules`. * * Layout inside JAR: * {{{ diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 05a69c1..0c29451 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -9,8 +9,8 @@ import mill.scalajslib.api.ModuleKind /** Opt-in trait for Scala.js libraries that publish JARs with embedded bun dependency manifests. * * Mix this into modules whose JARs should carry `META-INF/bun/bun-dependencies.json` - * plus vendored runtime `node_modules` so consumers automatically receive the - * exact JS packages required by the published library. + * and can optionally embed a vendored runtime `node_modules` tree for + * downstream consumers. * * {{{ * object myLib extends BunScalaJSModule with BunPublishModule { @@ -20,15 +20,43 @@ import mill.scalajslib.api.ModuleKind */ trait BunPublishModule extends BunScalaJSModule { + /** Embed resolved `node_modules` into published artifacts. + * + * Disabled by default because published JARs are cross-platform, while + * Bun installs can materialize host-specific binaries or optional packages. + */ + def bunPublishVendoredRuntime: T[Boolean] = Task { false } + + private def manifestField(extras: ujson.Obj, key: String, fallback: => Map[String, String]): Map[String, String] = + extras.value.get(key) match + case Some(value) => + try value.obj.map((name, version) => name -> version.str).toMap + catch + case e: Exception => + throw new RuntimeException( + s"BunPublishModule bunPackageJsonExtras.$key must be an object of string versions.", + e + ) + case None => fallback + + private def resolvedPublishedManifest: Task[BunManifest] = Task.Anon { + val extras = bunPackageJsonExtras() + def typed(deps: Seq[String]): Map[String, String] = + deps.map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap + + BunManifest( + dependencies = manifestField(extras, "dependencies", typed(npmDeps() ++ bunDeps())), + devDependencies = manifestField(extras, "devDependencies", typed(npmDevDeps() ++ bunDevDeps())), + optionalDependencies = manifestField(extras, "optionalDependencies", typed(bunOptionalDeps())) + ) + } + /** Generate bun dependency manifest for inclusion in published JARs. * * The manifest describes this library's direct runtime JS requirements. */ def bunDependencyManifest: T[PathRef] = Task { - val allDeps = (npmDeps() ++ bunDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val allDevDeps = (npmDevDeps() ++ bunDevDeps()).map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val optDeps = bunOptionalDeps().map(BunToolchainModule.splitDep).map((k, v) => k -> v.str).toMap - val manifest = BunManifest(allDeps, allDevDeps, optDeps) + val manifest = resolvedPublishedManifest() val metaDir = Task.dest / "META-INF" / "bun" os.write(metaDir / "bun-dependencies.json", BunManifest.toJson(manifest).render(indent = 2), createFolders = true) PathRef(Task.dest) @@ -76,7 +104,7 @@ trait BunPublishModule extends BunScalaJSModule { PathRef(dest) } - /** Vendored runtime node_modules for deterministic downstream consumption. */ + /** Vendored runtime node_modules for deterministic downstream consumption when enabled. */ def bunVendoredRuntimeBundle: T[PathRef] = Task { val metaDir = Task.dest / "META-INF" / "bun" val runtimeNodeModules = bunPublishedRuntimeInstall().path / "node_modules" @@ -90,12 +118,23 @@ trait BunPublishModule extends BunScalaJSModule { /** Resource paths that include the bun dependency manifest. * - * When this module declares any runtime JS deps, the manifest and vendored - * runtime tree are embedded in the published JAR. + * The manifest is emitted whenever this module declares publishable Bun + * dependency metadata. Vendored runtime trees are emitted only when + * `bunPublishVendoredRuntime` is enabled. */ def bunDependencyManifestResources: T[Seq[PathRef]] = Task { - val hasManifest = npmDeps().nonEmpty || bunDeps().nonEmpty || bunOptionalDeps().nonEmpty - val hasVendoredRuntime = hasManifest || unmanagedDeps().nonEmpty + val manifest = resolvedPublishedManifest() + val hasManifest = + manifest.dependencies.nonEmpty || + manifest.devDependencies.nonEmpty || + manifest.optionalDependencies.nonEmpty + val hasVendoredRuntime = + bunPublishVendoredRuntime() && ( + manifest.dependencies.nonEmpty || + manifest.optionalDependencies.nonEmpty || + unmanagedDeps().nonEmpty || + bunPackageJsonExtras().value.nonEmpty + ) (if hasManifest then Seq(bunDependencyManifest()) else Seq.empty) ++ (if hasVendoredRuntime then Seq(bunVendoredRuntimeBundle()) else Seq.empty)