Skip to main content

Typed subscriptions

useLive('CardsController.list') works, and the string tells the compiler nothing. It does not say what comes back, and a typo becomes an unknown_resource error in production rather than a red squiggle in the editor.

A route descriptor fixes that. It is one object per route, emitted by the client codegen, carrying the method, the path, the resource identifier when the route is live, and — as a phantom type — everything the route accepts and returns. It serves three uses: calling the route over HTTP, subscribing to it, and prefetching its value for server-side rendering with LiveService.prefetch().

What the codegen emits

The generated file gains a routes tree alongside the paths tree it already had:

export const routes = {
cards: {
list: { method: "get", path: "/cards", resourceId: "CardsController.list", live: { shared: "tenant", key: "id" } } as RouteDescriptor<App["cards"]["get"]>,
create: { method: "post", path: "/cards" } as RouteDescriptor<App["cards"]["post"]>,
},
} as const;

Two things are deliberately absent. dependsOn never appears: it is how the server decides what to recompute, and the browser has no use for it. And the controller name appears only on @Live() routes — the subscription protocol addresses a resource as Controller.handler, so those routes have to carry it, while everything else keeps the client ignorant of how the server organises its classes. create above is a plain POST and has neither resourceId nor live.

One object, two uses

createApi walks the tree and replaces each descriptor with a function that still carries the descriptor's own fields:

import { createApi } from '@carno.js/client';
import { routes } from './generated/app';

export const api = createApi(routes, { baseUrl: 'http://localhost:3000' });
// An HTTP call.
const { data } = await api.cards.list({ query: { status: 'open' } });

// A subscription to the same route, from the same object.
const cards = useLive(api.cards.list, { query: { status: 'open' } });

cards.data is Card[] | undefined, inferred from the handler's return type. Passing a route without @Live() to useLive throws with a message naming the route, rather than failing at the server as an unknown resource.

The input is { params, query, body } rather than one flat object. The flat form cannot distinguish /cards/:id from ?id= when the two names coincide, and the structured form is the same shape the subscription already puts on the wire.

createApi is additive. The existing client<App>(baseUrl) proxy is unchanged and keeps working; nothing forces a migration.

Optimistic updates

An action can show its expected result before the server confirms it:

const create = useLiveAction(api.cards.create, {
optimistic: [
{ on: api.cards.list, apply: (draft, dto) => draft.push({ id: 'temp', title: dto.title }) },
],
});

on names the resource the projection targets, and that is what makes draft typed: here it is Card[] and dto is the action's own payload type, both inferred. Without naming a target the draft could only be any, and an optimistic update on any is a guess the compiler cannot check. One action may list several targets, or none.

apply mutates a draft — push, splice, assign — rather than returning a new value.

What makes this safe is where the overlay lives. It is a projection above the confirmed snapshot, never a write into it. A server patch that arrives while the action is still in flight applies to the snapshot underneath, and the overlay is re-projected on top of the result. The screen never falls back to a state the server does not know about, and a failed optimistic update cannot corrupt the real data.

The overlay is removed when the action settles, success or failure. On success there is a short window — from the HTTP response to the server's patch arriving — in which the screen shows the last confirmed state without the optimistic row. Closing that window entirely would require the action's response to carry the revision it produced, which the protocol does not do today.

Build-time validation

The server refuses a badly declared live resource at startup. That is late: by then you have written the handler, run the codegen and built the screen. The scanner now reports the same rules while you are still typing, with the file and line:

  • Wrong verb. replace carries @Live() on @PUT(). Subscribing re-runs the handler whenever the data changes, so it has to be idempotent: only @Get() and @Post() may be live.
  • Request-bound parameters. withRequest is a live resource and takes @Req(). There is no request, no header set and no middleware locals during a recompute.
  • Inputs that cannot be hashed. withDate takes 'since: Date', which cannot be canonicalized into an instance key. Live inputs must be JSON.
  • A keyed collection with no key. needsKey returns rows with an 'id', but its @Live() declares no 'key'. Patches would be positional: inserting at the top rebuilds the whole list, so the user loses input focus and animations restart.
  • Two resources with one identifier. Two live resources share the id 'BoardController.list'. Rename one of the controllers.

Same rule, two moments. The build-time check is a warning and never blocks the codegen; the startup check still refuses to boot.