Install @velocity/ui and the same core that serves your API serves Pages — Preact-scale class
components, signal reactivity, and endpoints wired in without a single fetch(). Uninstall it and Velocity is byte-for-byte the
backend it always was.
Minimal, persistent (localStorage) reading preferences — theme, accent, font, and size.
Velocity’s identity is a fast, batteries-included backend. The view layer ships as a separate package the developer
chooses to install — the NestJS @nestjs/* model. Absent the package, there is nothing to gate: no frontend code on disk,
in node_modules, or in RSS.
HTTP, router, DI, ORM, sessions, guards, clustering, shared memory, velogen. ~5K lines, 3 deps. Unchanged.
Adds @Page, the component runtime, signals, the renderer, Callers/Actions/Chains, Context providers, and the bundler.
Controllers & services become typed clients/contexts inside components. One data model, authored once, consumed both sides.
velo.scan() classifies against fixed symbols today. @Page is a new kind. Three bridges — starting with your inert-symbols idea.
Core pre-declares the frontend symbols as constants and scan() recognizes but tolerates them. Add-on absent →
a @Page class is discovered, found to be a “page kind”, and skipped with a one-time hint. The decorators exist as no-op exports so
user code type-checks either way; they only do something once the add-on registers a handler for that symbol.
@Page type-checks even backend-only.registerKind() plugin seam cleanest long-termOne generic hook: the add-on registers a kind (symbol + onDiscover that compiles the class into a route). Core knows
“kinds”, not “pages” — and you get the framework’s missing general plugin API for free.
@Page ≡ @Controller+@Get returning HTML smallest, leakiestNo new core concept: in the add-on, @Page('/x') expands to a hidden controller with a GET handler returning
text/html. Rides the existing router. Catch: no place to special-case SSR streaming or html-vs-json negotiation.
registerKind(). C is a fine internal detail for the actual route insertion.One @Page per file, extending Component. Signals and methods are fields; a few well-known methods are
overridable — chiefly view(). Resolved by the same DI container that serves controllers.
signal()s, plain values, or injected deps.view() (required) — the markup (§04). Now may return multiple roots (fragments).onStart/onPause/onClose + reactions (§07), all optional.onStart().view() returns markupFour routes, from “free on Bun” to “build a compiler.” All target the same output: vnodes with signal-bound holes.
Bun compiles JSX for free. Point jsxImportSource at @velocity/ui; each tag becomes a jsx() vnode. Full typing, no custom toolchain.
html`…` build-freelit/htm-style. Pure standard TS, no compiler. Parsed once per call-site, cached in a WeakMap — near-zero after first paint.
h(tag, props, …kids) hyperscript escape hatchThe factory both others compile down to. Verbose but zero magic; great for programmatic trees.
Compile .vpage to direct DOM ops (no vnode diff). Fastest runtime — and a whole toolchain to own. (Related but lighter: template files, §11.)
| Approach | Build | Typing | Runtime | Payload | DX | Maint. |
|---|---|---|---|---|---|---|
| 1 · JSX (Bun) | free | excellent | good¹ | small | high | low |
| 2 · html`` | none | ok | good¹ | +2KB | good | med |
| 3 · h() | none | good | good¹ | tiny | low | low |
| 4 · compiler | huge | excellent | best | smallest | good | very high |
Run view() on every change, diff, patch. Simple — but a fetching read inside view() re-fires each render (the crash you flagged).
view() runs once to build the tree; each signal read becomes a live binding that patches only its node. No periodic re-render; a Caller read subscribes once.
Yes, and cheap. A view() may return a fragment (<>…</>) or an array of siblings. Internally a
fragment is just a vnode with no host element — its children mount into the parent directly; when a component is used elsewhere, it expands to
exactly those siblings (as you pictured). Fine-grained binding makes this free: there’s no wrapper node and no diff cost.
key mechanism lists already need. Benchmark impact: negligible — a fragment is one fewer DOM node, not more. Verdict: ship it.
(React forbade it originally only because its diff assumed a single root; a signals renderer has no such constraint.)html` alt · fragments allowed.A signal is a reactive box: read .value → subscribe; write → subscribers update. computed derives, effect reacts, batch groups.
@preact/signals-core recommendedA framework-agnostic ~1KB package with exactly signal/computed/effect/batch and a proven, glitch-free graph. No React dep. We re-export it under the Velocity namespace.
~150 lines: observer stack, subscriber sets, topological invalidation, batch flush. Correct fine-grained reactivity is subtle (diamonds, cycles, cleanup) — the reason to prefer adopting.
Component base scopes creation to the instance.The heart of the framework. Everything is typed off velogen, so URLs are never hand-written. Two primitives — Caller (you want the reactive result) and Action (you fire a command) — each now method-generic.
A Caller binds any endpoint to a Feeder (a signal holding the response). It doesn’t fetch eagerly: it fires when the Feeder is
first read, or when a trigger changes. Method is generic — a POST that returns data you want to keep reacting to is a
PostCaller. Caller('METHOD', …) is the base; the verb forms are shortcuts.
An Action is a command you invoke on an event; it returns a promise and can invalidate Feeders or run optimistic.
Same method family: Action('METHOD', …) base + Get/Post/Patch/DeleteAction shortcuts.
| Style | Reactive? | Perf | Memory | DX | Best for |
|---|---|---|---|---|---|
| Callers + Actions default | full | cache | cache | declarative | reads + writes, the common case |
| Controller injection | manual | good | whole client | familiar | imperative flows / escape hatch |
| @GetApi members | semi | good | narrow | explicit | few known calls (deferred) |
Besides the field/decorator form, a Feeder can be created inside a method via a functional API — it exists only within that reactive scope and is disposed when the scope ends. Useful for on-demand data (a modal, a one-off report) you don’t want alive for the page’s lifetime.
createRoot) or it leaks its effect. The runtime binds functional feeders to the component’s disposal scope by default and warns if
one is created in a hot loop. Doable and clean — but it’s the one place lifetime bugs hide, so it ships behind the well-lit feed()
API rather than as the default.@Fn endpoints as local methods your ideaVelocity already ships @Fn — RPC-style endpoints reachable at GET /.name(args). When a @Page shares a
path/namespace with a controller, that controller’s @Fn methods can be surfaced as if they were methods of the component:
write this.greet('Alice') and the add-on turns it into the RPC. The only obstacle is your exact observation — TypeScript won’t let
you call a method that isn’t declared. Three ways around it:
@FnProxy works, but boilerplateDeclare the method with an empty/dummy body; a decorator overwrites it with the RPC. This is the literal version of what you described — functional, but every endpoint needs a throwaway body.
The clean answer to “can I avoid empty bodies?” — yes. Declare an interface with the same name as the class:
TypeScript merges its method signatures onto the instance type, so this.greet(...) type-checks with nothing to implement. A
class decorator (@FnsFrom) installs the real RPC proxies at runtime. This is the standard mixin trick — idiomatic, fully typed, zero dummy bodies.
Fns<Controller> client most explicitNo merging magic: inject an object typed from the controller’s @Fn surface and call through it. Slightly more verbose at call sites
(this.fn.greet) but the most obvious to read.
@Internal() at the same path (§08), these @Fn calls resolve server-side during SSR — combining this with endpoint-hiding
so the function exists only inside the page.feed() offered as an opt-in advanced API.| Hook | Fires | Use |
|---|---|---|
| onStart() | after mount / hydration | kick loads, subscriptions |
| onPause() | idle for pauseAfter ms | pause polling, save draft |
| onResume() added | interaction after pause | resume polling, revalidate |
| onClose() | unmount / navigate away | flush, cancel in-flight |
| onError(e) added | uncaught error (boundary) | fallback UI |
@Page('/x', { pauseAfter: 30_000 }); tab-hidden starts the timer; any input resets it; one shared monitor, not a timer per Page.watch(src, fn)@Watch('src')watch()/@Watch over “onChange/automater” — onChange collides with DOM handlers; watch is the term Vue/Solid devs already know. automate()/@Auto map to the same primitive if you prefer the identity.Default in fullstack mode: API under /api (Pages own the root) — no collisions, clean URLs. Callers target /api/* transparently.
@Internal() your idea, formalizedMark an endpoint internal: never exposed over HTTP, only reachable from a component’s Caller (server-side during SSR, or via a signed page-scoped channel). Same path as a Page → the public GET returns the Page; the data stays private.
| Concern | Backend-only | Fullstack | Measure |
|---|---|---|---|
| Symbols | inert constants | active | meaningful only when a kind is registered |
| scan() | skips pages (hint) | routes to add-on | tolerant branch behind “kind registered?” |
| Request entry | no negotiation | html-vs-json | negotiation installed only with add-on |
| Hot path | unchanged | unchanged for API | Pages are a separate compiled route class, never a runtime if |
Your idea: call endpoints in order, each result flowing into the next, the last being the result — with destructors so only the needed slice passes forward (no giant input lists). Delivered as a first-class primitive with reactive and imperative variants.
A Chain is an ordered list of steps. Each step has a method + path and a mapper that transforms the previous response into
this step’s input — the mapper is the destructor: it picks only what’s needed. The final step’s response lands in a Feeder (reactive)
or is returned (imperative). Triggers re-run the whole pipe; results are de-duped/cached by input tuple; an error short-circuits into .error.
@GetChain — all-GET shortcut the common caseWhen every step is a GET, a step is just “a path, derived from the previous response.” The arrow function is the destructor.
@Chain — mixed methods, explicit steps full powerEach step is an object: a verb+path, an input builder (body/params/query) and a pick destructor
that narrows what flows onward. Keeps the pipe readable even at 4–5 steps.
@ChainAction / fluent chain() — imperative on-eventFire the pipe from a handler and await the final result — for wizard submits, multi-step mutations, etc. A fluent builder reads well for dynamic pipes.
The worry you raised — “the input list gets very long” — is solved by making each step responsible only for its own input, derived from
the immediately-previous result (not all prior results). If a step needs an earlier value, a tiny ctx accumulator carries just the
fields explicitly kept via keep(). So the pipe never threads a growing tuple; each hop declares its slice.
pick/keep destructors and an all-GET shortcut. It composes with
triggers & caching exactly like a single Caller.A distinct delivery mode you flagged for the future: the component never calls an API. A service exposes providers; when the
Page’s path is opened, all its providers run server-side, merge into one context object, and the page is rendered from it and returned.
Like Django templates / Remix loaders / getServerSideProps.
How it runs: on GET /dashboard, the add-on resolves the declared service(s), runs every @Provider() in parallel
(they’re server-only — full DB/DI access, no HTTP hop), merges the results into ctx, renders view(ctx) to HTML, and ships it.
No client fetching for this data; it can be embedded in the hydration payload so the client never re-requests it.
Providers receive the request, so route params flow straight in. A provider can depend on another’s result (the add-on resolves the dependency graph and orders them), and a provider that throws surfaces a typed error you branch on in the view — so you never ship a half-rendered page. This is the whole ergonomic win over hand-wiring loaders: the page declares what it needs and the framework figures out how and in what order to get it.
Providers run in parallel by default; add after only when one genuinely depends on another. Each result can be cached —
per-request always, and optionally across requests with a TTL — so an expensive query isn’t repeated for every visitor. Because it all runs server-side, a
context page reads the session, hits the DB directly, and ships a fully-formed HTML document with zero client round-trips: the ideal shape for
SEO-critical, content-heavy, or first-paint-sensitive routes. And it composes with Callers — take the context for the static shell, then let a
@GetCaller drive the one live widget on the page. Context is the fast skeleton; Callers are the beating heart.
| Context providers | Callers/Feeders | |
|---|---|---|
| Runs | server-side, on navigation | client-side, reactively |
| Data freshness | snapshot per request | live, re-fetches on triggers |
| Best for | content pages, dashboards, SEO | interactive, changing data |
| Client JS | minimal (static-ish) | full runtime |
context for its initial shell and use Callers for live widgets. Context is
the fast first paint; Callers are the living parts. This section is intentionally brief — a placeholder to expand later; syntax above is a first sketch (negotiable)..tmpl files — extract the markup out of the classFor big components, move the view’s markup into a separate .tmpl file with the same syntax. Signals and child
components are used identically inside — no re-declaration, no type ceremony. A preprocessor inlines it as the view body.
How it works: a Bun build plugin (or a velogen step) reads .tmpl, compiles the markup to a render function
bound to the component instance (so this’s fields — query, users, loading — are in scope), and
replaces view with it. Inside the file, identifiers resolve against the component instance, so no imports/types are repeated.
.tmpl language mode for highlighting/JSX-in-file; (2) type-safety trade-off
— relaxing types inside the file means typos there aren’t caught at compile time (mitigable with an optional @bind interface); (3)
source-maps so errors point back to the .tmpl; (4) HMR must recompile the template on save. Worth it only for genuinely large views —
for most, inline JSX is simpler..tmpl as an opt-in build plugin, not the default. Inline JSX/html` stays the norm; templates are for the 500-line-view outliers.Render view() to a string server-side for first paint + SEO, then hydrate (attach bindings, don’t recreate DOM). Alts: CSR-only, SSG. Hard bit: hydration mismatch + embedding Feeder SSR values.
Default MPA (full loads, fast on Bun); opt-in SPA router intercepts links, fetches next data via Callers, keeps signals alive. Hard bit: scroll restore, in-flight cancel, per-route splitting.
@Layout wraps Pages via <Outlet/>. A layout is just a Component — reuses everything.
Scoped css` (hashed classes), object styles, plain CSS, or Tailwind. Hard bit: SSR-inline critical CSS to avoid FOUC.
head() / @Meta renders title/OG into <head> server-side. Hard bit: dynamic meta from a Feeder before the head flushes.
Two-way <input model={sig}/>; submit via @Action. Hard bit: reuse the controller’s @Validate schema for instant client feedback.
Auto-escape interpolations (raw only via unsafe()); CSRF on Action POSTs; CSP for hydration scripts. Hard bit: audit every raw-output path (SSR-XSS).
Bun.build → per-Page chunks; HMR swaps a Page while preserving signal values. Hard bit: keep the bundler an optional add-on dep.
Heavy SSR blocks the loop → API p99 spikes (the one unavoidable enabled-mode cost). Mitigate: stream SSR, cap depth, offload to @Go workers.
css` · auto-escape+unsafe() · Feeder SSR values embedded · bundler optional dep.A fast scan of both for ideas worth stealing. Each is a suggestion, not a commitment.
Let a component accept markup from its caller — the clean way to build layouts, cards, modals.
Modals/toasts that must escape overflow:hidden render to a target node.
Declarative enter/leave animations; cache a Page instance across navigation so returning is instant.
Typed form models with validation — and reuse the same schema the controller validates with, for instant client feedback.
Pre-fetch a Page’s data before it activates (no loading flash) — essentially Context providers (§10), which is why I recommend that primitive.
Interceptors on Callers (auth token, retry, tracing) mirror Velocity’s backend interceptors. Pipes = pure display transforms in the view.
Validation of our choice: the most enterprise framework moved to signals. We’re on the right side of history.
A signal synced across all clients over the existing @WebSocket gateway, backed by SharedCounter/shm. Presence, live counters, collaborative UI — leveraging two features no competitor has.
Islands architecture: the page is rendered to static HTML on the server and ships with no client JS by
default. Only components marked @Island() get hydrated — each becomes an independent interactive “island” in a sea of static markup, loading
its own tiny bundle (optionally lazily, when it scrolls into view). Everything else stays inert HTML forever.
Why it matters: a typical page is ~90% static (header, copy, layout) and ~10% interactive (a like button, a search box). Full hydration ships JS for all of it; islands ship JS for only the interactive 10% — dramatically smaller client payload and faster time-to-interactive. This is the win Astro popularized, and it fits Velocity’s “ship the minimum” ethos perfectly.
How it works: the add-on’s bundler emits one chunk per @Island component plus a ~1KB loader;
SSR renders the whole tree to HTML and tags each island root with a data-attribute; the loader hydrates each island per its on option
(load/idle/visible). Trade-off: islands don’t share live signal state directly — cross-island communication goes
through a global store() or liveSignal (below). Backend impact: none — it’s purely a client-bundling strategy; the SSR output is
the same HTML either way.
Auto-refresh a Feeder on an interval — @Poll(5_000) re-runs the Caller every 5s and pauses automatically under onPause.
<Await>A store() is signals shared across pages and islands (cart, auth, theme) — the cross-island channel islands lack.
Pair with an <Await feeder={…}> Suspense helper for declarative loading/error slots.
Velocity already has @Fn RPC (GET /.name(args)). Surface those to components as typed functions — instant tRPC-like calls, no new backend concept.
A dev panel graphing the signal dependency tree and a network waterfall of Callers/Chains — the debugging story that makes implicit reactivity trustworthy.
Wrap SPA navigation in document.startViewTransition for free animated page changes — modern, tiny, high-polish.
These frontend features can’t exist until the core gains a capability. Each lists the backend change and its estimated effect on the backend’s benchmarks — because touching the core is the one thing that can move the numbers we’ve protected everywhere else.
Frontend: a @LiveCaller whose Feeder stays live — when the underlying rows change, the client updates with no polling.
Backend change: an ORM change-feed (emit on create/update/delete) + a WS subscription channel (Velocity already has
@WebSocket + shared memory to fan out across workers). Benchmark effect: the GET hot path is untouched (reads don’t emit). Writes gain a
few µs only when a subscriber exists (lazy emit), so POST/PUT throughput is unchanged in the common case; memory grows by bounded per-subscription
bookkeeping. Verdict: opt-in per entity keeps the 42K GET / 29K POST intact.
Frontend: flush the page shell immediately, then stream each slot as its Feeder/provider resolves (Suspense-style), improving TTFB and perceived speed.
Backend change: the core must return a streamed Response (a ReadableStream body) rather than a fully-buffered one —
Bun supports this natively, so the lift is moderate and localized to the Page route class. Benchmark effect: JSON API routes don’t stream and are
unchanged; streamed pages hold slightly more per-request memory while in flight. Verdict: no API-throughput impact; a Page-path-only addition.
select)Frontend: @GetCaller('/api/users', { select: ['id','name'] }) → smaller payloads, less over-fetching.
Backend change: controllers/ORM must honor a field-projection param (partial SELECT + response shaping). Benchmark effect: adds a
small per-request parse only when select is present (a few %); wire payload shrinks (net win end-to-end). Verdict: opt-in, measure — the query-builder
change is the real work, not the frontend.
Frontend: instead of hydrating, serialize listeners + state into the HTML so the page is interactive on load with near-zero client JS and no replay.
Backend change: an SSR serializer that captures component handlers/state into the output. Benchmark effect: larger HTML payloads and higher per-render server cost (serialization) → SSR renders/sec drop somewhat; the JSON API path is untouched. Verdict: a big project with real upside for TTI — defer until the basics ship.
Two samples: Easy — a focused Users CRUD (switch data style with the tabs); Pro — a full app showing auth, encrypted sessions,
entity relations, guards, validation, WebSocket + liveSignal, shared memory, clustering, context providers, chains, and @Fn. Flip the
Sample switch. (Illustrative; not meant to compile.)
users.page.tsx (and its imports) change with the selected style.No fullstack code exists yet → these are reasoned estimates from Velocity’s real backend numbers + published characteristics. Backend baseline (Bun, c=32): GET 42,927 · POST 29,750 · 66 MB.
| Parameter | Backend-only (today) | Fullstack · Tier-1 (JSX SSR) | Fullstack · Tier-2 (hydration) |
|---|---|---|---|
| JSON API throughput | 42,927 GET | ~42K (±2%) | ~42K (±2%) |
| SSR renders/sec (simple) | n/a | ~1.5–5K (est.) | ~1–4K (est.) |
| Idle RSS | 66 MB | ~68–72 MB | ~90–130 MB |
| Cold start | baseline | ~+5–15 ms | ~+150–400 ms |
| Client JS shipped | 0 | ~5–15 KB | ~15–40 KB + page |
| node_modules | 63 MB | ~65 MB | ~150–200 MB |
| Prod deps | 3 | 3 + addon (0 new prod on Bun) | 3 + addon + bundler |
| New security surface | none | +SSR-XSS | +hydration/build |
| API p99 under heavy SSR load | flat | mild spikes | spikes (shared loop) |
| Parameter | Velocity+UI | Next.js | Nuxt | SvelteKit | Remix | Elysia | Hono | Nest |
|---|---|---|---|---|---|---|---|---|
| SSR | yes | yes | yes | yes | yes | no | JSX | plugin |
| Hydration / islands | T2 | RSC | yes | yes | yes | no | HonoX | no |
| Streaming SSR | planned | yes | yes | yes | yes | — | yes | — |
| Signals reactivity | yes | no | ref | runes | no | — | — | yes(new) |
| Built-in ORM | yes | no | no | no | no | no | no | no |
| Built-in DI | yes | no | no | no | no | no | no | yes |
| Typed RPC to backend | native | actions | nitro | form | loader | Eden | RPC | — |
| Piped/chained data | Chains | manual | manual | manual | manual | — | — | — |
| Realtime primitive | liveSignal* | no | no | no | no | ws | ws | ws |
| Cross-worker shm | yes | no | no | no | no | no | no | no |
| Edge/serverless | no | yes | yes | yes | yes | no | yes | no |
| Ecosystem | new | vast | large | large | med | grow | grow | large |
| Learning curve | med | steep | med | gentle | med | gentle | gentle | steep |
| Metric | Velocity+UI (est.) | Next.js | Nuxt | SvelteKit | Hono JSX |
|---|---|---|---|---|---|
| JSON API throughput | ~42K (unchanged) | ~10–15K¹ | ~12–18K¹ | ~18–25K¹ | ~39K |
| SSR renders/sec (simple) | ~1.5–5K² | 53–104³ | 381³ | 589³ | ~945³ |
| Idle RSS (T1 / T2) | 68–72 / 90–130 MB | 120–200 MB | 150–250 MB | 90–130 MB | 55–70 MB |
| Client JS baseline | ~5–15 KB | ~87 KB³ | ~70 KB | ~10–20 KB | ~0–5 KB |
| End-to-end type safety | native (velogen) | partial | partial | partial | RPC |
fullstack-feasibility.md.Title + note → saved to localStorage. No server, no export — just for you, on this machine.
| # | Decision | Chosen | Alt |
|---|---|---|---|
| ★1 | Rendering model | Fine-grained signal binding | VDOM re-render |
| ★2 | Markup syntax | JSX (Bun) + html` | compiler / .tmpl |
| ★3 | Data linking | Callers + Actions (any method) + inject | @GetApi (deferred) |
| ★4 | Signals | Adopt @preact/signals-core | build minimal |
| 5 | Fragments | allowed (multi-root) | single-root |
| 6 | Chains | dedicated primitive + per-step destructors | Caller sub-feature |
| 7 | Context providers | offered as SSR mode | Callers only |
| 8 | Template files | opt-in build plugin | inline only |
| 9 | Reaction API | watch()/@Watch | @Auto |
| 10 | Package name | @velocity/ui | /front · /visuals |
fullstack-addon-vs-switch.md, fullstack-gated-mode.md, ../../researches/fullstack-feasibility.md.