Last updated:
@lunora/angular is the Angular adapter, a thin, idiomatic layer over
@lunora/client, which owns the WebSocket transport,
subscription registry, offline queue, and delta-merge. Angular signals map
directly onto Lunora's per-subscription deltas, so a live query is just a
signal the WebSocket writes to.
This page is the task-oriented guide; for the full reference see
@lunora/angular.
Deliberately small surface. The Angular adapter ships the core four: DI wiring, liveQuery, mutate, connectionStatus. Parity extras the other
adapters have (paginated queries, bound optimistic-mutation handles, presence) aren't here yet. Reactive loaders are, via @lunora/angular/server +
hydratePreloaded (see below). For adapter maturity across frameworks see Bring your framework.
Install
pnpm add @lunora/angular@angular/core is a peer dependency; the host app supplies the Angular
runtime.
Provide the client
Add provideLunora to your application config. It defaults to the page origin
(the single-worker deploy where /_lunora/ws loops back into the app's own
worker); pass options to point at a remote URL, or hand it an
already-constructed LunoraClient to share one instance.
import type { ApplicationConfig } from "@angular/core";
import { provideLunora } from "@lunora/angular";
export const appConfig: ApplicationConfig = {
providers: [provideLunora(/* { url: "https://api.example.com" } */)],
};Every reactive primitive resolves the client from the LUNORA_CLIENT injection
token, which has a root-scoped default (a lazily-built same-origin browser
client), so things work even without provideLunora. Call
injectLunoraClient() inside an injection context (a component/service field
initializer or constructor) when you need the client for imperative calls.
Live queries
liveQuery(fn, args) opens a subscription and mirrors every server delta into
a Signal. It reads undefined until the first frame lands, and tears down
automatically when the owning component is destroyed (DestroyRef.onDestroy).
Call it from an injection context: a field initializer or constructor.
import { Component } from "@angular/core";
import { liveQuery } from "@lunora/angular";
import { api } from "../lunora/_generated/api";
@Component({
selector: "app-messages",
standalone: true,
template: `@for (m of messages()?.messages ?? []; track m.id) {
<p>{{ m.text }}</p>
}`,
})
export class MessagesComponent {
readonly messages = liveQuery(api.messages.list, { channelId: "general" });
}Pass "skip" as the args to short-circuit: no network call, no socket, and the
signal stays undefined.
readonly profile = liveQuery(api.users.me, signedIn ? {} : "skip");Unlike the Vue/Solid adapters, args is a plain value here, not a reactive
source, and changing it after the fact does not re-subscribe.
Mutations
mutate(fn, args, options) runs a mutation and resolves with the server
result. Capture the client in a field first: mutations usually fire from event
handlers, which run outside an injection context.
import { Component } from "@angular/core";
import { injectLunoraClient, mutate } from "@lunora/angular";
import { api } from "../lunora/_generated/api";
@Component({/* … */})
export class Composer {
private readonly client = injectLunoraClient();
send(text: string) {
return mutate(api.messages.send, { text }, { client: this.client });
}
}Optimistic updates (optimistic / optimisticUpdate) and the offline queue
pass straight through to client.mutation.
Connection status
import { connectionStatus } from "@lunora/angular";
readonly status = connectionStatus(); // Signal<"idle" | "connecting" | "connected" | "offline">The aggregate live-socket status across all shard connections. Render it in a header badge or gate a "reconnecting…" banner.
Reactive loaders
Run the query on the server with the socket-free @lunora/angular/server entry
inside an Analog server route (or any Angular SSR loader), hand the serializable
Preloaded token to the client, and hydratePreloaded seeds the signal
synchronously. The first read returns the server value with no loading
flash, then the live subscription attaches after mount.
import { createServerClient, preloadQuery } from "@lunora/angular/server";
import { api } from "./lunora/_generated/api";
// Per request — never reuse a client across requests (token leakage).
const client = createServerClient({ url: process.env["LUNORA_URL"]!, token });
const preloaded = await preloadQuery(client, api.posts.list, {});import { Component, inject } from "@angular/core";
import { hydratePreloaded } from "@lunora/angular";
import type { Preloaded } from "@lunora/angular";
import { POSTS_PRELOADED } from "./posts-preloaded-token"; // however your route hands off the token
@Component({/* … */})
export class Posts {
// Field initializers run in an injection context, so `inject()` (used here
// by `hydratePreloaded` for its `DestroyRef`) resolves correctly. Seeded
// synchronously from SSR on the first read, then live.
readonly posts = hydratePreloaded(inject(POSTS_PRELOADED));
}@Input-bound values aren't available until after field initializers run, so
don't read this.somePreloadedInput inside one; inject the token instead (a
route resolver, a DI provider seeded by the server route, or a constructor
parameter) as shown above.
hydratePreloaded opens no WebSocket and touches no browser globals, so it is
safe to import into a server render pass. See
Reactive loaders for the full handoff.