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, {