clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / package / runtime / inspector.ts
inspector.ts 10.0K
1export const DEVELOPMENT_INSPECTOR_MARKER = "lofi-development-inspector";2// Package-owned development inspector.3import type { RuntimeStartupFailureCode } from "./startup-recovery.ts";45export type InspectorSnapshot = {6 identity: {7 state: "device-local key active" | "anonymous" | "external" | "unavailable";8 backup: "recovery phrase" | "local-only" | "unavailable";9 };10 storage: {11 driver: "persistent requested" | "persistent open" | "failed";12 persistence: "granted" | "not granted" | "unavailable" | "error";13 fallback: "none" | "explicit memory" | "unavailable";14 startupFailure: RuntimeStartupFailureCode | "none";15 };16 sync: {17 mode: "local-only" | "managed configured";18 transport: "paused by inspector" | "live detail unavailable" | "not configured";19 pendingLocalWrites: number;20 pendingGlobalWrites: number;21 lastWrite: "none" | "local" | "global" | "failed";22 };23 runtime: {24 clients: number;25 consumers: number;26 vendorSubscriptions: number;27 mutationListeners: number;28 mutationErrors: number;29 };30 lifecycle: {31 mode: "local-only" | "managed";32 status: "idle" | "offline-deferred" | "recovering" | "completed" | "failed";33 attempts: number;34 lastReason: "none" | "pageshow" | "visibilitychange" | "online";35 transportDetail: "live detail unavailable" | "not configured";36 };37 multiTab: {38 role: "unavailable" | "leader" | "follower";39 detail: string;40 };41};4243export type InspectorAdapter = {44 readSnapshot(): InspectorSnapshot | Promise<InspectorSnapshot>;45 subscribe(listener: () => void): () => void;46 setTransportPaused(paused: boolean): Promise<void>;47 restartClient(): Promise<void>;48 clearLocalReplica(): Promise<void>;49};5051export type InspectorRow = {52 label: string;53 value: string;54};5556export function inspectorRows(snapshot: InspectorSnapshot): InspectorRow[] {57 return [58 { label: "Identity", value: snapshot.identity.state },59 { label: "Auth backup", value: snapshot.identity.backup },60 { label: "Storage driver", value: snapshot.storage.driver },61 { label: "Persistence", value: snapshot.storage.persistence },62 { label: "Storage fallback", value: snapshot.storage.fallback },63 { label: "Storage startup", value: snapshot.storage.startupFailure },64 { label: "Sync mode", value: snapshot.sync.mode },65 { label: "Transport", value: snapshot.sync.transport },66 { label: "Pending lofi local", value: String(snapshot.sync.pendingLocalWrites) },67 { label: "Pending lofi global", value: String(snapshot.sync.pendingGlobalWrites) },68 { label: "Last write", value: snapshot.sync.lastWrite },69 { label: "Clients", value: String(snapshot.runtime.clients) },70 { label: "Consumers", value: String(snapshot.runtime.consumers) },71 { label: "Vendor subscriptions", value: String(snapshot.runtime.vendorSubscriptions) },72 { label: "Mutation listeners", value: String(snapshot.runtime.mutationListeners) },73 { label: "Mutation errors", value: String(snapshot.runtime.mutationErrors) },74 { label: "Lifecycle mode", value: snapshot.lifecycle.mode },75 { label: "Lifecycle action", value: snapshot.lifecycle.status },76 { label: "Lifecycle attempts", value: String(snapshot.lifecycle.attempts) },77 { label: "Lifecycle reason", value: snapshot.lifecycle.lastReason },78 { label: "Lifecycle transport", value: snapshot.lifecycle.transportDetail },79 { label: "Multi-tab role", value: snapshot.multiTab.role },80 { label: "Multi-tab detail", value: snapshot.multiTab.detail },81 ];82}8384function element<K extends keyof HTMLElementTagNameMap>(85 document: Document,86 name: K,87 text?: string,88): HTMLElementTagNameMap[K] {89 const node = document.createElement(name);90 if (text !== undefined) node.textContent = text;91 return node;92}9394export function mountInspector(95 adapter: InspectorAdapter,96 options: { document?: Document } = {},97): { element: HTMLElement; dispose(): void } {98 const document = options.document ?? globalThis.document;99 if (!document?.body) {100 throw new Error("lofi inspector requires a browser document body");101 }102103 const host = element(document, "aside");104 host.dataset.lofiInspector = DEVELOPMENT_INSPECTOR_MARKER;105 host.setAttribute("aria-label", "lofi developer inspector");106 const shadow = host.attachShadow({ mode: "open" });107 const style = element(document, "style");108 style.textContent = `109 :host { all: initial; color: #ecf7f1; font: 13px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; }110 section { position: fixed; right: 12px; bottom: 12px; z-index: 2147483647; width: min(390px, calc(100vw - 24px)); max-height: min(660px, calc(100vh - 24px)); overflow: auto; border: 1px solid #587066; border-radius: 10px; background: #101b17f2; box-shadow: 0 18px 54px #0008; }111 header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; position: sticky; top: 0; background: #101b17; }112 h2 { font: 700 13px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; margin: 0; }113 button { border: 1px solid #6f8d80; border-radius: 6px; color: inherit; background: #1b3028; padding: 6px 8px; font: inherit; cursor: pointer; }114 button:disabled { cursor: wait; opacity: .55; }115 [data-body][hidden] { display: none; }116 dl { display: grid; grid-template-columns: minmax(120px, .8fr) minmax(0, 1.2fr); margin: 0; padding: 0 12px 10px; }117 dt, dd { border-top: 1px solid #ffffff18; margin: 0; padding: 5px 0; overflow-wrap: anywhere; }118 dt { color: #a8c3b7; }119 .actions { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 12px; }120 [role=status] { color: #f0c47c; min-height: 1.4em; margin: 0; padding: 0 12px 10px; }121 `;122 const panel = element(document, "section");123 const header = element(document, "header");124 const title = element(document, "h2", "lofi dev inspector");125 const toggle = element(document, "button", "Hide");126 toggle.type = "button";127 toggle.setAttribute("aria-expanded", "true");128 const body = element(document, "div");129 body.dataset.body = "";130 const list = element(document, "dl");131 const actions = element(document, "div");132 actions.className = "actions";133 const offline = element(document, "button", "Simulate transport offline");134 const restart = element(document, "button", "Restart client");135 const clear = element(document, "button", "Clear local replica");136 const status = element(document, "p");137 status.setAttribute("role", "status");138 for (const button of [offline, restart, clear]) {139 button.type = "button";140 actions.append(button);141 }142 header.append(title, toggle);143 body.append(list, actions, status);144 panel.append(header, body);145 shadow.append(style, panel);146 document.body.append(host);147148 let active = true;149 let busy = false;150 let current: InspectorSnapshot | null = null;151 let refreshGeneration = 0;152 const setBusy = (value: boolean) => {153 busy = value;154 for (const button of [offline, restart, clear]) button.disabled = value;155 };156 const render = (snapshot: InspectorSnapshot) => {157 current = snapshot;158 list.replaceChildren();159 for (const row of inspectorRows(snapshot)) {160 list.append(element(document, "dt", row.label), element(document, "dd", row.value));161 }162 offline.textContent = snapshot.sync.transport === "paused by inspector"163 ? "Resume cloud transport"164 : snapshot.sync.mode === "local-only"165 ? "Transport pause unavailable in local-only"166 : "Pause cloud transport";167 offline.disabled = busy || snapshot.sync.mode === "local-only";168 };169 const refresh = async () => {170 const generation = ++refreshGeneration;171 const snapshot = await adapter.readSnapshot();172 if (active && generation === refreshGeneration) render(snapshot);173 };174 const runAction = async (name: string, action: () => Promise<void>) => {175 if (busy) return;176 setBusy(true);177 status.textContent = `${name}…`;178 try {179 await action();180 status.textContent = `${name} complete.`;181 await refresh();182 } catch {183 status.textContent =184 `${name} failed. Run deno task doctor, apply the first action, then retry.`;185 } finally {186 setBusy(false);187 }188 };189190 toggle.addEventListener("click", () => {191 body.hidden = !body.hidden;192 toggle.textContent = body.hidden ? "Show" : "Hide";193 toggle.setAttribute("aria-expanded", String(!body.hidden));194 });195 offline.addEventListener("click", () => {196 const nextPaused = current?.sync.transport !== "paused by inspector";197 void runAction(198 nextPaused ? "Cloud transport pause" : "Cloud transport resume",199 () => adapter.setTransportPaused(nextPaused),200 );201 });202 restart.addEventListener("click", () => void runAction("Client restart", adapter.restartClient));203 clear.addEventListener("click", () => {204 const confirmed = globalThis.confirm?.(205 "Clear this development replica? The device-local identity is preserved, but unsynced local data is deleted.",206 ) === true;207 if (confirmed) void runAction("Local replica clear", adapter.clearLocalReplica);208 });209210 // Diagnostics ticks arrive faster than snapshots resolve; coalesce bursts211 // into one in-flight snapshot plus at most one queued follow-up, and keep212 // every recurring rejection handled — previously only the initial refresh213 // was caught, so a runtime startup failure produced repeated unhandled214 // rejections in development.215 let refreshRunning = false;216 let refreshQueued = false;217 const reportRefreshFailure = () => {218 status.textContent =219 "Inspector snapshot failed. Run deno task doctor, apply the first action, then reload.";220 };221 const scheduleRefresh = () => {222 if (!active) return;223 if (refreshRunning) {224 refreshQueued = true;225 return;226 }227 refreshRunning = true;228 void refresh().catch(reportRefreshFailure).finally(() => {229 refreshRunning = false;230 if (refreshQueued) {231 refreshQueued = false;232 if (active) scheduleRefresh();233 }234 });235 };236 const unsubscribe = adapter.subscribe(scheduleRefresh);237 scheduleRefresh();238 return {239 element: host,240 dispose() {241 if (!active) return;242 active = false;243 unsubscribe();244 host.remove();245 },246 };247}