|
| 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 | +} |
0 commit comments