diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 11a0874..d68d02a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,13 +21,31 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.14 - name: Compile - run: ./mill millbun.compile + run: ./mill --no-server millbun.compile - name: Unit Tests - run: ./mill millbun.test + run: ./mill --no-server millbun.test - name: Integration Tests - run: ./mill millbun.integration + run: ./mill --no-server millbun.integration + + managed-bun: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Managed Bun smoke test + env: + MILL_BUN_USE_SYSTEM: 'false' + run: ./mill --no-server millbun.integration.testOnly mill.bun.BunManagedToolchainIntegrationTests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f3f63fc..7fe604a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.14 - name: Cache Coursier uses: actions/cache@v4 @@ -91,6 +91,8 @@ jobs: run: ./mill --no-server millbun.test - name: Integration tests + env: + MILL_BUN_USE_SYSTEM: "true" run: ./mill --no-server millbun.integration - name: Reset cached publish metadata diff --git a/CHANGELOG.md b/CHANGELOG.md index da12f99..6ca8938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tag-driven release workflow for Maven Central publishing and GitHub releases. - Release runbook covering secrets, version sweep, annotated tags, and verification. +- Checksum-verified managed Bun 1.3.14 for macOS, Linux, and Windows on x64 and arm64. +- Strict text-lockfile workflow with `bunLock`, frozen installs, and actionable missing-lock failures. +- Canonical `bundle`, `bundleFast`, `compileExecutable`, and `compileExecutables` task names. +- Paired `BunScalaJSWebModule` and `BunTypeScriptWebModule` HTML workflows. +- `npmOptionalDeps`, `npmPeerDeps`, `npmOverrides`, and deterministic dependency conflict detection. +- `BunWorkspaceModule` for one install and lockfile across mixed Scala.js/TypeScript packages. +- Published dependency manifest schema v2 with runtime, optional, and peer requirements. +- `bunDoctor` diagnostics and managed-toolchain CI smoke coverage. + +### Changed + +- Scala.js linking delegates to Mill's standard linker hooks; applications now choose `scalaJSVersion` explicitly. +- Development dependencies are local tooling inputs and are no longer published transitively. +- `bunPackageJsonExtras` rejects dependency fields now represented by typed settings. +- Missing dependency versions are represented as `latest` instead of an empty package.json value. + +### Deprecated + +- Scala.js `bunBundle*` and `bunCompile*` task names in favor of their canonical aliases. +- `bunOptionalDeps` in favor of `npmOptionalDeps`. +- `managedBunExecutable` in favor of `bunExecutableOverride`. +- The TypeScript `bunCompileExecutable: Boolean` switch in favor of the `compileExecutable` task. ## [0.2.1] - Overridable test-time JS env (2026-04-17) @@ -40,4 +62,3 @@ object test extends BunScalaJSTests: super.bunTestJsEnv() + ("NODE_ENV" -> "production") } ``` - diff --git a/README.md b/README.md index 716e277..8c9f970 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,62 @@ # mill-bun-plugin -A [Mill](https://mill-build.org) plugin that adds [Bun](https://bun.sh)-backed workflows for Scala.js and TypeScript projects. +A [Mill](https://mill-build.org) plugin for first-class [Bun](https://bun.sh) workflows in Scala.js and TypeScript projects. -Keeps Mill's task graph, module structure, caching, Scala.js linker integration, and generated `tsconfig` handling — while swapping the JS runtime/package/bundling backend from `node`/`npm`/`esbuild` to Bun. +It keeps Mill's task graph, caching, module relationships, Scala.js linker, and TypeScript configuration while using Bun for dependency installation, execution, tests, bundling, web development, and native executables. ## Requirements - Mill 1.1.5+ -- Bun 1.2+ on PATH - JDK 17+ -## Quick Start +Bun does not need to be installed. The plugin downloads and verifies Bun 1.3.14 by default on supported macOS, Linux, and Windows x64/arm64 hosts. Set `MILL_BUN_USE_SYSTEM=true` to opt into the Bun on `PATH`. -### Scala.js +## Scala.js quick start ```scala //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 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" + def scalaJSVersion = "1.22.0" + override def moduleKind = Task { ModuleKind.ESModule } - override def bunDeps = Task { Seq(bun"react@^19.0.0") } - override def bunBundleTarget = Task { "browser" } + override def npmDeps = Task { Seq(bun"lodash@^4.17.21") } object test extends BunScalaJSTests, TestModule.Utest } ``` -`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`. 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. +Scala.js versions are explicit: choose the version your application tests against instead of inheriting a plugin-bundled linker. + +```bash +./mill app.bunLock # generate and commit app/bun.lock (or ./bun.lock with moduleDir above) +./mill app.run +./mill app.bundle +./mill app.compileExecutable +./mill app.test.test +``` -### TypeScript +`BunScalaJSModule` delegates `fastLinkJS`, `fullLinkJS`, and test linking to Mill's standard `ScalaJSModule` hooks. That keeps the plugin compatible with Mill's linker lifecycle and removes its former private linker-worker coupling. + +## TypeScript quick start ```scala //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build @@ -58,162 +65,137 @@ import mill.javascriptlib.bun.* object app extends BunTypeScriptModule { override def moduleDir = build.moduleDir - override def npmDeps = Task { Seq("express@4.21.2") } - override def bunBundleTarget = Task { "bun" } + override def npmDeps = Task { Seq("hono@^4.9.0") } object test extends BunTypeScriptTests } ``` -## `bun""` String Interpolator +```bash +./mill app.bunLock +./mill app.run +./mill app.bundle +./mill app.compileExecutable +./mill app.test.test +``` -The `bun"pkg@version"` interpolator provides compile-time validation of Bun package specifiers. Import it with `import mill.bun.bun` and use it in `bunDeps` declarations: +## Web applications + +Use the paired web traits when HTML and static assets are part of the application: ```scala -import mill.bun.bun +object frontend extends BunScalaJSWebModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def moduleKind = Task { ModuleKind.ESModule } +} + +object admin extends BunTypeScriptWebModule +``` + +Both traits use `index.html` as the entrypoint, copy `public/`, generate a minimal page when HTML is absent, and emit an optimized `dist` from `bundle`. -override def bunDeps = Task { Seq( - bun"react@^19.0.0", - bun"@anthropic-ai/claude-agent-sdk@^0.2.90", - bun"zod@^4.0.0" -)} +```bash +./mill --watch frontend.dev # reliable Scala.js relink + browser reload +./mill admin.dev # Bun-native TypeScript HMR +./mill frontend.bundle +./mill admin.bundle ``` -Invalid or empty specifiers are caught at compile time. The interpolator returns a plain `String`, so it works anywhere `npmDeps` or `bunDeps` accepts strings. - -## Modules - -### `BunToolchainModule` - -Base trait providing Bun discovery and execution helpers. - -| Task | Default | Description | -|------|---------|-------------| -| `bunExecutableName` | `"bun"` | Command name for PATH lookup | -| `managedBunExecutable` | `None` | Hook for a downloaded/managed Bun binary | -| `bunEnv` | `Map.empty` | Environment variables for Bun subprocesses | -| `bunLinker` | `"hoisted"` | Bun linker strategy | -| `bunInstallArgs` | `--save-text-lockfile --linker hoisted` | Default install flags | -| `bunLockfiles` | `Seq("bun.lock", "bun.lockb")` | Lockfile names Bun may produce | -| `bunfigFiles` | auto-detected | Workspace `bunfig.toml` / `.bunfig.toml` configs | -| `bunCompileTargets` | `Seq.empty` | Cross-compilation targets (e.g. `"bun-linux-x64"`, `"bun-darwin-arm64"`) | -| `bunCompileResources` | `Seq.empty` | Extra files/directories for `bun build --compile` workspaces | - -### `BunScalaJSModule` - -Extends `ScalaJSModule` with Bun runtime and bundling. - -| Task | Default | Description | -|------|---------|-------------| -| `npmDeps` | `Seq.empty` | JS packages for `@JSImport` resolution | -| `npmDevDeps` | `Seq.empty` | Dev-only JS packages | -| `bunDeps` | `Seq.empty` | JS packages using `bun"pkg@version"` validated syntax | -| `bunDevDeps` | `Seq.empty` | Dev-only JS packages (independent of `npmDevDeps`) | -| `bunOptionalDeps` | `Seq.empty` | Optional JS packages — installed if available, not fatal if missing | -| `unmanagedDeps` | `Seq.empty` | Local tarballs or package directories | -| `bunPackageJsonExtras` | `ujson.Obj()` | Extra fields merged into generated `package.json` | -| `transitiveNpmDeps` | — | Merged `npmDeps` + `bunDeps` from this module, upstream deps, and classpath manifests | -| `transitiveNpmDevDeps` | — | Merged `npmDevDeps` + `bunDevDeps` from this module, upstream deps, and classpath manifests | -| `classpathBunDeps` | — | Runtime deps auto-populated from dependency JAR manifests | -| `classpathBunDevDeps` | — | Dev deps auto-populated from dependency JAR manifests | -| `classpathBunOptionalDeps` | — | Optional deps auto-populated from dependency JAR manifests | -| `bunBundleTarget` | `"browser"` | `bun build --target` value | -| `bunBundleFormat` | `None` | Output format (`esm`, `cjs`) | -| `bunBundleExternal` | `Seq.empty` | Packages treated as external during bundling | -| `bunBundleSplitting` | `false` | Enable code splitting | -| `bunBundleBytecode` | `false` | Emit Bun bytecode | -| `bunBundleArgs` | `Seq.empty` | Extra raw `bun build` flags | -| `bunBinaryName` | module name | Name for compiled executables | -| `bunInstall` | — | Runs `bun install` for linked output | -| `bunBundle` | — | Full Scala.js bundle via `bun build` | -| `bunBundleFast` | — | Fast bundle from `fastLinkJS` | -| `bunCompileExecutable` | — | Standalone Bun executable | -| `bunCompileExecutables` | — | Cross-compile executables per `bunCompileTargets` | - -### `BunWorkersModule` - -Mix into a `BunTypeScriptModule` to bundle worker entry points from the staged compile workspace instead of raw source files. - -| Task | Default | Description | -|------|---------|-------------| -| `workerEntryPoints` | — | Worker sources to bundle | -| `workerSourceRoots` | `Seq(moduleDir)` | Roots used to preserve worker output layout | -| `workerBundleTarget` | `bunBundleTarget()` | `bun build --target` value for workers | -| `workerBundleFormat` | `Some(bunBundleFormat())` | Optional worker bundle format | -| `workerBundleArgs` | `Seq.empty` | Extra raw flags for worker bundling | -| `bundleWorkers` | — | Bundles all workers under `workers/` while preserving relative paths | - -### `BunSQLiteModule` - -Mix into a `BunTypeScriptModule` to discover and include SQLite database files in `bun build --compile` workspaces via `bunCompileResources`. - -| Task | Default | Description | -|------|---------|-------------| -| `sqliteDatabases` | `Seq.empty` | Explicit SQLite database files to include | -| `sqliteDatabaseDir` | `None` | Directory to scan for `.db`, `.sqlite`, `.sqlite3` files | - -### `BunTypeScriptModule` - -Extends Mill's `TypeScriptModule`, replacing npm/node/esbuild with Bun. -For top-level modules whose sources live at the workspace root, set `override def moduleDir = build.moduleDir`. -When Mill's default `src/.ts` entrypoint is absent, the Bun run/bundle tasks fall back to `src/main.ts`, `src/index.ts`, `main.ts`, and `index.ts`. - -| Task | Default | Description | -|------|---------|-------------| -| `bunRunArgs` | `Seq.empty` | Extra flags for `bun run` | -| `bunBundleTarget` | `"bun"` | `bun build --target` value | -| `bunBundleFormat` | `"esm"` or `"cjs"` | Based on `enableEsm` | -| `bunCompileExecutable` | `false` | Emit standalone executable | -| `bunBundlePackagesExternal` | `false` | Treat all packages as external | -| `bunBundleExternal` | `Seq.empty` | Packages treated as external during bundling | -| `bunBinaryName` | module name | Name for compiled executables | -| `bunPackageJsonExtras` | `ujson.Obj()` | Extra fields merged into generated `package.json` | -| `bunBuildArgs` | `Seq.empty` | Extra raw `bun build` flags | -| `bunTestArgs` | `Seq.empty` | Extra raw `bun test` flags | -| `bunCompileExecutables` | — | Cross-compile executables per `bunCompileTargets` | - -Overrides: `npmInstall` (bun install), `compile` (bun x tsc), `run` (bun run), `bundle` (bun build). -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. - -**`BunTypeScriptTests`** inner trait for test modules: - -| Task | Default | Description | -|------|---------|-------------| -| `bunTestTimeout` | `0` | Test timeout in milliseconds (0 = no timeout) | -| `bunTestReporter` | `"default"` | Reporter format: `"default"`, `"junit"`, or `"json"` | -| `bunCoverageReporters` | `Seq("text", "lcov")` | Coverage reporter formats | - -Test commands: `test`, `testWatch`, `testUpdateSnapshots`, `coverage`, `coverageReport`. - -### `BunPublishModule` - -Mix into a published `BunScalaJSModule` when downstream consumers should receive its runtime JS closure automatically. - -Manifests (`META-INF/bun/bun-dependencies.json`) are always published when the module declares any Bun/npm dependencies. Consumer builds scan classpath JARs for these manifests and merge them into their `package.json` via `classpathBunDeps` / `classpathBunDevDeps` / `classpathBunOptionalDeps`. - -Optionally, enable `bunPublishVendoredRuntime = true` to also embed a vendored `node_modules` tree in the JAR. This gives consumers the exact resolved packages without running `bun install` for those transitive deps. Only enable this when the resolved closure is platform-independent — Bun installs can materialize host-specific binaries. - -| 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. +Configure `webEntryPoints`, `webPublicSources`, `webDevPort`, and `webDevArgs` for non-default layouts. + +## Reproducible installs + +Dependency-bearing modules require a source-controlled text `bun.lock` by default. Generate it with the module's `bunLock` command. Normal installs then use `--frozen-lockfile` and fail before resolution when the lock is missing. + +For migration or intentionally ephemeral builds, set `MILL_BUN_REQUIRE_LOCKFILE=false` or override `bunRequireLockfile`. `bunInstallExtraArgs` accepts additional flags but cannot disable the plugin's lockfile safety. + +The dependency model is shared across Scala.js and TypeScript: + +| Setting | Meaning | +|---|---| +| `npmDeps` | Runtime dependencies | +| `npmDevDeps` | Local development/tool dependencies; never published transitively | +| `npmOptionalDeps` | Optional runtime dependencies | +| `npmPeerDeps` | Requirements supplied by the consumer | +| `npmOverrides` | Explicit resolution for otherwise conflicting declarations | +| `bunPackageJsonExtras` | Unmodeled fields such as `scripts`; typed dependency fields are rejected here | + +The `bun"pkg@specifier"` interpolator is an optional compile-time validator for dependency strings. Unversioned dependencies resolve explicitly to `latest`; contradictory requirements fail unless selected by `npmOverrides`. + +## Managed Bun + +Resolution order is: + +1. `bunExecutableOverride` +2. system `PATH` when `bunUseSystem` or `MILL_BUN_USE_SYSTEM=true` +3. checksum-verified managed Bun 1.3.14 + +Use `./mill app.bunDoctor` to print and validate the resolved executable, version, revision, mode, and linker. Custom mirrors or unbundled Bun versions must configure both `bunArchiveUrl` and `bunArchiveSha256`. + +## Mixed Scala.js and TypeScript workspaces + +`BunWorkspaceModule` gives multiple packages one install and one root lockfile: + +```scala +import mill.bun.* + +object scalaApp extends BunScalaJSModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object tsApp extends BunTypeScriptModule { + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object workspace extends BunWorkspaceModule { + def bunWorkspacePackages = Seq(scalaApp, tsApp) +} +``` + +Run `./mill workspace.bunLock` once, commit `workspace/bun.lock`, and use either package normally. The generated root uses Bun workspaces and both member modules link to the same installed `node_modules`. + +## Publishing Scala.js libraries + +Mix `BunPublishModule` into a published Scala.js library to emit `META-INF/bun/bun-dependencies.json`: + +```scala +object ui extends BunScalaJSModule with BunPublishModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def npmPeerDeps = Task { Seq("react@^19.0.0") } +} +``` + +Manifest schema v2 publishes direct runtime, optional, and peer requirements. Development dependencies remain local. Consumers still read schema v1 manifests, but malformed metadata and contradictory requirements fail clearly instead of being ignored or resolved by order. + +`bunPublishVendoredRuntime = true` can additionally embed `node_modules`; use it only for platform-independent dependency closures. + +## Main modules and tasks + +- `BunToolchainModule`: managed/system Bun resolution, lock policy, environment, install flags, and `bunDoctor`. +- `BunScalaJSModule`: Scala.js linking, Bun runtime, `bundle`, `bundleFast`, and executable compilation. +- `BunTypeScriptModule`: Bun-backed install, TypeScript compile, run, bundle, tests, and executables. +- `BunScalaJSWebModule` / `BunTypeScriptWebModule`: paired HTML development and production builds. +- `BunWorkspaceModule`: one install and lockfile for mixed package graphs. +- `BunPublishModule`: transitive npm metadata for published Scala.js libraries. +- `BunWorkersModule`: bundles TypeScript worker entrypoints while preserving layout. +- `BunSQLiteModule`: stages SQLite resources for compiled executables. + +The older Scala.js `bunBundle*` and `bunCompile*` names remain as compatibility aliases during the 0.x migration. New code should use the idiomatic `bundle*` and `compile*` names. ## Development ```bash -./mill millbun.compile # Compile the plugin -./mill millbun.test # Unit tests -./mill millbun.integration # Integration tests (requires Bun on PATH) +./mill --no-server millbun.compile +./mill --no-server millbun.test +MILL_BUN_USE_SYSTEM=true MILL_BUN_REQUIRE_LOCKFILE=false \ + ./mill --no-server millbun.integration ``` -See `docs/RELEASING.md` for the tag-driven Maven Central release workflow. +See [the 0.3 migration guide](docs/MIGRATING-0.3.md), the runnable `example-*` projects, and [the release runbook](docs/RELEASING.md). ## License diff --git a/build.mill b/build.mill index ddef9a2..efe4ec0 100644 --- a/build.mill +++ b/build.mill @@ -8,15 +8,12 @@ object millbun extends ScalaModule, PublishModule { def scalaVersion = "3.8.2" def jvmVersion = "25" def platformSuffix = "_mill1" - def bundledScalaJSVersion = "1.20.2" def artifactName = "mill-bun" def mvnDeps = Seq( - mvn"com.lihaoyi::mill-libs:$millVersion", - mvn"com.lihaoyi::mill-libs-scalajslib-config-1:$millVersion", - mvn"org.scala-js:scalajs-linker_2.13:$bundledScalaJSVersion", - mvn"org.scala-js:scalajs-js-envs_2.13:1.4.0" + // mill-libs is Scala-cross-published but intentionally not Mill-platform-cross-published. + mvn"com.lihaoyi::mill-libs:$millVersion" ) object test extends ScalaTests, TestModule.Utest { @@ -39,7 +36,11 @@ object millbun extends ScalaModule, PublishModule { Seq(Task.env.getOrElse("COURSIER_REPOSITORIES", "ivy2Local|central")) Map( "MILL_EXECUTABLE_PATH" -> millScript.toString, - "COURSIER_REPOSITORIES" -> repos.mkString("|") + "COURSIER_REPOSITORIES" -> repos.mkString("|"), + // Existing fixtures exercise installed Bun behavior. Dedicated tests cover + // managed resolution and strict lockfile failures. + "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "true"), + "MILL_BUN_REQUIRE_LOCKFILE" -> Task.env.getOrElse("MILL_BUN_REQUIRE_LOCKFILE", "false") ) } diff --git a/docs/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md new file mode 100644 index 0000000..78ada32 --- /dev/null +++ b/docs/MIGRATING-0.3.md @@ -0,0 +1,68 @@ +# Migrating to 0.3 + +Version 0.3 makes dependency installation reproducible by default and aligns Scala.js and TypeScript around the same public vocabulary. + +## Required changes + +### Choose a Scala.js version + +Every `BunScalaJSModule` now defines its own tested Scala.js version: + +```scala +def scalaJSVersion = "1.22.0" +``` + +The plugin no longer ships or invokes its own Scala.js linker worker. Linking delegates to Mill's `ScalaJSModule` implementation. + +### Generate lockfiles + +Run `bunLock` for every dependency-bearing module and commit the resulting `bun.lock` beside that module's sources: + +```bash +./mill app.bunLock +./mill frontend.bunLock +``` + +Subsequent installs are frozen. During a staged migration only, set `MILL_BUN_REQUIRE_LOCKFILE=false`. + +### Move typed dependency fields out of `bunPackageJsonExtras` + +Use `npmDeps`, `npmDevDeps`, `npmOptionalDeps`, `npmPeerDeps`, and `npmOverrides`. `bunPackageJsonExtras` remains available for unmodeled fields such as `scripts`, but now rejects dependency sections so task invalidation and published metadata remain correct. + +## Renamed APIs + +| 0.2 name | 0.3 name | Status | +|---|---|---| +| `bunBundle` | `bundle` | Compatibility alias retained | +| `bunBundleFast` | `bundleFast` | Compatibility alias retained | +| `bunCompileExecutable` | `compileExecutable` | Compatibility alias retained for Scala.js; Boolean TypeScript setting deprecated | +| `bunCompileExecutables` | `compileExecutables` | Compatibility alias retained | +| `bunOptionalDeps` | `npmOptionalDeps` | Deprecated compatibility setting | +| `managedBunExecutable` | `bunExecutableOverride` | Deprecated compatibility setting | + +The old names are planned for removal at 1.0. + +## Toolchain behavior + +The default is now checksum-verified managed Bun 1.3.14. To preserve the old PATH behavior: + +```bash +export MILL_BUN_USE_SYSTEM=true +``` + +The selected executable must report the configured `bunVersion` unless `bunVerifyVersion` is explicitly disabled. Run `./mill app.bunDoctor` when diagnosing toolchain selection. + +## Published dependency manifests + +New JARs use schema v2: + +- runtime, optional, and peer dependencies are published; +- development dependencies are not transitive; +- contradictory requirements fail unless resolved with `npmOverrides`; +- malformed manifests fail rather than disappearing silently. + +Schema v1 remains readable for backward compatibility. + +## Optional workspace migration + +For repositories with several Scala.js or TypeScript modules, introduce a `BunWorkspaceModule`, list the packages in `bunWorkspacePackages`, and point each member's `bunWorkspaceInstall` at `workspace.bunInstall()`. Then replace per-package locks with `workspace/bun.lock`. diff --git a/example-scalajs/build.mill b/example-scalajs/build.mill index 921f9e4..627a43a 100644 --- a/example-scalajs/build.mill +++ b/example-scalajs/build.mill @@ -1,7 +1,7 @@ //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" def mvnDeps = Seq( mvn"org.scala-js::scalajs-dom::2.8.1" diff --git a/example-typescript/build.mill b/example-typescript/build.mill index 20c0bfb..9d52d4e 100644 --- a/example-typescript/build.mill +++ b/example-typescript/build.mill @@ -1,6 +1,6 @@ //| mill-version: 1.1.5 //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build diff --git a/examples/build.mill b/examples/build.mill index 9d35347..a904533 100644 --- a/examples/build.mill +++ b/examples/build.mill @@ -1,7 +1,7 @@ //| mill-version: 1.1.5 //| mill-jvm-version: system //| mvnDeps: -//| - com.tjclp::mill-bun_mill1:0.2.1 +//| - com.tjclp::mill-bun_mill1:0.3.0 package build @@ -18,20 +18,22 @@ import mill.javascriptlib.bun.* // --- Web Server (Bun.serve HTTP server) --- // ./mill webServer.run -// ./mill webServer.bunBundle -// ./mill webServer.bunCompileExecutable +// ./mill webServer.bundle +// ./mill webServer.compileExecutable object webServer extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "web-server" / "src") def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } override def bunBundleTarget = Task { "bun" } } // --- Frontend Todo App (browser bundle) --- -// ./mill todoApp.bunBundle +// ./mill todoApp.bundle object todoApp extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "frontend" / "src") def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") override def moduleKind = Task { ModuleKind.ESModule } override def bunBundleTarget = Task { "browser" } @@ -39,18 +41,20 @@ object todoApp extends BunScalaJSModule { // --- Fullstack (shared types + Bun server + browser frontend) --- // ./mill fullstack.server.run -// ./mill fullstack.frontend.bunBundle +// ./mill fullstack.frontend.bundle // ./mill fullstack.__.compile object fullstack extends Module { object shared extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "fullstack" / "shared") def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } } object server extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "fullstack" / "server") def scalaVersion = shared.scalaVersion + def scalaJSVersion = shared.scalaJSVersion override def moduleKind = Task { ModuleKind.ESModule } override def moduleDeps = Seq(shared) override def bunBundleTarget = Task { "bun" } @@ -59,6 +63,7 @@ object fullstack extends Module { object frontend extends BunScalaJSModule { override def sources = Task.Sources(build.moduleDir / "fullstack" / "frontend") def scalaVersion = shared.scalaVersion + def scalaJSVersion = shared.scalaJSVersion override def moduleKind = Task { ModuleKind.ESModule } override def moduleDeps = Seq(shared) def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") @@ -76,7 +81,7 @@ object fullstack extends Module { // ./mill tsLib.compile # TypeScript type-check // ./mill tsLib.test.test # Bun-native tests // ./mill client.bundle # TS browser bundle (imports tsLib) -// ./mill scalaClient.bunBundle # Scala.js browser bundle +// ./mill scalaClient.bundle # Scala.js browser bundle // ./mill backend.run # JVM Cask server on :8080 // ./mill __.compile # compile EVERYTHING @@ -103,6 +108,7 @@ object client extends BunTypeScriptModule { // --- Scala.js browser frontend (alternative client) --- object scalaClient extends BunScalaJSModule { def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") override def moduleKind = Task { ModuleKind.ESModule } override def bunBundleTarget = Task { "browser" } diff --git a/millbun/integration/resources/invalid-bun-literal/build.mill b/millbun/integration/resources/invalid-bun-literal/build.mill index d67f057..548ca07 100644 --- a/millbun/integration/resources/invalid-bun-literal/build.mill +++ b/millbun/integration/resources/invalid-bun-literal/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.CommonJSModule } override def sources = Task.Sources(moduleDir / "src") diff --git a/millbun/integration/resources/managed-bun/build.mill b/millbun/integration/resources/managed-bun/build.mill new file mode 100644 index 0000000..3fa5678 --- /dev/null +++ b/millbun/integration/resources/managed-bun/build.mill @@ -0,0 +1,13 @@ +//| 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.* + +object app extends BunToolchainModule { + override def moduleDir = build.moduleDir +} diff --git a/millbun/integration/resources/mixed-workspace/build.mill b/millbun/integration/resources/mixed-workspace/build.mill new file mode 100644 index 0000000..d066eab --- /dev/null +++ b/millbun/integration/resources/mixed-workspace/build.mill @@ -0,0 +1,59 @@ +//| 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.* +import mill.javascriptlib.bun.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +object scalaApp extends BunScalaJSModule { + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + override def moduleKind = Task { ModuleKind.ESModule } + override def npmDeps = Task { Seq("is-even@1.0.0") } + override def classpathBunDeps = Task { Seq.empty } + override def classpathBunOptionalDeps = Task { Seq.empty } + override def classpathBunPeerDeps = Task { Seq.empty } + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object typescriptApp extends BunTypeScriptModule { + override def npmDeps = Task { Seq("is-odd@3.0.1") } + override def bunWorkspaceInstall = Task { Some(workspace.bunInstall()) } +} + +object workspace extends BunWorkspaceModule { + override def moduleDir = build.moduleDir + def bunWorkspacePackages = Seq(scalaApp, typescriptApp) + 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.walk(cwd / "packages") + .filter(_.last == "package.json") + .foreach { packageJson => + val json = ujson.read(os.read(packageJson)) + Seq("dependencies", "devDependencies", "optionalDependencies").foreach { field => + json.obj.get(field).foreach(_.obj.foreach { case (name, version) => + os.write.over( + cwd / "node_modules" / os.RelPath(name) / "package.json", + ujson.Obj("name" -> name, "version" -> version.str).render(), + createFolders = true + ) + }) + } + } + os.write.over(cwd / "bun.lock", "stub lock") + os.write.over(cwd / ".workspace-installed", args.mkString(" ")) + os.call(Seq("true"), cwd = cwd, env = env) + } +} diff --git a/millbun/integration/resources/scalajs-bundle/build.mill b/millbun/integration/resources/scalajs-bundle/build.mill index a8f5e59..7112db3 100644 --- a/millbun/integration/resources/scalajs-bundle/build.mill +++ b/millbun/integration/resources/scalajs-bundle/build.mill @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def mainClass = Some("Main") override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-bunfig/build.mill b/millbun/integration/resources/scalajs-bunfig/build.mill index eef2273..bbe37e9 100644 --- a/millbun/integration/resources/scalajs-bunfig/build.mill +++ b/millbun/integration/resources/scalajs-bunfig/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def mainClass = Some("Main") override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-dependency-manifests/build.mill b/millbun/integration/resources/scalajs-dependency-manifests/build.mill index 3f7f022..d9b0660 100644 --- a/millbun/integration/resources/scalajs-dependency-manifests/build.mill +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* trait StubBunModule extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.CommonJSModule } override def bunExecutable = Task { "stub-bun" } @@ -58,13 +59,14 @@ trait RecordingStubBunModule extends StubBunModule { object localLib extends StubBunModule { override def sources = Task.Sources(moduleDir / "local-lib") - override def bunOptionalDeps = Task { Seq("optional-local@^1.0.0") } + override def npmOptionalDeps = 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") } + override def npmOptionalDeps = Task { Seq("optional-published@^3.0.0") } + override def npmPeerDeps = Task { Seq("peer-published@^4.0.0") } } object publishedDevOnlyLib extends StubBunModule with BunPublishModule { @@ -75,13 +77,7 @@ object publishedDevOnlyLib extends StubBunModule with BunPublishModule { 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" - ) - ) - } + override def npmDeps = Task { Seq("vendored-extra@^4.0.0") } } object appLocal extends StubBunModule { @@ -98,13 +94,7 @@ object appPublished extends StubBunModule { 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" - ) - ) - } + override def npmDeps = Task { Seq("extras-only@^5.0.0") } } object appVendored extends StubBunModule { diff --git a/millbun/integration/resources/scalajs-simple/build.mill b/millbun/integration/resources/scalajs-simple/build.mill index c494138..f1d2d36 100644 --- a/millbun/integration/resources/scalajs-simple/build.mill +++ b/millbun/integration/resources/scalajs-simple/build.mill @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } } diff --git a/millbun/integration/resources/scalajs-test/build.mill b/millbun/integration/resources/scalajs-test/build.mill index bf40d38..0e9b278 100644 --- a/millbun/integration/resources/scalajs-test/build.mill +++ b/millbun/integration/resources/scalajs-test/build.mill @@ -14,6 +14,7 @@ import mill.scalajslib.bun.* object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-transitive/build.mill b/millbun/integration/resources/scalajs-transitive/build.mill index a64a9a4..059a350 100644 --- a/millbun/integration/resources/scalajs-transitive/build.mill +++ b/millbun/integration/resources/scalajs-transitive/build.mill @@ -13,6 +13,7 @@ import mill.scalajslib.bun.* object lib extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } override def sources = Task.Sources(moduleDir / "lib") @@ -22,6 +23,7 @@ object lib extends BunScalaJSModule { object app extends BunScalaJSModule { override def moduleDir = build.moduleDir def scalaVersion = lib.scalaVersion + def scalaJSVersion = lib.scalaJSVersion override def mainClass = Some("Main") override def moduleKind = Task { ModuleKind.ESModule } diff --git a/millbun/integration/resources/scalajs-web/build.mill b/millbun/integration/resources/scalajs-web/build.mill new file mode 100644 index 0000000..cbb8d5f --- /dev/null +++ b/millbun/integration/resources/scalajs-web/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.scalalib.* +import mill.scalajslib.* +import mill.scalajslib.api.* +import mill.scalajslib.bun.* + +object app extends BunScalaJSWebModule { + override def moduleDir = build.moduleDir + def scalaVersion = "3.8.2" + def scalaJSVersion = "1.22.0" + def mvnDeps = Seq(mvn"org.scala-js::scalajs-dom::2.8.1") + override def mainClass = Some("Main") + override def moduleKind = Task { ModuleKind.ESModule } +} diff --git a/millbun/integration/resources/scalajs-web/index.html b/millbun/integration/resources/scalajs-web/index.html new file mode 100644 index 0000000..75ab5c8 --- /dev/null +++ b/millbun/integration/resources/scalajs-web/index.html @@ -0,0 +1,5 @@ + + + +
+ diff --git a/millbun/integration/resources/scalajs-web/public/styles.css b/millbun/integration/resources/scalajs-web/public/styles.css new file mode 100644 index 0000000..ecce13a --- /dev/null +++ b/millbun/integration/resources/scalajs-web/public/styles.css @@ -0,0 +1 @@ +body { color: rgb(20, 40, 60); } diff --git a/millbun/integration/resources/scalajs-web/src/Main.scala b/millbun/integration/resources/scalajs-web/src/Main.scala new file mode 100644 index 0000000..8b563d3 --- /dev/null +++ b/millbun/integration/resources/scalajs-web/src/Main.scala @@ -0,0 +1,5 @@ +import org.scalajs.dom.document + +object Main: + def main(args: Array[String]): Unit = + document.getElementById("app").textContent = "Hello from Scala.js web" diff --git a/millbun/integration/resources/typescript-compile/build.mill b/millbun/integration/resources/typescript-compile/build.mill index 8fa780e..119dcd7 100644 --- a/millbun/integration/resources/typescript-compile/build.mill +++ b/millbun/integration/resources/typescript-compile/build.mill @@ -17,7 +17,6 @@ object app extends BunTypeScriptModule { "moduleResolution" -> ujson.Str("bundler") ) } - override def bunCompileExecutable = Task { true } override def bunBundleTarget = Task { "bun" } override def bunCompileResources = Task.Sources("embedded") } diff --git a/millbun/integration/resources/typescript-lock/build.mill b/millbun/integration/resources/typescript-lock/build.mill new file mode 100644 index 0000000..c39c529 --- /dev/null +++ b/millbun/integration/resources/typescript-lock/build.mill @@ -0,0 +1,32 @@ +//| 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.javascriptlib.bun.* + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def npmDeps = Task { Seq("is-even@1.0.0") } + override def bunRequireLockfile = Task { true } + 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.write.over(cwd / ".bun-args", args.mkString(" ")) + os.write.over(cwd / "bun.lock", "stub lock") + os.write.over( + cwd / "node_modules" / "is-even" / "package.json", + ujson.Obj("name" -> "is-even", "version" -> "1.0.0").render(), + createFolders = true + ) + os.call(Seq("true"), cwd = cwd, env = env) + } +} diff --git a/millbun/integration/resources/typescript-web/build.mill b/millbun/integration/resources/typescript-web/build.mill new file mode 100644 index 0000000..ac9bebd --- /dev/null +++ b/millbun/integration/resources/typescript-web/build.mill @@ -0,0 +1,22 @@ +//| 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.javascriptlib.bun.* + +object app extends BunTypeScriptWebModule { + override def moduleDir = build.moduleDir + override def enableEsm = Task { true } + override def bunBundleTarget = Task { "browser" } + override def compilerOptions = Task { + super.compilerOptions() ++ Map( + "module" -> ujson.Str("esnext"), + "moduleResolution" -> ujson.Str("bundler"), + "lib" -> ujson.Arr("es2020", "dom") + ) + } +} diff --git a/millbun/integration/resources/typescript-web/index.html b/millbun/integration/resources/typescript-web/index.html new file mode 100644 index 0000000..3ec81e3 --- /dev/null +++ b/millbun/integration/resources/typescript-web/index.html @@ -0,0 +1,5 @@ + + + +
+ diff --git a/millbun/integration/resources/typescript-web/public/styles.css b/millbun/integration/resources/typescript-web/public/styles.css new file mode 100644 index 0000000..3184cab --- /dev/null +++ b/millbun/integration/resources/typescript-web/public/styles.css @@ -0,0 +1 @@ +body { color: rgb(60, 40, 20); } diff --git a/millbun/integration/resources/typescript-web/src/main.ts b/millbun/integration/resources/typescript-web/src/main.ts new file mode 100644 index 0000000..75cfa88 --- /dev/null +++ b/millbun/integration/resources/typescript-web/src/main.ts @@ -0,0 +1 @@ +document.getElementById("app")!.textContent = "Hello from TypeScript web"; diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index 6911ef8..532cd82 100644 --- a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -27,20 +27,17 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(!res.isSuccess) } - test("published dev-only manifests are still emitted") { + test("published dev-only modules do not emit runtime manifests") { 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) + assert(manifest.isEmpty) } - test("published manifests include dev-only modules") { + test("published manifests exclude local development dependencies") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("publishedLib.jar") assert(res.isSuccess) @@ -48,9 +45,11 @@ object BunDependencyManifestIntegrationTests extends TestSuite { val jar = outputPath(tester, "publishedLib.jar") val manifest = BunManifest.readFromJar(jar) assert(manifest.isDefined) + assert(manifest.get.schemaVersion == 2) assert(manifest.get.dependencies.isEmpty) - assert(manifest.get.devDependencies == Map("dev-only" -> "^2.0.0")) + assert(manifest.get.devDependencies.isEmpty) assert(manifest.get.optionalDependencies == Map("optional-published" -> "^3.0.0")) + assert(manifest.get.peerDependencies == Map("peer-published" -> "^4.0.0")) } test("published jars stay manifest-only by default") { @@ -71,17 +70,18 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(packageJson("optionalDependencies").obj("optional-local").str == "^1.0.0") } - test("classpath manifests flow dev and optional deps into generated package.json") { + test("classpath manifests flow publishable 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("devDependencies").obj.contains("dev-only")) assert(packageJson("optionalDependencies").obj("optional-published").str == "^3.0.0") + assert(packageJson("peerDependencies").obj("peer-published").str == "^4.0.0") } - test("bunInstall runs when bunPackageJsonExtras adds dependencies") { + test("bunInstall runs for typed npm dependencies") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("appExtrasOnly.bunInstall") assert(res.isSuccess) @@ -91,7 +91,7 @@ object BunDependencyManifestIntegrationTests extends TestSuite { assert(os.exists(installDir / "node_modules" / "extras-only" / "package.json")) } - test("bunPublishedRuntimeInstall runs for vendored extras-only published deps") { + test("bunPublishedRuntimeInstall runs for vendored typed published deps") { val tester = this.tester("scalajs-dependency-manifests") val res = tester.eval("publishedVendoredExtraLib.bunPublishedRuntimeInstall") assert(res.isSuccess) diff --git a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala new file mode 100644 index 0000000..8b85402 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala @@ -0,0 +1,22 @@ +package mill.bun + +import mill.testkit.IntegrationTester +import utest.* + +object BunManagedToolchainIntegrationTests 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")) + + def tests: Tests = Tests: + test("resolved toolchain runs the pinned Bun version"): + val tester = new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / "managed-bun", + millExecutable = millExe, + useInMemory = true + ) + val result = tester.eval("app.bunExecutable") + assert(result.isSuccess) + val executable = tester.out("app.bunExecutable").value[String] + val version = os.proc(executable, "--version").call(stdout = os.Pipe).out.text().trim + assert(version == "1.3.14") diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index 2138810..ec7bd04 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -61,31 +61,43 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(res.isSuccess) } - test("bunBundle") { + test("bundle") { val tester = this.tester("scalajs-bundle") - val res = tester.eval("app.bunBundle") + val res = tester.eval("app.bundle") assert(res.isSuccess) - val dist = outputPath(tester, "app.bunBundle") + val dist = outputPath(tester, "app.bundle") val mainJs = bundledScript(dist) assert(runBundledScript(mainJs) == "Hello from scala.js with lodash on bun") } - test("bunCompileExecutable") { + test("web bundle includes HTML CSS and JavaScript") { + val tester = this.tester("scalajs-web") + val res = tester.eval("app.bundle") + assert(res.isSuccess) + + val dist = outputPath(tester, "app.bundle") + val files = os.walk(dist).filter(os.isFile) + assert(files.exists(_.ext == "html")) + assert(files.exists(_.ext == "css")) + assert(files.exists(_.ext == "js")) + } + + test("compileExecutable") { val tester = this.tester("scalajs-bundle") - val res = tester.eval("app.bunCompileExecutable") + val res = tester.eval("app.compileExecutable") assert(res.isSuccess) - val executable = outputPath(tester, "app.bunCompileExecutable") + val executable = outputPath(tester, "app.compileExecutable") assert(runExecutable(executable) == "Hello from scala.js with lodash on bun") } test("transitive npm deps") { val tester = this.tester("scalajs-transitive") - val res = tester.eval("app.bunBundle") + val res = tester.eval("app.bundle") assert(res.isSuccess) - val dist = outputPath(tester, "app.bunBundle") + val dist = outputPath(tester, "app.bundle") val mainJs = bundledScript(dist) assert(runBundledScript(mainJs) == "Hello from transitive scala.js bun") } @@ -111,9 +123,9 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(os.exists(linkedDir / "bunfig.toml")) assert(!os.exists(linkedDir / ".npmrc")) - val compileRes = tester.eval("app.bunCompileExecutable") + val compileRes = tester.eval("app.compileExecutable") assert(compileRes.isSuccess) - val compileWorkspace = tester.workspacePath / "out" / "app" / "bunCompileExecutable.dest" / "workspace" + val compileWorkspace = tester.workspacePath / "out" / "app" / "compileExecutable.dest" / "workspace" assert(os.exists(compileWorkspace / "bunfig.toml")) assert(!os.exists(compileWorkspace / ".npmrc")) diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index d7194d9..f6a42db 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -50,6 +50,18 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(run.out.text().trim == "Hello from bundled TypeScript resources!") } + test("web bundle includes HTML CSS and JavaScript") { + val tester = this.tester("typescript-web") + val res = tester.eval("app.bundle") + assert(res.isSuccess) + + val dist = outputPath(tester, "app.bundle") + val files = os.walk(dist).filter(os.isFile) + assert(files.exists(_.ext == "html")) + assert(files.exists(_.ext == "css")) + assert(files.exists(_.ext == "js")) + } + test("run") { val tester = this.tester("typescript-simple") val res = tester.eval("app.run") @@ -61,10 +73,10 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("compile-executable") { val tester = this.tester("typescript-compile") - val res = tester.eval("app.bundle") + val res = tester.eval("app.compileExecutable") assert(res.isSuccess) - val executable = outputPath(tester, "app.bundle") + val executable = outputPath(tester, "app.compileExecutable") val run = os.call( Seq(executable.toString), cwd = executable / os.up @@ -72,6 +84,21 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(run.out.text().trim == "Hello from compiled TypeScript executable!") } + test("strict installs require a source lock and bunLock creates it") { + val tester = this.tester("typescript-lock") + val missingLock = tester.eval("app.npmInstall") + assert(!missingLock.isSuccess) + + val lockResult = tester.eval("app.bunLock") + assert(lockResult.isSuccess) + assert(os.exists(tester.workspacePath / "bun.lock")) + + val installResult = tester.eval("app.npmInstall") + assert(installResult.isSuccess) + val args = os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / ".bun-args") + assert(args.contains("--frozen-lockfile")) + } + test("bun target ambient types are pinned") { val tester = this.tester("typescript-simple") val res = tester.eval("app.npmInstall") diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala new file mode 100644 index 0000000..2d0d6c9 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -0,0 +1,47 @@ +package mill.bun + +import mill.api.PathRef +import mill.testkit.IntegrationTester +import utest.* + +object BunWorkspaceIntegrationTests 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(): IntegrationTester = + new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / "mixed-workspace", + millExecutable = millExe, + useInMemory = true + ) + + private def outputPath(tester: IntegrationTester, selector: String): os.Path = + tester.out(selector).value[PathRef].path + + def tests: Tests = Tests: + test("mixed Scala.js and TypeScript packages share one install"): + val tester = this.tester() + + val lockResult = tester.eval("workspace.bunLock") + assert(lockResult.isSuccess) + assert(os.exists(tester.workspacePath / "bun.lock")) + + val workspaceResult = tester.eval("workspace.bunInstall") + assert(workspaceResult.isSuccess) + val workspaceInstall = outputPath(tester, "workspace.bunInstall") + assert(os.read(workspaceInstall / ".workspace-installed").contains("--frozen-lockfile")) + val rootJson = ujson.read(os.read(workspaceInstall / "package.json")) + assert(rootJson("workspaces").arr.map(_.str).toSet == Set( + "packages/scalaApp", + "packages/typescriptApp" + )) + assert(os.exists(workspaceInstall / "node_modules" / "is-even" / "package.json")) + assert(os.exists(workspaceInstall / "node_modules" / "is-odd" / "package.json")) + + val scalaResult = tester.eval("scalaApp.bunInstall") + val typescriptResult = tester.eval("typescriptApp.npmInstall") + assert(scalaResult.isSuccess) + assert(typescriptResult.isSuccess) + assert(os.isLink(outputPath(tester, "scalaApp.bunInstall") / "node_modules")) + assert(os.isLink(outputPath(tester, "typescriptApp.npmInstall") / "node_modules")) diff --git a/millbun/src/mill/bun/BunManifest.scala b/millbun/src/mill/bun/BunManifest.scala index 53e6834..70a60a6 100644 --- a/millbun/src/mill/bun/BunManifest.scala +++ b/millbun/src/mill/bun/BunManifest.scala @@ -16,7 +16,9 @@ import java.util.jar.JarFile final case class BunManifest( dependencies: Map[String, String], devDependencies: Map[String, String], - optionalDependencies: Map[String, String] + optionalDependencies: Map[String, String], + peerDependencies: Map[String, String] = Map.empty, + schemaVersion: Int = 2 ) object BunManifest: @@ -26,25 +28,46 @@ object BunManifest: /** Serialize manifest to JSON. */ def toJson(manifest: BunManifest): ujson.Obj = + if manifest.schemaVersion != 1 && manifest.schemaVersion != 2 then + throw new IllegalArgumentException(s"Unsupported Bun manifest schemaVersion ${manifest.schemaVersion}") + if manifest.schemaVersion == 2 && manifest.devDependencies.nonEmpty then + throw new IllegalArgumentException("Bun manifest schema v2 does not allow devDependencies") val obj = ujson.Obj( - "dependencies" -> ujson.Obj.from(manifest.dependencies.map((k, v) => k -> ujson.Str(v))), - "devDependencies" -> ujson.Obj.from(manifest.devDependencies.map((k, v) => k -> ujson.Str(v))) + "schemaVersion" -> manifest.schemaVersion, + "dependencies" -> dependencyJson(manifest.dependencies) ) + if manifest.schemaVersion == 1 && manifest.devDependencies.nonEmpty then + obj("devDependencies") = dependencyJson(manifest.devDependencies) if manifest.optionalDependencies.nonEmpty then - obj("optionalDependencies") = ujson.Obj.from( - manifest.optionalDependencies.map((k, v) => k -> ujson.Str(v)) - ) + obj("optionalDependencies") = dependencyJson(manifest.optionalDependencies) + if manifest.peerDependencies.nonEmpty then + obj("peerDependencies") = dependencyJson(manifest.peerDependencies) obj + private def dependencyJson(dependencies: Map[String, String]): ujson.Obj = + ujson.Obj.from(dependencies.toSeq.sortBy(_._1).map((name, version) => name -> ujson.Str(version))) + /** Deserialize manifest from JSON. */ def fromJson(json: ujson.Value): BunManifest = val obj = json.obj + val schemaVersion = obj.get("schemaVersion").map(_.num.toInt).getOrElse(1) + if schemaVersion != 1 && schemaVersion != 2 then + throw new IllegalArgumentException(s"Unsupported Bun manifest schemaVersion $schemaVersion") + if schemaVersion == 2 && obj.contains("devDependencies") then + throw new IllegalArgumentException("Bun manifest schema v2 does not allow devDependencies") + def readDeps(key: String): Map[String, String] = - obj.get(key).map(_.obj.map((k, v) => k -> v.str).toMap).getOrElse(Map.empty) + obj.get(key).map { value => + value.obj.map { case (name, specifier) => + name -> specifier.str + }.toMap + }.getOrElse(Map.empty) BunManifest( dependencies = readDeps("dependencies"), devDependencies = readDeps("devDependencies"), - optionalDependencies = readDeps("optionalDependencies") + optionalDependencies = readDeps("optionalDependencies"), + peerDependencies = readDeps("peerDependencies"), + schemaVersion = schemaVersion ) /** Read a manifest from inside a JAR file. Returns None if no manifest is present. */ @@ -58,23 +81,31 @@ object BunManifest: val is = jar.getInputStream(entry) try Some(fromJson(ujson.read(is))) finally is.close() - catch case _: Exception => None finally jar.close() /** Read a manifest from an unpacked directory (e.g., classes output). */ def readFromDir(dirPath: os.Path): Option[BunManifest] = val manifestFile = dirPath / os.RelPath(ManifestPath) if os.exists(manifestFile) then - try Some(fromJson(ujson.read(os.read(manifestFile)))) - catch case _: Exception => None + Some(fromJson(ujson.read(os.read(manifestFile)))) else None - /** Merge multiple manifests into one. Later entries override earlier ones for the same package. */ + /** Merge publishable fields, rejecting contradictions and discarding legacy v1 development metadata. */ def merge(manifests: Seq[BunManifest]): BunManifest = - manifests.foldLeft(empty) { (acc, m) => - BunManifest( - dependencies = acc.dependencies ++ m.dependencies, - devDependencies = acc.devDependencies ++ m.devDependencies, - optionalDependencies = acc.optionalDependencies ++ m.optionalDependencies - ) - } + def mergeField(field: String, values: Seq[Map[String, String]]): Map[String, String] = + values.flatMap(_.toSeq).groupBy(_._1).map { case (name, entries) => + val specifiers = entries.map(_._2).distinct + if specifiers.size > 1 then + throw new IllegalArgumentException( + s"Conflicting $field dependency '$name': ${specifiers.sorted.mkString(", ")}" + ) + name -> specifiers.head + } + + BunManifest( + dependencies = mergeField("runtime", manifests.map(_.dependencies)), + devDependencies = Map.empty, + optionalDependencies = mergeField("optional", manifests.map(_.optionalDependencies)), + peerDependencies = mergeField("peer", manifests.map(_.peerDependencies)), + schemaVersion = 2 + ) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 0e65db9..9965eaf 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -2,18 +2,187 @@ package mill.bun import mill.* import mill.api.BuildCtx +import java.io.{BufferedInputStream, FileInputStream, FileOutputStream} +import java.net.URI +import java.net.http.{HttpClient, HttpRequest, HttpResponse} +import java.security.MessageDigest +import java.util.zip.ZipInputStream object BunToolchainModule { - /** Parse a dependency string like "react@19.1.1" or "@types/node@22.10.9" into (name, version). */ - def splitDep(input: String): (String, ujson.Str) = input match { - case s if s.startsWith("@") => - val withoutAt = s.drop(1) - val parts = withoutAt.split("@", 2) - ("@" + parts(0), ujson.Str(parts.lift(1).getOrElse(""))) - case _ => - val parts = input.split("@", 2) - (parts(0), ujson.Str(parts.lift(1).getOrElse(""))) + private val ModeledPackageJsonFields = Set( + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", + "overrides", + "workspaces" + ) + + private[bun] final case class NpmDependency(name: String, specifier: String) + + private[bun] final case class Distribution( + assetName: String, + executableName: String + ) + + private val Bun1314Checksums = Map( + "bun-darwin-aarch64.zip" -> "d8b96221828ad6f97ac7ac0ab7e95872341af763001e8803e8267652c2652620", + "bun-darwin-x64.zip" -> "4183df3374623e5bab315c547cfa0974533cd457d86b73b639f7a87974cd6633", + "bun-linux-aarch64.zip" -> "a27ffb63a8310375836e0d6f668ae17fa8d8d18b88c37c821c65331973a19a3b", + "bun-linux-x64.zip" -> "951ee2aee855f08595aeec6225226a298d3fea83a3dcd6465c09cbccdf7e848f", + "bun-windows-aarch64.zip" -> "89841f5a57f2348b67ec0839b718f4bf4ea7d07c371c9ba4b77b6c790f918953", + "bun-windows-x64.zip" -> "0a0620930b6675d7ba440e81f4e0e00d3cfbe096c4b140d3fff02205e9e18922" + ) + + private[bun] def distribution(osName: String, architecture: String): Either[String, Distribution] = { + val osPart = osName.toLowerCase match { + case name if name.contains("mac") || name.contains("darwin") => Right("darwin") + case name if name.contains("linux") => Right("linux") + case name if name.contains("windows") => Right("windows") + case other => Left(s"Unsupported operating system '$other'") + } + val archPart = architecture.toLowerCase match { + case "aarch64" | "arm64" => Right("aarch64") + case "amd64" | "x86_64" | "x64" => Right("x64") + case other => Left(s"Unsupported architecture '$other'") + } + + for { + os <- osPart + arch <- archPart + } yield Distribution( + assetName = s"bun-$os-$arch.zip", + executableName = if (os == "windows") "bun.exe" else "bun" + ) + } + + private[bun] def bundledChecksum(version: String, assetName: String): Option[String] = + if (version == "1.3.14") Bun1314Checksums.get(assetName) else None + + private[bun] def sha256(path: os.Path): String = { + val digest = MessageDigest.getInstance("SHA-256") + val stream = new BufferedInputStream(new FileInputStream(path.toIO)) + val buffer = new Array[Byte](64 * 1024) + try { + var read = stream.read(buffer) + while (read >= 0) { + if (read > 0) digest.update(buffer, 0, read) + read = stream.read(buffer) + } + } finally stream.close() + digest.digest().map(byte => f"${byte & 0xff}%02x").mkString + } + + private[bun] def download(url: String, destination: os.Path): Unit = { + os.makeDir.all(destination / os.up) + val client = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.ALWAYS) + .build() + val request = HttpRequest.newBuilder(URI.create(url)) + .header("User-Agent", "mill-bun-plugin") + .GET() + .build() + val response = client.send(request, HttpResponse.BodyHandlers.ofFile(destination.toNIO)) + if (response.statusCode() / 100 != 2) { + throw new RuntimeException(s"Unable to download Bun from $url: HTTP ${response.statusCode()}") + } + } + + private[bun] def extractExecutable( + archive: os.Path, + executableName: String, + destination: os.Path + ): Unit = { + val zip = new ZipInputStream(new BufferedInputStream(new FileInputStream(archive.toIO))) + var found = false + try { + var entry = zip.getNextEntry + while (entry != null) { + val entryName = entry.getName.replace('\\', '/') + if (!entry.isDirectory && entryName.split('/').lastOption.contains(executableName)) { + os.makeDir.all(destination / os.up) + val output = new FileOutputStream(destination.toIO) + try zip.transferTo(output) + finally output.close() + found = true + } + zip.closeEntry() + entry = zip.getNextEntry + } + } finally zip.close() + + if (!found) throw new RuntimeException(s"Bun archive does not contain $executableName") + if (executableName != "bun.exe" && !destination.toIO.setExecutable(true)) { + throw new RuntimeException(s"Unable to make downloaded Bun executable: $destination") + } + } + + /** Parse package.json-style `name@specifier` declarations without slicing scoped names incorrectly. */ + private[bun] def parseDependency(input: String): Either[String, NpmDependency] = { + val trimmed = input.trim + if (trimmed.isEmpty) Left("Dependency cannot be empty") + else if (trimmed.startsWith("@")) { + val slash = trimmed.indexOf('/') + if (slash <= 1 || slash == trimmed.length - 1) Left(s"Invalid scoped dependency '$input'") + else { + val separator = trimmed.indexOf('@', slash + 1) + val name = if (separator < 0) trimmed else trimmed.take(separator) + val specifier = if (separator < 0) "latest" else trimmed.drop(separator + 1) + if (specifier.isEmpty) Left(s"Dependency '$input' has an empty specifier") + else Right(NpmDependency(name, specifier)) + } + } else { + val separator = trimmed.indexOf('@') + val name = if (separator < 0) trimmed else trimmed.take(separator) + val specifier = if (separator < 0) "latest" else trimmed.drop(separator + 1) + if (name.isEmpty) Left(s"Dependency '$input' has an empty name") + else if (specifier.isEmpty) Left(s"Dependency '$input' has an empty specifier") + else Right(NpmDependency(name, specifier)) + } + } + + /** Parse a dependency for compatibility with the existing public helper. */ + def splitDep(input: String): (String, ujson.Str) = + parseDependency(input) match { + case Right(dep) => dep.name -> ujson.Str(dep.specifier) + case Left(message) => throw new IllegalArgumentException(message) + } + + /** Resolve duplicate declarations deterministically and fail conflicting specs unless overridden. */ + def dependencyPairs( + inputs: Seq[String], + overrides: Map[String, String] = Map.empty + ): Seq[(String, ujson.Str)] = { + val parsed = inputs.map(input => parseDependency(input).fold( + message => throw new IllegalArgumentException(message), + identity + )) + parsed.groupBy(_.name).toSeq.sortBy(_._1).map { case (name, entries) => + val specifiers = entries.map(_.specifier).distinct + val resolved = overrides.get(name).orElse(specifiers match { + case Seq(specifier) => Some(specifier) + case _ => None + }).getOrElse( + throw new IllegalArgumentException( + s"Conflicting npm dependency '$name': ${specifiers.sorted.mkString(", ")}. " + + "Declare npmOverrides to select one specifier." + ) + ) + name -> ujson.Str(resolved) + } + } + + /** Add unmodeled package.json fields without allowing typed dependency data to be replaced. */ + def mergePackageJson(base: ujson.Obj, extras: ujson.Obj): ujson.Obj = { + val conflicts = extras.value.keySet.intersect(ModeledPackageJsonFields).toSeq.sorted + if (conflicts.nonEmpty) { + throw new IllegalArgumentException( + s"bunPackageJsonExtras cannot replace modeled fields: ${conflicts.mkString(", ")}. " + + "Use npmDeps, npmDevDeps, npmOptionalDeps, npmPeerDeps, or npmOverrides." + ) + } + ujson.Obj.from(base.value.toSeq ++ extras.value.toSeq) } /** Build candidate executable names from a base name and PATHEXT extensions. @@ -30,8 +199,9 @@ object BunToolchainModule { val candidates = executableCandidates(name, sys.env.getOrElse("PATHEXT", "")) pathDirs.iterator + .filter(_.nonEmpty) .flatMap(dir => candidates.iterator.map(c => os.Path(dir) / c)) - .find(os.exists(_)) + .find(path => os.isFile(path) && java.nio.file.Files.isExecutable(path.toNIO)) } /** Copy a generated workspace into a fresh task destination, preserving layout. */ @@ -70,18 +240,53 @@ object BunToolchainModule { trait BunToolchainModule extends Module { + /** Tested Bun release downloaded by default when no override is configured. */ + def bunVersion: T[String] = Task { "1.3.14" } + + /** Resolve Bun from PATH instead of using the managed distribution. */ + def bunUseSystem: T[Boolean] = Task { + Task.env.get("MILL_BUN_USE_SYSTEM").exists(_.equalsIgnoreCase("true")) + } + /** Command name used when resolving Bun from PATH. */ def bunExecutableName: T[String] = Task { "bun" } - /** Future hook for a managed/downloaded Bun binary. */ + /** Explicit Bun binary override. */ + def bunExecutableOverride: T[Option[PathRef]] = Task { None } + + /** @deprecated Use [[bunExecutableOverride]]. */ + @deprecated("Use bunExecutableOverride", "0.3.0") def managedBunExecutable: T[Option[PathRef]] = Task { None } + /** Optional managed distribution mirror. Must be paired with [[bunArchiveSha256]]. */ + def bunArchiveUrl: T[Option[String]] = Task { None } + + /** SHA-256 for [[bunArchiveUrl]], or for an unbundled Bun version. */ + def bunArchiveSha256: T[Option[String]] = Task { None } + + /** Verify that an override/system executable matches [[bunVersion]]. */ + def bunVerifyVersion: T[Boolean] = Task { true } + /** Environment passed to Bun subprocesses. */ def bunEnv: T[Map[String, String]] = Task { Map.empty } + /** Explicit resolutions for conflicting transitive npm dependency declarations. */ + def npmOverrides: T[Map[String, String]] = Task { Map.empty } + /** Lockfile names that Bun may produce. */ def bunLockfiles: T[Seq[String]] = Task { Seq("bun.lock", "bun.lockb") } + /** Source-controlled text lockfile for this module. */ + def bunLockfile: T[Option[PathRef]] = Task.Input { + val path = moduleDir / "bun.lock" + if (os.exists(path)) Some(PathRef(path)) else None + } + + /** Require dependency-bearing modules to provide [[bunLockfile]]. */ + def bunRequireLockfile: T[Boolean] = Task { + !Task.env.get("MILL_BUN_REQUIRE_LOCKFILE").exists(_.equalsIgnoreCase("false")) + } + /** Hoisted installs are the safest default for Node-compatible resolution. */ def bunLinker: T[String] = Task { "hoisted" } @@ -89,6 +294,48 @@ trait BunToolchainModule extends Module { Seq("--save-text-lockfile", "--linker", bunLinker()) } + /** Additional install flags. Lockfile safety flags are controlled by the plugin. */ + def bunInstallExtraArgs: T[Seq[String]] = Task { Seq.empty } + + protected def copyBunLockfile(lockfile: Option[PathRef], destination: os.Path): Unit = + lockfile.foreach(ref => os.copy.over(ref.path, destination / "bun.lock", createFolders = true)) + + protected def resolvedBunInstallArgs( + baseArgs: Seq[String], + extraArgs: Seq[String], + hasLockfile: Boolean, + updateLockfile: Boolean + ): Seq[String] = { + val protectedPrefixes = Seq( + "--no-save", + "--lockfile", + "--frozen-lockfile", + "--save-text-lockfile" + ) + val forbidden = extraArgs.filter(arg => protectedPrefixes.exists(arg.startsWith)) + if (forbidden.nonEmpty) { + throw new IllegalArgumentException( + s"bunInstallExtraArgs cannot override lockfile safety: ${forbidden.mkString(", ")}" + ) + } + baseArgs ++ extraArgs ++ + (if (updateLockfile) Seq("--lockfile-only") + else if (hasLockfile) Seq("--frozen-lockfile") + else Seq.empty) + } + + protected def requireBunLockfile( + hasInstallInputs: Boolean, + lockfile: Option[PathRef], + required: Boolean + ): Unit = { + if (hasInstallInputs && required && lockfile.isEmpty) { + throw new RuntimeException( + s"Missing ${moduleDir / "bun.lock"}. Run this module's bunLock command and commit the generated lockfile." + ) + } + } + /** * Bun config files to copy into generated workspaces. * @@ -118,14 +365,92 @@ trait BunToolchainModule extends Module { */ def bunCompileResources: T[Seq[PathRef]] = Task { Seq.empty } - /** Resolve Bun either from a managed binary or from PATH. */ - def bunExecutable: T[String] = Task { - managedBunExecutable() - .map(_.path.toString) - .orElse(BunToolchainModule.findOnPath(bunExecutableName()).map(_.toString)) + private def downloadedBunExecutable: T[PathRef] = Task { + val version = bunVersion() + val dist = BunToolchainModule.distribution( + System.getProperty("os.name", "unknown"), + System.getProperty("os.arch", "unknown") + ).fold( + message => Task.fail(s"$message. Set bunExecutableOverride or bunUseSystem."), + identity + ) + val customUrl = bunArchiveUrl() + val customChecksum = bunArchiveSha256() + if (customUrl.isDefined != customChecksum.isDefined) { + Task.fail("bunArchiveUrl and bunArchiveSha256 must be configured together.") + } + val url = customUrl.getOrElse( + s"https://github.com/oven-sh/bun/releases/download/bun-v$version/${dist.assetName}" + ) + val checksum = customChecksum + .orElse(BunToolchainModule.bundledChecksum(version, dist.assetName)) .getOrElse(Task.fail( - s"Unable to find Bun executable '${bunExecutableName()}'. Put Bun on PATH or override managedBunExecutable." + s"No bundled checksum for Bun $version (${dist.assetName}). Set bunArchiveUrl and bunArchiveSha256." )) + .toLowerCase + + val archive = Task.dest / dist.assetName + val executable = Task.dest / dist.executableName + BunToolchainModule.download(url, archive) + val actual = BunToolchainModule.sha256(archive) + if (actual != checksum) { + Task.fail(s"Bun archive checksum mismatch for $url: expected $checksum, received $actual") + } + BunToolchainModule.extractExecutable(archive, dist.executableName, executable) + PathRef(executable) + } + + private def verifyBunVersion(executable: String, expected: String, verify: Boolean): Unit = { + if (verify) { + val result = os.proc(executable, "--version").call( + check = false, + stdout = os.Pipe, + stderr = os.Pipe + ) + val actual = result.out.text().trim + if (result.exitCode != 0 || actual != expected) { + throw new RuntimeException( + s"Bun version mismatch for '$executable': expected $expected, received " + + (if (actual.nonEmpty) actual else s"exit code ${result.exitCode}") + ) + } + } + } + + /** Resolve Bun from an explicit override, PATH opt-in, or the managed distribution. */ + def bunExecutable: T[String] = Task { + val explicit = bunExecutableOverride().orElse(managedBunExecutable()).map(_.path.toString) + val resolved = explicit.orElse { + if (bunUseSystem()) { + BunToolchainModule.findOnPath(bunExecutableName()).map(_.toString).orElse( + Some(Task.fail( + s"Unable to find Bun executable '${bunExecutableName()}' on PATH. " + + "Disable bunUseSystem to use managed Bun, or set bunExecutableOverride." + )) + ) + } else Some(downloadedBunExecutable().path.toString) + }.get + verifyBunVersion(resolved, bunVersion(), bunVerifyVersion()) + resolved + } + + /** Print and validate the resolved Bun toolchain. */ + def bunDoctor(): Command[Unit] = Task.Command { + val executable = bunExecutable() + val revision = os.proc(executable, "--revision").call( + check = false, + stdout = os.Pipe, + stderr = os.Pipe + ).out.text().trim + val mode = + if (bunExecutableOverride().orElse(managedBunExecutable()).nonEmpty) "override" + else if (bunUseSystem()) "system" + else "managed" + println(s"Bun mode: $mode") + println(s"Bun executable: $executable") + println(s"Bun version: ${bunVersion()}") + if (revision.nonEmpty) println(s"Bun revision: $revision") + println(s"Bun linker: ${bunLinker()}") } /** Run a Bun command. All task values must be resolved before calling this. */ diff --git a/millbun/src/mill/bun/BunWebSupport.scala b/millbun/src/mill/bun/BunWebSupport.scala new file mode 100644 index 0000000..a64af40 --- /dev/null +++ b/millbun/src/mill/bun/BunWebSupport.scala @@ -0,0 +1,130 @@ +package mill.bun + +import mill.api.PathRef + +private[mill] object BunWebSupport: + + def copyPreservingModuleDir(refs: Seq[PathRef], moduleDir: os.Path, destination: os.Path): Unit = + refs.filter(ref => os.exists(ref.path)).foreach { ref => + val source = ref.path + val target = + if source.startsWith(moduleDir) then destination / source.relativeTo(moduleDir) + else destination / source.last + copyPath(source, target) + } + + def copyContents(source: os.Path, destination: os.Path): Unit = + if os.exists(source) then + if os.isDir(source) then + os.walk(source).foreach { path => + val relative = path.relativeTo(source) + if relative.segments.nonEmpty then + val target = destination / relative + if os.isDir(path) then os.makeDir.all(target) + else os.copy.over(path, target, createFolders = true) + } + else copyPath(source, destination / source.last) + + private def copyPath(source: os.Path, target: os.Path): Unit = + if os.isDir(source) then + os.walk(source).foreach { path => + val destination = target / path.relativeTo(source) + if os.isDir(path) then os.makeDir.all(destination) + else os.copy.over(path, destination, createFolders = true) + } + else os.copy.over(source, target, createFolders = true) + + def htmlEntries( + configured: Seq[PathRef], + moduleDir: os.Path, + destination: os.Path, + generatedScript: String + ): Seq[os.Path] = + val copied = configured + .map(_.path) + .filter(path => os.exists(path) && os.isFile(path)) + .map(path => + if path.startsWith(moduleDir) then destination / path.relativeTo(moduleDir) + else destination / path.last + ) + + if copied.nonEmpty then copied + else + val index = destination / "index.html" + os.write.over( + index, + s""" + | + | + |
+ | + |""".stripMargin, + createFolders = true + ) + Seq(index) + + def syncRoots(roots: Seq[(os.Path, os.Path)]): Unit = + roots.foreach { case (source, target) => + if os.exists(source) then copyPath(source, target) + } + + def runDevelopmentServer( + bunExecutable: String, + htmlEntries: Seq[os.Path], + workingDirectory: os.Path, + port: Int, + extraArgs: Seq[String], + environment: Map[String, String], + syncRoots: Seq[(os.Path, os.Path)] = Seq.empty + ): Unit = + val running = new java.util.concurrent.atomic.AtomicBoolean(true) + val syncThread = + if syncRoots.nonEmpty then + val thread = new Thread( + () => + while running.get() do + try + syncRoots.foreach { case (source, target) => + if os.exists(source) then copyChangedFiles(source, target) + } + Thread.sleep(200) + catch + case _: InterruptedException => () + , + "mill-bun-web-sync" + ) + thread.setDaemon(true) + thread.start() + Some(thread) + else None + + val relativeEntries = htmlEntries.map(_.relativeTo(workingDirectory).toString) + val process = os.proc( + Seq(bunExecutable) ++ relativeEntries ++ Seq(s"--port=$port") ++ extraArgs + ).spawn( + cwd = workingDirectory, + env = environment, + stdin = os.Inherit, + stdout = os.Inherit, + stderr = os.Inherit + ) + + try + process.join() + if process.exitCode() != 0 then + throw new RuntimeException(s"Bun development server exited with ${process.exitCode()}") + finally + running.set(false) + syncThread.foreach(_.interrupt()) + if process.isAlive() then process.destroy() + + private def copyChangedFiles(source: os.Path, target: os.Path): Unit = + if os.isDir(source) then + os.walk(source).foreach { path => + val destination = target / path.relativeTo(source) + if os.isDir(path) then os.makeDir.all(destination) + else if !os.exists(destination) || os.mtime(path) != os.mtime(destination) || os.size(path) != os.size(destination) then + os.copy.over(path, destination, createFolders = true) + } + else if !os.exists(target) || os.mtime(source) != os.mtime(target) || os.size(source) != os.size(target) then + os.copy.over(source, target, createFolders = true) diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala new file mode 100644 index 0000000..a651969 --- /dev/null +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -0,0 +1,156 @@ +package mill.bun + +import mill.* +import mill.api.BuildCtx + +/** A Scala.js or TypeScript package participating in a shared Bun workspace. */ +trait BunPackageModule extends Module: + + /** Optional shared workspace install. Override with `Task { Some(workspace.bunInstall()) }`. */ + def bunWorkspaceInstall: T[Option[PathRef]] = Task { None } + + /** npm package name used in the generated workspace. */ + def bunWorkspacePackageName: T[String] + + /** Complete typed package.json used by [[BunWorkspaceModule]]. */ + def bunWorkspacePackageJson: T[ujson.Obj] + + /** Local package archives/directories passed to the workspace install. */ + def bunWorkspaceUnmanagedDeps: T[Seq[PathRef]] = Task { Seq.empty } + +/** Root module that gives Scala.js and TypeScript packages one Bun install and lockfile. + * + * Member modules opt in by returning this module's install from `bunWorkspaceInstall`. + */ +trait BunWorkspaceModule extends BunToolchainModule: + + /** Packages included in this workspace. */ + def bunWorkspacePackages: Seq[BunPackageModule] + + /** Root package name. */ + def bunWorkspaceName: T[String] = Task { + val name = toString + if name.nonEmpty then name.replace('.', '-') else "mill-bun-workspace" + } + + /** Unmodeled root package.json fields such as scripts. */ + def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } + + private def npmRc = Task.Source(BuildCtx.workspaceRoot / ".npmrc") + + private def packageDirectory(name: String): String = + name.stripPrefix("@").replace('/', '+') + + private def resolvedPackages: Task[Seq[(String, ujson.Obj, Seq[PathRef])]] = Task.Anon { + Task.traverse(bunWorkspacePackages) { module => + Task.Anon { + ( + module.bunWorkspacePackageName(), + module.bunWorkspacePackageJson(), + module.bunWorkspaceUnmanagedDeps() + ) + } + }() + } + + /** Generated root and member package.json files before installation. */ + def bunWorkspaceLayout: T[PathRef] = Task { + val packages = resolvedPackages() + val duplicateNames = packages.groupBy(_._1).collect { case (name, entries) if entries.size > 1 => name }.toSeq.sorted + if duplicateNames.nonEmpty then + Task.fail(s"Duplicate Bun workspace package names: ${duplicateNames.mkString(", ")}") + + val directories = packages.map((name, _, _) => name -> packageDirectory(name)) + val duplicateDirectories = directories.groupBy(_._2).collect { + case (directory, entries) if entries.size > 1 => directory + }.toSeq.sorted + if duplicateDirectories.nonEmpty then + Task.fail(s"Bun workspace package names map to duplicate directories: ${duplicateDirectories.mkString(", ")}") + + packages.foreach { case (name, json, _) => + val directory = packageDirectory(name) + os.write.over( + Task.dest / "packages" / directory / "package.json", + json.render(indent = 2), + createFolders = true + ) + } + + val root = ujson.Obj( + "name" -> bunWorkspaceName(), + "private" -> true, + "version" -> "0.0.0", + "packageManager" -> s"bun@${bunVersion()}", + "workspaces" -> ujson.Arr.from(directories.map((_, directory) => ujson.Str(s"packages/$directory"))) + ) + if npmOverrides().nonEmpty then + root("overrides") = ujson.Obj.from( + npmOverrides().toSeq.sortBy(_._1).map((name, specifier) => name -> ujson.Str(specifier)) + ) + val merged = BunToolchainModule.mergePackageJson(root, bunPackageJsonExtras()) + os.write.over(Task.dest / "package.json", merged.render(indent = 2), createFolders = true) + PathRef(Task.dest) + } + + private def copyConfigs(destination: os.Path, npmRcPath: os.Path, bunfigs: Seq[PathRef]): Unit = + if os.exists(npmRcPath) then + os.copy.over(npmRcPath, destination / ".npmrc", createFolders = true) + bunfigs.foreach(ref => + os.copy.over(ref.path, destination / ref.path.last, createFolders = true) + ) + + private def hasDependencyInputs(packages: Seq[(String, ujson.Obj, Seq[PathRef])]): Boolean = + val dependencyFields = Seq("dependencies", "devDependencies", "optionalDependencies", "peerDependencies") + packages.exists { case (_, json, unmanaged) => + unmanaged.nonEmpty || dependencyFields.exists(field => json.value.get(field).exists(_.obj.nonEmpty)) + } + + /** Install every member from one source-controlled root `bun.lock`. */ + def bunInstall: T[PathRef] = Task { + val packages = resolvedPackages() + BunToolchainModule.copyWorkspace(bunWorkspaceLayout().path, Task.dest) + copyConfigs(Task.dest, npmRc().path, bunfigFiles()) + + val lockfile = bunLockfile() + requireBunLockfile(hasDependencyInputs(packages), lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, Task.dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ packages.flatMap(_._3).map(_.path.toString), + cwd = Task.dest, + env = bunEnv() + ) + PathRef(Task.dest) + } + + /** Resolve the full workspace and update its source-controlled `bun.lock`. */ + def bunLock(): Command[PathRef] = Task.Command { + val packages = resolvedPackages() + BunToolchainModule.copyWorkspace(bunWorkspaceLayout().path, Task.dest) + copyConfigs(Task.dest, npmRc().path, bunfigFiles()) + copyBunLockfile(bunLockfile(), Task.dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ) ++ packages.flatMap(_._3).map(_.path.toString), + cwd = Task.dest, + env = bunEnv() + ) + + val generated = Task.dest / "bun.lock" + if !os.exists(generated) then Task.fail("Bun did not generate bun.lock") + val sourceLock = moduleDir / "bun.lock" + os.copy.over(generated, sourceLock, createFolders = true) + PathRef(sourceLock) + } diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index eece4db..33da9e9 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -3,9 +3,20 @@ package bun import mill.* import os.* -import mill.bun.BunToolchainModule +import mill.bun.{BunPackageModule, BunToolchainModule} -trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { outer => +trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with BunPackageModule { outer => + + /** Optional packages installed when available. */ + def npmOptionalDeps: T[Seq[String]] = Task { Seq.empty } + + /** Peer requirements supplied by consuming packages. */ + def npmPeerDeps: T[Seq[String]] = Task { Seq.empty } + + /** Development dependencies are local tooling inputs, not transitive runtime requirements. */ + override def transitiveNpmDevDeps: T[Seq[String]] = Task { npmDevDeps() } + + override def bunWorkspaceUnmanagedDeps: T[Seq[PathRef]] = transitiveUnmanagedDeps /** Extra flags passed to `bun run`. */ def bunRunArgs: T[Seq[String]] = Task { Seq.empty } @@ -17,6 +28,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out def bunBundleFormat: T[String] = Task { if (enableEsm()) "esm" else "cjs" } /** Emit a standalone executable instead of a JS bundle. */ + @deprecated("Use the compileExecutable task", "0.3.0") def bunCompileExecutable: T[Boolean] = Task { false } /** Treat all packages as external during bundling. */ @@ -66,22 +78,38 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out Seq(s"typescript@${typeScriptVersion()}") ++ ambientTypeDeps() } - private def mkBunPackageJson: Task[Unit] = Task.Anon { - val dest = Task.dest + override def bunWorkspacePackageName: T[String] = Task { moduleName } + + override def bunWorkspacePackageJson: T[ujson.Obj] = Task { val user = packageJson() + val overrides = npmOverrides() val resolved = ujson.Obj.from( user.copy( name = if (user.name.nonEmpty) user.name else moduleName, version = if (user.version.nonEmpty) user.version else "1.0.0", `type` = if (enableEsm()) "module" else user.`type`, - dependencies = ujson.Obj.from(transitiveNpmDeps().map(BunToolchainModule.splitDep)), - devDependencies = ujson.Obj.from((transitiveNpmDevDeps() ++ tsDeps()).map(BunToolchainModule.splitDep)) + dependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + devDependencies = ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps() ++ tsDeps(), overrides)) ).cleanJson.obj.toSeq ) - val merged = ujson.Obj.from(resolved.value.toSeq ++ bunPackageJsonExtras().value.toSeq) - os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) + val optional = BunToolchainModule.dependencyPairs(npmOptionalDeps(), overrides) + val peers = BunToolchainModule.dependencyPairs(npmPeerDeps(), overrides) + if optional.nonEmpty then resolved("optionalDependencies") = ujson.Obj.from(optional) + if peers.nonEmpty then resolved("peerDependencies") = ujson.Obj.from(peers) + if overrides.nonEmpty then + resolved("overrides") = ujson.Obj.from(overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value))) + + BunToolchainModule.mergePackageJson(resolved, bunPackageJsonExtras()) + } + + private def mkBunPackageJson: Task[Unit] = Task.Anon { + os.write.over( + Task.dest / "package.json", + bunWorkspacePackageJson().render(indent = 2), + createFolders = true + ) } private def resolvedBunfigs: Task[Seq[PathRef]] = Task.Anon { @@ -118,14 +146,62 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out mkBunPackageJson() copyBunWorkspaceConfigs() + bunWorkspaceInstall() match + case Some(workspaceInstall) => + val installed = workspaceInstall.path + if os.exists(installed / "node_modules") then + os.symlink(dest / "node_modules", installed / "node_modules") + bunLockfiles().foreach { name => + val source = installed / name + if os.exists(source) then os.symlink(dest / name, source) + } + case None => + val lockfile = bunLockfile() + requireBunLockfile(true, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunToolEnv() + ) + + PathRef(dest) + } + + /** Resolve dependencies and update the source-controlled `bun.lock`. */ + def bunLock(): Command[PathRef] = Task.Command { + if bunWorkspaceInstall().nonEmpty then + Task.fail("This package uses a Bun workspace. Run the workspace module's bunLock command.") + val dest = Task.dest + os.makeDir.all(dest) + mkBunPackageJson() + copyBunWorkspaceConfigs() + copyBunLockfile(bunLockfile(), dest) + runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ) ++ transitiveUnmanagedDeps().map(_.path.toString), cwd = dest, env = bunToolEnv() ) - PathRef(dest) + val generated = dest / "bun.lock" + if (!os.exists(generated)) Task.fail("Bun did not generate bun.lock") + val sourceLock = moduleDir / "bun.lock" + os.copy.over(generated, sourceLock, createFolders = true) + PathRef(sourceLock) } /** * Preserve Mill's compile sandbox preparation, but invoke TypeScript through @@ -232,12 +308,44 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out PathRef(outFile) } + /** Build the configured entrypoint as a standalone Bun executable. */ + def compileExecutable: T[PathRef] = Task { + val compileDir = compile().path + val buildDir = Task.dest / "workspace" + val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString + val outFile = Task.dest / bunBinaryName() + + BunToolchainModule.copyWorkspace(compileDir, buildDir) + BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) + BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) + copyCompileResources(bunCompileResources(), buildDir) + + val packagesExternal = if (bunBundlePackagesExternal()) Seq("--packages", "external") else Nil + val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) + runBun( + bunExecutable(), + Seq( + "build", + mainFile, + "--compile", + "--target", + "bun", + "--outfile", + outFile.toString + ) ++ packagesExternal ++ externalArgs ++ bunBuildArgs(), + cwd = buildDir, + env = bunToolEnv() + ) + + PathRef(outFile) + } + /** * Cross-compile standalone executables for each configured target. * Returns a map of target name to executable PathRef. * Requires `bunCompileTargets` to be non-empty. */ - def bunCompileExecutables: T[Map[String, PathRef]] = Task { + def compileExecutables: T[Map[String, PathRef]] = Task { val targets = bunCompileTargets() if (targets.isEmpty) Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") @@ -276,6 +384,10 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out }.toMap } + /** Compatibility alias for the canonical cross-platform executable task. */ + @deprecated("Use compileExecutables", "0.3.0") + def bunCompileExecutables: T[Map[String, PathRef]] = Task { compileExecutables() } + /** * Bun-native nested test module. * @@ -302,13 +414,14 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out // test module's transitive deps; we achieve the same by building one // merged package.json before `bun install`. val user = outer.packageJson() - val outerDeps = outer.transitiveNpmDeps().map(BunToolchainModule.splitDep) - val outerDevDeps = (outer.transitiveNpmDevDeps() ++ outer.tsDeps()).map(BunToolchainModule.splitDep) + val overrides = outer.npmOverrides() + val outerDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDeps(), overrides) + val outerDevDeps = BunToolchainModule.dependencyPairs(outer.transitiveNpmDevDeps() ++ outer.tsDeps(), overrides) val outerPackageNames = (outerDeps.iterator ++ outerDevDeps.iterator).map(_._1).toSet // Test-only deps are dev dependencies — they should not appear in the // production dependencies field, matching Bun/npm convention. - val testDevDeps = (transitiveNpmDeps() ++ transitiveNpmDevDeps() ++ this.tsDeps()) - .map(BunToolchainModule.splitDep) + val testDevDeps = BunToolchainModule + .dependencyPairs(transitiveNpmDeps() ++ this.npmDevDeps() ++ this.tsDeps(), overrides) .filterNot { case (name, _) => outerPackageNames.contains(name) } val resolved = ujson.Obj.from( @@ -321,14 +434,32 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule { out ).cleanJson.obj.toSeq ) - val merged = ujson.Obj.from(resolved.value.toSeq ++ outer.bunPackageJsonExtras().value.toSeq) + val optional = BunToolchainModule.dependencyPairs(outer.npmOptionalDeps(), overrides) + val peers = BunToolchainModule.dependencyPairs(outer.npmPeerDeps(), overrides) + if optional.nonEmpty then resolved("optionalDependencies") = ujson.Obj.from(optional) + if peers.nonEmpty then resolved("peerDependencies") = ujson.Obj.from(peers) + if overrides.nonEmpty then + resolved("overrides") = ujson.Obj.from( + overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value)) + ) + + val merged = BunToolchainModule.mergePackageJson(resolved, outer.bunPackageJsonExtras()) os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) outer.copyBunWorkspaceConfigs() + val lockfile = bunLockfile() + requireBunLockfile(true, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) + runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ (outer.transitiveUnmanagedDeps() ++ transitiveUnmanagedDeps()).distinct.map(_.path.toString), cwd = dest, env = outer.bunToolEnv() ) diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala new file mode 100644 index 0000000..fe0fc59 --- /dev/null +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala @@ -0,0 +1,114 @@ +package mill.javascriptlib +package bun + +import mill.* +import mill.bun.BunWebSupport + +/** TypeScript web application served and bundled through Bun's HTML pipeline. */ +trait BunTypeScriptWebModule extends BunTypeScriptModule: + + /** HTML entrypoints. A minimal index.html is generated when none exist. */ + def webEntryPoints: T[Seq[PathRef]] = Task.Sources(moduleDir / "index.html") + + /** Static web files copied beneath `public/`. */ + def webPublicSources: T[Seq[PathRef]] = Task.Sources(moduleDir / "public") + + /** Browser entrypoint used when the plugin generates index.html. */ + def webScriptEntryPoint: T[PathRef] = Task.Input { + val candidates = Seq( + moduleDir / "src" / "main.ts", + moduleDir / "src" / "main.tsx", + moduleDir / "src" / "index.ts", + moduleDir / "src" / "index.tsx" + ) + PathRef(candidates.find(os.exists).getOrElse( + throw new RuntimeException( + s"No TypeScript web entrypoint found beneath ${moduleDir / "src"}. Override webScriptEntryPoint." + ) + )) + } + + def webDevPort: T[Int] = Task { 3000 } + + def webDevArgs: T[Seq[String]] = Task { Seq.empty } + + private def prepareWebStage( + destination: os.Path, + sourceRefs: Seq[PathRef], + htmlRefs: Seq[PathRef], + publicRefs: Seq[PathRef], + install: os.Path, + configs: Seq[PathRef], + scriptEntryPoint: os.Path + ): Seq[os.Path] = { + BunWebSupport.copyPreservingModuleDir(sourceRefs, moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(htmlRefs, moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(publicRefs, moduleDir, destination) + if os.exists(install / "node_modules") then + os.symlink(destination / "node_modules", install / "node_modules") + os.copy.over(install / "package.json", destination / "package.json", createFolders = true) + configs.foreach(ref => os.copy.over(ref.path, destination / ref.path.last, createFolders = true)) + + val script = "./" + scriptEntryPoint.relativeTo(moduleDir).toString.replace('\\', '/') + BunWebSupport.htmlEntries(htmlRefs, moduleDir, destination, script) + } + + private def webDevelopmentStage: T[PathRef] = Task { + prepareWebStage( + Task.dest, + sources() ++ generatedSources() ++ resources(), + webEntryPoints(), + webPublicSources(), + npmInstall().path, + bunfigFiles(), + webScriptEntryPoint().path + ) + PathRef(Task.dest) + } + + private def webProductionStage: T[PathRef] = Task { + prepareWebStage( + Task.dest, + sources() ++ generatedSources() ++ resources(), + webEntryPoints(), + webPublicSources(), + npmInstall().path, + bunfigFiles(), + webScriptEntryPoint().path + ) + PathRef(Task.dest) + } + + /** Start Bun's HTML development server with source mirroring for native HMR. */ + def dev(): Command[Unit] = Task.Command { + val stage = webDevelopmentStage().path + val script = "./" + webScriptEntryPoint().path.relativeTo(moduleDir).toString.replace('\\', '/') + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, script) + val syncRoots = (sources() ++ generatedSources() ++ resources() ++ webEntryPoints() ++ webPublicSources()) + .filter(ref => os.exists(ref.path) && ref.path.startsWith(moduleDir)) + .map(ref => ref.path -> (stage / ref.path.relativeTo(moduleDir))) + BunWebSupport.runDevelopmentServer( + bunExecutable(), + entries, + stage, + webDevPort(), + webDevArgs(), + bunRuntimeEnv(), + syncRoots + ) + } + + /** Build complete optimized HTML/CSS/JavaScript assets under `dist`. */ + override def bundle: T[PathRef] = Task { + val stage = webProductionStage().path + val script = "./" + webScriptEntryPoint().path.relativeTo(moduleDir).toString.replace('\\', '/') + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, script) + val destination = Task.dest / "dist" + runBun( + bunExecutable(), + Seq("build") ++ entries.map(_.toString) ++ Seq("--minify", "--outdir", destination.toString) ++ bunBuildArgs(), + cwd = stage, + env = bunToolEnv() + ) + PathRef(destination) + } diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 0c29451..e8a1ecb 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -27,27 +27,16 @@ trait BunPublishModule extends BunScalaJSModule { */ 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 + BunToolchainModule.dependencyPairs(deps, npmOverrides()).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())) + dependencies = typed(npmDeps() ++ bunDeps()), + devDependencies = Map.empty, + optionalDependencies = typed(npmOptionalDeps() ++ bunOptionalDeps()), + peerDependencies = typed(npmPeerDeps()), + schemaVersion = 2 ) } @@ -73,8 +62,8 @@ trait BunPublishModule extends BunScalaJSModule { 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 deps = BunToolchainModule.dependencyPairs(npmDeps() ++ bunDeps(), npmOverrides()) + val optional = BunToolchainModule.dependencyPairs(npmOptionalDeps() ++ bunOptionalDeps(), npmOverrides()) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true, @@ -88,15 +77,22 @@ trait BunPublishModule extends BunScalaJSModule { case ModuleKind.ESModule => base("type") = "module" case _ => () - val merged = ujson.Obj.from(base.value.toSeq ++ bunPackageJsonExtras().value.toSeq) + val merged = BunToolchainModule.mergePackageJson(base, bunPackageJsonExtras()) os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) - val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty || - bunPackageJsonExtras().value.nonEmpty + val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty + val lockfile = bunLockfile() + requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) if hasRuntimeInputs then runBun( bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ unmanagedDeps().map(_.path.toString), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ unmanagedDeps().map(_.path.toString), cwd = dest, env = bunEnv() ) @@ -126,14 +122,13 @@ trait BunPublishModule extends BunScalaJSModule { val manifest = resolvedPublishedManifest() val hasManifest = manifest.dependencies.nonEmpty || - manifest.devDependencies.nonEmpty || - manifest.optionalDependencies.nonEmpty + manifest.optionalDependencies.nonEmpty || + manifest.peerDependencies.nonEmpty val hasVendoredRuntime = bunPublishVendoredRuntime() && ( manifest.dependencies.nonEmpty || manifest.optionalDependencies.nonEmpty || - unmanagedDeps().nonEmpty || - bunPackageJsonExtras().value.nonEmpty + unmanagedDeps().nonEmpty ) (if hasManifest then Seq(bunDependencyManifest()) else Seq.empty) ++ diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index c4fe0a3..51880e1 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -4,14 +4,13 @@ package bun import mill.* import mill.api.BuildCtx import mill.api.JsonFormatters.given -import mill.bun.{BunManifest, BunToolchainModule, BunVendoredNodeModules} +import mill.bun.{BunManifest, BunPackageModule, BunToolchainModule, BunVendoredNodeModules} import mill.javalib.JavaModule import mill.scalajslib.* import mill.scalajslib.api.* -import mill.scalajslib.config.ScalaJSConfigModule import scala.annotation.tailrec -trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { outer => +trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPackageModule { outer => /** JS packages needed by linked Scala.js output, e.g. packages referenced by @JSImport. */ def npmDeps: T[Seq[String]] = Task { Seq.empty } @@ -77,16 +76,23 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } def transitiveNpmDevDeps: T[Seq[String]] = Task { - val moduleNpm = Task.traverse(recursiveInstallBunModuleDeps)(_.npmDevDeps)().flatten - val moduleBun = Task.traverse(recursiveInstallBunModuleDeps)(_.bunDevDeps)().flatten - moduleNpm ++ moduleBun ++ classpathBunDevDeps() ++ npmDevDeps() ++ bunDevDeps() + npmDevDeps() ++ bunDevDeps() } def transitiveUnmanagedDeps: T[Seq[PathRef]] = Task { Task.traverse(recursiveInstallBunModuleDeps)(_.unmanagedDeps)().flatten ++ unmanagedDeps() } + override def bunWorkspaceUnmanagedDeps: T[Seq[PathRef]] = transitiveUnmanagedDeps + /** Optional JS packages — installed if available, not fatal if missing. */ + def npmOptionalDeps: T[Seq[String]] = Task { Seq.empty } + + /** Peer JS packages that must be supplied by the consuming application. */ + def npmPeerDeps: T[Seq[String]] = Task { Seq.empty } + + /** @deprecated Use [[npmOptionalDeps]]. */ + @deprecated("Use npmOptionalDeps", "0.3.0") def bunOptionalDeps: T[Seq[String]] = Task { Seq.empty } // --------------------------------------------------------------------------- @@ -98,7 +104,10 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out classpathBunManifests().flatMap(_.dependencies).map { case (name, version) => s"$name@$version" } } - /** Scan classpath JARs for embedded bun dev-dependency manifests. */ + /** Read legacy schema v1 development metadata for diagnostics. + * @deprecated Development dependencies are not transitive in schema v2. + */ + @deprecated("Development dependencies are local and no longer transitive", "0.3.0") def classpathBunDevDeps: T[Seq[String]] = Task { classpathBunManifests().flatMap(_.devDependencies).map { case (name, version) => s"$name@$version" } } @@ -108,6 +117,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out classpathBunManifests().flatMap(_.optionalDependencies).map { case (name, version) => s"$name@$version" } } + /** Peer packages declared by published Scala.js libraries. */ + def classpathBunPeerDeps: T[Seq[String]] = Task { + classpathBunManifests().flatMap(_.peerDependencies).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. */ @@ -161,23 +175,40 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out if (name.nonEmpty) name.split('.').last.replace('.', '-') else "app" } + @deprecated("Use transitiveNpmOptionalDeps", "0.3.0") def transitiveBunOptionalDeps: T[Seq[String]] = Task { - val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(_.bunOptionalDeps)().flatten - moduleOptional ++ classpathBunOptionalDeps() ++ bunOptionalDeps() + val moduleOptional = Task.traverse(recursiveInstallBunModuleDeps)(module => Task.Anon { + module.npmOptionalDeps() ++ module.bunOptionalDeps() + })().flatten + moduleOptional ++ classpathBunOptionalDeps() ++ npmOptionalDeps() ++ bunOptionalDeps() } - private def mkBunPackageJson: Task[Unit] = Task.Anon { - val dest = Task.dest - val allOptional = transitiveBunOptionalDeps().map(BunToolchainModule.splitDep) + def transitiveNpmOptionalDeps: T[Seq[String]] = Task { transitiveBunOptionalDeps() } + + def transitiveNpmPeerDeps: T[Seq[String]] = Task { + val modulePeers = Task.traverse(recursiveInstallBunModuleDeps)(_.npmPeerDeps)().flatten + modulePeers ++ classpathBunPeerDeps() ++ npmPeerDeps() + } + + override def bunWorkspacePackageName: T[String] = Task { defaultPackageName } + + override def bunWorkspacePackageJson: T[ujson.Obj] = Task { + val overrides = npmOverrides() + val allOptional = BunToolchainModule.dependencyPairs(transitiveNpmOptionalDeps(), overrides) + val allPeers = BunToolchainModule.dependencyPairs(transitiveNpmPeerDeps(), overrides) val base = ujson.Obj( "name" -> defaultPackageName, "private" -> true, "version" -> "0.0.0", - "dependencies" -> ujson.Obj.from(transitiveNpmDeps().map(BunToolchainModule.splitDep)), - "devDependencies" -> ujson.Obj.from(transitiveNpmDevDeps().map(BunToolchainModule.splitDep)) + "dependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDeps(), overrides)), + "devDependencies" -> ujson.Obj.from(BunToolchainModule.dependencyPairs(transitiveNpmDevDeps(), overrides)) ) if allOptional.nonEmpty then base("optionalDependencies") = ujson.Obj.from(allOptional) + if allPeers.nonEmpty then + base("peerDependencies") = ujson.Obj.from(allPeers) + if overrides.nonEmpty then + base("overrides") = ujson.Obj.from(overrides.toSeq.sortBy(_._1).map((name, value) => name -> ujson.Str(value))) val packageType = moduleKind() match { @@ -187,8 +218,15 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out packageType.foreach(tpe => base("type") = tpe) - val merged = ujson.Obj.from(base.value.toSeq ++ bunPackageJsonExtras().value.toSeq) - os.write.over(dest / "package.json", merged.render(indent = 2), createFolders = true) + BunToolchainModule.mergePackageJson(base, bunPackageJsonExtras()) + } + + private def mkBunPackageJson: Task[Unit] = Task.Anon { + os.write.over( + Task.dest / "package.json", + bunWorkspacePackageJson().render(indent = 2), + createFolders = true + ) } private def mergeVendoredNodeModules(entries: Seq[os.Path], destNodeModules: os.Path): Unit = @@ -211,17 +249,37 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out val hasInstallInputs = transitiveNpmDeps().nonEmpty || transitiveNpmDevDeps().nonEmpty || - transitiveBunOptionalDeps().nonEmpty || + transitiveNpmOptionalDeps().nonEmpty || + transitiveNpmPeerDeps().nonEmpty || transitiveUnmanagedDeps().nonEmpty || bunPackageJsonExtras().value.nonEmpty - if hasInstallInputs then - runBun( - bunExecutable(), - Seq("install") ++ bunInstallArgs() ++ transitiveUnmanagedDeps().map(_.path.toString), - cwd = dest, - env = bunEnv() - ) + bunWorkspaceInstall() match + case Some(workspaceInstall) => + val installed = workspaceInstall.path + if os.exists(installed / "node_modules") then + os.symlink(dest / "node_modules", installed / "node_modules") + bunLockfiles().foreach { name => + val source = installed / name + if os.exists(source) then os.symlink(dest / name, source) + } + case None => + val lockfile = bunLockfile() + requireBunLockfile(hasInstallInputs, lockfile, bunRequireLockfile()) + copyBunLockfile(lockfile, dest) + + if hasInstallInputs then + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + lockfile.nonEmpty, + updateLockfile = false + ) ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunEnv() + ) val ownResourceRoots = resources().map(_.path).toSet val vendoredEntries = runClasspath().map(_.path).filterNot(ownResourceRoots.contains) @@ -230,6 +288,38 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(dest) } + /** Resolve dependencies and update the source-controlled `bun.lock`. */ + def bunLock(): Command[PathRef] = Task.Command { + if bunWorkspaceInstall().nonEmpty then + Task.fail("This package uses a Bun workspace. Run the workspace module's bunLock command.") + val dest = Task.dest + os.makeDir.all(dest) + if (os.exists(npmRc().path)) os.copy.over(npmRc().path, dest / ".npmrc", createFolders = true) + bunfigFiles().foreach { cfg => + os.copy.over(cfg.path, dest / cfg.path.last, createFolders = true) + } + mkBunPackageJson() + copyBunLockfile(bunLockfile(), dest) + + runBun( + bunExecutable(), + Seq("install") ++ resolvedBunInstallArgs( + bunInstallArgs(), + bunInstallExtraArgs(), + bunLockfile().nonEmpty, + updateLockfile = true + ) ++ transitiveUnmanagedDeps().map(_.path.toString), + cwd = dest, + env = bunEnv() + ) + + val generated = dest / "bun.lock" + if (!os.exists(generated)) Task.fail("Bun did not generate bun.lock") + val sourceLock = moduleDir / "bun.lock" + os.copy.over(generated, sourceLock, createFolders = true) + PathRef(sourceLock) + } + private def resolvedBunConfigs: Task[Seq[PathRef]] = Task.Anon { bunfigFiles() } @@ -312,7 +402,8 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out ) } - def bunBundle: T[PathRef] = Task { + /** Canonical production bundle task. */ + def bundle: T[PathRef] = Task { val linked = fullLinkJS() val outDir = Task.dest / "dist" @@ -342,7 +433,11 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(outDir) } - def bunBundleFast: T[PathRef] = Task { + @deprecated("Use bundle", "0.3.0") + def bunBundle: T[PathRef] = Task { bundle() } + + /** Canonical fast-development bundle task. */ + def bundleFast: T[PathRef] = Task { val linked = fastLinkJS() val outDir = Task.dest / "dist" @@ -370,11 +465,14 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(outDir) } + @deprecated("Use bundleFast", "0.3.0") + def bunBundleFast: T[PathRef] = Task { bundleFast() } + private def copyCompileResources(resources: Seq[PathRef], dest: os.Path): Unit = BunToolchainModule.copyPathRefs(resources, dest, Seq(moduleDir)) - /** Convenience task for server-side Scala.js entrypoints. */ - def bunCompileExecutable: T[PathRef] = Task { + /** Build a server-side Scala.js entrypoint as a standalone executable. */ + def compileExecutable: T[PathRef] = Task { val linked = fullLinkJS() val buildDir = Task.dest / "workspace" BunToolchainModule.copyWorkspace(linked.dest.path, buildDir) @@ -401,11 +499,14 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out PathRef(outFile) } + @deprecated("Use compileExecutable", "0.3.0") + def bunCompileExecutable: T[PathRef] = Task { compileExecutable() } + /** * Cross-compile standalone executables for each configured target. * Returns a map of target name to executable PathRef. */ - def bunCompileExecutables: T[Map[String, PathRef]] = Task { + def compileExecutables: T[Map[String, PathRef]] = Task { val targets = bunCompileTargets() if (targets.isEmpty) Task.fail("bunCompileTargets is empty. Set targets like Seq(\"bun-linux-x64\", \"bun-darwin-arm64\").") @@ -440,7 +541,10 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out }.toMap } - trait BunScalaJSTests extends ScalaJSConfigTests { + @deprecated("Use compileExecutables", "0.3.0") + def bunCompileExecutables: T[Map[String, PathRef]] = Task { compileExecutables() } + + trait BunScalaJSTests extends ScalaJSTests { override def moduleKind: T[ModuleKind] = Task { outer.moduleKind() match { // Bun rejects the temporary file:-URL importer that Scala.js' Node env @@ -489,27 +593,9 @@ trait BunScalaJSModule extends ScalaJSConfigModule with BunToolchainModule { out } override protected def testLinkTask: Task[Report] = Task.Anon { - val linkConfig = - outer.moduleKind() match { - case ModuleKind.ESModule => - outer.scalaJSConfig().withModuleKind(org.scalajs.linker.interface.ModuleKind.CommonJSModule) - case _ => - outer.scalaJSConfig() - } - - linkJs( - worker = mill.scalajslib.config.worker.ScalaJSConfigWorkerExternalModule.scalaJSWorker(), - toolsClasspath = scalaJSToolsClasspath(), - runClasspath = scalaJSTestDeps() ++ runClasspath(), - moduleInitializers = testModuleInitializers(), - forceOutJs = false, - testBridgeInit = true, - importMap = scalaJSImportMap(), - config = linkConfig - ).map { linked => - outer.ensureLinkedWorkspace(linked, outer.bunInstall().path, outer.bunLockfiles(), outer.resolvedBunConfigs()) - linked - } + val linked = super.testLinkTask() + outer.ensureLinkedWorkspace(linked, outer.bunInstall().path, outer.bunLockfiles(), outer.resolvedBunConfigs()) + linked } /** Run Scala.js tests through Mill's test bridge with Bun as the JS runtime. */ diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala new file mode 100644 index 0000000..111d336 --- /dev/null +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala @@ -0,0 +1,74 @@ +package mill.scalajslib +package bun + +import mill.* +import mill.bun.{BunToolchainModule, BunWebSupport} + +/** Scala.js web application served and bundled through Bun's HTML pipeline. */ +trait BunScalaJSWebModule extends BunScalaJSModule: + + /** HTML entrypoints. A minimal index.html is generated when none exist. */ + def webEntryPoints: T[Seq[PathRef]] = Task.Sources(moduleDir / "index.html") + + /** Static web files copied beneath `public/`. */ + def webPublicSources: T[Seq[PathRef]] = Task.Sources(moduleDir / "public") + + def webDevPort: T[Int] = Task { 3000 } + + def webDevArgs: T[Seq[String]] = Task { Seq.empty } + + private def webDevelopmentStage: T[PathRef] = Task { + val linked = fastLinkJS() + val destination = Task.dest + BunWebSupport.copyContents(linked.dest.path, destination) + BunWebSupport.copyPreservingModuleDir(webEntryPoints(), moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(webPublicSources(), moduleDir, destination) + + val entrypoint = primaryEntrypoint(linked) + val stableEntrypoint = destination / "main.js" + if entrypoint != stableEntrypoint then os.copy.over(entrypoint, stableEntrypoint, createFolders = true) + BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, destination, "./main.js") + PathRef(destination) + } + + private def webProductionStage: T[PathRef] = Task { + val linked = fullLinkJS() + val destination = Task.dest + BunWebSupport.copyContents(linked.dest.path, destination) + BunWebSupport.copyPreservingModuleDir(webEntryPoints(), moduleDir, destination) + BunWebSupport.copyPreservingModuleDir(webPublicSources(), moduleDir, destination) + + val entrypoint = primaryEntrypoint(linked) + val stableEntrypoint = destination / "main.js" + if entrypoint != stableEntrypoint then os.copy.over(entrypoint, stableEntrypoint, createFolders = true) + BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, destination, "./main.js") + PathRef(destination) + } + + /** Start Bun's HTML development server. Use `mill --watch app.dev` for Scala relinking. */ + def dev(): Command[Unit] = Task.Command { + val stage = webDevelopmentStage().path + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, "./main.js") + BunWebSupport.runDevelopmentServer( + bunExecutable(), + entries, + stage, + webDevPort(), + webDevArgs(), + bunEnv() + ) + } + + /** Build complete optimized HTML/CSS/JavaScript assets under `dist`. */ + override def bundle: T[PathRef] = Task { + val stage = webProductionStage().path + val entries = BunWebSupport.htmlEntries(webEntryPoints(), moduleDir, stage, "./main.js") + val destination = Task.dest / "dist" + runBun( + bunExecutable(), + Seq("build") ++ entries.map(_.toString) ++ Seq("--minify", "--outdir", destination.toString) ++ bunBundleArgs(), + cwd = stage, + env = bunEnv() + ) + PathRef(destination) + } diff --git a/millbun/test/src/mill/bun/BunManifestTests.scala b/millbun/test/src/mill/bun/BunManifestTests.scala index 41d1e4a..707ff8a 100644 --- a/millbun/test/src/mill/bun/BunManifestTests.scala +++ b/millbun/test/src/mill/bun/BunManifestTests.scala @@ -8,45 +8,69 @@ object BunManifestTests extends TestSuite { test("empty manifest serialization") { val json = BunManifest.toJson(BunManifest.empty) val parsed = BunManifest.fromJson(json) + assert(parsed.schemaVersion == 2) assert(parsed.dependencies.isEmpty) assert(parsed.devDependencies.isEmpty) assert(parsed.optionalDependencies.isEmpty) + assert(parsed.peerDependencies.isEmpty) } - test("round-trip with dependencies") { + test("schema v2 round-trip with publishable dependencies") { val manifest = BunManifest( dependencies = Map( "@anthropic-ai/claude-agent-sdk" -> "^0.2.90", "zod" -> "^4.0.0" ), - devDependencies = Map("@types/bun" -> "^1.3.5"), - optionalDependencies = Map.empty + devDependencies = Map.empty, + optionalDependencies = Map("fsevents" -> "^2.3.3"), + peerDependencies = Map("react" -> "^19.0.0") ) val json = BunManifest.toJson(manifest) val parsed = BunManifest.fromJson(json) assert(parsed.dependencies == manifest.dependencies) - assert(parsed.devDependencies == manifest.devDependencies) + assert(parsed.optionalDependencies == manifest.optionalDependencies) + assert(parsed.peerDependencies == manifest.peerDependencies) + assert(!json.obj.contains("devDependencies")) } - test("round-trip with optional dependencies") { - val manifest = BunManifest( - dependencies = Map("react" -> "^19.0.0"), - devDependencies = Map.empty, - optionalDependencies = Map("@openai/codex-sdk" -> "^0.118.0") + test("schema v1 remains readable") { + val json = ujson.Obj( + "schemaVersion" -> 1, + "dependencies" -> ujson.Obj("react" -> "^18.0.0"), + "devDependencies" -> ujson.Obj("typescript" -> "^5.0.0") ) - val json = BunManifest.toJson(manifest) val parsed = BunManifest.fromJson(json) - assert(parsed.optionalDependencies == manifest.optionalDependencies) + assert(parsed.schemaVersion == 1) + assert(parsed.dependencies == Map("react" -> "^18.0.0")) + assert(parsed.devDependencies == Map("typescript" -> "^5.0.0")) } test("fromJson handles missing fields") { val json = ujson.Obj("dependencies" -> ujson.Obj("react" -> "19.0.0")) val parsed = BunManifest.fromJson(json) assert(parsed.dependencies == Map("react" -> "19.0.0")) + assert(parsed.schemaVersion == 1) assert(parsed.devDependencies.isEmpty) assert(parsed.optionalDependencies.isEmpty) } + test("schema v2 rejects dev dependencies") { + val json = ujson.Obj( + "schemaVersion" -> 2, + "dependencies" -> ujson.Obj(), + "devDependencies" -> ujson.Obj("typescript" -> "^5.0.0") + ) + val error = intercept[IllegalArgumentException](BunManifest.fromJson(json)) + assert(error.getMessage.contains("does not allow devDependencies")) + } + + test("unknown schema versions fail clearly") { + val error = intercept[IllegalArgumentException]( + BunManifest.fromJson(ujson.Obj("schemaVersion" -> 99)) + ) + assert(error.getMessage.contains("schemaVersion 99")) + } + test("merge combines manifests") { val m1 = BunManifest( Map("react" -> "^19.0.0"), @@ -60,15 +84,25 @@ object BunManifestTests extends TestSuite { ) val merged = BunManifest.merge(Seq(m1, m2)) assert(merged.dependencies == Map("react" -> "^19.0.0", "zod" -> "^4.0.0")) - assert(merged.devDependencies == Map("typescript" -> "^5.0.0")) + assert(merged.devDependencies.isEmpty) assert(merged.optionalDependencies == Map("lodash" -> "^4.17.0")) + assert(merged.schemaVersion == 2) } - test("merge later entries override earlier") { + test("schema v2 serialization rejects development dependencies") { + val manifest = BunManifest( + dependencies = Map.empty, + devDependencies = Map("typescript" -> "^5.0.0"), + optionalDependencies = Map.empty + ) + intercept[IllegalArgumentException](BunManifest.toJson(manifest)) + } + + test("merge rejects conflicting dependency requirements") { val m1 = BunManifest(Map("react" -> "^18.0.0"), Map.empty, Map.empty) val m2 = BunManifest(Map("react" -> "^19.0.0"), Map.empty, Map.empty) - val merged = BunManifest.merge(Seq(m1, m2)) - assert(merged.dependencies("react") == "^19.0.0") + val error = intercept[IllegalArgumentException](BunManifest.merge(Seq(m1, m2))) + assert(error.getMessage.contains("Conflicting runtime dependency 'react'")) } test("readFromDir returns None for missing directory") { @@ -87,6 +121,12 @@ object BunManifestTests extends TestSuite { assert(result.get.dependencies("react") == "^19.0.0") } + test("readFromDir reports malformed manifests") { + val dir = os.temp.dir() + os.write(dir / os.RelPath(BunManifest.ManifestPath), "{", createFolders = true) + intercept[Exception](BunManifest.readFromDir(dir)) + } + test("JAR round-trip: write manifest, read back") { val tmpDir = os.temp.dir() @@ -126,6 +166,18 @@ object BunManifestTests extends TestSuite { assert(manifest.isEmpty) } + test("readFromJar reports malformed manifests") { + val jarPath = os.temp.dir() / "malformed.jar" + val jarOut = new java.util.jar.JarOutputStream(new java.io.FileOutputStream(jarPath.toIO)) + try { + jarOut.putNextEntry(new java.util.jar.JarEntry(BunManifest.ManifestPath)) + jarOut.write("{".getBytes("UTF-8")) + jarOut.closeEntry() + } finally jarOut.close() + + intercept[Exception](BunManifest.readFromJar(jarPath)) + } + test("readFromJar returns None for nonexistent path") { val result = BunManifest.readFromJar(os.Path("/nonexistent/lib.jar")) assert(result.isEmpty) diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala new file mode 100644 index 0000000..9774c7e --- /dev/null +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -0,0 +1,104 @@ +package mill.bun + +import java.io.FileOutputStream +import java.util.zip.{ZipEntry, ZipOutputStream} +import utest.* + +object BunToolchainTests extends TestSuite: + def tests: Tests = Tests: + test("maps supported platforms to official release assets"): + assert( + BunToolchainModule.distribution("Mac OS X", "aarch64") == Right( + BunToolchainModule.Distribution("bun-darwin-aarch64.zip", "bun") + ) + ) + assert( + BunToolchainModule.distribution("Linux", "amd64") == Right( + BunToolchainModule.Distribution("bun-linux-x64.zip", "bun") + ) + ) + assert( + BunToolchainModule.distribution("Windows 11", "x86_64") == Right( + BunToolchainModule.Distribution("bun-windows-x64.zip", "bun.exe") + ) + ) + + test("rejects unsupported managed platforms"): + assert(BunToolchainModule.distribution("Plan 9", "x64").isLeft) + assert(BunToolchainModule.distribution("Linux", "riscv64").isLeft) + + test("bundles checksums for every supported Bun 1.3.14 asset"): + val assets = Seq( + "bun-darwin-aarch64.zip", + "bun-darwin-x64.zip", + "bun-linux-aarch64.zip", + "bun-linux-x64.zip", + "bun-windows-aarch64.zip", + "bun-windows-x64.zip" + ) + assets.foreach: asset => + val checksum = BunToolchainModule.bundledChecksum("1.3.14", asset) + assert(checksum.exists(_.matches("[0-9a-f]{64}"))) + assert(BunToolchainModule.bundledChecksum("1.3.15", assets.head).isEmpty) + + test("computes SHA-256"): + val file = os.temp(contents = "hello") + assert( + BunToolchainModule.sha256(file) == + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ) + + test("extracts the Bun executable from a release-shaped zip"): + val root = os.temp.dir() + val archive = root / "bun.zip" + val output = new ZipOutputStream(new FileOutputStream(archive.toIO)) + try + output.putNextEntry(new ZipEntry("bun-linux-x64/bun")) + output.write("fake-bun".getBytes("UTF-8")) + output.closeEntry() + finally output.close() + + val executable = root / "bin" / "bun" + BunToolchainModule.extractExecutable(archive, "bun", executable) + assert(os.read(executable) == "fake-bun") + assert(executable.toIO.canExecute) + + test("dependency pairs are deterministic and deduplicate identical declarations"): + val pairs = BunToolchainModule.dependencyPairs( + Seq("zod@^4.0.0", "react@^19.0.0", "zod@^4.0.0") + ) + assert(pairs.map((name, version) => name -> version.str) == Seq( + "react" -> "^19.0.0", + "zod" -> "^4.0.0" + )) + + test("dependency conflicts fail unless explicitly overridden"): + val error = intercept[IllegalArgumentException]( + BunToolchainModule.dependencyPairs(Seq("react@^18", "react@^19")) + ) + assert(error.getMessage.contains("Conflicting npm dependency 'react'")) + + val pairs = BunToolchainModule.dependencyPairs( + Seq("react@^18", "react@^19"), + Map("react" -> "19.1.1") + ) + assert(pairs.map((name, version) => name -> version.str) == Seq("react" -> "19.1.1")) + + test("malformed dependency declarations fail clearly"): + Seq("", "react@", "@types", "@types/bun@").foreach: input => + intercept[IllegalArgumentException](BunToolchainModule.splitDep(input)) + + test("package json extras cannot replace typed dependency fields"): + val error = intercept[IllegalArgumentException]( + BunToolchainModule.mergePackageJson( + ujson.Obj("dependencies" -> ujson.Obj("react" -> "^19")), + ujson.Obj("dependencies" -> ujson.Obj("react" -> "latest")) + ) + ) + assert(error.getMessage.contains("cannot replace modeled fields: dependencies")) + + val merged = BunToolchainModule.mergePackageJson( + ujson.Obj("name" -> "app"), + ujson.Obj("scripts" -> ujson.Obj("check" -> "bun test")) + ) + assert(merged("scripts")("check").str == "bun test") diff --git a/millbun/test/src/mill/bun/SplitDepTests.scala b/millbun/test/src/mill/bun/SplitDepTests.scala index 8545a9f..cb34105 100644 --- a/millbun/test/src/mill/bun/SplitDepTests.scala +++ b/millbun/test/src/mill/bun/SplitDepTests.scala @@ -20,13 +20,13 @@ object SplitDepTests extends TestSuite { test("simple package without version") { val (name, version) = BunToolchainModule.splitDep("react") assert(name == "react") - assert(version.str == "") + assert(version.str == "latest") } test("scoped package without version") { val (name, version) = BunToolchainModule.splitDep("@types/bun") assert(name == "@types/bun") - assert(version.str == "") + assert(version.str == "latest") } test("scoped package with latest tag") {