Skip to content

Commit b4907cb

Browse files
authored
Merge pull request #35 from Quafadas/preloadInternal2
Preload "internal-xxx" modules to accelerate browser loading
2 parents 7deb64d + ef315d0 commit b4907cb

File tree

6 files changed

+171
-89
lines changed

6 files changed

+171
-89
lines changed

justfile

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ compile:
1010
mill project.compile
1111

1212
test:
13-
mill project.test
13+
mill project.test.testOnly SafariSuite && mill project.test.testOnly RoutesSuite && mill project.test.testOnly UtilityFcs
1414

1515
checkOpts:
1616
mill project.run --help
@@ -33,7 +33,14 @@ jvmLinker:
3333
mill project.run --build-tool scala-cli --project-dir /Users/simon/Code/helloScalaJs --out-dir /Users/simon/Code/helloScalaJs/out --extra-build-args --js-cli-on-jvm --port 3007
3434

3535
serveMill:
36-
mill project.run --build-tool mill --project-dir /Users/simon/Code/mill-full-stack/mill-full-stack --styles-dir /Users/simon/Code/mill-full-stack/mill-full-stack/frontend/ui/assets --out-dir /Users/simon/Code/mill-full-stack/mill-full-stack/out/frontend/fastLinkJS.dest --log-level info --proxy-target-port 8080 --proxy-prefix-path /api --port 3007 --mill-module-name frontend
36+
mill -j 0 project.run --build-tool mill --project-dir /Users/simon/Code/mill-full-stack/mill-full-stack \
37+
--path-to-index-html /Users/simon/Code/mill-full-stack/mill-full-stack/frontend/ui \
38+
--out-dir /Users/simon/Code/mill-full-stack/mill-full-stack/out/frontend/fastLinkJS.dest \
39+
--log-level info \
40+
--port 3007 \
41+
--mill-module-name frontend \
42+
--proxy-prefix-path /api \
43+
--proxy-target-port 8080
3744

3845
setupPlaywright:
3946
cs launch com.microsoft.playwright:playwright:1.41.1 -M "com.microsoft.playwright.CLI" -- install --with-deps

project/src/htmlGen.scala

Lines changed: 43 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import scalatags.Text.all.*
22

33
import fs2.io.file.Path
4+
import cats.effect.kernel.Ref
5+
import cats.effect.IO
46

57
def lessStyle(withStyles: Boolean): Seq[Modifier] =
68
if withStyles then
@@ -49,22 +51,17 @@ def injectRefreshScript(template: String) =
4951

5052
end injectRefreshScript
5153

52-
def injectModulePreloads(modules: Seq[(Path, String)], template: String) =
53-
val modulesStrings =
54-
for
55-
m <- modules
56-
if m._1.toString.endsWith(".js")
57-
yield link(rel := "modulepreload", href := s"${m._1}?hash=${m._2}").render
54+
def injectModulePreloads(ref: Ref[IO, Map[String, String]], template: String) =
55+
val preloads = makeInternalPreloads(ref)
56+
preloads.map: modules =>
57+
val modulesStringsInject = modules.mkString("\n", "\n", "\n")
58+
val headCloseTag = "</head>"
59+
val insertionPoint = template.indexOf(headCloseTag)
5860

59-
val modulesStringsInject = modulesStrings.mkString("\n", "\n", "\n")
60-
val headCloseTag = "</head>"
61-
val insertionPoint = template.indexOf(headCloseTag)
62-
63-
val newHtmlContent = template.substring(0, insertionPoint) +
64-
modulesStringsInject +
65-
template.substring(insertionPoint)
66-
67-
newHtmlContent
61+
val newHtmlContent = template.substring(0, insertionPoint) +
62+
modulesStringsInject +
63+
template.substring(insertionPoint)
64+
newHtmlContent
6865

6966
end injectModulePreloads
7067

@@ -93,17 +90,35 @@ def makeHeader(modules: Seq[(Path, String)], withStyles: Boolean) =
9390
)
9491
end makeHeader
9592

