diff --git a/.claude/skills/devtools-docs/SKILL.md b/.claude/skills/devtools-docs/SKILL.md index 0bc97ec4..04789d4b 100644 --- a/.claude/skills/devtools-docs/SKILL.md +++ b/.claude/skills/devtools-docs/SKILL.md @@ -130,7 +130,7 @@ Rules: | Page | Source of truth | | ------------------ | ----------------------------------------------------------------------------------------------------------- | | inspectors/\* | `app/src/pages/*.ts` (the tab) and `packages/devtools/src/*` (collectors, actions) | -| agents/\* | `packages/devtools/src/devframe.ts`, `rpc/*.ts`, `rpc/analog-register.ts`, Devframe built-ins | +| agents/\* | `packages/devtools/src/devframe.ts`, `devframe-*.ts`, `rpc/*.ts`, `rpc/analog-register.ts`, Devframe built-ins | | getting-started/\* | `packages/devtools/package.json` exports, `hub.ts`, `vite.ts`, `config.ts`, `overlay.ts`, `popup.ts`, demos | | security | `hub.ts`, `vite.ts`, `forms-privacy.ts`, `forms-actions.ts`, router and Analog redaction | | contributing/\* | root `package.json`, `nx.json`, `project.json` files, `.github/workflows`, `extension/` | diff --git a/.claude/skills/devtools-inspector/SKILL.md b/.claude/skills/devtools-inspector/SKILL.md index 3b4f9412..f3db4b44 100644 --- a/.claude/skills/devtools-inspector/SKILL.md +++ b/.claude/skills/devtools-inspector/SKILL.md @@ -10,7 +10,7 @@ Every inspector follows the same path: ``` app page (overlay.ts + -collector.ts) -> rpc.call('push-', { pageId, ... }) - -> devframe.ts: per-page Map, shared state '', expiry, forget--page + -> devframe.ts and devframe-.ts: per-page Map, shared state '', expiry, forget--page -> panel page (app/src/pages/.ts) via rpc.sharedState('') -> agent tools (ctx.agent.registerTool) and MCP resources ``` @@ -32,7 +32,7 @@ Read `docs/contributing/coding-standards.md` ("Reading data from the page") befo ## Server side (`devframe.ts`, `rpc/`) -- `devframe.ts` wires the setup. A new inspector goes in its own `rpc/-register.ts` (see `rpc/errors-register.ts`), and agent tools of an existing area live in `devframe--tools.ts`, which take the setup values they read as an `env` object typed through `devframe-env.ts`. Keep `devframe.ts` under the line ratchet of `pnpm lint`. +- `devframe.ts` wires the setup. A new inspector goes in its own `rpc/-register.ts` (see `rpc/errors-register.ts`). Existing inspectors follow one of two layouts: the ones registered from `devframe-inspectors.ts` (errors, perf, build and the like) keep their state, RPC functions and tools in their `rpc/-register.ts`; the older areas keep their state and RPC functions in `devframe-.ts`. Change an inspector in the module it already lives in. An older area's agent tools are in that module or in a separate `devframe-*-tools.ts` module. These modules take the setup values they read as an `env` object typed through `devframe-env.ts`. The page registry (owners, heartbeats, lease) is `devframe-pages.ts` and the registrars are `devframe-registrars.ts`. Keep `devframe.ts` under the line ratchet of `pnpm lint`. - Keep a `Map` with `reportedAt`, drop pages older than `ttl(pageId)` (from `visibility.ttl()`, so a page with a current heartbeat never expires) in the shared expiry interval, and write the combined value into the shared state. - Page actions that the panel triggers (restore, highlight, run) go panel -> `request--action` -> broadcast `-action` to the page -> `-action-result`, keyed by `requestId`, with a timeout. @@ -48,7 +48,7 @@ Read `docs/contributing/coding-standards.md` ("Reading data from the page") befo - Describe what the tool returns, where the data comes from and what an empty answer means. Answer in markdown. - Give each new tool or resource one entry in `AGENT_ENTRIES` (`packages/devtools/src/config.ts`): its inspector, safety tier, the `actions` it writes for and whether it needs a page. `AGENT_INSPECTOR`, `AGENT_SAFETY`, `ACTION_TOOLS`, `PAGE_AGENT_ENTRIES` and the tags derive from it. `packages/devtools/src/__tests__/agent-metadata.test.ts` fails on a registered tool without an entry; update `packages/devtools/src/__tests__/fixtures/agent-metadata-derived.json` with it. -- Update tool descriptions (in `devframe.ts`, `devframe--tools.ts` or the inspector's register module) when data shapes change, and the tool list on the docs site (apps/docs/src/content/agents/tools.md). Use the `devtools-docs` skill for that edit. +- Update tool descriptions (in `devframe.ts`, `devframe-.ts`, `devframe--tools.ts` or the inspector's register module) when data shapes change, and the tool list on the docs site (apps/docs/src/content/agents/tools.md). Use the `devtools-docs` skill for that edit. ## Tests diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md index 880f99f8..45756c33 100644 --- a/apps/docs/src/content/contributing/development.md +++ b/apps/docs/src/content/contributing/development.md @@ -64,6 +64,7 @@ app/ # Devtools UI SPA (Angular + Vite) packages/ devtools/ # Publishable npm package src/devframe.ts # defineDevframe(): the tool definition + src/devframe-*.ts # Its parts: shared types, registrars, the page registry, and the state, RPC functions and agent tools of the areas kept here (other inspectors live in `src/rpc/-register.ts`) src/overlay.ts # Client script running in the user's page (the overlay-manual entry) src/overlay-auto.ts # The overlay entry: starts the overlay and the floating button on import src/rpc/ # Node-side RPC functions and agent tools @@ -269,7 +270,7 @@ If a code change needs no docs change, add the `no-docs` label to the pull reque ### Add an RPC function 1. Create the function in `packages/devtools/src/rpc/`. -2. Register it in `packages/devtools/src/devframe.ts`. +2. Register it in `packages/devtools/src/devframe.ts`, or in the `devframe-.ts` module of its inspector. 3. Map it to its inspector in `RPC_INSPECTOR` in `packages/devtools/src/config.ts`, so turning the inspector off removes it. 4. Call it from the UI in `app/src/pages/`. diff --git a/apps/docs/src/content/contributing/writing-docs.md b/apps/docs/src/content/contributing/writing-docs.md index b650fbff..422ebaeb 100644 --- a/apps/docs/src/content/contributing/writing-docs.md +++ b/apps/docs/src/content/contributing/writing-docs.md @@ -224,7 +224,7 @@ The docs describe what the code does today. Before you write a claim, find it in | Page | Source of truth | | ------------------------- | ------------------------------------------------------------------------------------------------------------------ | | Inspector pages | The tab in `app/src/pages/` and its collector in `packages/devtools/src/` | -| Agent tools and resources | `packages/devtools/src/devframe.ts`, `rpc/*.ts` and `rpc/analog-register.ts` | +| Agent tools and resources | `packages/devtools/src/devframe.ts`, `devframe-*.ts`, `rpc/*.ts` and `rpc/analog-register.ts` | | Setup pages | `packages/devtools/package.json` exports, `hub.ts`, `vite.ts`, `config.ts`, `overlay.ts`, `popup.ts` and the demos | | Security | `hub.ts`, `vite.ts` and the redaction code, such as `forms-privacy.ts` | | Contributing | Root `package.json` scripts, `nx.json`, `project.json` files and `.github/workflows` | diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 7919fba8..53421287 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -53,7 +53,7 @@ A library's own view in the panel and its own agent tools, through the public pl _Avoid_: extension (the Chrome extension), add-on, inspector **Devframe**: -The framework the devtools are built on (`devframe`). One definition, `packages/devtools/src/devframe.ts` (with the `devframe-*-tools.ts` and `rpc/*-register.ts` modules it calls), declares the RPC functions, shared state and agent tools, and Devframe serves it as the embedded panel, the standalone CLI, the static report and the MCP server. +The framework the devtools are built on (`devframe`). One definition, `packages/devtools/src/devframe.ts` (with the `devframe-*.ts` and `rpc/*-register.ts` modules it calls), declares the RPC functions, shared state and agent tools, and Devframe serves it as the embedded panel, the standalone CLI, the static report and the MCP server. _Avoid_: framework, runtime, backend **Hub**: diff --git a/lint-baseline.json b/lint-baseline.json index 383304e4..64fbbe8e 100644 --- a/lint-baseline.json +++ b/lint-baseline.json @@ -231,8 +231,7 @@ "packages/devtools/src/console-tap.ts": { "unicorn(no-useless-spread)": 1 }, - "packages/devtools/src/devframe.ts": { - "pangular(max-lines)": 3236, + "packages/devtools/src/devframe-components.ts": { "unicorn(no-useless-spread)": 1 }, "packages/devtools/src/dom-diff.ts": { diff --git a/packages/devtools/src/devframe-capacitor.ts b/packages/devtools/src/devframe-capacitor.ts new file mode 100644 index 00000000..7f32a21d --- /dev/null +++ b/packages/devtools/src/devframe-capacitor.ts @@ -0,0 +1,72 @@ +// The Capacitor inspector on the devframe server (`devframe.ts`): the +// `capacitor` shared state with patches, and its push and forget. + +import { createSharedState } from 'devframe/utils/shared-state'; +import type { ResolvedPangularConfig } from './config.ts'; +import { + mergeCapacitorReport, + patchCapacitorState, + syncCapacitorState, + toCapacitorReport, + type CapacitorPage, + type CapacitorState, +} from './rpc/capacitor-tools.ts'; +import { trackPageSessions } from './rpc/page-sessions.ts'; +import type { DevframeNodeContext, Owns, PangularScope, Register } from './devframe-env.ts'; + +export async function registerCapacitor(env: { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + owns: Owns; + mayForget: Owns; +}) { + const { ctx, my, config, register, owns, mayForget } = env; + const limits = config.limits; + const capacitorPages = new Map(); + // With patches on, a push sends the panel what changed, not every page: + // devframe's own shared states send the whole value on each change. + const capacitorShared = await my.rpc.sharedState('capacitor', { + sharedState: createSharedState({ + initialValue: { pages: {} }, + enablePatches: true, + }), + }); + const applyCapacitor = () => + capacitorShared.mutate((draft) => syncCapacitorState(draft, capacitorPages)); + const capacitorSessions = trackPageSessions(ctx.rpc, (pageIds) => { + let removed = false; + for (const pageId of pageIds) removed = capacitorPages.delete(pageId) || removed; + if (removed) applyCapacitor(); + }); + + register({ + name: 'push-capacitor', + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => { + const report = toCapacitorReport(message); + if (!report || !owns(report.pageId)) return { known: true }; + capacitorSessions.bind(report.pageId); + const answer = mergeCapacitorReport(capacitorPages, report, limits.capacitorCalls); + if (answer.known) { + capacitorShared.mutate((draft) => patchCapacitorState(draft, capacitorPages, report)); + } + return answer; + }, + }); + + register({ + name: 'forget-capacitor-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: string) => { + if (!mayForget(pageId)) return; + capacitorSessions.unbind(pageId); + if (capacitorPages.delete(pageId)) applyCapacitor(); + }, + }); + + return { capacitorPages, capacitorShared, applyCapacitor }; +} diff --git a/packages/devtools/src/devframe-components.ts b/packages/devtools/src/devframe-components.ts new file mode 100644 index 00000000..e43629da --- /dev/null +++ b/packages/devtools/src/devframe-components.ts @@ -0,0 +1,576 @@ +// The Components inspector on the devframe server (`devframe.ts`): the +// `component-tree` and `change-detection` shared states, the pushes and pings +// of both, selection, picking, value reads, edits, @defer loads and +// screenshots. Its agent tools live in `devframe-component-tools.ts`. + +import { PICK_KEYS } from './component-pick.ts'; +import { toCdOptionsRequest } from './cd-data.ts'; +import type { ResolvedPangularConfig } from './config.ts'; +import { CHANGE_DETECTION_SHAPE, COMPONENT_TREE_SHAPE } from './push-delta.ts'; +import { markCdPagesStopped, toCdPage, type CdPage, type CdState } from './rpc/cd-tools.ts'; +import { createComponentEdits } from './rpc/component-edits.ts'; +import { createComponentScreenshots } from './rpc/component-screenshots.ts'; +import { + findComponents, + isComponentReport, + latestComponentPage, + toComponentPage, +} from './rpc/component-tools.ts'; +import { createComponentValues } from './rpc/component-values.ts'; +import { registerConsoleGlobals } from './rpc/console-globals.ts'; +import { createDeferLoads } from './rpc/defer-loads.ts'; +import type { OwnerBroadcast, PageOwners } from './rpc/page-owners.ts'; +import { trackPageSessions } from './rpc/page-sessions.ts'; +import { byRecency, unknownPageText } from './rpc/pages.ts'; +import { createPushAcks } from './rpc/push-acks.ts'; +import { createReportDeltas, deltaPush } from './rpc/report-deltas.ts'; +import type { ComponentDetail, ComponentPage, LiveComponentNode } from './types.ts'; +import type { + AgentRegistrar, + AnswersFor, + DevframeNodeContext, + Owns, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** The live component trees and change detection records, before any RPC function reads them. */ +export async function createComponentState({ my }: { my: PangularScope }) { + const componentTree = await my.rpc.sharedState('component-tree', { + initialValue: { + nodes: [] as LiveComponentNode[], + pages: {} as Record, + selectedId: null as string | null, + highlightedId: null as string | null, + }, + }); + const componentPages = new Map(); + // What each page's next delta builds on (`push-delta.ts`). + const componentDeltas = createReportDeltas(COMPONENT_TREE_SHAPE); + const cdDeltas = createReportDeltas(CHANGE_DETECTION_SHAPE); + const applyComponentPages = () => + componentTree.mutate((draft) => { + draft.pages = Object.fromEntries(componentPages); + draft.nodes = latestComponentPage(componentPages.values())?.roots ?? []; + }); + const componentWaiters = new Set<() => void>(); + // `console` marks a selection made in the panel, which sets `$ng0` on that page. + const selectComponentOnPage = ( + pageId: string | undefined, + id: string | null, + console = false, + ) => { + componentTree.mutate((draft) => { + draft.selectedId = id; + }); + void my.rpc.broadcast({ + method: 'inspect-component-in-page', + args: [{ pageId, id, ...(console && pageId ? { console: true } : {}) }], + optional: true, + }); + }; + const waitForComponentDetail = (pageId: string, id: string, timeoutMs = 3000) => + new Promise((resolve) => { + const done = (detail: ComponentDetail | null) => { + clearTimeout(timer); + componentWaiters.delete(check); + resolve(detail); + }; + const check = () => { + const detail = componentPages.get(pageId)?.detail; + if (detail?.id === id) done(detail); + }; + const timer = setTimeout(() => done(null), timeoutMs); + timer.unref?.(); + componentWaiters.add(check); + check(); + }); + // Change detection records, per page, and the pages whose connection dropped. + const cdPages = new Map(); + const goneCdPages = new Set(); + + return { + componentTree, + componentPages, + componentDeltas, + cdDeltas, + cdPages, + goneCdPages, + /** Writes `cdPages` into the `change-detection` state, once `registerComponentRpc` made it. */ + applyCd: (): void => {}, + applyComponentPages, + componentWaiters, + selectComponentOnPage, + waitForComponentDetail, + }; +} + +export type ComponentState = Awaited>; + +/** What `registerComponentRpc` reads from the devframe server's setup (`devframe.ts`). */ +export interface ComponentRpcEnv { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + agent: AgentRegistrar; + owns: Owns; + mayForget: Owns; + answersFor: AnswersFor; + owners: PageOwners; + state: ComponentState; +} + +export async function registerComponentRpc(env: ComponentRpcEnv) { + const { ctx, my, config, register, agent, owns, mayForget, answersFor, owners, state } = env; + const limits = config.limits; + const { + componentPages, + componentDeltas, + cdDeltas, + cdPages, + goneCdPages, + applyComponentPages, + componentWaiters, + selectComponentOnPage, + } = state; + // A tab whose connection dropped gets no change detection record request, + // so its report goes at once and the tool does not count it as connected + // until it reports again. Bound from the component, injector and change + // detection reports, the ones the tool reads tabs from. + const markCdStopped = (pageId?: string) => { + if (markCdPagesStopped(cdPages, pageId)) state.applyCd(); + }; + const cdSessions = trackPageSessions(ctx.rpc, (pageIds) => { + let removed = false; + for (const pageId of pageIds) { + goneCdPages.add(pageId); + removed = cdPages.delete(pageId) || removed; + } + if (removed) state.applyCd(); + }); + const cdSeen = (pageId: string) => { + cdSessions.bind(pageId); + goneCdPages.delete(pageId); + }; + + register({ + name: 'push-component-tree', + type: 'action', + jsonSerializable: true, + handler: deltaPush(componentDeltas, owns, (report, pageNow) => { + if (!isComponentReport(report) || !owns(report.pageId)) return false; + componentPages.set(report.pageId, toComponentPage(report, Date.now(), pageNow)); + cdSeen(report.pageId); + applyComponentPages(); + for (const check of [...componentWaiters]) check(); + return true; + }), + }); + + const cdState = await my.rpc.sharedState('change-detection', { + initialValue: { pages: {} } as CdState, + }); + state.applyCd = () => + cdState.mutate((draft) => { + draft.pages = Object.fromEntries(cdPages); + }); + // The pages confirm a recording request by pushing their recording again. + const cdAcks = createPushAcks(); + const requestCdRecord = (message: { pageId?: unknown; on?: unknown; clear?: unknown }) => { + const pageId = typeof message?.pageId === 'string' ? message.pageId.slice(0, 50) : undefined; + const on = typeof message?.on === 'boolean' ? message.on : undefined; + // The summary must not say "Recording is on" from the last push before the stop. + if (on === false) markCdStopped(pageId); + void my.rpc.broadcast({ + method: 'change-detection-record', + args: [{ pageId, on, clear: message?.clear === true }], + optional: true, + }); + }; + + const requestCdOptions = (message: unknown) => { + const raw = (message && typeof message === 'object' ? message : {}) as { pageId?: unknown }; + const pageId = typeof raw.pageId === 'string' ? raw.pageId.slice(0, 50) : undefined; + void my.rpc.broadcast({ + method: 'change-detection-options', + args: [{ pageId, ...toCdOptionsRequest(message) }], + optional: true, + }); + }; + + register({ + name: 'push-change-detection', + type: 'action', + jsonSerializable: true, + handler: deltaPush(cdDeltas, owns, (report) => { + const page = toCdPage(report, limits.cdCycles); + if (!page || !owns(page.pageId)) return false; + cdPages.set(page.pageId, page); + cdSeen(page.pageId); + state.applyCd(); + cdAcks.notify(page.pageId, page); + return true; + }), + }); + + register({ + name: 'ping-change-detection', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + const page = owns(pageId) ? cdPages.get(pageId) : undefined; + if (!page) return { known: false }; + cdPages.set(page.pageId, { ...page, reportedAt: Date.now() }); + cdSeen(page.pageId); + return { known: true }; + }, + }); + + register({ + name: 'forget-change-detection-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (!mayForget(pageId)) return; + cdDeltas.forget(pageId); + if (cdPages.delete(pageId)) state.applyCd(); + }, + }); + + register({ + name: 'request-change-detection-record', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; on?: unknown; clear?: unknown }) => { + requestCdRecord(message); + }, + }); + + register({ + name: 'request-change-detection-options', + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => { + requestCdOptions(message); + }, + }); + + register({ + name: 'ping-component-tree', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + const page = owns(pageId) ? componentPages.get(pageId) : undefined; + if (!page) return { known: false }; + componentPages.set(page.pageId, { ...page, reportedAt: Date.now() }); + cdSeen(page.pageId); + return { known: true }; + }, + }); + + register({ + name: 'forget-component-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (!mayForget(pageId)) return; + componentDeltas.forget(pageId); + if (componentPages.delete(pageId)) applyComponentPages(); + }, + }); + + register({ + name: 'select-component', + type: 'action', + jsonSerializable: true, + handler: (target: string | { pageId?: unknown; id?: unknown; console?: unknown } | null) => { + if (typeof target === 'string') { + void my.rpc.broadcast({ + method: 'select-signal-component', + args: [target.slice(0, 500)], + optional: true, + }); + return; + } + const id = typeof target?.id === 'string' ? target.id.slice(0, 50) : null; + const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; + selectComponentOnPage(pageId, id, target?.console === true); + }, + }); + + // Draws the hydration overlay on a page; it changes nothing in the app. + const requestHydrationOverlay = (message: { pageId?: unknown; on?: unknown } | null) => { + if (typeof message?.on !== 'boolean') return; + const pageId = typeof message.pageId === 'string' ? message.pageId.slice(0, 50) : undefined; + void my.rpc.broadcast({ + method: 'hydration-overlay', + args: [{ pageId, on: message.on }], + optional: true, + }); + }; + register({ + name: 'request-hydration-overlay', + type: 'action', + jsonSerializable: true, + handler: requestHydrationOverlay, + }); + + // Writes to component inputs, properties and signals: panel and agent tools. + const componentEdits = createComponentEdits({ + config, + pages: () => componentPages.values(), + // Only the page's own connection gets the value to write, not the panel or other tabs. + broadcast: (method, message) => { + const pageId = (message as { pageId?: unknown } | null)?.pageId; + void (my.rpc.broadcast as (options: OwnerBroadcast) => Promise)({ + method, + args: [message], + optional: true, + filter: owners.deliversTo(typeof pageId === 'string' ? pageId : undefined), + }); + }, + answersFor, + }); + register({ + name: 'component-edit-result', + type: 'action', + jsonSerializable: true, + handler: componentEdits.answer, + }); + register({ + name: 'request-component-edit', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; request?: unknown } | null) => + componentEdits.request(message?.pageId, message?.request), + }); + for (const tool of componentEdits.tools) agent.registerTool(tool); + + // Loads one @defer block when an agent asks: same gate and owner check as the writes above. + const deferLoads = createDeferLoads({ + config, + pages: () => componentPages.values(), + broadcast: (method, message) => { + const pageId = (message as { pageId?: unknown } | null)?.pageId; + void (my.rpc.broadcast as (options: OwnerBroadcast) => Promise)({ + method, + args: [message], + optional: true, + filter: owners.deliversTo(typeof pageId === 'string' ? pageId : undefined), + }); + }, + answersFor, + }); + register({ + name: 'defer-load-result', + type: 'action', + jsonSerializable: true, + handler: deferLoads.answer, + }); + agent.registerTool(deferLoads.tool); + + // Nested levels of component values: the panel opens them, inspect-component reads them. + const componentValues = createComponentValues({ + pages: () => componentPages.values(), + broadcast: (method, message) => + void my.rpc.broadcast({ method, args: [message], optional: true }), + answersFor, + }); + register({ + name: 'component-value-result', + type: 'action', + jsonSerializable: true, + handler: componentValues.answer, + }); + register({ + name: 'request-component-value', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; request?: unknown } | null) => + componentValues.request(message?.pageId, message?.request), + }); + + // Images of one component, drawn by its page: the screenshot-component tool. + const componentScreenshots = createComponentScreenshots({ + pages: () => componentPages.values(), + broadcast: (method, message) => + void my.rpc.broadcast({ method, args: [message], optional: true }), + answersFor, + }); + register({ + name: 'component-screenshot-result', + type: 'action', + jsonSerializable: true, + handler: componentScreenshots.answer, + }); + agent.registerTool(componentScreenshots.tool); + + registerConsoleGlobals({ + config, + register, + broadcast: (message) => void my.rpc.broadcast(message), + answersFor, + }); + + return { + cdSeen, + cdAcks, + cdState, + requestCdRecord, + requestCdOptions, + requestHydrationOverlay, + componentValues, + }; +} + +/** The panel's pick mode: the page draws the picker, the panel moves and cancels it. */ +export function registerComponentPick(env: { + my: PangularScope; + register: Register; + answersFor: AnswersFor; + state: ComponentState; +}): void { + const { my, register, answersFor } = env; + const { componentPages, componentTree } = env.state; + const pendingPicks = new Map< + string, + { pageId: string; settle: (result: Record) => void } + >(); + let pickSeq = 0; + + register({ + name: 'request-component-pick', + type: 'action', + jsonSerializable: true, + handler: (target: { pageId?: unknown } | null) => { + const asked = typeof target?.pageId === 'string' ? target.pageId : undefined; + const pageId = asked ?? latestComponentPage(componentPages.values())?.pageId; + if (!pageId || !componentPages.has(pageId)) { + return { ok: false, error: 'No page is connected. Open the app in a browser first.' }; + } + return new Promise>((resolve) => { + const requestId = `pick${++pickSeq}`; + const timer = setTimeout(() => { + pendingPicks.delete(requestId); + resolve({ + ok: false, + error: + 'The page did not answer. Is the app still open in a browser, or the device app in the foreground?', + }); + }, 20_000); + timer.unref?.(); + pendingPicks.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingPicks.delete(requestId); + if (result['ok'] === true && typeof result['id'] === 'string') { + componentTree.mutate((draft) => { + draft.selectedId = result['id'] as string; + }); + } + resolve({ ...result, pageId }); + }, + }); + void my.rpc.broadcast({ + method: 'component-pick', + args: [{ requestId, pageId }], + optional: true, + }); + }); + }, + }); + + // A keyboard move of the running pick: the panel sends the key, the page + // answers with the component it shows now on the pick's result channel. + register({ + name: 'request-component-pick-move', + type: 'action', + jsonSerializable: true, + handler: (target: { pageId?: unknown; key?: unknown } | null) => { + const key = PICK_KEYS.find((k) => k === target?.key); + if (!key) return { moved: false, error: 'Unknown key.' }; + const asked = typeof target?.pageId === 'string' ? target.pageId : undefined; + const pageId = asked ?? latestComponentPage(componentPages.values())?.pageId; + if (!pageId || !componentPages.has(pageId)) { + return { moved: false, error: 'No page is connected. Open the app in a browser first.' }; + } + return new Promise>((resolve) => { + const requestId = `pick${++pickSeq}`; + const timer = setTimeout(() => { + pendingPicks.delete(requestId); + resolve({ moved: false, error: 'The page did not answer.' }); + }, 5_000); + timer.unref?.(); + pendingPicks.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingPicks.delete(requestId); + resolve(result); + }, + }); + void my.rpc.broadcast({ + method: 'component-pick', + args: [{ requestId, pageId, move: key }], + optional: true, + }); + }); + }, + }); + + register({ + name: 'cancel-component-pick', + type: 'action', + jsonSerializable: true, + handler: (target: { pageId?: unknown } | null) => { + const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; + void my.rpc.broadcast({ + method: 'component-pick', + args: [{ pageId, cancel: true }], + optional: true, + }); + }, + }); + + register({ + name: 'component-pick-result', + type: 'action', + jsonSerializable: true, + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown } | null) => { + if (typeof message?.requestId !== 'string') return; + const pending = pendingPicks.get(message.requestId); + if (!pending || !answersFor(pending.pageId, message.pageId)) return; + const result = + message.result && typeof message.result === 'object' + ? (message.result as Record) + : { ok: false, error: 'Empty answer.' }; + pending.settle(result); + }, + }); +} + +/** What the component agent tools say about pages and how they find components (`devframe-component-tools.ts`). */ +export function componentToolHelpers(componentPages: Map) { + const noComponentTree = (outcome: string) => + `No component tree has been reported${outcome}. This is what a page that has never connected reports, and also what a connected page reports when its components are not readable. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio MCP server has no page, so there it always answers this.`; + const componentPageProperty = { + type: 'string', + description: + 'Page id, when more than one tab reports (see pangular:list-pages). Searches every page without it, newest first.', + }; + const componentHits = (selector: string, pageId?: string) => + findComponents( + byRecency(componentPages.values()).filter((page) => !pageId || page.pageId === pageId), + selector, + ); + const unknownComponentPage = (pageId: string | undefined) => + pageId && !componentPages.has(pageId) + ? { + markdown: unknownPageText(pageId, byRecency(componentPages.values()), 'a component tree'), + } + : null; + + return { noComponentTree, componentPageProperty, componentHits, unknownComponentPage }; +} diff --git a/packages/devtools/src/devframe-env.ts b/packages/devtools/src/devframe-env.ts index 94cca3a6..bb18e235 100644 --- a/packages/devtools/src/devframe-env.ts +++ b/packages/devtools/src/devframe-env.ts @@ -27,3 +27,10 @@ export type PangularScope = DevframeScopedNodeContext<'pangular'>; /** A page's signal graph as the setup keeps it. */ export type PageGraph = SignalGraph & { pageId?: string }; + +/** The setup's `register`: an RPC function, unless the config turns it off. */ +export type Register = PangularScope['rpc']['register']; +/** Whether the connection of the current call owns the page (`rpc/page-owners.ts`). */ +export type Owns = (pageId: unknown) => pageId is string; +/** Whether a message from `pageId` may settle a request sent to `target`. */ +export type AnswersFor = (target: string | undefined, pageId: unknown) => boolean; diff --git a/packages/devtools/src/devframe-forms.ts b/packages/devtools/src/devframe-forms.ts new file mode 100644 index 00000000..6ad6f5af --- /dev/null +++ b/packages/devtools/src/devframe-forms.ts @@ -0,0 +1,284 @@ +// The Forms inspector on the devframe server (`devframe.ts`): the `forms` +// shared state, its push, ping and forget, and the form actions a page runs. +// Its agent tools live in `devframe-forms-tools.ts`. + +import { + FORM_WRITE_ACTIONS, + FORMLESS_FORM_ACTIONS, + actionBlockedMessage, + type ResolvedPangularConfig, +} from './config.ts'; +import type { HubFeed } from './hub-messages.ts'; +import { FORMS_SHAPE } from './push-delta.ts'; +import { + explainFieldText, + explainSubmitText, + exportFormText, + type FieldArgs, + formPayloadText, + lintFormsFor, +} from './rpc/forms-explain.ts'; +import { playwrightTestText } from './rpc/forms-playwright.ts'; +import { findFormSource } from './rpc/forms-source.ts'; +import { + currentForms, + isPageReport, + mergePageReport, + type FormsState, + type PageReport, +} from './rpc/forms-tools.ts'; +import type { OwnerBroadcast, PageOwners } from './rpc/page-owners.ts'; +import type { PageTtl } from './rpc/page-ttl.ts'; +import { createReportDeltas, deltaPush } from './rpc/report-deltas.ts'; +import type { + AnswersFor, + DevframeNodeContext, + Owns, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** What `createFormsState` reads from the devframe server's setup (`devframe.ts`). */ +export interface FormsStateEnv { + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + owns: Owns; + feed: HubFeed; + liveTtl: PageTtl; +} + +/** The forms of each page and `push-forms`, which writes them. */ +export async function createFormsState(env: FormsStateEnv) { + const { my, config, register, owns, feed, liveTtl } = env; + const limits = config.limits; + // What each page's next delta builds on (`push-delta.ts`). + const formDeltas = createReportDeltas(FORMS_SHAPE); + const formPages = new Map(); + const formsState = await my.rpc.sharedState('forms', { + initialValue: { forms: [], events: [], reportedAt: 0, setupErrors: [] } as FormsState, + }); + + const applyForms = (next: FormsState) => + formsState.mutate((draft) => { + draft.forms = next.forms; + draft.events = next.events; + draft.reportedAt = next.reportedAt; + draft.pagesReportedAt = next.pagesReportedAt ?? {}; + draft.setupErrors = next.setupErrors ?? []; + draft.instrumented = next.instrumented ?? []; + draft.dropped = next.dropped ?? {}; + draft.webMcp = next.webMcp ?? []; + }); + /** A sign of life from a page with forms: its report time, here and in the shared state. */ + const touchFormsPage = (pageId: string, now = Date.now()): boolean => { + const page = formPages.get(pageId); + if (!page) return false; + page.reportedAt = now; + formsState.mutate((draft) => { + draft.pagesReportedAt = { ...draft.pagesReportedAt, [pageId]: now }; + draft.reportedAt = Math.min(...[...formPages.values()].map((p) => p.reportedAt)); + }); + return true; + }; + + register({ + name: 'push-forms', + type: 'action', + jsonSerializable: true, + handler: deltaPush(formDeltas, owns, (report) => { + if (!isPageReport(report) || !owns(report.pageId)) return false; + applyForms(mergePageReport(formPages, report, Date.now(), limits.formTimeline, liveTtl)); + feed.ngErrors(report.pageId, 'forms', report.setupErrors ?? []); + return true; + }), + }); + + return { formPages, formsState, formDeltas, applyForms, touchFormsPage }; +} + +export type FormsStateHolder = Awaited>; + +/** `ping-forms` and `forget-forms-page`. */ +export function registerFormsPages(env: { + config: ResolvedPangularConfig; + register: Register; + owns: Owns; + mayForget: Owns; + state: FormsStateHolder; +}): void { + const { register, owns, mayForget } = env; + const limits = env.config.limits; + const { formPages, formDeltas, applyForms, touchFormsPage } = env.state; + register({ + name: 'ping-forms', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => ({ known: owns(pageId) && touchFormsPage(pageId) }), + }); + + register({ + name: 'forget-forms-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: string) => { + if (!mayForget(pageId)) return; + formDeltas.forget(pageId); + if (formPages.delete(pageId)) applyForms(currentForms(formPages, limits.formTimeline)); + }, + }); +} + +/** What `registerFormActions` reads from the devframe server's setup (`devframe.ts`). */ +export interface FormActionsEnv { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + owners: PageOwners; + answersFor: AnswersFor; + formsState: FormsStateHolder['formsState']; + /** The address of the page a form is on, for the Playwright test export (`devframe-pages.ts`). */ + formPageUrl: (state: FormsState, args: FieldArgs) => string | undefined; +} + +/** The form actions a page runs for the panel and the agents, and the forms reads of the panel. */ +export function registerFormActions(env: FormActionsEnv) { + const { ctx, my, config, register, owners, answersFor, formsState, formPageUrl } = env; + const pendingFormActions = new Map< + string, + { pageId: string | undefined; settle: (result: unknown) => void } + >(); + let formActionSeq = 0; + + const requestFormAction = (request: Record, timeoutMs = 15_000) => { + const action = request['action'] as string; + if (!config.actions.forms && FORM_WRITE_ACTIONS.includes(action)) { + return Promise.resolve>({ + ok: false, + error: actionBlockedMessage('forms'), + }); + } + const formId = request['formId']; + if (!FORMLESS_FORM_ACTIONS.includes(action) && (typeof formId !== 'string' || !formId)) { + return Promise.resolve>({ + ok: false, + error: `${String(action)} needs a formId.`, + }); + } + return sendFormAction(request, timeoutMs); + }; + + const sendFormAction = (request: Record, timeoutMs: number) => + new Promise>((resolve) => { + const requestId = `f${++formActionSeq}`; + const formId = typeof request['formId'] === 'string' ? request['formId'] : undefined; + const explicit = typeof request['page'] === 'string' ? request['page'] : undefined; + const pageId = explicit ?? (formId?.includes('@') ? formId.split('@')[1] : undefined); + const { page: _page, ...payload } = request; + const timer = setTimeout(() => { + pendingFormActions.delete(requestId); + resolve({ + ok: false, + error: `No page answered within ${Math.round(timeoutMs / 1000)}s. Is the app open in a browser (or the device app in the foreground), with that form on screen?`, + }); + }, timeoutMs); + timer.unref?.(); + pendingFormActions.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingFormActions.delete(requestId); + resolve( + (result && typeof result === 'object' + ? result + : { ok: false, error: 'Empty answer.' }) as Record, + ); + }, + }); + // Only the page's own connection gets the request: it can carry the values an agent fills in. + // The scoped `broadcast` passes `filter` through to devframe's host; its type leaves it out. + void (my.rpc.broadcast as (options: OwnerBroadcast) => Promise)({ + method: 'form-action', + args: [{ requestId, pageId, request: payload }], + optional: true, + filter: owners.deliversTo(pageId), + }); + }); + + register({ + name: 'form-action-result', + type: 'action', + jsonSerializable: true, + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { + if (typeof message?.requestId !== 'string') return; + const pending = pendingFormActions.get(message.requestId); + if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); + }, + }); + + register({ + name: 'request-form-action', + type: 'action', + jsonSerializable: true, + handler: (request: unknown) => + request && typeof request === 'object' + ? requestFormAction(request as Record) + : { ok: false, error: 'Bad request.' }, + }); + + register({ + name: 'forms-lint', + type: 'query', + jsonSerializable: true, + handler: (args: { form?: unknown; page?: unknown } | null) => + lintFormsFor( + formsState.value() as FormsState, + { + form: typeof args?.form === 'string' ? args.form : undefined, + page: typeof args?.page === 'string' ? args.page : undefined, + }, + undefined, + { rules: config.lint.rules }, + ), + }); + + register({ + name: 'forms-owners', + type: 'query', + jsonSerializable: true, + handler: () => + (formsState.value() as FormsState).forms.map((form) => ({ + formId: form.id, + label: form.label, + file: findFormSource(ctx.cwd, form.owner, form.property)?.form?.file ?? null, + })), + }); + + register({ + name: 'forms-explain', + type: 'query', + jsonSerializable: true, + handler: (args: { kind?: unknown; form?: unknown; path?: unknown } | null) => { + const state = formsState.value() as FormsState; + const target = { + form: typeof args?.form === 'string' ? args.form : undefined, + path: typeof args?.path === 'string' ? args.path : undefined, + }; + switch (args?.kind) { + case 'submit': + return explainSubmitText(state, target); + case 'payload': + return formPayloadText(state, target); + case 'fixture': + return exportFormText(state, { ...target, format: 'fixture' }); + case 'playwright': + return playwrightTestText(state, { ...target, url: formPageUrl(state, target) }); + default: + return explainFieldText(state, target); + } + }, + }); + + return requestFormAction; +} diff --git a/packages/devtools/src/devframe-http.ts b/packages/devtools/src/devframe-http.ts new file mode 100644 index 00000000..f453c8e0 --- /dev/null +++ b/packages/devtools/src/devframe-http.ts @@ -0,0 +1,287 @@ +// The HTTP inspector on the devframe server (`devframe.ts`): the `http` and +// `http-payloads` shared states, the server calls and SSR requests of this +// process, the pages' pushes, mock rules and SSR overrides. Its agent tools +// live in `devframe-http-tools.ts` and `devframe-refetch-tools.ts`. + +import { actionBlockedMessage, type ResolvedPangularConfig } from './config.ts'; +import type { HubFeed } from './hub-messages.ts'; +import { sanitizeHydration, sanitizePayload, type PayloadSummary } from './http-payload.ts'; +import { + httpRegistry, + sanitizeCalls, + sanitizeRules, + type HttpCall, + type HttpRule, +} from './http-rules.ts'; +import { redactCall } from './redaction.ts'; +import { redactMessage, redactUrl } from './router.ts'; +import { nameServerFns } from './rpc/analog-register.ts'; +import { ssrOverrideTools } from './rpc/ssr-override-tools.ts'; +import { sanitizeSsrRequest } from './rpc/ssr-tools.ts'; +import { sanitizeSsrOverrides, type SsrOverride } from './ssr-overrides.ts'; +import { isSsrRequestId, ssrRegistry } from './ssr-registry.ts'; +import type { HttpPage, HttpPayloadState, HttpReport, HttpState } from './types.ts'; +import { registerRefetchTools } from './devframe-refetch-tools.ts'; +import type { AgentRegistrar, AnswersFor, Owns, PangularScope, Register } from './devframe-env.ts'; + +/** What `registerHttp` reads from the devframe server's setup (`devframe.ts`). */ +export interface HttpEnv { + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + agent: AgentRegistrar; + owns: Owns; + mayForget: Owns; + answersFor: AnswersFor; + feed: HubFeed; +} + +export async function registerHttp(env: HttpEnv) { + const { my, config, register, agent, owns, mayForget, answersFor, feed } = env; + const on = config.inspectors; + const limits = config.limits; + const httpPages = new Map(); + const registry = httpRegistry(); + registry.dispose?.(); + registry.rules = on.http && config.actions.http ? (registry.rules ?? []) : []; + const httpState = await my.rpc.sharedState('http', { + initialValue: { + serverCalls: [], + requests: [], + pages: [], + rules: [...registry.rules], + ssrOverrides: [], + } as HttpState, + }); + // Overrides change server responses, so they live only while this server runs. + ssrRegistry().overrides = []; + const httpPayloadState = await my.rpc.sharedState('http-payloads', { + initialValue: { pages: {} } as HttpPayloadState, + }); + const httpPayloads = new Map(); + let pendingServerCalls: HttpCall[] = []; + let pendingDropped = 0; + let flushTimer: ReturnType | undefined; + const flushServerCalls = () => { + flushTimer = undefined; + const batch = pendingServerCalls; + pendingServerCalls = []; + let dropped = pendingDropped; + pendingDropped = 0; + httpState.mutate((draft) => { + draft.serverCalls.push(...batch); + const extra = draft.serverCalls.length - limits.httpCalls; + if (extra > 0) { + draft.serverCalls.splice(0, extra); + dropped += extra; + } + if (dropped) draft.serverDropped = (draft.serverDropped ?? 0) + dropped; + }); + }; + const ssr = ssrRegistry(); + ssr.record = on.http + ? (raw) => { + const request = sanitizeSsrRequest(raw); + if (!request) return; + // Server calls are batched, so flush them first to keep their order. + if (flushTimer) { + clearTimeout(flushTimer); + flushServerCalls(); + } + httpState.mutate((draft) => { + draft.requests.push(request); + const extra = draft.requests.length - limits.httpCalls; + if (extra > 0) draft.requests.splice(0, extra); + }); + } + : undefined; + registry.record = on.http + ? (call) => { + pendingServerCalls.push(redactCall(call)); + if (pendingServerCalls.length > limits.httpCalls) { + pendingServerCalls.shift(); + pendingDropped++; + } + flushTimer ??= setTimeout(flushServerCalls, 100); + } + : () => {}; + const applyHttpPages = () => + httpState.mutate((draft) => { + draft.pages = [...httpPages.values()].sort((a, b) => a.firstSeenAt - b.firstSeenAt); + }); + const forgetHttpPages = (pageIds: string[]) => { + for (const id of pageIds) httpPages.delete(id); + applyHttpPages(); + if (!pageIds.filter((id) => httpPayloads.delete(id)).length) return; + httpPayloadState.mutate((draft) => { + for (const id of pageIds) delete draft.pages[id]; + }); + }; + const setHttpRules = (rules: HttpRule[]) => { + registry.rules = rules; + httpState.mutate((draft) => { + draft.rules = rules; + }); + void my.rpc.broadcast({ method: 'http-rules', args: [rules], optional: true }); + return rules; + }; + + register({ + name: 'push-http', + type: 'action', + jsonSerializable: true, + handler: (report: unknown) => { + const page = report as (Partial & { payload?: unknown }) | null; + if (!page || typeof page.pageId !== 'string' || typeof page.url !== 'string') return; + if (page.pageId.length >= 50 || !owns(page.pageId)) return; + const known = httpPages.get(page.pageId); + const url = redactUrl(page.url); + const hasPayload = page.payload !== undefined; + if (!hasPayload && !known) return { needPayload: true }; + const calls = sanitizeCalls(page.calls, limits.httpCalls).map(redactCall); + httpPages.set(page.pageId, { + pageId: page.pageId, + url, + initialUrl: typeof page.initialUrl === 'string' ? redactUrl(page.initialUrl) : url, + title: + typeof page.title === 'string' + ? redactMessage(page.title.slice(0, 2000)).slice(0, 200) + : '', + hydration: sanitizeHydration(page.hydration), + calls: + page.full === false && known + ? [...known.calls, ...calls].slice(-limits.httpCalls) + : calls, + dropped: + typeof page.dropped === 'number' && page.dropped > 0 ? Math.floor(page.dropped) : 0, + ...(isSsrRequestId(page.ssrRequestId) + ? { ssrRequestId: page.ssrRequestId } + : known?.ssrRequestId + ? { ssrRequestId: known.ssrRequestId } + : {}), + firstSeenAt: known?.firstSeenAt ?? Date.now(), + reportedAt: Date.now(), + }); + applyHttpPages(); + feed.hydration(page.pageId, httpPages.get(page.pageId)?.hydration); + if (hasPayload) { + const payload = nameServerFns(sanitizePayload(page.payload)); + httpPayloads.set(page.pageId, payload); + httpPayloadState.mutate((draft) => { + draft.pages[page.pageId!] = payload; + }); + } + return { needPayload: false }; + }, + }); + + register({ + name: 'ping-http', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + const page = owns(pageId) ? httpPages.get(pageId) : undefined; + if (!page) return { known: false }; + httpPages.set(page.pageId, { ...page, reportedAt: Date.now() }); + return { known: true }; + }, + }); + + register({ + name: 'forget-http-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (mayForget(pageId) && httpPages.has(pageId)) forgetHttpPages([pageId]); + }, + }); + + register({ + name: 'get-http-rules', + type: 'query', + jsonSerializable: true, + handler: () => registry.rules ?? [], + }); + + register({ + name: 'set-http-rules', + type: 'action', + jsonSerializable: true, + handler: (rules: unknown) => { + if (!config.actions.http) throw new Error(actionBlockedMessage('http')); + return setHttpRules(sanitizeRules(rules)); + }, + }); + + const saveSsrOverrides = (overrides: SsrOverride[]) => { + ssrRegistry().overrides = overrides; + httpState.mutate((draft) => { + draft.ssrOverrides = overrides; + }); + return overrides; + }; + register({ + name: 'set-ssr-overrides', + type: 'action', + jsonSerializable: true, + handler: (input: unknown) => { + if (!config.actions.http) throw new Error(actionBlockedMessage('http')); + return saveSsrOverrides(sanitizeSsrOverrides(input)); + }, + }); + registerRefetchTools({ + agent, + answersFor, + config, + httpPages, + httpPayloadState, + httpPayloads, + my, + register, + }); + + // The panel's TransferState payload and SSR overrides, for agents. + for (const tool of ssrOverrideTools({ + config, + overrides: () => ssrRegistry().overrides ?? [], + save: saveSsrOverrides, + payloads: () => httpPayloads, + pages: () => httpPages.values(), + })) + agent.registerTool(tool); + + const clearHttpCalls = () => { + if (!config.actions.http) throw new Error(actionBlockedMessage('http')); + pendingServerCalls = []; + pendingDropped = 0; + for (const [id, page] of httpPages) httpPages.set(id, { ...page, calls: [], dropped: 0 }); + httpState.mutate((draft) => { + draft.serverCalls = []; + draft.serverDropped = 0; + draft.requests = []; + draft.pages = [...httpPages.values()].sort((a, b) => a.firstSeenAt - b.firstSeenAt); + }); + void my.rpc.broadcast({ method: 'http-clear', args: [], optional: true }); + }; + + register({ + name: 'clear-http-calls', + type: 'action', + jsonSerializable: true, + handler: clearHttpCalls, + }); + + return { + httpPages, + registry, + httpState, + httpPayloadState, + httpPayloads, + ssr, + forgetHttpPages, + setHttpRules, + clearHttpCalls, + /** Drops the server calls waiting for their batch. */ + stopFlush: () => clearTimeout(flushTimer), + }; +} diff --git a/packages/devtools/src/devframe-injectors.ts b/packages/devtools/src/devframe-injectors.ts new file mode 100644 index 00000000..d1d4ee41 --- /dev/null +++ b/packages/devtools/src/devframe-injectors.ts @@ -0,0 +1,201 @@ +// The Injectors inspector on the devframe server (`devframe.ts`): the +// `injector-tree` shared state with framework providers marked, its push and +// ping, provider values and refreshes. + +import type { PangularInspector } from './config.ts'; +import { INJECTOR_TREE_SHAPE } from './push-delta.ts'; +import { defaultPage, rankPages } from './rpc/default-page.ts'; +import { createFrameworkNames, markFramework } from './rpc/framework-tokens.ts'; +import { requestInjectorRefresh } from './rpc/injector-refresh.ts'; +import { INJECTOR_TREE_LIMITS, sanitizeInjectorTree } from './rpc/injector-report.ts'; +import type { NgrxDeclaration } from './rpc/ngrx-tools.ts'; +import { createProviderValues } from './rpc/provider-values.ts'; +import { createReportDeltas, deltaPush } from './rpc/report-deltas.ts'; +import { nameStoreTokens } from './rpc/store-tokens.ts'; +import type { InjectorPage, InjectorTreeNode, ZoneMode } from './types.ts'; +import { ZONE_MODES } from './zone-mode.ts'; +import type { + AnswersFor, + DevframeNodeContext, + Owns, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** The injector tree of each page, before any RPC function reads it. */ +export async function createInjectorState({ + ctx, + my, +}: { + ctx: DevframeNodeContext; + my: PangularScope; +}) { + const injectorTreeState = await my.rpc.sharedState('injector-tree', { + initialValue: { + roots: [] as InjectorTreeNode[], + environment: [] as InjectorTreeNode[], + pages: {} as Record, + truncated: false, + zone: null as ZoneMode | null, + selectedInjectorId: null as string | null, + }, + }); + const injectorPages = new Map(); + // Token names from the installed @angular/* packages mark framework providers and injectors. + const withFramework = (page: InjectorPage, names: ReadonlySet): InjectorPage => ({ + ...page, + roots: markFramework(page.roots, names), + environment: markFramework(page.environment, names), + }); + const frameworkNames = createFrameworkNames( + () => [ctx.cwd, ctx.workspaceRoot], + (names) => { + if (!injectorPages.size) return; + for (const [id, page] of injectorPages) injectorPages.set(id, withFramework(page, names)); + applyInjectorPages(); + }, + ); + const latestInjectorPage = () => rankPages(injectorPages.values())[0]; + const applyInjectorPages = () => + injectorTreeState.mutate((draft) => { + const latest = latestInjectorPage(); + draft.pages = Object.fromEntries(injectorPages); + draft.roots = latest?.roots ?? []; + draft.environment = latest?.environment ?? []; + draft.truncated = latest?.truncated === true; + draft.zone = latest?.zone ?? null; + }); + // What each page's next delta builds on (`push-delta.ts`). + const injectorDeltas = createReportDeltas(INJECTOR_TREE_SHAPE); + + return { + injectorTreeState, + injectorPages, + injectorDeltas, + withFramework, + frameworkNames, + latestInjectorPage, + applyInjectorPages, + }; +} + +export type InjectorState = Awaited>; + +/** What `registerInjectorRpc` reads from the devframe server's setup (`devframe.ts`). */ +export interface InjectorRpcEnv { + my: PangularScope; + register: Register; + owns: Owns; + mayForget: Owns; + answersFor: AnswersFor; + on: Record; + /** The signal stores of the source, named for the NgRx tools (`devframe-ngrx.ts`). */ + ngrxNames: () => NgrxDeclaration[]; + /** Marks a page that reported, for the change detection sessions (`devframe-components.ts`). */ + cdSeen: (pageId: string) => void; + state: InjectorState; +} + +export function registerInjectorRpc(env: InjectorRpcEnv) { + const { my, register, owns, mayForget, answersFor, on, ngrxNames, cdSeen } = env; + const { injectorPages, injectorDeltas, withFramework, frameworkNames, applyInjectorPages } = + env.state; + register({ + name: 'push-injector-tree', + type: 'action', + jsonSerializable: true, + handler: deltaPush(injectorDeltas, owns, (raw) => { + const report = raw as { + pageId?: unknown; + roots?: unknown; + environment?: unknown; + truncated?: unknown; + zone?: unknown; + } | null; + const pageId = report?.pageId; + if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return false; + if (!owns(pageId)) return false; + const zone = ZONE_MODES.find((mode) => mode === report?.zone); + const tree = sanitizeInjectorTree(report?.roots, report?.environment); + const names = frameworkNames.current(); + const page: InjectorPage = { + pageId, + roots: tree.roots, + environment: tree.environment, + ...(report?.truncated === true || tree.truncated + ? { truncated: true, limits: INJECTOR_TREE_LIMITS } + : {}), + ...(zone ? { zone } : {}), + reportedAt: Date.now(), + }; + // Signal stores get the names the NgRx tools use (rpc/store-tokens.ts). + const named = on.ngrx ? nameStoreTokens(page, ngrxNames()) : page; + injectorPages.set(pageId, withFramework(named, names ?? new Set())); + cdSeen(pageId); + applyInjectorPages(); + return true; + }), + }); + + register({ + name: 'ping-injector-tree', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + const page = owns(pageId) ? injectorPages.get(pageId) : undefined; + if (!page) return { known: false }; + injectorPages.set(page.pageId, { ...page, reportedAt: Date.now() }); + cdSeen(page.pageId); + return { known: true }; + }, + }); + + register({ + name: 'forget-injector-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (!mayForget(pageId)) return; + injectorDeltas.forget(pageId); + if (injectorPages.delete(pageId)) applyInjectorPages(); + }, + }); + + // The value of a provider an injector already created: the Injectors view and read-provider-value. + const providerValues = createProviderValues({ + pages: () => injectorPages.values(), + latest: () => defaultPage(injectorPages.values()), + broadcast: (method, message) => + void my.rpc.broadcast({ method, args: [message], optional: true }), + answersFor, + }); + register({ + name: 'provider-value-result', + type: 'action', + jsonSerializable: true, + handler: providerValues.answer, + }); + register({ + name: 'request-provider-value', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; request?: unknown } | null) => + providerValues.request(message?.pageId, message?.request), + }); + + // The panel asks the page to read an element injector again (the one it + // selected), or every injector (Refresh). The page pushes a fresh tree. + register({ + name: 'request-injector-refresh', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; injectorId?: unknown } | null) => + requestInjectorRefresh( + message, + injectorPages.keys(), + (method, args) => void my.rpc.broadcast({ method, args: [args], optional: true }), + ), + }); + + return providerValues; +} diff --git a/packages/devtools/src/devframe-inspectors.ts b/packages/devtools/src/devframe-inspectors.ts new file mode 100644 index 00000000..e6a76d61 --- /dev/null +++ b/packages/devtools/src/devframe-inspectors.ts @@ -0,0 +1,194 @@ +// The inspectors that register themselves (`rpc/-register.ts`), each +// only when the config turns it on. + +import { agentTags, actionBlockedMessage, type ResolvedPangularConfig } from './config.ts'; +import type { HubFeed } from './hub-messages.ts'; +import type { PangularServerPlugin } from './plugin.ts'; +import { answeringText } from './rpc/agent-output.ts'; +import { registerErrors } from './rpc/errors-register.ts'; +import { registerIonic } from './rpc/ionic-register.ts'; +import { registerLeaks } from './rpc/leaks-register.ts'; +import { leased, type PageInterest } from './rpc/page-interest.ts'; +import type { PageTtl } from './rpc/page-ttl.ts'; +import { registerPerformance } from './rpc/perf-register.ts'; +import { registerPlugins } from './rpc/plugins-register.ts'; +import { registerTanstackQuery } from './rpc/query-register.ts'; +import { registerReduxStores } from './rpc/redux-stores-register.ts'; +import { registerServerErrors } from './rpc/server-errors-register.ts'; +import { registerStartup } from './rpc/startup-register.ts'; +import { registerBuild } from './rpc/build-register.ts'; +import { guardHandler } from './rpc/tool-input.ts'; +import type { + AgentRegistrar, + AnswersFor, + DevframeNodeContext, + Owns, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** What `registerInspectors` reads from the devframe server's setup (`devframe.ts`). */ +export interface InspectorsEnv { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + agent: AgentRegistrar; + feed: HubFeed; + interest: PageInterest; + owns: Owns; + mayForget: Owns; + answersFor: AnswersFor; + liveTtl: PageTtl; + pageTools: boolean; + /** When a page last beat (`rpc/page-heartbeats.ts`). */ + seenAt: (pageId: string) => number | undefined; + serverPlugins: readonly PangularServerPlugin[] | undefined; +} + +/** Errors, performance, build, leaks, startup, Ionic, plugins, Redux stores and TanStack Query. */ +export async function registerInspectors(env: InspectorsEnv) { + const { + ctx, + my, + config, + register, + agent, + feed, + interest, + owns, + mayForget, + answersFor, + liveTtl, + pageTools, + seenAt, + serverPlugins, + } = env; + const on = config.inspectors; + const limits = config.limits; + const errorsInspector = on.errors + ? await registerErrors({ + my, + rpcHost: ctx.rpc, + register, + agent, + owns, + mayForget, + ttl: liveTtl, + }) + : null; + // SSR and Vite errors of this process (`server-errors.ts`); no page reports them. + const serverErrors = on.errors + ? await registerServerErrors({ my, register, agent, capture: pageTools }) + : null; + const perfInspector = on.performance + ? await registerPerformance({ + my, + rpcHost: ctx.rpc, + register, + agent, + owns, + mayForget, + ttl: liveTtl, + }) + : null; + if (on.build) registerBuild({ register, agent, root: ctx.cwd }); + const leaksInspector = on.components + ? await registerLeaks({ + my, + rpcHost: ctx.rpc, + register, + agent, + owns, + mayForget, + ttl: liveTtl, + }) + : null; + const startupInspector = on.injectors + ? await registerStartup({ + my, + rpcHost: ctx.rpc, + register, + agent, + owns, + mayForget, + ttl: liveTtl, + }) + : null; + // The `ion-router-outlet` stacks of Ionic pages, shown in the Capacitor tab. + const ionicInspector = on.capacitor + ? await registerIonic({ my, rpcHost: ctx.rpc, register, owns, mayForget, ttl: liveTtl }) + : null; + // A plugin tool's tier comes from the plugin; `pluginToolAllowed` checks it against the config. + const pluginsInspector = on.plugins + ? await registerPlugins({ + my, + rpcHost: ctx.rpc, + config, + register, + agent, + registerPluginTool: (tool, safety) => + ctx.agent.registerTool( + feed.wrapTool({ + ...(tool as Parameters[0]), + outputSchema: undefined, + safety, + tags: agentTags(String(tool['id'])), + handler: answeringText( + guardHandler( + tool['inputSchema'], + leased(() => interest, tool['handler']), + ), + ), + }), + ), + pageTools, + owns, + mayForget, + ttl: liveTtl, + seenAt, + serverPlugins, + }) + : null; + + const reduxStores = on.ngrx + ? await registerReduxStores({ + my, + rpcHost: ctx.rpc, + register, + agent, + owns, + mayForget, + ttl: liveTtl, + maxLog: limits.changeLog, + }) + : null; + const tanstackQuery = on.tanstackQuery + ? await registerTanstackQuery({ + my, + rpcHost: ctx.rpc, + register, + agent, + owns, + mayForget, + answersFor, + ttl: liveTtl, + writes: config.actions.tanstackQuery, + blocked: actionBlockedMessage('tanstackQuery'), + }) + : null; + + return { + errorsInspector, + serverErrors, + perfInspector, + leaksInspector, + startupInspector, + ionicInspector, + pluginsInspector, + reduxStores, + tanstackQuery, + }; +} + +export type Inspectors = Awaited>; diff --git a/packages/devtools/src/devframe-ngrx.ts b/packages/devtools/src/devframe-ngrx.ts new file mode 100644 index 00000000..2df69802 --- /dev/null +++ b/packages/devtools/src/devframe-ngrx.ts @@ -0,0 +1,369 @@ +// The NgRx inspector on the devframe server (`devframe.ts`): the `ngrx-store` +// shared state, the requests a page answers (dispatch, history, sessions) and +// the signal store and Store agent tools. + +import { actionBlockedMessage, type ResolvedPangularConfig } from './config.ts'; +import { + dispatchProblem, + type NgrxRequest, + type NgrxRequestResult, + type NgrxState, +} from './ngrx-shared.ts'; +import { defaultPage } from './rpc/default-page.ts'; +import { scanNgrxStore } from './rpc/get-ngrx-store.ts'; +import { createNgrxDevtoolsTools } from './rpc/ngrx-devtools-tools.ts'; +import { + INSPECT_SIGNAL_STORE_DESCRIPTION, + SIGNAL_STORE_HISTORY_DESCRIPTION, + inspectSignalStoreText, + signalStoreHistoryText, + storeIdNamed, + withUntrustedPreamble, +} from './rpc/ngrx-live-tools.ts'; +import { createNgrxHistory } from './rpc/ngrx-session-tools.ts'; +import { + dispatchResultText, + isNgrxReport, + mergeNgrxReport, + ngrxStateOf, + type NgrxDeclaration, + type NgrxPages, +} from './rpc/ngrx-tools.ts'; +import { + PAGE_ARGUMENT, + byRecency, + choosePageText, + pageArgument, + unknownPageText, +} from './rpc/pages.ts'; +import { refusal } from './rpc/refusal.ts'; +import type { PageDirectory } from './devframe-pages.ts'; +import type { + AgentRegistrar, + AnswersFor, + DevframeNodeContext, + Owns, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** The NgRx state of each page, before any RPC function reads it. */ +export async function createNgrxState({ + ctx, + my, +}: { + ctx: DevframeNodeContext; + my: PangularScope; +}) { + const ngrxStoreState = await my.rpc.sharedState('ngrx-store', { + initialValue: { pages: [] } as NgrxState, + }); + const ngrxPages: NgrxPages = new Map(); + /** The page an NgRx read tool reads: `page`; else, unless `store` names a store, the default page. */ + const ngrxReadPage = (args: { page?: string; store?: string } | undefined) => + args?.page || (args?.store ? undefined : defaultPage(ngrxPages.values())?.pageId); + let ngrxDeclarations: { at: number; list: NgrxDeclaration[] } = { at: 0, list: [] }; + const ngrxNames = () => { + if (Date.now() - ngrxDeclarations.at > 10_000) { + let list: NgrxDeclaration[] = []; + try { + list = scanNgrxStore(ctx.cwd).filter((e) => e.kind === 'signal-store'); + } catch { + list = []; + } + ngrxDeclarations = { at: Date.now(), list }; + } + return ngrxDeclarations.list; + }; + const applyNgrx = () => + ngrxStoreState.mutate((draft) => { + draft.pages = ngrxStateOf(ngrxPages).pages as never; + }); + + return { ngrxStoreState, ngrxPages, ngrxReadPage, ngrxNames, applyNgrx }; +} + +export type NgrxStateHolder = Awaited>; + +/** What `registerNgrxRpc` reads from the devframe server's setup (`devframe.ts`). */ +export interface NgrxRpcEnv { + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + owns: Owns; + mayForget: Owns; + answersFor: AnswersFor; + withPageUrls: PageDirectory['withPageUrls']; + state: NgrxStateHolder; +} + +/** The pushes and the requests a page answers: actions, history, sessions. */ +export function registerNgrxRpc(env: NgrxRpcEnv) { + const { my, config, register, owns, mayForget, answersFor, withPageUrls } = env; + const limits = config.limits; + const { ngrxPages, ngrxNames, applyNgrx } = env.state; + register({ + name: 'push-ngrx-state', + type: 'action', + jsonSerializable: true, + handler: (report: unknown) => { + if (!isNgrxReport(report) || !owns(report.pageId)) return { seq: 0 }; + const seq = mergeNgrxReport(ngrxPages, report, ngrxNames(), Date.now(), limits.changeLog); + applyNgrx(); + return { seq }; + }, + }); + + register({ + name: 'forget-ngrx-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (mayForget(pageId) && ngrxPages.delete(pageId)) applyNgrx(); + }, + }); + + const pendingNgrx = new Map void }>(); + let ngrxSeq = 0; + + register({ + name: 'ngrx-action-result', + type: 'action', + jsonSerializable: true, + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { + if (typeof message?.requestId !== 'string') return; + const pending = pendingNgrx.get(message.requestId); + if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); + }, + }); + + const requestNgrxAction = (page: unknown, request: unknown) => + new Promise((resolve) => { + const kind = (request as { type?: unknown } | null)?.type; + // Reading an entry's state or exporting a session changes nothing, so writes off allow it. + const reads = kind === 'entry' || kind === 'export'; + if (!reads && !config.actions.ngrx) return resolve({ error: actionBlockedMessage('ngrx') }); + const all = ngrxStateOf(ngrxPages).pages; + // A restore can target a signal store; every other request needs the classic Store. + const pages = kind === 'restore' || reads ? all : all.filter((p) => p.classic); + // Without a page, a write would reach every tab, or one the caller did not choose. + if (typeof page !== 'string' && pages.length !== 1 && !(reads && pages.length)) { + return resolve({ + error: pages.length + ? choosePageText(withPageUrls(pages), 'NgRx state') + : 'No page reports NgRx state. Open the app in a browser, or pass the page id.', + }); + } + if (typeof page === 'string' && !all.some((p) => p.pageId === page)) { + return resolve({ + error: `Page \`${page.slice(0, 50)}\` reports no NgRx state. pangular:list-pages lists the pages that do.`, + }); + } + const pageId = typeof page === 'string' ? page : defaultPage(pages)!.pageId; + const requestId = `n${++ngrxSeq}`; + const timer = setTimeout(() => { + pendingNgrx.delete(requestId); + resolve({ + error: + 'No page answered within 10s. Is the app open in a browser, or the device app in the foreground?', + }); + }, 10_000); + timer.unref?.(); + pendingNgrx.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingNgrx.delete(requestId); + resolve((result ?? {}) as NgrxRequestResult); + }, + }); + void my.rpc.broadcast({ + method: 'ngrx-action', + args: [{ requestId, pageId, request }], + optional: true, + }); + }); + + register({ + name: 'request-ngrx-action', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; request?: unknown }) => + requestNgrxAction(message?.pageId, message?.request), + }); + + // Past entries, session export and import, and generated tests (rpc/ngrx-session-tools.ts). + const ngrxHistory = createNgrxHistory({ + ask: (page, request) => requestNgrxAction(page, request), + page: (pageId) => ngrxPages.get(pageId), + pages: () => byRecency(ngrxPages.values()), + }); + register({ + name: 'request-ngrx-history', + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => ngrxHistory.history(message), + }); + register({ + name: 'import-ngrx-session', + type: 'action', + jsonSerializable: true, + handler: (message: { session?: unknown } | null) => ngrxHistory.importSession(message?.session), + }); + + return { requestNgrxAction, ngrxHistory }; +} + +export type NgrxRpc = ReturnType; + +/** The signal store read tools: `ngrx-inspect-store` and `ngrx-history`. */ +export function registerNgrxReadTools(env: { + agent: AgentRegistrar; + state: NgrxStateHolder; +}): void { + const { agent } = env; + const { ngrxPages, ngrxReadPage } = env.state; + const ngrxPageProperty = { + type: 'string', + description: 'Page id, when more than one tab reports. Defaults to every page.', + } as const; + + agent.registerTool({ + id: 'pangular:ngrx-inspect-store', + title: 'Inspect a signal store', + description: INSPECT_SIGNAL_STORE_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + page: ngrxPageProperty, + store: { + type: 'string', + description: + 'Store id or name (e.g. `ngrx-1` or `CartStore`, as shown on the NgRx Store page or returned by a previous call). Omit for a summary of every store across the matching page(s).', + }, + }, + }, + handler: async (args: { page?: string; store?: string }) => ({ + markdown: withUntrustedPreamble( + inspectSignalStoreText( + ngrxPages, + ngrxReadPage(args), + storeIdNamed(ngrxPages, ngrxReadPage(args), args?.store), + ), + ), + }), + }); + + agent.registerTool({ + id: 'pangular:ngrx-history', + title: 'Show signal store history', + description: SIGNAL_STORE_HISTORY_DESCRIPTION, + inputSchema: { + type: 'object', + properties: { + page: ngrxPageProperty, + store: { + type: 'string', + description: + 'Store id or name to filter the log to. Omit to include every store, plus `@ngrx/signals/events` events with no store effect.', + }, + after: { + type: 'number', + description: + 'Only return entries whose `seq` is strictly greater than this. Pass the last `seq` from a previous call to poll.', + }, + }, + }, + handler: async (args: { page?: string; store?: string; after?: number }) => ({ + markdown: withUntrustedPreamble( + signalStoreHistoryText( + ngrxPages, + ngrxReadPage(args), + storeIdNamed(ngrxPages, ngrxReadPage(args), args?.store), + args?.after, + ), + ), + }), + }); +} + +/** `ngrx-dispatch`, the session tools and the Store DevTools tools. */ +export function registerNgrxDispatchTools(env: { + agent: AgentRegistrar; + state: NgrxStateHolder; + rpc: NgrxRpc; + withPageUrls: PageDirectory['withPageUrls']; +}): void { + const { agent, withPageUrls } = env; + const { ngrxPages } = env.state; + const { requestNgrxAction, ngrxHistory } = env.rpc; + agent.registerTool({ + id: 'pangular:ngrx-dispatch', + title: 'Dispatch an NgRx action', + description: + 'Dispatch an action (`type`, `payload`) or redispatch a logged one (`seq`) to the @ngrx/store Store (dev only); returns the log entry with origin and diff. Needs `page` when several tabs have a Store, and actions.ngrx.', + inputSchema: { + type: 'object', + properties: { + page: PAGE_ARGUMENT, + type: { type: 'string', description: 'Action type, for example "[Cart] Add Item".' }, + payload: { + type: 'object', + description: 'Action props as a JSON object, for example {"id": 7}. No "type" key.', + }, + seq: { + type: 'integer', + description: 'Log entry number of an action to dispatch again, instead of type.', + }, + }, + }, + handler: async (args: { page?: string; type?: unknown; payload?: unknown; seq?: unknown }) => { + const pages = ngrxStateOf(ngrxPages).pages; + const page = pageArgument(args); + if (page && !ngrxPages.has(page)) { + return { markdown: unknownPageText(page, pages, 'NgRx state') }; + } + const stores = pages.filter((p) => p.classic); + // A dispatch changes the app, so it never picks one of several tabs on its own. + if (!page && stores.length > 1) { + return { markdown: choosePageText(withPageUrls(stores), 'an @ngrx/store Store') }; + } + const target = page ? ngrxPages.get(page) : defaultPage(stores); + if (!target?.classic) { + return { + markdown: refusal( + page + ? `Page \`${page}\` has no @ngrx/store Store, so there is nothing to dispatch to.` + : 'No connected page has an @ngrx/store Store. Open the app in a browser; the Store shows up once provideStore() or StoreModule.forRoot() runs.', + ), + }; + } + let request: NgrxRequest; + if (args.seq !== undefined) { + if (!Number.isInteger(args.seq)) + return { markdown: refusal('seq must be a log entry number.') }; + request = { type: 'dispatch-again', seq: args.seq as number }; + } else { + const problem = dispatchProblem(args.type, args.payload); + if (problem) return { markdown: refusal(problem) }; + request = { + type: 'dispatch', + action: args.type as string, + ...(args.payload !== undefined + ? { payload: args.payload as Record } + : {}), + }; + } + return { markdown: dispatchResultText(await requestNgrxAction(target.pageId, request)) }; + }, + }); + + for (const tool of ngrxHistory.tools) agent.registerTool(tool); + // Store DevTools history operations and the explain-change read (rpc/ngrx-devtools-tools.ts). + for (const tool of createNgrxDevtoolsTools({ + ask: (page, request) => requestNgrxAction(page, request), + pages: () => ngrxPages, + })) { + agent.registerTool(tool); + } +} diff --git a/packages/devtools/src/devframe-pages.ts b/packages/devtools/src/devframe-pages.ts new file mode 100644 index 00000000..446554c0 --- /dev/null +++ b/packages/devtools/src/devframe-pages.ts @@ -0,0 +1,399 @@ +// The page registry of the devframe server's setup (`devframe.ts`): which +// connection owns each page id, which pages beat, which are in the background, +// which a panel or an agent watches, and the URL, title and inspectors of every +// page that reports. + +import { + BROWSER_TOOLS_CHANGED, + BROWSER_TOOLS_STATE, + createBrowserToolsSwitch, + registerBrowserTools, + type BrowserToolHost, +} from './rpc/browser-tools.ts'; +import type { ResolvedPangularConfig } from './config.ts'; +import { REDACTION_CONFIG_RPC } from './overlay-redaction.ts'; +import { createAngularPages, sanitizeAngularReport } from './rpc/angular-pages.ts'; +import { analogPages } from './rpc/analog-register.ts'; +import { createPageOrder, setPageOrder } from './rpc/default-page.ts'; +import { createPageHeartbeats } from './rpc/page-heartbeats.ts'; +import { pageHighlightTarget } from './rpc/page-highlight-target.ts'; +import { INTEREST_TICK_MS, createPageInterest } from './rpc/page-interest.ts'; +import { createPageOwners } from './rpc/page-owners.ts'; +import { trackPageSessions } from './rpc/page-sessions.ts'; +import { createPageVisibility } from './rpc/page-ttl.ts'; +import { matchForms, type FormsState } from './rpc/forms-tools.ts'; +import type { FieldArgs } from './rpc/forms-explain.ts'; +import { pageDetails, summarizePages } from './rpc/pages.ts'; +import type { CapacitorPage } from './rpc/capacitor-tools.ts'; +import type { NgrxPages } from './rpc/ngrx-tools.ts'; +import type { PipePageReport } from './rpc/pipes-tools.ts'; +import type { PageReport } from './rpc/forms-tools.ts'; +import type { RouterPage } from './rpc/router-tools.ts'; +import type { ComponentPage, HttpPage, InjectorPage } from './types.ts'; +import type { Inspectors } from './devframe-inspectors.ts'; +import type { DevframeNodeContext, PageGraph, PangularScope, Register } from './devframe-env.ts'; + +/** What `registerPageRegistry` reads from the devframe server's setup (`devframe.ts`). */ +export interface PageRegistryEnv { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + /** A beat from a page with forms counts as its forms report (`devframe-forms.ts`). */ + touchFormsPage: (pageId: string, now?: number) => boolean; + /** A beat from a page with a router counts as its router report (`devframe-router.ts`). */ + touchRouter: (pageId: string, now?: number) => boolean; +} + +/** + * The pages themselves: heartbeats, background marks, owners, the default page + * order and the lease, with the RPC functions the overlay claims, beats and + * reports its Angular version through, and the browser tools switch. + */ +export async function registerPageRegistry(env: PageRegistryEnv) { + const { ctx, my, config, register, touchFormsPage, touchRouter } = env; + const limits = config.limits; + // Tabs in the background stop reporting, so their last data stays until + // they come back or close. + // Pages that beat keep all their reports, so they send only what changed. + const heartbeats = createPageHeartbeats(); + const visibility = createPageVisibility((pageId) => heartbeats.alive(pageId)); + const liveTtl = visibility.ttl(); + const visibilityState = await my.rpc.sharedState('page-visibility', { + initialValue: { hidden: [] as string[] }, + }); + const applyVisibility = () => + visibilityState.mutate((draft) => { + draft.hidden = visibility.list(); + }); + // One connection owns each page id, so one tab (or a stray client) can't + // overwrite or forget the pages another one reports. + const owners = createPageOwners(ctx.rpc, liveTtl); + // The default page of every tool: foreground first, then the newest connection. + const pageOrder = createPageOrder(() => visibility.list()); + setPageOrder(pageOrder.rank); + const owns = (pageId: unknown): pageId is string => { + if (typeof pageId !== 'string' || !owners.write(pageId)) return false; + pageOrder.seen(pageId); + return true; + }; + // A request is settled only by the page it was sent to, from the connection + // that owns that page, so another client can't answer for it. + const answersFor = (target: string | undefined, pageId: unknown) => + (target === undefined || pageId === target) && owns(pageId); + + // Pages push their heavy reports only while a panel or an agent watches. + const interest = createPageInterest({ + rpcHost: ctx.rpc, + leaseMs: limits.idleAfterMs, + hidden: (pageId) => visibility.has(pageId), + broadcast: (message) => + void my.rpc + .broadcast({ method: 'page-interest-changed', args: [message], optional: true }) + ?.catch?.(() => {}), + }); + register({ + name: 'page-interest', + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => interest.handle(message), + }); + register({ + name: 'page-heartbeat', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown } | null) => { + const pageId = message?.pageId; + if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return undefined; + if (!owners.write(pageId)) return undefined; + // The forms and router tools call a page stale by its last report, so + // a beat counts as their report, in the shared states too. + const now = Date.now(); + touchFormsPage(pageId, now); + touchRouter(pageId, now); + const known = heartbeats.beat(pageId, now, visibility.has); + return { known, ...interest.handle({ pageId }) }; + }, + }); + const interestTick = setInterval(() => interest.tick(), INTEREST_TICK_MS); + interestTick.unref?.(); + // A page that left is not waited for when an agent wakes the pages. + const mayForget = (pageId: unknown): pageId is string => { + if (typeof pageId !== 'string' || !owners.forget(pageId)) return false; + interest.forget(pageId); + pageOrder.forget(pageId); + return true; + }; + + // The `read` agent tools, for the page to register with browser agents. + // `agent.browserTools` sets where the switch starts; the panel's Settings + // flips it for every page until the server restarts. + const browserToolsState = await my.rpc.sharedState(BROWSER_TOOLS_STATE, { + initialValue: { enabled: config.agent.browserTools, config: config.agent.browserTools }, + }); + const browserToolsSwitch = createBrowserToolsSwitch(config.agent.browserTools, (state) => { + browserToolsState.mutate((draft) => { + draft.enabled = state.enabled; + draft.config = state.config; + }); + void my.rpc + .broadcast({ method: BROWSER_TOOLS_CHANGED, args: [state], optional: true }) + ?.catch?.(() => {}); + }); + registerBrowserTools(register, ctx as unknown as BrowserToolHost, { + enabled: browserToolsSwitch.enabled, + owns, + switch: browserToolsSwitch, + }); + + // Not in `__connection.json`: only a trusted page learns which names are secret. + register({ + name: REDACTION_CONFIG_RPC, + type: 'query', + jsonSerializable: true, + handler: () => config.redaction, + }); + + let onPageClaimed = (_pageId: string) => {}; + register({ + name: 'claim-page', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; key?: unknown } | null) => { + const pageId = message?.pageId; + if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return { ok: false }; + const ok = owners.claim(pageId, message?.key); + // A claim comes before the page sends everything, so its first beat finds it known. + if (ok) heartbeats.beat(pageId); + // The overlay claims once per load: what the audit holds of the old document is stale. + if (ok) onPageClaimed(pageId); + return { ok }; + }, + }); + + register({ + name: 'report-page-visibility', + type: 'action', + jsonSerializable: true, + handler: (report: { pageId?: unknown; hidden?: unknown } | null) => { + const pageId = report?.pageId; + if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return; + if (!owners.write(pageId)) return; + heartbeats.touch(pageId); + if (visibility.set(pageId, report?.hidden === true)) applyVisibility(); + }, + }); + + // Each page's Angular version and debug API parts, so a view can say why it is empty. + const angularPages = createAngularPages(); + const pageAngularState = await my.rpc.sharedState('page-angular', { + initialValue: angularPages.snapshot(), + }); + const applyAngularPages = () => + pageAngularState.mutate((draft) => { + draft.pages = angularPages.snapshot().pages; + }); + register({ + name: 'report-page-angular', + type: 'action', + jsonSerializable: true, + handler: (report: unknown) => { + const info = sanitizeAngularReport(report); + if (!info || !owns(info.pageId)) return; + if (angularPages.set(info)) applyAngularPages(); + }, + }); + register({ + name: 'forget-page-angular', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (mayForget(pageId) && angularPages.forget(pageId)) applyAngularPages(); + }, + }); + + return { + heartbeats, + visibility, + liveTtl, + applyVisibility, + owners, + owns, + answersFor, + interest, + interestTick, + mayForget, + angularPages, + applyAngularPages, + /** What a claim also does, once the audit exists. */ + onPageClaimed: (claimed: (pageId: string) => void) => { + onPageClaimed = claimed; + }, + }; +} + +export type PageRegistry = Awaited>; + +/** The form field and page highlights the panel asks for; a closed panel takes its highlight with it. */ +export function registerHighlights(env: { + ctx: DevframeNodeContext; + my: PangularScope; + register: Register; +}): void { + const { ctx, my, register } = env; + const highlightSessions = trackPageSessions(ctx.rpc, (kinds) => { + for (const method of kinds) void my.rpc.broadcast({ method, args: [null], optional: true }); + }); + + register({ + name: 'request-form-highlight', + type: 'action', + jsonSerializable: true, + handler: (target: { formId: string; path: string } | null) => { + if (target) highlightSessions.bind('highlight-form-field'); + else highlightSessions.unbind('highlight-form-field'); + void my.rpc.broadcast({ + method: 'highlight-form-field', + args: [target], + optional: true, + }); + }, + }); + + register({ + name: 'request-page-highlight', + type: 'action', + jsonSerializable: true, + handler: (selector: unknown) => { + const target = pageHighlightTarget(selector); + if (target) highlightSessions.bind('highlight-in-page'); + else highlightSessions.unbind('highlight-in-page'); + void my.rpc.broadcast({ method: 'highlight-in-page', args: [target], optional: true }); + }, + }); +} + +/** What `createPageDirectory` reads from the devframe server's setup (`devframe.ts`). */ +export interface PageDirectoryEnv { + config: ResolvedPangularConfig; + pages: PageRegistry; + inspectors: Inspectors; + componentPages: Map; + signalPages: Map; + injectorPages: Map; + ngrxPages: NgrxPages; + formPages: Map; + pipePages: Map; + capacitorPages: Map; + routerPages: Map; + httpPages: Map; +} + +/** Every reporting page with its URL and title, merged across the inspectors. */ +export function createPageDirectory(env: PageDirectoryEnv) { + const { + componentPages, + signalPages, + injectorPages, + ngrxPages, + formPages, + pipePages, + capacitorPages, + routerPages, + httpPages, + } = env; + const { heartbeats, angularPages, visibility } = env.pages; + const { + reduxStores, + tanstackQuery, + errorsInspector, + startupInspector, + ionicInspector, + pluginsInspector, + } = env.inspectors; + const on = env.config.inspectors; + // The URL and title of each page, from its router, HTTP or component report. + const livePageDetails = () => + pageDetails({ + router: [...routerPages.values()].map((page) => ({ + pageId: page.pageId, + reportedAt: page.reportedAt, + url: page.snapshot?.url, + })), + http: httpPages.values(), + components: componentPages.values(), + }); + /** The address of the page a form is on, for the Playwright test export. */ + function formPageUrl(state: FormsState, args: FieldArgs): string | undefined { + const forms = matchForms(state.forms, args.form, args.page); + const pageId = (forms[0]?.id ?? '').split('@')[1] ?? args.page; + return pageId ? livePageDetails().get(pageId)?.url : undefined; + } + const withPageUrls = (pages: T[]) => { + const details = livePageDetails(); + return pages.map((page) => { + const detail = details.get(page.pageId); + return { + pageId: page.pageId, + reportedAt: page.reportedAt, + url: detail?.url, + // The Audit page picker names a page by its title before its URL. + ...(detail?.title ? { title: detail.title } : {}), + }; + }); + }; + + // Every reporting page, merged across inspectors, newest first. + const livePages = () => { + const details = livePageDetails(); + const platforms = new Map(); + for (const page of componentPages.values()) { + if (page.platform) platforms.set(page.pageId, page.platform); + } + for (const page of capacitorPages.values()) { + if (page.info.bridge && !platforms.has(page.pageId)) { + platforms.set(page.pageId, `capacitor-${page.info.platform}`); + } + } + const withUrl = < + T extends { pageId: string; reportedAt: number; url?: string; title?: string }, + >( + pages: Iterable, + ) => + [...pages].map((page) => ({ + pageId: page.pageId, + // An idle page sends only its heartbeat, which counts as a report here. + reportedAt: Math.max(page.reportedAt, heartbeats.seenAt(page.pageId) ?? 0), + // A page only one inspector reports, such as Ionic, still names its own URL and title. + ...(page.url !== undefined ? { url: page.url } : {}), + ...(page.title !== undefined ? { title: page.title } : {}), + ...details.get(page.pageId), + platform: platforms.get(page.pageId), + angular: angularPages.get(page.pageId)?.version, + })); + return summarizePages( + { + components: withUrl(componentPages.values()), + signals: withUrl([...signalPages].map(([pageId, entry]) => ({ pageId, ...entry }))), + injectors: withUrl(injectorPages.values()), + ngrx: withUrl([...ngrxPages.values(), ...(reduxStores?.pages() ?? [])]), + tanstackQuery: withUrl(tanstackQuery?.pages() ?? []), + forms: withUrl(formPages.values()), + router: withUrl(routerPages.values()), + pipes: withUrl(pipePages.values()), + http: withUrl(httpPages.values()), + capacitor: withUrl(capacitorPages.values()), + errors: withUrl(errorsInspector?.pages() ?? []), + startup: withUrl(startupInspector?.pages() ?? []), + ionic: withUrl(ionicInspector?.pages() ?? []), + plugins: withUrl(pluginsInspector?.pages() ?? []), + analog: withUrl(on.analog ? analogPages() : []), + }, + visibility.list(), + ); + }; + + return { livePageDetails, formPageUrl, withPageUrls, livePages }; +} + +export type PageDirectory = ReturnType; diff --git a/packages/devtools/src/devframe-pipes.ts b/packages/devtools/src/devframe-pipes.ts new file mode 100644 index 00000000..0bc4d738 --- /dev/null +++ b/packages/devtools/src/devframe-pipes.ts @@ -0,0 +1,112 @@ +// The Pipes inspector on the devframe server (`devframe.ts`): the +// `pipe-usage` shared state, its push and forget, recording requests and the +// pipe lint. Its agent tools live in `devframe-forms-tools.ts`. + +import type { ResolvedPangularConfig } from './config.ts'; +import { lintPipes } from './rpc/pipe-lint.ts'; +import { trackPageSessions } from './rpc/page-sessions.ts'; +import type { PageTtl } from './rpc/page-ttl.ts'; +import { + currentPipes, + isPipePageReport, + mergePipePageReport, + pipeInstrumentRequest, + type PipePageReport, + type PipesState, +} from './rpc/pipes-tools.ts'; +import { createPushAcks } from './rpc/push-acks.ts'; +import type { DevframeNodeContext, Owns, PangularScope, Register } from './devframe-env.ts'; + +/** What `registerPipes` reads from the devframe server's setup (`devframe.ts`). */ +export interface PipesEnv { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + owns: Owns; + mayForget: Owns; + liveTtl: PageTtl; +} + +export async function registerPipes(env: PipesEnv) { + const { ctx, my, config, register, owns, mayForget, liveTtl } = env; + const pipePages = new Map(); + const pipesState = await my.rpc.sharedState('pipe-usage', { + initialValue: { pipes: [], async: [], reportedAt: 0, instrumented: [] } as PipesState, + }); + + const applyPipes = (next: PipesState) => + pipesState.mutate((draft) => { + draft.pipes = next.pipes; + draft.async = next.async; + draft.reportedAt = next.reportedAt; + draft.instrumented = next.instrumented; + if (next.asyncTotal === undefined) delete draft.asyncTotal; + else draft.asyncTotal = next.asyncTotal; + if (next.pages === undefined) delete draft.pages; + else draft.pages = next.pages; + }); + + // A closed tab drops its connection at once, so its entries go with it + // instead of lingering until they expire. + const pipeSessions = trackPageSessions(ctx.rpc, (pageIds) => { + let removed = false; + for (const pageId of pageIds) removed = pipePages.delete(pageId) || removed; + if (removed) applyPipes(currentPipes(pipePages)); + }); + + register({ + name: 'push-pipes', + type: 'action', + jsonSerializable: true, + handler: (report: unknown) => { + if (!isPipePageReport(report) || !owns(report.pageId)) return; + pipeSessions.bind(report.pageId); + applyPipes(mergePipePageReport(pipePages, report, Date.now(), liveTtl)); + pipeAcks.notify(report.pageId, report); + }, + }); + + register({ + name: 'forget-pipes-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: string) => { + if (!mayForget(pageId)) return; + pipeSessions.unbind(pageId); + if (pipePages.delete(pageId)) { + applyPipes(currentPipes(pipePages)); + } + }, + }); + + // Shared by the panel's Record calls button and the record-pipes agent tool. + // A page confirms a recording request by pushing its pipe report again. + const pipeAcks = createPushAcks(); + const requestPipeRecording = (message: unknown) => { + const request = pipeInstrumentRequest(message, pipePages); + // No page has reported yet: starting now would patch every connected tab. + if (request.on && !request.pageId) return { request, pages: { pages: 0 } }; + void my.rpc.broadcast({ method: 'instrument-pipes', args: [request], optional: true }); + if (!request.pageId) return { request, pages: { pages: pipePages.size } }; + return { request, pages: { pages: pipePages.has(request.pageId) ? 1 : 0 } }; + }; + + register({ + name: 'request-instrument-pipes', + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => requestPipeRecording(message).pages, + }); + + register({ + name: 'pipe-lint', + type: 'query', + jsonSerializable: true, + // A source scan like scan-pipes, so a static report bakes it in too. + snapshot: true, + handler: () => lintPipes(ctx.cwd, { rules: config.lint.rules }), + }); + + return { pipePages, applyPipes, pipeAcks, requestPipeRecording }; +} diff --git a/packages/devtools/src/devframe-registrars.ts b/packages/devtools/src/devframe-registrars.ts new file mode 100644 index 00000000..795f7b98 --- /dev/null +++ b/packages/devtools/src/devframe-registrars.ts @@ -0,0 +1,127 @@ +// The setup's two registrars (`devframe.ts`): `register` for RPC functions and +// `agent` for agent tools and resources. Both skip what the config turns off; +// `agent` adds the tier, the tags and the page lease every tool gets. + +import { + agentAllowed, + agentSafety, + agentTags, + autoLibraries, + isPageAgentEntry, + rpcAllowed, + type PangularInspector, + type ResolvedPangularConfig, +} from './config.ts'; +import type { HubFeed } from './hub-messages.ts'; +import { answeringText } from './rpc/agent-output.ts'; +import { analogPages } from './rpc/analog-register.ts'; +import { isAnalogProject } from './rpc/analog-scan.ts'; +import { createLibraryGate, usedLibraries } from './rpc/library-tools.ts'; +import { leased, type PageInterest } from './rpc/page-interest.ts'; +import { namingPageRead } from './rpc/page-read-scope.ts'; +import { readsSource, refusingTool } from './rpc/project-root.ts'; +import { ToolRefusal } from './rpc/refusal.ts'; +import { guardHandler } from './rpc/tool-input.ts'; +import type { + AgentRegistrar, + AgentToolInput, + DevframeNodeContext, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** What `createRegistrars` reads from the devframe server's setup (`devframe.ts`). */ +export interface RegistrarsEnv { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + /** Register the tools that need a connected page; off for the stdio MCP server. */ + pageTools: boolean; + /** Why the source scans refuse, when the folder holds no Angular project. */ + rootRefusal: string | undefined; + feed: HubFeed; + /** Library inspectors the config leaves to the project (`autoLibraries`). */ + autoLibraries: readonly PangularInspector[] | undefined; + /** The page URL lookup as it stands when a tool registers. */ + pageUrl: () => (pageId: string) => string | undefined; + /** The page lease, which exists once the page registry does. */ + interest: () => PageInterest | undefined; + /** Whether a page reports Capacitor or TanStack Query data now. */ + reporting: { capacitor: () => boolean; tanstackQuery: () => boolean }; +} + +export function createRegistrars(env: RegistrarsEnv): { + register: Register; + agent: AgentRegistrar; +} { + const { ctx, my, config, pageTools, rootRefusal, feed } = env; + const on = config.inspectors; + const agentExposed = (entry: { id: string }) => + agentAllowed(entry, config) && (pageTools || !isPageAgentEntry(entry.id)); + const register: typeof my.rpc.register = (definition) => { + if (!rpcAllowed(definition.name, config)) return; + // A source scan of a folder without an Angular project refuses and names the folder. + if (rootRefusal && definition.agent) { + definition = { + ...definition, + handler: () => { + throw new ToolRefusal(rootRefusal); + }, + } as typeof definition; + } + const exposed = !definition.agent || agentAllowed({ id: definition.name }, config); + if (!exposed) return my.rpc.register({ ...definition, agent: undefined }); + const agent = definition.agent && { ...definition.agent, tags: agentTags(definition.name) }; + my.rpc.register(agent ? { ...definition, agent } : definition); + }; + // Analog, Capacitor and TanStack Query tools wait until the app uses the library. + const libraryGate = createLibraryGate({ + auto: env.autoLibraries ?? autoLibraries(), + used: usedLibraries(env.autoLibraries ?? autoLibraries(), [ctx.cwd, ctx.workspaceRoot], () => + isAnalogProject(ctx.cwd), + ), + reporting: { + analog: () => on.analog && analogPages().length > 0, + capacitor: env.reporting.capacitor, + tanstackQuery: env.reporting.tanstackQuery, + }, + register: (tool) => registerAgentTool(tool), + }); + const agent = { + registerTool: (tool: AgentToolInput) => { + if (agentExposed(tool) && !libraryGate.hold(tool)) registerAgentTool(tool); + }, + registerResource: (resource: Parameters[0]) => { + if (agentExposed(resource)) + ctx.agent.registerResource({ ...resource, read: leased(env.interest, resource.read) }); + }, + }; + function registerAgentTool(input: AgentToolInput) { + const tool = rootRefusal && readsSource(input.id) ? refusingTool(input, rootRefusal) : input; + // The tier and the tags come from the tool's entry in AGENT_ENTRIES, so + // the config is the one place that sets them. + ctx.agent.registerTool( + feed.wrapTool({ + ...tool, + // Every answer is one plain text block (rpc/agent-output.ts). + outputSchema: undefined, + safety: agentSafety(tool.id, config), + tags: agentTags(tool.id), + // Arguments are checked against `inputSchema` before the lease + // wakes any page, and refusals go out with `isError`. + handler: answeringText( + guardHandler( + tool.inputSchema, + // A live tool's answer names the page it read. + isPageAgentEntry(tool.id) + ? namingPageRead(leased(env.interest, tool.handler), env.pageUrl()) + : leased(env.interest, tool.handler), + tool.id, + ), + ), + }), + ); + } + + return { register, agent }; +} diff --git a/packages/devtools/src/devframe-resources.ts b/packages/devtools/src/devframe-resources.ts new file mode 100644 index 00000000..09f46fbc --- /dev/null +++ b/packages/devtools/src/devframe-resources.ts @@ -0,0 +1,154 @@ +// The live agent resources of the devframe server (`devframe.ts`), whole and +// one page at a time. + +import { createPageResourceSource, providePageResources } from './agent-page-resources.ts'; +import type { ComponentState } from './devframe-components.ts'; +import type { FormsStateHolder } from './devframe-forms.ts'; +import type { InjectorState } from './devframe-injectors.ts'; +import type { NgrxStateHolder } from './devframe-ngrx.ts'; +import type { SignalState } from './devframe-signals.ts'; +import { scanPipes } from './rpc/get-pipes.ts'; +import { formsResourceText, type FormsState } from './rpc/forms-tools.ts'; +import { injectorResourceText } from './rpc/injector-resource.ts'; +import type { PageInterest } from './rpc/page-interest.ts'; +import { currentPipes, type PipePageReport } from './rpc/pipes-tools.ts'; +import { LIVE_RESOURCE_NOTE, liveResourceText } from './rpc/resource-text.ts'; +import { routerResourceText, type RouterState } from './rpc/router-tools.ts'; +import { signalStateForAgents } from './rpc/signal-tools.ts'; +import type { AgentRegistrar, DevframeNodeContext, SharedState } from './devframe-env.ts'; + +/** What `registerLiveResources` reads from the devframe server's setup (`devframe.ts`). */ +export interface LiveResourcesEnv { + ctx: DevframeNodeContext; + agent: AgentRegistrar; + interest: PageInterest; + componentTree: ComponentState['componentTree']; + signalGraphState: SignalState['signalGraphState']; + injectorTreeState: InjectorState['injectorTreeState']; + injectorPages: InjectorState['injectorPages']; + ngrxStoreState: NgrxStateHolder['ngrxStoreState']; + formsState: FormsStateHolder['formsState']; + routerState: SharedState; + pipePages: Map; +} + +export function registerLiveResources(env: LiveResourcesEnv): void { + const { + ctx, + agent, + interest, + componentTree, + signalGraphState, + injectorTreeState, + injectorPages, + ngrxStoreState, + formsState, + routerState, + pipePages, + } = env; + agent.registerResource({ + id: 'pangular:component-tree', + name: 'Angular Component Tree', + description: + 'Live component instances per page as JSON: rendered instances, directive hosts, counts and the selected instance detail. Empty when no page is connected. ' + + LIVE_RESOURCE_NOTE, + mimeType: 'application/json', + read: () => ({ + text: liveResourceText( + componentTree.value(), + 'pangular:list-components and pangular:inspect-component', + ), + }), + }); + + agent.registerResource({ + id: 'pangular:signal-graph', + name: 'Angular Signal Graph', + description: + 'Live signal graph per page as JSON: nodes, edges (`consumer` reads `producer`, by node id), contexts, resources, value history. Empty with no page. ' + + LIVE_RESOURCE_NOTE, + mimeType: 'application/json', + read: () => ({ + text: liveResourceText( + signalStateForAgents(signalGraphState.value()), + 'pangular:inspect-signals', + ), + }), + }); + + agent.registerResource({ + id: 'pangular:injector-tree', + name: 'Angular Injector Tree', + description: + 'DI injectors per page as JSON: element and environment injectors with providers and dependencies, and the zone mode. Empty when no page is connected. Past 100,000 characters an outline with provider counts.', + mimeType: 'application/json', + read: () => ({ + text: injectorResourceText(injectorTreeState.value(), Object.fromEntries(injectorPages)), + }), + }); + + agent.registerResource({ + id: 'pangular:ngrx-store', + name: 'NgRx Store State', + description: + 'Live NgRx state per page as JSON: signal stores, classic Store state and the change log with diffs and action origins. Empty when no page is connected. ' + + LIVE_RESOURCE_NOTE, + mimeType: 'application/json', + read: () => ({ + text: liveResourceText( + ngrxStoreState.value(), + 'pangular:ngrx-inspect-store and pangular:ngrx-history', + ), + }), + }); + + agent.registerResource({ + id: 'pangular:forms', + name: 'Angular Forms', + description: + "Every form a connected page last reported (Signal Forms, reactive and template-driven), with each field's value, status, touched, dirty and errors, plus recent changes. The JSON starts with an `_untrusted` notice. Empty when no page is connected.", + mimeType: 'application/json', + read: () => ({ text: formsResourceText(formsState.value() as FormsState) }), + }); + + agent.registerResource({ + id: 'pangular:router', + name: 'Angular Router', + description: + 'The active route tree (params, data, guards, resolvers) and recent navigations of each connected page. The JSON starts with an `_untrusted` notice. Empty when no page is connected.', + mimeType: 'application/json', + read: () => ({ text: routerResourceText(routerState.value() as RouterState) }), + }); + + agent.registerResource({ + id: 'pangular:pipe-usages', + name: 'Angular Pipe Usages', + description: + 'Pipes as JSON: `source` has each pipe with purity and template uses; `pipes`/`async` the live page usage, with churn and stale findings while recording. ' + + LIVE_RESOURCE_NOTE, + mimeType: 'application/json', + read: () => ({ + text: liveResourceText( + { source: scanPipes(ctx.cwd), ...currentPipes(pipePages) }, + 'pangular:scan-pipes and pangular:explain-pipe', + [...pipePages.keys()], + ), + }), + }); + + // The same states one page at a time, for the MCP resource templates (`agent-mcp.ts`). + providePageResources( + ctx, + createPageResourceSource({ + states: { + 'component-tree': componentTree, + 'signal-graph': signalGraphState, + 'injector-tree': injectorTreeState, + 'ngrx-store': ngrxStoreState, + forms: formsState, + router: routerState, + }, + touch: () => interest.touch(), + }), + ); +} diff --git a/packages/devtools/src/devframe-router.ts b/packages/devtools/src/devframe-router.ts new file mode 100644 index 00000000..884d0d1d --- /dev/null +++ b/packages/devtools/src/devframe-router.ts @@ -0,0 +1,390 @@ +// The Router inspector on the devframe server (`devframe.ts`): the `router` +// shared state, the navigation stream, the router actions a page runs and the +// panel's lint, match, HTTP, preview, diff and export reads. Its agent tools +// live in `devframe-router-tools.ts`. + +import { + ROUTER_WRITE_ACTIONS, + actionBlockedMessage, + type ResolvedPangularConfig, +} from './config.ts'; +import type { HubFeed } from './hub-messages.ts'; +import { defaultPage } from './rpc/default-page.ts'; +import { extractRoutes } from './rpc/get-routes.ts'; +import type { PageOwners } from './rpc/page-owners.ts'; +import type { PageTtl } from './rpc/page-ttl.ts'; +import { routerStateForApp } from './rpc/router-apps.ts'; +import { exportNavigationText, matchUrl, routerLintResult } from './rpc/router-config-tools.ts'; +import { navigationDiffResult } from './rpc/router-diff.ts'; +import { callsForNavigation, nextNavigation } from './rpc/router-http.ts'; +import { routePreviewResult } from './rpc/router-preview.ts'; +import { NavigationStream } from './rpc/router-stream.ts'; +import { + clearRouterNavigations, + currentRouter, + isRouterReport, + mergeRouterReport, + touchRouterPage, + unknownRouterApp, + type RouterPage, + type RouterState, +} from './rpc/router-tools.ts'; +import { scanServerRoutes } from './rpc/server-routes.ts'; +import type { ComponentPage, HttpPage } from './types.ts'; +import type { + AnswersFor, + DevframeNodeContext, + Owns, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** What `registerRouter` reads from the devframe server's setup (`devframe.ts`). */ +export interface RouterEnv { + ctx: DevframeNodeContext; + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + owns: Owns; + mayForget: Owns; + answersFor: AnswersFor; + owners: PageOwners; + feed: HubFeed; + liveTtl: PageTtl; + componentPages: Map; + /** The HTTP inspector's pages (`devframe-http.ts`), for the per-navigation HTTP join. */ + httpPages: () => Map; +} + +export async function registerRouter(env: RouterEnv) { + const { + ctx, + my, + config, + register, + owns, + mayForget, + answersFor, + owners, + feed, + liveTtl, + componentPages, + httpPages, + } = env; + const on = config.inspectors; + const limits = config.limits; + const routerPages = new Map(); + /** Every navigation the pages report, numbered, for pangular:follow-navigations. */ + const navigationStream = new NavigationStream(); + /** Drops the stream entries and signatures of pages that expired or were forgotten. */ + const dropGoneStreamPages = () => { + for (const id of navigationStream.pageIds()) { + if (!routerPages.has(id)) navigationStream.clear(id); + } + }; + const routerState = await my.rpc.sharedState('router', { + initialValue: { pages: [] } as RouterState, + }); + + const applyRouter = (next: RouterState) => + routerState.mutate((draft) => { + draft.pages = next.pages; + }); + /** A sign of life from a page with a router: its report time, here and in the shared state. */ + const touchRouter = (pageId: string, now = Date.now()): boolean => { + if (!touchRouterPage(routerPages, pageId, now)) return false; + routerState.mutate((draft) => { + const page = draft.pages.find((p) => p.pageId === pageId); + if (page) page.reportedAt = now; + }); + return true; + }; + + register({ + name: 'push-router', + type: 'action', + jsonSerializable: true, + handler: (report: unknown) => { + if (!isRouterReport(report, limits.navigations)) return { hasConfig: false }; + if (!owns(report.pageId)) return { hasConfig: true }; + try { + applyRouter(mergeRouterReport(routerPages, report, Date.now(), liveTtl)); + } catch { + routerPages.delete(report.pageId); + applyRouter(currentRouter(routerPages)); + } + feed.navigations(report.pageId, report.navigations); + // Merging a report expires stale pages too. + dropGoneStreamPages(); + // A report that failed to merge left no page, so its navigations stay out of the stream. + if (routerPages.has(report.pageId)) { + navigationStream.recordReport(report); + } + return { hasConfig: !!routerPages.get(report.pageId)?.config }; + }, + }); + + register({ + name: 'ping-router', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => ({ known: owns(pageId) && touchRouter(pageId) }), + }); + + const pendingActions = new Map< + string, + { pageId: string | undefined; settle: (result: unknown) => void } + >(); + let actionSeq = 0; + + const defaultPageId = () => { + const state = routerState.value() as RouterState; + return (defaultPage(state.pages.filter((p) => p.snapshot)) ?? defaultPage(state.pages))?.pageId; + }; + + const requestRouterAction = (page: string | undefined, request: unknown, app?: string) => { + const action = (request as { action?: unknown } | undefined)?.action; + return !config.actions.router && ROUTER_WRITE_ACTIONS.includes(action as string) + ? Promise.resolve({ error: actionBlockedMessage('router') }) + : sendRouterAction(page, request, app); + }; + + const sendRouterAction = (page: string | undefined, request: unknown, app?: string) => + new Promise((resolve) => { + const pageId = page || defaultPageId(); + // No page could answer: say so now instead of after the 15 s wait. + const routed = (routerState.value() as RouterState).pages; + const known = page + ? routed.some((p) => p.pageId === page) || componentPages.has(page) || owners.alive(page) + : routed.length > 0 || componentPages.size > 0 || owners.live().length > 0; + if (!known) { + return resolve({ + error: page + ? `Page \`${page.slice(0, 50)}\` is not connected. pangular:list-pages lists the pages that are.` + : 'No page is connected. Open the app in a browser, or the device app in the foreground.', + }); + } + const requestId = `a${++actionSeq}`; + const timer = setTimeout(() => { + pendingActions.delete(requestId); + resolve({ + error: + 'No page answered within 15s. Is the app open in a browser, or the device app in the foreground?', + }); + }, 15_000); + timer.unref?.(); + pendingActions.set(requestId, { + pageId, + settle: (result) => { + clearTimeout(timer); + pendingActions.delete(requestId); + resolve(result); + }, + }); + void my.rpc.broadcast({ + method: 'router-action', + // `app` picks one app on a page that runs several (`router-apps.ts`). + args: [{ requestId, pageId, request, ...(app ? { app } : {}) }], + optional: true, + }); + }); + + register({ + name: 'router-action-result', + type: 'action', + jsonSerializable: true, + handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { + if (typeof message?.requestId !== 'string') return; + const pending = pendingActions.get(message.requestId); + if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); + }, + }); + + register({ + name: 'request-router-action', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: string; app?: unknown; request?: unknown }) => + requestRouterAction( + typeof message?.pageId === 'string' ? message.pageId : undefined, + message?.request, + typeof message?.app === 'string' ? message.app.slice(0, 60) : undefined, + ), + }); + + /** The router state with one page shown as one of its apps, for the panel's app picker. */ + const routerStateFor = (pageId: unknown, app: unknown): RouterState => { + const state = routerState.value() as RouterState; + if (typeof app !== 'string' || !app) return state; + const scoped = routerStateForApp(state, { + page: typeof pageId === 'string' ? pageId : undefined, + app, + }); + // An app the page does not have shows nothing, never the first app under its name. + return 'error' in scoped ? { pages: [] } : scoped.state; + }; + const pageFor = (pageId: unknown, app?: unknown) => { + const state = routerStateFor(pageId, app); + return typeof pageId === 'string' + ? state.pages.find((p) => p.pageId === pageId) + : (defaultPage(state.pages.filter((p) => p.snapshot)) ?? defaultPage(state.pages)); + }; + + register({ + name: 'router-lint', + type: 'query', + jsonSerializable: true, + // A page id, or `{ pageId, app }` for one app of a page that runs several. + handler: (target: unknown) => + routerLintResult( + target && typeof target === 'object' + ? pageFor((target as { pageId?: unknown }).pageId, (target as { app?: unknown }).app) + : pageFor(target), + { + rules: config.lint.rules, + httpFor: routerHttpCalls, + }, + ), + }); + + register({ + name: 'router-match', + type: 'query', + jsonSerializable: true, + handler: (message: { pageId?: unknown; app?: unknown; url?: unknown }) => { + const page = pageFor(message?.pageId, message?.app); + if (!page?.config || typeof message?.url !== 'string') return null; + return matchUrl(page.config, message.url.slice(0, 2000)); + }, + }); + + /** The HTTP inspector's calls of a page, for the router's per-navigation HTTP join. */ + const routerHttpCalls = (pageId: string) => + on.http ? httpPages().get(pageId)?.calls : undefined; + + register({ + name: 'router-http', + type: 'query', + jsonSerializable: true, + handler: (message: { pageId?: unknown; app?: unknown; id?: unknown }) => { + const page = pageFor(message?.pageId, message?.app); + const nav = page?.navigations.find((n) => n.id === message?.id); + if (!page || !nav) return null; + return callsForNavigation( + nav, + nextNavigation(page.navigations, nav), + routerHttpCalls(page.pageId), + ); + }, + }); + + /** The source routes and ServerRoute entries a preview names, empty when the scan fails. */ + const previewScans = () => { + let sources: ReturnType = []; + let serverRoutes: ReturnType = []; + try { + sources = extractRoutes(ctx.cwd); + } catch { + sources = []; + } + try { + serverRoutes = scanServerRoutes(ctx.cwd); + } catch { + serverRoutes = []; + } + return { sources, serverRoutes }; + }; + + register({ + name: 'router-preview', + type: 'query', + jsonSerializable: true, + handler: (message: { pageId?: unknown; app?: unknown; url?: unknown }) => { + const { sources, serverRoutes } = previewScans(); + return routePreviewResult( + routerStateFor(message?.pageId, message?.app), + { + page: typeof message?.pageId === 'string' ? message.pageId : undefined, + url: typeof message?.url === 'string' ? message.url : undefined, + }, + sources, + serverRoutes, + ); + }, + }); + + register({ + name: 'router-diff', + type: 'query', + jsonSerializable: true, + handler: (message: { pageId?: unknown; app?: unknown; a?: unknown; b?: unknown }) => + navigationDiffResult(routerStateFor(message?.pageId, message?.app), { + page: typeof message?.pageId === 'string' ? message.pageId : undefined, + a: typeof message?.a === 'number' ? message.a : undefined, + b: typeof message?.b === 'number' ? message.b : undefined, + }), + }); + + register({ + name: 'router-export', + type: 'query', + jsonSerializable: true, + handler: (message: { pageId?: unknown; app?: unknown; id?: unknown }) => + exportNavigationText(routerStateFor(message?.pageId, message?.app), { + page: typeof message?.pageId === 'string' ? message.pageId : undefined, + id: typeof message?.id === 'number' ? message.id : undefined, + }), + }); + + register({ + name: 'clear-router-navigations', + type: 'action', + jsonSerializable: true, + // A page id, or `{ pageId, app }` to clear one app of a page that runs several. + handler: (target: unknown) => { + if (!config.actions.router) throw new Error(actionBlockedMessage('router')); + const record = + target && typeof target === 'object' ? (target as Record) : {}; + const pageId = typeof target === 'string' ? target : record['pageId']; + const app = typeof record['app'] === 'string' && record['app'] ? record['app'] : undefined; + if (typeof pageId !== 'string') throw new Error('Give the id of the page to clear.'); + const unknownApp = unknownRouterApp(routerPages, pageId, app); + if (unknownApp) throw new Error(unknownApp); + const secondary = routerPages.get(pageId)?.apps?.some((a) => a.id === app && !a.primary); + if (clearRouterNavigations(routerPages, pageId, Date.now(), app)) { + applyRouter(currentRouter(routerPages)); + } + // Each app's navigations in the stream go with its history; the first app's carry no id. + navigationStream.clear(pageId, app ? (secondary ? app : null) : undefined); + void my.rpc.broadcast({ + method: 'router-clear', + args: [app ? { pageId, app } : { pageId }], + optional: true, + }); + }, + }); + + register({ + name: 'forget-router-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: string) => { + if (mayForget(pageId) && routerPages.delete(pageId)) { + applyRouter(currentRouter(routerPages)); + navigationStream.clear(pageId); + } + }, + }); + + return { + routerPages, + navigationStream, + dropGoneStreamPages, + routerState, + applyRouter, + touchRouter, + requestRouterAction, + routerHttpCalls, + previewScans, + }; +} diff --git a/packages/devtools/src/devframe-signals.ts b/packages/devtools/src/devframe-signals.ts new file mode 100644 index 00000000..4b48620a --- /dev/null +++ b/packages/devtools/src/devframe-signals.ts @@ -0,0 +1,230 @@ +// The Signals inspector on the devframe server (`devframe.ts`): the +// `signal-graph` shared state, its push, ping and peek, watches, value +// restores and the `watch-signal` agent tool. + +import type { ResolvedPangularConfig } from './config.ts'; +import { unsupportedToolText, type AngularPages } from './rpc/angular-pages.ts'; +import { rankPages } from './rpc/default-page.ts'; +import type { PageOwners } from './rpc/page-owners.ts'; +import { PAGE_ARGUMENT, pageArgument } from './rpc/pages.ts'; +import { createSignalPeeks } from './rpc/signal-peeks.ts'; +import { createSignalRestores } from './rpc/signal-restores.ts'; +import { + signalWatchRequest, + watchSignalRequests, + watchSignalText, + type WatchSignalArgs, +} from './rpc/signal-watch-tools.ts'; +import { signalOwner } from './signal-graph.ts'; +import type { SignalChange } from './types.ts'; +import type { + AgentRegistrar, + AnswersFor, + Owns, + PageGraph, + PangularScope, + Register, +} from './devframe-env.ts'; + +/** The signal graph of each page, before any RPC function reads it. */ +export async function createSignalState({ my }: { my: PangularScope }) { + const signalGraphState = await my.rpc.sharedState('signal-graph', { + initialValue: { + graph: null as PageGraph | null, + pages: {} as Record, + selectedNodeId: null as string | null, + }, + }); + const signalPages = new Map(); + const signalPeeks = createSignalPeeks(); + + const forgetSignalPages = (ids: string[]) => { + for (const id of ids) signalPages.delete(id); + signalGraphState.mutate((draft) => { + for (const id of ids) delete draft.pages[id]; + const ownerId = draft.graph?.pageId; + if (ownerId && ids.includes(ownerId)) { + const latest = rankPages( + [...signalPages].map(([pageId, entry]) => ({ ...entry, pageId })), + )[0]; + draft.graph = latest?.graph ?? null; + } + }); + }; + + return { signalGraphState, signalPages, signalPeeks, forgetSignalPages }; +} + +export type SignalState = Awaited>; + +/** What `registerSignalRpc` reads from the devframe server's setup (`devframe.ts`). */ +export interface SignalRpcEnv { + my: PangularScope; + config: ResolvedPangularConfig; + register: Register; + agent: AgentRegistrar; + owns: Owns; + mayForget: Owns; + answersFor: AnswersFor; + owners: PageOwners; + angularPages: AngularPages; + state: SignalState; +} + +export function registerSignalRpc(env: SignalRpcEnv): void { + const { my, config, register, agent, owns, mayForget, answersFor, owners, angularPages } = env; + const { signalGraphState, signalPages, signalPeeks, forgetSignalPages } = env.state; + register({ + name: 'select-signal-target', + type: 'action', + jsonSerializable: true, + handler: (target: { pageId?: unknown; id?: unknown; env?: unknown } | null) => { + const id = typeof target?.id === 'string' ? target.id.slice(0, 50) : null; + const env = typeof target?.env === 'string' ? target.env.slice(0, 200) : undefined; + const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; + void my.rpc.broadcast({ + method: 'select-signal-component', + args: [env ? { pageId, env } : { pageId, id }], + optional: true, + }); + }, + }); + + // Shared by the panel's Watch and Record write stacks buttons and the watch-signal agent tool. + const sendSignalWatch = (message: unknown) => { + const request = signalWatchRequest(message, signalPages); + if (!request) return { pages: 0 }; + void my.rpc.broadcast({ method: 'signal-watch', args: [request], optional: true }); + return { pages: signalPages.has(request.pageId) ? 1 : 0, pageId: request.pageId }; + }; + + register({ + name: 'set-signal-watch', + type: 'action', + jsonSerializable: true, + handler: sendSignalWatch, + }); + + agent.registerTool({ + id: 'pangular:watch-signal', + title: 'Watch a signal', + description: + 'Watch a signal (`node`, id or label) so its changes log to the console and show in inspect-signals, or unwatch with `watch: false`; `stacks` toggles write stacks. Resets on reload.', + inputSchema: { + type: 'object', + properties: { + node: { + type: 'string', + description: + 'Node id or label from pangular:inspect-signals. A label watches every node with it.', + }, + watch: { + type: 'boolean', + description: 'False stops watching `node`. Defaults to true.', + }, + stacks: { + type: 'boolean', + description: 'True records write stacks on the page, false stops.', + }, + page: PAGE_ARGUMENT, + }, + }, + handler: async (args: WatchSignalArgs) => { + const unsupported = signalPages.size + ? null + : unsupportedToolText(angularPages.pick(pageArgument(args)), 'signalGraph'); + if (unsupported) return { markdown: unsupported }; + const asked = watchSignalRequests(args ?? {}, signalPages, pageArgument(args)); + if ('markdown' in asked) return asked; + for (const request of asked.requests) sendSignalWatch({ ...request, pageId: asked.pageId }); + return { markdown: watchSignalText(args ?? {}, asked.pageId, asked.labels) }; + }, + }); + + register({ + name: 'push-signal-graph', + type: 'action', + jsonSerializable: true, + handler: (incoming: PageGraph & { historyDelta?: Record }) => { + const pageId = incoming?.pageId; + const known = typeof pageId === 'string' && pageId.length < 50; + if (known && !owners.write(pageId)) return { delta: true }; + const prev = known ? signalPages.get(pageId)?.graph : undefined; + let graph: PageGraph = incoming; + let delta = true; + if (incoming?.historyDelta) { + const { historyDelta, ...rest } = incoming; + // Node ids are only unique per component or injector, so a delta for a new owner can't + // build on the previous history. + const base = prev && signalOwner(prev) === signalOwner(rest) ? prev : undefined; + if (!base) delta = false; + const history: Record = {}; + for (const { id } of [...(rest.nodes ?? []), ...(rest.resources ?? [])]) { + const list = [...(base?.history?.[id] ?? []), ...(historyDelta[id] ?? [])]; + if (list.length) history[id] = list.slice(-50); + } + graph = { ...rest, history }; + } + if (known) signalPages.set(pageId, { graph, reportedAt: Date.now() }); + signalGraphState.mutate((draft) => { + draft.graph = graph; + // Every open page pushes, so one shared graph would flip between them. + draft.pages = Object.fromEntries([...signalPages].map(([id, page]) => [id, page.graph])); + }); + return { delta }; + }, + }); + + register({ + name: 'ping-signal-graph', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + const page = owns(pageId) ? signalPages.get(pageId) : undefined; + if (!page) return { known: false }; + signalPages.set(pageId as string, { ...page, reportedAt: Date.now() }); + return { known: true }; + }, + }); + + register({ + name: 'signal-graph-peek-result', + type: 'action', + jsonSerializable: true, + handler: (message: unknown) => { + const pageId = (message as { pageId?: unknown } | null)?.pageId; + if (answersFor(undefined, pageId)) signalPeeks.answer(message); + }, + }); + + // The panel's Restore button on a signal's value history; the value stays on the page. + const signalRestores = createSignalRestores({ + config, + graphOf: (pageId) => signalPages.get(pageId)?.graph, + broadcast: (method, message) => + void my.rpc.broadcast({ method, args: [message], optional: true }), + answersFor, + }); + register({ + name: 'signal-restore-result', + type: 'action', + jsonSerializable: true, + handler: signalRestores.answer, + }); + register({ + name: 'request-signal-restore', + type: 'action', + jsonSerializable: true, + handler: (message: { pageId?: unknown; request?: unknown } | null) => + signalRestores.request(message?.pageId, message?.request), + }); + + register({ + name: 'forget-signal-page', + type: 'action', + jsonSerializable: true, + handler: (pageId: unknown) => { + if (mayForget(pageId) && signalPages.has(pageId)) forgetSignalPages([pageId]); + }, + }); +} diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts index 997a2065..d7016f0a 100644 --- a/packages/devtools/src/devframe.ts +++ b/packages/devtools/src/devframe.ts @@ -1,196 +1,29 @@ import type { DevframeSetupInfo, RemoteAssets } from 'devframe'; import { defineDevframe } from 'devframe'; -import { createSharedState } from 'devframe/utils/shared-state'; import { PANGULAR_LOGO_DATA_URI } from './brand.ts'; import { SCAN_RPC_DEFINITIONS, SCAN_TOOLS, scanTool } from './rpc/scan-tools.ts'; -import { - expireCapacitorPages, - mergeCapacitorReport, - patchCapacitorState, - syncCapacitorState, - toCapacitorReport, - type CapacitorPage, - type CapacitorState, -} from './rpc/capacitor-tools.ts'; -import { pageHighlightTarget } from './rpc/page-highlight-target.ts'; -import { registerConsoleGlobals } from './rpc/console-globals.ts'; -import { registerErrors } from './rpc/errors-register.ts'; -import { registerServerErrors } from './rpc/server-errors-register.ts'; +import { expireCapacitorPages } from './rpc/capacitor-tools.ts'; import { registerHttpAgentTools } from './devframe-http-tools.ts'; import { registerFormsAgentTools } from './devframe-forms-tools.ts'; import { registerComponentAgentTools } from './devframe-component-tools.ts'; import { registerRouterAgentTools } from './devframe-router-tools.ts'; -import { registerPerformance } from './rpc/perf-register.ts'; -import { registerBuild } from './rpc/build-register.ts'; import { registerSnapshot } from './rpc/snapshot-tools.ts'; -import { registerLeaks } from './rpc/leaks-register.ts'; -import { registerReduxStores } from './rpc/redux-stores-register.ts'; -import { registerTanstackQuery } from './rpc/query-register.ts'; -import { registerPlugins } from './rpc/plugins-register.ts'; -import { registerStartup } from './rpc/startup-register.ts'; -import { registerIonic } from './rpc/ionic-register.ts'; import type { PangularServerPlugin } from './plugin.ts'; import { registerAudit } from './rpc/audit-register.ts'; -import { INJECTOR_TREE_LIMITS, sanitizeInjectorTree } from './rpc/injector-report.ts'; -import { createFrameworkNames, markFramework } from './rpc/framework-tokens.ts'; import { lintPipes } from './rpc/pipe-lint.ts'; import { cachedPipeLint } from './rpc/pipe-lint-cache.ts'; -import { scanPipes } from './rpc/get-pipes.ts'; -import { createPageOwners, type OwnerBroadcast } from './rpc/page-owners.ts'; -import { trackPageSessions } from './rpc/page-sessions.ts'; import { createOpenInEditor, defaultLauncher } from './rpc/open-in-editor.ts'; -import { isAnalogProject, servedAnalogRoot } from './rpc/analog-scan.ts'; -import { signalStateForAgents } from './rpc/signal-tools.ts'; -import { createSignalPeeks } from './rpc/signal-peeks.ts'; -import { - signalWatchRequest, - watchSignalRequests, - watchSignalText, - type WatchSignalArgs, -} from './rpc/signal-watch-tools.ts'; -import { signalOwner } from './signal-graph.ts'; -import { scanNgrxStore } from './rpc/get-ngrx-store.ts'; -import { createNgrxHistory } from './rpc/ngrx-session-tools.ts'; -import { createProviderValues } from './rpc/provider-values.ts'; -import { requestInjectorRefresh } from './rpc/injector-refresh.ts'; -import { - dispatchResultText, - expireNgrxPages, - isNgrxReport, - mergeNgrxReport, - ngrxStateOf, - type NgrxDeclaration, - type NgrxPages, -} from './rpc/ngrx-tools.ts'; -import { - INSPECT_SIGNAL_STORE_DESCRIPTION, - SIGNAL_STORE_HISTORY_DESCRIPTION, - inspectSignalStoreText, - signalStoreHistoryText, - storeIdNamed, - withUntrustedPreamble, -} from './rpc/ngrx-live-tools.ts'; -import { createNgrxDevtoolsTools } from './rpc/ngrx-devtools-tools.ts'; -import { - dispatchProblem, - type NgrxRequest, - type NgrxRequestResult, - type NgrxState, -} from './ngrx-shared.ts'; -import type { - ComponentDetail, - ComponentPage, - InjectorPage, - InjectorTreeNode, - LiveComponentNode, - SignalChange, - SignalGraph, - ZoneMode, -} from './types.ts'; -import { ZONE_MODES } from './zone-mode.ts'; -import { - expireComponentPages, - findComponents, - isComponentReport, - latestComponentPage, - toComponentPage, -} from './rpc/component-tools.ts'; -import { createDeferLoads } from './rpc/defer-loads.ts'; -import { PICK_KEYS } from './component-pick.ts'; -import { createComponentEdits } from './rpc/component-edits.ts'; -import { createSignalRestores } from './rpc/signal-restores.ts'; -import { createComponentValues } from './rpc/component-values.ts'; -import { - formsResourceText, - currentForms, - expirePages, - isPageReport, - matchForms, - mergePageReport, - type PageReport, - type FormsState, -} from './rpc/forms-tools.ts'; -import { playwrightTestText } from './rpc/forms-playwright.ts'; -import { - explainFieldText, - explainSubmitText, - exportFormText, - type FieldArgs, - formPayloadText, - lintFormsFor, -} from './rpc/forms-explain.ts'; -import { findFormSource } from './rpc/forms-source.ts'; -import { - currentPipes, - expirePipePages, - isPipePageReport, - mergePipePageReport, - pipeInstrumentRequest, - type PipePageReport, - type PipesState, -} from './rpc/pipes-tools.ts'; -import { - clearRouterNavigations, - unknownRouterApp, - currentRouter, - expireRouterPages, - isRouterReport, - mergeRouterReport, - routerResourceText, - touchRouterPage, - type RouterPage, - type RouterState, -} from './rpc/router-tools.ts'; -import { exportNavigationText, matchUrl, routerLintResult } from './rpc/router-config-tools.ts'; -import { extractRoutes } from './rpc/get-routes.ts'; -import { scanServerRoutes } from './rpc/server-routes.ts'; -import { - httpRegistry, - sanitizeCalls, - sanitizeRules, - type HttpCall, - type HttpRule, -} from './http-rules.ts'; -import { sanitizeHydration, sanitizePayload, type PayloadSummary } from './http-payload.ts'; -import { redactCall } from './redaction.ts'; -import { redactMessage, redactUrl } from './router.ts'; -import { isSsrRequestId, ssrRegistry } from './ssr-registry.ts'; -import { sanitizeSsrOverrides, type SsrOverride } from './ssr-overrides.ts'; -import { ssrOverrideTools } from './rpc/ssr-override-tools.ts'; -import { registerRefetchTools } from './devframe-refetch-tools.ts'; -import { createComponentScreenshots } from './rpc/component-screenshots.ts'; -import { sanitizeSsrRequest } from './rpc/ssr-tools.ts'; -import { - expireCdPages, - markCdPagesStopped, - toCdPage, - type CdPage, - type CdState, -} from './rpc/cd-tools.ts'; -import { toCdOptionsRequest } from './cd-data.ts'; -import type { HttpPage, HttpPayloadState, HttpReport, HttpState } from './types.ts'; - -import { analogPages, nameServerFns, registerAnalog } from './rpc/analog-register.ts'; -import { createPageHeartbeats } from './rpc/page-heartbeats.ts'; -import { createPageVisibility } from './rpc/page-ttl.ts'; +import { servedAnalogRoot } from './rpc/analog-scan.ts'; +import { expireNgrxPages } from './rpc/ngrx-tools.ts'; +import { expireComponentPages } from './rpc/component-tools.ts'; +import { expirePages, type FormsState } from './rpc/forms-tools.ts'; +import { expirePipePages } from './rpc/pipes-tools.ts'; +import { expireRouterPages, type RouterState } from './rpc/router-tools.ts'; +import { expireCdPages, type CdState } from './rpc/cd-tools.ts'; +import type { HttpState } from './types.ts'; +import { registerAnalog } from './rpc/analog-register.ts'; import { guardPangularSharedStateWrites } from './rpc/shared-state-write-guard.ts'; -import { - createAngularPages, - sanitizeAngularReport, - unsupportedToolText, -} from './rpc/angular-pages.ts'; -import { - PAGE_ARGUMENT, - byRecency, - listPagesText, - pageArgument, - choosePageText, - pageDetails, - summarizePages, - unknownPageText, -} from './rpc/pages.ts'; -import { LIVE_RESOURCE_NOTE, liveResourceText } from './rpc/resource-text.ts'; -import { injectorResourceText } from './rpc/injector-resource.ts'; +import { PAGE_ARGUMENT, byRecency, listPagesText } from './rpc/pages.ts'; import { trackHubDocks } from './hub-dock-status.ts'; import { createHubFeed } from './hub-messages.ts'; import { PANEL_COMMAND_EVENT, registerHubCommands } from './hub-commands.ts'; @@ -200,24 +33,15 @@ import { openInPanel, } from './rpc/open-in-panel.ts'; import { setRedaction } from './forms-privacy.ts'; -import { REDACTION_CONFIG_RPC } from './overlay-redaction.ts'; import { - FORM_WRITE_ACTIONS, - FORMLESS_FORM_ACTIONS, PANGULAR_CONFIG_KEY, connectionConfig, - ROUTER_WRITE_ACTIONS, actionBlockedMessage, - agentAllowed, - agentSafety, - agentTags, enabledPanelTabs, - isPageAgentEntry, sharedStateExposed, autoLibraries, pangularConfigWarning, resolvePangularConfig, - rpcAllowed, type PangularConfig, type PangularInspector, type ResolvedPangularConfig, @@ -239,41 +63,35 @@ import { registerCodeGuard } from './auth-guard.ts'; import { expireAuthTokens, protectAuthFile, scopeAuthStore } from './auth-store.ts'; import { securityStatusHandler, type PangularSecurityHost } from './security-status.ts'; import { watchTokenRotation } from './auth-rotate.ts'; -import { - BROWSER_TOOLS_CHANGED, - BROWSER_TOOLS_STATE, - createBrowserToolsSwitch, - registerBrowserTools, - type BrowserToolHost, -} from './rpc/browser-tools.ts'; import pkg from '../package.json' with { type: 'json' }; -import { INTEREST_TICK_MS, createPageInterest, leased } from './rpc/page-interest.ts'; -import { createPageResourceSource, providePageResources } from './agent-page-resources.ts'; -import { createReportDeltas, deltaPush } from './rpc/report-deltas.ts'; +import { createRegistrars } from './devframe-registrars.ts'; +import { createPageDirectory, registerHighlights, registerPageRegistry } from './devframe-pages.ts'; import { - CHANGE_DETECTION_SHAPE, - COMPONENT_TREE_SHAPE, - FORMS_SHAPE, - INJECTOR_TREE_SHAPE, -} from './push-delta.ts'; + componentToolHelpers, + createComponentState, + registerComponentPick, + registerComponentRpc, +} from './devframe-components.ts'; +import { createSignalState, registerSignalRpc } from './devframe-signals.ts'; +import { createInjectorState, registerInjectorRpc } from './devframe-injectors.ts'; +import { + createNgrxState, + registerNgrxDispatchTools, + registerNgrxReadTools, + registerNgrxRpc, +} from './devframe-ngrx.ts'; +import { createFormsState, registerFormActions, registerFormsPages } from './devframe-forms.ts'; +import { registerPipes } from './devframe-pipes.ts'; +import { registerCapacitor } from './devframe-capacitor.ts'; +import { registerInspectors } from './devframe-inspectors.ts'; +import { registerRouter } from './devframe-router.ts'; +import { registerHttp } from './devframe-http.ts'; +import { registerLiveResources } from './devframe-resources.ts'; import { createWaitForTool } from './rpc/wait-for.ts'; import { answeringText, type MarkdownAnswer } from './rpc/agent-output.ts'; import { guardHandler } from './rpc/tool-input.ts'; -import { createPageOrder, defaultPage, rankPages, setPageOrder } from './rpc/default-page.ts'; -import { namingPageRead } from './rpc/page-read-scope.ts'; -import { navigationDiffResult } from './rpc/router-diff.ts'; -import { callsForNavigation, nextNavigation } from './rpc/router-http.ts'; -import { NavigationStream } from './rpc/router-stream.ts'; -import { routePreviewResult } from './rpc/router-preview.ts'; -import { routerStateForApp } from './rpc/router-apps.ts'; -import { nameStoreTokens } from './rpc/store-tokens.ts'; -import { notAProjectText, readsSource, refusingTool } from './rpc/project-root.ts'; +import { notAProjectText } from './rpc/project-root.ts'; import { STATE_READ_BY, stateReadTool } from './rpc/state-read-tool.ts'; -import { createLibraryGate, usedLibraries } from './rpc/library-tools.ts'; -import { createPushAcks } from './rpc/push-acks.ts'; -import { refusal, ToolRefusal } from './rpc/refusal.ts'; - -type PageGraph = SignalGraph & { pageId?: string }; const clientAssets: RemoteAssets = { package: pkg.name, @@ -310,88 +128,31 @@ const pangular = defineDevframe({ guardPangularSharedStateWrites(ctx.rpc); const config = info?.config ?? resolvePangularConfig(); const pageTools = info?.pageTools ?? true; - const agentExposed = (entry: { id: string }) => - agentAllowed(entry, config) && (pageTools || !isPageAgentEntry(entry.id)); setRedaction(config.redaction); const on = config.inspectors; const limits = config.limits; const my = ctx.scope('pangular'); (ctx.staticConfig as Record)[PANGULAR_CONFIG_KEY] = connectionConfig(config); const rootRefusal = info?.notAProject && notAProjectText(info.notAProject); - const register: typeof my.rpc.register = (definition) => { - if (!rpcAllowed(definition.name, config)) return; - // A source scan of a folder without an Angular project refuses and names the folder. - if (rootRefusal && definition.agent) { - definition = { - ...definition, - handler: () => { - throw new ToolRefusal(rootRefusal); - }, - } as typeof definition; - } - const exposed = !definition.agent || agentAllowed({ id: definition.name }, config); - if (!exposed) return my.rpc.register({ ...definition, agent: undefined }); - const agent = definition.agent && { ...definition.agent, tags: agentTags(definition.name) }; - my.rpc.register(agent ? { ...definition, agent } : definition); - }; // Agent writes, NG errors and hydration mismatches, posted to the hub's messages. const feed = createHubFeed(ctx, config); // The URL of a page, for the line that names it in a tool answer. Set once the reports exist. let pageUrl: (pageId: string) => string | undefined = () => undefined; - type AgentToolInput = Parameters[0]; - // Analog, Capacitor and TanStack Query tools wait until the app uses the library. - const libraryGate = createLibraryGate({ - auto: info?.autoLibraries ?? autoLibraries(), - used: usedLibraries( - info?.autoLibraries ?? autoLibraries(), - [ctx.cwd, ctx.workspaceRoot], - () => isAnalogProject(ctx.cwd), - ), + const { register, agent } = createRegistrars({ + ctx, + my, + config, + pageTools, + rootRefusal: rootRefusal || undefined, + feed, + autoLibraries: info?.autoLibraries, + pageUrl: () => pageUrl, + interest: () => interest, reporting: { - analog: () => on.analog && analogPages().length > 0, capacitor: () => capacitorPages.size > 0, - tanstackQuery: () => (tanstackQuery?.pages().length ?? 0) > 0, + tanstackQuery: () => (inspectors.tanstackQuery?.pages().length ?? 0) > 0, }, - register: (tool) => registerAgentTool(tool), }); - const agent = { - registerTool: (tool: AgentToolInput) => { - if (agentExposed(tool) && !libraryGate.hold(tool)) registerAgentTool(tool); - }, - registerResource: (resource: Parameters[0]) => { - if (agentExposed(resource)) - ctx.agent.registerResource({ ...resource, read: leased(() => interest, resource.read) }); - }, - }; - function registerAgentTool(input: AgentToolInput) { - const tool = rootRefusal && readsSource(input.id) ? refusingTool(input, rootRefusal) : input; - // The tier and the tags come from the tool's entry in AGENT_ENTRIES, so - // the config is the one place that sets them. - ctx.agent.registerTool( - feed.wrapTool({ - ...tool, - // Every answer is one plain text block (rpc/agent-output.ts). - outputSchema: undefined, - safety: agentSafety(tool.id, config), - tags: agentTags(tool.id), - // Arguments are checked against `inputSchema` before the lease - // wakes any page, and refusals go out with `isError`. - handler: answeringText( - guardHandler( - tool.inputSchema, - // A live tool's answer names the page it read. - isPageAgentEntry(tool.id) - ? namingPageRead( - leased(() => interest, tool.handler), - pageUrl, - ) - : leased(() => interest, tool.handler), - tool.id, - ), - ), - }), - ); - } // The panel reads the scans as RPC functions; agents read them as the `scan-*` tools. for (const definition of SCAN_RPC_DEFINITIONS) { @@ -410,216 +171,22 @@ const pangular = defineDevframe({ }), }); - // Tabs in the background stop reporting, so their last data stays until - // they come back or close. - // Pages that beat keep all their reports, so they send only what changed. - const heartbeats = createPageHeartbeats(); - const visibility = createPageVisibility((pageId) => heartbeats.alive(pageId)); - const liveTtl = visibility.ttl(); - const visibilityState = await my.rpc.sharedState('page-visibility', { - initialValue: { hidden: [] as string[] }, - }); - const applyVisibility = () => - visibilityState.mutate((draft) => { - draft.hidden = visibility.list(); - }); - // One connection owns each page id, so one tab (or a stray client) can't - // overwrite or forget the pages another one reports. - const owners = createPageOwners(ctx.rpc, liveTtl); - // The default page of every tool: foreground first, then the newest connection. - const pageOrder = createPageOrder(() => visibility.list()); - setPageOrder(pageOrder.rank); - const owns = (pageId: unknown): pageId is string => { - if (typeof pageId !== 'string' || !owners.write(pageId)) return false; - pageOrder.seen(pageId); - return true; - }; - // A request is settled only by the page it was sent to, from the connection - // that owns that page, so another client can't answer for it. - const answersFor = (target: string | undefined, pageId: unknown) => - (target === undefined || pageId === target) && owns(pageId); - - // Pages push their heavy reports only while a panel or an agent watches. - const interest = createPageInterest({ - rpcHost: ctx.rpc, - leaseMs: limits.idleAfterMs, - hidden: (pageId) => visibility.has(pageId), - broadcast: (message) => - void my.rpc - .broadcast({ method: 'page-interest-changed', args: [message], optional: true }) - ?.catch?.(() => {}), - }); - register({ - name: 'page-interest', - type: 'action', - jsonSerializable: true, - handler: (message: unknown) => interest.handle(message), - }); - register({ - name: 'page-heartbeat', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown } | null) => { - const pageId = message?.pageId; - if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return undefined; - if (!owners.write(pageId)) return undefined; - // The forms and router tools call a page stale by its last report, so - // a beat counts as their report, in the shared states too. - const now = Date.now(); - touchFormsPage(pageId, now); - touchRouter(pageId, now); - const known = heartbeats.beat(pageId, now, visibility.has); - return { known, ...interest.handle({ pageId }) }; - }, - }); - const interestTick = setInterval(() => interest.tick(), INTEREST_TICK_MS); - interestTick.unref?.(); - // A page that left is not waited for when an agent wakes the pages. - const mayForget = (pageId: unknown): pageId is string => { - if (typeof pageId !== 'string' || !owners.forget(pageId)) return false; - interest.forget(pageId); - pageOrder.forget(pageId); - return true; - }; - - // The `read` agent tools, for the page to register with browser agents. - // `agent.browserTools` sets where the switch starts; the panel's Settings - // flips it for every page until the server restarts. - const browserToolsState = await my.rpc.sharedState(BROWSER_TOOLS_STATE, { - initialValue: { enabled: config.agent.browserTools, config: config.agent.browserTools }, - }); - const browserToolsSwitch = createBrowserToolsSwitch(config.agent.browserTools, (state) => { - browserToolsState.mutate((draft) => { - draft.enabled = state.enabled; - draft.config = state.config; - }); - void my.rpc - .broadcast({ method: BROWSER_TOOLS_CHANGED, args: [state], optional: true }) - ?.catch?.(() => {}); - }); - registerBrowserTools(register, ctx as unknown as BrowserToolHost, { - enabled: browserToolsSwitch.enabled, - owns, - switch: browserToolsSwitch, - }); - - // Not in `__connection.json`: only a trusted page learns which names are secret. - register({ - name: REDACTION_CONFIG_RPC, - type: 'query', - jsonSerializable: true, - handler: () => config.redaction, - }); - - let onPageClaimed = (_pageId: string) => {}; - register({ - name: 'claim-page', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; key?: unknown } | null) => { - const pageId = message?.pageId; - if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return { ok: false }; - const ok = owners.claim(pageId, message?.key); - // A claim comes before the page sends everything, so its first beat finds it known. - if (ok) heartbeats.beat(pageId); - // The overlay claims once per load: what the audit holds of the old document is stale. - if (ok) onPageClaimed(pageId); - return { ok }; - }, - }); - - register({ - name: 'report-page-visibility', - type: 'action', - jsonSerializable: true, - handler: (report: { pageId?: unknown; hidden?: unknown } | null) => { - const pageId = report?.pageId; - if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return; - if (!owners.write(pageId)) return; - heartbeats.touch(pageId); - if (visibility.set(pageId, report?.hidden === true)) applyVisibility(); - }, - }); - - // Each page's Angular version and debug API parts, so a view can say why it is empty. - const angularPages = createAngularPages(); - const pageAngularState = await my.rpc.sharedState('page-angular', { - initialValue: angularPages.snapshot(), - }); - const applyAngularPages = () => - pageAngularState.mutate((draft) => { - draft.pages = angularPages.snapshot().pages; - }); - register({ - name: 'report-page-angular', - type: 'action', - jsonSerializable: true, - handler: (report: unknown) => { - const info = sanitizeAngularReport(report); - if (!info || !owns(info.pageId)) return; - if (angularPages.set(info)) applyAngularPages(); - }, - }); - register({ - name: 'forget-page-angular', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - if (mayForget(pageId) && angularPages.forget(pageId)) applyAngularPages(); - }, + // Owners, heartbeats, background marks and the lease of every page. + const pages = await registerPageRegistry({ + ctx, + my, + config, + register, + // The forms and router tools call a page stale by its last report, so a beat counts as one. + touchFormsPage: (pageId, now) => forms.touchFormsPage(pageId, now), + touchRouter: (pageId, now) => router.touchRouter(pageId, now), }); + const { heartbeats, visibility, liveTtl, owners, owns, answersFor, interest, mayForget } = + pages; + const { angularPages } = pages; - const componentTree = await my.rpc.sharedState('component-tree', { - initialValue: { - nodes: [] as LiveComponentNode[], - pages: {} as Record, - selectedId: null as string | null, - highlightedId: null as string | null, - }, - }); - const componentPages = new Map(); - // What each page's next delta builds on (`push-delta.ts`). - const componentDeltas = createReportDeltas(COMPONENT_TREE_SHAPE); - const injectorDeltas = createReportDeltas(INJECTOR_TREE_SHAPE); - const cdDeltas = createReportDeltas(CHANGE_DETECTION_SHAPE); - const formDeltas = createReportDeltas(FORMS_SHAPE); - const applyComponentPages = () => - componentTree.mutate((draft) => { - draft.pages = Object.fromEntries(componentPages); - draft.nodes = latestComponentPage(componentPages.values())?.roots ?? []; - }); - const componentWaiters = new Set<() => void>(); - // `console` marks a selection made in the panel, which sets `$ng0` on that page. - const selectComponentOnPage = ( - pageId: string | undefined, - id: string | null, - console = false, - ) => { - componentTree.mutate((draft) => { - draft.selectedId = id; - }); - void my.rpc.broadcast({ - method: 'inspect-component-in-page', - args: [{ pageId, id, ...(console && pageId ? { console: true } : {}) }], - optional: true, - }); - }; - const waitForComponentDetail = (pageId: string, id: string, timeoutMs = 3000) => - new Promise((resolve) => { - const done = (detail: ComponentDetail | null) => { - clearTimeout(timer); - componentWaiters.delete(check); - resolve(detail); - }; - const check = () => { - const detail = componentPages.get(pageId)?.detail; - if (detail?.id === id) done(detail); - }; - const timer = setTimeout(() => done(null), timeoutMs); - timer.unref?.(); - componentWaiters.add(check); - check(); - }); + const components = await createComponentState({ my }); + const { componentTree, componentPages } = components; await my.rpc.sharedState('routes', { initialValue: { @@ -628,2154 +195,250 @@ const pangular = defineDevframe({ }, }); - const signalGraphState = await my.rpc.sharedState('signal-graph', { - initialValue: { - graph: null as PageGraph | null, - pages: {} as Record, - selectedNodeId: null as string | null, - }, - }); - const signalPages = new Map(); - const signalPeeks = createSignalPeeks(); - - const injectorTreeState = await my.rpc.sharedState('injector-tree', { - initialValue: { - roots: [] as InjectorTreeNode[], - environment: [] as InjectorTreeNode[], - pages: {} as Record, - truncated: false, - zone: null as ZoneMode | null, - selectedInjectorId: null as string | null, - }, + const signals = await createSignalState({ my }); + const { signalGraphState, signalPages } = signals; + const injectors = await createInjectorState({ ctx, my }); + const { injectorTreeState, injectorPages } = injectors; + const ngrx = await createNgrxState({ ctx, my }); + const { ngrxStoreState, ngrxPages } = ngrx; + const forms = await createFormsState({ my, config, register, owns, feed, liveTtl }); + const { formPages, formsState } = forms; + const pipes = await registerPipes({ ctx, my, config, register, owns, mayForget, liveTtl }); + const { pipePages } = pipes; + const { capacitorPages, capacitorShared, applyCapacitor } = await registerCapacitor({ + ctx, + my, + config, + register, + owns, + mayForget, }); - const injectorPages = new Map(); - // Token names from the installed @angular/* packages mark framework providers and injectors. - const withFramework = (page: InjectorPage, names: ReadonlySet): InjectorPage => ({ - ...page, - roots: markFramework(page.roots, names), - environment: markFramework(page.environment, names), + const inspectors = await registerInspectors({ + ctx, + my, + config, + register, + agent, + feed, + interest, + owns, + mayForget, + answersFor, + liveTtl, + pageTools, + seenAt: heartbeats.seenAt, + serverPlugins: info?.serverPlugins, }); - const frameworkNames = createFrameworkNames( - () => [ctx.cwd, ctx.workspaceRoot], - (names) => { - if (!injectorPages.size) return; - for (const [id, page] of injectorPages) injectorPages.set(id, withFramework(page, names)); - applyInjectorPages(); - }, - ); - const latestInjectorPage = () => rankPages(injectorPages.values())[0]; - const applyInjectorPages = () => - injectorTreeState.mutate((draft) => { - const latest = latestInjectorPage(); - draft.pages = Object.fromEntries(injectorPages); - draft.roots = latest?.roots ?? []; - draft.environment = latest?.environment ?? []; - draft.truncated = latest?.truncated === true; - draft.zone = latest?.zone ?? null; - }); + const { errorsInspector, leaksInspector } = inspectors; - const ngrxStoreState = await my.rpc.sharedState('ngrx-store', { - initialValue: { pages: [] } as NgrxState, + const router = await registerRouter({ + ctx, + my, + config, + register, + owns, + mayForget, + answersFor, + owners, + feed, + liveTtl, + componentPages, + httpPages: () => http.httpPages, }); - const ngrxPages: NgrxPages = new Map(); - /** The page an NgRx read tool reads: `page`; else, unless `store` names a store, the default page. */ - const ngrxReadPage = (args: { page?: string; store?: string } | undefined) => - args?.page || (args?.store ? undefined : defaultPage(ngrxPages.values())?.pageId); - let ngrxDeclarations: { at: number; list: NgrxDeclaration[] } = { at: 0, list: [] }; - const ngrxNames = () => { - if (Date.now() - ngrxDeclarations.at > 10_000) { - let list: NgrxDeclaration[] = []; - try { - list = scanNgrxStore(ctx.cwd).filter((e) => e.kind === 'signal-store'); - } catch { - list = []; - } - ngrxDeclarations = { at: Date.now(), list }; - } - return ngrxDeclarations.list; - }; - const applyNgrx = () => - ngrxStoreState.mutate((draft) => { - draft.pages = ngrxStateOf(ngrxPages).pages as never; - }); + const { routerPages, navigationStream, routerState, applyRouter } = router; - const formPages = new Map(); - const formsState = await my.rpc.sharedState('forms', { - initialValue: { forms: [], events: [], reportedAt: 0, setupErrors: [] } as FormsState, + const http = await registerHttp({ + my, + config, + register, + agent, + owns, + mayForget, + answersFor, + feed, }); + const { httpPages, registry, httpState } = http; - const applyForms = (next: FormsState) => - formsState.mutate((draft) => { - draft.forms = next.forms; - draft.events = next.events; - draft.reportedAt = next.reportedAt; - draft.pagesReportedAt = next.pagesReportedAt ?? {}; - draft.setupErrors = next.setupErrors ?? []; - draft.instrumented = next.instrumented ?? []; - draft.dropped = next.dropped ?? {}; - draft.webMcp = next.webMcp ?? []; - }); - /** A sign of life from a page with forms: its report time, here and in the shared state. */ - const touchFormsPage = (pageId: string, now = Date.now()): boolean => { - const page = formPages.get(pageId); - if (!page) return false; - page.reportedAt = now; - formsState.mutate((draft) => { - draft.pagesReportedAt = { ...draft.pagesReportedAt, [pageId]: now }; - draft.reportedAt = Math.min(...[...formPages.values()].map((p) => p.reportedAt)); - }); - return true; + const expiry = setInterval(() => { + // Background marks first, then beats, on one clock: a page whose data + // goes in this sweep is gone from the beats too, so its next beat is unknown. + const now = Date.now(); + if (visibility.expire(now)) pages.applyVisibility(); + heartbeats.expire(now, visibility.has); + if (angularPages.expire(liveTtl)) pages.applyAngularPages(); + owners.expire(now); + const staleHttp = [...httpPages].filter(([id, p]) => now - p.reportedAt > liveTtl(id)); + if (staleHttp.length) http.forgetHttpPages(staleHttp.map(([id]) => id)); + const stale = [...signalPages].filter(([id, p]) => now - p.reportedAt > liveTtl(id)); + if (stale.length) signals.forgetSignalPages(stale.map(([id]) => id)); + if (expireComponentPages(componentPages, now, liveTtl)) components.applyComponentPages(); + if (expireCdPages(components.cdPages, now, liveTtl)) components.applyCd(); + for (const id of components.goneCdPages) { + if (!componentPages.has(id) && !injectorPages.has(id)) components.goneCdPages.delete(id); + } + const staleInjectors = [...injectorPages].filter( + ([id, p]) => now - p.reportedAt > liveTtl(id), + ); + if (staleInjectors.length) { + for (const [id] of staleInjectors) injectorPages.delete(id); + injectors.applyInjectorPages(); + } + const next = expirePages(formPages, now, limits.formTimeline, liveTtl); + if (next) forms.applyForms(next); + const nextRouter = expireRouterPages(routerPages, now, liveTtl); + if (nextRouter) { + applyRouter(nextRouter); + router.dropGoneStreamPages(); + } + const nextPipes = expirePipePages(pipePages, now, liveTtl); + if (nextPipes) pipes.applyPipes(nextPipes); + if (expireNgrxPages(ngrxPages, now, liveTtl)) ngrx.applyNgrx(); + if (expireCapacitorPages(capacitorPages, now, liveTtl)) applyCapacitor(); + inspectors.errorsInspector?.expire(now); + inspectors.serverErrors?.expire(now); + inspectors.perfInspector?.expire(now); + inspectors.leaksInspector?.expire(now); + inspectors.reduxStores?.expire(now); + inspectors.tanstackQuery?.expire(now); + inspectors.startupInspector?.expire(now); + inspectors.ionicInspector?.expire(now); + inspectors.pluginsInspector?.expire(now); + components.componentDeltas.prune((id) => componentPages.has(id)); + injectors.injectorDeltas.prune((id) => injectorPages.has(id)); + components.cdDeltas.prune((id) => components.cdPages.has(id)); + forms.formDeltas.prune((id) => formPages.has(id)); + // A page that stopped reporting (a closed browser that never said + // goodbye) is gone, so a wake doesn't wait for it. + interest.prune( + (id) => + owners.alive(id) || + componentPages.has(id) || + injectorPages.has(id) || + components.cdPages.has(id), + ); + }, 5000); + expiry.unref?.(); + registry.owner = ctx; + registry.dispose = () => { + clearInterval(expiry); + clearInterval(pages.interestTick); + inspectors.pluginsInspector?.dispose(); + inspectors.serverErrors?.dispose(); + interest.stop(); + http.stopFlush(); + registry.record = undefined; + http.ssr.record = undefined; + http.ssr.overrides = undefined; }; - register({ - name: 'push-forms', - type: 'action', - jsonSerializable: true, - handler: deltaPush(formDeltas, owns, (report) => { - if (!isPageReport(report) || !owns(report.pageId)) return false; - applyForms(mergePageReport(formPages, report, Date.now(), limits.formTimeline, liveTtl)); - feed.ngErrors(report.pageId, 'forms', report.setupErrors ?? []); - return true; - }), + const httpSnapshot = () => httpState.value() as HttpState; + registerHttpAgentTools({ + agent, + applyRouter, + capacitorPages, + clearHttpCalls: http.clearHttpCalls, + config, + httpState, + my, + registry, + routerPages, + setHttpRules: http.setHttpRules, + navigationStream, }); - const pipePages = new Map(); - const pipesState = await my.rpc.sharedState('pipe-usage', { - initialValue: { pipes: [], async: [], reportedAt: 0, instrumented: [] } as PipesState, + registerFormsPages({ config, register, owns, mayForget, state: forms }); + registerHighlights({ ctx, my, register }); + const requestFormAction = registerFormActions({ + ctx, + my, + config, + register, + owners, + answersFor, + formsState, + formPageUrl: (state, args) => directory.formPageUrl(state, args), }); - const applyPipes = (next: PipesState) => - pipesState.mutate((draft) => { - draft.pipes = next.pipes; - draft.async = next.async; - draft.reportedAt = next.reportedAt; - draft.instrumented = next.instrumented; - if (next.asyncTotal === undefined) delete draft.asyncTotal; - else draft.asyncTotal = next.asyncTotal; - if (next.pages === undefined) delete draft.pages; - else draft.pages = next.pages; - }); - - // A closed tab drops its connection at once, so its entries go with it - // instead of lingering until they expire. - const pipeSessions = trackPageSessions(ctx.rpc, (pageIds) => { - let removed = false; - for (const pageId of pageIds) removed = pipePages.delete(pageId) || removed; - if (removed) applyPipes(currentPipes(pipePages)); + const cd = await registerComponentRpc({ + ctx, + my, + config, + register, + agent, + owns, + mayForget, + answersFor, + owners, + state: components, }); - - register({ - name: 'push-pipes', - type: 'action', - jsonSerializable: true, - handler: (report: unknown) => { - if (!isPipePageReport(report) || !owns(report.pageId)) return; - pipeSessions.bind(report.pageId); - applyPipes(mergePipePageReport(pipePages, report, Date.now(), liveTtl)); - pipeAcks.notify(report.pageId, report); - }, + registerComponentPick({ my, register, answersFor, state: components }); + registerSignalRpc({ + my, + config, + register, + agent, + owns, + mayForget, + answersFor, + owners, + angularPages, + state: signals, }); - - register({ - name: 'forget-pipes-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: string) => { - if (!mayForget(pageId)) return; - pipeSessions.unbind(pageId); - if (pipePages.delete(pageId)) { - applyPipes(currentPipes(pipePages)); - } - }, + const providerValues = registerInjectorRpc({ + my, + register, + owns, + mayForget, + answersFor, + on, + ngrxNames: ngrx.ngrxNames, + cdSeen: cd.cdSeen, + state: injectors, }); - - // Shared by the panel's Record calls button and the record-pipes agent tool. - // A page confirms a recording request by pushing its pipe report again. - const pipeAcks = createPushAcks(); - const requestPipeRecording = (message: unknown) => { - const request = pipeInstrumentRequest(message, pipePages); - // No page has reported yet: starting now would patch every connected tab. - if (request.on && !request.pageId) return { request, pages: { pages: 0 } }; - void my.rpc.broadcast({ method: 'instrument-pipes', args: [request], optional: true }); - if (!request.pageId) return { request, pages: { pages: pipePages.size } }; - return { request, pages: { pages: pipePages.has(request.pageId) ? 1 : 0 } }; - }; - - register({ - name: 'request-instrument-pipes', - type: 'action', - jsonSerializable: true, - handler: (message: unknown) => requestPipeRecording(message).pages, + const ngrxRpc = registerNgrxRpc({ + my, + config, + register, + owns, + mayForget, + answersFor, + withPageUrls: (list) => directory.withPageUrls(list), + state: ngrx, }); - register({ - name: 'pipe-lint', - type: 'query', - jsonSerializable: true, - // A source scan like scan-pipes, so a static report bakes it in too. - snapshot: true, - handler: () => lintPipes(ctx.cwd, { rules: config.lint.rules }), + const directory = createPageDirectory({ + config, + pages, + inspectors, + componentPages, + signalPages, + injectorPages, + ngrxPages, + formPages, + pipePages, + capacitorPages, + routerPages, + httpPages, }); + const { livePageDetails, withPageUrls, livePages } = directory; + pageUrl = (pageId) => livePageDetails().get(pageId)?.url; - const capacitorPages = new Map(); - // With patches on, a push sends the panel what changed, not every page: - // devframe's own shared states send the whole value on each change. - const capacitorShared = await my.rpc.sharedState('capacitor', { - sharedState: createSharedState({ - initialValue: { pages: {} }, - enablePatches: true, - }), - }); - const applyCapacitor = () => - capacitorShared.mutate((draft) => syncCapacitorState(draft, capacitorPages)); - const capacitorSessions = trackPageSessions(ctx.rpc, (pageIds) => { - let removed = false; - for (const pageId of pageIds) removed = capacitorPages.delete(pageId) || removed; - if (removed) applyCapacitor(); - }); - - register({ - name: 'push-capacitor', - type: 'action', - jsonSerializable: true, - handler: (message: unknown) => { - const report = toCapacitorReport(message); - if (!report || !owns(report.pageId)) return { known: true }; - capacitorSessions.bind(report.pageId); - const answer = mergeCapacitorReport(capacitorPages, report, limits.capacitorCalls); - if (answer.known) { - capacitorShared.mutate((draft) => patchCapacitorState(draft, capacitorPages, report)); - } - return answer; - }, - }); - - register({ - name: 'forget-capacitor-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: string) => { - if (!mayForget(pageId)) return; - capacitorSessions.unbind(pageId); - if (capacitorPages.delete(pageId)) applyCapacitor(); - }, - }); - - const errorsInspector = on.errors - ? await registerErrors({ - my, - rpcHost: ctx.rpc, - register, - agent, - owns, - mayForget, - ttl: liveTtl, - }) - : null; - // SSR and Vite errors of this process (`server-errors.ts`); no page reports them. - const serverErrors = on.errors - ? await registerServerErrors({ my, register, agent, capture: pageTools }) - : null; - const perfInspector = on.performance - ? await registerPerformance({ - my, - rpcHost: ctx.rpc, - register, - agent, - owns, - mayForget, - ttl: liveTtl, - }) - : null; - if (on.build) registerBuild({ register, agent, root: ctx.cwd }); - const leaksInspector = on.components - ? await registerLeaks({ - my, - rpcHost: ctx.rpc, - register, - agent, - owns, - mayForget, - ttl: liveTtl, - }) - : null; - const startupInspector = on.injectors - ? await registerStartup({ - my, - rpcHost: ctx.rpc, - register, - agent, - owns, - mayForget, - ttl: liveTtl, - }) - : null; - // The `ion-router-outlet` stacks of Ionic pages, shown in the Capacitor tab. - const ionicInspector = on.capacitor - ? await registerIonic({ my, rpcHost: ctx.rpc, register, owns, mayForget, ttl: liveTtl }) - : null; - // A plugin tool's tier comes from the plugin; `pluginToolAllowed` checks it against the config. - const pluginsInspector = on.plugins - ? await registerPlugins({ - my, - rpcHost: ctx.rpc, - config, - register, - agent, - registerPluginTool: (tool, safety) => - ctx.agent.registerTool( - feed.wrapTool({ - ...(tool as Parameters[0]), - outputSchema: undefined, - safety, - tags: agentTags(String(tool['id'])), - handler: answeringText( - guardHandler( - tool['inputSchema'], - leased(() => interest, tool['handler']), - ), - ), - }), - ), - pageTools, - owns, - mayForget, - ttl: liveTtl, - seenAt: heartbeats.seenAt, - serverPlugins: info?.serverPlugins, - }) - : null; - - const reduxStores = on.ngrx - ? await registerReduxStores({ - my, - rpcHost: ctx.rpc, - register, - agent, - owns, - mayForget, - ttl: liveTtl, - maxLog: limits.changeLog, - }) - : null; - const tanstackQuery = on.tanstackQuery - ? await registerTanstackQuery({ - my, - rpcHost: ctx.rpc, - register, - agent, - owns, - mayForget, - answersFor, - ttl: liveTtl, - writes: config.actions.tanstackQuery, - blocked: actionBlockedMessage('tanstackQuery'), - }) - : null; - - const routerPages = new Map(); - /** Every navigation the pages report, numbered, for pangular:follow-navigations. */ - const navigationStream = new NavigationStream(); - /** Drops the stream entries and signatures of pages that expired or were forgotten. */ - const dropGoneStreamPages = () => { - for (const id of navigationStream.pageIds()) { - if (!routerPages.has(id)) navigationStream.clear(id); - } - }; - const routerState = await my.rpc.sharedState('router', { - initialValue: { pages: [] } as RouterState, - }); - - const applyRouter = (next: RouterState) => - routerState.mutate((draft) => { - draft.pages = next.pages; - }); - /** A sign of life from a page with a router: its report time, here and in the shared state. */ - const touchRouter = (pageId: string, now = Date.now()): boolean => { - if (!touchRouterPage(routerPages, pageId, now)) return false; - routerState.mutate((draft) => { - const page = draft.pages.find((p) => p.pageId === pageId); - if (page) page.reportedAt = now; - }); - return true; - }; - - register({ - name: 'push-router', - type: 'action', - jsonSerializable: true, - handler: (report: unknown) => { - if (!isRouterReport(report, limits.navigations)) return { hasConfig: false }; - if (!owns(report.pageId)) return { hasConfig: true }; - try { - applyRouter(mergeRouterReport(routerPages, report, Date.now(), liveTtl)); - } catch { - routerPages.delete(report.pageId); - applyRouter(currentRouter(routerPages)); - } - feed.navigations(report.pageId, report.navigations); - // Merging a report expires stale pages too. - dropGoneStreamPages(); - // A report that failed to merge left no page, so its navigations stay out of the stream. - if (routerPages.has(report.pageId)) { - navigationStream.recordReport(report); - } - return { hasConfig: !!routerPages.get(report.pageId)?.config }; - }, - }); - - register({ - name: 'ping-router', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => ({ known: owns(pageId) && touchRouter(pageId) }), - }); - - const pendingActions = new Map< - string, - { pageId: string | undefined; settle: (result: unknown) => void } - >(); - let actionSeq = 0; - - const defaultPageId = () => { - const state = routerState.value() as RouterState; - return (defaultPage(state.pages.filter((p) => p.snapshot)) ?? defaultPage(state.pages)) - ?.pageId; - }; - - const requestRouterAction = (page: string | undefined, request: unknown, app?: string) => { - const action = (request as { action?: unknown } | undefined)?.action; - return !config.actions.router && ROUTER_WRITE_ACTIONS.includes(action as string) - ? Promise.resolve({ error: actionBlockedMessage('router') }) - : sendRouterAction(page, request, app); - }; - - const sendRouterAction = (page: string | undefined, request: unknown, app?: string) => - new Promise((resolve) => { - const pageId = page || defaultPageId(); - // No page could answer: say so now instead of after the 15 s wait. - const routed = (routerState.value() as RouterState).pages; - const known = page - ? routed.some((p) => p.pageId === page) || componentPages.has(page) || owners.alive(page) - : routed.length > 0 || componentPages.size > 0 || owners.live().length > 0; - if (!known) { - return resolve({ - error: page - ? `Page \`${page.slice(0, 50)}\` is not connected. pangular:list-pages lists the pages that are.` - : 'No page is connected. Open the app in a browser, or the device app in the foreground.', - }); - } - const requestId = `a${++actionSeq}`; - const timer = setTimeout(() => { - pendingActions.delete(requestId); - resolve({ - error: - 'No page answered within 15s. Is the app open in a browser, or the device app in the foreground?', - }); - }, 15_000); - timer.unref?.(); - pendingActions.set(requestId, { - pageId, - settle: (result) => { - clearTimeout(timer); - pendingActions.delete(requestId); - resolve(result); - }, - }); - void my.rpc.broadcast({ - method: 'router-action', - // `app` picks one app on a page that runs several (`router-apps.ts`). - args: [{ requestId, pageId, request, ...(app ? { app } : {}) }], - optional: true, - }); - }); - - register({ - name: 'router-action-result', - type: 'action', - jsonSerializable: true, - handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { - if (typeof message?.requestId !== 'string') return; - const pending = pendingActions.get(message.requestId); - if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); - }, - }); - - register({ - name: 'request-router-action', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: string; app?: unknown; request?: unknown }) => - requestRouterAction( - typeof message?.pageId === 'string' ? message.pageId : undefined, - message?.request, - typeof message?.app === 'string' ? message.app.slice(0, 60) : undefined, - ), - }); - - /** The router state with one page shown as one of its apps, for the panel's app picker. */ - const routerStateFor = (pageId: unknown, app: unknown): RouterState => { - const state = routerState.value() as RouterState; - if (typeof app !== 'string' || !app) return state; - const scoped = routerStateForApp(state, { - page: typeof pageId === 'string' ? pageId : undefined, - app, - }); - // An app the page does not have shows nothing, never the first app under its name. - return 'error' in scoped ? { pages: [] } : scoped.state; - }; - const pageFor = (pageId: unknown, app?: unknown) => { - const state = routerStateFor(pageId, app); - return typeof pageId === 'string' - ? state.pages.find((p) => p.pageId === pageId) - : (defaultPage(state.pages.filter((p) => p.snapshot)) ?? defaultPage(state.pages)); - }; - - register({ - name: 'router-lint', - type: 'query', - jsonSerializable: true, - // A page id, or `{ pageId, app }` for one app of a page that runs several. - handler: (target: unknown) => - routerLintResult( - target && typeof target === 'object' - ? pageFor((target as { pageId?: unknown }).pageId, (target as { app?: unknown }).app) - : pageFor(target), - { - rules: config.lint.rules, - httpFor: routerHttpCalls, - }, - ), - }); - - register({ - name: 'router-match', - type: 'query', - jsonSerializable: true, - handler: (message: { pageId?: unknown; app?: unknown; url?: unknown }) => { - const page = pageFor(message?.pageId, message?.app); - if (!page?.config || typeof message?.url !== 'string') return null; - return matchUrl(page.config, message.url.slice(0, 2000)); - }, - }); - - /** The HTTP inspector's calls of a page, for the router's per-navigation HTTP join. */ - const routerHttpCalls = (pageId: string) => - on.http ? httpPages.get(pageId)?.calls : undefined; - - register({ - name: 'router-http', - type: 'query', - jsonSerializable: true, - handler: (message: { pageId?: unknown; app?: unknown; id?: unknown }) => { - const page = pageFor(message?.pageId, message?.app); - const nav = page?.navigations.find((n) => n.id === message?.id); - if (!page || !nav) return null; - return callsForNavigation( - nav, - nextNavigation(page.navigations, nav), - routerHttpCalls(page.pageId), - ); - }, - }); - - /** The source routes and ServerRoute entries a preview names, empty when the scan fails. */ - const previewScans = () => { - let sources: ReturnType = []; - let serverRoutes: ReturnType = []; - try { - sources = extractRoutes(ctx.cwd); - } catch { - sources = []; - } - try { - serverRoutes = scanServerRoutes(ctx.cwd); - } catch { - serverRoutes = []; - } - return { sources, serverRoutes }; - }; - - register({ - name: 'router-preview', - type: 'query', - jsonSerializable: true, - handler: (message: { pageId?: unknown; app?: unknown; url?: unknown }) => { - const { sources, serverRoutes } = previewScans(); - return routePreviewResult( - routerStateFor(message?.pageId, message?.app), - { - page: typeof message?.pageId === 'string' ? message.pageId : undefined, - url: typeof message?.url === 'string' ? message.url : undefined, - }, - sources, - serverRoutes, - ); - }, - }); - - register({ - name: 'router-diff', - type: 'query', - jsonSerializable: true, - handler: (message: { pageId?: unknown; app?: unknown; a?: unknown; b?: unknown }) => - navigationDiffResult(routerStateFor(message?.pageId, message?.app), { - page: typeof message?.pageId === 'string' ? message.pageId : undefined, - a: typeof message?.a === 'number' ? message.a : undefined, - b: typeof message?.b === 'number' ? message.b : undefined, - }), - }); - - register({ - name: 'router-export', - type: 'query', - jsonSerializable: true, - handler: (message: { pageId?: unknown; app?: unknown; id?: unknown }) => - exportNavigationText(routerStateFor(message?.pageId, message?.app), { - page: typeof message?.pageId === 'string' ? message.pageId : undefined, - id: typeof message?.id === 'number' ? message.id : undefined, - }), - }); - - register({ - name: 'clear-router-navigations', - type: 'action', - jsonSerializable: true, - // A page id, or `{ pageId, app }` to clear one app of a page that runs several. - handler: (target: unknown) => { - if (!config.actions.router) throw new Error(actionBlockedMessage('router')); - const record = - target && typeof target === 'object' ? (target as Record) : {}; - const pageId = typeof target === 'string' ? target : record['pageId']; - const app = typeof record['app'] === 'string' && record['app'] ? record['app'] : undefined; - if (typeof pageId !== 'string') throw new Error('Give the id of the page to clear.'); - const unknownApp = unknownRouterApp(routerPages, pageId, app); - if (unknownApp) throw new Error(unknownApp); - const secondary = routerPages.get(pageId)?.apps?.some((a) => a.id === app && !a.primary); - if (clearRouterNavigations(routerPages, pageId, Date.now(), app)) { - applyRouter(currentRouter(routerPages)); - } - // Each app's navigations in the stream go with its history; the first app's carry no id. - navigationStream.clear(pageId, app ? (secondary ? app : null) : undefined); - void my.rpc.broadcast({ - method: 'router-clear', - args: [app ? { pageId, app } : { pageId }], - optional: true, - }); - }, - }); - - register({ - name: 'forget-router-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: string) => { - if (mayForget(pageId) && routerPages.delete(pageId)) { - applyRouter(currentRouter(routerPages)); - navigationStream.clear(pageId); - } - }, - }); - - const httpPages = new Map(); - const registry = httpRegistry(); - registry.dispose?.(); - registry.rules = on.http && config.actions.http ? (registry.rules ?? []) : []; - const httpState = await my.rpc.sharedState('http', { - initialValue: { - serverCalls: [], - requests: [], - pages: [], - rules: [...registry.rules], - ssrOverrides: [], - } as HttpState, - }); - // Overrides change server responses, so they live only while this server runs. - ssrRegistry().overrides = []; - const httpPayloadState = await my.rpc.sharedState('http-payloads', { - initialValue: { pages: {} } as HttpPayloadState, - }); - const httpPayloads = new Map(); - let pendingServerCalls: HttpCall[] = []; - let pendingDropped = 0; - let flushTimer: ReturnType | undefined; - const flushServerCalls = () => { - flushTimer = undefined; - const batch = pendingServerCalls; - pendingServerCalls = []; - let dropped = pendingDropped; - pendingDropped = 0; - httpState.mutate((draft) => { - draft.serverCalls.push(...batch); - const extra = draft.serverCalls.length - limits.httpCalls; - if (extra > 0) { - draft.serverCalls.splice(0, extra); - dropped += extra; - } - if (dropped) draft.serverDropped = (draft.serverDropped ?? 0) + dropped; - }); - }; - const ssr = ssrRegistry(); - ssr.record = on.http - ? (raw) => { - const request = sanitizeSsrRequest(raw); - if (!request) return; - // Server calls are batched, so flush them first to keep their order. - if (flushTimer) { - clearTimeout(flushTimer); - flushServerCalls(); - } - httpState.mutate((draft) => { - draft.requests.push(request); - const extra = draft.requests.length - limits.httpCalls; - if (extra > 0) draft.requests.splice(0, extra); - }); - } - : undefined; - registry.record = on.http - ? (call) => { - pendingServerCalls.push(redactCall(call)); - if (pendingServerCalls.length > limits.httpCalls) { - pendingServerCalls.shift(); - pendingDropped++; - } - flushTimer ??= setTimeout(flushServerCalls, 100); - } - : () => {}; - const applyHttpPages = () => - httpState.mutate((draft) => { - draft.pages = [...httpPages.values()].sort((a, b) => a.firstSeenAt - b.firstSeenAt); - }); - const forgetHttpPages = (pageIds: string[]) => { - for (const id of pageIds) httpPages.delete(id); - applyHttpPages(); - if (!pageIds.filter((id) => httpPayloads.delete(id)).length) return; - httpPayloadState.mutate((draft) => { - for (const id of pageIds) delete draft.pages[id]; - }); - }; - const setHttpRules = (rules: HttpRule[]) => { - registry.rules = rules; - httpState.mutate((draft) => { - draft.rules = rules; - }); - void my.rpc.broadcast({ method: 'http-rules', args: [rules], optional: true }); - return rules; - }; - - register({ - name: 'push-http', - type: 'action', - jsonSerializable: true, - handler: (report: unknown) => { - const page = report as (Partial & { payload?: unknown }) | null; - if (!page || typeof page.pageId !== 'string' || typeof page.url !== 'string') return; - if (page.pageId.length >= 50 || !owns(page.pageId)) return; - const known = httpPages.get(page.pageId); - const url = redactUrl(page.url); - const hasPayload = page.payload !== undefined; - if (!hasPayload && !known) return { needPayload: true }; - const calls = sanitizeCalls(page.calls, limits.httpCalls).map(redactCall); - httpPages.set(page.pageId, { - pageId: page.pageId, - url, - initialUrl: typeof page.initialUrl === 'string' ? redactUrl(page.initialUrl) : url, - title: - typeof page.title === 'string' - ? redactMessage(page.title.slice(0, 2000)).slice(0, 200) - : '', - hydration: sanitizeHydration(page.hydration), - calls: - page.full === false && known - ? [...known.calls, ...calls].slice(-limits.httpCalls) - : calls, - dropped: - typeof page.dropped === 'number' && page.dropped > 0 ? Math.floor(page.dropped) : 0, - ...(isSsrRequestId(page.ssrRequestId) - ? { ssrRequestId: page.ssrRequestId } - : known?.ssrRequestId - ? { ssrRequestId: known.ssrRequestId } - : {}), - firstSeenAt: known?.firstSeenAt ?? Date.now(), - reportedAt: Date.now(), - }); - applyHttpPages(); - feed.hydration(page.pageId, httpPages.get(page.pageId)?.hydration); - if (hasPayload) { - const payload = nameServerFns(sanitizePayload(page.payload)); - httpPayloads.set(page.pageId, payload); - httpPayloadState.mutate((draft) => { - draft.pages[page.pageId!] = payload; - }); - } - return { needPayload: false }; - }, - }); - - register({ - name: 'ping-http', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - const page = owns(pageId) ? httpPages.get(pageId) : undefined; - if (!page) return { known: false }; - httpPages.set(page.pageId, { ...page, reportedAt: Date.now() }); - return { known: true }; - }, - }); - - register({ - name: 'forget-http-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - if (mayForget(pageId) && httpPages.has(pageId)) forgetHttpPages([pageId]); - }, - }); - - register({ - name: 'get-http-rules', - type: 'query', - jsonSerializable: true, - handler: () => registry.rules ?? [], - }); - - register({ - name: 'set-http-rules', - type: 'action', - jsonSerializable: true, - handler: (rules: unknown) => { - if (!config.actions.http) throw new Error(actionBlockedMessage('http')); - return setHttpRules(sanitizeRules(rules)); - }, - }); - - const saveSsrOverrides = (overrides: SsrOverride[]) => { - ssrRegistry().overrides = overrides; - httpState.mutate((draft) => { - draft.ssrOverrides = overrides; - }); - return overrides; - }; - register({ - name: 'set-ssr-overrides', - type: 'action', - jsonSerializable: true, - handler: (input: unknown) => { - if (!config.actions.http) throw new Error(actionBlockedMessage('http')); - return saveSsrOverrides(sanitizeSsrOverrides(input)); - }, - }); - registerRefetchTools({ - agent, - answersFor, - config, - httpPages, - httpPayloadState, - httpPayloads, - my, - register, - }); - - // The panel's TransferState payload and SSR overrides, for agents. - for (const tool of ssrOverrideTools({ - config, - overrides: () => ssrRegistry().overrides ?? [], - save: saveSsrOverrides, - payloads: () => httpPayloads, - pages: () => httpPages.values(), - })) - agent.registerTool(tool); - - const clearHttpCalls = () => { - if (!config.actions.http) throw new Error(actionBlockedMessage('http')); - pendingServerCalls = []; - pendingDropped = 0; - for (const [id, page] of httpPages) httpPages.set(id, { ...page, calls: [], dropped: 0 }); - httpState.mutate((draft) => { - draft.serverCalls = []; - draft.serverDropped = 0; - draft.requests = []; - draft.pages = [...httpPages.values()].sort((a, b) => a.firstSeenAt - b.firstSeenAt); - }); - void my.rpc.broadcast({ method: 'http-clear', args: [], optional: true }); - }; - - register({ - name: 'clear-http-calls', - type: 'action', - jsonSerializable: true, - handler: clearHttpCalls, - }); - - const forgetSignalPages = (ids: string[]) => { - for (const id of ids) signalPages.delete(id); - signalGraphState.mutate((draft) => { - for (const id of ids) delete draft.pages[id]; - const ownerId = draft.graph?.pageId; - if (ownerId && ids.includes(ownerId)) { - const latest = rankPages( - [...signalPages].map(([pageId, entry]) => ({ ...entry, pageId })), - )[0]; - draft.graph = latest?.graph ?? null; - } - }); - }; - - const expiry = setInterval(() => { - // Background marks first, then beats, on one clock: a page whose data - // goes in this sweep is gone from the beats too, so its next beat is unknown. - const now = Date.now(); - if (visibility.expire(now)) applyVisibility(); - heartbeats.expire(now, visibility.has); - if (angularPages.expire(liveTtl)) applyAngularPages(); - owners.expire(now); - const staleHttp = [...httpPages].filter(([id, p]) => now - p.reportedAt > liveTtl(id)); - if (staleHttp.length) forgetHttpPages(staleHttp.map(([id]) => id)); - const stale = [...signalPages].filter(([id, p]) => now - p.reportedAt > liveTtl(id)); - if (stale.length) forgetSignalPages(stale.map(([id]) => id)); - if (expireComponentPages(componentPages, now, liveTtl)) applyComponentPages(); - if (expireCdPages(cdPages, now, liveTtl)) applyCd(); - for (const id of goneCdPages) { - if (!componentPages.has(id) && !injectorPages.has(id)) goneCdPages.delete(id); - } - const staleInjectors = [...injectorPages].filter( - ([id, p]) => now - p.reportedAt > liveTtl(id), - ); - if (staleInjectors.length) { - for (const [id] of staleInjectors) injectorPages.delete(id); - applyInjectorPages(); - } - const next = expirePages(formPages, now, limits.formTimeline, liveTtl); - if (next) applyForms(next); - const nextRouter = expireRouterPages(routerPages, now, liveTtl); - if (nextRouter) { - applyRouter(nextRouter); - dropGoneStreamPages(); - } - const nextPipes = expirePipePages(pipePages, now, liveTtl); - if (nextPipes) applyPipes(nextPipes); - if (expireNgrxPages(ngrxPages, now, liveTtl)) applyNgrx(); - if (expireCapacitorPages(capacitorPages, now, liveTtl)) applyCapacitor(); - errorsInspector?.expire(now); - serverErrors?.expire(now); - perfInspector?.expire(now); - leaksInspector?.expire(now); - reduxStores?.expire(now); - tanstackQuery?.expire(now); - startupInspector?.expire(now); - ionicInspector?.expire(now); - pluginsInspector?.expire(now); - componentDeltas.prune((id) => componentPages.has(id)); - injectorDeltas.prune((id) => injectorPages.has(id)); - cdDeltas.prune((id) => cdPages.has(id)); - formDeltas.prune((id) => formPages.has(id)); - // A page that stopped reporting (a closed browser that never said - // goodbye) is gone, so a wake doesn't wait for it. - interest.prune( - (id) => - owners.alive(id) || componentPages.has(id) || injectorPages.has(id) || cdPages.has(id), - ); - }, 5000); - expiry.unref?.(); - registry.owner = ctx; - registry.dispose = () => { - clearInterval(expiry); - clearInterval(interestTick); - pluginsInspector?.dispose(); - serverErrors?.dispose(); - interest.stop(); - clearTimeout(flushTimer); - registry.record = undefined; - ssr.record = undefined; - ssr.overrides = undefined; - }; - - const httpSnapshot = () => httpState.value() as HttpState; - registerHttpAgentTools({ - agent, - applyRouter, - capacitorPages, - clearHttpCalls, - config, - httpState, - my, - registry, - routerPages, - setHttpRules, - navigationStream, - }); - - register({ - name: 'ping-forms', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => ({ known: owns(pageId) && touchFormsPage(pageId) }), - }); - - register({ - name: 'forget-forms-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: string) => { - if (!mayForget(pageId)) return; - formDeltas.forget(pageId); - if (formPages.delete(pageId)) applyForms(currentForms(formPages, limits.formTimeline)); - }, - }); - - const highlightSessions = trackPageSessions(ctx.rpc, (kinds) => { - for (const method of kinds) void my.rpc.broadcast({ method, args: [null], optional: true }); - }); - - register({ - name: 'request-form-highlight', - type: 'action', - jsonSerializable: true, - handler: (target: { formId: string; path: string } | null) => { - if (target) highlightSessions.bind('highlight-form-field'); - else highlightSessions.unbind('highlight-form-field'); - void my.rpc.broadcast({ - method: 'highlight-form-field', - args: [target], - optional: true, - }); - }, - }); - - register({ - name: 'request-page-highlight', - type: 'action', - jsonSerializable: true, - handler: (selector: unknown) => { - const target = pageHighlightTarget(selector); - if (target) highlightSessions.bind('highlight-in-page'); - else highlightSessions.unbind('highlight-in-page'); - void my.rpc.broadcast({ method: 'highlight-in-page', args: [target], optional: true }); - }, - }); - - const pendingFormActions = new Map< - string, - { pageId: string | undefined; settle: (result: unknown) => void } - >(); - let formActionSeq = 0; - - const requestFormAction = (request: Record, timeoutMs = 15_000) => { - const action = request['action'] as string; - if (!config.actions.forms && FORM_WRITE_ACTIONS.includes(action)) { - return Promise.resolve>({ - ok: false, - error: actionBlockedMessage('forms'), - }); - } - const formId = request['formId']; - if (!FORMLESS_FORM_ACTIONS.includes(action) && (typeof formId !== 'string' || !formId)) { - return Promise.resolve>({ - ok: false, - error: `${String(action)} needs a formId.`, - }); - } - return sendFormAction(request, timeoutMs); - }; - - const sendFormAction = (request: Record, timeoutMs: number) => - new Promise>((resolve) => { - const requestId = `f${++formActionSeq}`; - const formId = typeof request['formId'] === 'string' ? request['formId'] : undefined; - const explicit = typeof request['page'] === 'string' ? request['page'] : undefined; - const pageId = explicit ?? (formId?.includes('@') ? formId.split('@')[1] : undefined); - const { page: _page, ...payload } = request; - const timer = setTimeout(() => { - pendingFormActions.delete(requestId); - resolve({ - ok: false, - error: `No page answered within ${Math.round(timeoutMs / 1000)}s. Is the app open in a browser (or the device app in the foreground), with that form on screen?`, - }); - }, timeoutMs); - timer.unref?.(); - pendingFormActions.set(requestId, { - pageId, - settle: (result) => { - clearTimeout(timer); - pendingFormActions.delete(requestId); - resolve( - (result && typeof result === 'object' - ? result - : { ok: false, error: 'Empty answer.' }) as Record, - ); - }, - }); - // Only the page's own connection gets the request: it can carry the values an agent fills in. - // The scoped `broadcast` passes `filter` through to devframe's host; its type leaves it out. - void (my.rpc.broadcast as (options: OwnerBroadcast) => Promise)({ - method: 'form-action', - args: [{ requestId, pageId, request: payload }], - optional: true, - filter: owners.deliversTo(pageId), - }); - }); - - register({ - name: 'form-action-result', - type: 'action', - jsonSerializable: true, - handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { - if (typeof message?.requestId !== 'string') return; - const pending = pendingFormActions.get(message.requestId); - if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); - }, - }); - - register({ - name: 'request-form-action', - type: 'action', - jsonSerializable: true, - handler: (request: unknown) => - request && typeof request === 'object' - ? requestFormAction(request as Record) - : { ok: false, error: 'Bad request.' }, - }); - - register({ - name: 'forms-lint', - type: 'query', - jsonSerializable: true, - handler: (args: { form?: unknown; page?: unknown } | null) => - lintFormsFor( - formsState.value() as FormsState, - { - form: typeof args?.form === 'string' ? args.form : undefined, - page: typeof args?.page === 'string' ? args.page : undefined, - }, - undefined, - { rules: config.lint.rules }, - ), - }); - - register({ - name: 'forms-owners', - type: 'query', - jsonSerializable: true, - handler: () => - (formsState.value() as FormsState).forms.map((form) => ({ - formId: form.id, - label: form.label, - file: findFormSource(ctx.cwd, form.owner, form.property)?.form?.file ?? null, - })), - }); - - register({ - name: 'forms-explain', - type: 'query', - jsonSerializable: true, - handler: (args: { kind?: unknown; form?: unknown; path?: unknown } | null) => { - const state = formsState.value() as FormsState; - const target = { - form: typeof args?.form === 'string' ? args.form : undefined, - path: typeof args?.path === 'string' ? args.path : undefined, - }; - switch (args?.kind) { - case 'submit': - return explainSubmitText(state, target); - case 'payload': - return formPayloadText(state, target); - case 'fixture': - return exportFormText(state, { ...target, format: 'fixture' }); - case 'playwright': - return playwrightTestText(state, { ...target, url: formPageUrl(state, target) }); - default: - return explainFieldText(state, target); - } - }, - }); - - // A tab whose connection dropped gets no change detection record request, - // so its report goes at once and the tool does not count it as connected - // until it reports again. Bound from the component, injector and change - // detection reports, the ones the tool reads tabs from. - const cdPages = new Map(); - const goneCdPages = new Set(); - let applyCd = () => {}; - const markCdStopped = (pageId?: string) => { - if (markCdPagesStopped(cdPages, pageId)) applyCd(); - }; - const cdSessions = trackPageSessions(ctx.rpc, (pageIds) => { - let removed = false; - for (const pageId of pageIds) { - goneCdPages.add(pageId); - removed = cdPages.delete(pageId) || removed; - } - if (removed) applyCd(); - }); - const cdSeen = (pageId: string) => { - cdSessions.bind(pageId); - goneCdPages.delete(pageId); - }; - - register({ - name: 'push-component-tree', - type: 'action', - jsonSerializable: true, - handler: deltaPush(componentDeltas, owns, (report, pageNow) => { - if (!isComponentReport(report) || !owns(report.pageId)) return false; - componentPages.set(report.pageId, toComponentPage(report, Date.now(), pageNow)); - cdSeen(report.pageId); - applyComponentPages(); - for (const check of [...componentWaiters]) check(); - return true; - }), - }); - - const cdState = await my.rpc.sharedState('change-detection', { - initialValue: { pages: {} } as CdState, - }); - applyCd = () => - cdState.mutate((draft) => { - draft.pages = Object.fromEntries(cdPages); - }); - // The pages confirm a recording request by pushing their recording again. - const cdAcks = createPushAcks(); - const requestCdRecord = (message: { pageId?: unknown; on?: unknown; clear?: unknown }) => { - const pageId = typeof message?.pageId === 'string' ? message.pageId.slice(0, 50) : undefined; - const on = typeof message?.on === 'boolean' ? message.on : undefined; - // The summary must not say "Recording is on" from the last push before the stop. - if (on === false) markCdStopped(pageId); - void my.rpc.broadcast({ - method: 'change-detection-record', - args: [{ pageId, on, clear: message?.clear === true }], - optional: true, - }); - }; - - const requestCdOptions = (message: unknown) => { - const raw = (message && typeof message === 'object' ? message : {}) as { pageId?: unknown }; - const pageId = typeof raw.pageId === 'string' ? raw.pageId.slice(0, 50) : undefined; - void my.rpc.broadcast({ - method: 'change-detection-options', - args: [{ pageId, ...toCdOptionsRequest(message) }], - optional: true, - }); - }; - - register({ - name: 'push-change-detection', - type: 'action', - jsonSerializable: true, - handler: deltaPush(cdDeltas, owns, (report) => { - const page = toCdPage(report, limits.cdCycles); - if (!page || !owns(page.pageId)) return false; - cdPages.set(page.pageId, page); - cdSeen(page.pageId); - applyCd(); - cdAcks.notify(page.pageId, page); - return true; - }), - }); - - register({ - name: 'ping-change-detection', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - const page = owns(pageId) ? cdPages.get(pageId) : undefined; - if (!page) return { known: false }; - cdPages.set(page.pageId, { ...page, reportedAt: Date.now() }); - cdSeen(page.pageId); - return { known: true }; - }, - }); - - register({ - name: 'forget-change-detection-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - if (!mayForget(pageId)) return; - cdDeltas.forget(pageId); - if (cdPages.delete(pageId)) applyCd(); - }, - }); - - register({ - name: 'request-change-detection-record', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; on?: unknown; clear?: unknown }) => { - requestCdRecord(message); - }, - }); - - register({ - name: 'request-change-detection-options', - type: 'action', - jsonSerializable: true, - handler: (message: unknown) => { - requestCdOptions(message); - }, - }); - - register({ - name: 'ping-component-tree', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - const page = owns(pageId) ? componentPages.get(pageId) : undefined; - if (!page) return { known: false }; - componentPages.set(page.pageId, { ...page, reportedAt: Date.now() }); - cdSeen(page.pageId); - return { known: true }; - }, - }); - - register({ - name: 'forget-component-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - if (!mayForget(pageId)) return; - componentDeltas.forget(pageId); - if (componentPages.delete(pageId)) applyComponentPages(); - }, - }); - - register({ - name: 'select-component', - type: 'action', - jsonSerializable: true, - handler: (target: string | { pageId?: unknown; id?: unknown; console?: unknown } | null) => { - if (typeof target === 'string') { - void my.rpc.broadcast({ - method: 'select-signal-component', - args: [target.slice(0, 500)], - optional: true, - }); - return; - } - const id = typeof target?.id === 'string' ? target.id.slice(0, 50) : null; - const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; - selectComponentOnPage(pageId, id, target?.console === true); - }, - }); - - // Draws the hydration overlay on a page; it changes nothing in the app. - const requestHydrationOverlay = (message: { pageId?: unknown; on?: unknown } | null) => { - if (typeof message?.on !== 'boolean') return; - const pageId = typeof message.pageId === 'string' ? message.pageId.slice(0, 50) : undefined; - void my.rpc.broadcast({ - method: 'hydration-overlay', - args: [{ pageId, on: message.on }], - optional: true, - }); - }; - register({ - name: 'request-hydration-overlay', - type: 'action', - jsonSerializable: true, - handler: requestHydrationOverlay, - }); - - // Writes to component inputs, properties and signals: panel and agent tools. - const componentEdits = createComponentEdits({ - config, - pages: () => componentPages.values(), - // Only the page's own connection gets the value to write, not the panel or other tabs. - broadcast: (method, message) => { - const pageId = (message as { pageId?: unknown } | null)?.pageId; - void (my.rpc.broadcast as (options: OwnerBroadcast) => Promise)({ - method, - args: [message], - optional: true, - filter: owners.deliversTo(typeof pageId === 'string' ? pageId : undefined), - }); - }, - answersFor, - }); - register({ - name: 'component-edit-result', - type: 'action', - jsonSerializable: true, - handler: componentEdits.answer, - }); - register({ - name: 'request-component-edit', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; request?: unknown } | null) => - componentEdits.request(message?.pageId, message?.request), - }); - for (const tool of componentEdits.tools) agent.registerTool(tool); - - // Loads one @defer block when an agent asks: same gate and owner check as the writes above. - const deferLoads = createDeferLoads({ - config, - pages: () => componentPages.values(), - broadcast: (method, message) => { - const pageId = (message as { pageId?: unknown } | null)?.pageId; - void (my.rpc.broadcast as (options: OwnerBroadcast) => Promise)({ - method, - args: [message], - optional: true, - filter: owners.deliversTo(typeof pageId === 'string' ? pageId : undefined), - }); - }, - answersFor, - }); - register({ - name: 'defer-load-result', - type: 'action', - jsonSerializable: true, - handler: deferLoads.answer, - }); - agent.registerTool(deferLoads.tool); - - // Nested levels of component values: the panel opens them, inspect-component reads them. - const componentValues = createComponentValues({ - pages: () => componentPages.values(), - broadcast: (method, message) => - void my.rpc.broadcast({ method, args: [message], optional: true }), - answersFor, - }); - register({ - name: 'component-value-result', - type: 'action', - jsonSerializable: true, - handler: componentValues.answer, - }); - register({ - name: 'request-component-value', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; request?: unknown } | null) => - componentValues.request(message?.pageId, message?.request), - }); - - // Images of one component, drawn by its page: the screenshot-component tool. - const componentScreenshots = createComponentScreenshots({ - pages: () => componentPages.values(), - broadcast: (method, message) => - void my.rpc.broadcast({ method, args: [message], optional: true }), - answersFor, - }); - register({ - name: 'component-screenshot-result', - type: 'action', - jsonSerializable: true, - handler: componentScreenshots.answer, - }); - agent.registerTool(componentScreenshots.tool); - - registerConsoleGlobals({ - config, - register, - broadcast: (message) => void my.rpc.broadcast(message), - answersFor, - }); - - const pendingPicks = new Map< - string, - { pageId: string; settle: (result: Record) => void } - >(); - let pickSeq = 0; - - register({ - name: 'request-component-pick', - type: 'action', - jsonSerializable: true, - handler: (target: { pageId?: unknown } | null) => { - const asked = typeof target?.pageId === 'string' ? target.pageId : undefined; - const pageId = asked ?? latestComponentPage(componentPages.values())?.pageId; - if (!pageId || !componentPages.has(pageId)) { - return { ok: false, error: 'No page is connected. Open the app in a browser first.' }; - } - return new Promise>((resolve) => { - const requestId = `pick${++pickSeq}`; - const timer = setTimeout(() => { - pendingPicks.delete(requestId); - resolve({ - ok: false, - error: - 'The page did not answer. Is the app still open in a browser, or the device app in the foreground?', - }); - }, 20_000); - timer.unref?.(); - pendingPicks.set(requestId, { - pageId, - settle: (result) => { - clearTimeout(timer); - pendingPicks.delete(requestId); - if (result['ok'] === true && typeof result['id'] === 'string') { - componentTree.mutate((draft) => { - draft.selectedId = result['id'] as string; - }); - } - resolve({ ...result, pageId }); - }, - }); - void my.rpc.broadcast({ - method: 'component-pick', - args: [{ requestId, pageId }], - optional: true, - }); - }); - }, - }); - - // A keyboard move of the running pick: the panel sends the key, the page - // answers with the component it shows now on the pick's result channel. - register({ - name: 'request-component-pick-move', - type: 'action', - jsonSerializable: true, - handler: (target: { pageId?: unknown; key?: unknown } | null) => { - const key = PICK_KEYS.find((k) => k === target?.key); - if (!key) return { moved: false, error: 'Unknown key.' }; - const asked = typeof target?.pageId === 'string' ? target.pageId : undefined; - const pageId = asked ?? latestComponentPage(componentPages.values())?.pageId; - if (!pageId || !componentPages.has(pageId)) { - return { moved: false, error: 'No page is connected. Open the app in a browser first.' }; - } - return new Promise>((resolve) => { - const requestId = `pick${++pickSeq}`; - const timer = setTimeout(() => { - pendingPicks.delete(requestId); - resolve({ moved: false, error: 'The page did not answer.' }); - }, 5_000); - timer.unref?.(); - pendingPicks.set(requestId, { - pageId, - settle: (result) => { - clearTimeout(timer); - pendingPicks.delete(requestId); - resolve(result); - }, - }); - void my.rpc.broadcast({ - method: 'component-pick', - args: [{ requestId, pageId, move: key }], - optional: true, - }); - }); - }, - }); - - register({ - name: 'cancel-component-pick', - type: 'action', - jsonSerializable: true, - handler: (target: { pageId?: unknown } | null) => { - const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; - void my.rpc.broadcast({ - method: 'component-pick', - args: [{ pageId, cancel: true }], - optional: true, - }); - }, - }); - - register({ - name: 'component-pick-result', - type: 'action', - jsonSerializable: true, - handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown } | null) => { - if (typeof message?.requestId !== 'string') return; - const pending = pendingPicks.get(message.requestId); - if (!pending || !answersFor(pending.pageId, message.pageId)) return; - const result = - message.result && typeof message.result === 'object' - ? (message.result as Record) - : { ok: false, error: 'Empty answer.' }; - pending.settle(result); - }, - }); - - register({ - name: 'select-signal-target', - type: 'action', - jsonSerializable: true, - handler: (target: { pageId?: unknown; id?: unknown; env?: unknown } | null) => { - const id = typeof target?.id === 'string' ? target.id.slice(0, 50) : null; - const env = typeof target?.env === 'string' ? target.env.slice(0, 200) : undefined; - const pageId = typeof target?.pageId === 'string' ? target.pageId : undefined; - void my.rpc.broadcast({ - method: 'select-signal-component', - args: [env ? { pageId, env } : { pageId, id }], - optional: true, - }); - }, - }); - - // Shared by the panel's Watch and Record write stacks buttons and the watch-signal agent tool. - const sendSignalWatch = (message: unknown) => { - const request = signalWatchRequest(message, signalPages); - if (!request) return { pages: 0 }; - void my.rpc.broadcast({ method: 'signal-watch', args: [request], optional: true }); - return { pages: signalPages.has(request.pageId) ? 1 : 0, pageId: request.pageId }; - }; - - register({ - name: 'set-signal-watch', - type: 'action', - jsonSerializable: true, - handler: sendSignalWatch, - }); - - agent.registerTool({ - id: 'pangular:watch-signal', - title: 'Watch a signal', - description: - 'Watch a signal (`node`, id or label) so its changes log to the console and show in inspect-signals, or unwatch with `watch: false`; `stacks` toggles write stacks. Resets on reload.', - inputSchema: { - type: 'object', - properties: { - node: { - type: 'string', - description: - 'Node id or label from pangular:inspect-signals. A label watches every node with it.', - }, - watch: { - type: 'boolean', - description: 'False stops watching `node`. Defaults to true.', - }, - stacks: { - type: 'boolean', - description: 'True records write stacks on the page, false stops.', - }, - page: PAGE_ARGUMENT, - }, - }, - handler: async (args: WatchSignalArgs) => { - const unsupported = signalPages.size - ? null - : unsupportedToolText(angularPages.pick(pageArgument(args)), 'signalGraph'); - if (unsupported) return { markdown: unsupported }; - const asked = watchSignalRequests(args ?? {}, signalPages, pageArgument(args)); - if ('markdown' in asked) return asked; - for (const request of asked.requests) sendSignalWatch({ ...request, pageId: asked.pageId }); - return { markdown: watchSignalText(args ?? {}, asked.pageId, asked.labels) }; - }, - }); - - register({ - name: 'push-signal-graph', - type: 'action', - jsonSerializable: true, - handler: (incoming: PageGraph & { historyDelta?: Record }) => { - const pageId = incoming?.pageId; - const known = typeof pageId === 'string' && pageId.length < 50; - if (known && !owners.write(pageId)) return { delta: true }; - const prev = known ? signalPages.get(pageId)?.graph : undefined; - let graph: PageGraph = incoming; - let delta = true; - if (incoming?.historyDelta) { - const { historyDelta, ...rest } = incoming; - // Node ids are only unique per component or injector, so a delta for a new owner can't - // build on the previous history. - const base = prev && signalOwner(prev) === signalOwner(rest) ? prev : undefined; - if (!base) delta = false; - const history: Record = {}; - for (const { id } of [...(rest.nodes ?? []), ...(rest.resources ?? [])]) { - const list = [...(base?.history?.[id] ?? []), ...(historyDelta[id] ?? [])]; - if (list.length) history[id] = list.slice(-50); - } - graph = { ...rest, history }; - } - if (known) signalPages.set(pageId, { graph, reportedAt: Date.now() }); - signalGraphState.mutate((draft) => { - draft.graph = graph; - // Every open page pushes, so one shared graph would flip between them. - draft.pages = Object.fromEntries([...signalPages].map(([id, page]) => [id, page.graph])); - }); - return { delta }; - }, - }); - - register({ - name: 'ping-signal-graph', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - const page = owns(pageId) ? signalPages.get(pageId) : undefined; - if (!page) return { known: false }; - signalPages.set(pageId as string, { ...page, reportedAt: Date.now() }); - return { known: true }; - }, - }); - - register({ - name: 'signal-graph-peek-result', - type: 'action', - jsonSerializable: true, - handler: (message: unknown) => { - const pageId = (message as { pageId?: unknown } | null)?.pageId; - if (answersFor(undefined, pageId)) signalPeeks.answer(message); - }, - }); - - // The panel's Restore button on a signal's value history; the value stays on the page. - const signalRestores = createSignalRestores({ - config, - graphOf: (pageId) => signalPages.get(pageId)?.graph, - broadcast: (method, message) => - void my.rpc.broadcast({ method, args: [message], optional: true }), - answersFor, - }); - register({ - name: 'signal-restore-result', - type: 'action', - jsonSerializable: true, - handler: signalRestores.answer, - }); - register({ - name: 'request-signal-restore', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; request?: unknown } | null) => - signalRestores.request(message?.pageId, message?.request), - }); - - register({ - name: 'forget-signal-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - if (mayForget(pageId) && signalPages.has(pageId)) forgetSignalPages([pageId]); - }, - }); - - register({ - name: 'push-injector-tree', - type: 'action', - jsonSerializable: true, - handler: deltaPush(injectorDeltas, owns, (raw) => { - const report = raw as { - pageId?: unknown; - roots?: unknown; - environment?: unknown; - truncated?: unknown; - zone?: unknown; - } | null; - const pageId = report?.pageId; - if (typeof pageId !== 'string' || !pageId || pageId.length >= 50) return false; - if (!owns(pageId)) return false; - const zone = ZONE_MODES.find((mode) => mode === report?.zone); - const tree = sanitizeInjectorTree(report?.roots, report?.environment); - const names = frameworkNames.current(); - const page: InjectorPage = { - pageId, - roots: tree.roots, - environment: tree.environment, - ...(report?.truncated === true || tree.truncated - ? { truncated: true, limits: INJECTOR_TREE_LIMITS } - : {}), - ...(zone ? { zone } : {}), - reportedAt: Date.now(), - }; - // Signal stores get the names the NgRx tools use (rpc/store-tokens.ts). - const named = on.ngrx ? nameStoreTokens(page, ngrxNames()) : page; - injectorPages.set(pageId, withFramework(named, names ?? new Set())); - cdSeen(pageId); - applyInjectorPages(); - return true; - }), - }); - - register({ - name: 'ping-injector-tree', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - const page = owns(pageId) ? injectorPages.get(pageId) : undefined; - if (!page) return { known: false }; - injectorPages.set(page.pageId, { ...page, reportedAt: Date.now() }); - cdSeen(page.pageId); - return { known: true }; - }, - }); - - register({ - name: 'forget-injector-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - if (!mayForget(pageId)) return; - injectorDeltas.forget(pageId); - if (injectorPages.delete(pageId)) applyInjectorPages(); - }, - }); - - // The value of a provider an injector already created: the Injectors view and read-provider-value. - const providerValues = createProviderValues({ - pages: () => injectorPages.values(), - latest: () => defaultPage(injectorPages.values()), - broadcast: (method, message) => - void my.rpc.broadcast({ method, args: [message], optional: true }), - answersFor, - }); - register({ - name: 'provider-value-result', - type: 'action', - jsonSerializable: true, - handler: providerValues.answer, - }); - register({ - name: 'request-provider-value', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; request?: unknown } | null) => - providerValues.request(message?.pageId, message?.request), - }); - - // The panel asks the page to read an element injector again (the one it - // selected), or every injector (Refresh). The page pushes a fresh tree. - register({ - name: 'request-injector-refresh', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; injectorId?: unknown } | null) => - requestInjectorRefresh( - message, - injectorPages.keys(), - (method, args) => void my.rpc.broadcast({ method, args: [args], optional: true }), - ), - }); - - register({ - name: 'push-ngrx-state', - type: 'action', - jsonSerializable: true, - handler: (report: unknown) => { - if (!isNgrxReport(report) || !owns(report.pageId)) return { seq: 0 }; - const seq = mergeNgrxReport(ngrxPages, report, ngrxNames(), Date.now(), limits.changeLog); - applyNgrx(); - return { seq }; - }, - }); - - register({ - name: 'forget-ngrx-page', - type: 'action', - jsonSerializable: true, - handler: (pageId: unknown) => { - if (mayForget(pageId) && ngrxPages.delete(pageId)) applyNgrx(); - }, - }); - - // The URL and title of each page, from its router, HTTP or component report. - pageUrl = (pageId) => livePageDetails().get(pageId)?.url; - const livePageDetails = () => - pageDetails({ - router: [...routerPages.values()].map((page) => ({ - pageId: page.pageId, - reportedAt: page.reportedAt, - url: page.snapshot?.url, - })), - http: httpPages.values(), - components: componentPages.values(), - }); - /** The address of the page a form is on, for the Playwright test export. */ - function formPageUrl(state: FormsState, args: FieldArgs): string | undefined { - const forms = matchForms(state.forms, args.form, args.page); - const pageId = (forms[0]?.id ?? '').split('@')[1] ?? args.page; - return pageId ? livePageDetails().get(pageId)?.url : undefined; - } - const withPageUrls = (pages: T[]) => { - const details = livePageDetails(); - return pages.map((page) => { - const detail = details.get(page.pageId); - return { - pageId: page.pageId, - reportedAt: page.reportedAt, - url: detail?.url, - // The Audit page picker names a page by its title before its URL. - ...(detail?.title ? { title: detail.title } : {}), - }; - }); - }; - - const pendingNgrx = new Map void }>(); - let ngrxSeq = 0; - - register({ - name: 'ngrx-action-result', - type: 'action', - jsonSerializable: true, - handler: (message: { requestId?: unknown; pageId?: unknown; result?: unknown }) => { - if (typeof message?.requestId !== 'string') return; - const pending = pendingNgrx.get(message.requestId); - if (pending && answersFor(pending.pageId, message.pageId)) pending.settle(message.result); - }, - }); - - const requestNgrxAction = (page: unknown, request: unknown) => - new Promise((resolve) => { - const kind = (request as { type?: unknown } | null)?.type; - // Reading an entry's state or exporting a session changes nothing, so writes off allow it. - const reads = kind === 'entry' || kind === 'export'; - if (!reads && !config.actions.ngrx) return resolve({ error: actionBlockedMessage('ngrx') }); - const all = ngrxStateOf(ngrxPages).pages; - // A restore can target a signal store; every other request needs the classic Store. - const pages = kind === 'restore' || reads ? all : all.filter((p) => p.classic); - // Without a page, a write would reach every tab, or one the caller did not choose. - if (typeof page !== 'string' && pages.length !== 1 && !(reads && pages.length)) { - return resolve({ - error: pages.length - ? choosePageText(withPageUrls(pages), 'NgRx state') - : 'No page reports NgRx state. Open the app in a browser, or pass the page id.', - }); - } - if (typeof page === 'string' && !all.some((p) => p.pageId === page)) { - return resolve({ - error: `Page \`${page.slice(0, 50)}\` reports no NgRx state. pangular:list-pages lists the pages that do.`, - }); - } - const pageId = typeof page === 'string' ? page : defaultPage(pages)!.pageId; - const requestId = `n${++ngrxSeq}`; - const timer = setTimeout(() => { - pendingNgrx.delete(requestId); - resolve({ - error: - 'No page answered within 10s. Is the app open in a browser, or the device app in the foreground?', - }); - }, 10_000); - timer.unref?.(); - pendingNgrx.set(requestId, { - pageId, - settle: (result) => { - clearTimeout(timer); - pendingNgrx.delete(requestId); - resolve((result ?? {}) as NgrxRequestResult); - }, - }); - void my.rpc.broadcast({ - method: 'ngrx-action', - args: [{ requestId, pageId, request }], - optional: true, - }); - }); - - register({ - name: 'request-ngrx-action', - type: 'action', - jsonSerializable: true, - handler: (message: { pageId?: unknown; request?: unknown }) => - requestNgrxAction(message?.pageId, message?.request), - }); - - // Past entries, session export and import, and generated tests (rpc/ngrx-session-tools.ts). - const ngrxHistory = createNgrxHistory({ - ask: (page, request) => requestNgrxAction(page, request), - page: (pageId) => ngrxPages.get(pageId), - pages: () => byRecency(ngrxPages.values()), - }); - register({ - name: 'request-ngrx-history', - type: 'action', - jsonSerializable: true, - handler: (message: unknown) => ngrxHistory.history(message), - }); - register({ - name: 'import-ngrx-session', - type: 'action', - jsonSerializable: true, - handler: (message: { session?: unknown } | null) => - ngrxHistory.importSession(message?.session), - }); - - // Agent resources - agent.registerResource({ - id: 'pangular:component-tree', - name: 'Angular Component Tree', - description: - 'Live component instances per page as JSON: rendered instances, directive hosts, counts and the selected instance detail. Empty when no page is connected. ' + - LIVE_RESOURCE_NOTE, - mimeType: 'application/json', - read: () => ({ - text: liveResourceText( - componentTree.value(), - 'pangular:list-components and pangular:inspect-component', - ), - }), - }); - - agent.registerResource({ - id: 'pangular:signal-graph', - name: 'Angular Signal Graph', - description: - 'Live signal graph per page as JSON: nodes, edges (`consumer` reads `producer`, by node id), contexts, resources, value history. Empty with no page. ' + - LIVE_RESOURCE_NOTE, - mimeType: 'application/json', - read: () => ({ - text: liveResourceText( - signalStateForAgents(signalGraphState.value()), - 'pangular:inspect-signals', - ), - }), - }); - - agent.registerResource({ - id: 'pangular:injector-tree', - name: 'Angular Injector Tree', - description: - 'DI injectors per page as JSON: element and environment injectors with providers and dependencies, and the zone mode. Empty when no page is connected. Past 100,000 characters an outline with provider counts.', - mimeType: 'application/json', - read: () => ({ - text: injectorResourceText(injectorTreeState.value(), Object.fromEntries(injectorPages)), - }), - }); - - agent.registerResource({ - id: 'pangular:ngrx-store', - name: 'NgRx Store State', - description: - 'Live NgRx state per page as JSON: signal stores, classic Store state and the change log with diffs and action origins. Empty when no page is connected. ' + - LIVE_RESOURCE_NOTE, - mimeType: 'application/json', - read: () => ({ - text: liveResourceText( - ngrxStoreState.value(), - 'pangular:ngrx-inspect-store and pangular:ngrx-history', - ), - }), - }); - - agent.registerResource({ - id: 'pangular:forms', - name: 'Angular Forms', - description: - "Every form a connected page last reported (Signal Forms, reactive and template-driven), with each field's value, status, touched, dirty and errors, plus recent changes. The JSON starts with an `_untrusted` notice. Empty when no page is connected.", - mimeType: 'application/json', - read: () => ({ text: formsResourceText(formsState.value() as FormsState) }), - }); - - agent.registerResource({ - id: 'pangular:router', - name: 'Angular Router', - description: - 'The active route tree (params, data, guards, resolvers) and recent navigations of each connected page. The JSON starts with an `_untrusted` notice. Empty when no page is connected.', - mimeType: 'application/json', - read: () => ({ text: routerResourceText(routerState.value() as RouterState) }), - }); - - agent.registerResource({ - id: 'pangular:pipe-usages', - name: 'Angular Pipe Usages', - description: - 'Pipes as JSON: `source` has each pipe with purity and template uses; `pipes`/`async` the live page usage, with churn and stale findings while recording. ' + - LIVE_RESOURCE_NOTE, - mimeType: 'application/json', - read: () => ({ - text: liveResourceText( - { source: scanPipes(ctx.cwd), ...currentPipes(pipePages) }, - 'pangular:scan-pipes and pangular:explain-pipe', - [...pipePages.keys()], - ), - }), - }); - - // The same states one page at a time, for the MCP resource templates (`agent-mcp.ts`). - providePageResources( - ctx, - createPageResourceSource({ - states: { - 'component-tree': componentTree, - 'signal-graph': signalGraphState, - 'injector-tree': injectorTreeState, - 'ngrx-store': ngrxStoreState, - forms: formsState, - router: routerState, - }, - touch: () => interest.touch(), - }), - ); - - const ngrxPageProperty = { - type: 'string', - description: 'Page id, when more than one tab reports. Defaults to every page.', - } as const; - - agent.registerTool({ - id: 'pangular:ngrx-inspect-store', - title: 'Inspect a signal store', - description: INSPECT_SIGNAL_STORE_DESCRIPTION, - inputSchema: { - type: 'object', - properties: { - page: ngrxPageProperty, - store: { - type: 'string', - description: - 'Store id or name (e.g. `ngrx-1` or `CartStore`, as shown on the NgRx Store page or returned by a previous call). Omit for a summary of every store across the matching page(s).', - }, - }, - }, - handler: async (args: { page?: string; store?: string }) => ({ - markdown: withUntrustedPreamble( - inspectSignalStoreText( - ngrxPages, - ngrxReadPage(args), - storeIdNamed(ngrxPages, ngrxReadPage(args), args?.store), - ), - ), - }), - }); - - agent.registerTool({ - id: 'pangular:ngrx-history', - title: 'Show signal store history', - description: SIGNAL_STORE_HISTORY_DESCRIPTION, - inputSchema: { - type: 'object', - properties: { - page: ngrxPageProperty, - store: { - type: 'string', - description: - 'Store id or name to filter the log to. Omit to include every store, plus `@ngrx/signals/events` events with no store effect.', - }, - after: { - type: 'number', - description: - 'Only return entries whose `seq` is strictly greater than this. Pass the last `seq` from a previous call to poll.', - }, - }, - }, - handler: async (args: { page?: string; store?: string; after?: number }) => ({ - markdown: withUntrustedPreamble( - signalStoreHistoryText( - ngrxPages, - ngrxReadPage(args), - storeIdNamed(ngrxPages, ngrxReadPage(args), args?.store), - args?.after, - ), - ), - }), + registerLiveResources({ + ctx, + agent, + interest, + componentTree, + signalGraphState, + injectorTreeState, + injectorPages, + ngrxStoreState, + formsState, + routerState, + pipePages, }); + registerNgrxReadTools({ agent, state: ngrx }); // Agent tools - const noComponentTree = (outcome: string) => - `No component tree has been reported${outcome}. This is what a page that has never connected reports, and also what a connected page reports when its components are not readable. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio MCP server has no page, so there it always answers this.`; - const componentPageProperty = { - type: 'string', - description: - 'Page id, when more than one tab reports (see pangular:list-pages). Searches every page without it, newest first.', - }; - const componentHits = (selector: string, pageId?: string) => - findComponents( - byRecency(componentPages.values()).filter((page) => !pageId || page.pageId === pageId), - selector, - ); - const unknownComponentPage = (pageId: string | undefined) => - pageId && !componentPages.has(pageId) - ? { - markdown: unknownPageText( - pageId, - byRecency(componentPages.values()), - 'a component tree', - ), - } - : null; - const forAgents = (key: PangularInspector) => on[key] && config.agent.tools[key]; // Set with the forms tools below. let waitForForm: @@ -2796,31 +459,28 @@ const pangular = defineDevframe({ registerComponentAgentTools({ agent, angularPages, - cdAcks, - cdState, - componentHits, - componentPageProperty, + cdAcks: cd.cdAcks, + cdState: cd.cdState, + ...componentToolHelpers(componentPages), componentPages, - componentValues, + componentValues: cd.componentValues, config, ctx, - goneCdPages, + goneCdPages: components.goneCdPages, injectorPages, - latestInjectorPage, + latestInjectorPage: injectors.latestInjectorPage, my, - noComponentTree, providerValues, register, - requestCdOptions, - requestCdRecord, - requestHydrationOverlay, + requestCdOptions: cd.requestCdOptions, + requestCdRecord: cd.requestCdRecord, + requestHydrationOverlay: cd.requestHydrationOverlay, routerPages, - selectComponentOnPage, + selectComponentOnPage: components.selectComponentOnPage, signalGraphState, signalPages, - signalPeeks, - unknownComponentPage, - waitForComponentDetail, + signalPeeks: signals.signalPeeks, + waitForComponentDetail: components.waitForComponentDetail, }); const noRouter = `No router state has been reported. Live data needs a page: connect through the MCP endpoint of the server that runs the app, with the app open in a browser. The stdio MCP server has no page, so there it always answers this.`; @@ -2833,89 +493,16 @@ const pangular = defineDevframe({ limits, noRouter, pageProperty, - requestRouterAction, + requestRouterAction: router.requestRouterAction, routerPages, routerState, withPageUrls, navigationStream, - previewScans, - routerHttpCalls, - }); - - agent.registerTool({ - id: 'pangular:ngrx-dispatch', - title: 'Dispatch an NgRx action', - description: - 'Dispatch an action (`type`, `payload`) or redispatch a logged one (`seq`) to the @ngrx/store Store (dev only); returns the log entry with origin and diff. Needs `page` when several tabs have a Store, and actions.ngrx.', - inputSchema: { - type: 'object', - properties: { - page: pageProperty, - type: { type: 'string', description: 'Action type, for example "[Cart] Add Item".' }, - payload: { - type: 'object', - description: 'Action props as a JSON object, for example {"id": 7}. No "type" key.', - }, - seq: { - type: 'integer', - description: 'Log entry number of an action to dispatch again, instead of type.', - }, - }, - }, - handler: async (args: { - page?: string; - type?: unknown; - payload?: unknown; - seq?: unknown; - }) => { - const pages = ngrxStateOf(ngrxPages).pages; - const page = pageArgument(args); - if (page && !ngrxPages.has(page)) { - return { markdown: unknownPageText(page, pages, 'NgRx state') }; - } - const stores = pages.filter((p) => p.classic); - // A dispatch changes the app, so it never picks one of several tabs on its own. - if (!page && stores.length > 1) { - return { markdown: choosePageText(withPageUrls(stores), 'an @ngrx/store Store') }; - } - const target = page ? ngrxPages.get(page) : defaultPage(stores); - if (!target?.classic) { - return { - markdown: refusal( - page - ? `Page \`${page}\` has no @ngrx/store Store, so there is nothing to dispatch to.` - : 'No connected page has an @ngrx/store Store. Open the app in a browser; the Store shows up once provideStore() or StoreModule.forRoot() runs.', - ), - }; - } - let request: NgrxRequest; - if (args.seq !== undefined) { - if (!Number.isInteger(args.seq)) - return { markdown: refusal('seq must be a log entry number.') }; - request = { type: 'dispatch-again', seq: args.seq as number }; - } else { - const problem = dispatchProblem(args.type, args.payload); - if (problem) return { markdown: refusal(problem) }; - request = { - type: 'dispatch', - action: args.type as string, - ...(args.payload !== undefined - ? { payload: args.payload as Record } - : {}), - }; - } - return { markdown: dispatchResultText(await requestNgrxAction(target.pageId, request)) }; - }, + previewScans: router.previewScans, + routerHttpCalls: router.routerHttpCalls, }); - for (const tool of ngrxHistory.tools) agent.registerTool(tool); - // Store DevTools history operations and the explain-change read (rpc/ngrx-devtools-tools.ts). - for (const tool of createNgrxDevtoolsTools({ - ask: (page, request) => requestNgrxAction(page, request), - pages: () => ngrxPages, - })) { - agent.registerTool(tool); - } + registerNgrxDispatchTools({ agent, state: ngrx, rpc: ngrxRpc, withPageUrls }); ({ waitForForm } = registerFormsAgentTools({ agent, @@ -2923,63 +510,13 @@ const pangular = defineDevframe({ ctx, formPages, formsState, - pipeAcks, + pipeAcks: pipes.pipeAcks, pipePages, requestFormAction, - requestPipeRecording, - formPageUrl, + requestPipeRecording: pipes.requestPipeRecording, + formPageUrl: directory.formPageUrl, })); - // Every reporting page, merged across inspectors, newest first. - const livePages = () => { - const details = livePageDetails(); - const platforms = new Map(); - for (const page of componentPages.values()) { - if (page.platform) platforms.set(page.pageId, page.platform); - } - for (const page of capacitorPages.values()) { - if (page.info.bridge && !platforms.has(page.pageId)) { - platforms.set(page.pageId, `capacitor-${page.info.platform}`); - } - } - const withUrl = < - T extends { pageId: string; reportedAt: number; url?: string; title?: string }, - >( - pages: Iterable, - ) => - [...pages].map((page) => ({ - pageId: page.pageId, - // An idle page sends only its heartbeat, which counts as a report here. - reportedAt: Math.max(page.reportedAt, heartbeats.seenAt(page.pageId) ?? 0), - // A page only one inspector reports, such as Ionic, still names its own URL and title. - ...(page.url !== undefined ? { url: page.url } : {}), - ...(page.title !== undefined ? { title: page.title } : {}), - ...details.get(page.pageId), - platform: platforms.get(page.pageId), - angular: angularPages.get(page.pageId)?.version, - })); - return summarizePages( - { - components: withUrl(componentPages.values()), - signals: withUrl([...signalPages].map(([pageId, entry]) => ({ pageId, ...entry }))), - injectors: withUrl(injectorPages.values()), - ngrx: withUrl([...ngrxPages.values(), ...(reduxStores?.pages() ?? [])]), - tanstackQuery: withUrl(tanstackQuery?.pages() ?? []), - forms: withUrl(formPages.values()), - router: withUrl(routerPages.values()), - pipes: withUrl(pipePages.values()), - http: withUrl(httpPages.values()), - capacitor: withUrl(capacitorPages.values()), - errors: withUrl(errorsInspector?.pages() ?? []), - startup: withUrl(startupInspector?.pages() ?? []), - ionic: withUrl(ionicInspector?.pages() ?? []), - plugins: withUrl(pluginsInspector?.pages() ?? []), - analog: withUrl(on.analog ? analogPages() : []), - }, - visibility.list(), - ); - }; - // Replaces devframe's built-in shared-state reader with a capped one (rpc/state-read-tool.ts). const stateHost = ctx.rpc.sharedState as unknown as { keys(): string[]; @@ -3017,8 +554,8 @@ const pangular = defineDevframe({ forms: () => formsState.value() as FormsState, signals: () => [...signalPages].map(([pageId, entry]) => ({ pageId, ...entry })), ngrx: () => ngrxPages, - reduxStores: () => reduxStores?.state(), - queries: () => tanstackQuery?.state(), + reduxStores: () => inspectors.reduxStores?.state(), + queries: () => inspectors.tanstackQuery?.state(), }, }); @@ -3105,7 +642,7 @@ const pangular = defineDevframe({ sources: { ...(on.components ? { - cd: () => cdState.value() as CdState, + cd: () => cd.cdState.value() as CdState, components: (pageId: string) => componentPages.get(pageId) ?? null, } : {}), @@ -3119,7 +656,7 @@ const pangular = defineDevframe({ : {}), }, }); - onPageClaimed = audit.pageLoaded; + pages.onPageClaimed(audit.pageLoaded); if (on.analog) { await registerAnalog(my as never, { cwd: ctx.cwd, agent } as never, {