Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .envrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
use flake
22 changes: 18 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

54 changes: 51 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://localhost:8080/>. 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/):
Expand All @@ -61,21 +109,21 @@ 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.

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:
Expand Down
114 changes: 114 additions & 0 deletions bifunctor-tagless/js/sim-smoke.js
Original file line number Diff line number Diff line change
@@ -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 <script src=...> exposes it as
* an identifier in the script's lexical scope, not on `globalThis`); to thread
* it out for assertions in Node we eval the bundle in a `vm` context and
* re-export the binding via the trailing line.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');

const BUNDLE = path.resolve(
__dirname,
'..',
'jvm',
'src',
'main',
'resources',
'webapp',
'main.js',
);

if (!fs.existsSync(BUNDLE)) {
console.error(`Bundle not found at ${BUNDLE}. Run \`sbt copySimJs\` first.`);
process.exit(1);
}

const bundle = fs.readFileSync(BUNDLE, 'utf8');
// Deliberately omit `process` from the context so this smoke test fails the
// same way a browser would if the bundle accidentally probes Node.js APIs at
// init. The Scala.js bundle must run without a `process` global.
const ctx = {
setTimeout, setInterval, clearTimeout, clearInterval, console,
queueMicrotask, Promise,
};
vm.createContext(ctx);
vm.runInContext(bundle + '\nthis.LeaderboardSim = LeaderboardSim;', ctx);

const Sim = ctx.LeaderboardSim;
if (!Sim) {
console.error('FAIL: LeaderboardSim binding not produced by main.js');
process.exit(1);
}

const USER = '50753a00-5e2e-4a2f-94b0-e6721b0a3cc4';

function expect(label, response, expectedStatus, bodyPredicate) {
if (response.status !== expectedStatus) {
console.error(`FAIL: ${label} returned HTTP ${response.status}, expected ${expectedStatus}. body=${response.body}`);
process.exit(1);
}
if (bodyPredicate && !bodyPredicate(response.body)) {
console.error(`FAIL: ${label} body did not match expectation: ${response.body}`);
process.exit(1);
}
console.log(`ok ${label} -> ${response.status} ${response.body || ''}`);
}

async function main() {
// Submit a score (empty body, score from URL).
expect('POST /ladder/{user}/100', await Sim.call('POST', `/ladder/${USER}/100`, ''), 200);

// Fetch the ladder — must contain our user with score 100.
const ladder = await Sim.call('GET', '/ladder', '');
expect(
'GET /ladder',
ladder,
200,
body => {
const rows = JSON.parse(body);
return Array.isArray(rows) && rows.some(([u, s]) => u === USER && s === 100);
},
);

// Set a profile.
expect(
'POST /profile/{user}',
await Sim.call('POST', `/profile/${USER}`, JSON.stringify({ name: 'Kai', description: 'S C A L A' })),
200,
);

// Fetch the profile — must include rank + score from the ladder.
const profile = await Sim.call('GET', `/profile/${USER}`, '');
expect(
'GET /profile/{user}',
profile,
200,
body => {
const p = JSON.parse(body);
return p.name === 'Kai' && p.description === 'S C A L A' && p.rank === 1 && p.score === 100;
},
);

console.log('\nsim-smoke: all assertions passed');
}

