Server-rendered islands
A live island is a normal server-rendered region whose initial resource value is embedded beside the HTML. The browser hydrates that value and subscribes by hash, so the first live response does not send the same data twice.
There is no island runtime. The template decides which markup is an island;
liveIsland() only serializes the payload needed by the client.
1. Declare the live resource
import { Controller, Get } from '@carno.js/core';
import { Live } from '@carno.js/live';
@Controller('/notes')
export class NotesController {
@Get('/')
@Live({ key: 'id', shared: 'public' })
async list() {
const notes = await Note.find({});
return notes.map(note => ({ id: note.id, body: note.body }));
}
}
key: 'id' lets subsequent writes become keyed upsert, remove, and
order operations instead of replacing the whole collection.
2. Prefetch from the page controller
LiveService.prefetch() computes the resource without creating a subscription
or registering a dependency-graph instance. Pass the resulting payload to the
view:
import { Controller, Get } from '@carno.js/core';
import { LiveService } from '@carno.js/live';
import { ViewService } from '@carno.js/views';
@Controller('/dashboard')
export class DashboardController {
constructor(
private readonly live: LiveService,
private readonly views: ViewService
) {}
@Get('/notes')
async notesPage() {
const payload = await this.live.prefetch('NotesController.list', {
params: {},
query: {}
});
return this.views.html('notes', { payload });
}
}
The prefetch and the live subscription use the same resource id and canonical inputs. The hash in the payload is calculated from the same value the client will display.
Prefetch runs the same route middleware and DTO-validation pipeline as HTTP. For an authenticated page, pass the page request credentials as the optional third argument; omitting them makes protected middleware fail closed:
const payload = await this.live.prefetch('NotesController.list', inputs, {
headers: request.headers,
scope: { principal: user.id, tenant: user.tenantId }
});
3. Embed it in the Handlebars template
Render the data normally for the first request, and call the helper next to the region that will later be subscribed:
<h1>Notes</h1>
<section id="notes-island">
<ul>
{{#each payload.data}}
<li data-note-id="{{id}}">{{body}}</li>
{{/each}}
</ul>
{{{liveIsland payload}}}
</section>
<p id="static-help">This text was rendered once and is not live.</p>
ViewService registers liveIsland as a helper by default. The triple braces
are intentional: the helper returns a <script> element and must not be
HTML-escaped. The helper also escapes parser-sensitive sequences inside the
JSON payload so database text cannot terminate the script element.
The helper also accepts an array of payloads when one page has several independent live regions. Pass that array from the page controller using the normal data-shaping code for your template engine; each payload remains an independent resource/input identity.
4. Hydrate on the client
readHydrationPayload() finds every script[data-carno-live] element and
builds the map expected by LiveClient:
import { LiveClient, readHydrationPayload } from '@carno.js/live/client';
import { routes } from './generated/app';
const websocketProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const client = new LiveClient({
url: `${websocketProtocol}//${location.host}/live`,
hydrate: readHydrationPayload(),
routes
});
const notes = client.store('NotesController.list', {
params: {},
query: {}
});
The store starts with pending: false and the prefetched data. When it
subscribes, it sends the payload hash. If the server agrees, it answers with
current and no snapshot body; if a write happened between page rendering and
subscription, it sends a snapshot and the client corrects the screen.
The static help text above never becomes part of the live store. Only code that
subscribes to NotesController.list updates the notes region, and a page with
JavaScript disabled still has the server-rendered list.