96-
def vanillaTemplate(withStyles: Boolean) = html(
97-
head(
98-
meta(
99-
httpEquiv := "Cache-control",
100-
content := "no-cache, no-store, must-revalidate"
93+
def makeInternalPreloads(ref: Ref[IO, Map[String, String]]) =
94+
val keys = ref.get.map(_.toSeq)
95+
keys.map {
96+
modules =>
97+
for
98+
m <- modules
99+
if m._1.toString.endsWith(".js") && m._1.toString.startsWith("internal")
100+
yield link(rel := "modulepreload", href := s"${m._1}?h=${m._2}")
101+
end for
102+
}
103+
104+
end makeInternalPreloads
105+
106+
def vanillaTemplate(withStyles: Boolean, ref: Ref[IO, Map[String, String]]) =
107+
val preloads = makeInternalPreloads(ref)
108+
preloads.map: modules =>
109+
html(
110+
head(
111+
meta(
112+
httpEquiv := "Cache-control",
113+
content := "no-cache, no-store, must-revalidate",
114+
modules
115+
)
116+
),
117+
body(
118+
lessStyle(withStyles),
119+
script(src := "/main.js", `type` := "module"),
120+
div(id := "app"),
121+
refreshScript
122+
)
101123
)
102-
),
103-
body(
104-
lessStyle(withStyles),
105-
script(src := "/main.js", `type` := "module"),
106-
div(id := "app"),
107-
refreshScript
108-
)
109-
)
124+
end vanillaTemplate

project/src/middleware/ETagMiddleware.scala

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,25 @@ object ETagMiddleware:
2727
map.get(req.uri.path.toString.drop(1)) match
2828
case Some(hash) =>
2929
logger.debug(req.uri.toString) >>
30-
IO(resp.putHeaders(Header.Raw(ci"ETag", hash)))
30+
IO(
31+
resp.putHeaders(
32+
Header.Raw(ci"ETag", hash),
33+
Header.Raw(ci"Cache-control", "Must-Revalidate"),
34+
Header.Raw(ci"Cache-control", "No-cache"),
35+
Header.Raw(ci"Cache-control", "max-age=0"),
36+
Header.Raw(ci"Cache-control", "public")
37+
)
38+
)
3139
case None =>
3240
logger.debug(req.uri.toString) >>
33-
IO(resp)
41+
IO(
42+
resp.putHeaders(
43+
Header.Raw(ci"Cache-control", "Must-Revalidate"),
44+
Header.Raw(ci"Cache-control", "No-cache"),
45+
Header.Raw(ci"Cache-control", "max-age=0"),
46+
Header.Raw(ci"Cache-control", "public")
47+
)
48+
)
3449
end match
3550
}
3651
end respondWithEtag

project/src/routes.scala

Lines changed: 77 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import org.http4s.server.middleware.Logger
1919
import org.http4s.server.staticcontent.*
2020
import org.http4s.server.staticcontent.FileService
2121
import org.typelevel.ci.CIStringSyntax
22+
import org.http4s.EntityBody
2223

2324
import fs2.*
2425
import fs2.concurrent.Topic
@@ -36,6 +37,7 @@ import cats.effect.kernel.Resource
3637
import cats.syntax.all.*
3738

3839
import _root_.io.circe.syntax.EncoderOps
40+
import org.http4s.Http
3941

