Skip to content

ContentHashScalaJSModule: linker-wrapper via mill.scalajslib vendoring with topological hash ordering#43

Merged
Quafadas merged 26 commits into
mainfrom
copilot/add-mill-plugin-for-content-hashing
Mar 7, 2026
Merged

ContentHashScalaJSModule: linker-wrapper via mill.scalajslib vendoring with topological hash ordering#43
Quafadas merged 26 commits into
mainfrom
copilot/add-mill-plugin-for-content-hashing

Conversation

Copilot AI commented Mar 5, 2026

Copy link
Copy Markdown
Contributor
  • Remove Node.js setup step and fix mill command in copilot-setup-steps.yml
  • Add ContentHashScalaJSModule Mill plugin
  • Change file naming from [name]-[hash].js to [name].[hash].js
  • Add WASM unit test
  • Rewrite as proper linker-wrapper: override linkJs, capture output in temp dir, hash to ctx.dest
  • Fix broken cross-module link rewriting and hash ordering
  • Run scalafmt formatting
  • Remove .only tag from test causing CI failure
Original prompt

This section details on the original issue you should resolve

<issue_title>Content Hash mill plugin</issue_title>
<issue_description># 📌 Implementation Plan: Mill Plugin for Content‑Hashed Scala.js Output

🎯 Goal

Create a Mill plugin that:

  1. Intercepts Scala.js linking (both fastLinkJS and fullLinkJS).
  2. Runs the default Scala.js linker into an in‑memory output.
  3. Computes content hashes of each emitted JS file.
  4. Rewrites internal references (other module names) to use the hashed filenames.
  5. Writes the hashed files to the real Mill output directory.

Re-use Mill’s existing tasks where possible, extend from mill's existing ScalaJSModule


🧰 Background

  • Mill already provides ScalaJSModule in mill.scalajslib for Scala.js projects. ([mill-build.org]1)
  • It invokes Scala.js linking via a worker (ScalaJSWorkerImpl). ([mill-build.org][2])
  • We need to override just the linker behavior, not the entire Scala.js pipeline.

📦 Plugin Structure

mill‑contenthash‑scalajs/
├── src/
│   ├── mill/
│   │   └── ContentHashScalaJSModule.scala
│   └── plugin.scala
├── build.sc
└── README.md

🔧 Step‑by‑Step Tasks

1️⃣ Define Plugin Entrypoint

Create plugin.scala which declares the plugin:

import mill._, mill.define._
import mill.scalajslib._

object ContentHashPlugin extends mill.Module {
  object contentHash extends ContentHashScalaJSModule
}

2️⃣ Extend ScalaJSModule

Create ContentHashScalaJSModule.scala that mixes in with ScalaJSModule:

import mill.scalajslib._
import mill.scalajslib.api._

trait ContentHashScalaJSModule extends ScalaJSModule { … }

This module will override the linking step to use a custom content‑hashing linker instead of the default.


3️⃣ Add In‑Memory Output Directory

Define an in‑memory directory to capture linker output:

import scala.scalajs.linker.interface.OutputDirectory
import scala.collection.mutable

class InMemoryOutputDirectory extends OutputDirectory {
  private val files = mutable.LinkedHashMap.empty[String, Array[Byte]]

  override def writeFile(path: String, content: Array[Byte]) =
    files += path -> content

  override def exists(path: String): Boolean =
    files.contains(path)

  override def close(): Unit = ()

  def allFiles: Seq[(String, Array[Byte])] = files.toSeq
}

4️⃣ Implement ContentHashLinker Wrapper

Create a class that wraps the default Scala.js LinkerImpl:

import scala.scalajs.linker.interface._
import scala.scalajs.linker.interface.unstable._

class ContentHashLinker(underlying: LinkerImpl) extends Linker {

  override def link(moduleSet: ModuleSet,
                    output: OutputDirectory,
                    logger: Logger,
                    options: Linker.LinkerOptions): Unit = {

    val memOut = new InMemoryOutputDirectory

    // Delegate linking into memory
    underlying.link(moduleSet, memOut, logger, options)

    // Compute hashes
    val hashed = memOut.allFiles.map { case (p, bytes) =>
      val digest = java.security.MessageDigest.getInstance("SHA‑256")
      val hashStr = digest.digest(bytes).map("%02x".format(_)).mkString
      (p.stripSuffix(".js") + s"$hashStr.js", bytes)
    }

    // Write hashed, possibly rewritten, files to real output
    hashed.foreach { case (newPath, content) =>
      output.writeFile(newPath, content)
    }
  }
}

5️⃣ Override ScalaJS Linker in Mill Module

Inside ContentHashScalaJSModule, override the Scala.js linker:

override def scalaJSLinker = T {
  val default = super.scalaJSLinker()
  new ContentHashLinker(default)
}

This ensures the custom wrapper is used for all linking tasks.


🔍 Internal Reference Rewriting (Optional/Advanced)

To fully support hashed references (e.g., dynamic imports), you’ll need to scan JS output for module names and replace them with hashed ones. This is trickiest part.

Implementation notes:

  • First build a map: original → hashed filenames.
  • Then, for each JS file text, perform text transformation (regex or lightweight AST) to replace references.

Example (text‑based):

private def rewriteReferences(text: String, mapping: Map[String, String]): String =
  mapping.foldLeft(text) { case (acc, (orig, hashed)) =>
    acc.replace(orig, hashed)
  }

