Skip to content

Commit 3485da0

Browse files
authored
.
1 parent 4b0a522 commit 3485da0

2 files changed

Lines changed: 112 additions & 106 deletions

File tree

plugin/src/ContentHashScalaJSModule.scala

Lines changed: 87 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -10,63 +10,57 @@ import mill.scalajslib.worker.ScalaJSWorker
1010

1111
/** A Mill module trait that adds content hashing to Scala.js linked output.
1212
*
13-
* Mix this trait into a `ScalaJSModule` to produce JS files whose names
14-
* include a SHA-256 content hash, e.g. `main.a1b2c3d4.js`. Internal
15-
* references between modules (import/require statements and
16-
* `sourceMappingURL` comments) are automatically rewritten to point at the
17-
* new hashed filenames. This allows long-lived HTTP caching while
13+
* Mix this trait into a `ScalaJSModule` to produce JS files whose names include a SHA-256 content hash, e.g.
14+
* `main.a1b2c3d4.js`. Internal references between modules (import/require statements and `sourceMappingURL` comments)
15+
* are automatically rewritten to point at the new hashed filenames. This allows long-lived HTTP caching while
1816
* guaranteeing cache busting whenever file content changes.
1917
*
2018
* @note
21-
* This trait is placed in the `mill.scalajslib` package (vendored alongside
22-
* Mill) so that it can access the `private[scalajslib]` members
23-
* [[ScalaJSWorker]] and [[ScalaJSModule.linkJs]], which are intentionally
24-
* hidden from external packages. No Mill sources are modified; we only add
25-
* a new trait that overrides one `private[scalajslib]` method.
19+
* This trait is placed in the `mill.scalajslib` package (vendored alongside Mill) so that it can access the
20+
* `private[scalajslib]` members [[ScalaJSWorker]] and [[ScalaJSModule.linkJs]], which are intentionally hidden from
21+
* external packages. No Mill sources are modified; we only add a new trait that overrides one `private[scalajslib]`
22+
* method.
2623
*
2724
* @example
28-
* {{{
25+
* {{{
2926
* object app extends ScalaJSModule with ContentHashScalaJSModule {
3027
* def scalaVersion = "3.3.6"
3128
* def scalaJSVersion = "1.19.0"
3229
* }
33-
* }}}
30+
* }}}
3431
*
35-
* Running `mill app.fastLinkJS` (or `fullLinkJS`) will produce hashed files
36-
* in the task output directory, e.g. `out/app/fastLinkJS.dest/main.a1b2c3d4.js`.
32+
* Running `mill app.fastLinkJS` (or `fullLinkJS`) will produce hashed files in the task output directory, e.g.
33+
* `out/app/fastLinkJS.dest/main.a1b2c3d4.js`.
3734
*/
3835
trait ContentHashScalaJSModule extends ScalaJSModule:
3936

