Guide · @staticflux/router · @staticflux/urlstate · @staticflux/handoff
Routing & URL state
Navigation is an async, cancellable pipeline of named stages; the URL is a set of declared parts, each with a kind and an owner. The router never renders and never interprets requirements — plugins do.
#The pipeline
resolve → exit → availability → transient → pre-auth → auth-boundary → post-auth
→ authorize → load → prefetch → enter → render → commit
- Each stage is a span; plugins attach with
stagePluginand returnproceed(),redirect(target),block(error)orcancel(reason)as aResult. - The newest navigation cancels older ones; uninterruptible guards hold cancellation until they settle.
- Back / Forward run the same pipeline (Navigation API, History API fallback, memory history for embedded hosts) and are reverted if blocked.
- Code for a route loads only after
auth-boundaryandauthorizepass — a denied feature’s chunk is never fetched.
#Typed routes and capabilities
export const DocRoute = defineRoute({
id: 'studio/doc',
parent: StudioRoute, // nested: per-segment guards and chunks
path: 'doc/:id', // params inferred from the template
search: { tab: searchParam.enum(['content', 'comments', 'history'], { default: 'content', kind: 'view' }) },
feature: bindCapability('studio/editor', [
{ feature: Features.studioDesktop, modes: ['expanded', 'medium'] },
{ feature: Features.studioMobile, modes: ['compact'] }, // per-mode variants are features
]),
});
await router.navigate({ to: DocRoute, params: { id: '42' }, search: { tab: 'comments' } });
view: true routes and view-kind search params run a light pipeline (exit → enter → commit) — wizard steps and tabs never reactivate the feature. Route requirements are an open interface (RouteRequirements, declaration-merged): auth and gate plugins read ctx.requires.
#Exit guards
router.guardExit(DocRoute, guard(async (_navigation, signal) => {
const flushed = await doc.flush(signal);
return flushed.ok ? proceed() : err(flushed.error);
}, { uninterruptible: true, name: 'studio/save-before-leave' }), context.uiScope);
#Routable dialogs
Dialogs live at /d/<key> (app level) or <feature path>/d/<key> and run exit → availability → authorize → load → enter → commit. defineDialog({ id, key, owner, requires }) on the router side, a routable surface on the messaging side; dismissing goes back when opened in-app and replaces to the parent when arrived via deep link.
#URL state: kinds and owners
| Kind | Example | Owner | Handled by |
|---|---|---|---|
| path | /studio/doc/42 | router | full pipeline |
| view | ?tab=comments | feature store | createUrlSync |
| dialog | …/d/share | messaging | dialog bridge |
| intent | ?panel=chat&prompt=… | declaring feature | createIntentService |
| sticky | ?workspace=…&lang=… | declaring package | createUrlSync |
| hidden | scroll, drafts | store / handoff | never in the URL |
declare module '@staticflux/core' {
interface Registry { urlParams: { tab: 'content' | 'comments' | 'history'; prompt: string; panel: 'chat' } }
}
export const TabBinding = defineUrlBinding({
key: 'tab', kind: 'view', codec: codec.enum(['content', 'comments', 'history']),
owner: 'studio/document-store', default: 'content',
});
urlSync.bind(TabBinding, boxAdapter(store.tab), scope); // URL ⇄ store, batched per frame
// consume-once intent: digested at resolve, delivered after authorize, stripped via replace
export const ChatPromptIntent = defineIntent({
id: 'agent/chat-prompt', owner: 'agent/chat',
params: { panel: codec.enum(['chat']), prompt: codec.string({ maxLength: 2000 }) }, required: ['panel'],
});
intents.consume(ChatPromptIntent, (delivery) => chat.setDraft(delivery.params.prompt ?? ''), scope);
AGENTS.md on GitHub: @staticflux/router · @staticflux/urlstate · @staticflux/handoff