rxjs-fullstack
v1.0.0
Published
<p align="center"> <img src="./assets/rxjs-fullstack-logo.webp" alt="RxJS Fullstack logo" width="360" /> </p>
Readme
RxJS Fullstack
rxjs-fullstack is an experiment in a minimal fullstack web framework whose application execution model is RxJS.
The framework deliberately leaves existing technologies in charge of the jobs they already solve:
- TypeScript — language, strong typing, and JSX compilation.
- RxJS 7 — lazy dataflows, state, effects, cancellation, sharing, and Query/Cache.
- TypeScript JSX — view syntax without React.
- rxjs-router — strongly typed route matching, navigation, request resolution, and route data.
- Hono — Web-API HTTP layer.
- Bun — reference development runtime and build tool.
- Node.js / fetch-native edge runtimes — alternate runtime hosts around the same Web
Request → Responseapplication boundary.
The central rule is: RxJS is the application machine; the surrounding technologies remain thin, explicit boundaries.
This README is the canonical source for the rxjs-fullstack project page. Every milestone should therefore document not only what was implemented, but why it exists, what flows through it, where execution starts, how cancellation works, which technology owns each responsibility, and how the milestone is verified.
Milestone status
M01 TypeScript JSX runtime ✅
M02 RxJS DOM bindings ✅
M03 Pure HTML renderer + basic SSR ✅
M04 Hono + Bun server ✅
M05 Strongly typed routing + Query/Cache ✅
M06 File-based route discovery ✅
M07 Forms + RxJS server actions ✅
M08 SSR Query Prefetch / Client Data Continuity ✅
M09 Static Site Generation ✅
M10 Runtime Adapters ✅
M11 Database Integration ✅
M12 Authentication ✅
M13 Streaming / Advanced SSR ✅M01 — TypeScript JSX runtime
M01 establishes a framework-owned view representation without React.
TypeScript compiles TSX through the automatic JSX runtime (jsx: "react-jsx" with jsxImportSource pointing at the framework's own src/jsx/jsx-runtime.ts — the setting's name is historical; React is not involved). The compiler injects the runtime import itself, so view modules never import a JSX factory manually, and fragments (<>...</>) type-check. JSX therefore produces ViewChild values rather than DOM nodes or React elements.
The intrinsic JSX surface is deliberately typed: standard HTML tag names are accepted, common attributes are explicit, and data-* / aria-* remain extensibility points, so misspelled tags or unsupported attributes fail typechecking. The runtime supports primitives, intrinsic elements, fragments, function components, nested children, and RxJS Observables as live view values. It does not render, subscribe, create DOM, or own lifecycle.
The implementation lives in src/jsx/runtime.ts.
M02 — RxJS DOM bindings
M02 interprets the same ViewChild representation in the browser.
mount(view, container) returns the RxJS Subscription that owns the mounted view's lifetime. Observable children and attributes are subscribed only when the view is mounted. Unsubscribing tears down event listeners, child subscriptions, live regions, and the mounted DOM.
DOM events flow into RxJS through Observers:
<button on={{ click: click$ }}>...</button>The renderer only forwards event packages. Application meaning remains in the RxJS pipeline.
Live form state is the one exception to attribute binding: value, checked, and selected are assigned through the DOM property, because their attributes only set a control's default state and stop reflecting once the user has interacted with it. Everything else binds as an attribute.
Errors follow the same ownership rule: recovery belongs in the application dataflow (catchError), not in the renderer. If an Observable child or attribute binding errors anyway, the renderer tears the binding down — the live region is cleared, a failed attribute is removed — and reports the error through mount()'s optional onError hook. By default the error is rethrown as an unhandled error, so failures stay loud instead of freezing stale DOM.
The implementation lives in src/render/dom.ts. Its invariants have their own executable verification (scripts/verify-dom.tsx), which runs the renderer against a real DOM implementation: events dispatched on rendered elements reach their Observers and drive the counter state machine, every live-region emission tears down the previous child's subscription before the replacement renders, replaced children lose their event listeners, unsubscribing the mount() Subscription removes the DOM and closes every Observable binding, and erroring bindings clear their region or attribute and reach the onError hook.
M03 — Pure HTML renderer and SSR
M03 adds the server interpreter for the M01 view representation.
renderToString() is deliberately synchronous and pure. It renders already-resolved JSX values to HTML and never subscribes to an Observable. Observable children or attributes reaching the HTML renderer are rejected with a TypeError.
Server asynchronous work must therefore resolve before rendering:
request
↓
route/request dataflow
↓
resolved model
↓
JSX
↓
renderToString()
↓
HTMLThis keeps execution, cancellation, and subscription policy in RxJS rather than hiding them in rendering.
The implementation lives in src/render/html.ts.
M04 — Hono + Bun server
M04 completes the first full server path.
Hono owns HTTP. Bun is the first reference runtime adapter. src/server/app.tsx defines the Web-API application, while the Bun entry supplies the runtime fetch host and port.
The server path is:
HTTP request
↓
runtime host
↓
Hono
↓
RxJS/router data resolution
↓
resolved page data
↓
JSX + pure HTML rendering
↓
ResponseNo framework semantics depend on Bun-specific APIs. M10 later makes that architectural intention executable across multiple runtimes.
M05 — Strongly typed routing and Query/Cache
M05 adds reusable application infrastructure.
The running application uses the sibling rxjs-router package on both server and browser:
- server:
resolveRequest({ routes, request }), - browser:
createRouter({ routes, history }), - browser view state:
router.state$.
An earlier standalone typed-route experiment in src/router/ proved the routing type model with path-literal-derived params and typed href(...) generation. It has since been removed: rxjs-router itself provides the same guarantees through PathParams inference and typed buildPath(...) URL construction, and the dynamic /hello/$name page now lives in the application route tree as an ordinary discovered route module.
M05 also internalizes the Query/Cache layer under src/query/. Queries are Observables, mutations expose cold mutate$() streams, invalidation/refetching stays explicit, and the Todos vertical slice proves browser Query/Cache against a Hono API.
M06 — File-based route discovery
M06 removes central manual registration of page route modules.
Page modules live in:
src/routes/*.tsxEach page module default-exports its rxjs-router route object. scripts/generate-routes.ts discovers the modules with Bun.Glob, sorts them deterministically, and writes the complete strongly typed root tree to src/routes.generated.ts.
src/routes/*.tsx
↓
Bun.Glob discovery
↓
sorted imports
↓
generated createRoute({ children: [...] })
↓
src/routes.generated.ts
↓
src/routes.tsx public re-exportThe generator only discovers and assembles modules. rxjs-router still owns route semantics: matching, params, loaders, navigation, redirects, cancellation, and request resolution.
Generating the root createRoute() call with its inline children tuple preserves TypeScript's exact route-tree inference.
/about is the M06 proof route: it participates in SSR without being manually imported into a central route registry.
M07 — Forms + RxJS server actions
M07 is the point where rxjs-fullstack moves from framework structure into a complete fullstack effect.
Before M07, the Todos example could already fetch cached server data and perform a mutation, but the write path was still an application-specific HTTP workflow: a button click triggered code that queried the DOM for an input element and posted directly to POST /api/todos.
M07 replaces that path with a reusable framework model:
HTML form
↓
SubmitEvent
↓
RxJS submit dataflow
↓
typed ServerActionRef<Input, Output>
↓
cold/cancellable HTTP Observable
↓
Hono server-action boundary
↓
lazy RxJS server execution
↓
pure domain validation + operation
↓
typed result
↓
Query/Cache invalidation
↓
refetched viewThe result is the first end-to-end proof that UI events, concurrency policy, network effects, server execution, and data refresh can all remain one explicit RxJS-oriented architecture without introducing a component framework lifecycle or an eager Promise-based action abstraction.
The form itself is the event source
The Todos UI now declares an ordinary HTML form whose submit event is forwarded to an RxJS Subject<SubmitEvent>:
const submit$ = new Subject<SubmitEvent>();
<form on={{ submit: submit$ }}>
<input
name="title"
type="text"
placeholder="What needs doing?"
required
/>
<button type="submit">Add</button>
</form>This matters because the user intent is submit this form, not click this particular button. Keyboard submission and button submission therefore enter the same source stream.
The DOM renderer still knows nothing about Todos or forms. Its responsibility remains the M02 rule: take a browser event and forward that event package to an Observer. The application decides what the event means.
The browser-default navigation is stopped by an ordinary function used inside the pipeline:
const preventFormNavigation = (event: SubmitEvent): void => {
event.preventDefault();
};The operator remains visible:
submit$.pipe(
tap(preventFormNavigation),
...
);This follows the project's FP/RxJS rule: name the domain or application function; do not rename the RxJS mechanism around it.
FormData is converted into domain data before the effect
The form element is available as event.currentTarget. M07 reads FormData from that exact submitting form instead of searching global DOM state.
Conceptually:
SubmitEvent
↓
currentTarget: HTMLFormElement
↓
FormData(form)
↓
raw title string
↓
createTodoInput(title)
↓
CreateTodoInputcreateTodoInput() lives in src/domain/todos.ts. It trims the title and returns a typed CreateTodoInput only for a non-empty value.
The important boundary is:
browser representation domain representation
FormData / string → CreateTodoInputAfter that conversion, the server-action transport moves a typed package. It does not care what a Todo title means.
The submit concurrency policy is explicit
The central browser pipeline is:
const status$ = submit$.pipe(
tap(preventFormNavigation),
exhaustMap((event) => {
const submission = readTodoSubmission(event);
// ... invoke action, invalidate query, reset form
}),
startWith(''),
);exhaustMap is deliberately visible because it states the form's concurrency policy:
While one Todo creation is in flight, ignore additional submissions.
In practical terms:
time ─────────────────────────────────────────►
submit A────B────C────────────D
│ × × │
│ │
▼ ▼
action [ save A ........ ] [ save D ... ]
policy exhaustMap = ignore while busyNothing in the server-action API chooses this for the application. Another form can choose a different policy without changing the transport:
mergeMap = allow overlapping submissions
switchMap = cancel/replace with the latest submission
concatMap = queue submissions
exhaustMap = ignore submissions while one is activeThis is a central design decision for rxjs-fullstack: the effect describes how to execute one request; the RxJS pipeline describes how multiple requests relate over time.
Shared server-action references are contracts, not implementations
The client and server need to agree that an action exists and what TypeScript values it carries, but the browser must not import server implementation code.
M07 therefore introduces ServerActionRef<TInput, TOutput>.
The Todo action declaration is intentionally small:
export const createTodoAction = defineServerAction<CreateTodoInput, Todo>(
'todos.create',
);It provides three things:
action identity todos.create
input type CreateTodoInput
output type TodoIt does not contain:
- Todo persistence,
- validation implementation,
- Hono code,
- server-only dependencies,
- query invalidation,
- concurrency policy.
This makes the action reference safe to share with browser code while keeping the server implementation physically and conceptually separate.
The shared primitives live in:
src/actions/action.ts
src/actions/todos.tsClient action execution is cold and cancellable
The browser invokes the action through:
invokeServerAction$(createTodoAction, input)invokeServerAction$() returns an RxJS Observable backed by fromFetch.
That gives the client action the same execution model as the rest of the framework:
Observable exists
│
│ no subscription
▼
nothing happens
subscribe
↓
HTTP POST starts
↓
response arrives
↓
Todo emitted
↓
completeThe action URL is derived from the action identity:
todos.create
↓
/api/actions/todos.createThe request body contains the typed input serialized as JSON.
This transport remains intentionally small. It does not know about forms, Todo state, exhaustMap, or Query/Cache. It performs one job:
ServerActionRef<Input, Output> + Input
↓
Observable<Output>Cancellation follows the RxJS Subscription
Because the action request is a fromFetch Observable, unsubscription aborts the underlying request.
The cancellation chain is therefore explicit:
mount(TodoApp)
↓
view Subscription
↓
status$ subscription
↓
active createTodo$ subscription
↓
fromFetch requestIf the mounted view is torn down:
view lifetime.unsubscribe()
↓
status$ unsubscribes
↓
active inner action unsubscribes
↓
fromFetch aborts HTTP requestM07 therefore does not introduce a second cancellation system. It extends the M02 lifetime rule across the network boundary: the RxJS Subscription remains the owner of browser work.
The server action is also a lazy RxJS dataflow
The server side mirrors the browser-side execution model.
registerServerAction() connects a shared ServerActionRef to a server-only handler. The handler contract separates input parsing from execution:
interface ServerActionHandler<TInput, TOutput> {
parse(value: unknown): TInput;
run(
input: TInput,
context: ServerActionContext,
): Observable<TOutput>;
successStatus?: number;
}The HTTP adapter first obtains raw JSON. The typed server execution is then described by executeServerAction$():
raw unknown JSON
↓
executeServerAction$()
↓
defer(...)
↓
handler.parse(rawInput)
↓
typed TInput
↓
handler.run(input, context)
↓
Observable<TOutput>Both parsing and handler invocation are inside defer(). That means they do not run merely because the Observable was constructed.
At the HTTP boundary, Hono consumes that lazy description with firstValueFrom() and converts the result into a JSON Response.
Hono owns HTTP
↓
firstValueFrom(executeServerAction$(...))
↓
RxJS owns action execution
↓
Hono owns HTTP responseThis is the same boundary pattern already established for server rendering: asynchronous execution happens before the pure boundary consumes its resolved value.
Raw input is validated again on the server
TypeScript types disappear at the network boundary, so a typed client declaration is not sufficient runtime validation.
The server receives unknown and calls the domain parser:
parseCreateTodoInput(value)The flow is:
JSON payload
unknown
↓
parseCreateTodoInput
↓
CreateTodoInput | undefined
↓
valid input ──────────────► run action
invalid input ────────────► HTTP 400This means the action reference gives compile-time agreement while the domain parser gives runtime trust at the server boundary.
The validation rule itself still lives in the domain layer, not in Hono and not in the generic action adapter.
Server context carries request lifetime information
A server action receives:
interface ServerActionContext {
request: Request;
signal: AbortSignal;
}The Todo action checks signal.aborted before performing the in-memory write. This establishes the first server-action cancellation hook and leaves room for later database or network effects to consume the same request signal.
M07 does not yet claim that every possible server-side side effect is automatically cancellable. What it establishes is the correct architecture: the request AbortSignal is available to the RxJS server action instead of being hidden by the HTTP adapter.
Todo business logic remains outside the action framework
The server-specific Todo store lives in src/server/todos-store.ts. The generic action adapter does not know its structure.
The Todo action performs this conceptual work:
CreateTodoInput
↓
addTodo(input)
↓
TodoThe RxJS layer merely lifts that operation into a lazy execution description:
defer(() => of(addTodo(input)))This preserves the project's rule:
Pure/domain logic inside; RxJS and HTTP plumbing outside.
The domain can later change from an in-memory array to a database without changing the form source, the exhaustMap policy, the action reference, or the basic action transport.
Query/Cache owns the read side after a successful write
M07 deliberately does not make the server action secretly update browser state.
The action returns the created Todo. The application then explicitly invalidates the existing Todos query:
createTodo$(input)
↓
Todo emitted
↓
invalidateQueries(['todos'])
↓
active GET /api/todos refetch
↓
query result emits
↓
list$ renders latest dataThe relevant pipeline uses concatMap because the status should not become Saved. until invalidation/refetch work has completed:
createTodo$(submission.input).pipe(
concatMap(() =>
queryClient.invalidateQueries({ queryKey: todosQuery.queryKey }),
),
tap(() => submission.form.reset()),
map(() => 'Saved.'),
startWith('Saving...'),
);This gives each mechanism one responsibility:
| Concern | Owner |
| --- | --- |
| form event | browser / JSX Observer binding |
| temporal submit policy | exhaustMap |
| domain input creation | createTodoInput() |
| client action transport | invokeServerAction$() / fromFetch |
| HTTP routing and response | Hono |
| runtime input validation | parseCreateTodoInput() |
| Todo creation | addTodo() |
| server execution | executeServerAction$() + handler Observable |
| cached server reads | Query/Cache |
| post-write refresh | explicit invalidateQueries() |
| browser lifetime/cancellation | RxJS Subscription |
No single layer becomes a hidden mini-framework.
The M07 Todo write path versus the read path
M07 now gives the Todos feature two intentionally different fullstack paths.
Read path:
TodoApp subscription
↓
queryClient.query$(todosQuery)
↓
GET /api/todos
↓
readonly Todo[]
↓
Query/Cache
↓
list$ viewWrite path:
<form submit>
↓
submit$
↓
exhaustMap
↓
createTodo$(CreateTodoInput)
↓
POST /api/actions/todos.create
↓
server action
↓
Todo
↓
invalidate ['todos']
↓
read path refetchesThis separation is useful: actions perform effects; queries describe cached server reads. They coordinate explicitly rather than being fused into one opaque abstraction.
M07 failure behavior is explicit
The action boundary distinguishes several failure classes:
malformed JSON
↓
HTTP 400
valid JSON shape but invalid domain input
↓
ServerActionInputError
↓
HTTP 400
unexpected server handler failure
↓
HTTP 500
non-2xx browser response
↓
ServerActionRequestError
↓
Observable error
↓
catchError in Todo pipeline
↓
"Failed to save."The error therefore travels through the Observable error channel until the application decides how to turn it into view state.
M07 source map
The milestone is intentionally spread across small responsibility-focused files:
src/domain/todos.ts
Todo and CreateTodoInput types
createTodoInput()
parseCreateTodoInput()
src/actions/action.ts
ServerActionRef<Input, Output>
defineServerAction()
serverActionHref()
invokeServerAction$()
ServerActionRequestError
src/actions/todos.ts
shared createTodoAction reference
src/queries/todos.ts
todosQuery read definition
createTodo$ client action invocation
src/examples/todos.tsx
form source
SubmitEvent → FormData → CreateTodoInput
exhaustMap submit policy
Query/Cache invalidation
status rendering
src/server/action.ts
ServerActionHandler contract
executeServerAction$()
registerServerAction()
HTTP/error translation
src/server/api.ts
GET /todos
createTodoAction server registration
src/server/todos-store.ts
in-memory Todo state and addTodo()This file layout is part of the design. Shared action identity, browser transport, generic server adapter, domain rules, application orchestration, and server storage do not collapse into one module.
M07 verification
scripts/verify.tsx treats the milestone as an executable architectural specification. It checks that:
- server-action input parsing does not run before subscription,
- the server handler does not run before subscription,
- one subscription executes the action exactly once,
- valid Todo action input returns HTTP
201, - the returned object contains the created Todo,
- created data becomes visible through the existing
GET /api/todosread path, - empty/invalid Todo input returns HTTP
400, - the old ad-hoc
POST /api/todosmutation endpoint is no longer the write path, - the browser examples still typecheck and bundle successfully.
The milestone is therefore not considered complete merely because the Todo UI works. The verification also protects the intended execution semantics: laziness, typed boundaries, HTTP behavior, and separation of the old mutation route from the new server-action architecture.
What M07 establishes
M07 adds one important capability, but more importantly it establishes a reusable execution pattern for later fullstack features:
source event
↓
plain function extracts domain value
↓
RxJS operator chooses temporal policy
↓
cold effect Observable
↓
server RxJS dataflow
↓
domain operation
↓
result / error
↓
explicit state or cache updateA future login form, checkout command, settings save, file metadata update, or database mutation can use the same machine while changing only the domain packages and the chosen concurrency policy.
That is the larger M07 result: server actions are not a new execution model added beside RxJS; they are one more effect that participates in the existing RxJS machine.
M08 — SSR Query Prefetch / Client Data Continuity
M08 closes the read-side discontinuity that remained after M05–M07.
Before M08, /todos had two separate starts:
server request browser mount
↓ ↓
SSR route TodoApp subscribes
↓ ↓
"Loading todos..." Query/Cache is empty
↓
GET /api/todos
↓
render TodosThe server knew how to render the page shell, and the browser knew how to fetch Todos, but server-resolved data was not carried across the HTML boundary. The browser therefore had to cold-start the same read again.
M08 changes that flow to:
HTTP GET /todos
↓
request-scoped QueryClient
↓
rxjs-router loader
↓
queryClient.query$(todosQuery)
↓
in-process GET /api/todos
↓
readonly Todo[]
↓
Query/Cache
├──────────────► static SSR Todo snapshot
│
└──────────────► dehydrate()
↓
application/json bootstrap state
↓
HTML
↓
browser entry reads state before mount
↓
hydrate(queryClient, state)
↓
TodoApp subscribes to the same query key
↓
fresh cached Todos emit immediatelyThe important result is not merely that the server can fetch Todos. The important result is continuity of the same query state across the server/browser boundary.
The QueryClient is request-scoped on the server
The server must never reuse one application-wide QueryClient across users or requests. M08 therefore creates a new QueryClient inside each Hono page request:
const queryClient = new QueryClient();That client is passed into rxjs-router through its explicit route context:
resolveRequest({
routes,
request,
context: {
queryClient,
fetch: routeFetch,
},
});This preserves the route architecture established in M06. Hono does not gain a special if (pathname === '/todos') prefetch branch. The route loader declares the data it needs; Hono only supplies request infrastructure.
Request isolation is therefore:
request A ──► QueryClient A ──► dehydrate A ──► response A
request B ──► QueryClient B ──► dehydrate B ──► response BNo query state crosses between requests unless an application later introduces an explicit shared server cache.
The Todos route owns its SSR query requirement
src/routes/todos.tsx now uses the QueryClient from router context while resolving the page.
The server loader subscribes to:
context.queryClient.query$(createTodosQuery(context.fetch))and waits until the query has either data or an error before creating the pure SSR view.
The route request AbortSignal is connected with takeUntil(...). If the request is cancelled while the query is active, the route subscription is torn down rather than leaving a detached page-read subscription running.
Conceptually:
route request signal ───────────────┐
│
query$ ── loading ── data ── ... │
│ │
└──────── takeUntil(abort) ◄──────┘The request controls the lifetime of the server read.
Server and browser use the same query identity
M08 keeps the existing query key:
['todos']createTodosQuery(fetcher) makes only the transport replaceable. The browser uses the normal Web fetch; the server receives an in-process Hono fetch adapter.
The query contract remains:
query key ['todos']
result readonly Todo[]
HTTP contract GET /api/todosThe server adapter resolves /api/todos through the same Hono application without performing a real network round-trip. This is important because M08 does not introduce a second Todo read implementation just for SSR.
The transport can change; the query identity and data package do not.
SSR renders resolved data, not an Observable
M03's renderer rule remains unchanged: renderToString() never subscribes.
M08 resolves the query before rendering and then builds a static TodoSnapshot from the resulting readonly Todo[]:
query$ subscription
↓
readonly Todo[]
↓
TodoSnapshot
↓
ViewChild containing plain values
↓
renderToString()The SSR renderer still receives no live Observable child.
This means M08 extends SSR without weakening the M03 boundary. Asynchronous execution remains outside the pure HTML renderer.
Query state is dehydrated into the HTML document
After routing has finished, the request-scoped QueryClient contains the successful Todos query. The server calls:
dehydrate(queryClient)and emits the result into a JSON bootstrap script:
<script id="rxjs-query-state" type="application/json">...</script>renderDocument() now supports generic JSON bootstrap scripts and performs HTML-safe JSON escaping. In particular, <, >, and & are encoded so query data cannot accidentally terminate the script element and become executable HTML.
The bootstrap element is data, not JavaScript code. No query logic is embedded in the document.
The browser restores Query/Cache before TodoApp subscribes
src/examples/todos-client.tsx now performs the bootstrap in this order:
find #app
↓
read #rxjs-query-state
↓
JSON.parse
↓
hydrate(queryClient, state)
↓
mount(TodoApp)
↓
TodoApp subscribes to queryClient.query$(todosQuery)The ordering is essential.
If TodoApp subscribed first, its empty client QueryClient could begin a browser fetch before the server state was restored. Hydrating first means the first browser subscription sees the server-populated cache.
The helper hydrateQueryClientFromDocument() performs only this bootstrap boundary. It does not mount UI or choose query behavior.
Freshness policy prevents the immediate second cold fetch
Hydration alone is not enough.
A query with staleTime: 0 is stale immediately. The client could correctly restore the server value and then immediately refetch it because the normal query policy says the data is stale.
M08 therefore makes freshness explicit for Todos:
staleTime: 30_000The temporal behavior is now:
time ─────────────────────────────────────────────►
server fetch ● data resolved
│
HTML response │──── dehydrated state ────►
│
browser hydrate ● same data restored
│<------ 30s fresh ------->│
client subscribe ● cached data emits
│
└── no immediate GET /api/todosAfter the freshness window expires, ordinary Query/Cache rules apply again. M08 does not disable refetching; it prevents an unnecessary duplicate read during the initial server-to-browser handoff.
M08 is data hydration, not DOM hydration
The word "hydrate" here refers specifically to Query/Cache state.
The current DOM renderer's mount() still owns its container and calls replaceChildren(). When the browser client mounts, it may replace the static SSR snapshot with the live RxJS view.
That is deliberately outside the M08 claim.
M08 guarantees:
server query state ──► browser query stateIt does not yet claim:
server DOM nodes ──► attach bindings in placeKeeping those two problems separate makes the architecture easier to reason about. Query-state continuity can be verified independently of a future DOM-hydration strategy.
M08 source map
src/queries/todos.ts
createTodosQuery(fetcher)
shared ['todos'] identity
explicit staleTime freshness policy
src/server/route-context.ts
request-scoped QueryClient + in-process fetch contract
src/routes/todos.tsx
SSR query subscription
request cancellation with takeUntil
static TodoSnapshot construction
src/examples/todos.tsx
shared TodoList
static TodoSnapshot
live TodoApp
src/query/ssr.ts
QUERY_STATE_SCRIPT_ID
hydrateQueryClientFromDocument()
src/render/html.ts
generic application/json bootstrap scripts
HTML-safe JSON serialization
src/server/app.tsx
request-scoped QueryClient
router context
dehydrate() at the HTTP/HTML boundary
src/examples/todos-client.tsx
restore cache before mountEach file owns one piece of the handoff rather than hiding the complete process behind a new framework lifecycle.
M08 verification
scripts/verify.tsx now checks the continuity contract directly:
/todosSSR contains actual seeded Todo data,/todosSSR no longer contains theLoading todos...placeholder,- the HTML contains the
rxjs-query-statebootstrap element, - a server QueryClient executes a test query exactly once,
- that QueryClient can be dehydrated,
- a fresh browser QueryClient can restore the serialized state,
- the browser query emits the server value after hydration,
- the browser query function is not executed while the hydrated data remains fresh,
- TypeScript and both browser bundles still build through the normal
bun run checkpipeline.
The most important executable assertion is:
server query executions = 1
browser query executions = 0
browser observed value = server valueThat is the concrete M08 definition of client data continuity.
What M08 establishes
M08 adds a reusable server/browser read path:
route declares query
↓
server subscribes
↓
request QueryClient remembers result
↓
SSR consumes resolved data
↓
QueryClient dehydrates into HTML
↓
browser QueryClient hydrates before subscription
↓
normal RxJS Query/Cache execution continuesThe server and browser are no longer two unrelated executions that happen to request the same endpoint. They are two phases of one query lifecycle separated by an HTML transport boundary.
The broader principle is: SSR may resolve the first value, but Query/Cache remains the state machine that owns the read across the boundary.
M09 — Static Site Generation
M09 proves that request-time SSR and build-time static generation do not need separate application models.
The framework already had the complete page machine before M09:
route match
↓
route loaders
↓
Query/Cache where needed
↓
resolved PageData
↓
JSX ViewChild
↓
pure renderToString()
↓
HTML documentM09 keeps that machine and changes only when it is executed and what consumes the resulting HTML.
Request-time SSR:
HTTP request
↓
renderRouteDocument()
↓
HTML
↓
Hono ResponseBuild-time SSG:
build command
↓
static route pathname
↓
renderRouteDocument()
↓
HTML
↓
dist/static/.../index.htmlThis is the central M09 rule: SSG is not a new renderer. It is the existing route-document renderer executed during the build.
One route-document renderer now owns page resolution
Before M09, src/server/app.tsx contained both HTTP concerns and the reusable page-resolution work: it created the request QueryClient, called rxjs-router, rendered the resolved JSX, dehydrated query state, and then returned HTML through Hono.
M09 extracts the reusable part into:
src/render/page.tsIts central operation is:
renderRouteDocument({
routes,
request,
fetch,
})Conceptually:
routes + Request + fetch boundary
↓
request/build-scoped QueryClient
↓
resolveRequest()
↓
route loaders
↓
resolved PageData
↓
renderToString(page.view)
↓
dehydrate(QueryClient)
↓
renderDocument()
↓
RouteDocumentResultThe result can be a page, redirect, not-found result, or error. The consumer decides what those values mean.
Hono now owns only the HTTP translation:
RouteDocumentResult.page → context.html(...)
RouteDocumentResult.redirect → context.redirect(...)
RouteDocumentResult.notFound → HTTP 404
RouteDocumentResult.error → HTTP 500The static builder accepts only a successful page result and turns it into a file.
This extraction is important beyond M09: the framework now has an execution boundary that is independent of whether HTML is requested by a live HTTP client or generated ahead of time.
Static paths come from the generated router tree
M06 already made src/routes.generated.ts the generated application route tree. M09 deliberately does not add a second staticRoutes registry.
Instead, collectStaticPathnames() asks rxjs-router to normalize that same tree and reads each route node's fullPath.
For the current application:
generated route tree
↓
normalizeRoutes()
↓
/
/about
/counter
/todosThe current static set is therefore derived automatically:
/
/about
/counter
/todosAdding another concrete file-discovered route later automatically makes it eligible for the same discovery process.
This preserves the M06 principle: there is one route tree, not one tree for runtime routing and another manifest for build tooling.
Parameterized routes are explicit future build inputs
A concrete route such as:
/aboutcan be generated immediately because its pathname is complete.
A route such as:
/posts/$slugis different. The framework cannot know which slug values should exist at build time.
M09 therefore excludes route paths containing build-time parameters rather than guessing values or accidentally writing a literal $slug directory.
/posts/$slug
↓
requires explicit build-time params
↓
not generated by M09 automatic static discoveryThis is intentional scope, not a limitation hidden by the generator. A later parameterized-SSG feature can supply a list such as:
/posts/rxjs
/posts/functional-programming
/posts/observablesand feed those concrete pathnames into the same renderStaticPage() operation without changing the renderer.
Static URL structure maps to directory index files
M09 uses deployment-friendly directory-index output:
/ → dist/static/index.html
/about → dist/static/about/index.html
/counter → dist/static/counter/index.html
/todos → dist/static/todos/index.htmlThat mapping is owned by staticOutputPath().
The output structure means ordinary static hosts can serve clean URLs without requiring framework-specific URL rewriting just to remove .html suffixes.
Build-time pages reuse M08 Query/Cache prefetch
The most important proof route for M09 is /todos.
A simplistic SSG implementation could render only routes with synchronous data and leave query-backed pages to the browser. M09 instead reuses the M08 server execution path.
Build-time /todos is:
renderStaticPage('/todos')
↓
renderRouteDocument()
↓
Todos route loader
↓
request/build-scoped QueryClient
↓
queryClient.query$(createTodosQuery(fetch))
↓
GET /api/todos through supplied fetch boundary
↓
readonly Todo[]
↓
static TodoSnapshot
↓
dehydrate(QueryClient)
↓
HTML with prefetched Todos + query bootstrap
↓
dist/static/todos/index.htmlThe generated page therefore contains the same two outputs as M08 SSR:
visible resolved HTML
+
deferred browser Query/Cache stateThe build process does not introduce a special Todo reader. scripts/generate-static.ts supplies an in-process fetch adapter to the same Hono API contract, so GET /api/todos remains the read boundary used by the query.
Build-time QueryClient state is isolated per generated page
renderRouteDocument() creates a fresh QueryClient for every page render.
That gives static generation the same isolation principle M08 established for HTTP requests:
/about build
↓
QueryClient A
↓
/about HTML
/todos build
↓
QueryClient B
↓
/todos HTML + dehydrated Todos stateData from one generated page is not implicitly carried into another page's cache.
If the framework later chooses to introduce cross-page build caching, that will be an explicit optimization rather than an accidental consequence of one process-global QueryClient.
SSG does not weaken the pure HTML renderer
M03 remains unchanged.
renderToString() still receives only resolved values and still rejects Observable children.
The timing is:
build-time route/query execution
↓
resolved PageData
↓
plain/static ViewChild
↓
renderToString()M09 therefore adds no subscription logic to the renderer. The static builder coordinates execution before the pure rendering boundary, exactly as SSR does.
SSG and SSR can be compared directly
Because both modes now call the same renderRouteDocument(), deterministic pages can be checked byte-for-byte.
For /about:
HTTP /about
↓
renderRouteDocument()
↓
SSR HTML
build /about
↓
renderRouteDocument()
↓
static HTMLM09 verification asserts that those two HTML strings are identical.
For query-backed pages such as /todos, dehydrated query timestamps naturally differ between separate executions, so verification checks the semantic invariants instead:
static page contains resolved Todo data ✅
static page does not contain Loading todos... ✅
static page contains rxjs-query-state ✅
bootstrap contains queryKey ['todos'] ✅This distinguishes stable page semantics from incidental execution timestamps.
The build command is explicit
M09 adds:
bun run build:staticThat command:
generate routes
↓
discover concrete static pathnames
↓
clear dist/static
↓
render each pathname
↓
create parent directory
↓
write index.htmlThe generator logs each mapping, for example:
/ -> dist/static/index.html
/about -> dist/static/about/index.html
/counter -> dist/static/counter/index.html
/todos -> dist/static/todos/index.htmldist/ remains build output and is therefore still excluded from Git.
M09 source map
src/render/page.ts
renderRouteDocument()
shared route/data/query/HTML execution
page / redirect / notFound / error result
src/server/app.tsx
Hono HTTP adapter around renderRouteDocument()
src/ssg/static.ts
collectStaticPathnames()
staticOutputPath()
renderStaticPage()
scripts/generate-static.ts
build entry point
static path iteration
output-directory creation
HTML file writes
scripts/verify-static.ts
physical artifact verification
SSR/SSG /about equivalence
static /todos Query/Cache assertions
scripts/verify.tsx
static route discovery semantics
parameterized-route exclusion
in-memory build-time rendering assertions
package.json
build:static
verify:static
M09 steps in the complete check pipelineM09 verification
M09 is protected at two levels.
The normal executable verifier checks the framework behavior before files are written:
- static pathnames are derived from the generated route tree,
- the current set is exactly
/,/about,/counter, and/todos, - root output maps to
dist/static/index.html, - nested output maps to directory-index files,
- parameterized
$...routes are excluded without supplied build-time params, - build-time
/aboutrendering equals request-time SSR byte-for-byte, - build-time
/todoscontains prefetched query data, - build-time
/todosretains the M08 dehydrated Query/Cache bootstrap.
The static artifact verifier then runs after scripts/generate-static.ts and checks the actual files:
- every discovered concrete route produced an HTML file,
dist/static/about/index.htmlequals live SSR output for/about,dist/static/todos/index.htmlcontains the seeded Todo data,- the static Todos page has no loading placeholder,
- the static Todos page contains the
rxjs-query-stateelement, - its dehydrated state contains the
['todos']query identity.
The complete acceptance pipeline is now conceptually:
route generation
↓
strict TypeScript
↓
M01-M09 executable verification
↓
static site generation
↓
static artifact verification
↓
browser client bundlesWhat M09 establishes
M09 turns the page pipeline into a reusable execution engine rather than an HTTP-only feature.
┌── request time ──► Hono Response
route/data/JSX/HTML ─┤
└── build time ────► static index.htmlEverything above that final consumer remains the same:
routes
↓
loaders
↓
RxJS / Query/Cache execution
↓
resolved model
↓
JSX
↓
pure HTML renderingThat is the larger M09 result: SSR and SSG are execution-time policies around the same RxJS Fullstack page machine.
M10 — Runtime Adapters
M10 proves that runtime choice is outside the application machine.
By M09 the framework already had a runtime-neutral page and HTTP architecture. The remaining runtime-specific code was simply the process that hosted Hono. M10 makes that boundary explicit and verifies it across three hosting shapes:
┌── Bun server
│
Web Request ─► app.fetch ├── Cloudflare-style Worker
│
└── Node.js bridge
↓
Web ResponseEverything inside app.fetch remains the same:
Request
↓
Hono routes / API
↓
rxjs-router
↓
RxJS Query/Cache + server actions
↓
SSR / route-document rendering
↓
ResponseThe central M10 rule is: runtime adapters host the application; they do not redefine the application.
The portable application boundary is Web Request → Response
M10 introduces src/runtime/fetch.ts:
export const fetchHandler = app.fetch;That line is intentionally small. It represents the complete runtime-neutral contract:
(Request) → Response | Promise<Response>Routes do not receive a Bun request type, a Node IncomingMessage, or a Cloudflare-specific event. Hono continues to expose Web Platform request/response values to the application.
This gives the framework one portable server boundary:
runtime-specific incoming request
↓
runtime host / bridge
↓
Web Request
↓
fetchHandler
↓
Web Response
↓
runtime-specific outgoing transportOnly the outermost arrows are runtime-specific.
Bun is now an explicit adapter rather than an implicit framework dependency
M04 introduced Bun as the first runtime. M10 moves its hosting policy into:
src/runtime/bun.tsThe adapter exposes ordinary Bun server options:
{
port: 3000,
fetch: fetchHandler,
}The default port remains 3000, preserving the original development workflow.
The older src/server/bun.ts path remains as a compatibility re-export so M10 does not break the M04 entry point merely to reorganize runtime ownership.
Conceptually:
Bun
↓
createBunServerOptions()
↓
fetchHandler
↓
RxJS Fullstack applicationBun therefore remains a convenient development runtime and the build tool used by the project, but it is no longer the only demonstrated server host.
Fetch-native edge runtimes need almost no adapter
A runtime that already speaks the Web fetch contract does not require an HTTP translation layer.
M10 proves that with a Cloudflare Workers Module Worker shape:
export const cloudflareWorker = {
fetch: fetchHandler,
};The flow is:
Cloudflare-style fetch event
↓
worker.fetch(Request)
↓
fetchHandler(Request)
↓
Hono / RxJS Fullstack
↓
ResponseThe important point is not Cloudflare-specific functionality. It is the opposite: no Cloudflare-specific application functionality is needed for the core HTTP path.
The worker entry is built with a browser/edge target. That build is an architectural test: if Node-only modules accidentally leak into the fetch-native runtime dependency graph, the worker build should expose the portability violation.
Node.js uses a transport bridge, not a second application API
Node's traditional HTTP server API is not itself the Web fetch server contract, so M10 uses Hono's official Node adapter:
@hono/node-serversrc/runtime/node-adapter.ts performs only this translation:
Node HTTP server
↓
@hono/node-server
↓
fetchHandler
↓
Web Response
↓
Node HTTP responseThe adapter never calls route loaders directly and never knows about QueryClient, server actions, JSX, SSR, or SSG.
That separation is significant. A Node deployment does not become a parallel implementation of rxjs-fullstack; it is simply another host around the same application function.
The executable Node entry owns process policy
src/runtime/node.ts is the process-level Node entry point.
It owns runtime concerns that genuinely belong at the process boundary:
- reading
PORT, - validating the configured port,
- starting the Node server,
- logging the listening address,
- handling
SIGINTandSIGTERM, - closing the server during shutdown.
Those concerns do not leak into Hono routes or RxJS application pipelines.
The default remains:
port = 3000and deployments can override it through:
PORT=8080 ...Runtime choice does not change RxJS execution semantics
M10 introduces no new Observable type, scheduler, cancellation mechanism, query implementation, or action implementation.
For example, a browser action still flows as:
subscribe
↓
fromFetch
↓
HTTP request
↓
runtime host
↓
Hono server-action route
↓
executeServerAction$()
↓
Observable handlerWhether the server process is hosted by Bun or Node does not alter the exhaustMap, concatMap, unsubscription, query invalidation, or handler Observable semantics chosen by application code.
Likewise, SSR remains:
Request
↓
runtime adapter
↓
Hono
↓
renderRouteDocument()
↓
route loader / QueryClient
↓
renderToString()
↓
ResponseThe runtime adapter does not insert a new lifecycle into the middle of the RxJS machine.
M09 and M10 define two independent outer policies
M09 separated when a page is rendered:
request time → SSR
build time → SSGM10 separates what hosts request-time execution:
Bun → fetch-native host
Cloudflare → fetch-native host
Node → Node HTTP bridge → fetch handlerThese axes are independent:
execution time
request build
│ │
▼ ▼
application renderRouteDocument renderRouteDocument
│ │
▼ ▼
consumer Web Response static HTML file
│
┌─────────┼─────────┐
▼ ▼ ▼
Bun Node WorkerThis is useful because deployment concerns do not need to infect rendering or dataflow semantics.
Runtime bundles are part of the architecture check
M10 adds two explicit server build products:
dist/runtime/node.js
dist/runtime/cloudflare.jsThe Node entry is built with a Node target. The Worker entry is built with a browser/edge target.
This gives the repository a practical dependency-boundary check:
Node-only dependency
│
├── allowed in Node adapter graph
│
└── must not leak into worker graphThe application itself remains shared underneath both builds.
The Node proof runs under the actual node executable
M10 does not consider a successful TypeScript build sufficient proof of Node portability.
scripts/verify-runtimes.ts launches:
node dist/runtime/node.jsas a child process on an isolated test port.
The verifier then makes real HTTP requests to that process:
Node process
↓
GET /health
↓
200 {"ok":true}
Node process
↓
GET /about
↓
SSR HTMLIt compares the Node /about HTML byte-for-byte with the output obtained through the runtime-neutral fetchHandler.
That assertion is important:
Node transport result == fetch-native application resultThe Node adapter is therefore tested as a transparent transport bridge rather than trusted merely because it compiles.
Fetch-native adapters are compared directly
The same runtime verifier calls:
fetchHandler
bunRuntime.fetch
cloudflareRuntime.fetchwith Web Request values.
It checks that the adapters preserve the same application responses, including deterministic SSR output for /about.
The invariant is:
same Request
↓
different runtime host
↓
same application semantics
↓
equivalent ResponseM10 source map
src/runtime/fetch.ts
runtime-neutral app.fetch export
canonical Web Request → Response boundary
src/runtime/bun.ts
Bun port + fetch server options
src/runtime/cloudflare.ts
fetch-native Module Worker shape
src/runtime/node-adapter.ts
@hono/node-server bridge
src/runtime/node.ts
executable Node process entry
PORT + shutdown policy
src/server/bun.ts
M04 compatibility re-export
scripts/verify-runtimes.ts
fetch-native adapter comparison
real Node child-process smoke test
SSR response equivalence
package.json
start:bun / start:node
build:node / build:worker
verify:runtimes
runtime checks in the complete acceptance gateM10 verification
M10 is verified at several levels.
TypeScript checks that the runtime boundaries and adapters agree structurally.
The build gate proves that:
- the Node runtime dependency graph bundles for a Node target,
- the Cloudflare-style runtime dependency graph bundles for a browser/edge target,
- the existing browser clients still bundle independently.
The runtime verifier proves that:
- the shared
fetchHandlerserves/health, - the Bun adapter exposes that same handler,
- the Cloudflare-style adapter exposes that same handler,
- fetch-native adapters produce equivalent application responses,
- deterministic
/aboutSSR is unchanged by the Worker wrapper, dist/runtime/node.jsstarts under the actualnodeexecutable,- the Node process serves
/health, - the Node process serves SSR routes,
- Node
/aboutHTML is byte-for-byte identical to the runtime-neutral fetch result, - the Node process can be terminated cleanly after verification.
The complete acceptance pipeline is now conceptually:
route generation
↓
strict TypeScript
↓
M01-M10 executable verification
↓
static site generation + verification
↓
Node runtime build
↓
edge/Worker runtime build
↓
actual runtime adapter verification
↓
browser client bundlesWhat M10 establishes
M10 turns the M04 runtime intention into an executable architectural invariant:
┌── Bun
│
RxJS Fullstack app.fetch ──┼── fetch-native edge runtime
│
└── Node HTTP bridgeThe framework does not ask application code which runtime it is running on in order to route, query, render, or execute actions.
Instead:
runtime chooses hosting policy
application chooses dataflow policyThat is the larger M10 result: Bun, Node.js, and fetch-native edge runtimes are hosts around the same RxJS Fullstack machine, not separate versions of the framework.
M11 — Database Integration
M11 replaces the process-local Todo array with an explicit persistence boundary while preserving the RxJS Fullstack machine built in M01–M10.
The database is deliberately treated as another effect dependency. It does not become a new state-management system, router, action mechanism, or rendering lifecycle.
Before M11, the server-side Todo source of truth was a module-local array:
GET /api/todos ─────────────► in-process Todo[]
server action ──────────────► addTodo()
↓
in-process Todo[]M11 changes the persistence boundary to:
TodoRepository
/ \
/ \
▼ ▼
memory repository PGlite/Postgres
tests / portable Bun / Node
compositions persistent hostThe application sees only the repository contract. The composition root decides which implementation supplies the effect.
The central M11 rule is: persistence changes where Todo data is stored; it does not change how values move through RxJS Fullstack.
The repository is a typed effect port
The application-facing database contract lives in:
src/database/todos-repository.tsIts essential shape is:
interface TodoRepository {
list$(options?): Observable<readonly Todo[]>;
create$(input, options?): Observable<Todo>;
close(): Promise<void>;
}This contract says what packages move through the persistence boundary:
list$ : () → Observable<readonly Todo[]>
create$ : CreateTodoInput → Observable<Todo>It deliberately does not expose:
- PGlite objects,
- SQL result objects,
- Hono contexts,
- QueryClient,
- browser state,
- server-action references,
- runtime-specific APIs.
The database adapter knows persistence. The rest of the framework continues to know only domain values and Observables.
Database reads and writes remain cold
Repository operations are descriptions until subscribed.
For a write:
const save$ = repository.create$(input)
│
│ no subscription
▼
no SQL
subscribe
↓
INSERT starts
↓
Todo emitted
↓
completeBoth the memory repository and the PGlite repository implement read/write operations with defer(...).
This means constructing a database Observable does not eagerly perform the database effect. The same RxJS execution rule that applies to HTTP actions and other effects now applies to persistence.
Repository initialization is intentionally different from repository operations
M11 separates two lifetimes that should not be confused.
Repository initialization belongs to the composition root:
process starts
↓
open database
↓
apply migrations
↓
seed if empty
↓
construct application
↓
start accepting requestsIndividual reads and writes remain cold:
request arrives
↓
repository.list$() or repository.create$()
↓
Observable description
↓
subscription at the HTTP/action boundary
↓
SQL executesOpening the database and making the schema ready before the server accepts traffic is intentional. Laziness applies to application database effects, not to pretending that a server can use an unopened database.
PGlite is the reference Postgres adapter
M11 uses PGlite as the reference embedded Postgres implementation for Bun and Node.js.
The adapter lives in:
src/database/pglite-todos-repository.tsThe important architectural choice is not that every rxjs-fullstack application must use PGlite. It is that a concrete Postgres-compatible database can satisfy the same TodoRepository port without leaking database-specific APIs upward.
The current persistent composition is:
Bun / Node process
↓
createPgliteTodoRepository()
↓
TodoRepository
↓
createApp({ todosRepository })
↓
Hono / RxJS FullstackA later PostgreSQL server, SQLite adapter, remote database service, or application-specific persistence layer can implement the same role without changing the browser query/action machine.
SQL stays visible
M11 deliberately does not add an ORM.
The Todo read is conceptually:
SELECT id, title, done
FROM todos
ORDER BY idThe write is a parameterized statement:
INSERT INTO todos (title, done)
VALUES ($1, FALSE)
RETURNING id, title, doneThe domain value is passed separately from the SQL text:
CreateTodoInput.title
↓
parameter $1
↓
Postgres insert
↓
Todo row
↓
TodoKeeping SQL explicit matches the rest of the project: mechanisms remain visible rather than being renamed or hidden behind domain-sounding wrappers.
Schema changes are versioned migrations
The database adapter does not assume that the schema already exists.
M11 adds a migration ledger:
rxjs_fullstack_migrationsThe first application migration is:
version 1 — create_todoswhich creates:
todos
├── id SERIAL PRIMARY KEY
├── title TEXT NOT NULL
└── done BOOLEAN NOT NULL DEFAULT FALSEInitialization proceeds as:
open PGlite
↓
ensure migration ledger exists
↓
read applied versions
↓
for each unapplied migration
↓
transaction
├── apply SQL
└── record version/nameThe schema history is therefore explicit and repeatable instead of being inferred from application objects at runtime.
Seed data is idempotent
The two canonical Todo examples remain useful as the initial project dataset.
M11 moves them into a shared seed definition and inserts them only when the Todo table is empty.
fresh database
↓
COUNT(*) = 0
↓
insert canonical seed rows
reopened database
↓
COUNT(*) > 0
↓
do not seed againThis matters because persistence must survive restart without duplicating the example data every time the application boots.
The old array store disappears from the active architecture
M07's historical chapter documents the original src/server/todos-store.ts because that was the implementation at that milestone.
M11 removes that file from the current source tree.
The transition is:
M07–M10
server/api.ts
↓
todos-store.ts
↓
module-local array
M11
server/api.ts
↓
TodoRepository
↓
selected repository adapterThe historical README remains intact, but the current application no longer imports a persistence implementation directly from the server API.
createApi() receives persistence instead of importing it
The API boundary is now constructed with:
createApi({ todosRepository })The read path becomes:
GET /api/todos
↓
todosRepository.list$({ signal })
↓
firstValueFrom(...)
↓
readonly Todo[]
↓
JSON ResponseThe write path becomes:
POST /api/actions/todos.create
↓
server-action validation
↓
todosRepository.create$(inpu