├── playwrightVersion.mill ├── sjsls ├── test │ ├── resources │ │ ├── cat.webp │ │ └── dog.webp │ └── src │ │ ├── test_extensions.scala │ │ ├── dezombify.test.scala │ │ ├── UtilityFcts.scala │ │ ├── spaRoutesSuite.scala │ │ ├── liveServer.test.scala │ │ └── RoutesSpec.scala ├── src │ ├── CliValidationError.scala │ ├── sseReload.scala │ ├── BuildTool.scala │ ├── middleware │ │ ├── traceLogger.scala │ │ ├── noCache.middleware.scala │ │ ├── staticFileMiddleware.scala │ │ ├── staticpathMiddleware.scala │ │ └── ETagMiddleware.scala │ ├── makeProxyConfig.scala │ ├── hasher.scala │ ├── openBrowser.scala │ ├── LiveServerConfig.scala │ ├── staticHtmlMiddleware.scala │ ├── refreshRoute.scala │ ├── buildSpaRoute.scala │ ├── routes.scala │ ├── staticRoutes.scala │ ├── staticWatcher.scala │ ├── CliOpts.scala │ ├── dezombify.scala │ ├── htmlGen.scala │ ├── buildRunner.scala │ └── liveServer.scala └── package.mill ├── .gitignore ├── .vscode ├── settings.json └── launch.json ├── routes ├── src │ ├── IndexHtmlConfig.scala │ ├── proxyConfig.scala │ ├── appRoute.scala │ ├── proxyRoutes.scala │ ├── buildRoutes.scala │ └── proxyHttp.scala ├── package.mill └── test │ └── src │ └── build.routes.test.scala ├── site ├── docs │ ├── Configuration │ │ ├── library.md │ │ └── config.md │ ├── caveats.md │ ├── motivation.md │ ├── index.md │ ├── routes.md │ ├── Blog │ │ ├── 2024-07-17-OnCdns.md │ │ └── 2024-05-22-Viteless.md │ ├── advantages.md │ ├── bundler.md │ └── deployment.md └── package.mill ├── .devcontainer ├── features │ └── mill │ │ ├── devcontainer-feature.json │ │ └── install.sh └── devcontainer.json ├── .scalafmt.conf ├── .scalafix.conf ├── readme.md ├── .github └── workflows │ ├── container.yml │ └── ci.yml ├── plugin ├── package.mill └── src │ └── refresh_plugin.scala ├── justfile └── mill /playwrightVersion.mill: -------------------------------------------------------------------------------- 1 | package build 2 | 3 | val pwV = "1.51.0" -------------------------------------------------------------------------------- /sjsls/test/resources/cat.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quafadas/live-server-scala-cli-js/HEAD/sjsls/test/resources/cat.webp -------------------------------------------------------------------------------- /sjsls/test/resources/dog.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Quafadas/live-server-scala-cli-js/HEAD/sjsls/test/resources/dog.webp -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .scala-build 2 | .metals 3 | .bsp 4 | native 5 | testDir 6 | .bsp 7 | out 8 | .bloop 9 | .DS_Store 10 | .vscode/mcp.json 11 | -------------------------------------------------------------------------------- /sjsls/src/CliValidationError.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import scala.util.control.NoStackTrace 4 | 5 | private case class CliValidationError(message: String) extends NoStackTrace 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.watcherExclude": { 3 | "**/target": true 4 | }, 5 | "remote.extensionKind": { 6 | "scalameta.metals": [ 7 | "workspace" 8 | ] 9 | } 10 | 11 | } -------------------------------------------------------------------------------- /sjsls/test/src/test_extensions.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | extension (path: os.Path) 4 | def toFs2: fs2.io.file.Path = 5 | fs2.io.file.Path.fromNioPath(path.toNIO) 6 | end extension 7 | -------------------------------------------------------------------------------- /routes/src/IndexHtmlConfig.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import fs2.io.file.Path 4 | 5 | enum IndexHtmlConfig: 6 | case IndexHtmlPath(path: Path) 7 | case StylesOnly(path: Path) 8 | end IndexHtmlConfig 9 | -------------------------------------------------------------------------------- /site/docs/Configuration/library.md: -------------------------------------------------------------------------------- 1 | # Library 2 | 3 | The LiveServerConfig can also accept an `fs2.Topic` as a parameter. This allows any tool which can instantiate an `fs2.Topic` to emit a pulse, which will refresh the client. Have a look at the mill plugin code for details. -------------------------------------------------------------------------------- /.devcontainer/features/mill/devcontainer-feature.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mill", 3 | "version": "1.0.0", 4 | "name": "Mill, Coursier, Metals", 5 | "description": "Some OSS scala utils", 6 | "installsAfter": [ 7 | "ghcr.io/devcontainers/features/java:1" 8 | ], 9 | "options": {} 10 | } 11 | -------------------------------------------------------------------------------- /sjsls/src/sseReload.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import _root_.io.circe.* 4 | 5 | sealed trait FrontendEvent derives Encoder.AsObject 6 | 7 | case class KeepAlive() extends FrontendEvent derives Encoder.AsObject 8 | case class PageRefresh() extends FrontendEvent derives Encoder.AsObject 9 | -------------------------------------------------------------------------------- /sjsls/src/BuildTool.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | sealed trait BuildTool(val invokedVia: String) 4 | class ScalaCli 5 | extends BuildTool( 6 | if isWindows then "scala-cli.bat" else "scala-cli" 7 | ) 8 | class Mill 9 | extends BuildTool( 10 | if isWindows then "mill.bat" else "mill" 11 | ) 12 | 13 | class NoBuildTool extends BuildTool("") 14 | -------------------------------------------------------------------------------- /sjsls/src/middleware/traceLogger.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import org.http4s.server.middleware.Logger 4 | 5 | import scribe.Scribe 6 | 7 | import cats.effect.IO 8 | 9 | def traceLoggerMiddleware(logger: Scribe[IO]) = Logger.httpRoutes[IO]( 10 | logHeaders = true, 11 | logBody = true, 12 | redactHeadersWhen = _ => false, 13 | logAction = Some((msg: String) => logger.trace(msg)) 14 | ) 15 | -------------------------------------------------------------------------------- /sjsls/src/makeProxyConfig.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import com.comcast.ip4s.Port 4 | 5 | private[sjsls] def makeProxyConfig(frontendPort: Port, proxyTo: Port, matcher: String) = s""" 6 | http: 7 | servers: 8 | - listen: $frontendPort 9 | serverNames: 10 | - localhost 11 | locations: 12 | - matcher: $matcher 13 | proxyPass: http://$$backend 14 | 15 | upstreams: 16 | - name: backend 17 | servers: 18 | - host: localhost 19 | port: $proxyTo 20 | weight: 5 21 | """ 22 | -------------------------------------------------------------------------------- /site/package.mill: -------------------------------------------------------------------------------- 1 | package build.site 2 | 3 | import mill._, scalalib._ 4 | import mill.scalajslib.api._ 5 | import io.github.quafadas.millSite._ 6 | import mill.util.VcsVersion 7 | import build.V 8 | 9 | object `package` extends SiteModule { 10 | 11 | def moduleDeps = Seq(build.sjsls, build.routes) 12 | 13 | override def unidocTitle = "Scala JS Live Server API" 14 | 15 | override def repoLink = "https://github.com/Quafadas/live-server-scala-cli-js" 16 | 17 | override def latestVersion = VcsVersion.vcsState().lastTag.getOrElse("0.0.0").replace("v", "") 18 | 19 | } -------------------------------------------------------------------------------- /site/docs/caveats.md: -------------------------------------------------------------------------------- 1 | # Caveats 2 | 3 | Much of the frontend world is setup assuming that you in fact, are using a bundler. 4 | 5 | That means that not _all_ stuff on npm, can in fact be easily consumer through ESModules. 6 | 7 | Examples; 8 | 9 | https://github.com/SAP/ui5-webcomponents/discussions/9487 10 | 11 | It is usually possible to work around such limitations, but it is not pain free. 12 | 13 | I strongly suspect though that as time marches formward ESModules will become the norm, and this limitation will become irrelevant. 14 | 15 | ## Support 16 | 17 | This project is not backed by any power larger than my curiosity. -------------------------------------------------------------------------------- /site/docs/motivation.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | I'm a big scala JS fan. However, the reliance on vite for the dev loop / deployment complexified my build piplines to the point where full stack wasn't fun for me anymore. I found maintaining _both_ a JVM setup and a node setup was annoying locally. 4 | 5 | And then I had do it again in CI. So, intead of giving up on scala JS and going to write typescript, I figured it would be way more fun to simply try and go 100% scala - zero friction save, link, refresh. 6 | 7 | I wanted to break the dependance on node / npm completely whilst retaining a sane developer experience for browser based scala-js development. -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | version = "3.9.9" 2 | project.git = true 3 | 4 | runner.dialect = scala3 5 | 6 | rewrite.scala3.convertToNewSyntax = true 7 | rewrite.rules = [RedundantBraces] 8 | rewrite.scala3.removeOptionalBraces = yes 9 | runner.dialectOverride.withAllowToplevelTerms = true 10 | runner.dialectOverride.withAllowEndMarker = true 11 | runner.dialectOverride.allowSignificantIndentation = true 12 | rewrite.scala3.countEndMarkerLines = lastBlockOnly 13 | rewrite.scala3.insertEndMarkerMinLines = 1 14 | newlines.beforeCurlyLambdaParams = multiline 15 | newlines.avoidForSimpleOverflow = [tooLong] 16 | newlines.selectChains = "unfold" 17 | maxColumn = 120 -------------------------------------------------------------------------------- /.scalafix.conf: -------------------------------------------------------------------------------- 1 | rules = [ 2 | LeakingImplicitClassVal, 3 | OrganizeImports, 4 | RedundantSyntax, 5 | LeakingImplicitClassVal, 6 | NoAutoTupling, 7 | DisableSyntax, 8 | NoValInForComprehension, 9 | RemoveUnused 10 | ] 11 | 12 | OrganizeImports { 13 | coalesceToWildcardImportThreshold = 5 14 | expandRelative = true 15 | groupedImports = Explode 16 | groups = [ 17 | "java", 18 | "javax", 19 | "scala", 20 | "org", 21 | "com", 22 | "fs2", 23 | "scribe", 24 | "cats", 25 | "_root_" 26 | 27 | ], 28 | importsOrder = Ascii 29 | removeUnused = true 30 | } 31 | OrganizeImports.targetDialect = Scala3 -------------------------------------------------------------------------------- /.devcontainer/features/mill/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | # Put millw on the path, so we can call 'mill' from the command line. 6 | curl -L -o /usr/local/bin/mill https://repo1.maven.org/maven2/com/lihaoyi/mill-dist/1.0.3/mill-dist-1.0.3-mill.sh && chmod +x /usr/local/bin/mill 7 | 8 | # Install Coursier - can call cs from command line 9 | curl -fL "https://github.com/coursier/launchers/raw/master/cs-x86_64-pc-linux.gz" | gzip -d > /usr/local/bin/cs && chmod +x /usr/local/bin/cs 10 | 11 | # Install metals - should prevent dependancy downloads on container start 12 | cs install metals 13 | cs launch com.microsoft.playwright:playwright:1.51.0 -M "com.microsoft.playwright.CLI" -- install --with-deps -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "scala", 9 | "request": "attach", 10 | "name": "Attach debugger", 11 | // name of the module that is being debugging 12 | "buildTarget": "sjsls.test", 13 | // Host of the jvm to connect to 14 | "hostName": "localhost", 15 | // Port to connect to 16 | "port": 5005 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /routes/package.mill: -------------------------------------------------------------------------------- 1 | package build.routes 2 | 3 | import build.* 4 | import mill._ 5 | import mill.scalalib._ 6 | import mill.scalalib.publish._ 7 | 8 | object `package` extends FormatFixPublish { 9 | 10 | def scalaVersion: T[String] = V.scalaLts 11 | 12 | def mvnDeps = Seq( 13 | mvn"org.http4s::http4s-core:${V.http4sVersion}", 14 | mvn"org.http4s::http4s-client:${V.http4sVersion}", 15 | mvn"org.http4s::http4s-server:${V.http4sVersion}", 16 | mvn"org.http4s::http4s-dsl::${V.http4sVersion}", 17 | mvn"com.outr::scribe-cats::3.15.0" 18 | ) 19 | 20 | override def artifactName = "frontend-routes" 21 | 22 | object test extends Testy with ScalaTests{ 23 | def mvnDeps = super.mvnDeps() ++ sjsls.mvnDeps() 24 | } 25 | 26 | } -------------------------------------------------------------------------------- /sjsls/src/hasher.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import fs2.io.file.* 4 | 5 | import cats.effect.IO 6 | 7 | // TODO: Use last modified time once scala-cli stops 8 | // copy pasting the files from a temporary directory 9 | // and performs linking in place 10 | def fileLastModified(filePath: fs2.io.file.Path): IO[Long] = 11 | fs2.io.file.Files[IO].getLastModifiedTime(filePath).map(_.toSeconds) 12 | 13 | // def fileLastModified(filePath: Path): IO[FiniteDuration] = 14 | // fs2.io.file.Files[IO].getLastModifiedTime(fs2.io.file.Path(filePath.toString())) 15 | 16 | def fileHash(filePath: fs2.io.file.Path): IO[String] = 17 | fs2.io.file.Files[IO].readAll(filePath).through(fs2.hash.md5).through(fs2.text.hex.encode).compile.string 18 | -------------------------------------------------------------------------------- /sjsls/src/middleware/noCache.middleware.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import org.http4s.Header 4 | import org.http4s.HttpRoutes 5 | import org.http4s.Request 6 | import org.typelevel.ci.CIStringSyntax 7 | 8 | import scribe.Scribe 9 | 10 | import cats.data.Kleisli 11 | import cats.data.OptionT 12 | import cats.effect.* 13 | import cats.syntax.all.* 14 | 15 | object NoCacheMiddlware: 16 | 17 | def apply(service: HttpRoutes[IO])(logger: Scribe[IO]): HttpRoutes[IO] = Kleisli { 18 | (req: Request[IO]) => 19 | OptionT.liftF(logger.trace("No cache middleware")) >> 20 | OptionT.liftF(logger.trace(req.toString)) >> 21 | service(req).map { 22 | resp => 23 | resp.putHeaders( 24 | Header.Raw(ci"Cache-Control", "no-cache") 25 | ) 26 | } 27 | } 28 | 29 | end NoCacheMiddlware 30 | -------------------------------------------------------------------------------- /sjsls/src/openBrowser.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.awt.Desktop 4 | import java.net.URI 5 | 6 | import com.comcast.ip4s.Port 7 | 8 | import scribe.Scribe 9 | 10 | import cats.effect.IO 11 | 12 | def openBrowser(openBrowserAt: Option[String], port: Port)(logger: Scribe[IO]): IO[Unit] = 13 | openBrowserAt match 14 | case None => logger.trace("No openBrowserAt flag set, so no browser will be opened") 15 | case Some(value) => 16 | val openAt = URI(s"http://localhost:$port$value") 17 | logger.info(s"Attempting to open browser to $openAt") >> 18 | IO( 19 | if Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE) then 20 | IO(Desktop.getDesktop().browse(openAt)) 21 | else logger.error("Desktop not supported, so can't open browser") 22 | ).flatten 23 | -------------------------------------------------------------------------------- /sjsls/src/LiveServerConfig.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import com.comcast.ip4s.Port 4 | 5 | import fs2.concurrent.Topic 6 | 7 | import cats.effect.IO 8 | 9 | case class LiveServerConfig( 10 | baseDir: Option[String], 11 | outDir: Option[String] = None, 12 | port: Port, 13 | proxyPortTarget: Option[Port] = None, 14 | proxyPathMatchPrefix: Option[String] = None, 15 | clientRoutingPrefix: Option[String] = None, 16 | logLevel: String = "info", 17 | buildTool: BuildTool = ScalaCli(), 18 | openBrowserAt: String, 19 | preventBrowserOpen: Boolean = false, 20 | extraBuildArgs: List[String] = List.empty, 21 | millModuleName: Option[String] = None, 22 | stylesDir: Option[String] = None, 23 | indexHtmlTemplate: Option[String] = None, 24 | buildToolInvocation: Option[String] = None, 25 | injectPreloads: Boolean = false, 26 | dezombify: Boolean = true, 27 | customRefresh: Option[Topic[IO, Unit]] = None 28 | ) 29 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | See [docs](https://quafadas.github.io/live-server-scala-cli-js/) 2 | 3 | # Scala JS live server TL;DR 4 | 5 | > Show me scala JS - I have 20 seconds. 6 | 7 | Paste this into your terminal and hit enter. 8 | 9 | ```sh 10 | 11 | scala-cli --version && \ 12 | cs version && \ 13 | git clone https://github.com/Quafadas/viteless.git && \ 14 | cd viteless && \ 15 | cs launch io.github.quafadas::sjsls:{{projectVersion}} 16 | ``` 17 | 18 | ## Goals 19 | 20 | Replicate the "experience" of using vite with scala JS. Without vite. 21 | 22 | - Live reload / link on change 23 | - Hot application of style (no page reload) 24 | - Proxy server 25 | - page open on start 26 | 27 | ## Assumptions 28 | 29 | `cs`, `scala-cli` and `mill` are readily available on the path. 30 | The entry point for styles is `index.less`, and that file exists in the styles directory. It can link to other style files. 31 | App must be mounted to a div, with id `app`. 32 | 33 | 34 | ## Contributing 35 | 36 | It's so welcome. Start a dicsussion if you'd like so ideas! CI builds a container image which is ready to roll. 37 | -------------------------------------------------------------------------------- /site/docs/index.md: -------------------------------------------------------------------------------- 1 | # Scala JS live server 2 | 3 | > Show me scala JS - I have 20 seconds. 4 | 5 | Paste this into your terminal and hit enter. 6 | 7 | ```sh 8 | scala-cli --version && \ 9 | cs version && \ 10 | git clone https://github.com/Quafadas/viteless.git && \ 11 | cd viteless && \ 12 | cs launch io.github.quafadas::sjsls:latest.release 13 | ``` 14 | 15 | Note that to work, you need cs and scala-cli to be on your path. 16 | 17 | 18 | ## It worked... okay... I have 20 more seconds 19 | 20 | Edit `hello.scala` and save the change. You should see the change refreshed in your browser. 21 | 22 | 23 | 24 | 25 | ## Aw shoot - errors 26 | 27 | The command above assumes you have coursier (as cs) and scala-cli and git installed and available on your path. 28 | 29 | If you don't have those, consider visiting their respective websites and setting up those tools - they are both excellent and fundamental to the scala ecosystem, you'll need them at some point ... 30 | 31 | - [coursier](https://get-coursier.io/docs/cli-installation) 32 | - [scala-cli](https://scala-cli.virtuslab.org) 33 | 34 | -------------------------------------------------------------------------------- /sjsls/src/middleware/staticFileMiddleware.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.time.Instant 4 | import java.time.ZoneId 5 | import java.time.ZonedDateTime 6 | 7 | import org.http4s.Header 8 | import org.http4s.HttpRoutes 9 | import org.http4s.Request 10 | import org.http4s.Response 11 | import org.http4s.Status 12 | import org.http4s.dsl.io.* 13 | import org.typelevel.ci.CIStringSyntax 14 | 15 | import fs2.* 16 | import fs2.io.file.Path 17 | 18 | import scribe.Scribe 19 | 20 | import cats.data.Kleisli 21 | import cats.data.OptionT 22 | import cats.effect.* 23 | import cats.syntax.all.* 24 | 25 | def parseFromHeader(epochInstant: Instant, header: String): Long = 26 | java.time.Duration.between(epochInstant, ZonedDateTime.parse(header, formatter)).toSeconds() 27 | end parseFromHeader 28 | 29 | object StaticFileMiddleware: 30 | def apply(service: HttpRoutes[IO], file: Path)(logger: Scribe[IO]): HttpRoutes[IO] = Kleisli { 31 | (req: Request[IO]) => 32 | 33 | val epochInstant: Instant = Instant.EPOCH 34 | 35 | cachedFileResponse(epochInstant, file, req, service)(logger: Scribe[IO]) 36 | } 37 | end StaticFileMiddleware 38 | -------------------------------------------------------------------------------- /site/docs/routes.md: -------------------------------------------------------------------------------- 1 | # Routes 2 | 3 | Files are served under three seperate routing strategies. Conflicts are resolved in the order listed here. 4 | 5 | ## `--out-dir` Route 6 | 7 | These files are assumed to be created by a build tool emitting `.js`, `.js.map`, `.wasm`, and `.wasm.map` files. They are watched in the directory specified by the `--out-dir` argument, and served at the root of the server. For example, scala-js will usually emit a file called `main.js`. 8 | 9 | You'll find it at http://localhost:8080/main.js 10 | 11 | 12 | ## SPA Routes 13 | 14 | For things like client side routing to work, the backend must return the `index.html` file for any route that doesn't match a static asset. This is done by specifying a `--client-routes-prefix` argument. For example, if you specify `--client-routes-prefix app`, then any request to http://localhost:8080/app/anything/here will return the `index.html` file in response to a request made by the browser. The browser will then handle the routing / rendering. 15 | 16 | ## Static Routes 17 | 18 | Static assets are served from the directory specified by the `--path-to-index-html` argument to the root of the site. For example, if you have a file `styles/index.less` in that directory, it will be served at http://localhost:8080/styles/index.less 19 | -------------------------------------------------------------------------------- /.github/workflows/container.yml: -------------------------------------------------------------------------------- 1 | ## https://github.com/devcontainers/ci/blob/main/docs/github-action.md 2 | 3 | ## Our purpose here is only to check that the devcontainer remains valid, cached and current. 4 | ## So 5 | ## - We will not run the tests. We only build on push to main. 6 | ## 7 | 8 | permissions: write-all 9 | on: 10 | workflow_dispatch: 11 | #name: 'vs_code_container' 12 | #on: 13 | # push: 14 | # branches: 15 | # - main 16 | 17 | jobs: 18 | build: 19 | runs-on: ubuntu-latest 20 | steps: 21 | 22 | - name: Checkout (GitHub) 23 | uses: actions/checkout@v3 24 | 25 | - name: Login to GitHub Container Registry 26 | uses: docker/login-action@v2 27 | with: 28 | registry: ghcr.io 29 | username: ${{ github.repository_owner }} 30 | password: ${{ secrets.GITHUB_TOKEN }} 31 | 32 | - name: Build and run Dev Container task 33 | uses: devcontainers/ci@v0.3 34 | with: 35 | # Change this to point to your image name 36 | imageName: ghcr.io/quafadas/scala-js-live-server 37 | # Change this to be your CI task/script 38 | runCmd: | 39 | # Add multiple commands to run if needed 40 | cs version 41 | ./mill -v 42 | ./mill __.compile 43 | -------------------------------------------------------------------------------- /routes/src/proxyConfig.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import com.comcast.ip4s.* 4 | 5 | import cats.data.NonEmptyList 6 | import cats.syntax.all.* 7 | 8 | object ProxyConfig: 9 | 10 | case class Equilibrium( 11 | http: HttpProxyConfig 12 | ) 13 | sealed trait LocationMatcher 14 | object LocationMatcher: 15 | case class Exact(value: String) extends LocationMatcher 16 | case class Prefix(value: String) extends LocationMatcher 17 | 18 | sealed trait ModifierSymbol 19 | object ModifierSymbol: 20 | case object Exact extends ModifierSymbol 21 | case object Prefix extends ModifierSymbol 22 | end ModifierSymbol 23 | end LocationMatcher 24 | 25 | case class Location( 26 | // key modifier defaults to Prefix, can override to = 27 | matcher: LocationMatcher, 28 | proxyPass: String // Has Our variables 29 | ) 30 | 31 | case class Server( 32 | listen: Port, 33 | serverNames: List[String], 34 | locations: List[Location] 35 | ) 36 | case class UpstreamServer( 37 | host: Host, 38 | port: Port, 39 | weight: Int 40 | ) 41 | case class Upstream( 42 | name: String, 43 | servers: NonEmptyList[UpstreamServer] 44 | ) 45 | 46 | case class HttpProxyConfig( 47 | servers: NonEmptyList[Server], 48 | upstreams: List[Upstream] 49 | ) 50 | 51 | end ProxyConfig 52 | -------------------------------------------------------------------------------- /sjsls/test/src/dezombify.test.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import com.comcast.ip4s.Port 4 | 5 | import cats.effect.IO 6 | 7 | import munit.CatsEffectSuite 8 | 9 | class DezombieTest extends CatsEffectSuite: 10 | 11 | test("That we kill off a zombie server") { 12 | val portInt = 6789 13 | val port = Port.fromInt(portInt).get 14 | 15 | val lsc = LiveServerConfig( 16 | baseDir = None, 17 | stylesDir = None, 18 | port = port, 19 | buildTool = NoBuildTool(), 20 | openBrowserAt = "", 21 | preventBrowserOpen = true, 22 | dezombify = true, 23 | logLevel = "debug" 24 | ) 25 | 26 | for 27 | // Start first server in a separate process using mill run 28 | _ <- IO.println("You should have already started a zombie server in separate process...") 29 | 30 | // Check if port is actually in use 31 | portInUse <- checkPortInUse(port) 32 | _ <- IO(assert(portInUse)) // TODO, this needs to be co-ordinated with some external process. See forkEnv 33 | 34 | // Now start second server with dezombify enabled - this should kill the first one 35 | _ <- IO.println("Starting second server with `enabled...") 36 | allocated <- LiveServer.main(lsc).allocated 37 | (server2, release2) = allocated 38 | _ <- IO.println("Second server started successfully!") 39 | yield (()) 40 | end for 41 | } 42 | end DezombieTest 43 | -------------------------------------------------------------------------------- /plugin/package.mill: -------------------------------------------------------------------------------- 1 | package build.plugin 2 | 3 | import mill.util.BuildInfo. millBinPlatform 4 | import mill._, scalalib._, publish._ 5 | import mill.util.VcsVersion 6 | import build.V 7 | import build.FormatFixPublish 8 | 9 | object `package` extends ScalaModule with FormatFixPublish: 10 | def platformSuffix = s"_mill$millBinPlatform" 11 | 12 | def scalaVersion = build.V.scalaVersion 13 | 14 | def scalaArtefactVersion: Task[String] = 15 | scalaVersion.map(_.split("\\.").take(2).mkString(".")) 16 | 17 | override def artifactName = "sjsls_plugin" 18 | 19 | def mvnDeps = Task{ 20 | super.mvnDeps() ++ 21 | Seq( 22 | V.millLibs, 23 | mvn"co.fs2:fs2-io_3:${V.fs2}" 24 | ) 25 | } 26 | 27 | def moduleDeps = Seq(build.sjsls) 28 | 29 | def artifactSuffix = s"${platformSuffix()}_${scalaArtefactVersion()}" 30 | 31 | def publishVersion = VcsVersion.vcsState().format() 32 | // def publishVersion = "DONTUSEME" 33 | 34 | override def pomSettings = Task { 35 | PomSettings( 36 | description = "Mill plugin for mdoc, static site generation", 37 | organization = "io.github.quafadas", 38 | url = "https://github.com/Quafadas/millSite", 39 | licenses = Seq(License.`Apache-2.0`), 40 | versionControl = VersionControl.github("quafadas", "millSite"), 41 | developers = Seq( 42 | Developer("quafadas", "Simon Parten", "https://github.com/quafadas") 43 | ) 44 | ) 45 | } -------------------------------------------------------------------------------- /routes/src/appRoute.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import org.http4s.HttpRoutes 4 | import org.http4s.Response 5 | import org.http4s.StaticFile 6 | import org.http4s.Status 7 | import org.http4s.dsl.io.* 8 | import org.http4s.server.staticcontent.FileService 9 | import org.http4s.server.staticcontent.fileService 10 | 11 | import fs2.io.file.Files 12 | 13 | import cats.effect.kernel.Async 14 | 15 | def appRoute[F[_]: Files](stringPath: String)(using f: Async[F]): HttpRoutes[F] = HttpRoutes.of[F] { 16 | 17 | case req @ GET -> Root / fName ~ "js" => 18 | StaticFile 19 | .fromPath(fs2.io.file.Path(stringPath) / req.uri.path.renderString, Some(req)) 20 | .getOrElseF(f.pure(Response[F](Status.NotFound))) 21 | 22 | case req @ GET -> Root / fName ~ "wasm" => 23 | StaticFile 24 | .fromPath(fs2.io.file.Path(stringPath) / req.uri.path.renderString, Some(req)) 25 | .getOrElseF(f.pure(Response[F](Status.NotFound))) 26 | 27 | case req @ GET -> Root / fName ~ "map" => 28 | StaticFile 29 | .fromPath(fs2.io.file.Path(stringPath) / req.uri.path.renderString, Some(req)) 30 | .getOrElseF(f.pure(Response[F](Status.NotFound))) 31 | 32 | } 33 | 34 | def fileRoute[F[_]: Async](stringPath: String) = fileService[F](FileService.Config(stringPath)) 35 | 36 | def spaRoute[F[_]: Async](stringPath: String) = HttpRoutes[F] { 37 | case GET -> _ => 38 | StaticFile.fromPath(fs2.io.file.Path(stringPath)) 39 | } 40 | -------------------------------------------------------------------------------- /sjsls/src/staticHtmlMiddleware.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.time.Instant 4 | import java.time.ZoneId 5 | import java.time.ZonedDateTime 6 | 7 | import org.http4s.Header 8 | import org.http4s.HttpRoutes 9 | import org.http4s.Request 10 | import org.http4s.Response 11 | import org.typelevel.ci.CIStringSyntax 12 | 13 | import scribe.Scribe 14 | 15 | import cats.data.Kleisli 16 | import cats.effect.IO 17 | 18 | object StaticHtmlMiddleware: 19 | def apply(service: HttpRoutes[IO], injectStyles: Boolean, zdt: ZonedDateTime)(logger: Scribe[IO]): HttpRoutes[IO] = 20 | Kleisli { 21 | (req: Request[IO]) => 22 | service(req).semiflatMap(userBrowserCacheHeaders(_, zdt, injectStyles)) 23 | } 24 | 25 | end StaticHtmlMiddleware 26 | 27 | def userBrowserCacheHeaders(resp: Response[IO], lastModZdt: ZonedDateTime, injectStyles: Boolean) = 28 | val hash = resp.body.through(fs2.hash.md5).through(fs2.text.hex.encode).compile.string 29 | hash.map: h => 30 | resp.putHeaders( 31 | Header.Raw(ci"Cache-Control", "no-cache"), 32 | Header.Raw( 33 | ci"ETag", 34 | h 35 | ), 36 | Header.Raw( 37 | ci"Last-Modified", 38 | formatter.format(lastModZdt) 39 | ), 40 | Header.Raw( 41 | ci"Expires", 42 | httpCacheFormat(ZonedDateTime.ofInstant(Instant.now().plusSeconds(10000000), ZoneId.of("GMT"))) 43 | ) 44 | ) 45 | end userBrowserCacheHeaders 46 | -------------------------------------------------------------------------------- /sjsls/test/src/UtilityFcts.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import cats.effect.IO 4 | import cats.effect.kernel.Ref 5 | 6 | import munit.CatsEffectSuite 7 | 8 | class UtilityFcs extends CatsEffectSuite: 9 | 10 | test("That we actually inject the preloads ") { 11 | 12 | val html = makeHeader( 13 | modules = Seq( 14 | (fs2.io.file.Path("main.js"), "hash") 15 | ), 16 | withStyles = false, 17 | attemptPreload = true 18 | ) 19 | 20 | assert(html.render.contains("modulepreload")) 21 | assert(html.render.contains("hash")) 22 | 23 | } 24 | 25 | ResourceFunFixture { 26 | for 27 | ref <- Ref.of[IO, Map[String, String]](Map.empty).toResource 28 | _ <- ref.update(_.updated("internal.js", "hash")).toResource 29 | yield ref 30 | }.test("That we can make internal preloads") { 31 | ref => 32 | val html = injectModulePreloads(ref, "") 33 | html.map: html => 34 | assert(html.contains("modulepreload")) 35 | assert(html.contains("internal.js")) 36 | } 37 | 38 | test(" That we can inject a refresh script") { 39 | val html = injectRefreshScript("") 40 | assert( 41 | html.contains("sse.addEventListener") 42 | ) 43 | 44 | assert( 45 | html.contains("""location.reload()}); 46 | """) 47 | ) 48 | } 49 | 50 | end UtilityFcs 51 | -------------------------------------------------------------------------------- /sjsls/src/refreshRoute.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import scala.concurrent.duration.DurationInt 4 | 5 | import org.http4s.HttpRoutes 6 | import org.http4s.ServerSentEvent 7 | import org.http4s.dsl.io.* 8 | 9 | import fs2.concurrent.Topic 10 | 11 | import cats.effect.IO 12 | 13 | import _root_.io.circe.syntax.EncoderOps 14 | import cats.effect.kernel.Ref 15 | import scribe.Scribe 16 | 17 | def refreshRoutes( 18 | refreshTopic: Topic[IO, Unit], 19 | buildTool: BuildTool, 20 | stringPath: fs2.io.file.Path, 21 | mr: Ref[IO, Map[String, String]], 22 | logger: Scribe[IO] 23 | ) = HttpRoutes.of[IO] { 24 | 25 | val keepAlive = fs2.Stream.fixedRate[IO](10.seconds).as(KeepAlive()) 26 | val refresh = refreshTopic.subscribe(10) 27 | 28 | buildTool match 29 | case _: NoBuildTool => 30 | case GET -> Root / "refresh" / "v1" / "sse" => 31 | Ok( 32 | keepAlive 33 | .merge( 34 | refresh 35 | .evalTap( 36 | _ => 37 | // A different tool is responsible for linking, so we hash the files "on the fly" when an update is requested 38 | logger.debug("Updating Map Ref") >> 39 | updateMapRef(stringPath, mr)(logger) 40 | ) 41 | .as(PageRefresh()) 42 | ) 43 | .map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) 44 | ) 45 | case _ => 46 | case GET -> Root / "refresh" / "v1" / "sse" => 47 | Ok( 48 | keepAlive.merge(refresh.as(PageRefresh())).map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) 49 | ) 50 | end match 51 | } 52 | -------------------------------------------------------------------------------- /site/docs/Blog/2024-07-17-OnCdns.md: -------------------------------------------------------------------------------- 1 | --- 2 | 2024-07-17 On CDNs 3 | --- 4 | 5 | One of the objections which seems to crop up rather often when discussing this idea, is that 6 | 7 | > My users are now dependent on a CDN! 8 | 9 | This is true, although my investigation makes this appear way less scary then it first looks. In fact on balance I've developed a strong preference for them... 10 | 11 | The reliability concern is dealt with in two ways. Firstly according to jsdelivr [status](https://status.jsdelivr.com), its uptime was 100% in 2023. So, that's not too bad. 12 | 13 | And if you specify the explicit dependence of the module you are using... actually, it gets waaaay better ... because when the CDN responds to an explicitly versioned request, it includes a bunch of headers which say "This artifact is immutable. Don't bother me about this again. Ever". 14 | 15 | And the browser will respect that - next time you go ask for your dependency, it simply loads it out of its cache. There _is no network request_. Dependency load time: 0ms, according to the browser network tools. 16 | 17 | It's tought to get faster than 0. Also, no request means no network risk. 18 | 19 | So, under "ideal" conditions: 20 | 21 | - You are using a modern browser 22 | - You are making the request for a second+ time 23 | - You have explicitly specified the version of the dependencies you're using 24 | 25 | Dependency resolution is both very reliable and very fast. What's cool - that cache survives redeployment. So your app is slow for the user the first time... but the cache survives a redeployment. It's fast afterwards. We can reword the statement as follows; 26 | 27 | > My users are now dependent on a CDN being available the first time they visit my site. 28 | 29 | Which is less scary given the historical uptime! 30 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/ubuntu 3 | { 4 | 5 | "image": "mcr.microsoft.com/devcontainers/base:jammy", 6 | "customizations": { 7 | "vscode": { 8 | "extensions": [ 9 | "scalameta.metals", 10 | "usernamehw.errorlens", 11 | "vscjava.vscode-java-pack", 12 | "github.copilot", 13 | "github.copilot-chat", 14 | "github.vscode-github-actions", 15 | "github.vscode-pull-request-github", 16 | "eamodio.gitlens", 17 | "ms-vscode-remote.remote-containers", 18 | "github.vscode-pull-request-github" 19 | ] 20 | } 21 | }, 22 | 23 | // // Features to add to the dev container. More info: https://containers.dev/features. 24 | "features": { 25 | "ghcr.io/devcontainers/features/java:1": { 26 | "version": 21 27 | }, 28 | "ghcr.io/devcontainers-contrib/features/scalacli-sdkman:2":{}, 29 | "ghcr.io/guiyomh/features/just:0": {}, 30 | "./features/mill": {} 31 | // //, "ghcr.io/devcontainers-contrib/features/scalacli-sdkman:2": {} 32 | }, 33 | 34 | // Try do do as much as possible in the image, so that the container starts up quickly 35 | "onCreateCommand": "mill __.prepareOffline && mill __.compiledClassesAndSemanticDbFiles" 36 | 37 | // // Use 'forwardPorts' to make a list of ports inside the container available locally. 38 | // "forwardPorts": [], 39 | 40 | // Use 'postCreateCommand' to run commands after the container is created. 41 | // "postCreateCommand": "./mill __.prepareOffline" 42 | 43 | // Configure tool-specific properties. 44 | // "customizations": {}, 45 | 46 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 47 | // "remoteUser": "root" 48 | } 49 | -------------------------------------------------------------------------------- /sjsls/src/buildSpaRoute.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | import java.time.ZonedDateTime 3 | 4 | import org.http4s.HttpRoutes 5 | import org.http4s.Response 6 | import org.http4s.dsl.io.* 7 | import org.http4s.scalatags.* 8 | 9 | import scribe.Scribe 10 | 11 | import cats.effect.IO 12 | import cats.effect.kernel.Async 13 | import cats.effect.kernel.Ref 14 | 15 | /** This is expected to be hidden behind a route with the SPA prefix. It will serve the index.html file from all routes. 16 | * 17 | * @param indexOpts 18 | * @param modules 19 | * @param zdt 20 | * @param logger 21 | * @return 22 | */ 23 | def buildSpaRoute( 24 | indexOpts: Option[IndexHtmlConfig], 25 | modules: Ref[IO, Map[String, String]], 26 | zdt: ZonedDateTime, 27 | injectPreloads: Boolean 28 | )( 29 | logger: Scribe[IO] 30 | )(using 31 | Async[IO] 32 | ) = 33 | indexOpts match 34 | case None => 35 | // Root / spaRoute 36 | StaticHtmlMiddleware( 37 | HttpRoutes.of[IO] { 38 | case req @ GET -> _ => 39 | vanillaTemplate(false, modules, injectPreloads).map: html => 40 | Response[IO]().withEntity(html) 41 | 42 | }, 43 | false, 44 | zdt 45 | )(logger) 46 | 47 | case Some(IndexHtmlConfig.StylesOnly(dir)) => 48 | StaticHtmlMiddleware( 49 | HttpRoutes.of[IO] { 50 | case GET -> _ => 51 | vanillaTemplate(true, modules, injectPreloads).map: html => 52 | Response[IO]().withEntity(html) 53 | }, 54 | true, 55 | zdt 56 | )(logger) 57 | 58 | case Some(IndexHtmlConfig.IndexHtmlPath(dir)) => 59 | StaticFileMiddleware( 60 | HttpRoutes.of[IO] { 61 | case req @ GET -> _ => serveIndexHtml(dir, modules, injectPreloads) 62 | }, 63 | dir / "index.html" 64 | )(logger) 65 | end buildSpaRoute 66 | -------------------------------------------------------------------------------- /sjsls/src/routes.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import org.http4s.HttpRoutes 6 | 7 | import fs2.* 8 | import fs2.concurrent.Topic 9 | import fs2.io.file.Files 10 | 11 | import scribe.Scribe 12 | 13 | import cats.MonadThrow 14 | import cats.data.Kleisli 15 | import cats.data.OptionT 16 | import cats.effect.* 17 | import cats.effect.kernel.Ref 18 | import cats.effect.kernel.Resource 19 | import cats.syntax.all.* 20 | 21 | // TODO: Test that the map of hashes is updated, when an external build tool is responsible for refresh pulses 22 | def routes[F[_]: Files: MonadThrow]( 23 | stringPath: String, 24 | refreshTopic: Topic[IO, Unit], 25 | indexOpts: Option[IndexHtmlConfig], 26 | proxyRoutes: HttpRoutes[IO], 27 | ref: Ref[IO, Map[String, String]], 28 | clientRoutingPrefix: Option[String], 29 | injectPreloads: Boolean, 30 | buildTool: BuildTool 31 | )(logger: Scribe[IO]): Resource[IO, HttpRoutes[IO]] = 32 | 33 | val traceLogger = traceLoggerMiddleware(logger) 34 | val zdt = ZonedDateTime.now() 35 | 36 | // val linkedAppWithCaching: HttpRoutes[IO] = appRoute[IO](stringPath) 37 | val linkedAppWithCaching: HttpRoutes[IO] = ETagMiddleware(appRoute[IO](stringPath), ref)(logger) 38 | val spaRoutes = clientRoutingPrefix.map(s => (s, buildSpaRoute(indexOpts, ref, zdt, injectPreloads)(logger))) 39 | val staticRoutes = Some(staticAssetRoutes(indexOpts, ref, zdt, injectPreloads)(logger)) 40 | 41 | val routes = 42 | frontendRoutes[IO]( 43 | clientSpaRoutes = spaRoutes, 44 | staticAssetRoutes = staticRoutes, 45 | appRoutes = Some(linkedAppWithCaching) 46 | ) 47 | 48 | val refreshableApp = traceLogger( 49 | refreshRoutes(refreshTopic, buildTool, fs2.io.file.Path(stringPath), ref, logger) 50 | .combineK(proxyRoutes) 51 | .combineK(routes) 52 | ) 53 | logger.info("Routes created at : ").toResource >> 54 | logger.info("Path: " + stringPath).toResource >> 55 | IO(refreshableApp).toResource 56 | 57 | end routes 58 | -------------------------------------------------------------------------------- /routes/test/src/build.routes.test.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import org.http4s.HttpRoutes 4 | import org.http4s.Response 5 | import org.http4s.Status 6 | 7 | import cats.effect.IO 8 | 9 | import munit.CatsEffectSuite 10 | 11 | class BuildRoutesSuite extends CatsEffectSuite: 12 | 13 | def makeReq(s: String) = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString(s)) 14 | 15 | // def simpleResponse(body: String) = HttpRoutes.of[IO] { 16 | // case _ => OptionT(IO(Option(Response[IO]().withEntity(body).withStatus(Status.Ok)))) 17 | // } 18 | 19 | test("if no refresh route is given we get not found") { 20 | 21 | val routes = frontendRoutes[IO]( 22 | clientSpaRoutes = None, 23 | staticAssetRoutes = None, 24 | appRoutes = None 25 | ) 26 | 27 | val status = routes(makeReq("anything")).map(_.status).getOrElse(Status.NotFound) 28 | 29 | assertIO(status, Status.NotFound) 30 | } 31 | 32 | test("spa routes are found, i.e. the Router behaves as expected") { 33 | 34 | val routes = frontendRoutes( 35 | clientSpaRoutes = Some( 36 | "spa", 37 | HttpRoutes.of[IO](_ => IO(Response[IO]().withStatus(Status.Ok).withEntity("spaRoute"))) 38 | ), 39 | staticAssetRoutes = None, 40 | appRoutes = None 41 | ) 42 | 43 | val status1 = routes(makeReq("spa")).map(_.status).getOrElse(Status.NotFound) 44 | val status2 = routes(makeReq("/spa")).map(_.status).getOrElse(Status.NotFound) 45 | val status3 = routes(makeReq("spa/something")).map(_.status).getOrElse(Status.NotFound) 46 | val status4 = routes(makeReq("/spa/something?wwaaaaa")).map(_.status).getOrElse(Status.NotFound) 47 | val status5 = routes(makeReq("nope")).map(_.status).getOrElse(Status.NotFound) 48 | 49 | val checkEntity = routes(makeReq("spa")).map(_.bodyText.compile.string).getOrElse(IO("nope")).flatten 50 | 51 | assertIO(status1, Status.Ok) >> 52 | assertIO(status2, Status.Ok) >> 53 | assertIO(status3, Status.Ok) >> 54 | assertIO(status4, Status.Ok) >> 55 | assertIO(status5, Status.NotFound) >> 56 | assertIO(checkEntity, "spaRoute") 57 | 58 | } 59 | 60 | end BuildRoutesSuite 61 | -------------------------------------------------------------------------------- /sjsls/src/staticRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import org.http4s.Header 6 | import org.http4s.HttpRoutes 7 | import org.http4s.Request 8 | import org.http4s.Response 9 | import org.http4s.StaticFile 10 | import org.http4s.dsl.io.* 11 | import org.http4s.server.Router 12 | import org.http4s.server.staticcontent.FileService 13 | import org.http4s.server.staticcontent.fileService 14 | 15 | import fs2.text 16 | 17 | import scribe.Scribe 18 | 19 | import cats.data.Kleisli 20 | import cats.effect.IO 21 | import cats.effect.kernel.Ref 22 | import cats.syntax.all.* 23 | 24 | def staticAssetRoutes( 25 | indexOpts: Option[IndexHtmlConfig], 26 | modules: Ref[IO, Map[String, String]], 27 | zdt: ZonedDateTime, 28 | injectPreloads: Boolean 29 | )(logger: Scribe[IO]): HttpRoutes[IO] = 30 | indexOpts match 31 | case None => generatedIndexHtml(injectStyles = false, modules, zdt, injectPreloads)(logger) 32 | 33 | case Some(IndexHtmlConfig.IndexHtmlPath(path)) => 34 | HttpRoutes 35 | .of[IO] { 36 | case req @ GET -> Root => serveIndexHtml(path, modules, injectPreloads) 37 | 38 | } 39 | .combineK( 40 | StaticMiddleware( 41 | Router( 42 | "" -> fileService[IO](FileService.Config(path.toString())) 43 | ), 44 | fs2.io.file.Path(path.toString()) 45 | )(logger) 46 | ) 47 | 48 | case Some(IndexHtmlConfig.StylesOnly(stylesPath)) => 49 | NoCacheMiddlware( 50 | Router( 51 | "" -> fileService[IO](FileService.Config(stylesPath.toString())) 52 | ) 53 | )(logger).combineK(generatedIndexHtml(injectStyles = true, modules, zdt, injectPreloads)(logger)) 54 | 55 | def serveIndexHtml(from: fs2.io.file.Path, modules: Ref[IO, Map[String, String]], injectPreloads: Boolean) = 56 | StaticFile 57 | .fromPath[IO](from / "index.html") 58 | .getOrElseF(NotFound()) 59 | .flatMap { 60 | f => 61 | f.body 62 | .through(text.utf8.decode) 63 | .compile 64 | .string 65 | .flatMap { 66 | body => 67 | for str <- if injectPreloads then (injectModulePreloads(modules, body)) else IO.pure(body) 68 | yield 69 | val bytes = str.getBytes() 70 | f.withEntity(bytes) 71 | Response[IO]().withEntity(bytes).putHeaders("Content-Type" -> "text/html") 72 | 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] 2 | 3 | projectName := "sjsls" 4 | 5 | default: 6 | just --list 7 | 8 | setupIde: 9 | ./mill mill.bsp.BSP/install 10 | 11 | compile: 12 | ./mill {{projectName}}.compile 13 | 14 | test: 15 | ./mill {{projectName}}.test.testOnly io.github.quafadas.sjsls.SafariSuite && mill {{projectName}}.test.testOnly io.github.quafadas.sjsls.RoutesSuite && mill {{projectName}}.test.testOnly io.github.quafadas.sjsls.UtilityFcs 16 | 17 | checkOpts: 18 | ./mill {{projectName}}.run --help 19 | 20 | 21 | jvmServe: 22 | ./mill -w {{projectName}}.runBackground --build-tool scala-cli --project-dir /Users/simon/Code/indigoLite --log-level info --browse-on-open-at / --path-to-index-html /Users/simon/Code/indigoLite/static 23 | 24 | proxy: 25 | ./mill -w {{projectName}}.runBackground --project-dir /Users/simon/Code/viteless --port 3006 --proxy-prefix-path /api --proxy-target-port 8080 --log-level trace 26 | 27 | 28 | goViteless: 29 | ./mill -w {{projectName}}.run --project-dir /Users/simon/Code/viteless --styles-dir /Users/simon/Code/viteless/styles 30 | 31 | jvmServeNoStyles: 32 | ./mill {{projectName}}.run --build-tool scala-cli --project-dir /Users/simon/Code/helloScalaJs --out-dir /Users/simon/Code/helloScalaJs/out --log-level trace 33 | 34 | jvmLinker: 35 | ./mill {{projectName}}.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 36 | 37 | serveMill: 38 | ./mill {{projectName}}.run --build-tool mill --project-dir /Users/simon/Code/mill-full-stack/mill-full-stack \ 39 | --path-to-index-html /Users/simon/Code/mill-full-stack/mill-full-stack/frontend/ui \ 40 | --out-dir /Users/simon/Code/mill-full-stack/mill-full-stack/out/frontend/fastLinkJS.dest \ 41 | --log-level info \ 42 | --port 3007 \ 43 | --mill-module-name frontend \ 44 | --proxy-prefix-path /api \ 45 | --proxy-target-port 8080 46 | 47 | setupPlaywright: 48 | cs launch com.microsoft.playwright:playwright:1.51.0 -M "com.microsoft.playwright.CLI" -- install --with-deps 49 | 50 | publishLocal: 51 | ./mill __.publishLocal 52 | 53 | setupMill: 54 | curl -L https://raw.githubusercontent.com/lefou/millw/0.4.11/millw > mill && chmod +x mill 55 | 56 | format: 57 | ./mill mill.scalalib.scalafmt.ScalafmtModule/reformatAll __.sources 58 | 59 | fix: 60 | ./mill __.fix 61 | 62 | serveUnidoc: 63 | cs launch io.github.quafadas::sjsls:0.2.8 -- --path-to-index-html C:\live-server-scala-cli-js\out\SiteUnidoc\unidocLocal.dest --build-tool none 64 | 65 | gha: setupMill setupPlaywright test 66 | -------------------------------------------------------------------------------- /sjsls/package.mill: -------------------------------------------------------------------------------- 1 | package build.sjsls 2 | 3 | import build.* 4 | import mill.* 5 | import mill.scalalib.* 6 | import mill.scalajslib.* 7 | import mill.scalalib.publish.* 8 | import mill.contrib.buildinfo.BuildInfo 9 | import mill.api.Task.Simple 10 | import scala.util.Try 11 | object `package` extends FormatFixPublish: 12 | 13 | override def scalaVersion = V.scalaLts 14 | 15 | def mvnDeps = super.mvnDeps() ++ Seq( 16 | mvn"org.http4s::http4s-ember-server::${V.http4sVersion}", 17 | mvn"org.http4s::http4s-ember-client::${V.http4sVersion}", 18 | mvn"org.http4s::http4s-scalatags::0.25.2", 19 | mvn"io.circe::circe-core::${V.circeVersion}", 20 | mvn"io.circe::circe-generic::${V.circeVersion}", 21 | mvn"co.fs2::fs2-io::${V.fs2}", 22 | mvn"com.lihaoyi::scalatags::0.13.1", 23 | mvn"com.monovore::decline::2.5.0", 24 | mvn"com.monovore::decline-effect::2.5.0" 25 | ) 26 | 27 | def moduleDeps = Seq(routes) 28 | 29 | def artifactName = "sjsls" 30 | 31 | object test extends Testy with ScalaTests with BuildInfo: 32 | val name = "sjsls" 33 | val buildInfoPackageName = "sjsls" 34 | def buildInfoMembers = Seq( 35 | BuildInfo.Value("laminar", V.laminar), 36 | BuildInfo.Value("scalaJsDom", V.scalaJsDom), 37 | BuildInfo.Value("scalaJsVersion", V.scalaJs), 38 | BuildInfo.Value("scalaVersion", V.scalaLts) 39 | ) 40 | def mvnDeps = super.mvnDeps() ++ sjsls.mvnDeps() ++ Seq( 41 | mvn"com.microsoft.playwright:playwright:${V.pwV}", 42 | mvn"com.microsoft.playwright:driver-bundle:${V.pwV}" 43 | ) 44 | override def resources = super.resources() 45 | 46 | override def forkEnv: Simple[Map[String, String]] = Task{ 47 | squattyServer() 48 | super.forkEnv() 49 | } 50 | 51 | def squattyServer = Task.Worker{ 52 | println("Starting up a zombie server on port 6789") 53 | Try{ 54 | os.write.over(Task.dest / "hi.md", "hi") 55 | os.spawn( 56 | cmd = ("jwebserver", "-p", "6789"), 57 | cwd = Task.dest 58 | ) 59 | } 60 | Task.dest 61 | } 62 | 63 | override def runClasspath = Task { 64 | sjsls.cacheJsLibs.resolvedMvnDeps() 65 | super.runClasspath() 66 | } 67 | 68 | end test 69 | 70 | object cacheJsLibs extends ScalaJSModule: 71 | def scalaVersion = V.scalaVersion 72 | def scalaJSVersion = V.scalaJs 73 | def testFramework = "munit.Framework" 74 | def mvnDeps = super.mvnDeps() ++ Seq( 75 | mvn"org.scala-js::scalajs-dom::${V.scalaJsDom}", 76 | mvn"com.raquo::laminar::${V.laminar}" 77 | ) 78 | end cacheJsLibs 79 | 80 | 81 | // def scalaNativeVersion = "0.4.17" // aspirational :-) 82 | -------------------------------------------------------------------------------- /routes/src/proxyRoutes.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import org.http4s.HttpRoutes 4 | import org.http4s.client.Client 5 | import org.http4s.server.Router 6 | import org.http4s.server.middleware.Logger 7 | 8 | import com.comcast.ip4s.Host 9 | import com.comcast.ip4s.Port 10 | 11 | import scribe.Scribe 12 | 13 | import cats.data.NonEmptyList 14 | import cats.effect.IO 15 | import cats.effect.kernel.Resource 16 | import cats.effect.std.Random 17 | import cats.syntax.all.* 18 | 19 | import io.github.quafadas.sjsls.ProxyConfig.Equilibrium 20 | import io.github.quafadas.sjsls.ProxyConfig.LocationMatcher 21 | import io.github.quafadas.sjsls.ProxyConfig.Server 22 | 23 | def makeProxyRoutes( 24 | client: Client[IO], 25 | proxyConfig: Option[(Equilibrium, String)] 26 | )(logger: Scribe[IO]): HttpRoutes[IO] = 27 | proxyConfig match 28 | case Some((pc, pathPrefix)) => 29 | given R: Random[IO] = Random.javaUtilConcurrentThreadLocalRandom[IO] 30 | Logger.httpRoutes[IO]( 31 | logHeaders = true, 32 | logBody = true, 33 | redactHeadersWhen = _ => false, 34 | logAction = Some((msg: String) => logger.trace(msg)) 35 | )( 36 | Router( 37 | pathPrefix -> HttpProxy.servers[IO](pc, client, pathPrefix).head._2 38 | ) 39 | ) 40 | 41 | case None => 42 | HttpRoutes.empty[IO] 43 | 44 | end makeProxyRoutes 45 | 46 | def proxyConf(proxyTarget: Option[Port], pathPrefix: Option[String]): Resource[IO, Option[(Equilibrium, String)]] = 47 | proxyTarget 48 | .zip(pathPrefix) 49 | .traverse { 50 | (pt, prfx) => 51 | IO( 52 | ( 53 | Equilibrium( 54 | ProxyConfig.HttpProxyConfig( 55 | servers = NonEmptyList( 56 | Server( 57 | listen = pt, 58 | serverNames = List("localhost"), 59 | locations = List( 60 | ProxyConfig.Location( 61 | matcher = LocationMatcher.Prefix(prfx), 62 | proxyPass = "http://$backend" 63 | ) 64 | ) 65 | ), 66 | List() 67 | ), 68 | upstreams = List( 69 | ProxyConfig.Upstream( 70 | name = "backend", 71 | servers = NonEmptyList( 72 | ProxyConfig.UpstreamServer( 73 | host = Host.fromString("localhost").get, 74 | port = pt, 75 | weight = 5 76 | ), 77 | List() 78 | ) 79 | ) 80 | ) 81 | ) 82 | ), 83 | prfx 84 | ) 85 | ) 86 | 87 | } 88 | .toResource 89 | -------------------------------------------------------------------------------- /plugin/src/refresh_plugin.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas 2 | import fs2.concurrent.Topic 3 | 4 | import cats.effect.IO 5 | import cats.effect.unsafe.implicits.global 6 | 7 | import io.github.quafadas.sjsls.LiveServerConfig 8 | import mill.* 9 | import mill.api.BuildCtx 10 | import mill.api.Task.Simple 11 | import mill.scalajslib.* 12 | import mill.scalajslib.api.Report 13 | implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global 14 | 15 | trait ScalaJsRefreshModule extends ScalaJSModule: 16 | 17 | lazy val updateServer = Topic[IO, Unit].unsafeRunSync() 18 | 19 | def indexHtml = Task { 20 | os.write.over(Task.dest / "index.html", io.github.quafadas.sjsls.vanillaTemplate(withStyles())) 21 | PathRef(Task.dest / "index.html") 22 | } 23 | 24 | def assetsDir = 25 | super.moduleDir / "assets" 26 | 27 | def withStyles = Task(true) 28 | 29 | def assets = Task.Source { 30 | assetsDir 31 | } 32 | 33 | def port = Task { 34 | 8080 35 | } 36 | 37 | def openBrowser = Task { 38 | true 39 | } 40 | 41 | def logLevel = Task { 42 | "warn" 43 | } 44 | 45 | def dezombify = Task { 46 | true 47 | } 48 | 49 | def siteGen = Task { 50 | val assets_ = assets() 51 | val path = fastLinkJS().dest.path 52 | os.copy.over(indexHtml().path, Task.dest / "index.html") 53 | os.copy(assets_.path, Task.dest, mergeFolders = true) 54 | updateServer.publish1(println("publish update")).unsafeRunSync() 55 | (Task.dest.toString(), assets_.path.toString(), path.toString()) 56 | 57 | } 58 | 59 | def lcs = Task.Worker { 60 | val (site, assets, js) = siteGen() 61 | println("Gen lsc") 62 | LiveServerConfig( 63 | baseDir = None, 64 | outDir = Some(js), 65 | port = 66 | com.comcast.ip4s.Port.fromInt(port()).getOrElse(throw new IllegalArgumentException(s"invalid port: ${port()}")), 67 | indexHtmlTemplate = Some(site), 68 | buildTool = io.github.quafadas.sjsls.NoBuildTool(), // Here we are a slave to the build tool 69 | openBrowserAt = "/index.html", 70 | preventBrowserOpen = !openBrowser(), 71 | dezombify = dezombify(), 72 | logLevel = logLevel(), 73 | customRefresh = Some(updateServer) 74 | ) 75 | } 76 | 77 | def serve = Task.Worker { 78 | 79 | println(lcs()) 80 | BuildCtx.withFilesystemCheckerDisabled { 81 | new RefreshServer(lcs()) 82 | } 83 | } 84 | 85 | class RefreshServer(lcs: LiveServerConfig) extends AutoCloseable: 86 | val server = io.github.quafadas.sjsls.LiveServer.main(lcs).allocated 87 | 88 | server.map(_._1).unsafeRunSync() 89 | 90 | override def close(): Unit = 91 | // This is the shutdown hook for http4s 92 | println("Shutting down server...") 93 | server.map(_._2).flatten.unsafeRunSync() 94 | end close 95 | end RefreshServer 96 | end ScalaJsRefreshModule 97 | -------------------------------------------------------------------------------- /site/docs/advantages.md: -------------------------------------------------------------------------------- 1 | # Advantages 2 | 3 | Here are the key advantages of this approach: 4 | 5 | - Getting started is cca 10 seconds and you are live reloading your changes in browser. No config. 6 | 7 | - There is no bundling misdirection, so source maps work. You can [debug scalaJS straight out of VSCode](https://code.visualstudio.com/docs/nodejs/browser-debugging) by following the standard VSSCode debugging instructions. 8 | 9 | - Because there's no seperate ecosystem or NPM to configure, configuring build and CI is a lot easier. No `node_modules` to worry about. I found that this simplicity infected everything around it. 10 | 11 | - In terms of performance; NPM dependancies are loaded out the CDN. This is slow the first time, but check the network browser tools when you refresh the page. The second time they are all served out of browser cache - it takes 0ms. Even better, that cache _survives application redeployment!_. 12 | 13 | - You can use the same build tool for both backend and frontend, and share code between them. 14 | 15 | - Unit testing the frontend with the [Playwright](https://playwright.dev/java/) java client suddenly bursts into life... 16 | - And be because it's all orchestrated in the same process - you can test the _styles_ and the interplay between the application state and styles which is where most of my bugs are. 17 | 18 | - Your build becomes very simple. Here is a mill build.sc file that builds an assembly, including your static resources, frontend app and API. [Example project](https://github.com/Quafadas/mill-full-stack) 19 | 20 | ```scala sc:nocompile 21 | object backend extends Common with ScalafmtModule with ScalafixModule { 22 | 23 | def ivyDeps = 24 | super.ivyDeps() ++ Config.jvmDependencies ++ Config.sharedDependencies 25 | 26 | def moduleDeps = Seq(shared.jvm) 27 | 28 | def frontendResources = T{PathRef(frontend.fullLinkJS().dest.path)} 29 | 30 | def staticAssets = T.source{PathRef(frontend.millSourcePath / "ui")} 31 | 32 | def allClasspath = T{localClasspath() ++ Seq(frontendResources()) ++ Seq(staticAssets()) } 33 | 34 | override def assembly: T[PathRef] = T{ 35 | Assembly.createAssembly( 36 | Agg.from(allClasspath().map(_.path)), 37 | manifest(), 38 | prependShellScript(), 39 | Some(upstreamAssembly2().pathRef.path), 40 | assemblyRules 41 | ) 42 | } 43 | } 44 | 45 | ``` 46 | A dockerfile that uses the assembly: 47 | 48 | ```Dockerfile 49 | FROM azul/zulu-openjdk-alpine:17 50 | 51 | # See the GHA for building the assembly 52 | COPY "./out/backend/assembly.dest/out.jar" "/app/app.jar" 53 | 54 | EXPOSE 8080 55 | 56 | ENTRYPOINT [ "java", "-jar", "/app/app.jar" ] 57 | ``` 58 | 59 | Here is a GHA, that deploys that assembly to [fly.io](https://fly.io). 60 | 61 | ```yaml 62 | name: Deploy to fly.io 63 | 64 | on: 65 | push: 66 | branches: [main] 67 | 68 | env: 69 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 70 | jobs: 71 | build: 72 | runs-on: ubuntu-latest 73 | 74 | steps: 75 | - uses: actions/checkout@v2 76 | - uses: coursier/setup-action@main 77 | with: 78 | jvm: temurin@17 79 | apps: mill 80 | - uses: superfly/flyctl-actions/setup-flyctl@master 81 | - name: Build application 82 | run: mill show backend.assembly -j 0 83 | - name: Deploy to fly.io 84 | run: flyctl deploy --remote-only 85 | ``` 86 | You are live. -------------------------------------------------------------------------------- /site/docs/bundler.md: -------------------------------------------------------------------------------- 1 | Ummm... no Bundler? 2 | --- 3 | 4 | Yup, no bundler. 5 | 6 | Instead, consider using [ESModules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules), and resolving NPM dependancies out of a [CDN](https://www.jsdelivr.com). 7 | 8 | It's less scary than it appears, and it feels _very_ good once it's up and going. An example that uses shoelace. [Sans Bundler](https://github.com/Quafadas/ShoelaceSansBundler). 9 | 10 | # Scala-CLI 11 | 12 | You'll need an `importmap.json` file. 13 | 14 | ```json 15 | { 16 | "imports": { 17 | "@shoelace-style/shoelace/dist/": "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.13.1/cdn/", 18 | } 19 | } 20 | ``` 21 | 22 | and directives that tells scala-cli to use scala js, esmodules and where to find it. 23 | 24 | ``` 25 | //> using platform js 26 | //> using jsModuleKind es 27 | //> using jsEsModuleImportMap importmap.json 28 | //> using jsModuleSplitStyleStr smallmodulesfor 29 | //> using jsSmallModuleForPackage frontend 30 | //> using dep com.raquo::laminar-shoelace::0.1.0 31 | ``` 32 | 33 | # Mill (0.11.8+) 34 | 35 | In your frontend module 36 | 37 | ```scala sc:nocompile 38 | 39 | override def scalaJSImportMap = T { 40 | Seq( 41 | ESModuleImportMapping.Prefix("@shoelace-style/shoelace/dist/", "https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.13.1/cdn/") 42 | ) 43 | } 44 | ``` 45 | 46 | Don't forget to depend on the facade too :-)... 47 | 48 | ```scala sc:nocompile 49 | def ivyDeps = Agg( ivy"com.raquo::laminar-shoelace::0.1.0" ) 50 | ``` 51 | 52 | # SBT 53 | 54 | I haven't personally used, but it would be possible, with this plugin; 55 | 56 | https://github.com/armanbilge/scalajs-importmap 57 | 58 | # Misc 59 | 60 | If you're walking on the bleeding edge with modules that aren't designed to be loaded out of a CDN (looking at you, SAP UI5 webcomponents), then things are not easy. You may need to give the browser some hints, on where it can resolve other parts of the module grap in your index.html; 61 | 62 | 63 | ```json 64 | 127 | ``` 128 | *** 129 | 130 | ## Full stack - need proxy to backend 131 | 132 | With a backend running on `8080` and a frontend on `3000`, it is configured that requests beginning with `api` are proxied to localhost:8080. 133 | 134 | Also, we're now using mill. We need to tell the cli the frontend module name and the directory the compiles JS ends up in. 135 | 136 | ```sh 137 | cs launch io.github.quafadas::sjsls:latest.version -- \ 138 | --path-to-index-html /Users/simon/Code/mill-full-stack/frontend/ui \ 139 | --build-tool mill \ 140 | --mill-module-name frontend \ 141 | --port 3000 \ 142 | --out-dir /Users/simon/Code/mill-full-stack/out/frontend/fastLinkJS.dest \ 143 | --proxy-prefix-path /api \ 144 | --proxy-target-port 8080 \ 145 | 146 | ``` 147 | 148 | ## Static site, no build tool. 149 | 150 | This would serve the static site build with the `docJar` tool. 151 | 152 | ```sh 153 | C:\temp\live-server-scala-cli-js> cs launch io.github.quafadas::sjsls:0.2.0 -- --path-to-index-html C:\\temp\\live-server-scala-cli-js\\out\\site\\live.dest\\site --build-tool none --browse-on-open-at /docs/index.html 154 | ``` 155 | 156 | You need to include this javascript script tag in the body html - otherwise no page refresh. 157 | 158 | ```html 159 | 169 | ``` -------------------------------------------------------------------------------- /sjsls/src/htmlGen.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.time.ZonedDateTime 4 | 5 | import scalatags.Text.TypedTag 6 | import scalatags.Text.all.* 7 | 8 | import org.http4s.HttpRoutes 9 | import org.http4s.Response 10 | import org.http4s.Status 11 | import org.http4s.dsl.io.* 12 | import org.http4s.scalatags.* 13 | 14 | import fs2.io.file.Path 15 | 16 | import scribe.Scribe 17 | 18 | import cats.effect.IO 19 | import cats.effect.kernel.Ref 20 | import cats.syntax.all.* 21 | 22 | private def generatedIndexHtml( 23 | injectStyles: Boolean, 24 | modules: Ref[IO, Map[String, String]], 25 | zdt: ZonedDateTime, 26 | attemptPreload: Boolean 27 | )( 28 | logger: Scribe[IO] 29 | ) = 30 | StaticHtmlMiddleware( 31 | HttpRoutes.of[IO] { 32 | case req @ GET -> Root => 33 | logger.trace("Generated index.html") >> 34 | vanillaTemplate(injectStyles, modules, attemptPreload).flatMap: html => 35 | userBrowserCacheHeaders(Response[IO]().withEntity(html).withStatus(Status.Ok), zdt, injectStyles) 36 | 37 | }, 38 | injectStyles, 39 | zdt 40 | )(logger).combineK( 41 | StaticHtmlMiddleware( 42 | HttpRoutes.of[IO] { 43 | case GET -> Root / "index.html" => 44 | vanillaTemplate(injectStyles, modules, attemptPreload).flatMap: html => 45 | userBrowserCacheHeaders(Response[IO]().withEntity(html).withStatus(Status.Ok), zdt, injectStyles) 46 | 47 | }, 48 | injectStyles, 49 | zdt 50 | )(logger) 51 | ) 52 | 53 | private def lessStyle(withStyles: Boolean): Seq[Modifier] = 54 | if withStyles then 55 | Seq( 56 | link( 57 | rel := "stylesheet/less", 58 | `type` := "text/css", 59 | href := "/index.less" 60 | ), 61 | script( 62 | raw( 63 | """less = {env: "development",async: true,fileAsync: true,dumpLineNumbers: "comments",relativeUrls: false};""" 64 | ) 65 | ), 66 | script(src := "https://cdn.jsdelivr.net/npm/less"), 67 | script("less.watch();") 68 | ) 69 | else Seq.empty 70 | 71 | val refreshScript = script(raw("""const sse = new EventSource('/refresh/v1/sse'); 72 | sse.addEventListener('message', (e) => { 73 | const msg = JSON.parse(e.data) 74 | 75 | if ('KeepAlive' in msg) 76 | console.log("KeepAlive") 77 | 78 | if ('PageRefresh' in msg) 79 | location.reload()});""")) 80 | 81 | /* 82 | * create an html template with that has a head, which includes script tags, that have modulepreload enabled 83 | */ 84 | 85 | // def generateHtml(modules: Seq[(Path, String)]) = (template: String => String) => 86 | // template(makeHeader(modules, true).render) 87 | 88 | private def injectRefreshScript(template: String) = 89 | val bodyCloseTag = "" 90 | val insertionPoint = template.indexOf(bodyCloseTag) 91 | 92 | val newHtmlContent = template.substring(0, insertionPoint) + 93 | refreshScript.render + "\n" + 94 | template.substring(insertionPoint) 95 | 96 | newHtmlContent 97 | 98 | end injectRefreshScript 99 | 100 | private def injectModulePreloads(ref: Ref[IO, Map[String, String]], template: String) = 101 | val preloads = makeInternalPreloads(ref) 102 | preloads.map: modules => 103 | val modulesStringsInject = modules.mkString("\n", "\n", "\n") 104 | val headCloseTag = "" 105 | val insertionPoint = template.indexOf(headCloseTag) 106 | 107 | val newHtmlContent = template.substring(0, insertionPoint) + 108 | modulesStringsInject + 109 | template.substring(insertionPoint) 110 | newHtmlContent 111 | 112 | end injectModulePreloads 113 | 114 | private def makeHeader(modules: Seq[(Path, String)], withStyles: Boolean, attemptPreload: Boolean = false) = 115 | val scripts = 116 | for 117 | m <- modules 118 | if m._1.toString.endsWith(".js") 119 | yield link( 120 | rel := "modulepreload", 121 | href := s"${m._1}?hash=${m._2}" 122 | ) 123 | 124 | html( 125 | head( 126 | meta( 127 | httpEquiv := "Cache-control", 128 | content := "no-cache, no-store, must-revalidate" 129 | ), 130 | if attemptPreload then scripts else () 131 | ), 132 | body( 133 | lessStyle(withStyles), 134 | script(src := "main.js", `type` := "module"), 135 | div(id := "app"), 136 | // script(src := "main"), 137 | refreshScript 138 | ) 139 | ) 140 | end makeHeader 141 | 142 | private def makeInternalPreloads(ref: Ref[IO, Map[String, String]]) = 143 | val keys = ref.get.map(_.toSeq) 144 | keys.map { 145 | modules => 146 | for 147 | m <- modules 148 | if m._1.toString.endsWith(".js") && m._1.toString.startsWith("internal") 149 | yield link(rel := "modulepreload", href := s"${m._1}?h=${m._2}") 150 | end for 151 | } 152 | 153 | end makeInternalPreloads 154 | 155 | def vanillaTemplate(styles: Boolean): String = 156 | val r = Ref.of[IO, Map[String, String]](Map.empty) 157 | r.flatMap(rf => vanillaTemplate(styles, rf, false).map(_.render)) 158 | .unsafeRunSync()(using cats.effect.unsafe.implicits.global) 159 | end vanillaTemplate 160 | 161 | def vanillaTemplate( 162 | withStyles: Boolean, 163 | ref: Ref[IO, Map[String, String]], 164 | attemptPreload: Boolean 165 | ): IO[TypedTag[String]] = 166 | 167 | val preloads = makeInternalPreloads(ref) 168 | preloads.map: modules => 169 | html( 170 | head( 171 | meta( 172 | httpEquiv := "Cache-control", 173 | content := "no-cache, no-store, must-revalidate" 174 | ), 175 | if attemptPreload then modules else () 176 | ), 177 | body( 178 | lessStyle(withStyles), 179 | script(src := "/main.js", `type` := "module"), 180 | div(id := "app"), 181 | refreshScript 182 | ) 183 | ) 184 | end vanillaTemplate 185 | -------------------------------------------------------------------------------- /routes/src/proxyHttp.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import org.http4s.* 4 | import org.http4s.client.Client 5 | import org.http4s.headers.Host 6 | import org.http4s.headers.`X-Forwarded-For` 7 | 8 | import com.comcast.ip4s.Port 9 | 10 | import cats.* 11 | import cats.data.* 12 | import cats.effect.kernel.* 13 | import cats.effect.std.Random 14 | import cats.syntax.all.* 15 | 16 | import io.github.quafadas.sjsls.ProxyConfig.Location 17 | 18 | object HttpProxy: 19 | 20 | def servers[F[_]: MonadCancelThrow: Random]( 21 | c: ProxyConfig.Equilibrium, 22 | client: Client[F], 23 | pathPrefix: String 24 | ): NonEmptyMap[Port, HttpRoutes[F]] = 25 | val upstreams = c.http.upstreams.groupMapReduce(_.name)(_.servers) { case (a, b) => a.concatNel(b) } 26 | c.http 27 | .servers 28 | .groupByNem(server => server.listen) 29 | .map { 30 | servers => 31 | 32 | val routes: HttpRoutes[F] = HttpRoutes.of { 33 | case (req: Request[F]) => 34 | // println("in proxy") 35 | // println(req) 36 | val pathRendered = pathPrefix + req.uri.path.renderString 37 | // println(pathRendered) 38 | val host = req.headers.get[Host].map(_.host).getOrElse("") // Host set otherwise empty string 39 | val newServers = servers.filter(_.serverNames.contains(host)) 40 | // println(host) 41 | // println(newServers) 42 | val exact = 43 | newServers 44 | .flatMap(_.locations) 45 | .collect { 46 | case Location(out: ProxyConfig.LocationMatcher.Exact, proxy) => 47 | (out, proxy) 48 | } 49 | 50 | val proxy = exact 51 | .collectFirst { case (e, p) if e.value === pathRendered => p } 52 | .orElse { 53 | val prefix = 54 | newServers 55 | .flatMap(_.locations) 56 | .collect { 57 | case Location(out: ProxyConfig.LocationMatcher.Prefix, proxy) => 58 | (out, proxy) 59 | } 60 | prefix 61 | .collect { case (e, p) if pathRendered.startsWith(e.value) => (e, p) } 62 | .sortBy { 63 | case ((p, _)) => p.value.length() 64 | } 65 | .headOption 66 | .map(_._2) 67 | } 68 | proxy.fold( 69 | Response[F](Status.NotFound).withEntity("No Route Found").pure[F] 70 | )( 71 | proxyThrough[F](_, upstreams).flatMap { 72 | uri => 73 | client.toHttpApp(req.removeHeader[Host].withUri(uri.addPath(pathRendered))) 74 | } 75 | ) 76 | } 77 | 78 | xForwardedMiddleware(routes) 79 | } 80 | end servers 81 | 82 | private def proxyThrough[F[_]: Random: MonadThrow]( 83 | proxyPass: String, 84 | upstreams: Map[String, NonEmptyList[ProxyConfig.UpstreamServer]] 85 | ): F[Uri] = 86 | if !proxyPass.contains("$") then Uri.fromString(proxyPass).liftTo[F] 87 | else 88 | extractVariable(proxyPass).flatMap { 89 | case (before, variable, after) => 90 | upstreams 91 | .get(variable) 92 | .fold(throw new RuntimeException("Variable Not Found In Upstreams"))( 93 | nel => 94 | pickUpstream[F](nel).flatMap( 95 | us => Uri.fromString(before ++ us.host.toString ++ ":" ++ us.port.toString ++ after).liftTo[F] 96 | ) 97 | ) 98 | } 99 | end if 100 | end proxyThrough 101 | 102 | private def extractVariable[F[_]: ApplicativeThrow](s: String): F[(String, String, String)] = 103 | s.split('$').toList match 104 | case before :: after :: _ => 105 | val i = after.indexOf("/") 106 | if i < 0 then (before, after, "").pure[F] 107 | else 108 | after.split("/").toList match 109 | case variable :: after :: _ => (before, variable, after).pure[F] 110 | case _ => new RuntimeException("Split on / failed in extract Variable").raiseError 111 | end if 112 | case _ => new RuntimeException("Split on $ failed in extractVariable").raiseError 113 | 114 | private def pickUpstream[F[_]: Random: Monad]( 115 | upstreams: NonEmptyList[ProxyConfig.UpstreamServer] 116 | ): F[ProxyConfig.UpstreamServer] = 117 | randomWeighted(upstreams.map(a => (a.weight, a))) 118 | 119 | private def randomWeighted[F[_]: Random: Monad, A](weighted: NonEmptyList[(Int, A)]): F[A] = 120 | val max = weighted.foldMap(_._1) 121 | def go: F[Option[A]] = Random[F] 122 | .betweenInt(0, max) 123 | .map { 124 | i => 125 | var running: Int = i 126 | weighted.collectFirstSome { 127 | case (weight, a) => 128 | if running < weight then Some(a) 129 | else 130 | running -= weight 131 | None 132 | } 133 | } 134 | def f: F[A] = go.flatMap { 135 | case None => f 136 | case Some(a) => a.pure[F] 137 | } 138 | f 139 | end randomWeighted 140 | 141 | def xForwardedMiddleware[G[_], F[_]](http: Http[G, F]): Http[G, F] = Kleisli { 142 | (req: Request[F]) => 143 | req 144 | .remote 145 | .fold(http.run(req)) { 146 | remote => 147 | val forwardedFor = req 148 | .headers 149 | .get[`X-Forwarded-For`] 150 | .fold(`X-Forwarded-For`(NonEmptyList.of(Some(remote.host))))( 151 | init => `X-Forwarded-For`(init.values :+ remote.host.some) 152 | ) 153 | val forwardedProtocol = 154 | req.uri.scheme.map(headers.`X-Forwarded-Proto`(_)) 155 | 156 | val forwardedHost = req.headers.get[Host].map(host => "X-Forwarded-Host" -> Host.headerInstance.value(host)) 157 | 158 | val init = req.putHeaders(forwardedFor) 159 | 160 | val second = forwardedProtocol.fold(init)(proto => init.putHeaders(proto)) 161 | val third = forwardedHost.fold(second)(host => second.putHeaders(host)) 162 | http.run(third) 163 | } 164 | } 165 | end HttpProxy 166 | -------------------------------------------------------------------------------- /site/docs/Blog/2024-05-22-Viteless.md: -------------------------------------------------------------------------------- 1 | 2024-05-22 The Idea 2 | --- 3 | 4 | # Goal 5 | 6 | > Can we replicate the vite "experience" of web development in scalajs using vite... _without_ vite. Or NPM. Or node. 7 | 8 | Basically, toss JS tooling in the bin :-). 9 | 10 | After some time configuring node. And NPM. And vite. And then doing it all again in CI, I asked... 11 | 12 | Wouldn't it be more fun to... write our own frontend development server? 13 | 14 | This is, in a way the natural evolution to [this post](https://quafadas.github.io/Whimsy/2024/03/03/ESModules-At-Link-Time-copy.html). 15 | 16 | ## Contraints 17 | 18 | "replicating vite" is a big job. I might be stupid, but I ain't that bloody stupid :-). We aren't trying to replicate vite, with it's big plugin ecosystem and support of whatever the latest frontend whizzbangery is these days. We're trying to replicate vites _experience_ for _my_ scalaJS projects. 19 | 20 | I claim that this is less stupid. YMMV. 21 | 22 | Funnily enough though, once you break it down, each invidual piece is... not that bad... 23 | 24 | ## Features 25 | 26 | 1. insta-style application 27 | 1. proxy requests to backend 28 | 1. open webpage on start 29 | 1. resolve references to JS eco-system 30 | 1. serve website 31 | 1. naively 32 | 2. reloadably-on-change 33 | 34 | If all this works, that is our definition of done. 35 | 36 | ### 1. Insta Style Application 37 | 38 | I style things with [LESS](https://lesscss.org/). It turns out, that this is [built right in](https://lesscss.org/usage/#using-less-in-the-browser-watch-mode). 39 | 40 | We will not be needing vite, to save ourselves from a script tag in our html. One down. 41 | 42 | ### 2. Proxy requests to backend 43 | 44 | We're in scala, right? A mythical land where just about everyone you trip over is secretly a backend ninja. _Someone_ must have a prox... [well hello](https://github.com/davenverse/equilibrium). 45 | 46 | Mostly, I copied and pasted code from there and poked it with a sharp stick until it did what I wanted. 47 | 48 | ### 3. open webpage on start 49 | 50 | At least make it a challenge... 51 | 52 | ```scala sc:nocompile 53 | def openBrowserWindow(uri: java.net.URI): Unit = 54 | println(s"opening browser window at $uri") 55 | if Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE) then 56 | Desktop.getDesktop().browse(uri) 57 | ``` 58 | ### 4. resolve references to JS eco-system 59 | 60 | Finally! Something non-trivial... 61 | 62 | The big observation here, is that one can resolve ESModules _directly_ out of ES module syntax in Browser. See this post for more detail. [ES Modules at link time](https://quafadas.github.io/Whimsy/2024/03/03/ESModules-At-Link-Time-copy.html). This capability is now in SBT, scala-cli and mill. 63 | 64 | For the purposes of this excercise, it _negates the need for a bundler_. Instead, we can rely on the _browser_ resolution of ES Modules. From a strategic perspective - sure we're giving up vite. But we replace it with a ** _Browser_ **. Vite is good software, sure, but there are leagues, and vite ain't in the same league as Chrome or Safari. I never looked back. 65 | 66 | ### 5. serve website 67 | #### Naively 68 | 69 | There is a very simple approach, which is just serve straight out of javas simple http server. 70 | 71 | ```sh 72 | $JAVA_HOME/bin/jwebserver -d c:/temp/helloScalaJs/out -p 8000 73 | ``` 74 | 75 | That server starts super fast, and it proves our concept to this point works, because it resolves the modules and we can visit it in browser. It's here because firstly it's an easy way to verify the steps to this point, and also, because it's super useful for unit testing. It's killer in combination with [Playwright](https://playwright.dev/java/docs/intro). 76 | 77 | As part of a hot development loop however, it's seriously lacking. We need to restart the app on every change - which is not the experience we are looking for. 78 | 79 | # Hot Reload 80 | 81 | Well, we now come to the point. If do things the vite way, then we need to somehow track all the module dependancies, figure out which one has changed or is dependant, reload it and heaven knows what else. Vite seems to setup some heavy duty websocket comms to manage all this. 82 | 83 | Originall, I wanted to use module preloads. However, it's not possible to use module preloads in a way that is compatible with the browser cache - and we want the browser cache. Browser cache is _fast_. We want to use it. 84 | 85 | What we do intead, is to provide each module with a hash of it's content. When the module is loaded, we check the hash. If the hash is different, we reload the module. This is a very simple approach, but it works. If we configure middleware correctly, then wqhen the browser comes to reload, it can send the ETag and Validity of the existing resource. If we match, then we simply send back a 304 and the browser uses the cached resource. 86 | 87 | So reloading? Fast. Very, fast. And the difficult module resolution problems? All dealt with by your friendly neighbourhood browser. Even better, we can take advantage of a little knowledge of scala-js to preload the fat `internal` dependancies! 88 | 89 | This is a big win, because the fat dependancies are the slowest to load and appear at the end of the module graph. I believe this change makes us faster than vite for non-trivial projects. 90 | 91 | To generate our `index.html`, our dev server monitors file changes, and updates a `MapRef[File, Hash]`. We use that `MapRef` to generate the `index.html` on demand. It appears natural, to request a page refresh (and a new `index.html`) when we detect linker success. 92 | 93 | The final thing we need to do is include in `index.html` a script which refreshes the page when it recieves the right event from our dev server. 94 | 95 | ```js 96 | const sse = new EventSource('/api/v1/sse'); 97 | sse.addEventListener('message', (e) => { 98 | const msg = JSON.parse(e.data) 99 | 100 | if ('KeepAlive' in msg) 101 | console.log("KeepAlive") 102 | 103 | if ('PageRefresh' in msg) 104 | location.reload() 105 | }); 106 | ``` 107 | 108 | To trigger a page refresh, we use server sent events. 109 | 110 | ```scala sc:nocompile 111 | case GET -> Root / "api" / "v1" / "sse" => 112 | val keepAlive = fs2.Stream.fixedRate[IO](10.seconds).as(KeepAlive()) 113 | Ok( 114 | keepAlive 115 | .merge(refreshTopic.subscribe(10).as(PageRefresh())) 116 | .map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) 117 | ) 118 | 119 | ``` 120 | 121 | 122 | # Does it work? 123 | 124 | It certainly seems to. 125 | 126 | The "fat" scalaJS dependancy gets loaded out of memory in 9.88ms on page regfresh, which means page refresh is essentially instantaneous, once the linker completes. 127 | -------------------------------------------------------------------------------- /sjsls/src/buildRunner.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.util.Locale 4 | 5 | import fs2.* 6 | import fs2.concurrent.Topic 7 | import fs2.io.process.ProcessBuilder 8 | import fs2.io.process.Processes 9 | 10 | import scribe.Scribe 11 | 12 | import cats.effect.IO 13 | import cats.effect.ResourceIO 14 | import cats.effect.kernel.Resource 15 | import cats.syntax.all.* 16 | 17 | private lazy val isWindows: Boolean = 18 | System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("windows") 19 | 20 | def buildRunner( 21 | tool: BuildTool, 22 | linkingTopic: Topic[IO, Unit], 23 | workDir: fs2.io.file.Path, 24 | outDir: fs2.io.file.Path, 25 | extraBuildArgs: List[String], 26 | millModuleName: Option[String], 27 | buildToolInvocation: Option[String] 28 | )( 29 | logger: Scribe[IO] 30 | ): ResourceIO[Unit] = 31 | val invokeVia = buildToolInvocation.getOrElse(tool.invokedVia) 32 | tool match 33 | case scli: ScalaCli => 34 | buildRunnerScli(linkingTopic, workDir, outDir, invokeVia, extraBuildArgs)(logger) 35 | case m: Mill => 36 | buildRunnerMill( 37 | linkingTopic, 38 | workDir, 39 | millModuleName.getOrElse(throw new Exception("must have a module name when running with mill")), 40 | invokeVia, 41 | extraBuildArgs 42 | )(logger) 43 | case n: NoBuildTool => 44 | logger.info("No build tool specified, skipping build").toResource 45 | end match 46 | end buildRunner 47 | 48 | def buildRunnerScli( 49 | linkingTopic: Topic[IO, Unit], 50 | workDir: fs2.io.file.Path, 51 | outDir: fs2.io.file.Path, 52 | invokeVia: String, 53 | extraBuildArgs: List[String] 54 | )( 55 | logger: Scribe[IO] 56 | ): ResourceIO[Unit] = 57 | val scalaCliArgs = List( 58 | "--power", 59 | "package", 60 | "--js", 61 | ".", 62 | "-o", 63 | outDir.show, 64 | "-f", 65 | "-w" 66 | ) ++ extraBuildArgs 67 | 68 | logger 69 | .trace(s"Invoking via : $invokeVia with args : ${scalaCliArgs.toString()}") 70 | .toResource 71 | .flatMap( 72 | _ => 73 | ProcessBuilder( 74 | invokeVia, 75 | scalaCliArgs 76 | ).withWorkingDirectory(workDir) 77 | .spawn[IO] 78 | .use { 79 | p => 80 | // p.stderr.through(fs2.io.stdout).compile.drain >> 81 | p.stderr 82 | .through(text.utf8.decode) 83 | .debug() 84 | .chunks 85 | .evalMap( 86 | aChunk => 87 | if aChunk.toString.contains("main.js, run it with") then 88 | logger.trace("Detected that linking was successful, emitting refresh event") >> 89 | linkingTopic.publish1(()) 90 | else 91 | logger.trace(s"$aChunk :: Linking unfinished") >> 92 | IO.unit 93 | ) 94 | .compile 95 | .drain 96 | } 97 | .background 98 | .void 99 | ) 100 | end buildRunnerScli 101 | 102 | def buildRunnerMill( 103 | linkingTopic: Topic[IO, Unit], 104 | workDir: fs2.io.file.Path, 105 | moduleName: String, 106 | invokeVia: String, 107 | extraBuildArgs: List[String] 108 | )( 109 | logger: Scribe[IO] 110 | ): ResourceIO[Unit] = 111 | // val watchLinkComplePath = workDir / "out" / moduleName / "fastLinkJS.json" 112 | 113 | // val watcher = fs2 114 | // .Stream 115 | // .resource(Watcher.default[IO].evalTap(_.watch(watchLinkComplePath.toNioPath))) 116 | // .flatMap { 117 | // _.events(100.millis) 118 | // .evalTap { 119 | // (e: Event) => 120 | // e match 121 | // case Created(path, count) => logger.info("fastLinkJs.json was created") 122 | // case Deleted(path, count) => logger.info("fastLinkJs.json was deleted") 123 | // case Modified(path, count) => 124 | // logger.info("fastLinkJs.json was modified - link successful => trigger a refresh") >> 125 | // linkingTopic.publish1(()) 126 | // case Overflow(count) => logger.info("overflow") 127 | // case NonStandard(event, registeredDirectory) => logger.info("non-standard") 128 | 129 | // } 130 | // } 131 | // .compile 132 | // .drain 133 | // .background 134 | // .void 135 | 136 | val linkCommand = s"$moduleName.fastLinkJS" 137 | 138 | val millargs = List( 139 | "-w", 140 | linkCommand 141 | ) ++ extraBuildArgs 142 | // TODO pipe this to stdout so that we can see linker progress / errors. 143 | val builder = ProcessBuilder( 144 | invokeVia, 145 | millargs 146 | ).withWorkingDirectory(workDir) 147 | .spawn[IO] 148 | .use { 149 | p => 150 | // p.stderr.through(fs2.io.stdout).compile.drain >> 151 | // val stdOut = p.stdout.through(text.utf8.decode).debug().compile.drain 152 | // val stdErr = p.stderr.through(text.utf8.decode).debug().compile.drain 153 | // stdOut.both(stdErr).void 154 | 155 | p.stderr 156 | .through(text.utf8.decode) 157 | .debug() 158 | .chunks 159 | .sliding(2) 160 | .evalMap( 161 | aChunk => 162 | 163 | val chunk1 = aChunk(0).toString 164 | val chunk2 = aChunk(1).toString 165 | if chunk1.contains(s"= $linkCommand") && chunk2.contains("Watching for changes to ") then 166 | logger.trace("Detected that linking was successful, emitting refresh event") >> 167 | linkingTopic.publish1(()) 168 | else 169 | logger.trace(s"$aChunk :: Linking unfinished") >> 170 | IO.unit 171 | end if 172 | 173 | /** Doesn't work with new mill 174 | */ 175 | // if aChunk.head.exists(_.contains("BasicBackend: total modules:")) then 176 | // logger.trace("Detected that linking was successful, emitting refresh event") >> 177 | // linkingTopic.publish1(()) 178 | // else 179 | // logger.trace(s"$aChunk :: Linking unfinished") >> 180 | // IO.unit 181 | // end if 182 | ) 183 | .compile 184 | .drain 185 | // .both(stdOut) 186 | // .both(stdErr).void 187 | } 188 | .background 189 | .void 190 | 191 | for 192 | _ <- logger.trace("Starting buildRunnerMill").toResource 193 | _ <- logger.debug(s"running $invokeVia with args $millargs").toResource 194 | _ <- builder 195 | // _ <- watcher 196 | yield () 197 | end for 198 | 199 | end buildRunnerMill 200 | -------------------------------------------------------------------------------- /sjsls/src/liveServer.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import scala.concurrent.duration.* 4 | 5 | import org.http4s.* 6 | import org.http4s.ember.client.EmberClientBuilder 7 | import org.http4s.ember.server.EmberServerBuilder 8 | import org.http4s.server.Server 9 | 10 | import com.comcast.ip4s.Port 11 | import com.comcast.ip4s.host 12 | import com.monovore.decline.* 13 | import com.monovore.decline.effect.* 14 | 15 | import fs2.* 16 | import fs2.concurrent.Topic 17 | import fs2.io.file.* 18 | 19 | import scribe.Level 20 | 21 | import cats.effect.* 22 | import cats.implicits.* 23 | 24 | object LiveServer extends IOApp: 25 | private val logger = scribe.cats[IO] 26 | given filesInstance: Files[IO] = Files.forAsync[IO] 27 | 28 | import CliOps.* 29 | 30 | private def buildServer(httpApp: HttpApp[IO], port: Port) = EmberServerBuilder 31 | .default[IO] 32 | .withHttp2 33 | .withHost(host"localhost") 34 | .withPort(port) 35 | .withHttpApp(httpApp) 36 | .withShutdownTimeout(1.milli) 37 | .build 38 | 39 | def parseOpts = ( 40 | baseDirOpt, 41 | outDirOpt, 42 | portOpt, 43 | proxyPortTargetOpt, 44 | proxyPathMatchPrefixOpt, 45 | clientRoutingPrefixOpt, 46 | logLevelOpt, 47 | buildToolOpt, 48 | openBrowserAtOpt, 49 | preventBrowserOpenOpt, 50 | extraBuildArgsOpt, 51 | millModuleNameOpt, 52 | stylesDirOpt, 53 | indexHtmlTemplateOpt, 54 | buildToolInvocation, 55 | injectPreloadsOpt, 56 | dezombifyOpt, 57 | None.pure[Opts] 58 | ).mapN(LiveServerConfig.apply) 59 | 60 | def main(lsc: LiveServerConfig): Resource[IO, Server] = 61 | 62 | scribe 63 | .Logger 64 | .root 65 | .clearHandlers() 66 | .clearModifiers() 67 | .withHandler(minimumLevel = Some(Level.get(lsc.logLevel).get)) 68 | .replace() 69 | 70 | val server = for 71 | _ <- logger 72 | .debug( 73 | lsc.toString() 74 | ) 75 | .toResource 76 | 77 | _ <- Resource 78 | .pure[IO, Boolean](lsc.dezombify) 79 | .flatMap( 80 | if _ then 81 | Resource.eval(IO.println(s"Attempt to kill off process on port ${lsc.port}")) >> 82 | dezombify(lsc.port) 83 | else scribe.cats[IO].debug(s"Assuming port ${lsc.port} is free").toResource 84 | ) 85 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 86 | refreshTopic <- lsc 87 | .customRefresh 88 | .fold(Topic[IO, Unit])( 89 | scribe.cats[IO].debug("Custom refresh topic supplied") >> 90 | IO(_) 91 | ) 92 | .toResource 93 | linkingTopic <- Topic[IO, Unit].toResource 94 | client <- EmberClientBuilder.default[IO].build 95 | baseDirPath <- lsc.baseDir.fold(Files[IO].currentWorkingDirectory.toResource)(toDirectoryPath) 96 | outDirPath <- lsc 97 | .outDir 98 | .fold { 99 | // If we arent' responsible for linking and the user has specified a location for index html, we're essentially serving a static site. 100 | (lsc.buildTool, lsc.indexHtmlTemplate) match 101 | case (_: NoBuildTool, Some(indexHtml)) => toDirectoryPath(indexHtml) 102 | case _ => Files[IO].tempDirectory 103 | }(toDirectoryPath) 104 | outDirString = outDirPath.show 105 | indexHtmlTemplatePath <- lsc.indexHtmlTemplate.traverse(toDirectoryPath) 106 | stylesDirPath <- lsc.stylesDir.traverse(toDirectoryPath) 107 | 108 | indexOpts <- (indexHtmlTemplatePath, stylesDirPath) match 109 | case (Some(html), None) => 110 | val indexHtmlFile = html / "index.html" 111 | println(indexHtmlFile) 112 | (for 113 | indexHtmlExists <- Files[IO].exists(indexHtmlFile) 114 | _ <- IO.raiseUnless(indexHtmlExists)(CliValidationError(s"index.html doesn't exist in $html")) 115 | indexHtmlIsAFile <- Files[IO].isRegularFile(indexHtmlFile) 116 | _ <- IO.raiseUnless(indexHtmlIsAFile)(CliValidationError(s"$indexHtmlFile is not a file")) 117 | yield IndexHtmlConfig.IndexHtmlPath(html).some).toResource 118 | case (None, Some(styles)) => 119 | val indexLessFile = styles / "index.less" 120 | (for 121 | indexLessExists <- Files[IO].exists(indexLessFile) 122 | _ <- IO.raiseUnless(indexLessExists)(CliValidationError(s"index.less doesn't exist in $styles")) 123 | indexLessIsAFile <- Files[IO].isRegularFile(indexLessFile) 124 | _ <- IO.raiseUnless(indexLessIsAFile)(CliValidationError(s"$indexLessFile is not a file")) 125 | yield IndexHtmlConfig.StylesOnly(styles).some).toResource 126 | case (None, None) => 127 | Resource.pure(Option.empty[IndexHtmlConfig]) 128 | case (Some(_), Some(_)) => 129 | Resource.raiseError[IO, Nothing, Throwable]( 130 | CliValidationError("path-to-index-html and styles-dir can't be defined at the same time") 131 | ) 132 | 133 | proxyConf2 <- proxyConf(lsc.proxyPortTarget, lsc.proxyPathMatchPrefix) 134 | proxyRoutes: HttpRoutes[IO] = makeProxyRoutes(client, proxyConf2)(logger) 135 | 136 | _ <- buildRunner( 137 | lsc.buildTool, 138 | linkingTopic, 139 | baseDirPath, 140 | outDirPath, 141 | lsc.extraBuildArgs, 142 | lsc.millModuleName, 143 | lsc.buildToolInvocation 144 | )(logger) 145 | 146 | app <- routes( 147 | outDirString, 148 | refreshTopic, 149 | indexOpts, 150 | proxyRoutes, 151 | fileToHashRef, 152 | lsc.clientRoutingPrefix, 153 | lsc.injectPreloads, 154 | lsc.buildTool 155 | )(logger) 156 | 157 | _ <- updateMapRef(outDirPath, fileToHashRef)(logger).toResource 158 | // _ <- stylesDir.fold(Resource.unit)(sd => seedMapOnStart(sd, mr)) 159 | _ <- fileWatcher(outDirPath, fileToHashRef, linkingTopic, refreshTopic)(logger) 160 | _ <- indexOpts.match 161 | case Some(IndexHtmlConfig.IndexHtmlPath(indexHtmlatPath)) => 162 | staticWatcher(refreshTopic, fs2.io.file.Path(indexHtmlatPath.toString))(logger) 163 | case _ => Resource.unit[IO] 164 | 165 | // _ <- stylesDir.fold(Resource.unit[IO])(sd => fileWatcher(fs2.io.file.Path(sd), mr)) 166 | _ <- logger.info(s"Start dev server on http://localhost:${lsc.port}").toResource 167 | server <- buildServer(app.orNotFound, lsc.port) 168 | 169 | _ <- IO.whenA(!lsc.preventBrowserOpen)(openBrowser(Some(lsc.openBrowserAt), lsc.port)(logger)).toResource 170 | yield server 171 | 172 | server 173 | // .useForever 174 | // .as(ExitCode.Success) 175 | // .handleErrorWith { 176 | // case CliValidationError(message) => 177 | // IO.println(s"$message\n${command.showHelp}").as(ExitCode.Error) 178 | // case error => IO.raiseError(error) 179 | // } 180 | 181 | end main 182 | 183 | def runServerHandleErrors(lsc: LiveServerConfig): IO[ExitCode] = 184 | main(lsc).useForever.as(ExitCode.Success) 185 | 186 | def runServerHandleErrors: Opts[IO[ExitCode]] = parseOpts.map(runServerHandleErrors(_).handleErrorWith { 187 | case CliValidationError(message) => 188 | IO.println(s"${command.showHelp} \n $message \n see help above").as(ExitCode.Error) 189 | case error => IO.raiseError(error) 190 | }) 191 | 192 | val command = 193 | val versionFlag = Opts.flag( 194 | long = "version", 195 | short = "v", 196 | help = "Print the version number and exit.", 197 | visibility = Visibility.Partial 198 | ) 199 | 200 | val version = "0.0.1" 201 | val finalOpts = versionFlag 202 | .as(IO.println(version).as(ExitCode.Success)) 203 | .orElse( 204 | runServerHandleErrors 205 | ) 206 | Command(name = "LiveServer", header = "Scala JS live server", helpFlag = true)(finalOpts) 207 | end command 208 | 209 | override def run(args: List[String]): IO[ExitCode] = 210 | CommandIOApp.run(command, args) 211 | 212 | private def toDirectoryPath(path: String) = 213 | val res = Path(path) 214 | Files[IO] 215 | .isDirectory(res) 216 | .toResource 217 | .flatMap: 218 | case true => Resource.pure(res) 219 | case false => Resource.raiseError[IO, Nothing, Throwable](CliValidationError(s"$path is not a directory")) 220 | end toDirectoryPath 221 | 222 | end LiveServer 223 | -------------------------------------------------------------------------------- /mill: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This is a wrapper script, that automatically selects or downloads Mill from Maven Central or GitHub release pages. 4 | # 5 | # This script determines the Mill version to use by trying these sources 6 | # - env-variable `MILL_VERSION` 7 | # - local file `.mill-version` 8 | # - local file `.config/mill-version` 9 | # - `mill-version` from YAML fronmatter of current buildfile 10 | # - if accessible, find the latest stable version available on Maven Central (https://repo1.maven.org/maven2) 11 | # - env-variable `DEFAULT_MILL_VERSION` 12 | # 13 | # If a version has the suffix '-native' a native binary will be used. 14 | # If a version has the suffix '-jvm' an executable jar file will be used, requiring an already installed Java runtime. 15 | # If no such suffix is found, the script will pick a default based on version and platform. 16 | # 17 | # Once a version was determined, it tries to use either 18 | # - a system-installed mill, if found and it's version matches 19 | # - an already downloaded version under ~/.cache/mill/download 20 | # 21 | # If no working mill version was found on the system, 22 | # this script downloads a binary file from Maven Central or Github Pages (this is version dependent) 23 | # into a cache location (~/.cache/mill/download). 24 | # 25 | # Mill Project URL: https://github.com/com-lihaoyi/mill 26 | # Script Version: 1.0.0-M1-21-7b6fae-DIRTY892b63e8 27 | # 28 | # If you want to improve this script, please also contribute your changes back! 29 | # This script was generated from: dist/scripts/src/mill.sh 30 | # 31 | # Licensed under the Apache License, Version 2.0 32 | 33 | set -e 34 | 35 | if [ "$1" = "--setup-completions" ] ; then 36 | # Need to preserve the first position of those listed options 37 | MILL_FIRST_ARG=$1 38 | shift 39 | fi 40 | 41 | if [ -z "${DEFAULT_MILL_VERSION}" ] ; then 42 | DEFAULT_MILL_VERSION=1.0.3 43 | fi 44 | 45 | 46 | if [ -z "${GITHUB_RELEASE_CDN}" ] ; then 47 | GITHUB_RELEASE_CDN="" 48 | fi 49 | 50 | 51 | MILL_REPO_URL="https://github.com/com-lihaoyi/mill" 52 | 53 | if [ -z "${CURL_CMD}" ] ; then 54 | CURL_CMD=curl 55 | fi 56 | 57 | # Explicit commandline argument takes precedence over all other methods 58 | if [ "$1" = "--mill-version" ] ; then 59 | echo "The --mill-version option is no longer supported." 1>&2 60 | fi 61 | 62 | MILL_BUILD_SCRIPT="" 63 | 64 | if [ -f "build.mill" ] ; then 65 | MILL_BUILD_SCRIPT="build.mill" 66 | elif [ -f "build.mill.scala" ] ; then 67 | MILL_BUILD_SCRIPT="build.mill.scala" 68 | elif [ -f "build.sc" ] ; then 69 | MILL_BUILD_SCRIPT="build.sc" 70 | fi 71 | 72 | # Please note, that if a MILL_VERSION is already set in the environment, 73 | # We reuse it's value and skip searching for a value. 74 | 75 | # If not already set, read .mill-version file 76 | if [ -z "${MILL_VERSION}" ] ; then 77 | if [ -f ".mill-version" ] ; then 78 | MILL_VERSION="$(tr '\r' '\n' < .mill-version | head -n 1 2> /dev/null)" 79 | elif [ -f ".config/mill-version" ] ; then 80 | MILL_VERSION="$(tr '\r' '\n' < .config/mill-version | head -n 1 2> /dev/null)" 81 | elif [ -n "${MILL_BUILD_SCRIPT}" ] ; then 82 | MILL_VERSION="$(cat ${MILL_BUILD_SCRIPT} | grep '//[|] *mill-version: *' | sed 's;//| *mill-version: *;;')" 83 | fi 84 | fi 85 | 86 | MILL_USER_CACHE_DIR="${XDG_CACHE_HOME:-${HOME}/.cache}/mill" 87 | 88 | if [ -z "${MILL_DOWNLOAD_PATH}" ] ; then 89 | MILL_DOWNLOAD_PATH="${MILL_USER_CACHE_DIR}/download" 90 | fi 91 | 92 | # If not already set, try to fetch newest from Github 93 | if [ -z "${MILL_VERSION}" ] ; then 94 | # TODO: try to load latest version from release page 95 | echo "No mill version specified." 1>&2 96 | echo "You should provide a version via a '//| mill-version: ' comment or a '.mill-version' file." 1>&2 97 | 98 | mkdir -p "${MILL_DOWNLOAD_PATH}" 99 | LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" 2>/dev/null || ( 100 | # we might be on OSX or BSD which don't have -d option for touch 101 | # but probably a -A [-][[hh]mm]SS 102 | touch "${MILL_DOWNLOAD_PATH}/.expire_latest"; touch -A -010000 "${MILL_DOWNLOAD_PATH}/.expire_latest" 103 | ) || ( 104 | # in case we still failed, we retry the first touch command with the intention 105 | # to show the (previously suppressed) error message 106 | LANG=C touch -d '1 hour ago' "${MILL_DOWNLOAD_PATH}/.expire_latest" 107 | ) 108 | 109 | # POSIX shell variant of bash's -nt operator, see https://unix.stackexchange.com/a/449744/6993 110 | # if [ "${MILL_DOWNLOAD_PATH}/.latest" -nt "${MILL_DOWNLOAD_PATH}/.expire_latest" ] ; then 111 | if [ -n "$(find -L "${MILL_DOWNLOAD_PATH}/.latest" -prune -newer "${MILL_DOWNLOAD_PATH}/.expire_latest")" ]; then 112 | # we know a current latest version 113 | MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) 114 | fi 115 | 116 | if [ -z "${MILL_VERSION}" ] ; then 117 | # we don't know a current latest version 118 | echo "Retrieving latest mill version ..." 1>&2 119 | LANG=C ${CURL_CMD} -s -i -f -I ${MILL_REPO_URL}/releases/latest 2> /dev/null | grep --ignore-case Location: | sed s'/^.*tag\///' | tr -d '\r\n' > "${MILL_DOWNLOAD_PATH}/.latest" 120 | MILL_VERSION=$(head -n 1 "${MILL_DOWNLOAD_PATH}"/.latest 2> /dev/null) 121 | fi 122 | 123 | if [ -z "${MILL_VERSION}" ] ; then 124 | # Last resort 125 | MILL_VERSION="${DEFAULT_MILL_VERSION}" 126 | echo "Falling back to hardcoded mill version ${MILL_VERSION}" 1>&2 127 | else 128 | echo "Using mill version ${MILL_VERSION}" 1>&2 129 | fi 130 | fi 131 | 132 | MILL_NATIVE_SUFFIX="-native" 133 | MILL_JVM_SUFFIX="-jvm" 134 | FULL_MILL_VERSION=$MILL_VERSION 135 | ARTIFACT_SUFFIX="" 136 | set_artifact_suffix(){ 137 | if [ "$(expr substr $(uname -s) 1 5 2>/dev/null)" = "Linux" ]; then 138 | if [ "$(uname -m)" = "aarch64" ]; then 139 | ARTIFACT_SUFFIX="-native-linux-aarch64" 140 | else 141 | ARTIFACT_SUFFIX="-native-linux-amd64" 142 | fi 143 | elif [ "$(uname)" = "Darwin" ]; then 144 | if [ "$(uname -m)" = "arm64" ]; then 145 | ARTIFACT_SUFFIX="-native-mac-aarch64" 146 | else 147 | ARTIFACT_SUFFIX="-native-mac-amd64" 148 | fi 149 | else 150 | echo "This native mill launcher supports only Linux and macOS." 1>&2 151 | exit 1 152 | fi 153 | } 154 | 155 | case "$MILL_VERSION" in 156 | *"$MILL_NATIVE_SUFFIX") 157 | MILL_VERSION=${MILL_VERSION%"$MILL_NATIVE_SUFFIX"} 158 | set_artifact_suffix 159 | ;; 160 | 161 | *"$MILL_JVM_SUFFIX") 162 | MILL_VERSION=${MILL_VERSION%"$MILL_JVM_SUFFIX"} 163 | ;; 164 | 165 | *) 166 | case "$MILL_VERSION" in 167 | 0.1.*) ;; 168 | 0.2.*) ;; 169 | 0.3.*) ;; 170 | 0.4.*) ;; 171 | 0.5.*) ;; 172 | 0.6.*) ;; 173 | 0.7.*) ;; 174 | 0.8.*) ;; 175 | 0.9.*) ;; 176 | 0.10.*) ;; 177 | 0.11.*) ;; 178 | 0.12.*) ;; 179 | *) 180 | set_artifact_suffix 181 | esac 182 | ;; 183 | esac 184 | 185 | MILL="${MILL_DOWNLOAD_PATH}/$MILL_VERSION$ARTIFACT_SUFFIX" 186 | 187 | try_to_use_system_mill() { 188 | if [ "$(uname)" != "Linux" ]; then 189 | return 0 190 | fi 191 | 192 | MILL_IN_PATH="$(command -v mill || true)" 193 | 194 | if [ -z "${MILL_IN_PATH}" ]; then 195 | return 0 196 | fi 197 | 198 | SYSTEM_MILL_FIRST_TWO_BYTES=$(head --bytes=2 "${MILL_IN_PATH}") 199 | if [ "${SYSTEM_MILL_FIRST_TWO_BYTES}" = "#!" ]; then 200 | # MILL_IN_PATH is (very likely) a shell script and not the mill 201 | # executable, ignore it. 202 | return 0 203 | fi 204 | 205 | SYSTEM_MILL_PATH=$(readlink -e "${MILL_IN_PATH}") 206 | SYSTEM_MILL_SIZE=$(stat --format=%s "${SYSTEM_MILL_PATH}") 207 | SYSTEM_MILL_MTIME=$(stat --format=%y "${SYSTEM_MILL_PATH}") 208 | 209 | if [ ! -d "${MILL_USER_CACHE_DIR}" ]; then 210 | mkdir -p "${MILL_USER_CACHE_DIR}" 211 | fi 212 | 213 | SYSTEM_MILL_INFO_FILE="${MILL_USER_CACHE_DIR}/system-mill-info" 214 | if [ -f "${SYSTEM_MILL_INFO_FILE}" ]; then 215 | parseSystemMillInfo() { 216 | LINE_NUMBER="${1}" 217 | # Select the line number of the SYSTEM_MILL_INFO_FILE, cut the 218 | # variable definition in that line in two halves and return 219 | # the value, and finally remove the quotes. 220 | sed -n "${LINE_NUMBER}p" "${SYSTEM_MILL_INFO_FILE}" |\ 221 | cut -d= -f2 |\ 222 | sed 's/"\(.*\)"/\1/' 223 | } 224 | 225 | CACHED_SYSTEM_MILL_PATH=$(parseSystemMillInfo 1) 226 | CACHED_SYSTEM_MILL_VERSION=$(parseSystemMillInfo 2) 227 | CACHED_SYSTEM_MILL_SIZE=$(parseSystemMillInfo 3) 228 | CACHED_SYSTEM_MILL_MTIME=$(parseSystemMillInfo 4) 229 | 230 | if [ "${SYSTEM_MILL_PATH}" = "${CACHED_SYSTEM_MILL_PATH}" ] \ 231 | && [ "${SYSTEM_MILL_SIZE}" = "${CACHED_SYSTEM_MILL_SIZE}" ] \ 232 | && [ "${SYSTEM_MILL_MTIME}" = "${CACHED_SYSTEM_MILL_MTIME}" ]; then 233 | if [ "${CACHED_SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then 234 | MILL="${SYSTEM_MILL_PATH}" 235 | return 0 236 | else 237 | return 0 238 | fi 239 | fi 240 | fi 241 | 242 | SYSTEM_MILL_VERSION=$(${SYSTEM_MILL_PATH} --version | head -n1 | sed -n 's/^Mill.*version \(.*\)/\1/p') 243 | 244 | cat < "${SYSTEM_MILL_INFO_FILE}" 245 | CACHED_SYSTEM_MILL_PATH="${SYSTEM_MILL_PATH}" 246 | CACHED_SYSTEM_MILL_VERSION="${SYSTEM_MILL_VERSION}" 247 | CACHED_SYSTEM_MILL_SIZE="${SYSTEM_MILL_SIZE}" 248 | CACHED_SYSTEM_MILL_MTIME="${SYSTEM_MILL_MTIME}" 249 | EOF 250 | 251 | if [ "${SYSTEM_MILL_VERSION}" = "${MILL_VERSION}" ]; then 252 | MILL="${SYSTEM_MILL_PATH}" 253 | fi 254 | } 255 | try_to_use_system_mill 256 | 257 | # If not already downloaded, download it 258 | if [ ! -s "${MILL}" ] || [ "$MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT" = "1" ] ; then 259 | case $MILL_VERSION in 260 | 0.0.* | 0.1.* | 0.2.* | 0.3.* | 0.4.* ) 261 | DOWNLOAD_SUFFIX="" 262 | DOWNLOAD_FROM_MAVEN=0 263 | ;; 264 | 0.5.* | 0.6.* | 0.7.* | 0.8.* | 0.9.* | 0.10.* | 0.11.0-M* ) 265 | DOWNLOAD_SUFFIX="-assembly" 266 | DOWNLOAD_FROM_MAVEN=0 267 | ;; 268 | *) 269 | DOWNLOAD_SUFFIX="-assembly" 270 | DOWNLOAD_FROM_MAVEN=1 271 | ;; 272 | esac 273 | case $MILL_VERSION in 274 | 0.12.0 | 0.12.1 | 0.12.2 | 0.12.3 | 0.12.4 | 0.12.5 | 0.12.6 | 0.12.7 | 0.12.8 | 0.12.9 | 0.12.10 | 0.12.11 ) 275 | DOWNLOAD_EXT="jar" 276 | ;; 277 | 0.12.* ) 278 | DOWNLOAD_EXT="exe" 279 | ;; 280 | 0.* ) 281 | DOWNLOAD_EXT="jar" 282 | ;; 283 | *) 284 | DOWNLOAD_EXT="exe" 285 | ;; 286 | esac 287 | 288 | DOWNLOAD_FILE=$(mktemp mill.XXXXXX) 289 | if [ "$DOWNLOAD_FROM_MAVEN" = "1" ] ; then 290 | DOWNLOAD_URL="https://repo1.maven.org/maven2/com/lihaoyi/mill-dist${ARTIFACT_SUFFIX}/${MILL_VERSION}/mill-dist${ARTIFACT_SUFFIX}-${MILL_VERSION}.${DOWNLOAD_EXT}" 291 | else 292 | MILL_VERSION_TAG=$(echo "$MILL_VERSION" | sed -E 's/([^-]+)(-M[0-9]+)?(-.*)?/\1\2/') 293 | DOWNLOAD_URL="${GITHUB_RELEASE_CDN}${MILL_REPO_URL}/releases/download/${MILL_VERSION_TAG}/${MILL_VERSION}${DOWNLOAD_SUFFIX}" 294 | unset MILL_VERSION_TAG 295 | fi 296 | 297 | if [ "$MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT" = "1" ] ; then 298 | echo $DOWNLOAD_URL 299 | echo $MILL 300 | exit 0 301 | fi 302 | # TODO: handle command not found 303 | echo "Downloading mill ${MILL_VERSION} from ${DOWNLOAD_URL} ..." 1>&2 304 | ${CURL_CMD} -f -L -o "${DOWNLOAD_FILE}" "${DOWNLOAD_URL}" 305 | chmod +x "${DOWNLOAD_FILE}" 306 | mkdir -p "${MILL_DOWNLOAD_PATH}" 307 | mv "${DOWNLOAD_FILE}" "${MILL}" 308 | 309 | unset DOWNLOAD_FILE 310 | unset DOWNLOAD_SUFFIX 311 | fi 312 | 313 | if [ -z "$MILL_MAIN_CLI" ] ; then 314 | MILL_MAIN_CLI="${0}" 315 | fi 316 | 317 | MILL_FIRST_ARG="" 318 | if [ "$1" = "--bsp" ] || [ "${1#"-i"}" != "$1" ] || [ "$1" = "--interactive" ] || [ "$1" = "--no-server" ] || [ "$1" = "--no-daemon" ] || [ "$1" = "--repl" ] || [ "$1" = "--help" ] ; then 319 | # Need to preserve the first position of those listed options 320 | MILL_FIRST_ARG=$1 321 | shift 322 | fi 323 | 324 | unset MILL_DOWNLOAD_PATH 325 | unset MILL_OLD_DOWNLOAD_PATH 326 | unset OLD_MILL 327 | unset MILL_VERSION 328 | unset MILL_REPO_URL 329 | 330 | # -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 331 | # We don't quote MILL_FIRST_ARG on purpose, so we can expand the empty value without quotes 332 | # shellcheck disable=SC2086 333 | exec "${MILL}" $MILL_FIRST_ARG -D "mill.main.cli=${MILL_MAIN_CLI}" "$@" 334 | -------------------------------------------------------------------------------- /sjsls/test/src/liveServer.test.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import scala.compiletime.uninitialized 4 | import scala.concurrent.duration.* 5 | 6 | import org.http4s.HttpRoutes 7 | import org.http4s.Method 8 | import org.http4s.Uri 9 | import org.http4s.dsl.io.* 10 | import org.http4s.ember.client.EmberClientBuilder 11 | import org.http4s.ember.server.EmberServerBuilder 12 | 13 | import com.comcast.ip4s.Port 14 | import com.microsoft.playwright.* 15 | import com.microsoft.playwright.assertions.LocatorAssertions.ContainsTextOptions 16 | import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat 17 | 18 | import fs2.concurrent.Topic 19 | 20 | import cats.effect.IO 21 | import cats.effect.kernel.Ref 22 | 23 | import munit.CatsEffectSuite 24 | 25 | /* 26 | Run 27 | cs launch com.microsoft.playwright:playwright:1.51.0 -M "com.microsoft.playwright.CLI" -- install --with-deps 28 | before this test, to make sure that the driver bundles are downloaded. 29 | */ 30 | 31 | class FirefoxSuite extends PlaywrightTest: 32 | 33 | override def beforeAll(): Unit = 34 | basePort = 5000 35 | backendPort = 8999 36 | pw = Playwright.create() 37 | browser = pw.firefox().launch(options); 38 | page = browser.newPage(); 39 | page.setDefaultTimeout(30000) 40 | end beforeAll 41 | 42 | end FirefoxSuite 43 | 44 | class SafariSuite extends PlaywrightTest: 45 | 46 | override def beforeAll(): Unit = 47 | basePort = 4000 48 | backendPort = 8998 49 | pw = Playwright.create() 50 | browser = pw.webkit().launch(options); 51 | page = browser.newPage(); 52 | page.setDefaultTimeout(30000) 53 | end beforeAll 54 | 55 | end SafariSuite 56 | 57 | class ChromeSuite extends PlaywrightTest: 58 | 59 | override def beforeAll(): Unit = 60 | basePort = 3000 61 | backendPort = 8997 62 | pw = Playwright.create() 63 | browser = pw.chromium().launch(options); 64 | page = browser.newPage(); 65 | page.setDefaultTimeout(30000) 66 | end beforeAll 67 | 68 | end ChromeSuite 69 | 70 | trait PlaywrightTest extends CatsEffectSuite: 71 | 72 | var basePort: Int = uninitialized 73 | var backendPort: Int = uninitialized 74 | var pw: Playwright = uninitialized 75 | var browser: Browser = uninitialized 76 | var page: Page = uninitialized 77 | 78 | val options = new BrowserType.LaunchOptions() 79 | // options.setHeadless(false); 80 | 81 | // def testDir(base: os.Path) = os.pwd / "testDir" 82 | def outDir(base: os.Path) = base / ".out" 83 | def styleDir(base: os.Path) = base / "styles" 84 | 85 | val vanilla = vanillaTemplate(true, Ref.unsafe(Map[String, String]()), false).unsafeRunSync() 86 | 87 | val files = 88 | IO { 89 | val tempDir = os.temp.dir() 90 | os.makeDir.all(styleDir(tempDir)) 91 | os.write.over(tempDir / "hello.scala", helloWorldCode("Hello")) 92 | os.write.over(styleDir(tempDir) / "index.less", "") 93 | tempDir 94 | }.flatTap { 95 | tempDir => 96 | IO.blocking(os.proc("scala-cli", "compile", tempDir.toString).call(cwd = tempDir)) 97 | } 98 | .toResource 99 | 100 | val externalHtml = IO { 101 | val tempDir = os.temp.dir() 102 | val staticDir = tempDir / "assets" 103 | os.makeDir(staticDir) 104 | os.write.over(tempDir / "hello.scala", helloWorldCode("Hello")) 105 | os.write.over(staticDir / "index.less", "h1{color:red}") 106 | os.write.over(staticDir / "index.html", vanilla.render) 107 | (tempDir, staticDir) 108 | }.flatTap { 109 | tempDir => 110 | IO.blocking(os.proc("scala-cli", "compile", tempDir._1.toString).call(cwd = tempDir._1)) 111 | } 112 | .toResource 113 | 114 | val client = EmberClientBuilder.default[IO].build 115 | 116 | def simpleBackend(port: Int) = EmberServerBuilder 117 | .default[IO] 118 | .withHttpApp( 119 | HttpRoutes 120 | .of[IO] { 121 | case GET -> Root / "api" / "hello" => 122 | Ok("hello world") 123 | } 124 | .orNotFound 125 | ) 126 | .withPort(Port.fromInt(port).get) 127 | .withShutdownTimeout(1.millis) 128 | .build 129 | 130 | ResourceFunFixture { 131 | files.flatMap { 132 | dir => 133 | val lsc = LiveServerConfig( 134 | baseDir = Some(dir.toString), 135 | stylesDir = Some(styleDir(dir).toString), 136 | port = Port.fromInt(basePort).get, 137 | openBrowserAt = "", 138 | preventBrowserOpen = true 139 | ) 140 | LiveServer.main(lsc).map((_, dir, lsc.port)) 141 | } 142 | }.test("incremental") { 143 | (_, testDir, port) => 144 | val increaseTimeout = ContainsTextOptions() 145 | increaseTimeout.setTimeout(15000) 146 | IO.sleep(3.seconds) >> 147 | IO(page.navigate(s"http://localhost:$port")) >> 148 | IO(assertThat(page.locator("h1")).containsText("HelloWorld", increaseTimeout)) >> 149 | IO.blocking(os.write.over(testDir / "hello.scala", helloWorldCode("Bye"))) >> 150 | IO(assertThat(page.locator("h1")).containsText("ByeWorld", increaseTimeout)) >> 151 | IO.blocking(os.write.append(styleDir(testDir) / "index.less", "h1 { color: red; }")) >> 152 | IO(assertThat(page.locator("h1")).hasCSS("color", "rgb(255, 0, 0)")) 153 | } 154 | 155 | ResourceFunFixture { 156 | files 157 | .both(client) 158 | .flatMap { 159 | (dir, client) => 160 | val lsc = LiveServerConfig( 161 | baseDir = Some(dir.toString), 162 | stylesDir = Some(styleDir(dir).toString), 163 | port = Port.fromInt(basePort).get, 164 | openBrowserAt = "", 165 | preventBrowserOpen = true 166 | ) 167 | LiveServer.main(lsc).map((_, dir, lsc.port, client)) 168 | } 169 | }.test("no proxy server gives not found for a request to an API") { 170 | (_, _, port, client) => 171 | assertIO( 172 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$port/api/hello"))), 173 | NotFound 174 | ) 175 | } 176 | 177 | ResourceFunFixture { 178 | files 179 | .both(client) 180 | .flatMap { 181 | (dir, client) => 182 | val lsc = LiveServerConfig( 183 | baseDir = Some(dir.toString), 184 | stylesDir = Some(styleDir(dir).toString), 185 | port = Port.fromInt(basePort).get, 186 | openBrowserAt = "", 187 | preventBrowserOpen = true, 188 | proxyPortTarget = Port.fromInt(backendPort), // Now uses class-level backendPort 189 | proxyPathMatchPrefix = Some("/api") 190 | ) 191 | 192 | simpleBackend(backendPort).flatMap { // Now uses class-level backendPort 193 | _ => 194 | LiveServer.main(lsc).map(_ => (lsc.port, client)) 195 | } 196 | } 197 | }.test("proxy server forwards to a backend server") { 198 | (port, client) => 199 | assertIO( 200 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$port/api/hello"))), 201 | Ok 202 | ) >> 203 | assertIO( 204 | client.expect[String](s"http://localhost:$port/api/hello"), 205 | "hello world" 206 | ) >> 207 | assertIO( 208 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$port/api/nope"))), 209 | NotFound 210 | ) 211 | } 212 | 213 | ResourceFunFixture { 214 | externalHtml 215 | .both(client) 216 | .flatMap { 217 | case ((dir, extHtmlDir), client) => 218 | val lsc = LiveServerConfig( 219 | baseDir = Some(dir.toString), 220 | indexHtmlTemplate = Some(extHtmlDir.toString), 221 | port = Port.fromInt(basePort).get, 222 | openBrowserAt = "", 223 | preventBrowserOpen = true, 224 | proxyPortTarget = Port.fromInt(backendPort), 225 | proxyPathMatchPrefix = Some("/api"), 226 | clientRoutingPrefix = Some("/app"), 227 | logLevel = "info" 228 | ) 229 | 230 | simpleBackend(backendPort).flatMap { 231 | _ => 232 | LiveServer.main(lsc).map(_ => (lsc.port, client)) 233 | } 234 | } 235 | }.test("proxy server and SPA client apps") { 236 | (port, client) => 237 | assertIO( 238 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$port/api/hello"))), 239 | Ok 240 | ) >> 241 | assertIO( 242 | client.expect[String](s"http://localhost:$port/api/hello"), 243 | "hello world" 244 | ) >> 245 | assertIO( 246 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$port/api/nope"))), 247 | NotFound 248 | ) >> 249 | assertIO( 250 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$port"))), 251 | Ok 252 | ) >> 253 | assertIO( 254 | client.expect[String](s"http://localhost:$port").map(_.filterNot(_.isWhitespace)), 255 | vanilla.render.filterNot(_.isWhitespace) 256 | ) >> 257 | assertIO( 258 | client.expect[String](s"http://localhost:$port/app/spaRoute").map(_.filterNot(_.isWhitespace)), 259 | vanilla.render.filterNot(_.isWhitespace) 260 | ) 261 | 262 | } 263 | 264 | // TODO: Test that the map of hashes is updated, when an external build tool is responsible for refresh pulses 265 | 266 | ResourceFunFixture { 267 | files.flatMap { 268 | dir => 269 | val lsc = LiveServerConfig( 270 | baseDir = Some(dir.toString), 271 | port = Port.fromInt(basePort).get, 272 | openBrowserAt = "", 273 | preventBrowserOpen = true 274 | ) 275 | LiveServer.main(lsc).flatMap(_ => client) 276 | } 277 | }.test("no styles") { 278 | client => 279 | assertIO( 280 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$basePort"))), 281 | Ok 282 | ) >> 283 | assertIOBoolean(client.expect[String](s"http://localhost:$basePort").map(out => !out.contains("less"))) 284 | } 285 | 286 | ResourceFunFixture { 287 | files.flatMap { 288 | dir => 289 | val lsc = LiveServerConfig( 290 | baseDir = Some(dir.toString), 291 | stylesDir = Some(styleDir(dir).toString), 292 | port = Port.fromInt(basePort).get, 293 | openBrowserAt = "", 294 | preventBrowserOpen = true 295 | ) 296 | LiveServer.main(lsc).flatMap(_ => client) 297 | } 298 | }.test("with styles") { 299 | client => 300 | assertIO( 301 | client.status(org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$basePort"))), 302 | Ok 303 | ) >> 304 | assertIOBoolean( 305 | client 306 | .expect[String](s"http://localhost:$basePort") 307 | .map(out => out.contains("src=\"https://cdn.jsdelivr.net/npm/less")) 308 | ) >> 309 | assertIOBoolean( 310 | client.expect[String](s"http://localhost:$basePort").map(out => out.contains("less.watch()")) 311 | ) >> 312 | assertIO( 313 | client.status( 314 | org.http4s.Request[IO](Method.GET, Uri.unsafeFromString(s"http://localhost:$basePort/index.less")) 315 | ), 316 | Ok 317 | ) 318 | } 319 | 320 | override def afterAll(): Unit = 321 | super.afterAll() 322 | pw.close() 323 | end afterAll 324 | 325 | end PlaywrightTest 326 | 327 | def helloWorldCode(greet: String) = s""" 328 | //> using scala ${sjsls.BuildInfo.scalaVersion} 329 | //> using platform js 330 | //> using jsVersion ${sjsls.BuildInfo.scalaJsVersion} 331 | 332 | //> using dep org.scala-js::scalajs-dom::${sjsls.BuildInfo.scalaJsDom} 333 | //> using dep com.raquo::laminar::${sjsls.BuildInfo.laminar} 334 | 335 | //> using jsModuleKind es 336 | //> using jsModuleSplitStyleStr smallmodulesfor 337 | //> using jsSmallModuleForPackage webapp 338 | 339 | package webapp 340 | 341 | import org.scalajs.dom 342 | import org.scalajs.dom.document 343 | import com.raquo.laminar.api.L.{*, given} 344 | 345 | @main 346 | def main: Unit = 347 | renderOnDomContentLoaded( 348 | dom.document.getElementById("app"), 349 | interactiveApp 350 | ) 351 | 352 | def interactiveApp = 353 | val hiVar = Var("World") 354 | div( 355 | h1( 356 | s"$greet", 357 | child.text <-- hiVar.signal 358 | ), 359 | p("This is a simple example of a Laminar app."), 360 | // https://demo.laminar.dev/app/form/controlled-inputs 361 | input( 362 | typ := "text", 363 | controlled( 364 | value <-- hiVar.signal, 365 | onInput.mapToValue --> hiVar.writer 366 | ) 367 | ) 368 | ) 369 | """ 370 | -------------------------------------------------------------------------------- /sjsls/test/src/RoutesSpec.scala: -------------------------------------------------------------------------------- 1 | package io.github.quafadas.sjsls 2 | 3 | import java.security.MessageDigest 4 | import java.time.Instant 5 | import java.time.ZoneId 6 | import java.time.ZonedDateTime 7 | 8 | import scala.concurrent.duration.* 9 | 10 | import org.http4s.* 11 | import org.http4s.client.Client 12 | import org.http4s.implicits.* 13 | import org.http4s.server.middleware.ErrorAction 14 | import org.typelevel.ci.CIStringSyntax 15 | 16 | import fs2.concurrent.Topic 17 | import fs2.io.file.Files 18 | 19 | import scribe.Level 20 | import scribe.Scribe 21 | 22 | import cats.effect.* 23 | import cats.effect.kernel.Ref 24 | import cats.effect.std.MapRef 25 | 26 | import munit.CatsEffectSuite 27 | 28 | class RoutesSuite extends CatsEffectSuite: 29 | 30 | val md = MessageDigest.getInstance("MD5") 31 | val testStr = "const hi = 'Hello, world'" 32 | val simpleCss = "h1 {color: red;}" 33 | val testHash = md.digest(testStr.getBytes()).map("%02x".format(_)).mkString 34 | val testBinary = os.read.bytes(os.resource / "cat.webp") 35 | given filesInstance: Files[IO] = Files.forAsync[IO] 36 | 37 | val files = FunFixture[os.Path]( 38 | setup = test => 39 | // create a temp folder 40 | val tempDir = os.temp.dir() 41 | // create a file in the folder 42 | val tempFile = tempDir / "test.js" 43 | os.write(tempFile, testStr) 44 | os.write(tempDir / "test2.js", testStr) 45 | os.write(tempDir / "test3.js", testStr) 46 | os.write(tempDir / "test.wasm", testBinary) 47 | tempDir 48 | , 49 | teardown = tempDir => 50 | // Always gets called, even if test failed. 51 | os.remove.all(tempDir) 52 | ) 53 | 54 | val externalIndexHtml = FunFixture[os.Path]( 55 | setup = test => 56 | // create a temp folder 57 | val tempDir = os.temp.dir() 58 | // create a file in the folder 59 | val tempFile = tempDir / "index.html" 60 | os.write(tempFile, """Test