main().catch(e => {
console.error('FAIL:', e);
process.exit(1);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package leaderboard.sim

import distage.StandardAxis.Repo
import distage.{Activation, Injector, ModuleDef, Roots}
import izumi.distage.modules.DefaultModule2
import izumi.logstage.api.IzLogger
import izumi.logstage.distage.LogIO2Module
import izumi.logstage.sink.ConsoleSink
import leaderboard.dispatch.LocalDispatcher
import leaderboard.plugins.LeaderboardCoreModule
import zio.{IO, Promise as ZPromise, Runtime, Unsafe, ZIO}

import scala.concurrent.ExecutionContext
import scala.scalajs.concurrent.JSExecutionContext
import scala.scalajs.js
import scala.scalajs.js.JSConverters.*
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}

/**
* Entrypoint for the in-browser "perfect simulation" of the leaderboard
* backend. Boots the same distage object graph as the JVM application but
* with `Repo` axis pinned to `Dummy` (so all repository calls hit the
* in-memory implementations), then exposes the resulting [[LocalDispatcher]]
* to JavaScript as `window.LeaderboardSim.call(method, path, body)`.
*
* The dispatcher invokes the exact same `HttpRoutes` the JVM server uses,
* so the front-end can address it with the same method/path/body it would
* send via fetch() to the real backend.
*/
@JSExportTopLevel("LeaderboardSim")
object SimulationMain {

private type G[A] = IO[Throwable, A]

// A zio runtime — fine on JS since the default runtime does not assume any
// JVM-specific scheduler.
private val runtime: Runtime[Any] = Runtime.default

// JS event loop — used only to materialize the cats Future returned by
// `runtime.unsafe.runToFuture` into a JavaScript Promise.
private implicit val ec: ExecutionContext = JSExecutionContext.queue

// We resolve the dispatcher asynchronously (distage produce returns a
// resource) and surface it through this promise. Every JS `call(...)`
// awaits this before dispatching, so the front-end never sees an
// uninitialised state.
private val dispatcherReady: ZPromise[Throwable, LocalDispatcher[IO]] =
Unsafe.unsafe(implicit u => runtime.unsafe.run(ZPromise.make[Throwable, LocalDispatcher[IO]]).getOrThrow())

locally {
val module = new ModuleDef {
include(LeaderboardCoreModule.api[IO])
include(LeaderboardCoreModule.repoDummy[IO])
// LogIO2[IO] (needed by ProfileApi) + a console logger. We use
// `SimpleConsoleSink` instead of the default `ColoredConsoleSink`
// because the latter probes `process.env` for terminal-color detection
// on init, which doesn't exist in the browser.
include(LogIO2Module[IO]())
make[IzLogger].fromValue(IzLogger(sink = ConsoleSink.SimpleConsoleSink))
// BIO + cats-effect typeclass instances for ZIO. When zio-interop-cats
// is on the classpath, this resolves to `DefaultModule.forZIOPlusCats`
// which binds `cats.effect.Async[Task]` etc. — the dispatcher needs it.
include(DefaultModule2[IO])
}

// Build the object graph and keep the resource open for the lifetime of
// the page. We never finalize — the in-memory dummy state should live as
// long as the JS module is loaded.
val program: G[Nothing] =
Injector.NoProxies[G]()
// `Roots.Everything` instead of `Roots.target[LocalDispatcher]` because
// `LeaderboardCoreModule.api` adds `LadderApi`/`ProfileApi` to the
// `Set[HttpApi[F]]` as *weak* references — they only join the set if
// they're independently reachable through the plan. The JVM app pulls
// them in via roles; here we have no roles, so we ask the planner to
// include every binding that's not pinned out by the activation.
.produce(module, Roots.Everything, Activation(Repo -> Repo.Dummy))
.use {
locator =>
dispatcherReady.succeed(locator.get[LocalDispatcher[IO]]) *> ZIO.never
}
.catchAll(err => dispatcherReady.fail(err) *> ZIO.never)

Unsafe.unsafe(implicit u => runtime.unsafe.fork(program))
}

/**
* Dispatch a request to the simulated backend.
*
* @return a JS promise resolving to `{ status: Int, body: String }`.
*/
@JSExport
def call(method: String, path: String, body: String): js.Promise[js.Dynamic] = {
val program: G[js.Dynamic] = for {
dispatcher <- dispatcherReady.await
response <- dispatcher.call(method, path, body)
} yield js.Dynamic.literal(status = response.status, body = response.body)

Unsafe.unsafe(implicit u => runtime.unsafe.runToFuture(program).toJSPromise)
}
}
Loading