From 6f5e118b88c516316e3430491f5ac7bb4fba572a Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 2 Sep 2025 14:44:26 +0200 Subject: [PATCH 1/5] . --- build.mill | 25 ++-- plugin/package.mill | 44 +++++++ plugin/src/refresh_plugin.scala | 112 +++++++++++++++++ site/docs/Configuration/config.md | 20 ++- site/docs/Configuration/library.md | 3 + site/docs/advantages.md | 2 +- site/docs/caveats.md | 4 +- site/docs/deployment.md | 2 +- site/docs/index.md | 9 +- site/docs/motivation.md | 2 +- site/package.mill | 19 +++ sjsls/package.mill | 29 ++++- sjsls/src/CliOpts.scala | 138 +++++++++++++++++++++ sjsls/src/CliValidationError.scala | 5 + sjsls/src/LiveServerConfig.scala | 26 ++++ sjsls/src/buildRunner.scala | 4 +- sjsls/src/dezombify.scala | 132 ++++++++++++++++++++ sjsls/src/htmlGen.scala | 21 ++-- sjsls/src/liveServer.scala | 177 ++------------------------- sjsls/src/makeProxyConfig.scala | 21 ++++ sjsls/test/src/dezombify.test.scala | 42 +++++++ sjsls/test/src/liveServer.test.scala | 1 - 22 files changed, 616 insertions(+), 222 deletions(-) create mode 100644 plugin/package.mill create mode 100644 plugin/src/refresh_plugin.scala create mode 100644 site/docs/Configuration/library.md create mode 100644 site/package.mill create mode 100644 sjsls/src/CliOpts.scala create mode 100644 sjsls/src/CliValidationError.scala create mode 100644 sjsls/src/LiveServerConfig.scala create mode 100644 sjsls/src/dezombify.scala create mode 100644 sjsls/src/makeProxyConfig.scala create mode 100644 sjsls/test/src/dezombify.test.scala diff --git a/build.mill b/build.mill index a338ae3..0ed2994 100644 --- a/build.mill +++ b/build.mill @@ -1,21 +1,21 @@ //| mill-jvm-version: 21 -//| mill-version: 1.0.3 +//| mill-version: 1.0.4 //| mvnDeps: //| - com.lihaoyi::mill-contrib-buildinfo:$MILL_VERSION //| - com.goyeau::mill-scalafix::0.6.0 - - -package build +//| - io.github.quafadas:millSite_mill1_3.7:0.0.50 import os.copy.over // import io.github.quafadas.millSite._ import mill._, scalalib._, publish._, scalanativelib._ import mill.scalalib.scalafmt.ScalafmtModule import mill.util.* +import mill.util.BuildInfo.millVersion import com.goyeau.mill.scalafix.ScalafixModule import java.text.Format -// import io.github.quafadas.millSite.SiteModule +import io.github.quafadas.millSite.SiteModule +import mill.util.VcsVersion object V{ @@ -27,6 +27,8 @@ object V{ val laminar = "17.2.1" val scalaJsDom = "2.8.1" val scalaJs = "1.19.0" + val fs2 = "3.11.0" + val millLibs = mvn"com.lihaoyi::mill-libs:$millVersion" } trait FormatFix extends ScalafmtModule with ScalafixModule with ScalaModule @@ -67,19 +69,6 @@ trait Testy extends TestModule.Munit with FormatFix { } -// object site extends SiteModule { - -// def scalaVersion = V.scalaVersion -// def unidocDeps = Seq(build.sjsls, build.routes) -// override def unidocTitle = "Scala JS Live Server API" - -// override def repoLink = "https://github.com/Quafadas/live-server-scala-cli-js" - -// override def latestVersion = "0.2.11" - - -// } - // SN deps which aren't yet there. /** 1 targets failed diff --git a/plugin/package.mill b/plugin/package.mill new file mode 100644 index 0000000..377ad43 --- /dev/null +++ b/plugin/package.mill @@ -0,0 +1,44 @@ +package build.plugin + +import mill.util.BuildInfo. millBinPlatform +import mill._, scalalib._, publish._ +import mill.util.VcsVersion +import build.V + +object `package` extends ScalaModule with PublishModule: + def platformSuffix = s"_mill$millBinPlatform" + + def scalaVersion = build.V.scalaVersion + + def scalaArtefactVersion: Task[String] = + scalaVersion.map(_.split("\\.").take(2).mkString(".")) + + override def artifactName = "sjsls_plugin" + + def mvnDeps = Task{ + super.mvnDeps() ++ + Seq( + V.millLibs, + mvn"co.fs2:fs2-io_3:${V.fs2}" + ) + } + + def moduleDeps = Seq(build.sjsls) + + def artifactSuffix = s"${platformSuffix()}_${scalaArtefactVersion()}" + + def publishVersion = VcsVersion.vcsState().format() + // def publishVersion = "DONTUSEME" + + override def pomSettings = Task { + PomSettings( + description = "Mill plugin for mdoc, static site generation", + organization = "io.github.quafadas", + url = "https://github.com/Quafadas/millSite", + licenses = Seq(License.`Apache-2.0`), + versionControl = VersionControl.github("quafadas", "millSite"), + developers = Seq( + Developer("quafadas", "Simon Parten", "https://github.com/quafadas") + ) + ) + } \ No newline at end of file diff --git a/plugin/src/refresh_plugin.scala b/plugin/src/refresh_plugin.scala new file mode 100644 index 0000000..054b42e --- /dev/null +++ b/plugin/src/refresh_plugin.scala @@ -0,0 +1,112 @@ +package io.github.quafadas.RefreshPlugin + +import io.github.quafadas.sjsls.LiveServerConfig +import mill.* +import mill.scalalib.* +import mill.scalajslib.* +import os.Path +import mill.api.Task.Simple +import fs2.concurrent.Topic +import cats.effect.IO +// import mill.scalajslib.* +// import coursier.maven.MavenRepository +// import mill.api.Result +// import mill.util.Jvm.createJar +// import mill.define.PathRef +// import mill.scalalib.api.CompilationResult +// // import de.tobiasroeser.mill.vcs.version.VcsVersion +// import scala.util.Try +// import mill.scalalib.publish.PomSettings +// import mill.scalalib.publish.License +// import mill.scalalib.publish.VersionControl +// import os.SubPath +// import ClasspathHelp.* +import cats.effect.unsafe.implicits.global +import io.github.quafadas.sjsls.LiveServerConfig +import cats.effect.ExitCode +import scala.util.{Try, Success, Failure} +import scala.concurrent.Future +import mill.api.BuildCtx +import mill.scalajslib.api.Report +implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global + +trait ScalaJsRefreshModule extends ScalaJSModule: + + lazy val updateServer = Topic[IO, Unit].unsafeRunSync() + + + def indexHtml = Task{ + os.write.over(Task.dest / "index.html", io.github.quafadas.sjsls.vanillaTemplate) + PathRef(Task.dest / "index.html") + } + + def assetsDir = Task{ + "assets" + } + + def assets = Task{ + os.write.over(Task.dest / "style.css", io.github.quafadas.sjsls.lessStyle(true).render) + PathRef(Task.dest / assetsDir()) + } + + def port = Task { + 8080 + } + + def openBrowser= Task { + true + } + + def logLevel = Task { + "warn" + } + + def dezombify = Task { + true + } + + def siteGen = Task{ + val assets_ = assets() + val path = fastLinkJS().dest + os.copy.over(Task.dest / "index.html", indexHtml().path) + os.copy.over(Task.dest / assetsDir(), assets_.path) + updateServer.publish1(println("publishing update")) + (assets_.path.toString(), path.path.toString()) + } + + def lcs = Task.Worker{ + val (assets, js) = siteGen() + LiveServerConfig( + baseDir = None, + outDir = Some(js), + port = com.comcast.ip4s.Port.fromInt(port()).getOrElse(throw new IllegalArgumentException(s"invalid port: ${port()}")), + indexHtmlTemplate = Some(assets), + buildTool = io.github.quafadas.sjsls.NoBuildTool(), // Here we are a slave to the build tool + openBrowserAt = "/index.html", + preventBrowserOpen = !openBrowser(), + dezombify = dezombify(), + logLevel = logLevel() + ) + } + + def serve = Task.Worker{ + // Let's kill off anything that is a zombie on the port we want to use + val p = port() + + BuildCtx.withFilesystemCheckerDisabled { + new RefreshServer(lcs()) + } + } + + class RefreshServer(lcs: LiveServerConfig) extends AutoCloseable { + val server = io.github.quafadas.sjsls.LiveServer.main(lcs).allocated + + server.map(_._1).unsafeRunSync() + + override def close(): Unit = { + // This is the shutdown hook for http4s + println("Shutting down server...") + server.map(_._2).flatten.unsafeRunSync() + } + } + diff --git a/site/docs/Configuration/config.md b/site/docs/Configuration/config.md index c631a12..548ddb2 100644 --- a/site/docs/Configuration/config.md +++ b/site/docs/Configuration/config.md @@ -1,9 +1,9 @@ # Config -The server is a CLI. It has a number of flags that can be used to configure it. Here is the current list of flags and what they do. You can see these flags by running ` --help` in your terminal. +The CLI launches an http server. It has a number of flags that can be used to configure it. Here is the current list of flags and what they do. You can see these flags by running ` --help` in your terminal. -``` -cs launch io.github.quafadas::sjsls:{{projectVersion}} -- --help +```sh +cs launch io.github.quafadas::sjsls:latest.version -- --help ``` @@ -56,7 +56,7 @@ Fire up a terminal in projectDir ``` ```sh -cs launch io.github.quafadas::sjsls:{{projectVersion}} +cs launch io.github.quafadas::sjsls:latest.version ``` This is the classic [viteless](https://github.com/Quafadas/viteless/tree/main) example @@ -73,7 +73,7 @@ With styles. Run ```sh -cs launch io.github.quafadas::sjsls:{{projectVersion}} -- --styles-dir --fully/qualified/dir/to/styles +cs launch io.github.quafadas::sjsls:latest.version -- --styles-dir --fully/qualified/dir/to/styles ``` ## Did I mention I want a full blown SPA? @@ -89,7 +89,7 @@ With client side routing under `/app`? Run ```sh -cs launch io.github.quafadas::sjsls:{{projectVersion}} -- --client-routes-prefix app +cs launch io.github.quafadas::sjsls:latest.version -- --client-routes-prefix app ``` ## Stop generating my HTML. I want to bring my own. @@ -105,7 +105,7 @@ Okay. ``` With ```sh -cs launch io.github.quafadas::sjsls:{{projectVersion}} -- --path-to-index-html fully/qualified/path/to/assets +cs launch io.github.quafadas::sjsls:latest.version -- --path-to-index-html fully/qualified/path/to/assets ``` Note: if you're brining your own html, drop the `--styles` flag - reference `index.less` from your html and read [docs](https://lesscss.org) to get it working in browser. @@ -134,7 +134,7 @@ With a backend running on `8080` and a frontend on `3000`, it is configured that 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. ```sh -cs launch io.github.quafadas::sjsls:{{projectVersion}} -- \ +cs launch io.github.quafadas::sjsls:latest.version -- \ --path-to-index-html /Users/simon/Code/mill-full-stack/frontend/ui \ --build-tool mill \ --mill-module-name frontend \ @@ -153,7 +153,6 @@ This would serve the static site build with the `docJar` tool. 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 ``` -*** You need to include this javascript script tag in the body html - otherwise no page refresh. ```html @@ -167,5 +166,4 @@ You need to include this javascript script tag in the body html - otherwise no p if ("PageRefresh" in msg) location.reload(); }); -``` -*** \ No newline at end of file +``` \ No newline at end of file diff --git a/site/docs/Configuration/library.md b/site/docs/Configuration/library.md new file mode 100644 index 0000000..0a1c60d --- /dev/null +++ b/site/docs/Configuration/library.md @@ -0,0 +1,3 @@ +# Library + +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. \ No newline at end of file diff --git a/site/docs/advantages.md b/site/docs/advantages.md index d18fd95..911226a 100644 --- a/site/docs/advantages.md +++ b/site/docs/advantages.md @@ -8,7 +8,7 @@ Here are the key advantages of this approach: - 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. -- In terms of performance; NPM dependancies are loaded out the CDN. This is slow the first time - but seriously - 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!_. If you pre-load the (fat) "internal-" xxx dependancies scalaJS produces, this combination crushes page load times. +- 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!_. - You can use the same build tool for both backend and frontend, and share code between them. diff --git a/site/docs/caveats.md b/site/docs/caveats.md index 967854e..f0bd5ae 100644 --- a/site/docs/caveats.md +++ b/site/docs/caveats.md @@ -12,6 +12,6 @@ It is usually possible to work around such limitations, but it is not pain free. I strongly suspect though that as time marches formward ESModules will become the norm, and this limitation will become irrelevant. -# Scale +## Support -This project is not backed by anyone making money from it. Support is therefore ad hoc and limited. \ No newline at end of file +This project is not backed by any power larger than my curiosity. \ No newline at end of file diff --git a/site/docs/deployment.md b/site/docs/deployment.md index ac28939..931bb54 100644 --- a/site/docs/deployment.md +++ b/site/docs/deployment.md @@ -1,4 +1,4 @@ -title: Deployment +Deployment --- This project targets the dev loop. When comes to deploy however, I always hit the same problem. From discord; diff --git a/site/docs/index.md b/site/docs/index.md index 391d5a0..8a37f70 100644 --- a/site/docs/index.md +++ b/site/docs/index.md @@ -4,17 +4,15 @@ Paste this into your terminal and hit enter. -${version.latest} - ```sh scala-cli --version && \ cs version && \ git clone https://github.com/Quafadas/viteless.git && \ cd viteless && \ -cs launch io.github.quafadas::sjsls:${{version.latest}} +cs launch io.github.quafadas::sjsls:latest.release ``` -Note that to work, you need the following directives in scala-cli: +Note that to work, you need cs and scala-cli to be on your path. ## It worked... okay... I have 20 more seconds @@ -26,11 +24,10 @@ Edit `hello.scala` and save the change. You should see the change refreshed in y ## Aw shoot - errors -The command above assumes you have coursier (as cs) and scala-cli installed and available on your path. +The command above assumes you have coursier (as cs) and scala-cli and git installed and available on your path. 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 ... - [coursier](https://get-coursier.io/docs/cli-installation) - [scala-cli](https://scala-cli.virtuslab.org) -Once installed, give it another go. diff --git a/site/docs/motivation.md b/site/docs/motivation.md index c205fb2..8b78424 100644 --- a/site/docs/motivation.md +++ b/site/docs/motivation.md @@ -2,6 +2,6 @@ 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. -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. +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. I wanted to break the dependance on node / npm completely whilst retaining a sane developer experience for browser based scala-js development. \ No newline at end of file diff --git a/site/package.mill b/site/package.mill new file mode 100644 index 0000000..977eba1 --- /dev/null +++ b/site/package.mill @@ -0,0 +1,19 @@ +package build.site + +import mill._, scalalib._ +import mill.scalajslib.api._ +import io.github.quafadas.millSite._ +import mill.util.VcsVersion +import build.V + +object `package` extends SiteModule { + + def moduleDeps = Seq(build.sjsls, build.routes) + + override def unidocTitle = "Scala JS Live Server API" + + override def repoLink = "https://github.com/Quafadas/live-server-scala-cli-js" + + override def latestVersion = VcsVersion.vcsState().lastTag.getOrElse("0.0.0").replace("v", "") + +} \ No newline at end of file diff --git a/sjsls/package.mill b/sjsls/package.mill index 8b08ecb..99acb6d 100644 --- a/sjsls/package.mill +++ b/sjsls/package.mill @@ -6,6 +6,8 @@ import mill.scalalib.* import mill.scalajslib.* import mill.scalalib.publish.* import mill.contrib.buildinfo.BuildInfo +import mill.api.Task.Simple +import scala.util.Try object `package` extends FormatFixPublish: override def scalaVersion = V.scalaLts @@ -16,7 +18,7 @@ object `package` extends FormatFixPublish: mvn"org.http4s::http4s-scalatags::0.25.2", mvn"io.circe::circe-core::${V.circeVersion}", mvn"io.circe::circe-generic::${V.circeVersion}", - mvn"co.fs2::fs2-io::3.11.0", + mvn"co.fs2::fs2-io::${V.fs2}", mvn"com.lihaoyi::scalatags::0.13.1", mvn"com.monovore::decline::2.5.0", mvn"com.monovore::decline-effect::2.5.0" @@ -39,14 +41,32 @@ object `package` extends FormatFixPublish: mvn"com.microsoft.playwright:playwright:${V.pwV}", mvn"com.microsoft.playwright:driver-bundle:${V.pwV}" ) - override def resources = super.resources + override def resources = super.resources() + + override def forkEnv: Simple[Map[String, String]] = Task{ + squattyServer() + super.forkEnv() + } + + def squattyServer = Task.Worker{ + println("Starting up a zombie server on port 6789") + Try{ + os.write.over(Task.dest / "hi.md", "hi") + os.spawn( + cmd = ("jwebserver", "-p", "6789"), + cwd = Task.dest + ) + } + Task.dest + } override def runClasspath = Task { sjsls.cacheJsLibs.resolvedMvnDeps() super.runClasspath() } + end test - + object cacheJsLibs extends ScalaJSModule: def scalaVersion = V.scalaVersion def scalaJSVersion = V.scalaJs @@ -55,8 +75,7 @@ object `package` extends FormatFixPublish: mvn"org.scala-js::scalajs-dom::${V.scalaJsDom}", mvn"com.raquo::laminar::${V.laminar}" ) - end cacheJsLibs + end cacheJsLibs // def scalaNativeVersion = "0.4.17" // aspirational :-) -end `package` diff --git a/sjsls/src/CliOpts.scala b/sjsls/src/CliOpts.scala new file mode 100644 index 0000000..632aca9 --- /dev/null +++ b/sjsls/src/CliOpts.scala @@ -0,0 +1,138 @@ +package io.github.quafadas.sjsls + +import com.monovore.decline.Opts +import com.comcast.ip4s.Port + +private[sjsls] object CliOps: + val logLevelOpt: Opts[String] = Opts + .option[String]("log-level", help = "The log level. info, debug, error, trace)") + .withDefault("info") + .validate("Invalid log level") { + case "info" => true + case "debug" => true + case "error" => true + case "warn" => true + case "trace" => true + case _ => false + } + + val openBrowserAtOpt = + Opts + .option[String]( + "browse-on-open-at", + "A suffix to localhost where we'll open a browser window on server start - e.g. /ui/greatPage OR just `/` for root " + ) + .withDefault("/") + + val baseDirOpt = + Opts + .option[String]("project-dir", "The fully qualified location of your project - e.g. c:/temp/helloScalaJS") + .orNone + + val outDirOpt = Opts + .option[String]( + "out-dir", + "Where the compiled JS will be compiled to - e.g. c:/temp/helloScalaJS/.out. If no file is given, a temporary directory is created." + ) + .orNone + + val portOpt = Opts + .option[Int]("port", "The port you want to run the server on - e.g. 3000") + .withDefault(3000) + .validate("Port must be between 1 and 65535")(i => i > 0 && i < 65535) + .map(i => Port.fromInt(i).get) + + val proxyPortTargetOpt = Opts + .option[Int]("proxy-target-port", "The port you want to forward api requests to - e.g. 8080") + .orNone + .validate("Proxy Port must be between 1 and 65535")(iOpt => iOpt.fold(true)(i => i > 0 && i < 65535)) + .map(i => i.flatMap(Port.fromInt)) + + val proxyPathMatchPrefixOpt = Opts + .option[String]("proxy-prefix-path", "Match routes starting with this prefix - e.g. /api") + .orNone + + val clientRoutingPrefixOpt = Opts + .option[String]( + "client-routes-prefix", + "Routes starting with this prefix e.g. /app will return index.html. This enables client side routing via e.g. waypoint" + ) + .orNone + + val buildToolOpt = Opts + .option[String]("build-tool", "scala-cli or mill") + .validate("Invalid build tool") { + case "scala-cli" => true + case "mill" => true + case "none" => true + case _ => false + } + .withDefault("scala-cli") + .map { + _ match + case "scala-cli" => ScalaCli() + case "mill" => Mill() + case "none" => NoBuildTool() + } + + val injectPreloadsOpt = Opts + .flag( + "inject-preloads", + "Whether or not to attempt injecting module preloads into the index.html, potentially speeds up page load, but may not work with all servers and or cause instability in the refresh process." + ) + .orFalse + + val extraBuildArgsOpt: Opts[List[String]] = Opts + .options[String]( + "extra-build-args", + "Extra arguments to pass to the build tool" + ) + .orEmpty + + val stylesDirOpt: Opts[Option[String]] = Opts + .option[String]( + "styles-dir", + "A fully qualified path to your styles directory with LESS files in - e.g. c:/temp/helloScalaJS/styles" + ) + .orNone + + val indexHtmlTemplateOpt: Opts[Option[String]] = Opts + .option[String]( + "path-to-index-html", + "a path to a directory which contains index.html. The entire directory will be served as static assets" + ) + .orNone + + val preventBrowserOpenOpt = Opts + .flag( + "prevent-browser-open", + "prevent the browser from opening on server start" + ) + .orFalse + + val millModuleNameOpt: Opts[Option[String]] = Opts + .option[String]( + "mill-module-name", + "Extra arguments to pass to the build tool" + ) + .validate("mill module name cannot be blank") { + case "" => false + case _ => true + } + .orNone + + val buildToolInvocation: Opts[Option[String]] = Opts + .option[String]( + "build-tool-invocation", + "This string will be passed to an fs2 process which invokes the build tool. By default it's 'scala-cli', or `mill`, " + + "and is assumed is on the path" + ) + .orNone + + val dezombifyOpt = Opts + .flag( + "dezombify", + "Whether or not to attempt killing any processes that are using the specified port. Default: true" + ) + .orTrue + diff --git a/sjsls/src/CliValidationError.scala b/sjsls/src/CliValidationError.scala new file mode 100644 index 0000000..957c317 --- /dev/null +++ b/sjsls/src/CliValidationError.scala @@ -0,0 +1,5 @@ +package io.github.quafadas.sjsls + +import scala.util.control.NoStackTrace + +private case class CliValidationError(message: String) extends NoStackTrace \ No newline at end of file diff --git a/sjsls/src/LiveServerConfig.scala b/sjsls/src/LiveServerConfig.scala new file mode 100644 index 0000000..7327b10 --- /dev/null +++ b/sjsls/src/LiveServerConfig.scala @@ -0,0 +1,26 @@ +package io.github.quafadas.sjsls + +import com.comcast.ip4s.Port +import fs2.concurrent.Topic +import cats.effect.IO + +case class LiveServerConfig( + baseDir: Option[String], + outDir: Option[String] = None, + port: Port, + proxyPortTarget: Option[Port] = None, + proxyPathMatchPrefix: Option[String] = None, + clientRoutingPrefix: Option[String] = None, + logLevel: String = "info", + buildTool: BuildTool = ScalaCli(), + openBrowserAt: String, + preventBrowserOpen: Boolean = false, + extraBuildArgs: List[String] = List.empty, + millModuleName: Option[String] = None, + stylesDir: Option[String] = None, + indexHtmlTemplate: Option[String] = None, + buildToolInvocation: Option[String] = None, + injectPreloads: Boolean = false, + dezombify: Boolean = true, + customRefresh: Option[Topic[IO, Unit]] = None +) \ No newline at end of file diff --git a/sjsls/src/buildRunner.scala b/sjsls/src/buildRunner.scala index 58670f2..29a2047 100644 --- a/sjsls/src/buildRunner.scala +++ b/sjsls/src/buildRunner.scala @@ -24,7 +24,7 @@ class Mill if isWindows then "mill.bat" else "mill" ) -class None extends BuildTool("") +class NoBuildTool extends BuildTool("") private lazy val isWindows: Boolean = System.getProperty("os.name").toLowerCase(Locale.ENGLISH).contains("windows") @@ -52,7 +52,7 @@ def buildRunner( invokeVia, extraBuildArgs )(logger) - case n: None => logger.info("No build tool specified, skipping build").toResource + case n: NoBuildTool => logger.info("No build tool specified, skipping build").toResource end match end buildRunner diff --git a/sjsls/src/dezombify.scala b/sjsls/src/dezombify.scala new file mode 100644 index 0000000..63ca347 --- /dev/null +++ b/sjsls/src/dezombify.scala @@ -0,0 +1,132 @@ +package io.github.quafadas.sjsls + +import cats.effect.IO +import fs2.io.process +import cats.effect.kernel.Resource +import com.comcast.ip4s.Port +import scala.concurrent.duration._ + +private[sjsls] def checkPortInUse(port: Port): IO[Boolean] = { + val osName = System.getProperty("os.name").toLowerCase + val portInt = port.value + + if (osName.contains("win")) { + val ps = s"Get-NetTCPConnection -LocalPort $portInt -ErrorAction SilentlyContinue | Measure-Object | Select-Object -ExpandProperty Count" + + process.ProcessBuilder("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps) + .spawn[IO] + .use { proc => + proc.stdout.through(fs2.text.utf8.decode).compile.string.map(_.trim.toIntOption.getOrElse(0) > 0) + } + } else { + val sh = s"lsof -ti tcp:$portInt 2>/dev/null | wc -l" + + process.ProcessBuilder("sh", "-c", sh) + .spawn[IO] + .use { proc => + proc.stdout.through(fs2.text.utf8.decode).compile.string.map(_.trim.toIntOption.getOrElse(0) > 0) + } + } +} + +private[sjsls] def dezombify(port: Port): Resource[IO, Unit] = { + val portInt = port.value + + val checkAndKill = for { + _ <- scribe.cats[IO].debug(s"Checking if port $portInt is in use before attempting cleanup") + portInUse <- checkPortInUse(port) + _ <- if (portInUse) { + scribe.cats[IO].warn(s"Found zombie server on port $portInt - attempting to kill it") + } else { + scribe.cats[IO].debug(s"Port $portInt appears to be free, no zombie cleanup needed") + } + _ <- if (portInUse) killProcessesOnPort(port) else IO.unit + _ <- if (portInUse) { + for { + _ <- IO.sleep(scala.concurrent.duration.Duration.fromNanos(500_000_000)) // 500ms + stillInUse <- checkPortInUse(port) + _ <- if (stillInUse) { + scribe.cats[IO].error(s"Port $portInt still appears to be in use after cleanup attempt") + } else { + scribe.cats[IO].debug(s"Successfully cleaned up port $portInt") + } + } yield () + } else IO.unit + } yield () + + checkAndKill.toResource + +} + +private def killProcessesOnPort(port: Port): IO[Unit] = { + val osName = System.getProperty("os.name").toLowerCase + val portInt = port.value + + if (osName.contains("win")) { + // Windows: try PowerShell Get-NetTCPConnection, fallback to netstat/taskkill + val ps = s""" + |if (Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue) { + | $$pids = Get-NetTCPConnection -LocalPort $portInt -ErrorAction SilentlyContinue | Select-Object -ExpandProperty OwningProcess -Unique + | if ($$pids) { + | Write-Host "Found PIDs: $$pids" + | $$pids | ForEach-Object { Stop-Process -Id $$_ -Force } + | Write-Host "Killed processes on port $portInt" + | } else { + | Write-Host "No processes found on port $portInt" + | } + |} else { + | $$lines = netstat -ano | Select-String ":$portInt\\s" + | $$pids = $$lines | ForEach-Object { ($$_ -split '\\s+')[-1] } | Select-Object -Unique + | if ($$pids) { + | Write-Host "Found PIDs: $$pids" + | $$pids | ForEach-Object { taskkill /F /PID $$_ } + | Write-Host "Killed processes on port $portInt" + | } else { + | Write-Host "No processes found on port $portInt" + | } + |} + |""".stripMargin + + for { + _ <- scribe.cats[IO].debug(s"Running Windows cleanup command for port $portInt") + exitCode <- process.ProcessBuilder("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps) + .spawn[IO] + .use(_.exitValue) + _ <- scribe.cats[IO].debug(s"Windows cleanup command completed with exit code $exitCode") + } yield () + + } else { + // macOS/Linux: use lsof if available, fallback to fuser + val sh = s""" + |if command -v lsof >/dev/null 2>&1; then + | pids=$$(lsof -ti tcp:$portInt 2>/dev/null) + | if [ -n "$$pids" ]; then + | echo "Found PIDs: $$pids" + | kill -9 $$pids + | echo "Killed processes on port $portInt" + | else + | echo "No processes found using lsof on port $portInt" + | fi + |elif command -v fuser >/dev/null 2>&1; then + | echo "Using fuser to kill processes on port $portInt" + | fuser -k -TERM $portInt/tcp || echo "No processes killed with TERM signal" + | fuser -k -KILL $portInt/tcp || echo "No processes killed with KILL signal" + |else + | echo "Neither lsof nor fuser available for port cleanup" + |fi + |""".stripMargin + + for { + _ <- scribe.cats[IO].debug(s"Running Unix cleanup command for port $portInt") + // Use a timeout to prevent hanging + result <- process.ProcessBuilder("sh", "-c", sh) + .spawn[IO] + .use(_.exitValue) + .timeout(5.seconds) + .handleErrorWith { err => + scribe.cats[IO].warn(s"Process cleanup timed out or failed: ${err.getMessage}") *> IO.pure(-1) + } + _ <- scribe.cats[IO].debug(s"Unix cleanup command completed with exit code $result") + } yield () + } +} \ No newline at end of file diff --git a/sjsls/src/htmlGen.scala b/sjsls/src/htmlGen.scala index 6a162fe..5d54c70 100644 --- a/sjsls/src/htmlGen.scala +++ b/sjsls/src/htmlGen.scala @@ -17,8 +17,9 @@ import scribe.Scribe import cats.effect.IO import cats.effect.kernel.Ref import cats.syntax.all.* +import scalatags.Text.TypedTag -def generatedIndexHtml( +private def generatedIndexHtml( injectStyles: Boolean, modules: Ref[IO, Map[String, String]], zdt: ZonedDateTime, @@ -49,7 +50,7 @@ def generatedIndexHtml( )(logger) ) -def lessStyle(withStyles: Boolean): Seq[Modifier] = +private def lessStyle(withStyles: Boolean): Seq[Modifier] = if withStyles then Seq( link( @@ -84,7 +85,7 @@ sse.addEventListener('message', (e) => { // def generateHtml(modules: Seq[(Path, String)]) = (template: String => String) => // template(makeHeader(modules, true).render) -def injectRefreshScript(template: String) = +private def injectRefreshScript(template: String) = val bodyCloseTag = "" val insertionPoint = template.indexOf(bodyCloseTag) @@ -96,7 +97,7 @@ def injectRefreshScript(template: String) = end injectRefreshScript -def injectModulePreloads(ref: Ref[IO, Map[String, String]], template: String) = +private def injectModulePreloads(ref: Ref[IO, Map[String, String]], template: String) = val preloads = makeInternalPreloads(ref) preloads.map: modules => val modulesStringsInject = modules.mkString("\n", "\n", "\n") @@ -110,7 +111,7 @@ def injectModulePreloads(ref: Ref[IO, Map[String, String]], template: String) = end injectModulePreloads -def makeHeader(modules: Seq[(Path, String)], withStyles: Boolean, attemptPreload: Boolean = false) = +private def makeHeader(modules: Seq[(Path, String)], withStyles: Boolean, attemptPreload: Boolean = false) = val scripts = for m <- modules @@ -138,7 +139,7 @@ def makeHeader(modules: Seq[(Path, String)], withStyles: Boolean, attemptPreload ) end makeHeader -def makeInternalPreloads(ref: Ref[IO, Map[String, String]]) = +private def makeInternalPreloads(ref: Ref[IO, Map[String, String]]) = val keys = ref.get.map(_.toSeq) keys.map { modules => @@ -151,7 +152,13 @@ def makeInternalPreloads(ref: Ref[IO, Map[String, String]]) = end makeInternalPreloads -def vanillaTemplate(withStyles: Boolean, ref: Ref[IO, Map[String, String]], attemptPreload: Boolean) = +def vanillaTemplate: String = + val r = Ref.of[IO, Map[String, String]](Map.empty) + r.flatMap( rf => + vanillaTemplate(true, rf, false).map(_.render) + ).unsafeRunSync()(using cats.effect.unsafe.implicits.global) + +def vanillaTemplate(withStyles: Boolean, ref: Ref[IO, Map[String, String]], attemptPreload: Boolean): IO[TypedTag[String]] = val preloads = makeInternalPreloads(ref) preloads.map: modules => diff --git a/sjsls/src/liveServer.scala b/sjsls/src/liveServer.scala index 483da60..23ba56d 100644 --- a/sjsls/src/liveServer.scala +++ b/sjsls/src/liveServer.scala @@ -1,10 +1,7 @@ package io.github.quafadas.sjsls import scala.concurrent.duration.* -import scala.util.control.NoStackTrace - import org.http4s.* -import org.http4s.HttpApp import org.http4s.ember.client.EmberClientBuilder import org.http4s.ember.server.EmberServerBuilder import org.http4s.server.Server @@ -23,30 +20,12 @@ import scribe.Level import cats.effect.* import cats.implicits.* -def makeProxyConfig(frontendPort: Port, proxyTo: Port, matcher: String) = s""" -http: - servers: - - listen: $frontendPort - serverNames: - - localhost - locations: - - matcher: $matcher - proxyPass: http://$$backend - - upstreams: - - name: backend - servers: - - host: localhost - port: $proxyTo - weight: 5 -""" - -private case class CliValidationError(message: String) extends NoStackTrace - object LiveServer extends IOApp: private val logger = scribe.cats[IO] given filesInstance: Files[IO] = Files.forAsync[IO] + import CliOps.* + private def buildServer(httpApp: HttpApp[IO], port: Port) = EmberServerBuilder .default[IO] .withHttp2 @@ -56,150 +35,6 @@ object LiveServer extends IOApp: .withShutdownTimeout(1.milli) .build - val logLevelOpt: Opts[String] = Opts - .option[String]("log-level", help = "The log level. info, debug, error, trace)") - .withDefault("info") - .validate("Invalid log level") { - case "info" => true - case "debug" => true - case "error" => true - case "warn" => true - case "trace" => true - case _ => false - } - - val openBrowserAtOpt = - Opts - .option[String]( - "browse-on-open-at", - "A suffix to localhost where we'll open a browser window on server start - e.g. /ui/greatPage OR just `/` for root " - ) - .withDefault("/") - - val baseDirOpt = - Opts - .option[String]("project-dir", "The fully qualified location of your project - e.g. c:/temp/helloScalaJS") - .orNone - - val outDirOpt = Opts - .option[String]( - "out-dir", - "Where the compiled JS will be compiled to - e.g. c:/temp/helloScalaJS/.out. If no file is given, a temporary directory is created." - ) - .orNone - - val portOpt = Opts - .option[Int]("port", "The port you want to run the server on - e.g. 3000") - .withDefault(3000) - .validate("Port must be between 1 and 65535")(i => i > 0 && i < 65535) - .map(i => Port.fromInt(i).get) - - val proxyPortTargetOpt = Opts - .option[Int]("proxy-target-port", "The port you want to forward api requests to - e.g. 8080") - .orNone - .validate("Proxy Port must be between 1 and 65535")(iOpt => iOpt.fold(true)(i => i > 0 && i < 65535)) - .map(i => i.flatMap(Port.fromInt)) - - val proxyPathMatchPrefixOpt = Opts - .option[String]("proxy-prefix-path", "Match routes starting with this prefix - e.g. /api") - .orNone - - val clientRoutingPrefixOpt = Opts - .option[String]( - "client-routes-prefix", - "Routes starting with this prefix e.g. /app will return index.html. This enables client side routing via e.g. waypoint" - ) - .orNone - - val buildToolOpt = Opts - .option[String]("build-tool", "scala-cli or mill") - .validate("Invalid build tool") { - case "scala-cli" => true - case "mill" => true - case "none" => true - case _ => false - } - .withDefault("scala-cli") - .map { - _ match - case "scala-cli" => ScalaCli() - case "mill" => Mill() - case "none" => None() - } - - val injectPreloadsOpt = Opts - .flag( - "inject-preloads", - "Whether or not to attempt injecting module preloads into the index.html, potentially speeds up page load, but may not work with all servers and or cause instability in the refresh process." - ) - .orFalse - - val extraBuildArgsOpt: Opts[List[String]] = Opts - .options[String]( - "extra-build-args", - "Extra arguments to pass to the build tool" - ) - .orEmpty - - val stylesDirOpt: Opts[Option[String]] = Opts - .option[String]( - "styles-dir", - "A fully qualified path to your styles directory with LESS files in - e.g. c:/temp/helloScalaJS/styles" - ) - .orNone - - val indexHtmlTemplateOpt: Opts[Option[String]] = Opts - .option[String]( - "path-to-index-html", - "a path to a directory which contains index.html. The entire directory will be served as static assets" - ) - .orNone - - val preventBrowserOpenOpt = Opts - .flag( - "prevent-browser-open", - "prevent the browser from opening on server start" - ) - .orFalse - - val millModuleNameOpt: Opts[Option[String]] = Opts - .option[String]( - "mill-module-name", - "Extra arguments to pass to the build tool" - ) - .validate("mill module name cannot be blank") { - case "" => false - case _ => true - } - .orNone - - val buildToolInvocation: Opts[Option[String]] = Opts - .option[String]( - "build-tool-invocation", - "This string will be passed to an fs2 process which invokes the build tool. By default it's 'scala-cli', or `mill`, " + - "and is assumed is on the path" - ) - .orNone - - case class LiveServerConfig( - baseDir: Option[String], - outDir: Option[String] = None, - port: Port, - proxyPortTarget: Option[Port] = None, - proxyPathMatchPrefix: Option[String] = None, - clientRoutingPrefix: Option[String] = None, - logLevel: String = "info", - buildTool: BuildTool = ScalaCli(), - openBrowserAt: String, - preventBrowserOpen: Boolean = false, - extraBuildArgs: List[String] = List.empty, - millModuleName: Option[String] = None, - stylesDir: Option[String] = None, - indexHtmlTemplate: Option[String] = None, - buildToolInvocation: Option[String] = None, - injectPreloads: Boolean = false, - customRefresh: Option[Topic[IO, Unit]] = None - ) def parseOpts = ( baseDirOpt, @@ -218,6 +53,7 @@ object LiveServer extends IOApp: indexHtmlTemplateOpt, buildToolInvocation, injectPreloadsOpt, + dezombifyOpt, None.pure[Opts] ).mapN(LiveServerConfig.apply) @@ -238,6 +74,13 @@ object LiveServer extends IOApp: ) .toResource + _ <- Resource.pure[IO, Boolean](lsc.dezombify).flatMap( + if(_) + Resource.eval(IO.println(s"Attempt to kill off process on port ${lsc.port}")) >> + dezombify(lsc.port) + else + scribe.cats[IO].debug(s"Assuming port ${lsc.port} is free").toResource + ) fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource refreshTopic <- lsc.customRefresh.fold(Topic[IO, Unit])(IO(_)).toResource linkingTopic <- Topic[IO, Unit].toResource diff --git a/sjsls/src/makeProxyConfig.scala b/sjsls/src/makeProxyConfig.scala new file mode 100644 index 0000000..b437737 --- /dev/null +++ b/sjsls/src/makeProxyConfig.scala @@ -0,0 +1,21 @@ +package io.github.quafadas.sjsls + +import com.comcast.ip4s.Port + +private[sjsls] def makeProxyConfig(frontendPort: Port, proxyTo: Port, matcher: String) = s""" +http: + servers: + - listen: $frontendPort + serverNames: + - localhost + locations: + - matcher: $matcher + proxyPass: http://$$backend + + upstreams: + - name: backend + servers: + - host: localhost + port: $proxyTo + weight: 5 +""" \ No newline at end of file diff --git a/sjsls/test/src/dezombify.test.scala b/sjsls/test/src/dezombify.test.scala new file mode 100644 index 0000000..ddcb525 --- /dev/null +++ b/sjsls/test/src/dezombify.test.scala @@ -0,0 +1,42 @@ +package io.github.quafadas.sjsls + +import cats.effect._ +import com.comcast.ip4s._ +import munit.CatsEffectSuite + +import scala.concurrent.duration.DurationInt + +class DezombieTest extends CatsEffectSuite: + + test("That we kill off a zombie server") { + val portInt = 6789 + val port = Port.fromInt(portInt).get + + val lsc = LiveServerConfig( + baseDir = None, + stylesDir = None, + port = port, + buildTool = NoBuildTool(), + openBrowserAt = "", + preventBrowserOpen = true, + dezombify = true, + logLevel = "debug", + ) + + for { + // Start first server in a separate process using mill run + _ <- IO.println("You should have already started a zombie server in separate process...") + + // Check if port is actually in use + portInUse <- checkPortInUse(port) + _ <- IO(assert(portInUse)) // TODO, this needs to be co-ordinated with some external process. See forkEnv + + // Now start second server with dezombify enabled - this should kill the first one + _ <- IO.println("Starting second server with `enabled...") + allocated <- LiveServer.main(lsc).allocated + (server2, release2) = allocated + _ <- IO.println("Second server started successfully!") + + } yield + (()) + } \ No newline at end of file diff --git a/sjsls/test/src/liveServer.test.scala b/sjsls/test/src/liveServer.test.scala index c969004..c7837d9 100644 --- a/sjsls/test/src/liveServer.test.scala +++ b/sjsls/test/src/liveServer.test.scala @@ -18,7 +18,6 @@ import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat import cats.effect.IO import cats.effect.kernel.Ref -import io.github.quafadas.sjsls.LiveServer.LiveServerConfig import munit.CatsEffectSuite /* From 71911b6c74ea4660336c5445cfc6b5d5f4c74ba2 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 2 Sep 2025 17:12:17 +0200 Subject: [PATCH 2/5] . --- plugin/package.mill | 3 +- plugin/src/refresh_plugin.scala | 58 +++++++------------ sjsls/src/buildRunner.scala | 3 +- sjsls/src/htmlGen.scala | 4 +- sjsls/src/liveServer.scala | 8 ++- sjsls/src/middleware/ETagMiddleware.scala | 5 +- .../src/middleware/staticpathMiddleware.scala | 7 ++- sjsls/src/refreshRoute.scala | 42 +++++++++++--- sjsls/src/routes.scala | 8 ++- sjsls/src/staticWatcher.scala | 12 ++-- sjsls/test/src/RoutesSpec.scala | 21 ++++--- sjsls/test/src/dezombify.test.scala | 6 +- sjsls/test/src/liveServer.test.scala | 3 + 13 files changed, 105 insertions(+), 75 deletions(-) diff --git a/plugin/package.mill b/plugin/package.mill index 377ad43..1976b53 100644 --- a/plugin/package.mill +++ b/plugin/package.mill @@ -4,8 +4,9 @@ import mill.util.BuildInfo. millBinPlatform import mill._, scalalib._, publish._ import mill.util.VcsVersion import build.V +import build.FormatFixPublish -object `package` extends ScalaModule with PublishModule: +object `package` extends ScalaModule with FormatFixPublish: def platformSuffix = s"_mill$millBinPlatform" def scalaVersion = build.V.scalaVersion diff --git a/plugin/src/refresh_plugin.scala b/plugin/src/refresh_plugin.scala index 054b42e..97ebc47 100644 --- a/plugin/src/refresh_plugin.scala +++ b/plugin/src/refresh_plugin.scala @@ -1,31 +1,15 @@ -package io.github.quafadas.RefreshPlugin +package io.github.quafadas import io.github.quafadas.sjsls.LiveServerConfig import mill.* import mill.scalalib.* import mill.scalajslib.* -import os.Path + import mill.api.Task.Simple import fs2.concurrent.Topic import cats.effect.IO -// import mill.scalajslib.* -// import coursier.maven.MavenRepository -// import mill.api.Result -// import mill.util.Jvm.createJar -// import mill.define.PathRef -// import mill.scalalib.api.CompilationResult -// // import de.tobiasroeser.mill.vcs.version.VcsVersion -// import scala.util.Try -// import mill.scalalib.publish.PomSettings -// import mill.scalalib.publish.License -// import mill.scalalib.publish.VersionControl -// import os.SubPath -// import ClasspathHelp.* import cats.effect.unsafe.implicits.global import io.github.quafadas.sjsls.LiveServerConfig -import cats.effect.ExitCode -import scala.util.{Try, Success, Failure} -import scala.concurrent.Future import mill.api.BuildCtx import mill.scalajslib.api.Report implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global @@ -36,17 +20,17 @@ trait ScalaJsRefreshModule extends ScalaJSModule: def indexHtml = Task{ - os.write.over(Task.dest / "index.html", io.github.quafadas.sjsls.vanillaTemplate) + os.write.over(Task.dest / "index.html", io.github.quafadas.sjsls.vanillaTemplate(withStyles())) PathRef(Task.dest / "index.html") } - def assetsDir = Task{ - "assets" - } + def assetsDir = + super.moduleDir / "assets" - def assets = Task{ - os.write.over(Task.dest / "style.css", io.github.quafadas.sjsls.lessStyle(true).render) - PathRef(Task.dest / assetsDir()) + def withStyles = Task{ true} + + def assets = Task.Source{ + assetsDir } def port = Task { @@ -66,33 +50,35 @@ trait ScalaJsRefreshModule extends ScalaJSModule: } def siteGen = Task{ - val assets_ = assets() - val path = fastLinkJS().dest - os.copy.over(Task.dest / "index.html", indexHtml().path) - os.copy.over(Task.dest / assetsDir(), assets_.path) - updateServer.publish1(println("publishing update")) - (assets_.path.toString(), path.path.toString()) + val assets_ = assets() + val path = fastLinkJS().dest.path + os.copy.over(indexHtml().path, Task.dest / "index.html") + os.copy(assets_.path, Task.dest, mergeFolders = true) + updateServer.publish1(println("publish update")).unsafeRunSync() + (Task.dest.toString(), assets_.path.toString(), path.toString()) + } def lcs = Task.Worker{ - val (assets, js) = siteGen() + val (site, assets, js) = siteGen() + println("Gen lsc") LiveServerConfig( baseDir = None, outDir = Some(js), port = com.comcast.ip4s.Port.fromInt(port()).getOrElse(throw new IllegalArgumentException(s"invalid port: ${port()}")), - indexHtmlTemplate = Some(assets), + indexHtmlTemplate = Some(site), buildTool = io.github.quafadas.sjsls.NoBuildTool(), // Here we are a slave to the build tool openBrowserAt = "/index.html", preventBrowserOpen = !openBrowser(), dezombify = dezombify(), - logLevel = logLevel() + logLevel = logLevel(), + customRefresh = Some(updateServer) ) } def serve = Task.Worker{ - // Let's kill off anything that is a zombie on the port we want to use - val p = port() + println(lcs()) BuildCtx.withFilesystemCheckerDisabled { new RefreshServer(lcs()) } diff --git a/sjsls/src/buildRunner.scala b/sjsls/src/buildRunner.scala index 29a2047..0060a97 100644 --- a/sjsls/src/buildRunner.scala +++ b/sjsls/src/buildRunner.scala @@ -52,7 +52,8 @@ def buildRunner( invokeVia, extraBuildArgs )(logger) - case n: NoBuildTool => logger.info("No build tool specified, skipping build").toResource + case n: NoBuildTool => + logger.info("No build tool specified, skipping build").toResource end match end buildRunner diff --git a/sjsls/src/htmlGen.scala b/sjsls/src/htmlGen.scala index 5d54c70..40bda83 100644 --- a/sjsls/src/htmlGen.scala +++ b/sjsls/src/htmlGen.scala @@ -152,10 +152,10 @@ private def makeInternalPreloads(ref: Ref[IO, Map[String, String]]) = end makeInternalPreloads -def vanillaTemplate: String = +def vanillaTemplate(styles: Boolean): String = val r = Ref.of[IO, Map[String, String]](Map.empty) r.flatMap( rf => - vanillaTemplate(true, rf, false).map(_.render) + vanillaTemplate(styles, rf, false).map(_.render) ).unsafeRunSync()(using cats.effect.unsafe.implicits.global) def vanillaTemplate(withStyles: Boolean, ref: Ref[IO, Map[String, String]], attemptPreload: Boolean): IO[TypedTag[String]] = diff --git a/sjsls/src/liveServer.scala b/sjsls/src/liveServer.scala index 23ba56d..1d16489 100644 --- a/sjsls/src/liveServer.scala +++ b/sjsls/src/liveServer.scala @@ -82,7 +82,10 @@ object LiveServer extends IOApp: scribe.cats[IO].debug(s"Assuming port ${lsc.port} is free").toResource ) fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource - refreshTopic <- lsc.customRefresh.fold(Topic[IO, Unit])(IO(_)).toResource + refreshTopic <- lsc.customRefresh.fold(Topic[IO, Unit])( + scribe.cats[IO].debug(s"Custom refresh topic supplied") >> + IO(_) + ).toResource linkingTopic <- Topic[IO, Unit].toResource client <- EmberClientBuilder.default[IO].build baseDirPath <- lsc.baseDir.fold(Files[IO].currentWorkingDirectory.toResource)(toDirectoryPath) @@ -136,7 +139,8 @@ object LiveServer extends IOApp: proxyRoutes, fileToHashRef, lsc.clientRoutingPrefix, - lsc.injectPreloads + lsc.injectPreloads, + lsc.buildTool )(logger) _ <- updateMapRef(outDirPath, fileToHashRef)(logger).toResource diff --git a/sjsls/src/middleware/ETagMiddleware.scala b/sjsls/src/middleware/ETagMiddleware.scala index 17002f3..f245ad1 100644 --- a/sjsls/src/middleware/ETagMiddleware.scala +++ b/sjsls/src/middleware/ETagMiddleware.scala @@ -24,10 +24,12 @@ object ETagMiddleware: ) = mr.get .flatMap { + map => - // logger.trace(s"Responding with ETag at path: ${req.uri.path}") >> map.get(req.uri.path.toString.drop(1)) match case Some(hash) => + logger.debug("Map") >> + logger.debug(map.toString) >> logger.debug(s"Found ETag: $hash in map for ${req.uri.path}") >> IO( resp.putHeaders( @@ -69,6 +71,7 @@ object ETagMiddleware: case Some(foundEt) => if etag == foundEt then logger.debug(s"ETag $etag found in cache at path ${req.uri.path}, returning 304") >> + logger.debug("map is: " + map.toString) >> IO(Response[IO](Status.NotModified)) else logger.debug(s"$etag not found in cache at path ${req.uri.path} returning 200") >> diff --git a/sjsls/src/middleware/staticpathMiddleware.scala b/sjsls/src/middleware/staticpathMiddleware.scala index 7875fc9..372002a 100644 --- a/sjsls/src/middleware/staticpathMiddleware.scala +++ b/sjsls/src/middleware/staticpathMiddleware.scala @@ -53,13 +53,13 @@ inline def cachedFileResponse(epochInstant: Instant, fullPath: Path, req: Reques val zdt = ZonedDateTime.ofInstant(Instant.ofEpochSecond(lastmod), ZoneId.of("GMT")) val response = if parseFromHeader(epochInstant, browserLastModifiedAt) == lastmod then - logger.debug("Time matches, returning 304") >> + logger.debug(s"Time matches, returning 304 for ${req.uri.path}") >> IO( respondWithCacheLastModified(Response[IO](Status.NotModified), zdt) ) else logger.debug(lastmod.toString()) >> - logger.debug("Last modified doesn't match, returning 200") >> + logger.debug(s"Last modified doesn't match, returning 200 for ${req.uri.path}") >> IO( respondWithCacheLastModified(resp, zdt) ) @@ -70,7 +70,8 @@ inline def cachedFileResponse(epochInstant: Instant, fullPath: Path, req: Reques response } case _ => - OptionT.liftF(logger.debug("No If-Modified-Since headers in request")) >> + OptionT.liftF( + logger.debug(s"No If-Modified-Since headers in request ${req.uri.path}") ) >> service(req).map { resp => respondWithCacheLastModified( diff --git a/sjsls/src/refreshRoute.scala b/sjsls/src/refreshRoute.scala index 1a722ec..c9ac4db 100644 --- a/sjsls/src/refreshRoute.scala +++ b/sjsls/src/refreshRoute.scala @@ -11,13 +11,39 @@ import fs2.concurrent.Topic import cats.effect.IO import _root_.io.circe.syntax.EncoderOps +import cats.effect.kernel.Ref +import scribe.Scribe +import cats.syntax.all.* -def refreshRoutes(refreshTopic: Topic[IO, Unit]) = HttpRoutes.of[IO] { - case GET -> Root / "refresh" / "v1" / "sse" => - val keepAlive = fs2.Stream.fixedRate[IO](10.seconds).as(KeepAlive()) - Ok( - keepAlive - .merge(refreshTopic.subscribe(10).as(PageRefresh())) - .map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) - ) + +def refreshRoutes(refreshTopic: Topic[IO, Unit], buildTool: BuildTool,stringPath: fs2.io.file.Path, mr: Ref[IO, Map[String, String]], logger: Scribe[IO]) = HttpRoutes.of[IO] { + + val keepAlive = fs2.Stream.fixedRate[IO](10.seconds).as(KeepAlive()) + val refresh = refreshTopic + .subscribe(10) + + buildTool match + case _: NoBuildTool => + case GET -> Root / "refresh" / "v1" / "sse" => + Ok( + keepAlive + .merge( + refresh + .evalTap(_ => + // A different tool is responsible for linking, so we hash the files "on the fly" when an update is requested + logger.debug("Updating Map Ref") >> + updateMapRef(stringPath, mr)(logger) + ) + .as(PageRefresh()) + ) + .map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) + ) + case _ => + case GET -> Root / "refresh" / "v1" / "sse" => + println("Hit this one") + Ok( + keepAlive + .merge(refresh.as(PageRefresh())) + .map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) + ) } diff --git a/sjsls/src/routes.scala b/sjsls/src/routes.scala index 99982de..baf1ff4 100644 --- a/sjsls/src/routes.scala +++ b/sjsls/src/routes.scala @@ -14,11 +14,11 @@ import cats.MonadThrow import cats.data.Kleisli import cats.data.OptionT import cats.effect.* -import cats.effect.IO import cats.effect.kernel.Ref import cats.effect.kernel.Resource import cats.syntax.all.* +// TODO: Test that the map of hashes is updated, when an external build tool is responsible for refresh pulses def routes[F[_]: Files: MonadThrow]( stringPath: String, refreshTopic: Topic[IO, Unit], @@ -26,12 +26,14 @@ def routes[F[_]: Files: MonadThrow]( proxyRoutes: HttpRoutes[IO], ref: Ref[IO, Map[String, String]], clientRoutingPrefix: Option[String], - injectPreloads: Boolean + injectPreloads: Boolean, + buildTool: BuildTool )(logger: Scribe[IO]): Resource[IO, HttpRoutes[IO]] = val traceLogger = traceLoggerMiddleware(logger) val zdt = ZonedDateTime.now() + // val linkedAppWithCaching: HttpRoutes[IO] = appRoute[IO](stringPath) val linkedAppWithCaching: HttpRoutes[IO] = ETagMiddleware(appRoute[IO](stringPath), ref)(logger) val spaRoutes = clientRoutingPrefix.map(s => (s, buildSpaRoute(indexOpts, ref, zdt, injectPreloads)(logger))) val staticRoutes = Some(staticAssetRoutes(indexOpts, ref, zdt, injectPreloads)(logger)) @@ -44,7 +46,7 @@ def routes[F[_]: Files: MonadThrow]( ) val refreshableApp = traceLogger( - refreshRoutes(refreshTopic).combineK(proxyRoutes).combineK(routes) + refreshRoutes(refreshTopic, buildTool, fs2.io.file.Path(stringPath), ref, logger).combineK(proxyRoutes).combineK(routes) ) IO(refreshableApp).toResource diff --git a/sjsls/src/staticWatcher.scala b/sjsls/src/staticWatcher.scala index 8d059ef..eef7d1d 100644 --- a/sjsls/src/staticWatcher.scala +++ b/sjsls/src/staticWatcher.scala @@ -20,9 +20,7 @@ import fs2.io.file.Path import scribe.Scribe import cats.effect.* -import cats.effect.IO -import cats.effect.OutcomeIO -import cats.effect.ResourceIO + import cats.syntax.all.* def staticWatcher( @@ -87,10 +85,10 @@ def updateMapRef(stringPath: fs2.io.file.Path, mr: Ref[IO, Map[String, String]]) .walk(stringPath) .evalFilter(Files[IO].isRegularFile) .parEvalMap(maxConcurrent = 8)(path => fileHash(path).map(path -> _)) - // .evalTap { - // case (path, hash) => - // logger.debug(s"File $path has hash $hash") - // } + .evalTap { + case (path, hash) => + logger.debug(s"File $path has hash $hash") + } .compile .toVector .flatMap( diff --git a/sjsls/test/src/RoutesSpec.scala b/sjsls/test/src/RoutesSpec.scala index cfbf373..f15135a 100644 --- a/sjsls/test/src/RoutesSpec.scala +++ b/sjsls/test/src/RoutesSpec.scala @@ -184,7 +184,8 @@ class RoutesSuite extends CatsEffectSuite: HttpRoutes.empty[IO], fileToHashRef, Some("app"), - false + false, + ScalaCli() )(logger) yield errorActionFor(theseRoutes, aLogger).orNotFound @@ -271,7 +272,8 @@ class RoutesSuite extends CatsEffectSuite: HttpRoutes.empty[IO], fileToHashRef, None, - false + false, + ScalaCli() )(logger) yield theseRoutes.orNotFound @@ -302,7 +304,8 @@ class RoutesSuite extends CatsEffectSuite: HttpRoutes.empty[IO], fileToHashRef, None, - false + false, + ScalaCli() )(logger) yield theseRoutes.orNotFound @@ -334,7 +337,8 @@ class RoutesSuite extends CatsEffectSuite: HttpRoutes.empty[IO], fileToHashRef, None, - false + false, + ScalaCli() )(logger) yield theseRoutes.orNotFound @@ -368,7 +372,8 @@ class RoutesSuite extends CatsEffectSuite: HttpRoutes.empty[IO], fileToHashRef, Some("app"), - false + false, + ScalaCli() )(logger) yield theseRoutes.orNotFound @@ -402,7 +407,8 @@ class RoutesSuite extends CatsEffectSuite: HttpRoutes.empty[IO], fileToHashRef, None, - false + false, + ScalaCli() )(logger) yield (theseRoutes.orNotFound, logger, modifedAt) @@ -457,7 +463,8 @@ class RoutesSuite extends CatsEffectSuite: HttpRoutes.empty[IO], fileToHashRef, Some("app"), - false + false, + ScalaCli() )(logger) yield (theseRoutes.orNotFound, logger) diff --git a/sjsls/test/src/dezombify.test.scala b/sjsls/test/src/dezombify.test.scala index ddcb525..ee75b70 100644 --- a/sjsls/test/src/dezombify.test.scala +++ b/sjsls/test/src/dezombify.test.scala @@ -1,11 +1,9 @@ package io.github.quafadas.sjsls -import cats.effect._ -import com.comcast.ip4s._ +import cats.effect.IO +import com.comcast.ip4s.Port import munit.CatsEffectSuite -import scala.concurrent.duration.DurationInt - class DezombieTest extends CatsEffectSuite: test("That we kill off a zombie server") { diff --git a/sjsls/test/src/liveServer.test.scala b/sjsls/test/src/liveServer.test.scala index c7837d9..341a2a9 100644 --- a/sjsls/test/src/liveServer.test.scala +++ b/sjsls/test/src/liveServer.test.scala @@ -19,6 +19,7 @@ import cats.effect.IO import cats.effect.kernel.Ref import munit.CatsEffectSuite +import fs2.concurrent.Topic /* Run @@ -259,6 +260,8 @@ trait PlaywrightTest extends CatsEffectSuite: } +// TODO: Test that the map of hashes is updated, when an external build tool is responsible for refresh pulses + ResourceFunFixture { files.flatMap { dir => From 8188d0215bc3018e39092fbd134f0154efdab0e9 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 2 Sep 2025 21:56:10 +0200 Subject: [PATCH 3/5] format fix --- .scalafmt.conf | 2 +- build.mill | 1 + plugin/src/refresh_plugin.scala | 76 ++++++------ sjsls/src/CliOpts.scala | 24 ++-- sjsls/src/CliValidationError.scala | 2 +- sjsls/src/LiveServerConfig.scala | 4 +- sjsls/src/dezombify.scala | 110 ++++++++++-------- sjsls/src/htmlGen.scala | 14 ++- sjsls/src/liveServer.scala | 28 +++-- sjsls/src/makeProxyConfig.scala | 2 +- sjsls/src/middleware/ETagMiddleware.scala | 8 +- sjsls/src/middleware/noCache.middleware.scala | 1 - .../src/middleware/staticFileMiddleware.scala | 1 - .../src/middleware/staticpathMiddleware.scala | 4 +- sjsls/src/refreshRoute.scala | 32 ++--- sjsls/src/routes.scala | 4 +- sjsls/src/sseReload.scala | 1 - sjsls/src/staticWatcher.scala | 1 - sjsls/test/src/RoutesSpec.scala | 3 - sjsls/test/src/dezombify.test.scala | 34 +++--- sjsls/test/src/liveServer.test.scala | 3 +- 21 files changed, 184 insertions(+), 171 deletions(-) diff --git a/.scalafmt.conf b/.scalafmt.conf index 4a0d6b7..4eba861 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -version = "3.9.7" +version = "3.9.9" project.git = true runner.dialect = scala3 diff --git a/build.mill b/build.mill index 0ed2994..de8b17b 100644 --- a/build.mill +++ b/build.mill @@ -33,6 +33,7 @@ object V{ trait FormatFix extends ScalafmtModule with ScalafixModule with ScalaModule + trait FormatFixPublish extends ScalaModule with FormatFix with PublishModule{ override def scalaVersion = V.scalaVersion diff --git a/plugin/src/refresh_plugin.scala b/plugin/src/refresh_plugin.scala index 97ebc47..4b92c6c 100644 --- a/plugin/src/refresh_plugin.scala +++ b/plugin/src/refresh_plugin.scala @@ -1,16 +1,14 @@ package io.github.quafadas - -import io.github.quafadas.sjsls.LiveServerConfig -import mill.* -import mill.scalalib.* -import mill.scalajslib.* - -import mill.api.Task.Simple import fs2.concurrent.Topic + import cats.effect.IO import cats.effect.unsafe.implicits.global + import io.github.quafadas.sjsls.LiveServerConfig +import mill.* import mill.api.BuildCtx +import mill.api.Task.Simple +import mill.scalajslib.* import mill.scalajslib.api.Report implicit val ec: scala.concurrent.ExecutionContext = scala.concurrent.ExecutionContext.global @@ -18,18 +16,17 @@ trait ScalaJsRefreshModule extends ScalaJSModule: lazy val updateServer = Topic[IO, Unit].unsafeRunSync() - - def indexHtml = Task{ + def indexHtml = Task { os.write.over(Task.dest / "index.html", io.github.quafadas.sjsls.vanillaTemplate(withStyles())) PathRef(Task.dest / "index.html") } def assetsDir = - super.moduleDir / "assets" + super.moduleDir / "assets" - def withStyles = Task{ true} + def withStyles = Task(true) - def assets = Task.Source{ + def assets = Task.Source { assetsDir } @@ -37,7 +34,7 @@ trait ScalaJsRefreshModule extends ScalaJSModule: 8080 } - def openBrowser= Task { + def openBrowser = Task { true } @@ -49,34 +46,35 @@ trait ScalaJsRefreshModule extends ScalaJSModule: true } - def siteGen = Task{ - val assets_ = assets() - val path = fastLinkJS().dest.path - os.copy.over(indexHtml().path, Task.dest / "index.html") - os.copy(assets_.path, Task.dest, mergeFolders = true) - updateServer.publish1(println("publish update")).unsafeRunSync() - (Task.dest.toString(), assets_.path.toString(), path.toString()) + def siteGen = Task { + val assets_ = assets() + val path = fastLinkJS().dest.path + os.copy.over(indexHtml().path, Task.dest / "index.html") + os.copy(assets_.path, Task.dest, mergeFolders = true) + updateServer.publish1(println("publish update")).unsafeRunSync() + (Task.dest.toString(), assets_.path.toString(), path.toString()) } - def lcs = Task.Worker{ + def lcs = Task.Worker { val (site, assets, js) = siteGen() println("Gen lsc") LiveServerConfig( - baseDir = None, - outDir = Some(js), - port = com.comcast.ip4s.Port.fromInt(port()).getOrElse(throw new IllegalArgumentException(s"invalid port: ${port()}")), - indexHtmlTemplate = Some(site), - buildTool = io.github.quafadas.sjsls.NoBuildTool(), // Here we are a slave to the build tool - openBrowserAt = "/index.html", - preventBrowserOpen = !openBrowser(), - dezombify = dezombify(), - logLevel = logLevel(), - customRefresh = Some(updateServer) - ) + baseDir = None, + outDir = Some(js), + port = + com.comcast.ip4s.Port.fromInt(port()).getOrElse(throw new IllegalArgumentException(s"invalid port: ${port()}")), + indexHtmlTemplate = Some(site), + buildTool = io.github.quafadas.sjsls.NoBuildTool(), // Here we are a slave to the build tool + openBrowserAt = "/index.html", + preventBrowserOpen = !openBrowser(), + dezombify = dezombify(), + logLevel = logLevel(), + customRefresh = Some(updateServer) + ) } - def serve = Task.Worker{ + def serve = Task.Worker { println(lcs()) BuildCtx.withFilesystemCheckerDisabled { @@ -84,15 +82,15 @@ trait ScalaJsRefreshModule extends ScalaJSModule: } } - class RefreshServer(lcs: LiveServerConfig) extends AutoCloseable { - val server = io.github.quafadas.sjsls.LiveServer.main(lcs).allocated + class RefreshServer(lcs: LiveServerConfig) extends AutoCloseable: + val server = io.github.quafadas.sjsls.LiveServer.main(lcs).allocated server.map(_._1).unsafeRunSync() - override def close(): Unit = { + override def close(): Unit = // This is the shutdown hook for http4s println("Shutting down server...") server.map(_._2).flatten.unsafeRunSync() - } - } - + end close + end RefreshServer +end ScalaJsRefreshModule diff --git a/sjsls/src/CliOpts.scala b/sjsls/src/CliOpts.scala index 632aca9..1172404 100644 --- a/sjsls/src/CliOpts.scala +++ b/sjsls/src/CliOpts.scala @@ -1,20 +1,20 @@ package io.github.quafadas.sjsls -import com.monovore.decline.Opts import com.comcast.ip4s.Port +import com.monovore.decline.Opts private[sjsls] object CliOps: val logLevelOpt: Opts[String] = Opts - .option[String]("log-level", help = "The log level. info, debug, error, trace)") - .withDefault("info") - .validate("Invalid log level") { - case "info" => true - case "debug" => true - case "error" => true - case "warn" => true - case "trace" => true - case _ => false - } + .option[String]("log-level", help = "The log level. info, debug, error, trace)") + .withDefault("info") + .validate("Invalid log level") { + case "info" => true + case "debug" => true + case "error" => true + case "warn" => true + case "trace" => true + case _ => false + } val openBrowserAtOpt = Opts @@ -135,4 +135,4 @@ private[sjsls] object CliOps: "Whether or not to attempt killing any processes that are using the specified port. Default: true" ) .orTrue - +end CliOps diff --git a/sjsls/src/CliValidationError.scala b/sjsls/src/CliValidationError.scala index 957c317..ec17204 100644 --- a/sjsls/src/CliValidationError.scala +++ b/sjsls/src/CliValidationError.scala @@ -2,4 +2,4 @@ package io.github.quafadas.sjsls import scala.util.control.NoStackTrace -private case class CliValidationError(message: String) extends NoStackTrace \ No newline at end of file +private case class CliValidationError(message: String) extends NoStackTrace diff --git a/sjsls/src/LiveServerConfig.scala b/sjsls/src/LiveServerConfig.scala index 7327b10..f0ff74c 100644 --- a/sjsls/src/LiveServerConfig.scala +++ b/sjsls/src/LiveServerConfig.scala @@ -1,7 +1,9 @@ package io.github.quafadas.sjsls import com.comcast.ip4s.Port + import fs2.concurrent.Topic + import cats.effect.IO case class LiveServerConfig( @@ -23,4 +25,4 @@ case class LiveServerConfig( injectPreloads: Boolean = false, dezombify: Boolean = true, customRefresh: Option[Topic[IO, Unit]] = None -) \ No newline at end of file +) diff --git a/sjsls/src/dezombify.scala b/sjsls/src/dezombify.scala index 63ca347..838be18 100644 --- a/sjsls/src/dezombify.scala +++ b/sjsls/src/dezombify.scala @@ -1,68 +1,72 @@ package io.github.quafadas.sjsls -import cats.effect.IO +import scala.concurrent.duration.* + +import com.comcast.ip4s.Port + import fs2.io.process + +import cats.effect.IO import cats.effect.kernel.Resource -import com.comcast.ip4s.Port -import scala.concurrent.duration._ -private[sjsls] def checkPortInUse(port: Port): IO[Boolean] = { +private[sjsls] def checkPortInUse(port: Port): IO[Boolean] = val osName = System.getProperty("os.name").toLowerCase val portInt = port.value - if (osName.contains("win")) { - val ps = s"Get-NetTCPConnection -LocalPort $portInt -ErrorAction SilentlyContinue | Measure-Object | Select-Object -ExpandProperty Count" + if osName.contains("win") then + val ps = + s"Get-NetTCPConnection -LocalPort $portInt -ErrorAction SilentlyContinue | Measure-Object | Select-Object -ExpandProperty Count" - process.ProcessBuilder("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps) + process + .ProcessBuilder("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps) .spawn[IO] - .use { proc => - proc.stdout.through(fs2.text.utf8.decode).compile.string.map(_.trim.toIntOption.getOrElse(0) > 0) + .use { + proc => + proc.stdout.through(fs2.text.utf8.decode).compile.string.map(_.trim.toIntOption.getOrElse(0) > 0) } - } else { + else val sh = s"lsof -ti tcp:$portInt 2>/dev/null | wc -l" - process.ProcessBuilder("sh", "-c", sh) + process + .ProcessBuilder("sh", "-c", sh) .spawn[IO] - .use { proc => - proc.stdout.through(fs2.text.utf8.decode).compile.string.map(_.trim.toIntOption.getOrElse(0) > 0) + .use { + proc => + proc.stdout.through(fs2.text.utf8.decode).compile.string.map(_.trim.toIntOption.getOrElse(0) > 0) } - } -} + end if +end checkPortInUse -private[sjsls] def dezombify(port: Port): Resource[IO, Unit] = { +private[sjsls] def dezombify(port: Port): Resource[IO, Unit] = val portInt = port.value - val checkAndKill = for { + val checkAndKill = for _ <- scribe.cats[IO].debug(s"Checking if port $portInt is in use before attempting cleanup") portInUse <- checkPortInUse(port) - _ <- if (portInUse) { - scribe.cats[IO].warn(s"Found zombie server on port $portInt - attempting to kill it") - } else { - scribe.cats[IO].debug(s"Port $portInt appears to be free, no zombie cleanup needed") - } - _ <- if (portInUse) killProcessesOnPort(port) else IO.unit - _ <- if (portInUse) { - for { - _ <- IO.sleep(scala.concurrent.duration.Duration.fromNanos(500_000_000)) // 500ms - stillInUse <- checkPortInUse(port) - _ <- if (stillInUse) { - scribe.cats[IO].error(s"Port $portInt still appears to be in use after cleanup attempt") - } else { - scribe.cats[IO].debug(s"Successfully cleaned up port $portInt") - } - } yield () - } else IO.unit - } yield () + _ <- + if portInUse then scribe.cats[IO].warn(s"Found zombie server on port $portInt - attempting to kill it") + else scribe.cats[IO].debug(s"Port $portInt appears to be free, no zombie cleanup needed") + _ <- if portInUse then killProcessesOnPort(port) else IO.unit + _ <- + if portInUse then + for + _ <- IO.sleep(scala.concurrent.duration.Duration.fromNanos(500_000_000)) // 500ms + stillInUse <- checkPortInUse(port) + _ <- + if stillInUse then scribe.cats[IO].error(s"Port $portInt still appears to be in use after cleanup attempt") + else scribe.cats[IO].debug(s"Successfully cleaned up port $portInt") + yield () + else IO.unit + yield () checkAndKill.toResource +end dezombify -} - -private def killProcessesOnPort(port: Port): IO[Unit] = { +private def killProcessesOnPort(port: Port): IO[Unit] = val osName = System.getProperty("os.name").toLowerCase val portInt = port.value - if (osName.contains("win")) { + if osName.contains("win") then // Windows: try PowerShell Get-NetTCPConnection, fallback to netstat/taskkill val ps = s""" |if (Get-Command Get-NetTCPConnection -ErrorAction SilentlyContinue) { @@ -87,15 +91,16 @@ private def killProcessesOnPort(port: Port): IO[Unit] = { |} |""".stripMargin - for { + for _ <- scribe.cats[IO].debug(s"Running Windows cleanup command for port $portInt") - exitCode <- process.ProcessBuilder("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps) + exitCode <- process + .ProcessBuilder("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps) .spawn[IO] .use(_.exitValue) _ <- scribe.cats[IO].debug(s"Windows cleanup command completed with exit code $exitCode") - } yield () - - } else { + yield () + end for + else // macOS/Linux: use lsof if available, fallback to fuser val sh = s""" |if command -v lsof >/dev/null 2>&1; then @@ -116,17 +121,20 @@ private def killProcessesOnPort(port: Port): IO[Unit] = { |fi |""".stripMargin - for { + for _ <- scribe.cats[IO].debug(s"Running Unix cleanup command for port $portInt") // Use a timeout to prevent hanging - result <- process.ProcessBuilder("sh", "-c", sh) + result <- process + .ProcessBuilder("sh", "-c", sh) .spawn[IO] .use(_.exitValue) .timeout(5.seconds) - .handleErrorWith { err => - scribe.cats[IO].warn(s"Process cleanup timed out or failed: ${err.getMessage}") *> IO.pure(-1) + .handleErrorWith { + err => + scribe.cats[IO].warn(s"Process cleanup timed out or failed: ${err.getMessage}") *> IO.pure(-1) } _ <- scribe.cats[IO].debug(s"Unix cleanup command completed with exit code $result") - } yield () - } -} \ No newline at end of file + yield () + end for + end if +end killProcessesOnPort diff --git a/sjsls/src/htmlGen.scala b/sjsls/src/htmlGen.scala index 40bda83..630be21 100644 --- a/sjsls/src/htmlGen.scala +++ b/sjsls/src/htmlGen.scala @@ -2,6 +2,7 @@ package io.github.quafadas.sjsls import java.time.ZonedDateTime +import scalatags.Text.TypedTag import scalatags.Text.all.* import org.http4s.HttpRoutes @@ -17,7 +18,6 @@ import scribe.Scribe import cats.effect.IO import cats.effect.kernel.Ref import cats.syntax.all.* -import scalatags.Text.TypedTag private def generatedIndexHtml( injectStyles: Boolean, @@ -154,11 +154,15 @@ end makeInternalPreloads def vanillaTemplate(styles: Boolean): String = val r = Ref.of[IO, Map[String, String]](Map.empty) - r.flatMap( rf => - vanillaTemplate(styles, rf, false).map(_.render) - ).unsafeRunSync()(using cats.effect.unsafe.implicits.global) + r.flatMap(rf => vanillaTemplate(styles, rf, false).map(_.render)) + .unsafeRunSync()(using cats.effect.unsafe.implicits.global) +end vanillaTemplate -def vanillaTemplate(withStyles: Boolean, ref: Ref[IO, Map[String, String]], attemptPreload: Boolean): IO[TypedTag[String]] = +def vanillaTemplate( + withStyles: Boolean, + ref: Ref[IO, Map[String, String]], + attemptPreload: Boolean +): IO[TypedTag[String]] = val preloads = makeInternalPreloads(ref) preloads.map: modules => diff --git a/sjsls/src/liveServer.scala b/sjsls/src/liveServer.scala index 1d16489..a7dd4f5 100644 --- a/sjsls/src/liveServer.scala +++ b/sjsls/src/liveServer.scala @@ -1,6 +1,7 @@ package io.github.quafadas.sjsls import scala.concurrent.duration.* + import org.http4s.* import org.http4s.ember.client.EmberClientBuilder import org.http4s.ember.server.EmberServerBuilder @@ -35,7 +36,6 @@ object LiveServer extends IOApp: .withShutdownTimeout(1.milli) .build - def parseOpts = ( baseDirOpt, outDirOpt, @@ -74,18 +74,22 @@ object LiveServer extends IOApp: ) .toResource - _ <- Resource.pure[IO, Boolean](lsc.dezombify).flatMap( - if(_) - Resource.eval(IO.println(s"Attempt to kill off process on port ${lsc.port}")) >> - dezombify(lsc.port) - else - scribe.cats[IO].debug(s"Assuming port ${lsc.port} is free").toResource - ) + _ <- Resource + .pure[IO, Boolean](lsc.dezombify) + .flatMap( + if _ then + Resource.eval(IO.println(s"Attempt to kill off process on port ${lsc.port}")) >> + dezombify(lsc.port) + else scribe.cats[IO].debug(s"Assuming port ${lsc.port} is free").toResource + ) fileToHashRef <- Ref[IO].of(Map.empty[String, String]).toResource - refreshTopic <- lsc.customRefresh.fold(Topic[IO, Unit])( - scribe.cats[IO].debug(s"Custom refresh topic supplied") >> - IO(_) - ).toResource + refreshTopic <- lsc + .customRefresh + .fold(Topic[IO, Unit])( + scribe.cats[IO].debug("Custom refresh topic supplied") >> + IO(_) + ) + .toResource linkingTopic <- Topic[IO, Unit].toResource client <- EmberClientBuilder.default[IO].build baseDirPath <- lsc.baseDir.fold(Files[IO].currentWorkingDirectory.toResource)(toDirectoryPath) diff --git a/sjsls/src/makeProxyConfig.scala b/sjsls/src/makeProxyConfig.scala index b437737..be4198c 100644 --- a/sjsls/src/makeProxyConfig.scala +++ b/sjsls/src/makeProxyConfig.scala @@ -18,4 +18,4 @@ http: - host: localhost port: $proxyTo weight: 5 -""" \ No newline at end of file +""" diff --git a/sjsls/src/middleware/ETagMiddleware.scala b/sjsls/src/middleware/ETagMiddleware.scala index f245ad1..0defe18 100644 --- a/sjsls/src/middleware/ETagMiddleware.scala +++ b/sjsls/src/middleware/ETagMiddleware.scala @@ -5,7 +5,6 @@ import org.http4s.HttpRoutes import org.http4s.Request import org.http4s.Response import org.http4s.Status -import org.http4s.dsl.io.* import org.typelevel.ci.CIStringSyntax import scribe.Scribe @@ -13,7 +12,6 @@ import scribe.Scribe import cats.data.Kleisli import cats.data.OptionT import cats.effect.* -import cats.effect.IO import cats.effect.kernel.Ref import cats.syntax.all.* @@ -29,8 +27,8 @@ object ETagMiddleware: map.get(req.uri.path.toString.drop(1)) match case Some(hash) => logger.debug("Map") >> - logger.debug(map.toString) >> - logger.debug(s"Found ETag: $hash in map for ${req.uri.path}") >> + logger.debug(map.toString) >> + logger.debug(s"Found ETag: $hash in map for ${req.uri.path}") >> IO( resp.putHeaders( Header.Raw(ci"ETag", hash), @@ -71,7 +69,7 @@ object ETagMiddleware: case Some(foundEt) => if etag == foundEt then logger.debug(s"ETag $etag found in cache at path ${req.uri.path}, returning 304") >> - logger.debug("map is: " + map.toString) >> + logger.debug("map is: " + map.toString) >> IO(Response[IO](Status.NotModified)) else logger.debug(s"$etag not found in cache at path ${req.uri.path} returning 200") >> diff --git a/sjsls/src/middleware/noCache.middleware.scala b/sjsls/src/middleware/noCache.middleware.scala index eef0a0f..7033295 100644 --- a/sjsls/src/middleware/noCache.middleware.scala +++ b/sjsls/src/middleware/noCache.middleware.scala @@ -10,7 +10,6 @@ import scribe.Scribe import cats.data.Kleisli import cats.data.OptionT import cats.effect.* -import cats.effect.IO import cats.syntax.all.* object NoCacheMiddlware: diff --git a/sjsls/src/middleware/staticFileMiddleware.scala b/sjsls/src/middleware/staticFileMiddleware.scala index eedebf8..a79b9d7 100644 --- a/sjsls/src/middleware/staticFileMiddleware.scala +++ b/sjsls/src/middleware/staticFileMiddleware.scala @@ -20,7 +20,6 @@ import scribe.Scribe import cats.data.Kleisli import cats.data.OptionT import cats.effect.* -import cats.effect.IO import cats.syntax.all.* def parseFromHeader(epochInstant: Instant, header: String): Long = diff --git a/sjsls/src/middleware/staticpathMiddleware.scala b/sjsls/src/middleware/staticpathMiddleware.scala index 372002a..ca71ecf 100644 --- a/sjsls/src/middleware/staticpathMiddleware.scala +++ b/sjsls/src/middleware/staticpathMiddleware.scala @@ -20,7 +20,6 @@ import scribe.Scribe import cats.data.Kleisli import cats.data.OptionT import cats.effect.* -import cats.effect.IO import cats.syntax.all.* inline def respondWithCacheLastModified(resp: Response[IO], lastModZdt: ZonedDateTime) = @@ -70,8 +69,7 @@ inline def cachedFileResponse(epochInstant: Instant, fullPath: Path, req: Reques response } case _ => - OptionT.liftF( - logger.debug(s"No If-Modified-Since headers in request ${req.uri.path}") ) >> + OptionT.liftF(logger.debug(s"No If-Modified-Since headers in request ${req.uri.path}")) >> service(req).map { resp => respondWithCacheLastModified( diff --git a/sjsls/src/refreshRoute.scala b/sjsls/src/refreshRoute.scala index c9ac4db..986fedf 100644 --- a/sjsls/src/refreshRoute.scala +++ b/sjsls/src/refreshRoute.scala @@ -13,14 +13,17 @@ import cats.effect.IO import _root_.io.circe.syntax.EncoderOps import cats.effect.kernel.Ref import scribe.Scribe -import cats.syntax.all.* - -def refreshRoutes(refreshTopic: Topic[IO, Unit], buildTool: BuildTool,stringPath: fs2.io.file.Path, mr: Ref[IO, Map[String, String]], logger: Scribe[IO]) = HttpRoutes.of[IO] { +def refreshRoutes( + refreshTopic: Topic[IO, Unit], + buildTool: BuildTool, + stringPath: fs2.io.file.Path, + mr: Ref[IO, Map[String, String]], + logger: Scribe[IO] +) = HttpRoutes.of[IO] { val keepAlive = fs2.Stream.fixedRate[IO](10.seconds).as(KeepAlive()) - val refresh = refreshTopic - .subscribe(10) + val refresh = refreshTopic.subscribe(10) buildTool match case _: NoBuildTool => @@ -29,21 +32,20 @@ def refreshRoutes(refreshTopic: Topic[IO, Unit], buildTool: BuildTool,stringPath keepAlive .merge( refresh - .evalTap(_ => - // A different tool is responsible for linking, so we hash the files "on the fly" when an update is requested - logger.debug("Updating Map Ref") >> - updateMapRef(stringPath, mr)(logger) - ) - .as(PageRefresh()) + .evalTap( + _ => + // A different tool is responsible for linking, so we hash the files "on the fly" when an update is requested + logger.debug("Updating Map Ref") >> + updateMapRef(stringPath, mr)(logger) + ) + .as(PageRefresh()) ) .map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) ) case _ => case GET -> Root / "refresh" / "v1" / "sse" => - println("Hit this one") Ok( - keepAlive - .merge(refresh.as(PageRefresh())) - .map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) + keepAlive.merge(refresh.as(PageRefresh())).map(msg => ServerSentEvent(Some(msg.asJson.noSpaces))) ) + end match } diff --git a/sjsls/src/routes.scala b/sjsls/src/routes.scala index baf1ff4..d79ae40 100644 --- a/sjsls/src/routes.scala +++ b/sjsls/src/routes.scala @@ -46,7 +46,9 @@ def routes[F[_]: Files: MonadThrow]( ) val refreshableApp = traceLogger( - refreshRoutes(refreshTopic, buildTool, fs2.io.file.Path(stringPath), ref, logger).combineK(proxyRoutes).combineK(routes) + refreshRoutes(refreshTopic, buildTool, fs2.io.file.Path(stringPath), ref, logger) + .combineK(proxyRoutes) + .combineK(routes) ) IO(refreshableApp).toResource diff --git a/sjsls/src/sseReload.scala b/sjsls/src/sseReload.scala index 2391d8b..a137b41 100644 --- a/sjsls/src/sseReload.scala +++ b/sjsls/src/sseReload.scala @@ -1,7 +1,6 @@ package io.github.quafadas.sjsls import _root_.io.circe.* -import _root_.io.circe.Encoder sealed trait FrontendEvent derives Encoder.AsObject diff --git a/sjsls/src/staticWatcher.scala b/sjsls/src/staticWatcher.scala index eef7d1d..8dae11e 100644 --- a/sjsls/src/staticWatcher.scala +++ b/sjsls/src/staticWatcher.scala @@ -20,7 +20,6 @@ import fs2.io.file.Path import scribe.Scribe import cats.effect.* - import cats.syntax.all.* def staticWatcher( diff --git a/sjsls/test/src/RoutesSpec.scala b/sjsls/test/src/RoutesSpec.scala index f15135a..12b164e 100644 --- a/sjsls/test/src/RoutesSpec.scala +++ b/sjsls/test/src/RoutesSpec.scala @@ -8,7 +8,6 @@ import java.time.ZonedDateTime import scala.concurrent.duration.* import org.http4s.* -import org.http4s.HttpRoutes import org.http4s.client.Client import org.http4s.implicits.* import org.http4s.server.middleware.ErrorAction @@ -16,13 +15,11 @@ import org.typelevel.ci.CIStringSyntax import fs2.concurrent.Topic import fs2.io.file.Files -import fs2.io.file.Path import scribe.Level import scribe.Scribe import cats.effect.* -import cats.effect.IO import cats.effect.kernel.Ref import cats.effect.std.MapRef diff --git a/sjsls/test/src/dezombify.test.scala b/sjsls/test/src/dezombify.test.scala index ee75b70..fe5dbc7 100644 --- a/sjsls/test/src/dezombify.test.scala +++ b/sjsls/test/src/dezombify.test.scala @@ -1,7 +1,9 @@ package io.github.quafadas.sjsls -import cats.effect.IO import com.comcast.ip4s.Port + +import cats.effect.IO + import munit.CatsEffectSuite class DezombieTest extends CatsEffectSuite: @@ -11,17 +13,17 @@ class DezombieTest extends CatsEffectSuite: val port = Port.fromInt(portInt).get val lsc = LiveServerConfig( - baseDir = None, - stylesDir = None, - port = port, - buildTool = NoBuildTool(), - openBrowserAt = "", - preventBrowserOpen = true, - dezombify = true, - logLevel = "debug", - ) - - for { + baseDir = None, + stylesDir = None, + port = port, + buildTool = NoBuildTool(), + openBrowserAt = "", + preventBrowserOpen = true, + dezombify = true, + logLevel = "debug" + ) + + for // Start first server in a separate process using mill run _ <- IO.println("You should have already started a zombie server in separate process...") @@ -34,7 +36,7 @@ class DezombieTest extends CatsEffectSuite: allocated <- LiveServer.main(lsc).allocated (server2, release2) = allocated _ <- IO.println("Second server started successfully!") - - } yield - (()) - } \ No newline at end of file + yield (()) + end for + } +end DezombieTest diff --git a/sjsls/test/src/liveServer.test.scala b/sjsls/test/src/liveServer.test.scala index 341a2a9..dfac792 100644 --- a/sjsls/test/src/liveServer.test.scala +++ b/sjsls/test/src/liveServer.test.scala @@ -15,11 +15,12 @@ import com.microsoft.playwright.* import com.microsoft.playwright.assertions.LocatorAssertions.ContainsTextOptions import com.microsoft.playwright.assertions.PlaywrightAssertions.assertThat +import fs2.concurrent.Topic + import cats.effect.IO import cats.effect.kernel.Ref import munit.CatsEffectSuite -import fs2.concurrent.Topic /* Run From e000968e131f0263782cc7dd127ce65ffdd9e4b3 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 2 Sep 2025 21:59:52 +0200 Subject: [PATCH 4/5] republish website --- .github/workflows/ci.yml | 86 ++++++++++++++++++++-------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f48398c..9ba09fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,46 +102,46 @@ jobs: MILL_SONATYPE_PASSWORD: ${{ secrets.PUBLISH_PASSWORD }} MILL_SONATYPE_USERNAME: ${{ secrets.PUBLISH_USER }} - # site: - # if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/tags/') - # needs: build - # runs-on: ubuntu-latest - # steps: - # - uses: coursier/setup-action@main - # with: - # jvm: temurin@21 - # apps: scala-cli - # - uses: actions/checkout@main - # with: - # fetch-depth: 0 - # fetch-tags: true - # - run: ./mill site.publishDocs - # - name: Setup Pages - # uses: actions/configure-pages@main - # - uses: actions/upload-artifact@main - # with: - # name: page - # path: out/site/publishDocs.dest - # if-no-files-found: error - - # deploy: - # needs: site - # permissions: - # pages: write - # id-token: write - # environment: - # name: github-pages - # url: ${{ steps.deployment.outputs.page_url }} - # runs-on: ubuntu-latest - # steps: - # - uses: actions/download-artifact@main - # with: - # name: page - # path: . - # - uses: actions/configure-pages@main - # - uses: actions/upload-pages-artifact@main - # with: - # path: . - # - name: Deploy to GitHub Pages - # id: deployment - # uses: actions/deploy-pages@main \ No newline at end of file + site: + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/tags/') + needs: build + runs-on: ubuntu-latest + steps: + - uses: coursier/setup-action@main + with: + jvm: temurin@21 + apps: scala-cli + - uses: actions/checkout@main + with: + fetch-depth: 0 + fetch-tags: true + - run: ./mill site.siteGen + - name: Setup Pages + uses: actions/configure-pages@main + - uses: actions/upload-artifact@main + with: + name: page + path: out/site/laika/generateSite.dest + if-no-files-found: error + + deploy: + needs: site + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@main + with: + name: page + path: . + - uses: actions/configure-pages@main + - uses: actions/upload-pages-artifact@main + with: + path: . + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@main \ No newline at end of file From b5812537a1dc8d326236cbfaf8217f137c26a42d Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 2 Sep 2025 22:08:52 +0200 Subject: [PATCH 5/5] . --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ba09fa..b5194c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,7 +103,7 @@ jobs: MILL_SONATYPE_USERNAME: ${{ secrets.PUBLISH_USER }} site: - if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/main' || github.ref == 'refs/tags/') + if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest steps: @@ -125,6 +125,7 @@ jobs: if-no-files-found: error deploy: + if: github.ref == 'refs/heads/main' needs: site permissions: pages: write