Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/docs/src/content/getting-started/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ When no app is connected, the tabs show what your source declares. An [Angular N
- [Pipes](../inspectors/pipes.md)

<ngmd-alert severity="helpful">
For live data, mount the devtools in your app's own server. See <a href="./express.md">Angular CLI and Express</a> or <a href="./vite.md">Vite and Analog</a>.
For live data, mount the devtools in your app's own server. See <a href="./express.md">Angular CLI and Express</a> or <a href="./vite.md">Vite and Analog</a>. A client-only Angular CLI app can proxy <code>ng serve</code> to this server instead. See <a href="./installation.md#client-only-angular-cli-app">Client-only Angular CLI app</a>.
</ngmd-alert>

## Static report
Expand Down Expand Up @@ -179,7 +179,7 @@ The stdio server has no page connected, so it registers only the tools that read
The root of your Angular workspace. The scan starts from the current directory, or from the folder you pass with <code>--root</code>.
</ngmd-accordion-item>
<ngmd-accordion-item title="Port 9999 is taken">
Without <code>--port</code>, the server picks a random free port and prints it. Pass <code>--port</code> to choose one yourself.
Without <code>--port</code>, the server listens on 9999, or on a random free port if 9999 is taken, and prints it. Pass <code>--port</code> to choose one yourself.
</ngmd-accordion-item>
</ngmd-accordion>

Expand Down
57 changes: 56 additions & 1 deletion apps/docs/src/content/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ export default defineConfig({
npx @pangular-inspector/devtools
```

```json group="setup" name="Client-only Angular CLI" image="https://cdn.simpleicons.org/angular/DD0031"
// proxy.conf.json
{
"/__pangular": {
"target": "http://localhost:9999",
"pathRewrite": {"^/__pangular": ""},
"ws": true
}
}
```

A client-only app (no SSR) has no server of its own to mount the devtools in. See [Client-only Angular CLI app](#client-only-angular-cli-app).

### Browser part

Load the overlay after bootstrap, in development only. The check depends on your build tool:
Expand Down Expand Up @@ -134,7 +147,49 @@ bootstrapApplication(App, appConfig).then(() => {
});
```

The standalone CLI has no page connected, so it needs no browser part.
The standalone CLI on its own has no page connected, so it needs no browser part. Behind the `ng serve` proxy of a client-only app it does, like any other setup.

### Client-only Angular CLI app

An app created with `ng new --ssr=false` only runs `ng serve`. Run the standalone CLI next to it, and let `ng serve` forward `/__pangular/` to the CLI. The overlay and the popup look for the devtools at `/__pangular/` on the page's own origin, so they find the CLI there.

<ngmd-workflow>
<ngmd-step title="Start the devtools server">
Run <code>npx pangular dev --port 9999</code> from the root of your workspace. It serves the panel, the connection and the WebSocket at the root of port 9999.
</ngmd-step>
<ngmd-step title="Add the proxy config">
Save the <code>proxy.conf.json</code> above next to <code>angular.json</code>. It strips the <code>/__pangular</code> prefix, and <code>"ws": true</code> forwards the WebSocket the overlay and the panel connect over.
</ngmd-step>
<ngmd-step title="Point ng serve at it">
Set <code>proxyConfig</code> in the <code>serve</code> options of <code>angular.json</code>, or run <code>ng serve --proxy-config proxy.conf.json</code>.
</ngmd-step>
<ngmd-step title="Load the overlay">
Import the overlay in <code>main.ts</code>, as in the <strong>Angular CLI</strong> tab above.
</ngmd-step>
<ngmd-step title="Enter the one-time code">
When your app connects, the CLI prints a code and a link like <code>http://localhost:9999/#devframe_otp=123456</code>. That link only trusts port 9999. Open the same code on your app's origin instead, <code>http://localhost:4200/__pangular/#devframe_otp=123456</code>. If the panel was already open, reload your app.
</ngmd-step>
</ngmd-workflow>

```json
// angular.json (excerpt)
{
"projects": {
"my-app": {
"architect": {
"serve": {
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.conf.json"
}
}
}
}
}
}
```

The full-page panel is at `/__pangular/` on your app's origin.

### Configure the devtools

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/getting-started/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ The devtools are a <a href="https://devfra.me" target="_blank" rel="noopener nor
No. The overlay adds a floating button to your page and opens the devtools in a panel. The <a href="./chrome-extension.md">Chrome extension</a> is optional. It adds the same UI as a panel in Chrome DevTools.
</ngmd-accordion-item>
<ngmd-accordion-item title="Does it work without SSR?">
The devtools need a server part. An Angular CLI app mounts it in its Express <code>server.ts</code>. An Analog app gets it from the Vite plugin. Without either, the <a href="./cli.md">standalone CLI</a> serves the source scan.
Yes. The devtools need a server part, but it doesn't have to be your app's. An Angular CLI app with SSR mounts it in its Express <code>server.ts</code>, and an Analog app gets it from the Vite plugin. A client-only Angular CLI app runs the <a href="./cli.md">standalone CLI</a> next to <code>ng serve</code> and proxies <code>/__pangular/</code> to it. See <a href="./installation.md#client-only-angular-cli-app">Client-only Angular CLI app</a>.
</ngmd-accordion-item>
<ngmd-accordion-item title="Does it ship in my production bundle?">
Not if you follow the setup guides. They load the overlay with a dynamic import that only runs in development builds.
Expand Down
118 changes: 118 additions & 0 deletions packages/devtools/src/__tests__/proxy-setup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';

const docs = readFileSync(
join(import.meta.dirname, '../../../../apps/docs/src/content/getting-started/installation.md'),
'utf8',
);

interface ProxyEntry {
target: string;
pathRewrite?: Record<string, string>;
ws?: boolean;
}

function documentedProxy(): Record<string, ProxyEntry> {
const block = /```json[^\n]*\n\/\/ proxy\.conf\.json\n([\s\S]*?)```/.exec(docs);
if (!block) throw new Error('installation.md has no proxy.conf.json sample');
return JSON.parse(block[1]!) as Record<string, ProxyEntry>;
}

/** Where `ng serve` sends a request under the documented config, or `null` when it serves it itself. */
function forwarded(url: string, upgrade = false): string | null {
const { pathname, search } = new URL(url, location.href);
for (const [prefix, entry] of Object.entries(documentedProxy())) {
if (!pathname.startsWith(prefix)) continue;
if (upgrade && !entry.ws) return null;
let path = pathname;
for (const [pattern, value] of Object.entries(entry.pathRewrite ?? {})) {
path = path.replace(new RegExp(pattern), value);
}
return new URL(path + search, entry.target).href;
}
return null;
}

const DEV_SERVER = 'http://localhost:9999';

/** `pangular dev` serves the panel and its connection at its root. */
function reachesConnection(url: string): boolean {
const target = forwarded(url);
return target !== null && new URL(target).pathname === '/__connection.json';
}

function respond(url: string): Response {
return reachesConnection(url)
? new Response('{}', { headers: { 'content-type': 'application/json' } })
: new Response('<!doctype html>', { headers: { 'content-type': 'text/html' } });
}

const connectionOf = (base: string) =>
new URL('__connection.json', new URL(base, location.href)).href;

let tried: string[] = [];

vi.mock('devframe/client', () => ({
connectDevframe: async ({ baseURL }: { baseURL: string | string[] }) => {
tried = Array.isArray(baseURL) ? baseURL : [baseURL];
const base = tried.find((b) => reachesConnection(connectionOf(b)));
if (!base) throw new Error('no devtools server');
return {
connectionMeta: { backend: 'websocket', websocket: { path: '__ws' } },
connection: { metaBaseUrl: connectionOf(base) },
scope: () => ({ rpc: { call: async () => undefined, register: () => {} } }),
};
},
}));

afterEach(() => {
vi.unstubAllGlobals();
document.body.innerHTML = '';
sessionStorage.clear();
localStorage.clear();
});

describe('client-only Angular CLI proxy setup in the docs', () => {
it('forwards HTTP and WebSocket to the port the documented dev server listens on', () => {
const proxy = documentedProxy();
expect(Object.keys(proxy)).toEqual(['/__pangular']);
const entry = proxy['/__pangular']!;
expect(entry.target).toBe(DEV_SERVER);
expect(entry.ws).toBe(true);
expect(docs).toContain(`pangular dev --port ${new URL(DEV_SERVER).port}`);
});

it('lets the overlay find the dev server and open its WebSocket on the page origin', async () => {
vi.resetModules();
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => respond(url)),
);
const { initOverlay } = await import('../overlay.ts');
const stop = await initOverlay();
const base = tried.find((b) => reachesConnection(connectionOf(b)));
expect(base).toBe('/__pangular/');
const ws = new URL('__ws', new URL(base!, location.href)).href;
expect(forwarded(ws, true)).toBe(`${DEV_SERVER}/__ws`);
stop();
});

it('opens the panel the dev server serves at its root from the popup', async () => {
vi.resetModules();
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) => respond(url)),
);
const popup = await import('../popup.ts');
await popup.showDevtools();
const shadow = document.getElementById('pangular-popup-root')!.shadowRoot!;
(shadow.querySelector('.fab') as HTMLButtonElement).click();
const frame = shadow.querySelector('iframe') as HTMLIFrameElement;
await vi.waitFor(() => expect(frame.src).not.toBe(''));
expect(frame.src.startsWith(`${location.origin}/__pangular/?baseURL=`)).toBe(true);
expect(new URL(forwarded(frame.src)!).pathname).toBe('/');
await popup.hideDevtools();
});
});
Loading