state-surface
v0.1.0
Published
A state-layout mapping runtime for MPA pages with NDJSON streaming updates
Downloads
104
Maintainers
Readme
StateSurface is a state-layout mapping runtime for the web. Pages load as real MPA HTML. In-page updates stream through <h-state> anchors via NDJSON — no SPA router, no client-side state management, no virtual DOM diffing on your side.
User action → POST /transition/:name → Server yields state frames
→ NDJSON stream → Client frame queue → DOM projectionWhy StateSurface?
| Traditional SPA | StateSurface | |---|---| | Client fetches data, manages state, renders UI | Server streams state, client projects DOM | | Full-page JS bundle, hydration cost | Per-anchor hydration, minimal client JS | | Complex state sync (Redux, Zustand, ...) | No client state — server is the source of truth | | Router + layout + data loading | File-based routes, declarative layout slots | | Loading spinners everywhere | Progressive streaming with partial/accumulate frames |
Quick Start
npx create-state-surface my-app
cd my-app
pnpm install
pnpm devOpen http://localhost:3000 — full SSR, streaming transitions, and live action binding out of the box.
The Four Concepts
StateSurface has exactly four concepts. That's the whole model.
1. Surface — The page shell
A surface is static HTML with <h-state> anchor slots. It never changes during a page visit — only the content inside each slot updates.
// routes/dashboard.ts
import type { RouteModule } from 'state-surface';
import { baseSurface, joinSurface, stateSlots } from '../layouts/surface.js';
export default {
layout: stateScript =>
baseSurface(
joinSurface(
'<main class="max-w-6xl mx-auto p-6">',
' <h1 class="text-2xl font-bold mb-6">Stock Dashboard</h1>',
' <div class="grid grid-cols-3 gap-4">',
stateSlots('stock:price', 'stock:news', 'stock:chart'),
' </div>',
stateSlots('stock:analysis'),
'</main>'
),
stateScript
),
} satisfies RouteModule;2. Template — Pure projection
A template receives server data and returns JSX. No useState, no useEffect, no fetch calls.
// routes/dashboard/templates/stockPrice.tsx
import { defineTemplate } from 'state-surface';
const StockPrice = ({ symbol, price, change }: Props) => (
<div class="rounded-lg border p-4">
<h3 class="font-bold">{symbol}</h3>
<p class="text-2xl">${price}</p>
<span class={change >= 0 ? 'text-green-600' : 'text-red-600'}>
{change >= 0 ? '+' : ''}{change}%
</span>
</div>
);
export default defineTemplate('stock:price', StockPrice);3. Transition — Server-side state generator
An async generator that yields state frames. Each yield sends one NDJSON line to the client, updating the UI progressively.
// routes/dashboard/transitions/loadDashboard.ts
import { defineTransition } from 'state-surface/server';
export default defineTransition('dashboard-load', async function* (_params, _req) {
// First frame: full state — renders loading skeleton
yield {
type: 'state',
states: {
'stock:price': { symbol: 'AAPL', price: 0, change: 0, loading: true },
'stock:news': { items: [], loading: true },
'stock:chart': { data: [], loading: true },
},
};
// Partial frame: update only price slot
const price = await fetchPrice('AAPL');
yield {
type: 'state',
full: false,
changed: ['stock:price'],
states: { 'stock:price': price },
};
// More partial frames as data arrives...
});4. Action — Declarative trigger
Trigger transitions from HTML attributes — zero JS event handlers.
<button data-action="search" data-params='{"query":"test"}'>Search</button>
<form data-action="update-shipping">
<select name="method">
<option value="express">Express</option>
</select>
<button type="submit">Update</button>
</form>| Attribute | Purpose |
|---|---|
| data-action | Transition name to invoke |
| data-params | JSON params (optional) |
| data-pending-targets | Anchor names to mark pending during transition (optional) |
Frame Types
StateSurface streams three types of state frames:
Full frame — Replaces all active state. First frame in every stream must be full.
{"type":"state","states":{"slot:a":{"title":"Hello"},"slot:b":{"items":[1,2,3]}}}Partial frame — Updates or removes specific slots without touching others.
{"type":"state","full":false,"changed":["slot:a"],"states":{"slot:a":{"title":"Updated"}}}Accumulate frame — Appends data into existing slots (arrays concat, strings concat, objects merge). Perfect for streaming chat, logs, or progressive content.
{"type":"state","accumulate":true,"states":{"chat:current":{"text":" world"}}}Features
- Full SSR — Every page renders complete HTML on the server
- NDJSON streaming — Progressive UI updates via full, partial, and accumulate frames
- Abort previous — Concurrent transitions auto-cancel earlier ones
- Per-anchor hydration — SHA256 hash check, no full-page rehydration
- File-based routing — Routes, templates, and transitions auto-discovered from
routes/ - View Transition API — MPA cross-fade + in-page element morphing
- Animation presets — 8 CSS animations via
data-animate(fade,slide-up,scale,blur, ...) - i18n ready — Bilingual content driven by cookie + transition
- Sub-path mounting —
BASE_PATH=/demo pnpm dev
Project Structure
routes/ # Your route modules (auto-loaded)
index.ts # GET / — page layout + config
guide/[slug].ts # Dynamic params: GET /guide/:slug
<route>/templates/ # TSX projection components
<route>/transitions/ # Server-side state generators
_shared/ # Cross-route templates & transitions
layouts/ # Page composition helpers
shared/ # Data helpers, i18n utilities
client/ # Assets (styles.css, plugins/)
engine/ # Framework core (do not edit)
server/ # Express routes, SSR, transition handler
client/ # Browser bootstrap, hydration, frame queue
shared/ # Protocol types, template registryInstall & Update
New project — use the CLI scaffolding:
npx create-state-surface my-appUpdate existing project — upgrade the runtime package:
pnpm up state-surface
pnpm test && pnpm buildDo not re-run
create-state-surfaceto update an existing app. For breaking releases, seeMIGRATION.md.
Commands
pnpm dev # Dev server (tsx watch + Vite HMR)
pnpm build # Production build
pnpm start # Run production server
pnpm test # Run all tests (Vitest)
pnpm test path/to/file.test.ts # Run a single test file
pnpm format # Format with Prettier
BASE_PATH=/demo pnpm dev # Serve under /demo/ prefixEnvironment Variables
| Variable | Default | Description |
|---|---|---|
| PORT | 3000 | Server listen port |
| BASE_PATH | (empty) | Mount app under a sub-path (e.g. /demo) |
| NODE_ENV | development | development / test / production |
API Reference
// Client + shared
import { defineTemplate, prefixPath, getBasePath } from 'state-surface';
// Server
import { createApp, defineTransition } from 'state-surface/server';
// Client runtime
import { createStateSurface } from 'state-surface/client';// Types
import type { RouteModule, StateFrame, BootConfig, TemplateModule } from 'state-surface';
import type { TransitionHandler, TransitionHooks } from 'state-surface/server';
import type { StateSurfacePlugin } from 'state-surface/client';Tech Stack
- Server: Express 5 + Vite middleware
- Rendering: Lithent (~4 KB VDOM diff engine)
- Styling: Tailwind CSS via
@tailwindcss/vite - Transport: NDJSON over HTTP POST
- Testing: Vitest + Supertest + happy-dom