Test

""") 61 | os.write(tempDir / "index.less", simpleCss) 62 | os.write(tempDir / "image.webp", os.read.bytes(os.resource / "cat.webp")) 63 | tempDir 64 | , 65 | teardown = tempDir => 66 | // Always gets called, even if test failed. 67 | os.remove.all(tempDir) 68 | ) 69 | 70 | val externalIndexR = 71 | ResourceFunFixture { 72 | IO { 73 | val tempDir = os.temp.dir() 74 | // create a file in the folder 75 | val tempFile = tempDir / "index.html" 76 | os.write(tempFile, """Test

Test

""") 77 | os.write(tempDir / "index.less", simpleCss) 78 | os.write(tempDir / "image.webp", os.read.bytes(os.resource / "cat.webp")) 79 | tempDir 80 | }.toResource 81 | } 82 | 83 | val externalSyles = FunFixture[os.Path]( 84 | setup = test => 85 | val tempDir = os.temp.dir() 86 | os.write(tempDir / "index.less", "h1 {color: red;}") 87 | tempDir 88 | , 89 | teardown = tempDir => 90 | // Always gets called, even if test failed. 91 | os.remove.all(tempDir) 92 | ) 93 | 94 | override def beforeAll(): Unit = 95 | scribe 96 | .Logger 97 | .root 98 | .clearHandlers() 99 | .clearModifiers() 100 | .withHandler(minimumLevel = Some(Level.get("info").get)) 101 | .replace() 102 | 103 | files.test("seed map puts files in the map on start") { 104 | tempDir => 105 | for 106 | logger <- IO(scribe.cats[IO]).toResource 107 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 108 | _ <- updateMapRef(tempDir.toFs2, fileToHashRef)(logger).toResource 109 | yield fileToHashRef 110 | .get 111 | .map { 112 | map => 113 | assertEquals(map.size, 3) 114 | assertEquals(map.get("test.js"), Some(testHash)) 115 | } 116 | 117 | } 118 | 119 | files.test("watched map is updated") { 120 | tempDir => 121 | val toCheck = for 122 | logger <- IO(scribe.cats[IO]).toResource 123 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 124 | linkingTopic <- Topic[IO, Unit].toResource 125 | refreshTopic <- Topic[IO, Unit].toResource 126 | _ <- fileWatcher(fs2.io.file.Path(tempDir.toString), fileToHashRef, linkingTopic, refreshTopic)(logger) 127 | _ <- IO.sleep(200.millis).toResource // wait for watcher to start 128 | _ <- updateMapRef(tempDir.toFs2, fileToHashRef)(logger).toResource 129 | _ <- IO.blocking(os.write.over(tempDir / "test.js", "const hi = 'bye, world'")).toResource 130 | _ <- linkingTopic.publish1(()).toResource 131 | _ <- refreshTopic.subscribe(1).head.compile.resource.drain 132 | oldHash <- fileToHashRef.get.map(_("test.js")).toResource 133 | _ <- IO.blocking(os.write.over(tempDir / "test.js", "const hi = 'modified, workd'")).toResource 134 | _ <- linkingTopic.publish1(()).toResource 135 | _ <- refreshTopic.subscribe(1).head.compile.resource.drain 136 | newHash <- fileToHashRef.get.map(_("test.js")).toResource 137 | yield oldHash -> newHash 138 | 139 | toCheck.use { 140 | case (oldHash, newHash) => 141 | IO(assertNotEquals(oldHash, newHash)) >> 142 | IO(assertEquals(oldHash, "27b2d040a66fb938f134c4b66fb7e9ce")) >> 143 | IO(assertEquals(newHash, "3ebb82d4d6236c6bfbb90d65943b3e3d")) 144 | } 145 | 146 | } 147 | 148 | files.test( 149 | "That the routes serve files on first call with a 200, that the eTag is set, and on second call with a 304, that index.html is served from SPA" 150 | ) { 151 | tempDir => 152 | 153 | scribe 154 | .Logger 155 | .root 156 | .clearHandlers() 157 | .clearModifiers() 158 | .withHandler(minimumLevel = Some(Level.get("info").get)) 159 | .replace() 160 | 161 | val aLogger = scribe.cats[IO] 162 | 163 | def errorActionFor(service: HttpRoutes[IO], logger: Scribe[IO]) = ErrorAction.httpRoutes[IO]( 164 | service, 165 | (req, thr) => 166 | logger.trace(req.toString()) >> 167 | logger.error(thr) 168 | ) 169 | 170 | val app: Resource[IO, HttpApp[IO]] = for 171 | logger <- IO( 172 | scribe.cats[IO] 173 | ).toResource 174 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 175 | _ <- updateMapRef(tempDir.toFs2, fileToHashRef)(logger).toResource 176 | refreshPub <- Topic[IO, Unit].toResource 177 | theseRoutes: HttpRoutes[IO] <- routes( 178 | tempDir.toString, 179 | refreshPub, 180 | None, 181 | HttpRoutes.empty[IO], 182 | fileToHashRef, 183 | Some("app"), 184 | false, 185 | ScalaCli() 186 | )(logger) 187 | yield errorActionFor(theseRoutes, aLogger).orNotFound 188 | 189 | app.use { 190 | (served: HttpApp[IO]) => 191 | val client = Client.fromHttpApp(served) 192 | val request = Request[IO](uri = uri"/test.js") 193 | 194 | val checkResp1 = client 195 | .run(request) 196 | .use { 197 | response => 198 | assertEquals(response.status.code, 200) 199 | assertEquals(response.headers.get(ci"ETag").isDefined, true) 200 | assertEquals(response.headers.get(ci"ETag").get.head.value, testHash) 201 | IO.unit 202 | } 203 | 204 | val request2 = org 205 | .http4s 206 | .Request[IO](uri = org.http4s.Uri.unsafeFromString("/test.js")) 207 | .withHeaders( 208 | org.http4s.Headers.of(org.http4s.Header.Raw(ci"If-None-Match", testHash)) 209 | ) 210 | 211 | val requestWasm = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/test.wasm")) 212 | 213 | val checkWasm = client 214 | .run(requestWasm) 215 | .use { 216 | resp => 217 | assertEquals(resp.status.code, 200) 218 | assertEquals(resp.headers.get(ci"ETag").isDefined, true) 219 | IO.unit 220 | } 221 | 222 | val checkResp2 = client 223 | .run(request2) 224 | .use { 225 | resp2 => 226 | assertEquals(resp2.status.code, 304) 227 | IO.unit 228 | } 229 | 230 | val requestSpaRoute = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("app/anything/random")) 231 | val checkRespSpa = client 232 | .run(requestSpaRoute) 233 | .use { 234 | resp3 => 235 | assertEquals(resp3.status.code, 200) 236 | assertIOBoolean(resp3.bodyText.compile.string.map(_.contains("src=\"/main.js"))) >> 237 | IO.unit 238 | } 239 | 240 | val requestHtml = Request[IO](uri = uri"/") 241 | // val etag = "699892091" 242 | 243 | val checkRespHtml = client 244 | .run(requestHtml) 245 | .use { 246 | respH => 247 | assertEquals(respH.status.code, 200) 248 | assertEquals(respH.headers.get(ci"ETag").isDefined, true) 249 | IO.unit 250 | } 251 | checkResp1 >> checkResp2 >> checkWasm >> checkRespSpa >> checkRespHtml 252 | 253 | } 254 | } 255 | 256 | FunFixture 257 | .map2(files, externalIndexHtml) 258 | .test("The files configured externally are served") { 259 | (appDir, staticDir) => 260 | val app = for 261 | logger <- IO(scribe.cats[IO]).toResource 262 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 263 | fileToHashMapRef = MapRef.fromSingleImmutableMapRef[IO, String, String](fileToHashRef) 264 | refreshPub <- Topic[IO, Unit].toResource 265 | theseRoutes <- routes( 266 | appDir.toString, 267 | refreshPub, 268 | Some(IndexHtmlConfig.IndexHtmlPath(staticDir.toFs2)), 269 | HttpRoutes.empty[IO], 270 | fileToHashRef, 271 | None, 272 | false, 273 | ScalaCli() 274 | )(logger) 275 | yield theseRoutes.orNotFound 276 | 277 | app.use { 278 | served => 279 | val requestHtml = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/index.html")) 280 | val requestLess = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/index.less")) 281 | 282 | val responseHtml = served(requestHtml) 283 | val responseLess = served(requestLess) 284 | 285 | assertIO(responseHtml.map(_.status.code), 200) >> 286 | assertIO(responseLess.map(_.status.code), 200) 287 | } 288 | } 289 | 290 | files.test("That we generate an index.html in the absence of config") { 291 | appDir => 292 | val app = for 293 | logger <- IO(scribe.cats[IO]).toResource 294 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 295 | fileToHashMapRef = MapRef.fromSingleImmutableMapRef[IO, String, String](fileToHashRef) 296 | refreshPub <- Topic[IO, Unit].toResource 297 | theseRoutes <- routes( 298 | appDir.toString, 299 | refreshPub, 300 | None, 301 | HttpRoutes.empty[IO], 302 | fileToHashRef, 303 | None, 304 | false, 305 | ScalaCli() 306 | )(logger) 307 | yield theseRoutes.orNotFound 308 | 309 | app.use { 310 | served => 311 | val requestHtml = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/index.html")) 312 | val requestRoot = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/")) 313 | 314 | val responseHtml = served(requestHtml) 315 | val responseRoot = served(requestRoot) 316 | assertIO(responseHtml.map(_.status.code), 200) >> 317 | assertIO(responseRoot.map(_.status.code), 200) 318 | } 319 | } 320 | 321 | FunFixture 322 | .map2(files, externalSyles) 323 | .test("That index.html and index.less is served with style config") { 324 | (appDir, styleDir) => 325 | val app = for 326 | logger <- IO(scribe.cats[IO]).toResource 327 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 328 | fileToHashMapRef = MapRef.fromSingleImmutableMapRef[IO, String, String](fileToHashRef) 329 | refreshPub <- Topic[IO, Unit].toResource 330 | theseRoutes <- routes( 331 | appDir.toString, 332 | refreshPub, 333 | Some(IndexHtmlConfig.StylesOnly(styleDir.toFs2)), 334 | HttpRoutes.empty[IO], 335 | fileToHashRef, 336 | None, 337 | false, 338 | ScalaCli() 339 | )(logger) 340 | yield theseRoutes.orNotFound 341 | 342 | app.use { 343 | served => 344 | val requestHtml = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/index.html")) 345 | val requestLess = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/index.less")) 346 | 347 | val responseHtml = served(requestHtml) 348 | val responseLess = served(requestLess) 349 | 350 | assertIO(responseHtml.map(_.status.code), 200) >> 351 | assertIO(responseLess.map(_.status.code), 200) >> 352 | assertIO(responseLess.flatMap(_.bodyText.compile.string), simpleCss) 353 | } 354 | } 355 | 356 | FunFixture 357 | .map2(files, externalSyles) 358 | .test("That styles and SPA play nicely together") { 359 | (appDir, styleDir) => 360 | val app = for 361 | logger <- IO(scribe.cats[IO]).toResource 362 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 363 | fileToHashMapRef = MapRef.fromSingleImmutableMapRef[IO, String, String](fileToHashRef) 364 | refreshPub <- Topic[IO, Unit].toResource 365 | theseRoutes <- routes( 366 | appDir.toString, 367 | refreshPub, 368 | Some(IndexHtmlConfig.StylesOnly(styleDir.toFs2)), 369 | HttpRoutes.empty[IO], 370 | fileToHashRef, 371 | Some("app"), 372 | false, 373 | ScalaCli() 374 | )(logger) 375 | yield theseRoutes.orNotFound 376 | 377 | app.use { 378 | served => 379 | val requestLess = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/index.less")) 380 | val responseLess = served(requestLess) 381 | assertIO(responseLess.map(_.status.code), 200) >> 382 | assertIO(responseLess.flatMap(_.bodyText.compile.string), simpleCss) 383 | } 384 | } 385 | 386 | externalIndexHtml.test("Static files are updated when needed and cached otherwise") { 387 | staticDir => 388 | val app = for 389 | logger <- IO(scribe.cats[IO]).toResource 390 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 391 | fileToHashMapRef = MapRef.fromSingleImmutableMapRef[IO, String, String](fileToHashRef) 392 | refreshPub <- Topic[IO, Unit].toResource 393 | _ <- logger.trace(os.stat(staticDir / "image.webp").toString()).toResource 394 | modifedAt <- fileLastModified((staticDir / "image.webp").toFs2) 395 | .map { 396 | seconds => 397 | httpCacheFormat(ZonedDateTime.ofInstant(Instant.ofEpochSecond(seconds), ZoneId.of("GMT"))) 398 | } 399 | .toResource 400 | theseRoutes <- routes( 401 | os.temp.dir().toString, 402 | refreshPub, 403 | Some(IndexHtmlConfig.IndexHtmlPath(staticDir.toFs2)), 404 | HttpRoutes.empty[IO], 405 | fileToHashRef, 406 | None, 407 | false, 408 | ScalaCli() 409 | )(logger) 410 | yield (theseRoutes.orNotFound, logger, modifedAt) 411 | 412 | app.use { 413 | case (served, logger, firstModified) => 414 | val request1 = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("/image.webp")) 415 | 416 | val request2 = org 417 | .http4s 418 | .Request[IO](uri = org.http4s.Uri.unsafeFromString("/image.webp")) 419 | .withHeaders( 420 | org.http4s.Headers.of(org.http4s.Header.Raw(ci"If-Modified-Since", firstModified.toString())) 421 | ) 422 | 423 | served(request1).flatTap(r => logger.debug("headers" + r.headers.headers.mkString(","))) >> 424 | logger.trace("first modified " + firstModified) >> 425 | // You need these ... otherwise no caching. 426 | // https://simonhearne.com/2022/caching-header-best-practices/ 427 | assertIOBoolean(served(request1).map(_.headers.get(ci"ETag").isDefined)) >> 428 | assertIOBoolean(served(request1).map(_.headers.get(ci"Cache-Control").isDefined)) >> 429 | assertIOBoolean(served(request1).map(_.headers.get(ci"Expires").isDefined)) >> 430 | assertIOBoolean(served(request1).map(_.headers.get(ci"Last-Modified").isDefined)) >> 431 | // Don't forget to set them _all_ 432 | assertIO(served(request1).map(_.status.code), 200) >> 433 | assertIO(served(request2).map(_.status.code), 304) >> 434 | IO.sleep( 435 | 1500.millis 436 | ) >> 437 | IO.blocking(os.write.over(staticDir / "image.webp", os.read.bytes(os.resource / "dog.webp"))) >> 438 | served(request2).flatMap(_.bodyText.compile.string).flatMap(s => logger.trace(s)) >> 439 | assertIO(served(request2).map(_.status.code), 200) 440 | 441 | } 442 | } 443 | 444 | externalIndexHtml.test("Client SPA routes return index.html") { 445 | staticDir => 446 | def cacheFormatTime = fileLastModified((staticDir / "index.html").toFs2).map { 447 | seconds => 448 | httpCacheFormat(ZonedDateTime.ofInstant(Instant.ofEpochSecond(seconds), ZoneId.of("GMT"))) 449 | } 450 | 451 | val app = for 452 | logger <- IO(scribe.cats[IO]).toResource 453 | fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource 454 | fileToHashMapRef = MapRef.fromSingleImmutableMapRef[IO, String, String](fileToHashRef) 455 | refreshPub <- Topic[IO, Unit].toResource 456 | theseRoutes <- routes( 457 | os.temp.dir().toString, 458 | refreshPub, 459 | Some(IndexHtmlConfig.IndexHtmlPath(staticDir.toFs2)), 460 | HttpRoutes.empty[IO], 461 | fileToHashRef, 462 | Some("app"), 463 | false, 464 | ScalaCli() 465 | )(logger) 466 | yield (theseRoutes.orNotFound, logger) 467 | 468 | app 469 | .both(cacheFormatTime.toResource) 470 | .use { 471 | case ((served, logger), firstModified) => 472 | val request1 = org.http4s.Request[IO](uri = org.http4s.Uri.unsafeFromString("app/whocare")) 473 | 474 | val request2 = org 475 | .http4s 476 | .Request[IO](uri = org.http4s.Uri.unsafeFromString("/index.html")) 477 | .withHeaders( 478 | org.http4s.Headers.of(org.http4s.Header.Raw(ci"If-Modified-Since", firstModified.toString())) 479 | ) 480 | 481 | served(request1).flatTap(r => logger.debug("headers" + r.headers.headers.mkString(","))) >> 482 | logger.trace("first modified " + firstModified) >> 483 | // You need these ... otherwise no caching. 484 | // https://simonhearne.com/2022/caching-header-best-practices/ 485 | assertIOBoolean(served(request1).map(_.headers.get(ci"ETag").isDefined)) >> 486 | assertIOBoolean(served(request1).map(_.headers.get(ci"Cache-Control").isDefined)) >> 487 | assertIOBoolean(served(request1).map(_.headers.get(ci"Expires").isDefined)) >> 488 | assertIOBoolean(served(request1).map(_.headers.get(ci"Last-Modified").isDefined)) >> 489 | // Don't forget to set them _all_ 490 | assertIO(served(request1).map(_.status.code), 200) >> 491 | assertIO(served(request2).map(_.status.code), 304) >> 492 | IO.sleep( 493 | 1500.millis 494 | ) >> // have to wait at least one second otherwish last modified could be the same, if test took <1 sceond to get to this point 495 | IO.blocking(os.write.over(staticDir / "index.html", """Test""")) >> 496 | served(request2).flatMap(_.bodyText.compile.string).flatMap(s => logger.trace(s)) >> 497 | assertIO(served(request2).map(_.status.code), 200) 498 | 499 | } 500 | } 501 | 502 | end RoutesSuite 503 | --------------------------------------------------------------------------------