clone

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

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