🧪 Testing & Validation

  1. Add a simple ScalaJSModule in a test project.
  2. Build with your plugin enabled via import ContentHashPlugin._.
  3. Run mill <module>.fullLinkJS and inspect output directory — you should see hashed JS filenames.

Your opinion.

Before starting implementation, read this plan. Analyse feasibility and double check your imlementation plan.

Write the plugin tests first, using mill's plugin test infrastructure.


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

…output

Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com>
Copilot AI changed the title [WIP] Add Mill plugin for content-hashed Scala.js output Add ContentHashScalaJSModule Mill plugin for cache-busting Scala.js output Mar 5, 2026
….yml

Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com>
Copilot AI changed the title Add ContentHashScalaJSModule Mill plugin for cache-busting Scala.js output Add ContentHashScalaJSModule Mill plugin + fix copilot setup steps Mar 5, 2026
@Quafadas

Quafadas commented Mar 5, 2026

Copy link
Copy Markdown
Owner

@copilot please change the tests and implementation to be [name].[hash].js/wasm.

Add a test for WASM too please, by configuring the module to use the experimentalWasm linker too.

…M test

Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com>

Copilot AI commented Mar 5, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please change the tests and implementation to be [name].[hash].js/wasm.

Add a test for WASM too please, by configuring the module to use the experimentalWasm linker to...

Done in commit 28b36a7:

  • Naming format changed from [name]-[hash].js to [name].[hash].js throughout the implementation and all tests.
  • WASM test added: applyContentHash preserves WASM binary alongside hashed JS — it simulates experimental-WASM linker output (a thin JS wrapper + raw WASM magic bytes), and verifies that:
    • the JS wrapper is renamed to main.[hash].js
    • the .wasm binary is copied verbatim with its original name
    • the returned Report reflects the hashed JS filename

All 12 tests pass.

…l.scalajslib package

Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com>
Copilot AI changed the title Add ContentHashScalaJSModule Mill plugin + fix copilot setup steps ContentHashScalaJSModule: linker-wrapper via mill.scalajslib vendoring Mar 5, 2026
@Quafadas

Quafadas commented Mar 5, 2026

Copy link
Copy Markdown
Owner

@copilot I've written the scaffolding for some integration tests in the plugin.integration folder.

you can run them with ./mill plugin.integration

Currently, the WASM test seems to fail.

The JS test passes, but it does the wrong thing. If you inspect the files created by the plugin, it renames them according to the hash, but it does so and breaks their internal links. Please review the hint;

The only thing that is a little bit harder is that you need to actually scan the start of each content to recover dependencies, instead of relying on the ModuleSet.

Please scan the modules and recover their dependancies. Then, in topological order do the hashing and re-write the header imports.

Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com>

Copilot AI commented Mar 5, 2026

Copy link
Copy Markdown
Contributor Author

@copilot I've written the scaffolding for some integration tests in the plugin.integration folder.

you can run them with ./mill plugin.integration
...

Fixed in commit 329422d. Two root causes were identified and addressed:

WASM test failure: The test had override def moduleSplitStyle = ModuleSplitStyle.SmallModulesFor("webapp") which the WebAssembly backend rejects (it requires a single module). Removed that override.

JS broken internal links: Scala.js ESModule output always uses "./module.js" (with ./ prefix) for cross-module imports, but rewriteJsReferences only matched "module.js" — so no replacement happened and the output files had imports pointing to original (un-hashed) filenames.

The hint about scanning dependencies and doing topological ordering was also implemented:

  1. parseJsImports extracts the dependency filenames from each file's from "./..." statements
  2. topologicalSort (Kahn's algorithm) ensures leaf files (no imports) are processed before the files that import them
  3. applyContentHash processes files in that order — each file's imports are rewritten using already-computed hashes for its dependencies, then the hash is computed on the rewritten content, so the hash reflects the actual dependency graph

Integration tests now assert that all cross-module imports in each output file resolve to a file that actually exists in the output directory (verifying links are correct).

Copilot AI changed the title ContentHashScalaJSModule: linker-wrapper via mill.scalajslib vendoring ContentHashScalaJSModule: linker-wrapper via mill.scalajslib vendoring with topological hash ordering Mar 5, 2026
@Quafadas

Quafadas commented Mar 7, 2026

Copy link
Copy Markdown
Owner

@copilot run formatting

Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com>

Copilot AI commented Mar 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot run formatting

Done in commit 1a5b95f — scalafmt reformatted sjsls/test/src/html.test.scala. All 50 sources now pass checkFormatAll.

@Quafadas Quafadas marked this pull request as ready for review March 7, 2026 09:50
@Quafadas

Quafadas commented Mar 7, 2026

Copy link
Copy Markdown
Owner

@copilot remove only tag that is causing co to fail

Co-authored-by: Quafadas <24899792+Quafadas@users.noreply.github.com>

Copilot AI commented Mar 7, 2026

Copy link
Copy Markdown
Contributor Author

@copilot remove only tag that is causing co to fail

Done in commit ec20be5 — removed .only from "with styles".only in liveServer.test.scala.

@Quafadas Quafadas merged commit 6091b89 into main Mar 7, 2026
4 checks passed
@Quafadas Quafadas deleted the copilot/add-mill-plugin-for-content-hashing branch March 7, 2026 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Content Hash mill plugin

2 participants