diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 39c49bc..8aef4df 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,15 +14,29 @@ jobs: runs-on: ubuntu-latest steps: - uses: sbt/setup-sbt@v1 # setup sbt - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Run tests run: sbt +test + + sim-smoke: + runs-on: ubuntu-latest + steps: + - uses: sbt/setup-sbt@v1 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: '24' + - name: Link Scala.js bundle into JVM resources + run: sbt copySimJs + - name: Smoke-test the simulation bundle via node + run: node bifunctor-tagless/js/sim-smoke.js + build-native: runs-on: ubuntu-latest steps: - uses: sbt/setup-sbt@v1 # setup sbt - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 - name: Build native app - run: sbt "project bifunctor-tagless; GraalVMNativeImage/packageBin" + run: sbt "project bifunctor-taglessJVM; GraalVMNativeImage/packageBin" - name: Check native app - run: ./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless :help + run: ./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless :help diff --git a/.gitignore b/.gitignore index 7c003ed..1997e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,16 @@ hs_err_pid* ### Scala template *.class *.log + +# Project-local sbt/coursier/ivy caches used by the containerised dev loop. +.cache/ + +# direnv-materialised devShell +.direnv/ + +# Scala.js bundle copied here by the `copySimJs` sbt task — regenerated on +# demand from bifunctor-tagless-js. Not checked in. +bifunctor-tagless/jvm/src/main/resources/webapp/main.js +bifunctor-tagless/jvm/src/main/resources/webapp/*.js.map +bifunctor-tagless/jvm/src/main/resources/webapp/internal-*.js + diff --git a/README.md b/README.md index 93fa7f2..7756d1b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,54 @@ curl -X GET http://localhost:8080/ladder curl -X GET http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4 ``` +### Reproducible toolchain via Nix (optional) + +A `flake.nix` is included. With Nix flakes enabled you can drop into a shell +that has the exact JDK, sbt, Scala 3 and Node versions used by this project: + +```bash +nix develop # one-off shell +direnv allow # automatic — uses the bundled .envrc +``` + +### Perfect simulation in the browser (`bifunctor-tagless` only) + +The `bifunctor-tagless` variant is cross-built for the JVM and Scala.js. The +same `LadderApi`/`ProfileApi` http4s routes that the JVM server exposes are +also assembled in the browser into an in-process `LocalDispatcher`, which a +`@JSExportTopLevel("LeaderboardSim")` object surfaces to JavaScript. The JS +graph is configured with `Repo -> Dummy`, so it uses the same in-memory +repositories that the JVM tests use — no network, no postgres, no docker. + +A small demo UI in `bifunctor-tagless/jvm/src/main/resources/webapp/` lets you +call each endpoint with a radio toggle between **production** (real HTTP) and +**simulation** (the in-page Scala.js build). + +To use it, run the single convenience script — it builds the Scala.js bundle, +copies it next to the UI, and starts the server in dummy mode: + +```bash +./launch-sim +``` + +Then open . You can also open +`bifunctor-tagless/jvm/src/main/resources/webapp/index.html` directly via +`file://` (CORS on the server allows the `null` origin used by `file://`). + +If you prefer the steps separately: + +```bash +sbt copySimJs # build + copy the Scala.js bundle +./launcher -u repo:dummy :leaderboard # start the server +``` + +The "Production" radio talks to `http://localhost:8080`; the "Simulation" +radio calls `window.LeaderboardSim.call(method, path, body)`, which runs the +exact same request through the in-browser http4s routes. State only persists +within each mode — flipping back and forth is itself a useful demonstration +that the simulation is a clean process that knows nothing about the real +server's state. + #### Note If `./launcher` command fails for you with some cryptic stack trace, there's most likely an issue with your Docker. First of all, check that you have `docker` and `contrainerd` daemons running. If you're using something else than Ubuntu, please stick to the relevant [installation page](https://docs.docker.com/engine/install/): @@ -61,7 +109,7 @@ Both of them should have `Active: active (running)` status. If your problem isn' Use `sbt` to build a native Linux binary with GraalVM NativeImage under Docker: ```bash -sbt bifunctor-tagless/GraalVMNativeImage/packageBin +sbt bifunctor-taglessJVM/GraalVMNativeImage/packageBin ``` If you want to build the app using local `native-image` executable (e.g. on a Mac), comment out the `graalVMNativeImageGraalVersion` key in `build.sbt` first. @@ -69,13 +117,13 @@ If you want to build the app using local `native-image` executable (e.g. on a Ma To test the native app with dummy repositories run: ```bash -./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:dummy :leaderboard +./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:dummy :leaderboard ``` To test the native app with production repositories in Docker run: ```bash -./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:prod :leaderboard +./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:prod :leaderboard ``` Notes: diff --git a/bifunctor-tagless/js/sim-smoke.js b/bifunctor-tagless/js/sim-smoke.js new file mode 100755 index 0000000..c05bba7 --- /dev/null +++ b/bifunctor-tagless/js/sim-smoke.js @@ -0,0 +1,114 @@ +#!/usr/bin/env node +/** + * Smoke-test the in-browser simulation bundle outside the browser. + * + * Loads the linked Scala.js bundle from the JVM project's + * `resources/webapp/main.js` (produced by `sbt copySimJs`) and exercises the + * same four endpoints the README's `curl` snippets call: submit a score, get + * the ladder, set a profile, get the profile. Each call should return HTTP + * 200; mismatches fail the run with a non-zero exit code. + * + * Run from the repo root: + * + * sbt copySimJs + * node bifunctor-tagless/js/sim-smoke.js + * + * scala.js NoModule mode emits `let LeaderboardSim` at script top level. This + * is intentionally script-scoped (so a browser + + + diff --git a/bifunctor-tagless/src/main/scala/leaderboard/LeaderboardRole.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/LeaderboardRole.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/LeaderboardRole.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/LeaderboardRole.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/config/PostgresCfg.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresCfg.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/config/PostgresCfg.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresCfg.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/config/PostgresPortCfg.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresPortCfg.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/config/PostgresPortCfg.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/config/PostgresPortCfg.scala diff --git a/bifunctor-tagless/jvm/src/main/scala/leaderboard/http/HttpServer.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/http/HttpServer.scala new file mode 100644 index 0000000..f5f4e24 --- /dev/null +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/http/HttpServer.scala @@ -0,0 +1,60 @@ +package leaderboard.http + +import cats.effect.Async +import cats.syntax.all.* +import com.comcast.ip4s.Port +import fs2.io.net.Network +import izumi.distage.model.definition.Lifecycle +import leaderboard.api.HttpApi +import org.http4s.dsl.Http4sDsl +import org.http4s.ember.server.EmberServerBuilder +import org.http4s.headers.Location +import org.http4s.server.Server +import org.http4s.server.middleware.CORS +import org.http4s.server.staticcontent.resourceServiceBuilder +import org.http4s.{HttpRoutes, Uri} + +final case class HttpServer( + server: Server +) + +object HttpServer { + + final class Impl[F[+_, +_]]( + allHttpApis: Set[HttpApi[F]], + dsl: Http4sDsl[F[Throwable, _]], + )(implicit + async: Async[F[Throwable, _]] + ) extends Lifecycle.Of[F[Throwable, _], HttpServer]( + Lifecycle.fromCats { + type G[A] = F[Throwable, A] + import dsl.* + + // The same HttpRoutes the LocalDispatcher invokes in-process. + val apiRoutes: HttpRoutes[G] = allHttpApis.map(_.http).toList.foldK + + // Static demo UI in classpath under /webapp. The resource builder maps + // request paths verbatim (e.g. GET /main.js -> classpath /webapp/main.js), + // so a tiny extra route redirects GET / to /index.html. + val indexRedirect: HttpRoutes[G] = HttpRoutes.of[G] { + case GET -> Root => PermanentRedirect(Location(Uri.unsafeFromString("/index.html"))) + } + val staticRoutes: HttpRoutes[G] = resourceServiceBuilder[G]("/webapp").toRoutes + + // Permissive CORS so the same UI also works when opened from file:// + // (its origin is "null" in that case). Demo-grade — do not copy + // verbatim into a real production service. + val app = CORS.policy.withAllowOriginAll( + (apiRoutes <+> indexRedirect <+> staticRoutes).orNotFound + ) + + EmberServerBuilder + .default(using async, Network.forAsync) + .withHttpApp(app) + .withPort(Port.fromInt(8080).get) + .build + .map(HttpServer(_)) + } + ) + +} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala similarity index 66% rename from bifunctor-tagless/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala index 18dda71..7ff56e2 100644 --- a/bifunctor-tagless/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/LeaderboardPlugin.scala @@ -9,23 +9,22 @@ import izumi.distage.roles.bundled.BundledRolesModule import izumi.distage.roles.model.definition.RoleModuleDef import izumi.fundamentals.platform.integration.PortCheck import izumi.fundamentals.platform.versions.Version -import leaderboard.api.{HttpApi, LadderApi, ProfileApi} import leaderboard.config.{PostgresCfg, PostgresPortCfg} import leaderboard.http.HttpServer -import leaderboard.repo.{Ladder, Profiles} -import leaderboard.services.Ranks +import leaderboard.repo.{Ladder, LadderPostgres, Profiles, ProfilesPostgres} import leaderboard.sql.{SQL, TransactorResource} import leaderboard.{LadderRole, LeaderboardRole, ProfileRole} -import org.http4s.dsl.Http4sDsl import zio.IO import scala.concurrent.duration.* object LeaderboardPlugin extends PluginDef { include(modules.roles[IO]) - include(modules.api[IO]) - include(modules.repoDummy[IO]) + // Shared API + dummy repos wiring — same code that runs in the browser. + include(LeaderboardCoreModule.api[IO]) + include(LeaderboardCoreModule.repoDummy[IO]) include(modules.repoProd[IO]) + include(modules.server[IO]) include(modules.configs) include(modules.prodConfigs) @@ -44,36 +43,15 @@ object LeaderboardPlugin extends PluginDef { include(BundledRolesModule[F[Throwable, _]](version = Version.parse("1.0.0"))) } - def api[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { - // The `ladder` API - make[LadderApi[F]] - // The `profile` API - make[ProfileApi[F]] - - // A set of all APIs - many[HttpApi[F]] - .weak[LadderApi[F]] // add ladder API as a _weak reference_ - .weak[ProfileApi[F]] // add profiles API as a _weak reference_ - + def server[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { make[HttpServer].fromResource[HttpServer.Impl[F]] - - make[Ranks[F]].from[Ranks.Impl[F]] - - makeTrait[Http4sDsl[F[Throwable, _]]] - } - - def repoDummy[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { - tag(Repo.Dummy) - - make[Ladder[F]].fromResource[Ladder.Dummy[F]] - make[Profiles[F]].fromResource[Profiles.Dummy[F]] } def repoProd[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { tag(Repo.Prod) - make[Ladder[F]].fromResource[Ladder.Postgres[F]] - make[Profiles[F]].fromResource[Profiles.Postgres[F]] + make[Ladder[F]].fromResource[LadderPostgres[F]] + make[Profiles[F]].fromResource[ProfilesPostgres[F]] make[SQL[F]].from[SQL.Impl[F]] diff --git a/bifunctor-tagless/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/plugins/PostgresDockerPlugin.scala diff --git a/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/LadderPostgres.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/LadderPostgres.scala new file mode 100644 index 0000000..e21900c --- /dev/null +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/LadderPostgres.scala @@ -0,0 +1,42 @@ +package leaderboard.repo + +import distage.Lifecycle +import doobie.postgres.implicits.* +import doobie.syntax.string.* +import izumi.functional.bio.Monad2 +import leaderboard.model.{QueryFailure, Score, UserId} +import leaderboard.sql.SQL +import logstage.LogIO2 + +// Postgres-backed implementation of [[Ladder]]. Lives JVM-side because doobie +// is not cross-built to scala.js. +final class LadderPostgres[F[+_, +_]: Monad2]( + sql: SQL[F], + log: LogIO2[F], +) extends Lifecycle.LiftF[F[Throwable, _], Ladder[F]](for { + _ <- log.info(s"Creating Ladder table") + _ <- sql.execute("ladder-ddl") { + sql"""create table if not exists ladder ( + | user_id uuid not null, + | score bigint not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + res = new Ladder[F] { + override def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] = + sql + .execute("submit-score") { + sql"""insert into ladder (user_id, score) values ($userId, $score) + |on conflict (user_id) do update set + | score = excluded.score + |""".stripMargin.update.run + }.void + + override val getScores: F[QueryFailure, List[(UserId, Score)]] = + sql.execute("get-leaderboard") { + sql"""select user_id, score from ladder order by score DESC + |""".stripMargin.query[(UserId, Score)].to[List] + } + } + } yield res) diff --git a/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/ProfilesPostgres.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/ProfilesPostgres.scala new file mode 100644 index 0000000..23b2c1a --- /dev/null +++ b/bifunctor-tagless/jvm/src/main/scala/leaderboard/repo/ProfilesPostgres.scala @@ -0,0 +1,47 @@ +package leaderboard.repo + +import distage.Lifecycle +import doobie.postgres.implicits.* +import doobie.implicits.* +import izumi.functional.bio.Monad2 +import leaderboard.model.{QueryFailure, UserId, UserProfile} +import leaderboard.sql.SQL +import logstage.LogIO2 + +// Postgres-backed implementation of [[Profiles]]. Lives JVM-side because doobie +// is not cross-built to scala.js. +final class ProfilesPostgres[F[+_, +_]: Monad2]( + sql: SQL[F], + log: LogIO2[F], +) extends Lifecycle.LiftF[F[Throwable, _], Profiles[F]](for { + _ <- log.info("Creating Profile table") + _ <- sql.execute("ddl-profiles") { + sql"""create table if not exists profiles ( + | user_id uuid not null, + | name text not null, + | description text not null, + | primary key (user_id) + |) without oids + |""".stripMargin.update.run + } + } yield new Profiles[F] { + override def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] = { + sql + .execute("set-profile") { + sql"""insert into profiles (user_id, name, description) + |values ($userId, ${profile.name}, ${profile.description}) + |on conflict (user_id) do update set + | name = excluded.name, + | description = excluded.description + |""".stripMargin.update.run + }.void + } + + override def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] = { + sql.execute("get-profile") { + sql"""select name, description from profiles + |where user_id = $userId + |""".stripMargin.query[UserProfile].option + } + } + }) diff --git a/bifunctor-tagless/src/main/scala/leaderboard/sql/SQL.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/SQL.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/sql/SQL.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/SQL.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/sql/TransactorResource.scala b/bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/TransactorResource.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/sql/TransactorResource.scala rename to bifunctor-tagless/jvm/src/main/scala/leaderboard/sql/TransactorResource.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/Rnd.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/Rnd.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/Rnd.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/Rnd.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/WiringTest.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/WiringTest.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/WiringTest.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/WiringTest.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/tests.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/tests.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/tests.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/tests.scala diff --git a/bifunctor-tagless/src/test/scala/leaderboard/zioenv.scala b/bifunctor-tagless/jvm/src/test/scala/leaderboard/zioenv.scala similarity index 100% rename from bifunctor-tagless/src/test/scala/leaderboard/zioenv.scala rename to bifunctor-tagless/jvm/src/test/scala/leaderboard/zioenv.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/api/HttpApi.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/api/HttpApi.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/api/HttpApi.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/api/HttpApi.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/api/LadderApi.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/api/LadderApi.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/api/LadderApi.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/api/LadderApi.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/api/ProfileApi.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/api/ProfileApi.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/api/ProfileApi.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/api/ProfileApi.scala diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/dispatch/LocalDispatcher.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/dispatch/LocalDispatcher.scala new file mode 100644 index 0000000..2658404 --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/dispatch/LocalDispatcher.scala @@ -0,0 +1,45 @@ +package leaderboard.dispatch + +import cats.effect.Async +import cats.syntax.all.* +import leaderboard.api.HttpApi +import leaderboard.dispatch.LocalDispatcher.Response +import org.http4s.{HttpApp, Method, Request, Uri} + +import scala.annotation.unused + +/** + * Invokes the same `HttpRoutes` that the http4s server exposes, but without + * a network hop. Used by the http4s server on the JVM as the actual handler + * for incoming requests, and by the `@JSExportTopLevel` entrypoint in the + * browser so the front-end can call the simulated backend exactly like it + * would call the real one. + */ +trait LocalDispatcher[F[_, _]] { + def call(method: String, path: String, body: String): F[Throwable, Response] +} + +object LocalDispatcher { + final case class Response(status: Int, body: String) + + final class Impl[F[+_, +_]]( + @unused apis: Set[HttpApi[F]] + )(implicit + async: Async[F[Throwable, _]] + ) extends LocalDispatcher[F] { + private type G[A] = F[Throwable, A] + + // Pre-assembled handler matching what the http4s server runs. + private val httpApp: HttpApp[G] = apis.map(_.http).toList.foldK.orNotFound + + override def call(method: String, path: String, body: String): F[Throwable, Response] = { + val req = Request[G]( + method = Method.fromString(method).getOrElse(Method.GET), + uri = Uri.unsafeFromString(path), + ).withEntity(body) + httpApp.run(req).flatMap { resp => + resp.as[String].map(text => Response(resp.status.code, text)) + } + } + } +} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/QueryFailure.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/QueryFailure.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/QueryFailure.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/QueryFailure.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/RankedProfile.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/RankedProfile.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/RankedProfile.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/RankedProfile.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/UserProfile.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/UserProfile.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/UserProfile.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/UserProfile.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/model/package.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/model/package.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/model/package.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/model/package.scala diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/plugins/LeaderboardCoreModule.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/plugins/LeaderboardCoreModule.scala new file mode 100644 index 0000000..0981fc7 --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/plugins/LeaderboardCoreModule.scala @@ -0,0 +1,46 @@ +package leaderboard.plugins + +import distage.StandardAxis.Repo +import distage.{ModuleDef, TagKK} +import leaderboard.api.{HttpApi, LadderApi, ProfileApi} +import leaderboard.dispatch.LocalDispatcher +import leaderboard.repo.{Ladder, Profiles} +import leaderboard.services.Ranks +import org.http4s.dsl.Http4sDsl + +/** + * Cross-compilable subset of the application wiring. Holds everything that + * doesn't depend on JVM-only libraries (http4s ember server, doobie, JNA, + * roles), so the same module can be assembled into a real http server on the + * JVM and into an in-browser scala.js simulation. + */ +object LeaderboardCoreModule { + + def api[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { + // The `ladder` API + make[LadderApi[F]] + // The `profile` API + make[ProfileApi[F]] + + // A set of all APIs + many[HttpApi[F]] + .weak[LadderApi[F]] // add ladder API as a _weak reference_ + .weak[ProfileApi[F]] // add profiles API as a _weak reference_ + + make[Ranks[F]].from[Ranks.Impl[F]] + + makeTrait[Http4sDsl[F[Throwable, _]]] + + // Wraps the assembled HttpRoutes for direct in-process calls. Used both by + // the http4s adapter on JVM and by the @JSExportTopLevel entrypoint in the + // browser simulation. + make[LocalDispatcher[F]].from[LocalDispatcher.Impl[F]] + } + + def repoDummy[F[+_, +_]: TagKK]: ModuleDef = new ModuleDef { + tag(Repo.Dummy) + + make[Ladder[F]].fromResource[Ladder.Dummy[F]] + make[Profiles[F]].fromResource[Profiles.Dummy[F]] + } +} diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Ladder.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Ladder.scala new file mode 100644 index 0000000..52155af --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Ladder.scala @@ -0,0 +1,26 @@ +package leaderboard.repo + +import distage.Lifecycle +import izumi.functional.bio.{Applicative2, F, Primitives2} +import leaderboard.model.{QueryFailure, Score, UserId} + +trait Ladder[F[_, _]] { + def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] + def getScores: F[QueryFailure, List[(UserId, Score)]] +} + +object Ladder { + // In-memory dummy used by both JVM tests and the in-browser simulation. + final class Dummy[F[+_, +_]: Applicative2: Primitives2] + extends Lifecycle.LiftF[F[Nothing, _], Ladder[F]](for { + state <- F.mkRef(Map.empty[UserId, Score]) + } yield { + new Ladder[F] { + override def submitScore(userId: UserId, score: Score): F[Nothing, Unit] = + state.update_(_ + (userId -> score)) + + override def getScores: F[Nothing, List[(UserId, Score)]] = + state.get.map(_.toList.sortBy(_._2)(Ordering[Score].reverse)) + } + }) +} diff --git a/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Profiles.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Profiles.scala new file mode 100644 index 0000000..8cfcf8a --- /dev/null +++ b/bifunctor-tagless/shared/src/main/scala/leaderboard/repo/Profiles.scala @@ -0,0 +1,26 @@ +package leaderboard.repo + +import distage.Lifecycle +import izumi.functional.bio.{Applicative2, F, Primitives2} +import leaderboard.model.{QueryFailure, UserId, UserProfile} + +trait Profiles[F[_, _]] { + def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] + def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] +} + +object Profiles { + // In-memory dummy used by both JVM tests and the in-browser simulation. + final class Dummy[F[+_, +_]: Applicative2: Primitives2] + extends Lifecycle.LiftF[F[Nothing, _], Profiles[F]](for { + state <- F.mkRef(Map.empty[UserId, UserProfile]) + } yield { + new Profiles[F] { + override def setProfile(userId: UserId, profile: UserProfile): F[Nothing, Unit] = + state.update_(_ + (userId -> profile)) + + override def getProfile(userId: UserId): F[Nothing, Option[UserProfile]] = + state.get.map(_.get(userId)) + } + }) +} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/services/Ranks.scala b/bifunctor-tagless/shared/src/main/scala/leaderboard/services/Ranks.scala similarity index 100% rename from bifunctor-tagless/src/main/scala/leaderboard/services/Ranks.scala rename to bifunctor-tagless/shared/src/main/scala/leaderboard/services/Ranks.scala diff --git a/bifunctor-tagless/src/main/scala/leaderboard/http/HttpServer.scala b/bifunctor-tagless/src/main/scala/leaderboard/http/HttpServer.scala deleted file mode 100644 index 6a3cda1..0000000 --- a/bifunctor-tagless/src/main/scala/leaderboard/http/HttpServer.scala +++ /dev/null @@ -1,35 +0,0 @@ -package leaderboard.http - -import cats.effect.Async -import cats.syntax.all.* -import com.comcast.ip4s.Port -import fs2.io.net.Network -import izumi.distage.model.definition.Lifecycle -import leaderboard.api.HttpApi -import org.http4s.ember.server.EmberServerBuilder -import org.http4s.server.Server - -final case class HttpServer( - server: Server -) - -object HttpServer { - - final class Impl[F[+_, +_]]( - allHttpApis: Set[HttpApi[F]] - )(implicit - async: Async[F[Throwable, _]] - ) extends Lifecycle.Of[F[Throwable, _], HttpServer]( - Lifecycle.fromCats { - val combinedApis = allHttpApis.map(_.http).toList.foldK - - EmberServerBuilder - .default(using async, Network.forAsync) - .withHttpApp(combinedApis.orNotFound) - .withPort(Port.fromInt(8080).get) - .build - .map(HttpServer(_)) - } - ) - -} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/repo/Ladder.scala b/bifunctor-tagless/src/main/scala/leaderboard/repo/Ladder.scala deleted file mode 100644 index 105adaf..0000000 --- a/bifunctor-tagless/src/main/scala/leaderboard/repo/Ladder.scala +++ /dev/null @@ -1,60 +0,0 @@ -package leaderboard.repo - -import distage.Lifecycle -import doobie.postgres.implicits.* -import doobie.syntax.string.* -import izumi.functional.bio.{Applicative2, F, Monad2, Primitives2} -import leaderboard.model.{QueryFailure, Score, UserId} -import leaderboard.sql.SQL -import logstage.LogIO2 - -trait Ladder[F[_, _]] { - def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] - def getScores: F[QueryFailure, List[(UserId, Score)]] -} - -object Ladder { - final class Dummy[F[+_, +_]: Applicative2: Primitives2] - extends Lifecycle.LiftF[F[Nothing, _], Ladder[F]](for { - state <- F.mkRef(Map.empty[UserId, Score]) - } yield { - new Ladder[F] { - override def submitScore(userId: UserId, score: Score): F[Nothing, Unit] = - state.update_(_ + (userId -> score)) - - override def getScores: F[Nothing, List[(UserId, Score)]] = - state.get.map(_.toList.sortBy(_._2)(Ordering[Score].reverse)) - } - }) - - final class Postgres[F[+_, +_]: Monad2]( - sql: SQL[F], - log: LogIO2[F], - ) extends Lifecycle.LiftF[F[Throwable, _], Ladder[F]](for { - _ <- log.info(s"Creating Ladder table") - _ <- sql.execute("ladder-ddl") { - sql"""create table if not exists ladder ( - | user_id uuid not null, - | score bigint not null, - | primary key (user_id) - |) without oids - |""".stripMargin.update.run - } - res = new Ladder[F] { - override def submitScore(userId: UserId, score: Score): F[QueryFailure, Unit] = - sql - .execute("submit-score") { - sql"""insert into ladder (user_id, score) values ($userId, $score) - |on conflict (user_id) do update set - | score = excluded.score - |""".stripMargin.update.run - }.void - - override val getScores: F[QueryFailure, List[(UserId, Score)]] = - sql.execute("get-leaderboard") { - sql"""select user_id, score from ladder order by score DESC - |""".stripMargin.query[(UserId, Score)].to[List] - } - } - } yield res) -} diff --git a/bifunctor-tagless/src/main/scala/leaderboard/repo/Profiles.scala b/bifunctor-tagless/src/main/scala/leaderboard/repo/Profiles.scala deleted file mode 100644 index 96b64a7..0000000 --- a/bifunctor-tagless/src/main/scala/leaderboard/repo/Profiles.scala +++ /dev/null @@ -1,65 +0,0 @@ -package leaderboard.repo - -import distage.Lifecycle -import doobie.postgres.implicits.* -import doobie.implicits.* -import izumi.functional.bio.{Applicative2, F, Monad2, Primitives2} -import leaderboard.model.{QueryFailure, UserId, UserProfile} -import leaderboard.sql.SQL -import logstage.LogIO2 - -trait Profiles[F[_, _]] { - def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] - def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] -} - -object Profiles { - final class Dummy[F[+_, +_]: Applicative2: Primitives2] - extends Lifecycle.LiftF[F[Nothing, _], Profiles[F]](for { - state <- F.mkRef(Map.empty[UserId, UserProfile]) - } yield { - new Profiles[F] { - override def setProfile(userId: UserId, profile: UserProfile): F[Nothing, Unit] = - state.update_(_ + (userId -> profile)) - - override def getProfile(userId: UserId): F[Nothing, Option[UserProfile]] = - state.get.map(_.get(userId)) - } - }) - - final class Postgres[F[+_, +_]: Monad2]( - sql: SQL[F], - log: LogIO2[F], - ) extends Lifecycle.LiftF[F[Throwable, _], Profiles[F]](for { - _ <- log.info("Creating Profile table") - _ <- sql.execute("ddl-profiles") { - sql"""create table if not exists profiles ( - | user_id uuid not null, - | name text not null, - | description text not null, - | primary key (user_id) - |) without oids - |""".stripMargin.update.run - } - } yield new Profiles[F] { - override def setProfile(userId: UserId, profile: UserProfile): F[QueryFailure, Unit] = { - sql - .execute("set-profile") { - sql"""insert into profiles (user_id, name, description) - |values ($userId, ${profile.name}, ${profile.description}) - |on conflict (user_id) do update set - | name = excluded.name, - | description = excluded.description - |""".stripMargin.update.run - }.void - } - - override def getProfile(userId: UserId): F[QueryFailure, Option[UserProfile]] = { - sql.execute("get-profile") { - sql"""select name, description from profiles - |where user_id = $userId - |""".stripMargin.query[UserProfile].option - } - } - }) -} diff --git a/build.sbt b/build.sbt index 1667b06..74d5744 100644 --- a/build.sbt +++ b/build.sbt @@ -1,3 +1,6 @@ +import org.scalajs.linker.interface.{ModuleKind, OutputPatterns} +import sbtcrossproject.CrossPlugin.autoImport.{CrossType, crossProject} + import scala.util.chaining.scalaUtilChainingOps val V = new { @@ -9,7 +12,7 @@ val V = new { val doobie = "1.0.0-RC12" val catsCore = "2.13.0" val zio = "2.1.25" - val zioCats = "23.0.0.8" + val zioCats = "23.1.0.13" val kindProjector = "0.13.4" val circeGeneric = "0.14.15" val graalMetadata = "0.11.5" @@ -47,6 +50,8 @@ val Deps = new { val graalMetadata = "org.graalvm.buildtools" % "graalvm-reachability-metadata" % V.graalMetadata + // Standard set of deps for JVM-only sub-projects (monomorphic-cats, + // monofunctor-tagless, and the JVM half of bifunctor-tagless). val CoreDeps = Seq( distageCore, distageRoles, @@ -79,9 +84,75 @@ inThisBuild( ) ) -lazy val `bifunctor-tagless` = project +// ----------------------------------------------------------------------------- +// bifunctor-tagless: a cross-built project (JVM + Scala.js) +// +// The shared half holds everything that compiles to both targets: the model, +// the API classes (which return http4s `HttpRoutes`), the repository traits +// and their in-memory dummy implementations, and the `LocalDispatcher` that +// invokes the assembled routes in-process. The JVM half adds the postgres +// repositories, the http4s ember server with CORS + static-file serving, and +// the role/launcher infrastructure. The JS half adds a `@JSExportTopLevel` +// entrypoint that boots the same distage graph with `Repo -> Dummy` and +// surfaces the dispatcher to the browser as `window.LeaderboardSim`. +// ----------------------------------------------------------------------------- + +lazy val `bifunctor-tagless` = crossProject(JVMPlatform, JSPlatform) + .crossType(CrossType.Full) .in(file("bifunctor-tagless")) - .pipe(sharedSettings(Seq(Deps.zio, Deps.zioCats))) + .settings( + sharedScalaSettings, + libraryDependencies ++= Seq( + "io.7mind.izumi" %%% "distage-core" % V.distage, + "io.7mind.izumi" %%% "distage-extension-plugins" % V.distage, + "io.7mind.izumi" %%% "distage-extension-logstage" % V.distage, + "io.7mind.izumi" %%% "logstage-core" % V.logstage, + "org.http4s" %%% "http4s-dsl" % V.http4s, + "org.http4s" %%% "http4s-circe" % V.http4s, + "io.circe" %%% "circe-generic" % V.circeGeneric, + "org.typelevel" %%% "cats-core" % V.catsCore, + "dev.zio" %%% "zio" % V.zio, + // `zio-managed` is required transitively by `zio-interop-cats`'s + // ZManaged bridge classes; under scala.js the linker validates all + // referenced classes, so we must pull the artifact in explicitly. + "dev.zio" %%% "zio-managed" % V.zio, + "dev.zio" %%% "zio-interop-cats" % V.zioCats, + ), + ) + .jvmConfigure(_.pipe(jvmSharedSettings(Seq(Deps.zio, Deps.zioCats)))) + .jsSettings( + scalaJSUseMainModuleInitializer := false, + // NoModule output: the linker emits a plain script that publishes + // `@JSExportTopLevel` bindings on the global scope. We deliberately do + // NOT use ESModule here because browsers refuse to load ES modules from + // `file://` (the simulation page is meant to be usable both via the + // http4s server and by opening index.html directly from disk). + scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.NoModule) }, + ) + +// sbt-scalajs-crossproject derives the per-platform project IDs from the +// cross-project's name: `bifunctor-taglessJVM` and `bifunctor-taglessJS`. +// These aliases give us Project handles to reference in tasks below. +lazy val `bifunctor-taglessJVM` = `bifunctor-tagless`.jvm +lazy val `bifunctor-taglessJS` = `bifunctor-tagless`.js + +// Task: link the Scala.js bundle in `bifunctor-tagless-js` and copy the +// resulting main.js into the JVM project's resources/webapp/ so the http4s +// server (and a browser opening index.html directly) can find it. +lazy val copySimJs = taskKey[Seq[File]]("Link the simulation JS and copy it into the JVM resources/webapp/ directory.") + +copySimJs := { + val _ = (`bifunctor-taglessJS` / Compile / fullLinkJS).value + val srcDir = (`bifunctor-taglessJS` / Compile / fullLinkJS / scalaJSLinkerOutputDirectory).value + val outDir = (`bifunctor-taglessJVM` / baseDirectory).value / "src" / "main" / "resources" / "webapp" + IO.createDirectory(outDir) + val srcs = (srcDir ** "*.js").get ++ (srcDir ** "*.js.map").get + srcs.map { f => + val dst = outDir / f.getName + IO.copyFile(f, dst, preserveLastModified = true) + dst + } +} lazy val `monofunctor-tagless` = project .in(file("monofunctor-tagless")) @@ -98,51 +169,66 @@ lazy val `graal-resources` = project lazy val `distage-example` = project .in(file(".")) .aggregate( - `bifunctor-tagless`, + `bifunctor-taglessJVM`, + `bifunctor-taglessJS`, `monofunctor-tagless`, `monomorphic-cats`, `graal-resources`, ) .enablePlugins(GraalVMNativeImagePlugin, UniversalPlugin) -def sharedSettings(additionalDeps: Seq[ModuleID])(project: Project): Project = { +// Scalac/source settings shared by every Scala project in this build, both +// JVM-only ones and the cross-built bifunctor-tagless halves. +def sharedScalaSettings: Seq[Setting[_]] = Seq( + libraryDependencies ++= { + if (scalaVersion.value.startsWith("2")) { + Seq(compilerPlugin(Deps.kindProjector)) + } else { + Seq.empty + } + }, + scalacOptions --= Seq("-Xfatal-warnings", "-Ykind-projector", "-Wnonunit-statement"), + scalacOptions ++= { + if (scalaVersion.value.startsWith("2")) { + Seq( + "-Xsource:3", + "-P:kind-projector:underscore-placeholders", + "-Wmacros:after", + ) + } else { + Seq( + "-Ykind-projector:underscores", + "-Yretain-trees", + ) + } + }, + scalacOptions ++= Seq( + s"-Xmacro-settings:product-name=${name.value}", + s"-Xmacro-settings:product-version=${version.value}", + s"-Xmacro-settings:product-group=${organization.value}", + s"-Xmacro-settings:scala-version=${scalaVersion.value}", + s"-Xmacro-settings:scala-versions=${crossScalaVersions.value.mkString(":")}", + s"-Xmacro-settings:sbt-version=${sbtVersion.value}", + s"-Xmacro-settings:git-repo-clean=${git.gitUncommittedChanges.value}", + s"-Xmacro-settings:git-branch=${git.gitCurrentBranch.value}", + s"-Xmacro-settings:git-described-version=${git.gitDescribedVersion.value.getOrElse("")}", + s"-Xmacro-settings:git-head-commit=${git.gitHeadCommit.value.getOrElse("")}", + ), +) + +// JVM-only project settings (the http4s ember server, doobie, GraalVM native +// image, etc). Applied to the JVM half of `bifunctor-tagless` and — via +// `sharedSettings` below — to the plain JVM-only projects. +// +// Intentionally does NOT include `sharedScalaSettings`: the cross-built +// bifunctor-tagless applies those once on the cross project so they reach +// both halves, and applying them a second time here would set scalacOptions +// like `-Yretain-trees` twice and trip sbt-tpolecat's "set repeatedly" +// guard. +def jvmSharedSettings(additionalDeps: Seq[ModuleID])(project: Project): Project = { project .settings( libraryDependencies ++= Deps.CoreDeps ++ additionalDeps, - libraryDependencies ++= { - if (scalaVersion.value.startsWith("2")) { - Seq(compilerPlugin(Deps.kindProjector)) - } else { - Seq.empty - } - }, - scalacOptions --= Seq("-Xfatal-warnings", "-Ykind-projector", "-Wnonunit-statement"), - scalacOptions ++= { - if (scalaVersion.value.startsWith("2")) { - Seq( - "-Xsource:3", - "-P:kind-projector:underscore-placeholders", - "-Wmacros:after", - ) - } else { - Seq( - "-Ykind-projector:underscores", - "-Yretain-trees", - ) - } - }, - scalacOptions ++= Seq( - s"-Xmacro-settings:product-name=${name.value}", - s"-Xmacro-settings:product-version=${version.value}", - s"-Xmacro-settings:product-group=${organization.value}", - s"-Xmacro-settings:scala-version=${scalaVersion.value}", - s"-Xmacro-settings:scala-versions=${crossScalaVersions.value.mkString(":")}", - s"-Xmacro-settings:sbt-version=${sbtVersion.value}", - s"-Xmacro-settings:git-repo-clean=${git.gitUncommittedChanges.value}", - s"-Xmacro-settings:git-branch=${git.gitCurrentBranch.value}", - s"-Xmacro-settings:git-described-version=${git.gitDescribedVersion.value.getOrElse("")}", - s"-Xmacro-settings:git-head-commit=${git.gitHeadCommit.value.getOrElse("")}", - ), GraalVMNativeImage / mainClass := Some("leaderboard.GenericLauncher"), graalVMNativeImageOptions ++= Seq( "--no-fallback", @@ -159,5 +245,12 @@ def sharedSettings(additionalDeps: Seq[ModuleID])(project: Project): Project = { .enablePlugins(GraalVMNativeImagePlugin, UniversalPlugin) } +// Plain (non-cross) Scala project — used by `monomorphic-cats` and +// `monofunctor-tagless`, which stay JVM-only. These need both +// `sharedScalaSettings` (scalac flags + macro settings) and the JVM-only +// extras above. +def sharedSettings(additionalDeps: Seq[ModuleID])(project: Project): Project = + jvmSharedSettings(additionalDeps)(project).settings(sharedScalaSettings) + // for quick experiments with distage snapshots -ThisBuild / resolvers ++= Resolver.sonatypeOssRepos("snapshots") +ThisBuild / resolvers += Resolver.sonatypeCentralSnapshots diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..cd56566 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1778443072, + "narHash": "sha256-zi7/fsqM/kFdNuED//4WOCUtezGtKKqRNORjMvfwjnA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "da5ad661ba4e5ef59ba743f0d112cbc30e474f32", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5197893 --- /dev/null +++ b/flake.nix @@ -0,0 +1,52 @@ +{ + description = "distage-example — cross-built distage app with a Scala.js in-browser simulation"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + # GraalVM CE — used as a regular JDK for `sbt compile/test/run` and + # additionally provides the `native-image` binary so the local + # native-image workflow described in the README works without Docker. + # (The Docker-based `graalVMNativeImageGraalVersion` path in build.sbt + # still pulls its own image and doesn't depend on this JDK.) + jdk = pkgs.graalvmPackages.graalvm-ce; + in { + devShells.default = pkgs.mkShell { + name = "distage-example"; + + # Build/dev tooling. `nodejs` is here so the linked Scala.js bundle + # can be exercised outside the browser if needed (none of the demo + # paths require it — the http4s server serves the UI directly). + packages = with pkgs; [ + jdk + sbt + scala_3 + nodejs_22 + git + ]; + + # sbt sometimes needs more heap when assembling the cross-built + # project + linking the Scala.js bundle. + SBT_OPTS = "-Xmx4G -Xss4m"; + + JAVA_HOME = "${jdk}/lib/openjdk"; + + shellHook = '' + echo "distage-example dev shell" + echo " java : $(java -version 2>&1 | head -n1)" + echo " sbt : $(sbt --script-version 2>/dev/null || sbt --version 2>&1 | tail -n1)" + echo " scala 3 : $(scala -version 2>&1 | head -n1)" + echo + echo "Quick start:" + echo " ./launch-sim # build JS, run dummy server, UI at http://localhost:8080/" + echo " ./launcher -u scene:managed :leaderboard # real backend with dockerized postgres" + ''; + }; + }); +} diff --git a/launch-sim b/launch-sim new file mode 100755 index 0000000..868bfc0 --- /dev/null +++ b/launch-sim @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# +# Build the Scala.js simulation bundle, drop it into the JVM project's +# resources/webapp/, and start the leaderboard server in dummy mode (so it +# needs neither Docker nor postgres). The UI is then reachable at +# http://localhost:8080/ — the prod/sim toggle in that UI lets you compare +# the real http4s backend against the in-browser simulation. + +set -euo pipefail +cd "$(dirname "$0")" + +sbt "copySimJs; project bifunctor-taglessJVM; runMain leaderboard.GenericLauncher -u repo:dummy :leaderboard" diff --git a/launcher b/launcher index b2965dd..fd3f895 100755 --- a/launcher +++ b/launcher @@ -2,4 +2,4 @@ ARGS="$@" -sbt "project bifunctor-tagless; runMain leaderboard.GenericLauncher $ARGS" +sbt "project bifunctor-taglessJVM; runMain leaderboard.GenericLauncher $ARGS" diff --git a/project/plugins.sbt b/project/plugins.sbt index f3d76ac..7a1151b 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,3 +1,5 @@ addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.3") addSbtPlugin("com.github.sbt" % "sbt-git" % "2.1.0") addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.13") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.21.0") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2")