Skip to content
Merged
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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<module-name>/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`. 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

Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -129,6 +129,18 @@ 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.
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/**` when vendored publishing is enabled |

## 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.
Expand Down
21 changes: 21 additions & 0 deletions millbun/integration/resources/invalid-bun-literal/build.mill
Original file line number Diff line number Diff line change
@@ -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"") }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object App
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object AppLocal
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object AppPublished
115 changes: 115 additions & 0 deletions millbun/integration/resources/scalajs-dependency-manifests/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//| 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)
}
}

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

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 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)
}

object appPublished extends StubBunModule {
override def sources = Task.Sources(moduleDir / "app-published")
override def runClasspath: T[Seq[PathRef]] = Task {
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())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object LocalLib
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
object PublishedLib
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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 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")
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("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")
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")
}

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"))
}
}
}
64 changes: 50 additions & 14 deletions millbun/src/mill/bun/BunDep.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading
Loading