Skip to content

Commit a62e504

Browse files
authored
In-browser back-end simulation (#528)
1 parent 009343a commit a62e504

43 files changed

Lines changed: 1012 additions & 238 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.envrc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
use flake

.github/workflows/build.yml

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,29 @@ jobs:
1414
runs-on: ubuntu-latest
1515
steps:
1616
- uses: sbt/setup-sbt@v1 # setup sbt
17-
- uses: actions/checkout@v2
17+
- uses: actions/checkout@v5
1818
- name: Run tests
1919
run: sbt +test
20+
21+
sim-smoke:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: sbt/setup-sbt@v1
25+
- uses: actions/checkout@v5
26+
- uses: actions/setup-node@v5
27+
with:
28+
node-version: '24'
29+
- name: Link Scala.js bundle into JVM resources
30+
run: sbt copySimJs
31+
- name: Smoke-test the simulation bundle via node
32+
run: node bifunctor-tagless/js/sim-smoke.js
33+
2034
build-native:
2135
runs-on: ubuntu-latest
2236
steps:
2337
- uses: sbt/setup-sbt@v1 # setup sbt
24-
- uses: actions/checkout@v2
38+
- uses: actions/checkout@v5
2539
- name: Build native app
26-
run: sbt "project bifunctor-tagless; GraalVMNativeImage/packageBin"
40+
run: sbt "project bifunctor-taglessJVM; GraalVMNativeImage/packageBin"
2741
- name: Check native app
28-
run: ./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless :help
42+
run: ./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless :help

.gitignore

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,16 @@ hs_err_pid*
126126
### Scala template
127127
*.class
128128
*.log
129+
130+
# Project-local sbt/coursier/ivy caches used by the containerised dev loop.
131+
.cache/
132+
133+
# direnv-materialised devShell
134+
.direnv/
135+
136+
# Scala.js bundle copied here by the `copySimJs` sbt task — regenerated on
137+
# demand from bifunctor-tagless-js. Not checked in.
138+
bifunctor-tagless/jvm/src/main/resources/webapp/main.js
139+
bifunctor-tagless/jvm/src/main/resources/webapp/*.js.map
140+
bifunctor-tagless/jvm/src/main/resources/webapp/internal-*.js
141+

README.md

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,54 @@ curl -X GET http://localhost:8080/ladder
4141
curl -X GET http://localhost:8080/profile/50753a00-5e2e-4a2f-94b0-e6721b0a3cc4
4242
```
4343

44+
### Reproducible toolchain via Nix (optional)
45+
46+
A `flake.nix` is included. With Nix flakes enabled you can drop into a shell
47+
that has the exact JDK, sbt, Scala 3 and Node versions used by this project:
48+
49+
```bash
50+
nix develop # one-off shell
51+
direnv allow # automatic — uses the bundled .envrc
52+
```
53+
54+
### Perfect simulation in the browser (`bifunctor-tagless` only)
55+
56+
The `bifunctor-tagless` variant is cross-built for the JVM and Scala.js. The
57+
same `LadderApi`/`ProfileApi` http4s routes that the JVM server exposes are
58+
also assembled in the browser into an in-process `LocalDispatcher`, which a
59+
`@JSExportTopLevel("LeaderboardSim")` object surfaces to JavaScript. The JS
60+
graph is configured with `Repo -> Dummy`, so it uses the same in-memory
61+
repositories that the JVM tests use — no network, no postgres, no docker.
62+
63+
A small demo UI in `bifunctor-tagless/jvm/src/main/resources/webapp/` lets you
64+
call each endpoint with a radio toggle between **production** (real HTTP) and
65+
**simulation** (the in-page Scala.js build).
66+
67+
To use it, run the single convenience script — it builds the Scala.js bundle,
68+
copies it next to the UI, and starts the server in dummy mode:
69+
70+
```bash
71+
./launch-sim
72+
```
73+
74+
Then open <http://localhost:8080/>. You can also open
75+
`bifunctor-tagless/jvm/src/main/resources/webapp/index.html` directly via
76+
`file://` (CORS on the server allows the `null` origin used by `file://`).
77+
78+
If you prefer the steps separately:
79+
80+
```bash
81+
sbt copySimJs # build + copy the Scala.js bundle
82+
./launcher -u repo:dummy :leaderboard # start the server
83+
```
84+
85+
The "Production" radio talks to `http://localhost:8080`; the "Simulation"
86+
radio calls `window.LeaderboardSim.call(method, path, body)`, which runs the
87+
exact same request through the in-browser http4s routes. State only persists
88+
within each mode — flipping back and forth is itself a useful demonstration
89+
that the simulation is a clean process that knows nothing about the real
90+
server's state.
91+
4492
#### Note
4593

4694
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,21 +109,21 @@ Both of them should have `Active: active (running)` status. If your problem isn'
61109
Use `sbt` to build a native Linux binary with GraalVM NativeImage under Docker:
62110

63111
```bash
64-
sbt bifunctor-tagless/GraalVMNativeImage/packageBin
112+
sbt bifunctor-taglessJVM/GraalVMNativeImage/packageBin
65113
```
66114

67115
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.
68116

69117
To test the native app with dummy repositories run:
70118

71119
```bash
72-
./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:dummy :leaderboard
120+
./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:dummy :leaderboard
73121
```
74122

75123
To test the native app with production repositories in Docker run:
76124

77125
```bash
78-
./bifunctor-tagless/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:prod :leaderboard
126+
./bifunctor-tagless/jvm/target/graalvm-native-image/bifunctor-tagless -u scene:managed -u repo:prod :leaderboard
79127
```
80128

81129
Notes:

bifunctor-tagless/js/sim-smoke.js

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Smoke-test the in-browser simulation bundle outside the browser.
4+
*
5+
* Loads the linked Scala.js bundle from the JVM project's
6+
* `resources/webapp/main.js` (produced by `sbt copySimJs`) and exercises the
7+
* same four endpoints the README's `curl` snippets call: submit a score, get
8+
* the ladder, set a profile, get the profile. Each call should return HTTP
9+
* 200; mismatches fail the run with a non-zero exit code.
10+
*
11+
* Run from the repo root:
12+
*
13+
* sbt copySimJs
14+
* node bifunctor-tagless/js/sim-smoke.js
15+
*
16+
* scala.js NoModule mode emits `let LeaderboardSim` at script top level. This
17+
* is intentionally script-scoped (so a browser <script src=...> exposes it as
18+
* an identifier in the script's lexical scope, not on `globalThis`); to thread
19+
* it out for assertions in Node we eval the bundle in a `vm` context and
20+
* re-export the binding via the trailing line.
21+
*/
22+
const fs = require('fs');
23+
const path = require('path');
24+
const vm = require('vm');
25+
26+
const BUNDLE = path.resolve(
27+
__dirname,
28+
'..',
29+
'jvm',
30+
'src',
31+
'main',
32+
'resources',
33+
'webapp',
34+
'main.js',
35+
);
36+
37+
if (!fs.existsSync(BUNDLE)) {
38+
console.error(`Bundle not found at ${BUNDLE}. Run \`sbt copySimJs\` first.`);
39+
process.exit(1);
40+
}
41+
42+
const bundle = fs.readFileSync(BUNDLE, 'utf8');
43+
// Deliberately omit `process` from the context so this smoke test fails the
44+
// same way a browser would if the bundle accidentally probes Node.js APIs at
45+
// init. The Scala.js bundle must run without a `process` global.
46+
const ctx = {
47+
setTimeout, setInterval, clearTimeout, clearInterval, console,
48+
queueMicrotask, Promise,
49+
};
50+
vm.createContext(ctx);
51+
vm.runInContext(bundle + '\nthis.LeaderboardSim = LeaderboardSim;', ctx);
52+
53+
const Sim = ctx.LeaderboardSim;
54+
if (!Sim) {
55+
console.error('FAIL: LeaderboardSim binding not produced by main.js');
56+
process.exit(1);
57+
}
58+
59+
const USER = '50753a00-5e2e-4a2f-94b0-e6721b0a3cc4';
60+
61+
function expect(label, response, expectedStatus, bodyPredicate) {
62+
if (response.status !== expectedStatus) {
63+
console.error(`FAIL: ${label} returned HTTP ${response.status}, expected ${expectedStatus}. body=${response.body}`);
64+
process.exit(1);
65+
}
66+
if (bodyPredicate && !bodyPredicate(response.body)) {
67+
console.error(`FAIL: ${label} body did not match expectation: ${response.body}`);
68+
process.exit(1);
69+
}
70+
console.log(`ok ${label} -> ${response.status} ${response.body || ''}`);
71+
}
72+
73+
async function main() {
74+
// Submit a score (empty body, score from URL).
75+
expect('POST /ladder/{user}/100', await Sim.call('POST', `/ladder/${USER}/100`, ''), 200);
76+
77+
// Fetch the ladder — must contain our user with score 100.
78+
const ladder = await Sim.call('GET', '/ladder', '');
79+
expect(
80+
'GET /ladder',
81+
ladder,
82+
200,
83+
body => {
84+
const rows = JSON.parse(body);
85+
return Array.isArray(rows) && rows.some(([u, s]) => u === USER && s === 100);
86+
},
87+
);
88+
89+
// Set a profile.
90+
expect(
91+
'POST /profile/{user}',
92+
await Sim.call('POST', `/profile/${USER}`, JSON.stringify({ name: 'Kai', description: 'S C A L A' })),
93+
200,
94+
);
95+
96+
// Fetch the profile — must include rank + score from the ladder.
97+
const profile = await Sim.call('GET', `/profile/${USER}`, '');
98+
expect(
99+
'GET /profile/{user}',
100+
profile,
101+
200,
102+
body => {
103+
const p = JSON.parse(body);
104+
return p.name === 'Kai' && p.description === 'S C A L A' && p.rank === 1 && p.score === 100;
105+
},
106+
);
107+
108+
console.log('\nsim-smoke: all assertions passed');
109+
}
110+
111+
main().catch(e => {
112+
console.error('FAIL:', e);
113+
process.exit(1);
114+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package leaderboard.sim
2+
3+
import distage.StandardAxis.Repo
4+
import distage.{Activation, Injector, ModuleDef, Roots}
5+
import izumi.distage.modules.DefaultModule2
6+
import izumi.logstage.api.IzLogger
7+
import izumi.logstage.distage.LogIO2Module
8+
import izumi.logstage.sink.ConsoleSink
9+
import leaderboard.dispatch.LocalDispatcher
10+
import leaderboard.plugins.LeaderboardCoreModule
11+
import zio.{IO, Promise as ZPromise, Runtime, Unsafe, ZIO}
12+
13+
import scala.concurrent.ExecutionContext
14+
import scala.scalajs.concurrent.JSExecutionContext
15+
import scala.scalajs.js
16+
import scala.scalajs.js.JSConverters.*
17+
import scala.scalajs.js.annotation.{JSExport, JSExportTopLevel}
18+
19+
/**
20+
* Entrypoint for the in-browser "perfect simulation" of the leaderboard
21+
* backend. Boots the same distage object graph as the JVM application but
22+
* with `Repo` axis pinned to `Dummy` (so all repository calls hit the
23+
* in-memory implementations), then exposes the resulting [[LocalDispatcher]]
24+
* to JavaScript as `window.LeaderboardSim.call(method, path, body)`.
25+
*
26+
* The dispatcher invokes the exact same `HttpRoutes` the JVM server uses,
27+
* so the front-end can address it with the same method/path/body it would
28+
* send via fetch() to the real backend.
29+
*/
30+
@JSExportTopLevel("LeaderboardSim")
31+
object SimulationMain {
32+
33+
private type G[A] = IO[Throwable, A]
34+
35+
// A zio runtime — fine on JS since the default runtime does not assume any
36+
// JVM-specific scheduler.
37+
private val runtime: Runtime[Any] = Runtime.default
38+
39+
// JS event loop — used only to materialize the cats Future returned by
40+
// `runtime.unsafe.runToFuture` into a JavaScript Promise.
41+
private implicit val ec: ExecutionContext = JSExecutionContext.queue
42+
43+
// We resolve the dispatcher asynchronously (distage produce returns a
44+
// resource) and surface it through this promise. Every JS `call(...)`
45+
// awaits this before dispatching, so the front-end never sees an
46+
// uninitialised state.
47+
private val dispatcherReady: ZPromise[Throwable, LocalDispatcher[IO]] =
48+
Unsafe.unsafe(implicit u => runtime.unsafe.run(ZPromise.make[Throwable, LocalDispatcher[IO]]).getOrThrow())
49+
50+
locally {
51+
val module = new ModuleDef {
52+
include(LeaderboardCoreModule.api[IO])
53+
include(LeaderboardCoreModule.repoDummy[IO])
54+
// LogIO2[IO] (needed by ProfileApi) + a console logger. We use
55+
// `SimpleConsoleSink` instead of the default `ColoredConsoleSink`
56+
// because the latter probes `process.env` for terminal-color detection
57+
// on init, which doesn't exist in the browser.
58+
include(LogIO2Module[IO]())
59+
make[IzLogger].fromValue(IzLogger(sink = ConsoleSink.SimpleConsoleSink))
60+
// BIO + cats-effect typeclass instances for ZIO. When zio-interop-cats
61+
// is on the classpath, this resolves to `DefaultModule.forZIOPlusCats`
62+
// which binds `cats.effect.Async[Task]` etc. — the dispatcher needs it.
63+
include(DefaultModule2[IO])
64+
}
65+
66+
// Build the object graph and keep the resource open for the lifetime of
67+
// the page. We never finalize — the in-memory dummy state should live as
68+
// long as the JS module is loaded.
69+
val program: G[Nothing] =
70+
Injector.NoProxies[G]()
71+
// `Roots.Everything` instead of `Roots.target[LocalDispatcher]` because
72+
// `LeaderboardCoreModule.api` adds `LadderApi`/`ProfileApi` to the
73+
// `Set[HttpApi[F]]` as *weak* references — they only join the set if
74+
// they're independently reachable through the plan. The JVM app pulls
75+
// them in via roles; here we have no roles, so we ask the planner to
76+
// include every binding that's not pinned out by the activation.
77+
.produce(module, Roots.Everything, Activation(Repo -> Repo.Dummy))
78+
.use {
79+
locator =>
80+
dispatcherReady.succeed(locator.get[LocalDispatcher[IO]]) *> ZIO.never
81+
}
82+
.catchAll(err => dispatcherReady.fail(err) *> ZIO.never)
83+
84+
Unsafe.unsafe(implicit u => runtime.unsafe.fork(program))
85+
}
86+
87+
/**
88+
* Dispatch a request to the simulated backend.
89+
*
90+
* @return a JS promise resolving to `{ status: Int, body: String }`.
91+
*/
92+
@JSExport
93+
def call(method: String, path: String, body: String): js.Promise[js.Dynamic] = {
94+
val program: G[js.Dynamic] = for {
95+
dispatcher <- dispatcherReady.await
96+
response <- dispatcher.call(method, path, body)
97+
} yield js.Dynamic.literal(status = response.status, body = response.body)
98+
99+
Unsafe.unsafe(implicit u => runtime.unsafe.runToFuture(program).toJSPromise)
100+
}
101+
}

bifunctor-tagless/src/main/resources/common-reference.conf renamed to bifunctor-tagless/jvm/src/main/resources/common-reference.conf

File renamed without changes.

0 commit comments

Comments
 (0)