diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md index c85c3f5f..a7c78d11 100644 --- a/apps/docs/src/content/getting-started/cli.md +++ b/apps/docs/src/content/getting-started/cli.md @@ -121,7 +121,7 @@ When no app is connected, the tabs show what your source declares. An [Angular N - [Pipes](../inspectors/pipes.md) - For live data, mount the devtools in your app's own server. See Angular CLI and Express or Vite and Analog. + For live data, mount the devtools in your app's own server. See Angular CLI and Express or Vite and Analog. A client-only Angular CLI app can proxy ng serve to this server instead. See Client-only Angular CLI app. ## Static report @@ -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 --root. - Without --port, the server picks a random free port and prints it. Pass --port to choose one yourself. + Without --port, the server listens on 9999, or on a random free port if 9999 is taken, and prints it. Pass --port to choose one yourself. diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index aa65e6ea..ff91cebb 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -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: @@ -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. + + + + 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. + + + 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. + + + Set proxyConfig in the serve options of angular.json, or run ng serve --proxy-config proxy.conf.json. + + + 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. + + + +```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 diff --git a/apps/docs/src/content/getting-started/introduction.md b/apps/docs/src/content/getting-started/introduction.md index 9ee772a7..9c298937 100644 --- a/apps/docs/src/content/getting-started/introduction.md +++ b/apps/docs/src/content/getting-started/introduction.md @@ -134,7 +134,7 @@ The devtools are a Chrome extension is optional. It adds the same UI as a panel in Chrome DevTools. - The devtools need a server part. An Angular CLI app mounts it in its Express server.ts. An Analog app gets it from the Vite plugin. Without either, the standalone CLI 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 server.ts, and an Analog app gets it from the Vite plugin. A client-only Angular CLI app runs the standalone CLI next to ng serve and proxies /__pangular/ to it. See Client-only Angular CLI app. Not if you follow the setup guides. They load the overlay with a dynamic import that only runs in development builds. diff --git a/packages/devtools/src/__tests__/proxy-setup.test.ts b/packages/devtools/src/__tests__/proxy-setup.test.ts new file mode 100644 index 00000000..276b0fc7 --- /dev/null +++ b/packages/devtools/src/__tests__/proxy-setup.test.ts @@ -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; + ws?: boolean; +} + +function documentedProxy(): Record { + 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; +} + +/** 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('', { 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(); + }); +});