4042
def routes[F[_]: Files: MonadThrow](
4143
stringPath: String,
@@ -70,8 +72,8 @@ def routes[F[_]: Files: MonadThrow](
7072
ref
7173
)(logger)
7274

73-
val hashFalse = vanillaTemplate(false).render.hashCode.toString
74-
val hashTrue = vanillaTemplate(true).render.hashCode.toString
75+
// val hashFalse = vanillaTemplate(false).render.hashCode.toString
76+
// val hashTrue = vanillaTemplate(true).render.hashCode.toString
7577
val zdt = ZonedDateTime.now()
7678

7779
def userBrowserCacheHeaders(resp: Response[IO], lastModZdt: ZonedDateTime, injectStyles: Boolean) =
@@ -97,73 +99,95 @@ def routes[F[_]: Files: MonadThrow](
9799
object StaticHtmlMiddleware:
98100
def apply(service: HttpRoutes[IO], injectStyles: Boolean)(logger: Scribe[IO]): HttpRoutes[IO] = Kleisli {
99101
(req: Request[IO]) =>
100-
req.headers.get(ci"If-None-Match").map(_.toList) match
101-
case Some(h :: Nil) if h.value == hashFalse => OptionT.liftF(IO(Response[IO](Status.NotModified)))
102-
case Some(h :: Nil) if h.value == hashTrue => OptionT.liftF(IO(Response[IO](Status.NotModified)))
103-
case _ => service(req).semiflatMap(userBrowserCacheHeaders(_, zdt, injectStyles))
104-
end match
105-
102+
service(req).semiflatMap(userBrowserCacheHeaders(_, zdt, injectStyles))
106103
}
107104

108105
end StaticHtmlMiddleware
109106

110-
def generatedIndexHtml(injectStyles: Boolean) =
107+
def generatedIndexHtml(injectStyles: Boolean, modules: Ref[IO, Map[String, String]]) =
111108
StaticHtmlMiddleware(
112109
HttpRoutes.of[IO] {
113110
case req @ GET -> Root =>
114111
logger.trace("Generated index.html") >>
115-
IO(
112+
vanillaTemplate(injectStyles, modules).map: html =>
116113
Response[IO]()
117-
.withEntity(vanillaTemplate(injectStyles))
114+
.withEntity(html)
118115
.withHeaders(
119116
Header.Raw(ci"Cache-Control", "no-cache"),
120117
Header.Raw(
121118
ci"ETag",
122-
injectStyles match
123-
case true => hashTrue
124-
case false => hashFalse
119+
html.hashCode.toString
125120
),
126121
Header.Raw(ci"Last-Modified", formatter.format(zdt)),
127122
Header.Raw(
128123
ci"Expires",
129124
httpCacheFormat(ZonedDateTime.ofInstant(Instant.now().plusSeconds(10000000), ZoneId.of("GMT")))
130125
)
131126
)
132-
)
127+
133128
},
134129
injectStyles
135130
)(logger).combineK(
136131
StaticHtmlMiddleware(
137132
HttpRoutes.of[IO] {
138133
case GET -> Root / "index.html" =>
139-
IO {
140-
Response[IO]().withEntity(vanillaTemplate(injectStyles))
141-
}
134+
vanillaTemplate(injectStyles, modules).map: html =>
135+
Response[IO]().withEntity(html)
136+
142137
},
143138
injectStyles
144139
)(logger)
145140
)
146141

147142
// val formatter = DateTimeFormatter.RFC_1123_DATE_TIME
148-
val staticAssetRoutes: HttpRoutes[IO] = indexOpts match
149-
case None => generatedIndexHtml(injectStyles = false)
143+
def staticAssetRoutes(modules: Ref[IO, Map[String, String]]): HttpRoutes[IO] = indexOpts match
144+
case None => generatedIndexHtml(injectStyles = false, modules)
150145

151146
case Some(IndexHtmlConfig.IndexHtmlPath(path)) =>
152-
StaticMiddleware(
153-
Router(
154-
"" -> fileService[IO](FileService.Config(path.toString()))
155-
),
156-
fs2.io.file.Path(path.toString())
157-
)(logger)
147+
// StaticMiddleware(
148+
// Router(
149+
// "" ->
150+
HttpRoutes
151+
.of[IO] {
152+
case req @ GET -> Root =>
153+
StaticFile
154+
.fromPath[IO](path / "index.html")
155+
.getOrElseF(NotFound())
156+
.flatMap {
157+
f =>
158+
f.body
159+
.through(text.utf8.decode)
160+
.compile
161+
.string
162+
.flatMap {
163+
body =>
164+
for str <- injectModulePreloads(modules, body)
165+
yield
166+
val bytes = str.getBytes()
167+
f.withEntity(bytes)
168+
Response[IO]().withEntity(bytes).putHeaders("Content-Type" -> "text/html")
169+
170+
}
171+
}
172+
173+
}
174+
.combineK(
175+
StaticMiddleware(
176+
Router(
177+
"" -> fileService[IO](FileService.Config(path.toString()))
178+
),
179+
fs2.io.file.Path(path.toString())
180+
)(logger)
181+
)
158182

159183
case Some(IndexHtmlConfig.StylesOnly(stylesPath)) =>
160184
NoCacheMiddlware(
161185
Router(
162186
"" -> fileService[IO](FileService.Config(stylesPath.toString()))
163187
)
164-
)(logger).combineK(generatedIndexHtml(injectStyles = true))
188+
)(logger).combineK(generatedIndexHtml(injectStyles = true, modules))
165189

166-
val clientSpaRoutes: HttpRoutes[IO] =
190+
def clientSpaRoutes(modules: Ref[IO, Map[String, String]]): HttpRoutes[IO] =
167191
clientRoutingPrefix match
168192
case None => HttpRoutes.empty[IO]
169193
case Some(spaRoute) =>
@@ -173,9 +197,9 @@ def routes[F[_]: Files: MonadThrow](
173197
StaticHtmlMiddleware(
174198
HttpRoutes.of[IO] {
175199
case req @ GET -> root /: path =>
176-
IO(
177-
Response[IO]().withEntity(vanillaTemplate(false))
178-
)
200+
vanillaTemplate(false, modules).map: html =>
201+
Response[IO]().withEntity(html)
202+
179203
},
180204
false
181205
)(logger)
@@ -184,9 +208,8 @@ def routes[F[_]: Files: MonadThrow](
184208
StaticHtmlMiddleware(
185209
HttpRoutes.of[IO] {
186210
case GET -> root /: spaRoute /: path =>
187-
IO(
188-
Response[IO]().withEntity(vanillaTemplate(true))
189-
)
211+
vanillaTemplate(true, modules).map: html =>
212+
Response[IO]().withEntity(html)
190213
},
191214
true
192215
)(logger)
@@ -195,7 +218,24 @@ def routes[F[_]: Files: MonadThrow](
195218
StaticFileMiddleware(
196219
HttpRoutes.of[IO] {
197220
case req @ GET -> spaRoute /: path =>
198-
StaticFile.fromPath(dir / "index.html", Some(req)).getOrElseF(NotFound())
221+
StaticFile
222+
.fromPath(dir / "index.html", Some(req))
223+
.getOrElseF(NotFound())
224+
.flatMap {
225+
f =>
226+
f.body
227+
.through(text.utf8.decode)
228+
.compile
229+
.string
230+
.flatMap: body =>
231+
for str <- injectModulePreloads(modules, body)
232+
yield
233+
val bytes = str.getBytes()
234+
f.withEntity(bytes)
235+
f
236+
237+
}
238+
199239
},
200240
dir / "index.html"
201241
)(logger)
@@ -215,8 +255,8 @@ def routes[F[_]: Files: MonadThrow](
215255
refreshRoutes
216256
.combineK(linkedAppWithCaching)
217257
.combineK(proxyRoutes)
218-
.combineK(clientSpaRoutes)
219-
.combineK(staticAssetRoutes)
258+
.combineK(clientSpaRoutes(ref))
259+
.combineK(staticAssetRoutes(ref))
220260
)
221261

222262
clientRoutingPrefix.fold(IO.unit)(s => logger.trace(s"client spa at : $s")).toResource >>

project/test/src/UtilityFcts.scala

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
class UtilityFcs extends munit.FunSuite:
1+
import munit.CatsEffectSuite
2+
import cats.effect.kernel.Ref
3+
import cats.effect.IO
4+
5+
class UtilityFcs extends CatsEffectSuite:
26

37
test("That we actually inject the preloads ") {
48

@@ -14,20 +18,17 @@ class UtilityFcs extends munit.FunSuite:
1418

1519
}
1620

17-
test(" That we can inject preloads into a template") {
18-
val html = injectModulePreloads(
19-
modules = Seq(
20-
(fs2.io.file.Path("main.js"), "hash")
21-
),
22-
template = "<html><head></head></html>"
23-
)
24-
assert(html.contains("hash"))
25-
assertEquals(
26-
html,
27-
"""<html><head>
28-
<link rel="modulepreload" href="main.js?hash=hash" />
29-
</head></html>"""
30-
)
21+
ResourceFunFixture {
22+
for
23+
ref <- Ref.of[IO, Map[String, String]](Map.empty).toResource
24+
_ <- ref.update(_.updated("internal.js", "hash")).toResource
25+
yield ref
26+
}.test("That we can make internal preloads") {
27+
ref =>
28+
val html = injectModulePreloads(ref, "<html><head></head><body></body></html>")
29+
html.map: html =>
30+
assert(html.contains("modulepreload"))
31+
assert(html.contains("internal.js"))
3132
}
3233

3334
test(" That we can inject a refresh script") {

0 commit comments

Comments
 (0)