40-
/** Override [[linkJs]] to capture linker output in a temporary directory,
41-
* compute SHA-256 content hashes, rewrite intra-bundle references, and
42-
* write only the hashed files to the task output directory (`ctx.dest`).
37+
/** Override [[linkJs]] to capture linker output in a temporary directory, compute SHA-256 content hashes, rewrite
38+
* intra-bundle references, and write only the hashed files to the task output directory (`ctx.dest`).
4339
*
44-
* This is the linker-wrapper approach: instead of post-processing files
45-
* that a prior task has already written to disk, we intercept at the
46-
* linking step itself — redirecting [[ScalaJSWorker.link]] output to a
47-
* temporary directory — so only the final hashed artefacts ever land in
48-
* Mill's task output directory.
40+
* This is the linker-wrapper approach: instead of post-processing files that a prior task has already written to
41+
* disk, we intercept at the linking step itself — redirecting [[ScalaJSWorker.link]] output to a temporary directory
42+
* — so only the final hashed artefacts ever land in Mill's task output directory.
4943
*
50-
* The trait must reside in `mill.scalajslib` because both [[ScalaJSWorker]]
51-
* and [[linkJs]] are declared `private[scalajslib]`.
44+
* The trait must reside in `mill.scalajslib` because both [[ScalaJSWorker]] and [[linkJs]] are declared
45+
* `private[scalajslib]`.
5246
*/
5347
private[scalajslib] override def linkJs(
54-
worker: ScalaJSWorker,
55-
toolsClasspath: Seq[PathRef],
56-
runClasspath: Seq[PathRef],
57-
mainClass: Result[String],
58-
forceOutJs: Boolean,
59-
testBridgeInit: Boolean,
60-
isFullLinkJS: Boolean,
61-
optimizer: Boolean,
62-
sourceMap: Boolean,
63-
moduleKind: ModuleKind,
64-
esFeatures: ESFeatures,
65-
moduleSplitStyle: ModuleSplitStyle,
66-
outputPatterns: OutputPatterns,
67-
minify: Boolean,
68-
importMap: Seq[ESModuleImportMapping],
69-
experimentalUseWebAssembly: Boolean
48+
worker: ScalaJSWorker,
49+
toolsClasspath: Seq[PathRef],
50+
runClasspath: Seq[PathRef],
51+
mainClass: Result[String],
52+
forceOutJs: Boolean,
53+
testBridgeInit: Boolean,
54+
isFullLinkJS: Boolean,
55+
optimizer: Boolean,
56+
sourceMap: Boolean,
57+
moduleKind: ModuleKind,
58+
esFeatures: ESFeatures,
59+
moduleSplitStyle: ModuleSplitStyle,
60+
outputPatterns: OutputPatterns,
61+
minify: Boolean,
62+
importMap: Seq[ESModuleImportMapping],
63+
experimentalUseWebAssembly: Boolean
7064
)(implicit ctx: TaskCtx): Result[Report] =
7165
val tempDir = os.temp.dir()
7266
try
@@ -89,21 +83,23 @@ trait ContentHashScalaJSModule extends ScalaJSModule:
8983
importMap = importMap,
9084
experimentalUseWebAssembly = experimentalUseWebAssembly
9185
)
92-
.map { report =>
93-
ContentHashScalaJSModule.applyContentHash(report, ctx.dest)
86+
.map {
87+
report =>
88+
ContentHashScalaJSModule.applyContentHash(report, ctx.dest)
9489
}
9590
finally os.remove.all(tempDir)
91+
end try
92+
end linkJs
9693

9794
end ContentHashScalaJSModule
9895

9996
object ContentHashScalaJSModule:
10097

101-
/** Post-process a `Report` by computing SHA-256 content hashes for every
102-
* emitted `.js` file, renaming each file to include the hash, rewriting
103-
* all intra-bundle references, and returning an updated `Report`.
98+
/** Post-process a `Report` by computing SHA-256 content hashes for every emitted `.js` file, renaming each file to
99+
* include the hash, rewriting all intra-bundle references, and returning an updated `Report`.
104100
*
105-
* `report.dest.path` is treated as the source directory (where the Scala.js
106-
* linker wrote unhashed output). Hashed files are written to `destDir`.
101+
* `report.dest.path` is treated as the source directory (where the Scala.js linker wrote unhashed output). Hashed
102+
* files are written to `destDir`.
107103
*/
108104
def applyContentHash(report: Report, destDir: os.Path): Report =
109105
val srcDir = report.dest.path
@@ -113,47 +109,56 @@ object ContentHashScalaJSModule:
113109
val jsFiles = os.list(srcDir).filter(p => os.isFile(p) && p.ext == "js")
114110

115111
// Compute hash → new name mapping for every JS file
116-
val jsHashMapping: Map[String, String] = jsFiles.map { f =>
117-
val bytes = os.read.bytes(f)
118-
val hashed = computeContentHash(bytes)
119-
val newName = s"${f.baseName}.$hashed.${f.ext}"
120-
(f.last, newName)
121-
}.toMap
112+
val jsHashMapping: Map[String, String] = jsFiles
113+
.map {
114+
f =>
115+
val bytes = os.read.bytes(f)
116+
val hashed = computeContentHash(bytes)
117+
val newName = s"${f.baseName}.$hashed.${f.ext}"
118+
(f.last, newName)
119+
}
120+
.toMap
122121

123122
// A combined mapping that also covers `.js.map` names so that
124123
// sourceMappingURL lines and source-map `"file"` fields are updated.
125-
val fullMapping: Map[String, String] = jsHashMapping.flatMap { case (orig, hashed) =>
126-
Seq(
127-
orig -> hashed,
128-
s"$orig.map" -> s"$hashed.map"
129-
)
124+
val fullMapping: Map[String, String] = jsHashMapping.flatMap {
125+
case (orig, hashed) =>
126+
Seq(
127+
orig -> hashed,
128+
s"$orig.map" -> s"$hashed.map"
129+
)
130130
}
131131

132132
// Write hashed JS files with rewritten intra-bundle references
133-
jsFiles.foreach { f =>
134-
val originalContent = os.read(f)
135-
val rewritten = rewriteJsReferences(originalContent, fullMapping)
136-
os.write(destDir / jsHashMapping(f.last), rewritten.getBytes("UTF-8"))
133+
jsFiles.foreach {
134+
f =>
135+
val originalContent = os.read(f)
136+
val rewritten = rewriteJsReferences(originalContent, fullMapping)
137+
os.write(destDir / jsHashMapping(f.last), rewritten.getBytes("UTF-8"))
137138
}
138139

139140
// Copy / rename remaining files (source maps, wasm, etc.)
140141
val copiedSoFar = jsHashMapping.keySet
141142
os.list(srcDir)
142143
.filter(p => os.isFile(p) && !copiedSoFar.contains(p.last))
143-
.foreach { f =>
144-
val newName = fullMapping.getOrElse(f.last, f.last)
145-
os.copy(f, destDir / newName)
144+
.foreach {
145+
f =>
146+
val newName = fullMapping.getOrElse(f.last, f.last)
147+
os.copy(f, destDir / newName)
146148
}
147149

148150
// Build an updated Report that names the hashed files
149-
val updatedModules = report.publicModules.map { m =>
150-
Report.Module(
151-
moduleID = m.moduleID,
152-
jsFileName = jsHashMapping.getOrElse(m.jsFileName, m.jsFileName),
153-
sourceMapName = m.sourceMapName.map(sm => fullMapping.getOrElse(sm, sm)),
154-
moduleKind = m.moduleKind
155-
)
156-
}
151+
val updatedModules = report
152+
.publicModules
153+
.map {
154+
m =>
155+
Report.Module(
156+
moduleID = m.moduleID,
157+
jsFileName = jsHashMapping.getOrElse(m.jsFileName, m.jsFileName),
158+
sourceMapName = m.sourceMapName.map(sm => fullMapping.getOrElse(sm, sm)),
159+
moduleKind = m.moduleKind
160+
)
161+
}
157162

158163
Report(updatedModules, PathRef(destDir))
159164
end applyContentHash
@@ -162,21 +167,23 @@ object ContentHashScalaJSModule:
162167
def computeContentHash(bytes: Array[Byte]): String =
163168
val digest = MessageDigest.getInstance("SHA-256")
164169
digest.digest(bytes).take(8).map("%02x".format(_)).mkString
170+
end computeContentHash
165171

166-
/** Replace every occurrence of an original filename (quoted or in a
167-
* `sourceMappingURL` comment) with the corresponding hashed filename.
172+
/** Replace every occurrence of an original filename (quoted or in a `sourceMappingURL` comment) with the
173+
* corresponding hashed filename.
168174
*
169175
* Handles:
170176
* - Double-quoted import/require arguments: `"original.js"`
171177
* - Single-quoted import/require arguments: `'original.js'`
172178
* - Inline source-map comments: `//# sourceMappingURL=original.js.map`
173179
*/
174180
def rewriteJsReferences(content: String, mapping: Map[String, String]): String =
175-
mapping.foldLeft(content) { case (acc, (orig, hashed)) =>
176-
acc
177-
.replace(s""""$orig"""", s""""$hashed"""")
178-
.replace(s"'$orig'", s"'$hashed'")
179-
.replace(s"sourceMappingURL=$orig", s"sourceMappingURL=$hashed")
181+
mapping.foldLeft(content) {
182+
case (acc, (orig, hashed)) =>
183+
acc
184+
.replace(s""""$orig"""", s""""$hashed"""")
185+
.replace(s"'$orig'", s"'$hashed'")
186+
.replace(s"sourceMappingURL=$orig", s"sourceMappingURL=$hashed")
180187
}
181188

182189
end ContentHashScalaJSModule

0 commit comments

Comments
 (0)