Preview
ascent-preview is a published JVM library: path-jailed static files plus an SSE endpoint that
fires when assets/dev-stamp contents change. The tab reloads. The Preview process does not.
Preview.serve is that process; extra routes and/or a sidecar effect can share its Scope.
HTML and JS may churn on disk; rewrite the stamp to poke the browser. Never watch a task that
kills the server (~runReload, ~preview/run).
The command to remember is sbt <module>/ascentPreview. It starts Preview, prints the URL, and
watches sources. From a terminal it stays in the foreground until interrupt (Ctrl-C). Typed at an
sbt prompt, it returns so tests and compiles still run; stop with <module>/ascentPreviewStop. A
private file poller (not sbt ~) watches Compile / unmanagedSources / fileInputs plus
ascentPreviewIndex. Saving a .scala file or index.html queues ascentPreviewRebuild
(default JS: spliceFast) on the sbt command queue, rewrites the stamp, and the tab does
location.reload(). Preview stays up. One-shot (start and return, no watch):
sbt <module>/ascentPreviewOnce. Do not copy watchTriggers onto these keys: a non-empty set
replaces transitive fileInputs on sbt 2. Do not ~ascentPreview.
Install
Library (JVM):
libraryDependencies += "rocks.earlyeffect" %% "ascent-preview" % "0.11.1"
Plugin (the command):
addSbtPlugin("rocks.earlyeffect" % "sbt-ascent-preview" % "0.11.1")
Then enablePlugins(AscentPreviewPlugin) on the module you type. sbt-specular already requires
this plugin, so a docs module that enables SpecularPlugin gets Preview; set ascentPreviewRoot
to the site directory and ascentPreviewRebuild to specularSiteDev.
Scala.js app
lazy val todoConduit = (project in file("example/todo-conduit"))
.enablePlugins(ScalaJSPlugin, AscentPreviewPlugin)
.settings(
// JS projects have no PreviewMain on Compile. Point at a JVM ascent-preview classpath:
ascentPreviewClasspath := (LocalProject("preview") / Compile / fullClasspath).value,
)
sbt todoConduitJS/ascentPreview
# open http://localhost:8765
Default rebuild is ascentPreviewStage: this project's spliceFast, copy index.html, write
assets/dev-stamp. The poller sees Scala.js sources because ascentPreview / fileInputs copies
Compile / unmanagedSources / fileInputs, not because spliceFast reaches zinc compile. Override
ascentPreviewBundle for plain fastLinkJS. If spliceFast is not defined, set
ascentPreviewBundle or override ascentPreviewRebuild.
Specular docs
Docs are the same command. The rebuild task writes target/site (including the stamp) instead of
an example target/preview.
.enablePlugins(SpecularPlugin, AscentPreviewPlugin)
.settings(
ascentPreviewRoot := specularSiteDirectory.value,
ascentPreviewRebuild := specularSiteDev.value,
ascentPreviewAutoOpen := true, // open the tab once Preview binds
ascentPreviewPort := AscentPreviewPort("auto"), // first free port >= 8700
)
sbt docs/ascentPreview
# open the localhost URL sbt prints (port is auto)
specularServe stays a blocking one-shot of an already-built tree. The edit loop is only
ascentPreview.
Sidecar and extra routes
Preview.serve is the scoped wrapper. PreviewMain is that wrapper with empty sidecar and empty
extra routes. Supply neither, one, or both:
// extra HTTP only (datastar / hybrid)
Preview.serve(config, extraRoutes = apiRoutes(state))
.provideSome[Scope](Preview.server(config.port))
// background lifetime only
Preview.serve(config, sidecar = spawnAgent)
.provideSome[Scope](Preview.server(config.port))
// routes that need a resource: acquire first, same Scope
clients <- LiveClients.grok(log)
_ <- Preview.serve(config, extraRoutes = apiRoutes(clients))
.provideSome[Scope](Preview.server(config.port))
Callers provide Server (PreviewMain uses Preview.server(config.port); examples add
compression). Sidecar and HTTP run beside each other in the caller's Scope. Interrupt closes HTTP,
then sidecar finalizers (child process, Hub, runtime). Extra routes are composed in front of the
static trailing GET so /sse wins over a file lookup.
restartSidecarOnStamp = true reruns the sidecar when assets/dev-stamp changes. HTTP stays up;
extra routes are not reinstalled. Default off (process-lifetime sidecar).
A custom wrapper main is still a ZIOAppDefault that calls Preview.serve. Point
ascentPreviewMain at it if ascentPreview should fork that main instead of PreviewMain.
Datastar / hybrid still use two commands when the API lives in a JVM module:
sbt datastarExampleJS/ascentPreview # stage + stamp only
sbt datastarExampleServer/run # Preview.serve + API on :8080
The server process stays up. Only the files under target/preview change.
Tab client
From ascent-js (localhost only; inert on GitHub Pages):
DevReload.install() // EventSource /__ascent/reload → location.reload()
Specular's SpecularClient.mountAll already calls this. Do not also poll assets/dev-stamp.
Without Scala.js, a tiny snippet in index.html:
<script>
if (["localhost","127.0.0.1","[::1]"].includes(location.hostname)) {
const es = new EventSource("/__ascent/reload");
const reload = () => location.reload();
es.addEventListener("reload", reload);
es.addEventListener("message", reload);
es.addEventListener("error", () => es.close());
}
</script>
Config and jail
PreviewConfig(root, port = 8765, stamp = assets/dev-stamp, reloadPath = __ascent/reload, cors = false, openBrowser = false).
CORS is off by default so same-origin example servers stay strict.
Paths are jailed: .. is rejected, and the resolved file must be a canonical descendant of root.
CLI: PreviewMain <port> <siteRoot> [--open] (default 8765 and target/site). --open is
what ascentPreviewAutoOpen := true passes so the tab opens once the socket is bound, not on every
watch rebuild.
Module bind: ascentPreviewPort := AscentPreviewPort(8701) or
ascentPreviewPort := AscentPreviewPort("auto") (first free port >= 8700).
Neotype rejects "noo auto" and privileged ports like 80 at compile time.
Anti-pattern
sbt ~docs/Test/runReload # restarts the Preview JVM on every compile
sbt ~preview/run # same: the process is the watch target
Watch the rebuild (ascentPreview / ascentPreviewStage / specularSiteDev), not the server.
ascentPreview copies Compile source fileInputs onto itself (plus index.html) and then queues
ascentPreviewRebuild on each save. Overriding rebuild (for example
ascentPreviewRebuild := specularSiteDev.value) keeps the source watch. The poller is a background
job; it does not own the sbt prompt.