Architecture
JSlop is a pnpm workspace of small, single-purpose packages. Each one does one job and depends only on the runtime and (sometimes) the compiler.
┌──────────────────────────────────────────────────────────────────────────┐
│ @jslop/vite (plugin) │
│ │
│ .jslop file ──► @jslop/compiler ──► JS module │
│ │
│ @jslop/router (scan src/routes) ──► routes manifest │
│ │
│ dev: request /url ──► match ──► @jslop/server.render → response │
│ │
│ build: vite build → dist/client/ (hashed JS + CSS + manifest) │
│ vite build --ssr → dist/server/entry-server.js exports │
│ render(url) using @jslop/server, /router │
│ │
│ prod: @jslop/node-adapter ──► static dist/client/ + render(url) │
│ │
│ virtual:jslop-client ──► @jslop/client.boot() in browser │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
@jslop/runtime
(cell / derived / effect / batch)
Packages
@jslop/runtime
The reactivity engine. Tiny — about 150 lines.
Exports: cell, derived, effect, batch, untrack, isReactive, types Cell<T>, Derived<T>, Reactive<T>.
Push-based subscription model: cells track which subscribers read them; on set, subscribers re-run unless we’re inside a batch. See ../reactivity.md.
@jslop/compiler
Parses .jslop files and emits ES modules.
Three stages:
- Parser (
parser.ts) — hand-rolled cursor-based DSL parser. Produces aParsedFileAST with file-level imports (default and/or named specifiers) and one or moreParsedComponententries; each component carries props, reactivestatedeclarations, non-reactiveletbindings, functions, and aViewNodetree. - Rewriter (
rewrite.ts) — AST-aware JS rewriter built onacorn+magic-string. The reactive-name set isprops ∪ states—letbindings are never in it, so identifier references to them pass through untouched. For names that are reactive, reads become.get(), writes become.set(...), and compound assignments expand via.peek(). Shadow-aware: function parameters, function-localconst/let, andeachitem/index bindings shadow same-named outer reactives. - Codegen (
codegen.ts) — walks the AST and emits an ES module with oneexport const Name = { name, create(props) }per component, andexport default <FirstComponent>so default-import call sites still work. Thecreate()function builds cells, declares functions, wires up children, and returns{ actions, buildView, serializeState, restoreState, children }.
Public API: compile(source, opts?), parseFile(source), parseComponent(source) (single-component shorthand), generate(parsed, opts?).
@jslop/router
File-system route scanning and URL matching.
scanRoutes(dir)— async walk, returnsRouteDef[]sorted most-specific first.matchRoute(url, routes)— returns{ route, params } | null.
Pure functions, no runtime dependency. Used by @jslop/vite to build the routes manifest.
@jslop/server
SSR. Walks a component instance’s buildView() tree and emits HTML + a JSON state capsule.
renderView(node)—ViewNode→ HTML string.renderPage({ title, component, props, appScriptUrl, stylesheets })— full page including capsule,<script type="module" src="...">for the client bundle, optional<link rel="stylesheet">injections.
The state capsule lives in <script id="__jslop_state" type="application/json">.
@jslop/client
Browser boot.
boot(registry)— reads the state capsule from the DOM, instantiates the root component fromregistry, callsrestoreState, then walks the view tree and attaches event handlers + sets upeffect()s for every reactive binding.- Every mounted root opens a top-level
Scope.mountIfopens a fresh scope per branch swap,mountEachopens one scope per list item. Disposing a scope tears down every effect created inside it, so{#if}swaps and{#each}removes don’t leak. - Keyed
{#each list as item, i (key)}is reconciled by key: matching items keep their DOM and per-item scope across reorders / inserts; removed keys have their scope disposed and DOM removed. Unkeyed lists fall back to dispose-then-rebuild (still scoped, just less efficient). bind:value/bind:checkeddesugar to a property bind ({ kind: "prop", get }) that the runtime writes directly to the DOM IDL property — necessary becausesetAttribute('value', …)doesn’t update an<input>the user has already typed into.
The view tree it walks is the same shape the server walked, so DOM and view nodes line up by traversal order — there’s no separate “hydration matching” step.
@jslop/vite
The bundler integration that ties everything together.
- Transform plugin — every
.jslopfile goes through@jslop/compiler.compile(). - Virtual modules:
virtual:jslop-client— browser entry. Importsbootplus every route/layout component, callsboot({ name: Component, ... }).virtual:jslop-entry-server— production SSR entry. Statically imports every route/layout, exportsrender(url, opts?) → { status, html, headers }.virtual:jslop-routes— server-side routes manifest (currently unused by the runtime; kept as a stable surface for adapters).
- Build config — a
config()hook detectsenv.command === "build"and flips Rollup input/output between two modes:- Default (
vite build): client entry →dist/client/withmanifest: true(hashed JS + CSS inassets/,.vite/manifest.jsonindex). vite build --ssr: SSR entry →dist/server/entry-server.js, withssr.noExternal: [/^@jslop\//]so workspace packages bundle into a self-contained server entry.
- Default (
- Dev SSR middleware — registered ahead of Vite’s HTML fallback. On each request: load routes manifest, match URL,
ssrLoadModulethe matched.jslop, callrenderPage, runtransformIndexHtml, send. - Optional Tailwind v4 —
tailwind: trueauto-loads@tailwindcss/vite.
@jslop/node-adapter
A Node HTTP wrapper around a built SSR render(url).
createHandler({ render, clientDir })— returns a(req, res) → voidhandler. Paths with a file extension are served as static assets fromclientDir; everything else goes throughrender(url). Assets under/assets/getcache-control: public, max-age=31536000, immutable(safe because Vite hashes their filenames).createServer(opts)— same plushttp.createServer(handler).- The
RenderFnsignature is request-agnostic, so Bun / Workers adapters can drop in with the samerender(url, opts) → { status, html, headers }contract.
A request, end-to-end (dev)
For GET /posts/hello-world:
@jslop/viteSSR middleware intercepts the request.loadRoutes()returns the cachedRouteDef[](or scanssrc/routes/).matchRoute("/posts/hello-world", routes)findsposts/[slug].jslopand extracts{ slug: "hello-world" }.ssrLoadModule(absPath)runs the compiled module on the server. Vite’s transform pipeline calls@jslop/compiler.compile()on the source first.- The module’s default export is
__jslop_component. We callcomponent.create({ slug: "hello-world" }). renderPage({ component, props, ... }):- Calls
component.create(props)again (one instance for render). - Walks
instance.buildView()and writes HTML. - Calls
instance.serializeState()and inlines the JSON. - Emits
<script type="module" src="/@id/virtual:jslop-client">.
- Calls
transformIndexHtmlruns Vite’s normal pipeline (HMR client, etc.).- Response goes out as
text/html.
In the browser:
- Vite serves
virtual:jslop-client. The generated module importsbootfrom@jslop/clientplus every route component, then callsboot({ ComponentName: ComponentRef, ... }). bootreads<script id="__jslop_state">, finds the right component in the registry, callscreate(props)andrestoreState(capsule).bootwalksinstance.buildView()and the existing DOM in lockstep, attaching event handlers and wrapping eachbindnode in aneffect().- From this point on, user interactions trigger
cell.set→ subscribers re-run → DOM nodes update fine-grained.
A request, end-to-end (production)
After vite build && vite build --ssr:
@jslop/node-adapter’s handler receives the request.- If the URL has a file extension and resolves under
dist/client/, the file is served directly (long-cache for/assets/*). - Otherwise:
render(url)fromdist/server/entry-server.jsruns. - On first call,
renderreadsdist/client/.vite/manifest.jsononce and caches it. It finds the entry chunk (assets/client-<hash>.js) plus any CSS Vite emitted for that entry. matchRoute(url, routes)against the statically-imported route table; on miss, the bundled_404.jslop(if any) renders with the layout chain.renderPage({ component, layouts, props, appScriptUrl, stylesheets })produces HTML + capsule, exactly the same shape as dev.- Response goes out. No
transformIndexHtmlpass — the HTML is final.
What’s intentionally not here
- No virtual DOM. The view tree is just descriptors; updates write directly to DOM nodes via the
effectgraph. - No global store or context. State is component-local; reactivity flows through cells passed as props.
- No special server/client file split. Today everything is “isomorphic” because there are no server-only constructs yet — when
server functionlands, the compiler will split bodies at compile time. - No CSS-in-JS engine. Plain
class="..."works; Tailwind works because nothing rewrites classes.
See PLAN.md for the design intent and TODO.md for the gap list.