diff --git a/.claude/skills/devtools-commit/SKILL.md b/.claude/skills/devtools-commit/SKILL.md
index 1573e4e3..0298d361 100644
--- a/.claude/skills/devtools-commit/SKILL.md
+++ b/.claude/skills/devtools-commit/SKILL.md
@@ -22,7 +22,7 @@ The rules are in `docs/contributing/commit-message-guidelines.md`. Summary:
## Pull requests
- Pull requests are squash merged; the title becomes the commit on `main`, so it must follow the header format.
-- A `commit-msg` hook (`scripts/commit-message.mjs`, enabled by `pnpm install`) warns about a bad message as you commit; CI checks the title and every commit in the pull request and, for now, reports problems as warnings. Run `pnpm commit:check` before pushing, and fix flagged messages with `git commit --amend` or a reword rebase.
+- A `commit-msg` hook (`scripts/commit-message.mjs`, enabled by `pnpm install`) warns about a bad message as you commit; CI checks the title and every commit in the pull request: a bad title fails the check, and problems in the commits show as warnings. Run `pnpm commit:check` before pushing, and fix flagged messages with `git commit --amend` or a reword rebase.
- Address review feedback with fixup commits (`docs/contributing/using-fixup-commits.md`).
- One feature per pull request, with its tests (including agent tool tests when tools change).
- Rebuild and commit `extension/ui` when `app/` changed.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ccc7d789..cfad349f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -64,6 +64,23 @@ jobs:
- name: Smoke-test the CLI
run: node bin.mjs --help
+ node-22:
+ name: Package tests on Node.js 22
+ # `engines.node` allows 22, while the build job runs the `.nvmrc` version.
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup
+ with:
+ node-version: 22
+
+ - name: Test devtools package
+ run: pnpm test:devtools
+
axe:
runs-on: ubuntu-latest
timeout-minutes: 15
diff --git a/.github/workflows/commit-message.yml b/.github/workflows/commit-message.yml
index 9d0be3b5..1a455f3a 100644
--- a/.github/workflows/commit-message.yml
+++ b/.github/workflows/commit-message.yml
@@ -28,7 +28,7 @@ jobs:
- name: Pull request title
env:
TITLE: ${{ github.event.pull_request.title }}
- run: node scripts/commit-message.mjs --title "$TITLE" --warn
+ run: node scripts/commit-message.mjs --title "$TITLE"
- name: Commit messages
env:
diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml
index 40918a26..8aebcb0d 100644
--- a/.github/workflows/compat.yml
+++ b/.github/workflows/compat.yml
@@ -19,6 +19,8 @@ jobs:
name: Angular ${{ matrix.angular }} on Node.js 22
runs-on: ubuntu-latest
timeout-minutes: 15
+ # The prerelease entry reports a private API rename early without failing the run.
+ continue-on-error: ${{ matrix.prerelease == true }}
strategy:
fail-fast: false
matrix:
@@ -29,6 +31,9 @@ jobs:
range: '^21.0.0'
- angular: '22'
range: '^22.0.0'
+ - angular: 'next'
+ range: 'next'
+ prerelease: true
steps:
- uses: actions/checkout@v7
with:
diff --git a/README.md b/README.md
index 52e9864e..0a88144b 100644
--- a/README.md
+++ b/README.md
@@ -15,9 +15,11 @@ The unified Angular devtools. Inspect Angular component trees, signals, dependen
The package is published on npm as [`@pangular-inspector/devtools`](https://www.npmjs.com/package/@pangular-inspector/devtools).
```sh
-npm install @pangular-inspector/devtools devframe
+npm install -D @pangular-inspector/devtools
```
+An Angular SSR server imports the hub when it starts, so install the package as a dependency there (`npm install @pangular-inspector/devtools`).
+
Then follow the [installation guide](./apps/docs/src/content/getting-started/installation.md) for your setup: Angular CLI with Express, Vite and Analog, the standalone CLI, [Angular Native](./apps/docs/src/content/guides/angular-native.md), or [Capacitor](./apps/docs/src/content/guides/capacitor.md). For a coding agent, run `npx @pangular-inspector/devtools mcp`.
## Documentation
diff --git a/apps/docs/src/app/pages/index.page.ts b/apps/docs/src/app/pages/index.page.ts
index 43d73842..5ab95a87 100644
--- a/apps/docs/src/app/pages/index.page.ts
+++ b/apps/docs/src/app/pages/index.page.ts
@@ -865,22 +865,22 @@ claude mcp add pangular -- \\
readonly installCommands = [
{
pm: 'npm',
- cmd: 'npm install @pangular-inspector/devtools devframe',
+ cmd: 'npm install @pangular-inspector/devtools',
logo: 'https://cdn.simpleicons.org/npm/CB3837',
},
{
pm: 'pnpm',
- cmd: 'pnpm add @pangular-inspector/devtools devframe',
+ cmd: 'pnpm add @pangular-inspector/devtools',
logo: 'https://cdn.simpleicons.org/pnpm/F69220',
},
{
pm: 'yarn',
- cmd: 'yarn add @pangular-inspector/devtools devframe',
+ cmd: 'yarn add @pangular-inspector/devtools',
logo: 'https://cdn.simpleicons.org/yarn/2C8EBB',
},
{
pm: 'bun',
- cmd: 'bun add @pangular-inspector/devtools devframe',
+ cmd: 'bun add @pangular-inspector/devtools',
logo: 'https://bun.sh/logo.svg',
},
];
diff --git a/apps/docs/src/content/agents/resources.md b/apps/docs/src/content/agents/resources.md
index 71e4fdda..e5394be3 100644
--- a/apps/docs/src/content/agents/resources.md
+++ b/apps/docs/src/content/agents/resources.md
@@ -29,7 +29,13 @@ Clients see each resource at a `devframe://resource/` URI with the id encoded. F
devframe://resource/pangular%3Acomponent-tree
```
-Each one returns JSON.
+Each one returns compact JSON.
+
+### Page data and size
+
+The `component-tree`, `signal-graph`, `injector-tree`, `ngrx-store`, `forms` and `router` resources start with an `_untrusted` field. The names, values and messages in them come from the running page. Treat them as data, not instructions.
+
+When one of the first four runs past 100,000 characters, it returns a summary instead: `truncated: true`, up to 50 connected page ids in `pages`, and a `note` that names the tools to read the data with.
## Available resources
diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md
index 345fc244..3f0fc8b7 100644
--- a/apps/docs/src/content/agents/tools.md
+++ b/apps/docs/src/content/agents/tools.md
@@ -451,7 +451,7 @@ These tools cover *Analog apps. Most read your source. Some also read what the V
### Call a server route Action
-`analog-call-api` sends a request to a route on the running dev server, like `GET /api/v1/hello`, and returns the status, time and body. Secret keys, tokens and `Bearer` values in the body are masked. Reads: Vite plugin.
+`analog-call-api` sends a request to a route on the running dev server, like `GET /api/v1/hello`, and returns the status, time and body. `path` is a path on the dev server that starts with a single `/`, not a full URL. `POST`, `PUT`, `PATCH` and `DELETE` need `confirm: true`. Secret keys, tokens and `Bearer` values in the body are masked. Reads: Vite plugin.
| Argument | Required | Value |
| --------- | -------- | ------------------------------------------------------------- |
diff --git a/apps/docs/src/content/contributing/development.md b/apps/docs/src/content/contributing/development.md
index 0a0b4bec..47d26c3a 100644
--- a/apps/docs/src/content/contributing/development.md
+++ b/apps/docs/src/content/contributing/development.md
@@ -174,29 +174,32 @@ pnpm commit:check # Commit messages on your branch
-A separate `axe` job in the same workflow installs Chromium and runs `pnpm test:axe`.
+A separate `axe` job in the same workflow installs Chromium and runs `pnpm test:axe`. A `node-22` job runs `pnpm test:devtools` on Node.js 22, the oldest version `engines.node` allows.
### Angular compatibility
-`.github/workflows/compat.yml` runs the page-side tests on the oldest and newest Angular versions the peer range allows. It runs on pushes to `main`, every Monday at 06:00 UTC, and on demand from the **Actions** tab. It doesn't run on pull requests.
+`.github/workflows/compat.yml` runs the page-side tests on the oldest and newest Angular versions the peer range allows, and on the `next` prerelease. It runs on pushes to `main`, every Monday at 06:00 UTC, and on demand from the **Actions** tab. It doesn't run on pull requests.
| Job | Angular range | Node.js |
| ------------ | ------------- | ------- |
| Angular 20.0 | `~20.0.0` | 22 |
| Angular 21 | `^21.0.0` | 22 |
| Angular 22 | `^22.0.0` | 22 |
+| Angular next | `next` | 22 |
-Each job appends `overrides` for the `@angular/*` runtime packages to `pnpm-workspace.yaml`, reinstalls, and runs `vitest` with `packages/devtools/vitest.compat.config.ts`. That config picks the tests that render a real Angular app and leaves out the panel and server tests. It adds the Signal Forms tests on Angular 22 and later, and loads `zone.js` before Angular 21, where `TestBed` still expects it. A test that needs a feature from a later release checks the version with `angularAtLeast()` from `src/__tests__/angular-version.ts` and skips on older ones.
+The `next` job installs the `next` dist-tag, so a renamed `ɵ` API shows up before the next major ships. It runs with `continue-on-error`: a failure marks the job red but not the workflow.
+
+Each job appends `overrides` for the `@angular/*` runtime packages to `pnpm-workspace.yaml`, reinstalls, and runs `vitest` with `packages/devtools/vitest.compat.config.ts`. That config picks the tests that render a real Angular app or read Angular's private debug APIs, including the `*-real` tests that boot a real app where the main tests use fakes, and leaves out the panel tests and most server tests. It adds the Signal Forms tests on Angular 22 and later, and loads `zone.js` before Angular 21, where `TestBed` still expects it. A test that needs a feature from a later release checks the version with `angularAtLeast()` from `src/__tests__/angular-version.ts` and skips on older ones.
To reproduce a job, add the same `overrides` block to `pnpm-workspace.yaml`, run `pnpm install --no-frozen-lockfile`, then `pnpm exec vitest run --root packages/devtools --config vitest.compat.config.ts`. Don't commit the changed lockfile.
### Pull request checks
-Two more workflows run on pull requests. Both only warn. They never fail the pull request.
+Two more workflows run on pull requests. Only a pull request title that breaks the commit format fails one; everything else they find is a warning.
| Workflow | File | What it checks |
| ---------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `Commit message` | `.github/workflows/commit-message.yml` | The pull request title and every commit message. The title becomes the commit on `main` when the pull request is squash merged. |
+| `Commit message` | `.github/workflows/commit-message.yml` | The pull request title and every commit message. The title becomes the commit on `main` when the pull request is squash merged, so a bad title fails the check. |
| `Docs check` | `.github/workflows/docs-check.yml` | That a change to `packages/devtools/src/`, `app/src/` or the top-level files in `extension/` also changes a page in `apps/docs/src/content`. Tests don't count. |
If a code change needs no docs change, add the `no-docs` label to the pull request and say why in the description. The `Docs check` workflow then skips the warning.
diff --git a/apps/docs/src/content/contributing/kitchen-sink.md b/apps/docs/src/content/contributing/kitchen-sink.md
index 21ef7078..bf90ad74 100644
--- a/apps/docs/src/content/contributing/kitchen-sink.md
+++ b/apps/docs/src/content/contributing/kitchen-sink.md
@@ -84,19 +84,19 @@ bootstrapApplication(App, appConfig).then(() => {
### Code group
```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active
-pnpm add @pangular-inspector/devtools devframe
+pnpm add @pangular-inspector/devtools
```
```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837"
-npm install @pangular-inspector/devtools devframe
+npm install @pangular-inspector/devtools
```
```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB"
-yarn add @pangular-inspector/devtools devframe
+yarn add @pangular-inspector/devtools
```
```bash group="install" name="bun" image="https://bun.sh/logo.svg"
-bun add @pangular-inspector/devtools devframe
+bun add @pangular-inspector/devtools
```
### File import
@@ -241,7 +241,7 @@ Text between alerts.
- Run pnpm add @pangular-inspector/devtools devframe.
+ Run pnpm add @pangular-inspector/devtools.
Add the hub to your server.
diff --git a/apps/docs/src/content/contributing/writing-docs.md b/apps/docs/src/content/contributing/writing-docs.md
index 8ae40f8a..26bf1942 100644
--- a/apps/docs/src/content/contributing/writing-docs.md
+++ b/apps/docs/src/content/contributing/writing-docs.md
@@ -178,11 +178,11 @@ For install commands, use a code group so readers pick their package manager. Li
````md
```bash group="install" name="pnpm" active
-pnpm add @pangular-inspector/devtools devframe
+pnpm add @pangular-inspector/devtools
```
```bash group="install" name="npm"
-npm install @pangular-inspector/devtools devframe
+npm install @pangular-inspector/devtools
```
````
diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md
index 12aa11b0..8b7d3b19 100644
--- a/apps/docs/src/content/getting-started/cli.md
+++ b/apps/docs/src/content/getting-started/cli.md
@@ -102,21 +102,22 @@ When the server is ready, it prints the version, the panel URL, the panel URL fo
MCP: http://localhost:9999/__mcp
```
-If you pass no `--port` and port 9999 is taken, it also prints the port it uses instead. It also lists the extra allowed origins and a short hash of each pairing token, never the token.
+With `--proxy-origin`, it also prints **Panel on your app**, the panel URL on your app's origin. If you pass no `--port` and port 9999 is taken, it also prints the port it uses instead. It also lists the extra allowed origins and a short hash of each pairing token, never the token.
When the server listens on your network (`--host 0.0.0.0` or a network address), it also prints the network URLs and an `initOverlay()` call to paste into an app on a device, with the server's whole connection info, its config included. See [Set up Capacitor](../guides/capacitor.md).
### Dev server flags
-| Flag | What it does |
-| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `--port ` | Port to listen on. The default is 9999. If it is taken, a random free port is used. |
-| `--host ` | Host to bind to. The default is `localhost`. |
-| `--open`, `--no-open` | Open the browser on start, or not. |
-| `--no-auth` | Turn off the one-time code the server asks for. |
-| `--mcp`, `--no-mcp` | Mount the MCP endpoint at `/__mcp`, or not. It is on by default. |
-| `--allowed-origin ` | An extra origin allowed to reach the server, such as `http://192.168.1.20:8100`. Repeat it for more. |
-| `--auth-token ` | A [pairing token](../security.md#pairing-tokens) a client may send instead of the one-time code. Repeat it for more. Prefer the `PANGULAR_AUTH_TOKEN` environment variable. |
+| Flag | What it does |
+| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `--port ` | Port to listen on. The default is 9999. If it is taken, a random free port is used. |
+| `--host ` | Host to bind to. The default is `localhost`. |
+| `--open`, `--no-open` | Open the browser on start, or not. |
+| `--no-auth` | Turn off the one-time code the server asks for. |
+| `--mcp`, `--no-mcp` | Mount the MCP endpoint at `/__mcp`, or not. It is on by default. |
+| `--allowed-origin ` | An extra origin allowed to reach the server, such as `http://192.168.1.20:8100`. Repeat it for more. |
+| `--proxy-origin ` | The origin of your app's dev server when it proxies `/__pangular/` to this server, such as `http://localhost:4200`. The panel URL on that origin and the one-time code link point there. See [Client-only Angular CLI app](./installation.md#client-only-angular-cli-app). |
+| `--auth-token ` | A [pairing token](../security.md#pairing-tokens) a client may send instead of the one-time code. Repeat it for more. Prefer the `PANGULAR_AUTH_TOKEN` environment variable. |
The server binds to localhost by default and asks for a one-time code. Changing --host or passing --no-auth widens who can reach it. See Access and redaction.
diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md
index be3629b5..c53e8366 100644
--- a/apps/docs/src/content/getting-started/configuration.md
+++ b/apps/docs/src/content/getting-started/configuration.md
@@ -69,11 +69,10 @@ export default defineConfig({
The default export of `@pangular-inspector/devtools/devframe` uses the defaults. Call `createPangular()` to pass options:
-```ts {7-9}
+```ts {6-8}
// src/server.ts
import express from 'express';
-import {initDevframe} from 'devframe/initiate';
-import {createPangular} from '@pangular-inspector/devtools/devframe';
+import {createPangular, initDevframe} from '@pangular-inspector/devtools/devframe';
const app = express();
const devtools = initDevframe(createPangular({agent: {readOnly: true}}), {
diff --git a/apps/docs/src/content/getting-started/express.md b/apps/docs/src/content/getting-started/express.md
index de999f8d..6554c9c6 100644
--- a/apps/docs/src/content/getting-started/express.md
+++ b/apps/docs/src/content/getting-started/express.md
@@ -19,7 +19,7 @@ In an *Angular app with server-side rendering, the devtools run inside your Expr
- Add @pangular-inspector/devtools and devframe. See Installation.
+ Add @pangular-inspector/devtools as a dependency, because server.ts imports it. See Installation.
Add initPangularHub() to server.ts, before your other routes.
@@ -232,6 +232,19 @@ export const appConfig: ApplicationConfig = {
Register `withPangular()` before your own interceptors, for example `provideHttpClient(withPangular(), withInterceptors([authInterceptor]))`. It then records requests as the app makes them, and fault rules apply before anything else.
+### Trace each server render
+
+To fill the **SSR requests** table, add the hub's `ssrMiddleware` after the hub middleware and before the Angular handler:
+
+```ts {4}
+// src/server.ts
+const devtools = initPangularHub({ws: false});
+app.use(devtools.nodeMiddleware);
+app.use(devtools.ssrMiddleware);
+```
+
+It is optional. It also adds a `Server-Timing` header to each traced response. If the server uses `compression()`, mount `ssrMiddleware` after it. See [SSR & HTTP](../inspectors/ssr-http.md).
+
### Run SSR in the same process
SSR and the devtools middleware must run in the same Express process. Otherwise the server-side calls never reach the tab.
@@ -240,12 +253,11 @@ The [SSR & HTTP guide](../guides/ssr-http.md) covers interceptor order and fault
## Mount only the panel
-To mount only the devtools panel without the dock, use `initDevframe()` from `devframe/initiate`:
+To mount only the devtools panel without the dock, use `initDevframe()`. The devframe entry point re-exports it from the Devframe copy the package depends on:
```ts
// src/server.ts
-import {initDevframe} from 'devframe/initiate';
-import pangular from '@pangular-inspector/devtools/devframe';
+import pangular, {initDevframe} from '@pangular-inspector/devtools/devframe';
const devtools = initDevframe(pangular, {base: '/__pangular/'});
app.use(devtools.nodeMiddleware);
diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md
index 685087d2..5dff1c1d 100644
--- a/apps/docs/src/content/getting-started/installation.md
+++ b/apps/docs/src/content/getting-started/installation.md
@@ -46,29 +46,38 @@ Some features need a later Angular release than 20.0. The inspector pages list t
## Install the package
```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active
-pnpm add @pangular-inspector/devtools devframe
+pnpm add -D @pangular-inspector/devtools
```
```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837"
-npm install @pangular-inspector/devtools devframe
+npm install -D @pangular-inspector/devtools
```
```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB"
-yarn add @pangular-inspector/devtools devframe
+yarn add -D @pangular-inspector/devtools
```
```bash group="install" name="bun" image="https://bun.sh/logo.svg"
-bun add @pangular-inspector/devtools devframe
+bun add -D @pangular-inspector/devtools
```
-MCP agent support (`@devframes/agentic`) is included. You don't install it separately.
+MCP agent support (`@devframes/agentic`) and Devframe itself are included. You don't install them separately.
+
+### Dependency or dev dependency
+
+| Setup | Install it as |
+| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Vite or Analog | A dev dependency. The Vite plugin only runs in the dev server. |
+| Client-only Angular CLI app | A dev dependency. The CLI runs next to `ng serve`, and the overlay import only runs in development builds. |
+| Standalone CLI | A dev dependency, or no install at all with `npx`. |
+| Angular SSR with Express or another server | A dependency, because `server.ts` imports the hub when the production server starts. To keep it a dev dependency, import the hub only in development, with a dynamic `import()` behind your own check. |
### Entry points
| Import | Use it for |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `@pangular-inspector/devtools/hub` | `initPangularHub()`, the server middleware for an Express app. |
-| `@pangular-inspector/devtools/vite` | The Vite plugin for Analog apps. |
+| `@pangular-inspector/devtools/vite` | The Vite plugin for a Vite-based Angular app, such as an Analog app. |
| `@pangular-inspector/devtools/overlay` | The browser script that collects live data from your page. |
| `@pangular-inspector/devtools/overlay-manual` | The same script, started only when you call `initOverlay`. See [Set up Capacitor](../guides/capacitor.md). |
| `@pangular-inspector/devtools/capacitor-hook` | Logs Capacitor plugin calls from app start. See [Capacitor](../inspectors/capacitor.md#web-calls). |
@@ -77,7 +86,7 @@ MCP agent support (`@devframes/agentic`) is included. You don't install it separ
| `@pangular-inspector/devtools/popup` | The floating button and panel on your page. |
| `@pangular-inspector/devtools/http` | The HTTP interceptor and hydration hooks for the SSR & HTTP tab. |
| `@pangular-inspector/devtools/config` | The `PangularConfig` type and its defaults. See [Configuration](./configuration.md). |
-| `@pangular-inspector/devtools/devframe` | The devframe definition, for custom hosts. |
+| `@pangular-inspector/devtools/devframe` | The devframe definition and `initDevframe()`, for custom hosts. |
### The CLI binary
@@ -172,7 +181,7 @@ An app created with `ng new --ssr=false` only runs `ng serve`. Run the standalon
- Run npx pangular dev --port 9999 from the root of your workspace. It serves the panel, the connection and the WebSocket at the root of port 9999.
+ Run npx pangular dev --port 9999 --proxy-origin http://localhost:4200 from the root of your workspace. It serves the panel, the connection and the WebSocket at the root of port 9999. --proxy-origin names the origin of ng serve, so the CLI prints its links for http://localhost:4200/__pangular/.
Save the proxy.conf.json above next to angular.json. It strips the /__pangular prefix, and "ws": true forwards the WebSocket the overlay and the panel connect over.
@@ -184,7 +193,7 @@ An app created with `ng new --ssr=false` only runs `ng serve`. Run the standalon
Import the overlay in main.ts, as in the Angular CLI tab above.
- When your app connects, the CLI prints a code and a link like http://localhost:9999/#devframe_otp=123456. That link only trusts port 9999. Open the same code on your app's origin instead, http://localhost:4200/__pangular/#devframe_otp=123456. If the panel was already open, reload your app.
+ When your app connects, the CLI prints a code and a link like http://localhost:4200/__pangular/#devframe_otp=123456. Open the link, then reload your app if the panel was already open. Without --proxy-origin, the link points at port 9999, which only trusts port 9999. Then open the same code on your app's origin instead.
@@ -220,7 +229,7 @@ The [Chrome extension](./chrome-extension.md) adds a panel to Chrome DevTools. I
- Run ng serve for an Angular CLI app, or the Vite dev server for an Analog app.
+ Run ng serve for an Angular CLI app, or the Vite dev server for an Analog app. For a client-only app, keep pangular dev running next to ng serve.
An amber button appears in the bottom-right corner of the page. The overlay adds it.
@@ -229,15 +238,15 @@ The [Chrome extension](./chrome-extension.md) adds a panel to Chrome DevTools. I
Click the button. The header shows Live once the panel is connected.
- Go to /__devframes/ on the same server to see the devtools on their own page.
+ Go to /__devframes/ on the same server to see the devtools on their own page. For a client-only app, the full-page viewer is at /__pangular/ on your app's origin.
## FAQ
-
- The devtools are built on Devframe. Some setups import from devframe directly, for example initDevframe from devframe/initiate to mount only the panel. Package managers like pnpm only resolve imports of direct dependencies.
+
+ No. The package depends on Devframe and re-exports initDevframe from @pangular-inspector/devtools/devframe, for example to mount only the panel. Import it from there, so the panel and the devframe definition come from the same Devframe copy.
Wherever your server part runs. An Express app imports the hub in server.ts, so the package must be installed where that server starts. The overlay import in main.ts only runs in development builds.
diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md
index 0d37fc22..2314e599 100644
--- a/apps/docs/src/content/getting-started/vite.md
+++ b/apps/docs/src/content/getting-started/vite.md
@@ -1,6 +1,6 @@
---
title: Vite and Analog
-description: Add the devtools Vite plugin to an Analog app.
+description: Add the devtools Vite plugin to an Analog app or another Vite-based Angular app.
---
@@ -9,13 +9,13 @@ description: Add the devtools Vite plugin to an Analog app.
# Vite and Analog
-For *Analog apps, add the *Vite plugin next to `analog()` and load the overlay in `main.ts`. The plugin mounts the devtools hub on the Vite dev server.
+For *Analog apps, add the *Vite plugin next to `analog()` and load the overlay in `main.ts`. The plugin mounts the devtools hub on the Vite dev server. It works the same in any Angular app built with its own `vite.config.ts`, not only in Analog. If you serve your app with `ng serve`, this plugin does not apply, because the Angular CLI dev server does not accept Vite plugins. See [Angular CLI apps](#angular-cli-apps).
## Setup at a glance
- Add @pangular-inspector/devtools and devframe. See Installation.
+ Add @pangular-inspector/devtools as a dev dependency. See Installation.
Register pangular() after analog() in vite.config.ts.
@@ -68,6 +68,8 @@ bootstrapApplication(App, appConfig).then(() => {
| Full-page viewer | `/__devframes/` on the Vite dev server |
| HTTP MCP endpoint | `/__devframes/__mcp` |
+When the dev server starts, Vite prints the full-page viewer URL under its own URLs, on a line labeled `Pangular Inspector`.
+
## What the plugin does
### Dev server only
diff --git a/apps/docs/src/content/guides/analog.md b/apps/docs/src/content/guides/analog.md
index b7a4f360..d52a5a2f 100644
--- a/apps/docs/src/content/guides/analog.md
+++ b/apps/docs/src/content/guides/analog.md
@@ -32,7 +32,7 @@ This guide adds the devtools to an *Analog app. Everything runs on the *Vite dev
- Add @pangular-inspector/devtools and devframe.
+ Add @pangular-inspector/devtools as a dev dependency.
Register it next to analog() in vite.config.ts.
@@ -48,19 +48,19 @@ This guide adds the devtools to an *Analog app. Everything runs on the *Vite dev
## Step 1: Install
```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220" active
-pnpm add @pangular-inspector/devtools devframe
+pnpm add -D @pangular-inspector/devtools
```
```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837"
-npm install @pangular-inspector/devtools devframe
+npm install -D @pangular-inspector/devtools
```
```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB"
-yarn add @pangular-inspector/devtools devframe
+yarn add -D @pangular-inspector/devtools
```
```bash group="install" name="bun" image="https://bun.sh/logo.svg"
-bun add @pangular-inspector/devtools devframe
+bun add -D @pangular-inspector/devtools
```
## Step 2: Add the Vite plugin
diff --git a/apps/docs/src/content/guides/angular-native.md b/apps/docs/src/content/guides/angular-native.md
index bc66144d..b1fc2a8c 100644
--- a/apps/docs/src/content/guides/angular-native.md
+++ b/apps/docs/src/content/guides/angular-native.md
@@ -32,7 +32,7 @@ This guide adds the devtools to an [Angular Native](https://ng-native.com) app.
- Add @pangular-inspector/devtools and devframe. A bare React Native app also needs a URL polyfill.
+ Add @pangular-inspector/devtools. A bare React Native app also needs a URL polyfill.
Call initAngularNativeOverlay() in src/main.ts, after mount(), with the root node it returns.
@@ -48,19 +48,19 @@ This guide adds the devtools to an [Angular Native](https://ng-native.com) app.
## Step 1: Install
```bash group="install" name="npm" image="https://cdn.simpleicons.org/npm/CB3837" active
-npm install @pangular-inspector/devtools devframe
+npm install @pangular-inspector/devtools
```
```bash group="install" name="pnpm" image="https://cdn.simpleicons.org/pnpm/F69220"
-pnpm add @pangular-inspector/devtools devframe
+pnpm add @pangular-inspector/devtools
```
```bash group="install" name="yarn" image="https://cdn.simpleicons.org/yarn/2C8EBB"
-yarn add @pangular-inspector/devtools devframe
+yarn add @pangular-inspector/devtools
```
```bash group="install" name="bun" image="https://bun.sh/logo.svg"
-bun add @pangular-inspector/devtools devframe
+bun add @pangular-inspector/devtools
```
### WebSocket and URL
diff --git a/apps/docs/src/content/inspectors/pipes.md b/apps/docs/src/content/inspectors/pipes.md
index af0b2b2b..a16c9457 100644
--- a/apps/docs/src/content/inspectors/pipes.md
+++ b/apps/docs/src/content/inspectors/pipes.md
@@ -54,7 +54,7 @@ A source that changes on three reports in a row, like `getData() | async` return
| Rule | Severity | Finds |
| -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `impure-pipe-in-for` | warning | An impure pipe inside an `@for` block. It runs on every check, possibly once per row. `async` is skipped because it is cheap there, and `json` has its own rule. |
+| `impure-pipe-in-for` | warning | An impure pipe inside an `@for` block or an `*ngFor` element. It runs on every check, possibly once per row. `async` is skipped because it is cheap there, and `json` has its own rule. |
| `json-pipe-in-template` | info | `\| json` left in a template. It is a debugging aid. |
| `signal-read-in-pure-pipe` | warning, info | A pure pipe whose `transform()` reads a `signal`, `input`, `computed`, `linkedSignal`, `toSignal` or `model` field of the pipe (warning). A call with no arguments on an injected service, like `this.rates.current()`, might be a signal read (info). |
| `async-on-call` | info | A method call piped to `\| async`, like `getUsers() \| async`. If the method builds a new Observable each time, `AsyncPipe` resubscribes on every check. Signal, `input`, `computed` and `toSignal` fields of the component are skipped. |
diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md
index feda9efd..73fd9fe2 100644
--- a/apps/docs/src/content/security.md
+++ b/apps/docs/src/content/security.md
@@ -256,7 +256,7 @@ An app can put any text in a value, a URL, a label or an error message, includin
A tool answer that names a page the server does not know lists the pages that report, with their URLs. That answer starts with the first notice too.
-Answers that hold no page text carry no notice: `highlight` (instance ids, class names and tags), answers that only say there is no data yet, `lint-pipes` and the source scan tools such as `get-components` (they read your project files, not the page). The resources (`component-tree`, `signal-graph` and the others) return JSON with no notice, so treat what they hold as data from the page as well.
+Answers that hold no page text carry no notice: `highlight` (instance ids, class names and tags), answers that only say there is no data yet, `lint-pipes` and the source scan tools such as `get-components` (they read your project files, not the page). The `component-tree`, `signal-graph`, `injector-tree`, `ngrx-store`, `forms` and `router` resources carry the notice in an `_untrusted` field at the top of their JSON, also when they return a truncated summary.
## Checklist
diff --git a/docs/contributing/commit-message-guidelines.md b/docs/contributing/commit-message-guidelines.md
index f1a508be..47d6a394 100644
--- a/docs/contributing/commit-message-guidelines.md
+++ b/docs/contributing/commit-message-guidelines.md
@@ -5,7 +5,7 @@ Every commit follows one format, with scopes that match the areas of this reposi
Every commit is checked twice:
- **Locally**, by a `commit-msg` git hook that `pnpm install` turns on (it sets `core.hooksPath` to `.githooks` and `commit.template` to [`.gitmessage`](../../.gitmessage)). It warns and lets the commit through; fix the message with `git commit --amend` before you push.
-- **In CI**, on every pull request: each commit and the pull request title. Pull requests are squash merged, so **the title becomes the commit on `main`**. For now the CI check only warns: problems show up as annotations on the pull request, and the check stays green.
+- **In CI**, on every pull request: each commit and the pull request title. Pull requests are squash merged, so **the title becomes the commit on `main`**. A title that breaks the format fails the check. Problems in the individual commits only warn: they show up as annotations on the pull request, and the check stays green.
Run `pnpm commit:check` to check the commits on your branch before you push. To fix an older commit, reword it with `git rebase -i` and force-push your branch. While a review is in progress, prefer [fixup commits](./using-fixup-commits.md). `fixup!` and `squash!` commits and merge commits are skipped.
diff --git a/packages/devtools/src/__tests__/cli.test.ts b/packages/devtools/src/__tests__/cli.test.ts
index b4326c78..00a273b2 100644
--- a/packages/devtools/src/__tests__/cli.test.ts
+++ b/packages/devtools/src/__tests__/cli.test.ts
@@ -1,8 +1,17 @@
import { existsSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
+import { defineDevframe } from 'devframe';
import { createCac } from 'devframe/adapters/cac';
+import { getInstanceInternals, initDevframe } from 'devframe/initiate';
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { checkReportOutDir, createPangularCli, guardReportOutDir, startupLines } from '../cli.ts';
+import {
+ checkReportOutDir,
+ createPangularCli,
+ guardReportOutDir,
+ proxyAuth,
+ proxyPanelUrl,
+ startupLines,
+} from '../cli.ts';
import { fixtureDir } from '../rpc/__tests__/fixture-dir.ts';
function project() {
@@ -143,3 +152,65 @@ describe('pangular dev startup', () => {
]);
});
});
+
+describe('pangular dev --proxy-origin', () => {
+ it('points at /__pangular/ on the app origin unless a path is given', () => {
+ expect(proxyPanelUrl('http://localhost:4200')).toBe('http://localhost:4200/__pangular/');
+ expect(proxyPanelUrl('http://localhost:4200/')).toBe('http://localhost:4200/__pangular/');
+ expect(proxyPanelUrl('https://app.test:8443/tools')).toBe('https://app.test:8443/tools/');
+ expect(proxyPanelUrl('http://localhost:4200/devtools/?x=1#y')).toBe(
+ 'http://localhost:4200/devtools/',
+ );
+ });
+
+ it('refuses a value that is not an http or https URL', () => {
+ for (const value of ['localhost:4200', '4200', 'ftp://localhost:4200']) {
+ expect(() => proxyPanelUrl(value)).toThrow(/--proxy-origin needs an http or https URL/);
+ }
+ });
+
+ it('prints the panel URL on the app origin', () => {
+ const lines = startupLines('http://localhost:9999', 9999, {
+ mcp: false,
+ proxyUrl: 'http://localhost:4200/__pangular/',
+ });
+ expect(lines.slice(1, 3)).toEqual([
+ ' Panel: http://localhost:9999/',
+ ' Panel on your app: http://localhost:4200/__pangular/',
+ ]);
+ });
+
+ it('prints the one-time code link for the app origin', async () => {
+ const instance = initDevframe(
+ defineDevframe({
+ id: 'proxy-auth',
+ name: 'Proxy auth',
+ version: '0.0.0',
+ packageName: 'proxy-auth',
+ homepage: 'https://pangular-inspector.dev',
+ description: 'Proxy auth test',
+ setup() {},
+ }),
+ {
+ base: '/__pangular/',
+ ws: false,
+ auth: proxyAuth('http://localhost:4200/__pangular/') as unknown as boolean,
+ },
+ );
+ await instance.ready;
+ const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
+ const auth = getInstanceInternals(instance).authHandler as { printBanner?: () => void };
+ auth.printBanner?.();
+ expect(log.mock.calls.flat().join('\n')).toContain(
+ 'http://localhost:4200/__pangular/#devframe_otp=',
+ );
+ await instance.close();
+ });
+
+ it('describes --outDir in the --force help', () => {
+ const { cli } = createPangularCli({ env: {} });
+ const build = cli.commands.find((command) => command.name === 'build');
+ const force = build?.options.find((option) => option.name === 'force');
+ expect(force?.description).toContain('--outDir');
+ });
+});
diff --git a/packages/devtools/src/__tests__/defer-blocks-real.test.ts b/packages/devtools/src/__tests__/defer-blocks-real.test.ts
new file mode 100644
index 00000000..2a379548
--- /dev/null
+++ b/packages/devtools/src/__tests__/defer-blocks-real.test.ts
@@ -0,0 +1,70 @@
+// @vitest-environment jsdom
+import '@angular/compiler';
+import {
+ Component,
+ provideZonelessChangeDetection,
+ signal,
+ type ApplicationRef,
+} from '@angular/core';
+import { bootstrapApplication } from '@angular/platform-browser';
+import { afterEach, describe, expect, it } from 'vitest';
+import { createDeferTracker, type DeferDebugNg } from '../defer-blocks.ts';
+
+class TripReviews {
+ readonly ready = signal(false);
+}
+Component({
+ selector: 'app-root',
+ template: `
+ @defer (when ready()) {
+ Reviews
+ } @placeholder {
+
Reviews soon
+ } @error {
+
Could not load reviews
+ }
+ `,
+})(TripReviews);
+
+const settle = async (app: ApplicationRef) => {
+ for (let i = 0; i < 5; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ app.tick();
+ }
+};
+
+describe('createDeferTracker with a real app', () => {
+ let app: ApplicationRef | null = null;
+ afterEach(() => {
+ app?.destroy();
+ app = null;
+ document.body.innerHTML = '';
+ });
+
+ it('reads a real @defer block through the private defer block util', async () => {
+ document.body.innerHTML = '';
+ app = await bootstrapApplication(TripReviews, {
+ providers: [provideZonelessChangeDetection()],
+ });
+ await settle(app);
+
+ const ng = (globalThis as { ng?: DeferDebugNg }).ng!;
+ expect(typeof (ng.ɵgetControlFlowBlocks ?? ng.ɵgetDeferBlocks)).toBe('function');
+ const tracker = createDeferTracker();
+ const [block] = tracker.collect(ng) ?? [];
+ expect(block).toMatchObject({
+ state: 'placeholder',
+ hydration: 'not-configured',
+ hasErrorBlock: true,
+ placeholder: { minimumTime: null },
+ owner: { name: 'TripReviews', tag: 'app-root' },
+ });
+ expect(block.triggers.join(' ')).toContain('when');
+
+ (app.components[0].instance as TripReviews).ready.set(true);
+ await settle(app);
+ const [loaded] = tracker.collect(ng) ?? [];
+ expect(loaded).toMatchObject({ id: block.id, state: 'complete' });
+ expect(loaded.rootIds).toHaveLength(1);
+ });
+});
diff --git a/packages/devtools/src/__tests__/defer-blocks.test.ts b/packages/devtools/src/__tests__/defer-blocks.test.ts
index 72c9b5fe..714bee6d 100644
--- a/packages/devtools/src/__tests__/defer-blocks.test.ts
+++ b/packages/devtools/src/__tests__/defer-blocks.test.ts
@@ -124,6 +124,106 @@ describe('createDeferTracker', () => {
{ hydrateNever: true, hydration: 'not-configured' },
]);
});
+
+ it('keeps blocks without a hostNode, known by their position under the root', () => {
+ const { reviews, instances } = page();
+ const section = reviews.querySelector('section')!;
+ let clock = 1000;
+ let raw: unknown[] = [
+ { ...block({ rootNodes: [section] }), type: undefined },
+ { ...block({ state: 'complete' }), type: undefined },
+ ];
+ const ng: DeferDebugNg = {
+ getComponent: (el) => instances.get(el) ?? null,
+ ɵgetDeferBlocks: () => raw,
+ };
+ const tracker = createDeferTracker(() => clock);
+ const first = tracker.collect(ng)!;
+ expect(first).toHaveLength(2);
+ expect(first[0]).toMatchObject({ state: 'placeholder', owner: { name: 'TripReviews' } });
+ expect(first[1].owner).toBeUndefined();
+
+ clock = 2000;
+ raw = [
+ { ...block({ state: 'complete', rootNodes: [section] }), type: undefined },
+ { ...block({ state: 'complete' }), type: undefined },
+ ];
+ const second = tracker.collect(ng)!;
+ expect(second.map((b) => b.id)).toEqual(first.map((b) => b.id));
+ expect(second[0]).toMatchObject({ state: 'complete', since: 2000 });
+ expect(second[1].since).toBe(1000);
+ });
+
+ it('keeps a block without a hostNode on its id and timing when a block appears before it', () => {
+ const { reviews, anchor, instances } = page();
+ const section = reviews.querySelector('section')!;
+ let clock = 1000;
+ let raw: unknown[] = [{ ...block({ rootNodes: [section] }), type: undefined }];
+ const ng: DeferDebugNg = {
+ getComponent: (el) => instances.get(el) ?? null,
+ ɵgetDeferBlocks: () => raw,
+ };
+ const tracker = createDeferTracker(() => clock);
+ const [first] = tracker.collect(ng)!;
+
+ clock = 2000;
+ const added = document.createElement('p');
+ reviews.insertBefore(added, section);
+ reviews.insertBefore(document.createComment('container'), section);
+ raw = [
+ { ...block({ rootNodes: [added] }), type: undefined },
+ { ...block({ rootNodes: [section] }), type: undefined },
+ ];
+ const second = tracker.collect(ng)!;
+ expect(second[1]).toMatchObject({ id: first.id, since: 1000 });
+ expect(second[0].id).not.toBe(first.id);
+ expect(second[0].since).toBe(2000);
+
+ clock = 3000;
+ const content = document.createElement('div');
+ reviews.replaceChild(content, section);
+ raw = [
+ { ...block({ rootNodes: [added] }), type: undefined },
+ { ...block({ state: 'complete', rootNodes: [content] }), type: undefined },
+ ];
+ const third = tracker.collect(ng)!;
+ expect(content.nextSibling).toBe(anchor);
+ expect(third[1]).toMatchObject({ id: first.id, state: 'complete', since: 3000 });
+ expect(third[0].id).toBe(second[0].id);
+ });
+
+ it('keeps the id of a block without a hostNode or a placeholder once it renders', () => {
+ const { reviews, anchor, instances } = page();
+ const section = reviews.querySelector('section')!;
+ let clock = 1000;
+ const empty = { placeholderBlock: { exists: false, minimumTime: null } };
+ let raw: unknown[] = [
+ { ...block({ ...empty, state: 'initial' }), type: undefined },
+ { ...block({ ...empty, state: 'initial' }), type: undefined },
+ ];
+ const ng: DeferDebugNg = {
+ getComponent: (el) => instances.get(el) ?? null,
+ ɵgetDeferBlocks: () => raw,
+ };
+ const tracker = createDeferTracker(() => clock);
+ const first = tracker.collect(ng)!;
+
+ clock = 2000;
+ raw = [
+ { ...block({ ...empty, state: 'complete', rootNodes: [section] }), type: undefined },
+ { ...block({ ...empty, state: 'initial' }), type: undefined },
+ ];
+ const second = tracker.collect(ng)!;
+ expect(section.nextSibling).toBe(anchor);
+ expect(second.map((b) => b.id)).toEqual(first.map((b) => b.id));
+ expect(second[0]).toMatchObject({ state: 'complete', since: 2000 });
+ expect(second[1]).toMatchObject({ state: 'initial', since: 1000 });
+
+ clock = 3000;
+ const third = tracker.collect(ng)!;
+ expect(third.map((b) => b.id)).toEqual(first.map((b) => b.id));
+ expect(third[0].since).toBe(2000);
+ });
});
describe('deferBlocksText', () => {
diff --git a/packages/devtools/src/__tests__/devframe-entry.test.ts b/packages/devtools/src/__tests__/devframe-entry.test.ts
new file mode 100644
index 00000000..926d56a5
--- /dev/null
+++ b/packages/devtools/src/__tests__/devframe-entry.test.ts
@@ -0,0 +1,9 @@
+import { initDevframe as devframeInit } from 'devframe/initiate';
+import { describe, expect, it } from 'vitest';
+import { initDevframe } from '../devframe.ts';
+
+describe('@pangular-inspector/devtools/devframe', () => {
+ it('re-exports initDevframe from the devframe copy the package depends on', () => {
+ expect(initDevframe).toBe(devframeInit);
+ });
+});
diff --git a/packages/devtools/src/__tests__/forms-collector.test.ts b/packages/devtools/src/__tests__/forms-collector.test.ts
index ac6f1931..107456fb 100644
--- a/packages/devtools/src/__tests__/forms-collector.test.ts
+++ b/packages/devtools/src/__tests__/forms-collector.test.ts
@@ -407,6 +407,9 @@ describe('forms collector', () => {
]),
).toContain('Bearer [redacted]');
expect(setupErrorOf(['plain error'])).toBeNull();
+ expect(setupErrorOf(['NG1050: not a forms code'])).toBeNull();
+ expect(setupErrorOf(['XNG01050 inside a longer token'])).toBeNull();
+ expect(setupErrorOf(['NG010500 too many digits'])).toBeNull();
expect(
setupErrorOf([
new Error(
diff --git a/packages/devtools/src/__tests__/forms-tools.test.ts b/packages/devtools/src/__tests__/forms-tools.test.ts
index b09669c9..9eb7672c 100644
--- a/packages/devtools/src/__tests__/forms-tools.test.ts
+++ b/packages/devtools/src/__tests__/forms-tools.test.ts
@@ -1,5 +1,6 @@
import '@angular/compiler';
import { FormControl, FormGroup, Validators } from '@angular/forms';
+import { RESOURCE_UNTRUSTED } from '../rpc/resource-text.ts';
import { describe, expect, it } from 'vitest';
import { serializeControl, type CollectedForm, type FormFieldNode } from '../forms.ts';
import {
@@ -376,6 +377,11 @@ describe('value hiding and resource size', () => {
const text = formsResourceText({ forms: [big], events, reportedAt: 1 });
const parsed = JSON.parse(text);
expect(parsed.truncated).toBe(true);
+ expect(Object.keys(parsed)[0]).toBe('_untrusted');
+ expect(parsed._untrusted).toBe(RESOURCE_UNTRUSTED);
+ const small = JSON.parse(formsResourceText({ forms: [], events: [], reportedAt: 1 }));
+ expect(Object.keys(small)[0]).toBe('_untrusted');
+ expect(small).toMatchObject({ _untrusted: RESOURCE_UNTRUSTED, forms: [] });
expect(parsed.forms[0]).toMatchObject({ id: 'form-1', fields: 101 });
expect(parsed.events).toHaveLength(50);
expect(parsed.events[0].timestamp).toBe(10);
diff --git a/packages/devtools/src/__tests__/injector-tree-real.test.ts b/packages/devtools/src/__tests__/injector-tree-real.test.ts
new file mode 100644
index 00000000..30249a8f
--- /dev/null
+++ b/packages/devtools/src/__tests__/injector-tree-real.test.ts
@@ -0,0 +1,89 @@
+// @vitest-environment jsdom
+import '@angular/compiler';
+import {
+ Component,
+ Injectable,
+ InjectionToken,
+ inject,
+ provideZonelessChangeDetection,
+ type ApplicationRef,
+} from '@angular/core';
+import { bootstrapApplication } from '@angular/platform-browser';
+import { afterEach, describe, expect, it } from 'vitest';
+import { collectInjectorTree, type DebugNg } from '../injector-tree.ts';
+import type { InjectorTreeNode } from '../types.ts';
+
+const API_URL = new InjectionToken('API_URL');
+
+class TripStore {
+ readonly api = inject(API_URL);
+}
+Injectable({ providedIn: 'root' })(TripStore);
+
+class CardState {}
+Injectable()(CardState);
+
+class TripCard {
+ readonly state = inject(CardState);
+ readonly store = inject(TripStore);
+}
+Component({ selector: 'app-trip-card', template: 'card', providers: [CardState] })(TripCard);
+
+class TripPage {}
+Component({
+ selector: 'app-root',
+ imports: [TripCard],
+ template: '',
+})(TripPage);
+
+const flatten = (nodes: InjectorTreeNode[]): InjectorTreeNode[] =>
+ nodes.flatMap((node) => [node, ...flatten(node.children)]);
+
+describe('collectInjectorTree with a real app', () => {
+ let app: ApplicationRef | null = null;
+ afterEach(() => {
+ app?.destroy();
+ app = null;
+ document.body.innerHTML = '';
+ });
+
+ it('reads element and environment injectors through the private injector utils', async () => {
+ document.body.innerHTML = '';
+ app = await bootstrapApplication(TripPage, {
+ providers: [provideZonelessChangeDetection(), { provide: API_URL, useValue: '/api' }],
+ });
+ app.tick();
+
+ const ng = (globalThis as { ng?: DebugNg }).ng!;
+ expect(typeof ng.ɵgetInjectorProviders).toBe('function');
+ expect(typeof ng.ɵgetInjectorResolutionPath).toBe('function');
+ const report = collectInjectorTree(ng);
+
+ const elements = flatten(report.roots);
+ const card = elements.find((n) => n.injector.component === 'TripCard');
+ expect(card?.injector.type).toBe('element');
+ expect(card?.providers).toContainEqual(
+ expect.objectContaining({ token: 'CardState', type: 'class' }),
+ );
+ expect(card?.providers.map((p) => p.token)).not.toContain('ElementRef');
+ const page = elements.find((n) => n.injector.component === 'TripPage');
+ expect(page?.children).toContain(card);
+ expect(card?.injector.path?.slice(0, 2)).toEqual([card?.injector.id, page?.injector.id]);
+ expect(card?.dependencies).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ from: 'TripCard',
+ token: 'CardState',
+ providedBy: card?.injector.id,
+ }),
+ expect.objectContaining({ from: 'TripCard', token: 'TripStore' }),
+ ]),
+ );
+
+ const environments = flatten(report.environment);
+ expect(environments.length).toBeGreaterThan(0);
+ expect(environments.every((n) => n.injector.type === 'environment')).toBe(true);
+ const tokens = environments.flatMap((n) => n.providers.map((p) => p.token));
+ expect(tokens).toContain('API_URL');
+ });
+});
diff --git a/packages/devtools/src/__tests__/injector-tree.test.ts b/packages/devtools/src/__tests__/injector-tree.test.ts
index 8d2ef51e..3f9b2094 100644
--- a/packages/devtools/src/__tests__/injector-tree.test.ts
+++ b/packages/devtools/src/__tests__/injector-tree.test.ts
@@ -9,7 +9,14 @@ import {
import { zoneModeOf } from '../zone-mode.ts';
class ElementRef {
- static __NG_ELEMENT_ID__ = 1;
+ static __NG_ELEMENT_ID__ = () => null;
+}
+class Injector {
+ static __NG_ELEMENT_ID__ = -1;
+}
+/** Angular stamps a bloom id on every class an element provides. */
+class CardState {
+ static __NG_ELEMENT_ID__ = 0;
}
class Store {}
class Logger {}
@@ -63,6 +70,8 @@ function fakeNg() {
if (inj.el === card)
return [
{ token: ElementRef, provider: ElementRef },
+ { token: Injector, provider: Injector },
+ { token: CardState, provider: CardState },
{
token: Logger,
provider: { provide: Logger, useFactory: () => 0 },
@@ -98,8 +107,11 @@ describe('collectInjectorTree', () => {
it('drops built-in element tokens and names provider kinds', () => {
const { ng } = fakeNg();
const card = collectInjectorTree(ng).roots[0].children[0];
- expect(card.providers).toEqual([{ token: 'Logger', type: 'factory', isViewProvider: true }]);
- expect(card.injector.providerCount).toBe(1);
+ expect(card.providers).toEqual([
+ { token: 'CardState', type: 'class', isViewProvider: false },
+ { token: 'Logger', type: 'factory', isViewProvider: true },
+ ]);
+ expect(card.injector.providerCount).toBe(2);
});
it('builds the environment chain from the platform down', () => {
diff --git a/packages/devtools/src/__tests__/ngrx-mcp.test.ts b/packages/devtools/src/__tests__/ngrx-mcp.test.ts
index 7f004542..5f2ba794 100644
--- a/packages/devtools/src/__tests__/ngrx-mcp.test.ts
+++ b/packages/devtools/src/__tests__/ngrx-mcp.test.ts
@@ -33,6 +33,17 @@ const entry = (seq: number, type: string, origin: 'dispatch' | 'effect' | 'react
restorable: false,
});
+describe('live-state resource descriptions', () => {
+ it('describe the untrusted notice and the large-state fallback', async () => {
+ const { ctx } = await boot();
+ for (const id of ['component-tree', 'signal-graph', 'injector-tree', 'ngrx-store']) {
+ const description = ctx.agent.getResource(`pangular:${id}`)?.description ?? '';
+ expect(description, id).toContain('`_untrusted`');
+ expect(description, id).toContain('Past 100,000 characters');
+ }
+ });
+});
+
describe('ngrx-store resource', () => {
it('lists where each classic store action came from', async () => {
const { push, read, ctx } = await boot();
diff --git a/packages/devtools/src/__tests__/router-extras.test.ts b/packages/devtools/src/__tests__/router-extras.test.ts
index 6f657cb3..19a8a54c 100644
--- a/packages/devtools/src/__tests__/router-extras.test.ts
+++ b/packages/devtools/src/__tests__/router-extras.test.ts
@@ -97,6 +97,26 @@ describe('lint rules', () => {
expect(rules).toEqual(expect.arrayContaining(['email-in-url', 'redirect-from-query']));
});
+ it('masks the email address in the email-in-url finding', () => {
+ const navigations = [nav(1, { url: '/invite/ada@example.com?cc=bob@example.org' })];
+ const finding = lintRoutes(page({ config: [node('/x', { title: 'X' })], navigations })).find(
+ (f) => f.rule === 'email-in-url',
+ )!;
+ expect(finding.route).toBe('/invite/[email]?cc=[email]');
+ expect(JSON.stringify(finding)).not.toContain('example');
+ });
+
+ it('masks the email address in the redirect-from-query finding', () => {
+ const navigations = [
+ nav(1, { url: '/invite/ada@example.com', from: '/login?next=%2Finvite%2Fada%40example.com' }),
+ ];
+ const finding = lintRoutes(page({ config: [node('/x', { title: 'X' })], navigations })).find(
+ (f) => f.rule === 'redirect-from-query',
+ )!;
+ expect(finding.route).toBe('/invite/[email]');
+ expect(JSON.stringify(finding)).not.toContain('example');
+ });
+
it('only warns about chunks loaded before guards for loadChildren routes', () => {
const config = [
node('/admin', {
diff --git a/packages/devtools/src/__tests__/router-tools.test.ts b/packages/devtools/src/__tests__/router-tools.test.ts
index 49553fae..21bee182 100644
--- a/packages/devtools/src/__tests__/router-tools.test.ts
+++ b/packages/devtools/src/__tests__/router-tools.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
+import { RESOURCE_UNTRUSTED } from '../rpc/resource-text.ts';
import {
applyRouterEvent,
droppedNavigations,
@@ -467,12 +468,21 @@ describe('router tool text', () => {
expect(inspectRouteText(two, { page: 'zzzz' }, 1_000)).toContain('No page `zzzz`');
});
+ it('leads the resource with the untrusted notice', () => {
+ const parsed = JSON.parse(routerResourceText(state({ snapshot, navigations: [nav(1)] })));
+ expect(Object.keys(parsed)[0]).toBe('_untrusted');
+ expect(parsed._untrusted).toBe(RESOURCE_UNTRUSTED);
+ expect(parsed.pages[0].pageId).toBe('aaaa');
+ });
+
it('keeps the resource valid JSON when it is too large', () => {
const big = route('', { data: { blob: 'x'.repeat(120_000) } });
const parsed = JSON.parse(
routerResourceText(state({ snapshot: { ...snapshot, root: big }, navigations: [nav(1)] })),
);
expect(parsed.truncated).toBe(true);
+ expect(Object.keys(parsed)[0]).toBe('_untrusted');
+ expect(parsed._untrusted).toBe(RESOURCE_UNTRUSTED);
expect(parsed.pages[0]).toMatchObject({ pageId: 'aaaa', url: '/users/42?tab=posts' });
expect(parsed.pages[0].navigations).toHaveLength(1);
diff --git a/packages/devtools/src/__tests__/vite-server.test.ts b/packages/devtools/src/__tests__/vite-server.test.ts
index 30560b7c..35b1bd31 100644
--- a/packages/devtools/src/__tests__/vite-server.test.ts
+++ b/packages/devtools/src/__tests__/vite-server.test.ts
@@ -8,7 +8,7 @@ import { Duplex } from 'node:stream';
import { createServer, type InlineConfig, type ViteDevServer } from 'vite';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { httpRegistry } from '../http-rules.ts';
-import pangularVite from '../vite.ts';
+import pangularVite, { devtoolsUrl } from '../vite.ts';
const servers: ViteDevServer[] = [];
@@ -112,6 +112,25 @@ afterEach(async () => {
});
describe('pangularVite on a real Vite server', () => {
+ it('adds the devtools URL to the URLs Vite prints', async () => {
+ const info = vi.fn();
+ const server = await devServer({
+ logLevel: 'info',
+ customLogger: {
+ info,
+ warn: () => undefined,
+ warnOnce: () => undefined,
+ error: () => undefined,
+ clearScreen: () => undefined,
+ hasErrorLogged: () => false,
+ hasWarned: false,
+ },
+ });
+ server.printUrls();
+ const printed = info.mock.calls.map(([message]) => String(message)).join('\n');
+ expect(printed).toContain(`Pangular Inspector: ${origin(server).url}/__devframes/`);
+ });
+
it('shares the dev server socket and accepts loopback WebSocket upgrades', async () => {
const server = await devServer();
const meta = await connectionMeta(server);
@@ -186,3 +205,13 @@ describe('pangularVite on a real Vite server', () => {
expect(typeof httpRegistry().record).toBe('function');
});
});
+
+describe('devtoolsUrl', () => {
+ it('joins the local URL and the hub base', () => {
+ expect(devtoolsUrl('http://localhost:5173/', '/__devframes/')).toBe(
+ 'http://localhost:5173/__devframes/',
+ );
+ expect(devtoolsUrl('http://localhost:5173/app/', 'tools')).toBe('http://localhost:5173/tools/');
+ expect(devtoolsUrl(undefined, '/__devframes/')).toBeUndefined();
+ });
+});
diff --git a/packages/devtools/src/cli.ts b/packages/devtools/src/cli.ts
index 2e7eddbd..27e17ace 100644
--- a/packages/devtools/src/cli.ts
+++ b/packages/devtools/src/cli.ts
@@ -5,6 +5,7 @@ import type { CAC, Command } from 'cac';
import { defineDevframe } from 'devframe';
import { createCac } from 'devframe/adapters/cac';
import { createDevServer, resolveDevServerPort } from 'devframe/adapters/dev';
+import { createInteractiveAuth } from 'devframe/recipes/interactive-auth';
import {
accessLines,
cliAccess,
@@ -69,7 +70,7 @@ export function guardReportOutDir(cli: CAC, invokedFrom = process.cwd()) {
const build = cli.commands.find((command) => command.name === 'build');
const run = build?.commandAction;
if (!build || !run) return;
- build.option('--force', 'Empty --out-dir even when it is not a previous report');
+ build.option('--force', 'Empty --outDir even when it is not a previous report');
build.action(async (flags: { outDir: string; force?: boolean }) => {
try {
checkReportOutDir(flags.outDir, { force: flags.force, invokedFrom });
@@ -84,6 +85,7 @@ export function guardReportOutDir(cli: CAC, invokedFrom = process.cwd()) {
export interface PangularCliFlags extends CliAccessFlags {
root?: string;
+ proxyOrigin?: string;
config?: string;
readOnly?: boolean;
}
@@ -174,17 +176,45 @@ export function cliHostOptions(command: string, access: CliAccess) {
};
}
+/** The path the overlay and the popup look for on the app's own origin. */
+const PROXY_PATH = '/__pangular/';
+
+/**
+ * The panel URL behind the app's dev server proxy, from `--proxy-origin`: the
+ * origin with `/__pangular/` when no path is given, or the given path.
+ */
+export function proxyPanelUrl(value: string): string {
+ const url = URL.canParse(value) ? new URL(value) : undefined;
+ if (!url || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
+ throw new Error(
+ `[pangular] --proxy-origin needs an http or https URL, such as http://localhost:4200, not "${value}".`,
+ );
+ }
+ const path = url.pathname === '/' ? PROXY_PATH : url.pathname.replace(/\/?$/, '/');
+ return `${url.origin}${path}`;
+}
+
+/**
+ * devframe's one-time code gate, with the code link printed for the app's
+ * origin. The link it prints by default only trusts the CLI's own port.
+ */
+export function proxyAuth(panelUrl: string) {
+ return (ctx: Parameters[0]) =>
+ createInteractiveAuth(ctx, { serverUrl: () => panelUrl });
+}
+
/** The lines the dev server prints once it listens. */
export function startupLines(
origin: string,
port: number,
- options: { mcp: boolean; requestedPort?: number },
+ options: { mcp: boolean; requestedPort?: number; proxyUrl?: string },
): string[] {
const base = origin.replace(/\/$/, '');
const panel = `${base}/`;
const lines = [
` pangular v${pkg.version}`,
` Panel: ${panel}`,
+ ...(options.proxyUrl ? [` Panel on your app: ${options.proxyUrl}`] : []),
` Angular Native apps: ${panel}?view=angular-native`,
];
if (options.mcp) lines.push(` MCP: ${base}/__mcp`);
@@ -211,6 +241,7 @@ export function createPangularCli(options: PangularCliOptions = {}) {
let mcpOn = true;
let access: CliAccess = { allowedOrigins: [], authTokens: [] };
let listenHost = 'localhost';
+ let proxyUrl: string | undefined;
const definition = defineDevframe({
...pangular,
cli: { ...pangular.cli, command: 'pangular' },
@@ -267,6 +298,7 @@ export function createPangularCli(options: PangularCliOptions = {}) {
requestedPort = flags.port === undefined ? undefined : Number(flags.port);
mcpOn = flags.mcp !== false;
listenHost = flags.host ?? 'localhost';
+ proxyUrl = flags.proxyOrigin ? proxyPanelUrl(flags.proxyOrigin) : undefined;
// createCac's own dev action, plus the origins it does not forward.
const port =
requestedPort ??
@@ -277,9 +309,16 @@ export function createPangularCli(options: PangularCliOptions = {}) {
flags: flags as Record,
mcp: flags.mcp ?? true,
...(access.allowedOrigins.length ? { allowedOrigins: access.allowedOrigins } : {}),
+ // devframe builds the handler from a function too; its dev server
+ // types only list the handler object. `--no-auth` still turns it off.
+ ...(proxyUrl ? { auth: proxyAuth(proxyUrl) as unknown as boolean } : {}),
onReady,
});
};
+ defaultCommand.option(
+ '--proxy-origin ',
+ "Your app's dev server origin that proxies /__pangular/ to this server, such as http://localhost:4200, so the one-time code link opens there",
+ );
copyCommand(cli, defaultCommand, 'dev', 'Start a local dev server (the default)').action(run);
defaultCommand.description = 'Same as dev';
defaultCommand.action(async (args: string[], flags: PangularCliFlags) => {
@@ -304,7 +343,7 @@ export function createPangularCli(options: PangularCliOptions = {}) {
const onReady = async ({ origin, port }: { origin: string; port: number }) => {
log(
- `\n${[...startupLines(origin, port, { mcp: mcpOn, requestedPort }), ...accessLines(access)].join('\n')}\n`,
+ `\n${[...startupLines(origin, port, { mcp: mcpOn, requestedPort, proxyUrl }), ...accessLines(access)].join('\n')}\n`,
);
const addresses = networkAddresses(listenHost);
if (!addresses.length) return;
diff --git a/packages/devtools/src/defer-blocks.ts b/packages/devtools/src/defer-blocks.ts
index 215e6908..43368464 100644
--- a/packages/devtools/src/defer-blocks.ts
+++ b/packages/devtools/src/defer-blocks.ts
@@ -34,6 +34,21 @@ function read(fn: () => T, fallback: T): T {
}
}
+/** The container comment after a block's root nodes, or failing that its first root node. */
+function anchorOf(roots: unknown[]): Node | null {
+ const nodes = roots.filter((node): node is Node => node instanceof Node);
+ const after = nodes.at(-1)?.nextSibling;
+ return after instanceof Comment ? after : (nodes[0] ?? null);
+}
+
+function isDeferBlock(raw: unknown): raw is RawDeferBlock & { state: string } {
+ if (!raw || typeof raw !== 'object') return false;
+ const block = raw as RawDeferBlock;
+ return (
+ typeof block.state === 'string' && (typeof block.type !== 'number' || block.type === DEFER_TYPE)
+ );
+}
+
function timeOf(value: unknown): number | null {
return typeof value === 'number' ? value : null;
}
@@ -41,13 +56,45 @@ function timeOf(value: unknown): number | null {
/**
* Reads `@defer` blocks through `ng.ɵgetControlFlowBlocks` (Angular 22+) or
* `ng.ɵgetDeferBlocks` (earlier). Ids stay stable between collections, and
- * the tracker remembers when each block changed state or hydrated.
+ * the tracker remembers when each block changed state or hydrated. When
+ * Angular gives no `hostNode` (Angular 20), a block is known by the comment
+ * Angular places right after its root nodes, which stays put while the block
+ * moves from placeholder to content. Only a block with no root nodes falls
+ * back to its position among such blocks under its Angular root. When such a
+ * block renders and gains an anchor, the anchor takes over its id and timing
+ * as long as the block list under that root kept its length.
*/
export function createDeferTracker(now: () => number = Date.now) {
const ids = new WeakMap