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

Text
resolve → exit → availability → transient → pre-auth → auth-boundary → post-auth
        → authorize → load → prefetch → enter → render → commit
  • Each stage is a span; plugins attach with stagePlugin and return proceed(), redirect(target), block(error) or cancel(reason) as a Result.
  • 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-boundary and authorize pass — a denied feature’s chunk is never fetched.

Typed routes and capabilities

TypeScript
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

TypeScript
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

KindExampleOwnerHandled by
path/studio/doc/42routerfull pipeline
view?tab=commentsfeature storecreateUrlSync
dialog…/d/sharemessagingdialog bridge
intent?panel=chat&prompt=…declaring featurecreateIntentService
sticky?workspace=…&lang=…declaring packagecreateUrlSync
hiddenscroll, draftsstore / handoffnever in the URL
TypeScript
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