From f1a2856b8ae70fbc9a1fcb122908f2ea6e08353d Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 18:53:51 +0000 Subject: [PATCH 01/13] feat: inject the dev server's LAN address for phone apps A Capacitor or NativeScript app on a phone has no localhost to find the devtools on, so its main.ts had to hard-code the machine's address. Add an opt-in lanAddress option to the Vite plugin that defines __PANGULAR_LAN_URL__ in the served app and in development builds, and a --lan-address flag to pangular dev that prints the URL and the define for the app's own build. lanDevtoolsURL() reads the constant, and the NativeScript overlay connects there when no baseURL is given. Both are off by default and widen nothing: the plugin injects an address only while allowLan is in effect (one-time code on, a network origin listed) and server.host is on the network, and the CLI flag only prints. Production builds never get an address. Refs #373 --- .../devtools/src/__tests__/cli-access.test.ts | 69 +++++ .../src/__tests__/lan-address.test.ts | 265 ++++++++++++++++++ .../overlay-nativescript-connection.test.ts | 15 + packages/devtools/src/cli.ts | 23 +- packages/devtools/src/config.ts | 36 +++ packages/devtools/src/lan-address.ts | 88 ++++++ packages/devtools/src/lan-url.ts | 25 ++ packages/devtools/src/overlay-nativescript.ts | 10 +- packages/devtools/src/overlay.ts | 1 + packages/devtools/src/vite-lan.ts | 127 +++++++++ packages/devtools/src/vite.ts | 61 ++-- 11 files changed, 685 insertions(+), 35 deletions(-) create mode 100644 packages/devtools/src/__tests__/lan-address.test.ts create mode 100644 packages/devtools/src/lan-address.ts create mode 100644 packages/devtools/src/lan-url.ts create mode 100644 packages/devtools/src/vite-lan.ts diff --git a/packages/devtools/src/__tests__/cli-access.test.ts b/packages/devtools/src/__tests__/cli-access.test.ts index d0ca8807..5298a59e 100644 --- a/packages/devtools/src/__tests__/cli-access.test.ts +++ b/packages/devtools/src/__tests__/cli-access.test.ts @@ -216,6 +216,75 @@ describe('pangular dev', () => { expect(String(log.mock.calls[1][0])).toContain('bootstrapApplication(App, appConfig)'); }); + async function lanDev(extra: string[]) { + const root = mkdtempSync(join(tmpdir(), 'pangular-cli-')); + dirs.push(root); + writeFileSync(join(root, 'angular.json'), '{}'); + const log = vi.fn(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const cwd = process.cwd(); + const { createPangularCli } = await import('../cli.ts'); + try { + await createPangularCli({ log, env: {} }).parse([ + 'node', + 'pangular', + 'dev', + '--root', + root, + '--port', + '9992', + '--no-open', + ...extra, + ]); + const onReady = devServer.options[0]['onReady'] as (info: { + origin: string; + port: number; + }) => Promise; + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: false })), + ); + await onReady({ origin: 'http://localhost:9992', port: 9992 }); + } finally { + vi.unstubAllGlobals(); + process.chdir(cwd); + } + const warnings = warn.mock.calls.map(([message]) => String(message)); + warn.mockRestore(); + return { + printed: log.mock.calls.map(([message]) => String(message)).join('\n'), + warnings, + options: devServer.options[0], + }; + } + + it('prints the LAN address and its define with --lan-address on a network host', async () => { + const { printed, options } = await lanDev(['--host', '192.168.1.20', '--lan-address']); + expect(printed).toContain('LAN address for a device build: http://192.168.1.20:9992/'); + expect(printed).toContain(`--define "__PANGULAR_LAN_URL__='http://192.168.1.20:9992/'"`); + // The flag only prints: the bind host and the origin check stay as they were. + expect(options).toMatchObject({ host: '192.168.1.20' }); + expect(options).not.toHaveProperty('allowedOrigins'); + }); + + it('prints nothing and says why with --lan-address on localhost', async () => { + const { printed, warnings, options } = await lanDev(['--lan-address']); + expect(printed).not.toContain('LAN address'); + expect(warnings.some((message) => message.includes('--lan-address prints nothing'))).toBe(true); + expect(options).toMatchObject({ host: 'localhost' }); + }); + + it('prints nothing without --lan-address', async () => { + const { printed } = await lanDev(['--host', '192.168.1.20']); + expect(printed).not.toContain('LAN address'); + }); + + it('refuses a loopback address for --lan-address', async () => { + const { printed, warnings } = await lanDev(['--host', '0.0.0.0', '--lan-address', '127.0.0.1']); + expect(printed).not.toContain('LAN address'); + expect(warnings.some((message) => message.includes('Ignoring --lan-address'))).toBe(true); + }); + it("keeps devframe's default origin check without --allowed-origin", async () => { const root = mkdtempSync(join(tmpdir(), 'pangular-cli-')); dirs.push(root); diff --git a/packages/devtools/src/__tests__/lan-address.test.ts b/packages/devtools/src/__tests__/lan-address.test.ts new file mode 100644 index 00000000..ea77b5cb --- /dev/null +++ b/packages/devtools/src/__tests__/lan-address.test.ts @@ -0,0 +1,265 @@ +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import type { networkInterfaces } from 'node:os'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { build } from 'vite'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { resolveLanAddress } from '../config.ts'; +import { cliLanLines, lanDefine, lanUrl, pickLanAddress, viteBindHost } from '../lan-address.ts'; +import { LAN_URL_DEFINE, lanDevtoolsURL } from '../lan-url.ts'; +import { viteLanAddress } from '../vite-lan.ts'; +import pangularVite from '../vite.ts'; + +type Interfaces = ReturnType; + +function nic(address: string, internal = false) { + return { + address, + netmask: '255.255.255.0', + family: 'IPv4' as const, + mac: '00:00:00:00:00:00', + internal, + cidr: `${address}/24`, + }; +} + +const INTERFACES: Interfaces = { + lo: [nic('127.0.0.1', true)], + docker0: [nic('100.64.0.2')], + wlan0: [nic('192.168.1.20')], +}; + +const LAN_OK = { + allowLan: true, + allowedOrigins: ['capacitor://localhost'], +}; + +afterEach(() => { + delete (globalThis as Record)[LAN_URL_DEFINE]; +}); + +describe('resolveLanAddress', () => { + it('is off by default and for false', () => { + expect(resolveLanAddress(undefined)).toEqual({ on: false }); + expect(resolveLanAddress(false)).toEqual({ on: false }); + }); + + it('takes true and a network IPv4 address', () => { + expect(resolveLanAddress(true)).toEqual({ on: true }); + expect(resolveLanAddress('192.168.1.20')).toEqual({ on: true, address: '192.168.1.20' }); + }); + + it('refuses loopback, unspecified and malformed values with a warning', () => { + for (const value of ['127.0.0.1', '0.0.0.0', 'localhost', '192.168.1', 1, {}]) { + const warn = vi.fn(); + expect(resolveLanAddress(value, warn)).toEqual({ on: false }); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/^\[pangular\] Ignoring lanAddress/)); + } + }); +}); + +describe('the LAN URL', () => { + it('picks a private address over other non-internal ones', () => { + expect(pickLanAddress(INTERFACES)).toBe('192.168.1.20'); + expect(pickLanAddress({ eth0: [nic('100.64.0.2')] })).toBe('100.64.0.2'); + expect(pickLanAddress({ lo: [nic('127.0.0.1', true)] })).toBeUndefined(); + }); + + it("reads Vite's server.host as a bind host", () => { + expect(viteBindHost(true)).toBe('0.0.0.0'); + expect(viteBindHost('')).toBe('0.0.0.0'); + expect(viteBindHost(undefined)).toBe('localhost'); + expect(viteBindHost(false)).toBe('localhost'); + expect(viteBindHost('192.168.1.20')).toBe('192.168.1.20'); + }); + + it('builds the URL for a server on every interface', () => { + const input = { host: '0.0.0.0', port: 5173, path: '/__devframes/pangular/' }; + expect(lanUrl({ ...input, lan: { on: true }, interfaces: INTERFACES })).toEqual({ + url: 'http://192.168.1.20:5173/__devframes/pangular/', + }); + expect( + lanUrl({ ...input, lan: { on: true, address: '10.0.0.5' }, interfaces: INTERFACES }), + ).toEqual({ url: 'http://10.0.0.5:5173/__devframes/pangular/' }); + expect(lanUrl({ ...input, https: true, lan: { on: true }, interfaces: INTERFACES })).toEqual({ + url: 'https://192.168.1.20:5173/__devframes/pangular/', + }); + }); + + it('gives no URL for a server on loopback, so it never points a device at a closed port', () => { + for (const host of ['localhost', '127.0.0.1', '::1']) { + const result = lanUrl({ lan: { on: true }, host, port: 9999, path: '/' }); + expect(result).toEqual({ problem: expect.stringContaining('listens on') }); + } + }); + + it('uses the one address a server binds to, and refuses another', () => { + const base = { host: '192.168.1.20', port: 9999, path: '/' }; + expect(lanUrl({ ...base, lan: { on: true } })).toEqual({ url: 'http://192.168.1.20:9999/' }); + expect(lanUrl({ ...base, lan: { on: true, address: '10.0.0.5' } })).toEqual({ + problem: expect.stringContaining('not on 10.0.0.5'), + }); + }); + + it('says when the machine has no network address', () => { + expect( + lanUrl({ + lan: { on: true }, + host: '0.0.0.0', + port: 9999, + path: '/', + interfaces: { lo: [nic('127.0.0.1', true)] }, + }), + ).toEqual({ problem: 'this machine has no network address.' }); + }); + + it('writes the define as a JSON string, and the CLI lines as an ng build flag', () => { + expect(lanDefine('http://192.168.1.20:9999/')).toEqual({ + __PANGULAR_LAN_URL__: '"http://192.168.1.20:9999/"', + }); + expect(cliLanLines('http://192.168.1.20:9999/').join('\n')).toContain( + `--define "__PANGULAR_LAN_URL__='http://192.168.1.20:9999/'"`, + ); + }); +}); + +describe('lanDevtoolsURL', () => { + it('is undefined when the build injected nothing', () => { + expect(lanDevtoolsURL()).toBeUndefined(); + }); + + it('reads the value Vite defines on globalThis in dev, and ignores anything but a URL', () => { + (globalThis as Record)[LAN_URL_DEFINE] = 'http://192.168.1.20:5173/x/'; + expect(lanDevtoolsURL()).toBe('http://192.168.1.20:5173/x/'); + (globalThis as Record)[LAN_URL_DEFINE] = 'javascript:alert(1)'; + expect(lanDevtoolsURL()).toBeUndefined(); + (globalThis as Record)[LAN_URL_DEFINE] = 42; + expect(lanDevtoolsURL()).toBeUndefined(); + }); +}); + +describe('viteLanAddress gates', () => { + const user = { server: { host: true, port: 5173 } }; + + function run(options: Parameters[0], config: object = user) { + const lan = viteLanAddress(options, '/__devframes/', INTERFACES); + const define = lan.define(config); + const warn = vi.fn(); + lan.report({ warn }); + return { define, url: lan.url, warnings: warn.mock.calls.map(([m]) => String(m)), lan }; + } + + it('injects nothing and says nothing when off', () => { + expect(run({ ...LAN_OK })).toEqual( + expect.objectContaining({ define: undefined, warnings: [] }), + ); + }); + + it('injects the hub URL when allowLan is in effect and Vite listens on the network', () => { + const { define, url, warnings } = run({ ...LAN_OK, lanAddress: true }); + expect(url).toBe('http://192.168.1.20:5173/__devframes/pangular/'); + expect(define).toEqual({ __PANGULAR_LAN_URL__: JSON.stringify(url) }); + expect(warnings).toEqual([]); + }); + + it('respects allowLan, the one-time code and allowedOrigins', () => { + for (const options of [ + { lanAddress: true, allowedOrigins: ['capacitor://localhost'] }, + { lanAddress: true, allowLan: true }, + { lanAddress: true, allowLan: true, allowedOrigins: ['http://localhost:4200'] }, + { ...LAN_OK, lanAddress: true, auth: false }, + ]) { + const { define, warnings } = run(options); + expect(define).toBeUndefined(); + expect(warnings).toEqual([expect.stringMatching(/don't answer other machines/)]); + } + }); + + it('keeps the one-time code on with pairing tokens', () => { + const { define } = run({ ...LAN_OK, lanAddress: true, auth: { tokens: ['a'.repeat(32)] } }); + expect(define).toBeDefined(); + }); + + it('needs server.host on the network', () => { + const { define, warnings } = run({ ...LAN_OK, lanAddress: true }, { server: {} }); + expect(define).toBeUndefined(); + expect(warnings).toEqual([expect.stringContaining('listens on localhost only')]); + }); + + it('warns when Vite moved to another port than the injected one', () => { + const { lan } = run({ ...LAN_OK, lanAddress: true }); + const warn = vi.fn(); + lan.checkPort({ address: '::', family: 'IPv6', port: 5173 }, { warn }); + expect(warn).not.toHaveBeenCalled(); + lan.checkPort({ address: '::', family: 'IPv6', port: 5174 }, { warn }); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('server.strictPort')); + }); + + it('applies to development builds only, never to production ones', () => { + const on = viteLanAddress({ ...LAN_OK, lanAddress: true }, '/__devframes/'); + expect(on.appliesToBuild('development')).toBe(true); + expect(on.appliesToBuild('production')).toBe(false); + expect(viteLanAddress({ ...LAN_OK }, '/__devframes/').appliesToBuild('development')).toBe( + false, + ); + }); +}); + +describe('pangularVite lanAddress', () => { + type Apply = (config: object, env: { command: string; mode: string }) => boolean; + type ConfigHook = (user: object, env: { command: string; mode: string }) => unknown; + + it('keeps the plugin to the dev server unless lanAddress is on', () => { + expect(pangularVite({ ...LAN_OK }).apply).toBe('serve'); + const lan = pangularVite({ ...LAN_OK, lanAddress: true }).apply as Apply; + expect(lan({}, { command: 'serve', mode: 'development' })).toBe(true); + expect(lan({}, { command: 'build', mode: 'development' })).toBe(true); + expect(lan({}, { command: 'build', mode: 'production' })).toBe(false); + }); + + it('adds the define in the config hook of the dev server', () => { + const plugin = pangularVite({ ...LAN_OK, lanAddress: '192.168.1.20' }); + const config = (plugin.config as ConfigHook)( + { server: { host: true, port: 4300 } }, + { command: 'serve', mode: 'development' }, + ); + expect(config).toEqual({ + define: { __PANGULAR_LAN_URL__: '"http://192.168.1.20:4300/__devframes/pangular/"' }, + }); + }); + + it('injects the address into a development build, and only the define', async () => { + const root = mkdtempSync(join(tmpdir(), 'pangular-lan-build-')); + writeFileSync( + join(root, 'index.html'), + '', + ); + writeFileSync( + join(root, 'main.js'), + 'console.log(typeof __PANGULAR_LAN_URL__ === "undefined" ? "none" : __PANGULAR_LAN_URL__);\n', + ); + const run = async (mode: string) => { + const outDir = join(root, `dist-${mode}`); + await build({ + configFile: false, + root, + mode, + logLevel: 'silent', + server: { host: true, port: 5180 }, + build: { outDir, minify: false }, + plugins: [pangularVite({ ...LAN_OK, lanAddress: '192.168.1.20' })], + }); + const assets = join(outDir, 'assets'); + const js = readdirSync(assets).find((file) => file.endsWith('.js'))!; + return { + js: readFileSync(join(assets, js), 'utf8'), + html: readFileSync(join(outDir, 'index.html'), 'utf8'), + }; + }; + const development = await run('development'); + expect(development.js).toContain('http://192.168.1.20:5180/__devframes/pangular/'); + expect(development.html).not.toContain('data-pangular-overlay'); + const production = await run('production'); + expect(production.js).not.toContain('192.168.1.20'); + }, 30_000); +}); diff --git a/packages/devtools/src/__tests__/overlay-nativescript-connection.test.ts b/packages/devtools/src/__tests__/overlay-nativescript-connection.test.ts index e505c90a..b498b53a 100644 --- a/packages/devtools/src/__tests__/overlay-nativescript-connection.test.ts +++ b/packages/devtools/src/__tests__/overlay-nativescript-connection.test.ts @@ -105,6 +105,21 @@ describe('NativeScript overlay connection', () => { dispose(); }); + it('connects to the LAN address the build injected when no baseURL is given', async () => { + const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts'); + vi.stubGlobal('__PANGULAR_LAN_URL__', 'http://192.168.1.20:9999/'); + const dispose = initNativeScriptOverlay({ intervalMs: 100 }); + await flush(); + expect(clients[0].options).toMatchObject({ baseURL: 'http://192.168.1.20:9999/' }); + dispose(); + vi.unstubAllGlobals(); + vi.stubGlobal('WebSocket', class {}); + const fallback = initNativeScriptOverlay({ intervalMs: 100 }); + await flush(); + expect(clients[1].options).toMatchObject({ baseURL: 'http://10.0.2.2:9999/' }); + fallback(); + }); + it('keeps its page id across a reconnect and forgets nothing on the dropped socket', async () => { const { initNativeScriptOverlay } = await import('../overlay-nativescript.ts'); const dispose = initNativeScriptOverlay({ diff --git a/packages/devtools/src/cli.ts b/packages/devtools/src/cli.ts index e0ac5d8c..1d91c93e 100644 --- a/packages/devtools/src/cli.ts +++ b/packages/devtools/src/cli.ts @@ -19,10 +19,13 @@ import { cliGatePolicy, gatedDevApp } from './cli-gate.ts'; import pangular, { createPangular } from './devframe.ts'; import { mcpPrintConfig, printConfigPort } from './mcp-print-config.ts'; import { + resolveLanAddress, resolvePangularConfig, type PangularConfig, + type ResolvedLanAddress, type ResolvedPangularConfig, } from './config.ts'; +import { cliLanLines, lanUrl } from './lan-address.ts'; import { pangularMcp } from './agent-mcp.ts'; import { PANGULAR_MCP_TOKEN_ENV, loadMcpToken } from './mcp-token.ts'; import { registerSkillsCommand } from './skills-install.ts'; @@ -303,6 +306,7 @@ export function createPangularCli(options: PangularCliOptions = {}) { let access: CliAccess = { allowedOrigins: [], authTokens: [] }; let listenHost = 'localhost'; let proxyUrl: string | undefined; + let lan: ResolvedLanAddress = { on: false }; const definition = defineDevframe({ ...pangular, cli: { ...pangular.cli, command: 'pangular' }, @@ -354,9 +358,17 @@ export function createPangularCli(options: PangularCliOptions = {}) { const defaultCommand = cli.commands.find((command) => command.isDefaultCommand); if (defaultCommand) { const run = async ( - flags: PangularCliFlags & { port?: number; host?: string; mcp?: boolean }, + flags: PangularCliFlags & { + port?: number; + host?: string; + mcp?: boolean; + lanAddress?: unknown; + }, ) => { prepare('dev', flags); + lan = resolveLanAddress(flags.lanAddress, (message) => + console.warn(message.replace('lanAddress', '--lan-address')), + ); requestedPort = flags.port === undefined ? undefined : Number(flags.port); mcpOn = flags.mcp !== false; listenHost = flags.host ?? 'localhost'; @@ -398,6 +410,10 @@ export function createPangularCli(options: PangularCliOptions = {}) { mcpOption.description = 'Serve the MCP route for agents (on by default; --no-mcp turns it off)'; } + defaultCommand.option( + '--lan-address [address]', + 'Print the LAN address and the define that injects it into a device build (needs --host on the network; off by default)', + ); 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", @@ -433,6 +449,11 @@ export function createPangularCli(options: PangularCliOptions = {}) { log( `\n${[...startupLines(origin, port, { mcp: mcpOn, requestedPort, proxyUrl }), ...accessLines(access)].join('\n')}\n`, ); + if (lan.on) { + const result = lanUrl({ lan, host: listenHost, port, path: '/' }); + if ('url' in result) log(`${cliLanLines(result.url).join('\n')}\n`); + else console.warn(`[pangular] --lan-address prints nothing: ${result.problem}`); + } const addresses = networkAddresses(listenHost); if (!addresses.length) return; // The server's own connection info, so the printed call carries its config. diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index 06255ba8..11ca6b11 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -940,6 +940,42 @@ export function pickPangularConfig( return { config: { inspectors, agent, actions, redaction, limits, popup, plugins, lint }, rest }; } +/** + * The `lanAddress` option of the Vite plugin and `--lan-address` of `pangular dev`: off (the + * default), on with the machine's own LAN address, or on with the IPv4 address given. + */ +export type LanAddressOption = boolean | string; + +/** `lanAddress` once read: off, or on with the address to use when one was given. */ +export type ResolvedLanAddress = { on: false } | { on: true; address?: string }; + +const IPV4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/; + +/** + * Reads `lanAddress`. Anything but `true` or a network IPv4 address, such as `192.168.1.20`, + * leaves it off with a warning: a loopback or unspecified address is no LAN address. + */ +export function resolveLanAddress( + value: unknown, + warn: (message: string) => void = () => undefined, +): ResolvedLanAddress { + if (value === undefined || value === false) return { on: false }; + if (value === true) return { on: true }; + if (typeof value === 'string' && IPV4.test(value)) { + if (value.startsWith('127.') || value === '0.0.0.0') { + warn( + `[pangular] Ignoring lanAddress: ${value} is not a network address. Pass true, or your machine's LAN address such as 192.168.1.20.`, + ); + return { on: false }; + } + return { on: true, address: value }; + } + warn( + `[pangular] Ignoring lanAddress: ${JSON.stringify(value)}. Pass true, or your machine's LAN address such as 192.168.1.20.`, + ); + return { on: false }; +} + /** Form actions that work without a `formId`: they act on the page, not on one form. */ export const FORMLESS_FORM_ACTIONS: readonly string[] = [ 'locate', diff --git a/packages/devtools/src/lan-address.ts b/packages/devtools/src/lan-address.ts new file mode 100644 index 00000000..d203fca9 --- /dev/null +++ b/packages/devtools/src/lan-address.ts @@ -0,0 +1,88 @@ +// LAN address injection (`lanAddress` on the Vite plugin, `--lan-address` on +// `pangular dev`). Off by default. It only hands an app the address of a +// server that already answers other machines: the Vite plugin needs +// `allowLan` in effect (the one-time code on and a network origin listed), +// and both need the server listening on the network. It never widens who +// can reach the server. +import { networkInterfaces } from 'node:os'; +import { listensOnNetwork, networkAddresses } from './cli-access.ts'; +import type { ResolvedLanAddress } from './config.ts'; +import { LAN_URL_DEFINE } from './lan-url.ts'; + +export { LAN_URL_DEFINE }; + +type Interfaces = ReturnType; + +const PRIVATE = [/^10\./, /^192\.168\./, /^172\.(1[6-9]|2\d|3[01])\./]; + +/** + * The machine's LAN address: the first private IPv4 address (10/8, 172.16/12, + * 192.168/16), else the first other non-internal one, else `undefined`. + */ +export function pickLanAddress(interfaces: Interfaces = networkInterfaces()): string | undefined { + const addresses = networkAddresses('0.0.0.0', interfaces); + return addresses.find((address) => PRIVATE.some((range) => range.test(address))) ?? addresses[0]; +} + +/** Vite's `server.host` as a bind host: `true` and `''` listen everywhere, unset is loopback. */ +export function viteBindHost(host: unknown): string { + if (host === true || host === '') return '0.0.0.0'; + return typeof host === 'string' ? host : 'localhost'; +} + +/** The `define` entry that injects `url`, for Vite, esbuild or webpack's `DefinePlugin`. */ +export function lanDefine(url: string): Record { + return { [LAN_URL_DEFINE]: JSON.stringify(url) }; +} + +export interface LanUrlInput { + lan: ResolvedLanAddress; + /** The host the server binds to. */ + host: string; + port: number; + /** The path the devtools answer on, with a trailing slash. */ + path: string; + https?: boolean; + interfaces?: Interfaces; +} + +/** + * The devtools URL to inject, or why there is none. With a bind host on one + * network address, that address is the only one that answers, so it wins + * over the machine's first address. + */ +export function lanUrl(input: LanUrlInput): { url: string } | { problem: string } { + if (!input.lan.on) return { problem: 'lanAddress is off.' }; + if (!listensOnNetwork(input.host)) { + return { + problem: `the server listens on ${input.host} only, so other machines can't reach it. Start it on the network (Vite: server.host; CLI: --host 0.0.0.0).`, + }; + } + const bound = networkAddresses(input.host, input.interfaces); + const all = input.host === '0.0.0.0' || input.host === '::' || input.host === '[::]'; + const address = all + ? (input.lan.address ?? pickLanAddress(input.interfaces)) + : (bound[0] ?? input.lan.address); + if (!address) return { problem: 'this machine has no network address.' }; + if (!all && input.lan.address && input.lan.address !== address) { + return { + problem: `the server listens on ${address} only, not on ${input.lan.address}.`, + }; + } + const scheme = input.https ? 'https' : 'http'; + return { url: `${scheme}://${address}:${input.port}${input.path}` }; +} + +/** + * What `pangular dev --lan-address` prints once it listens: the URL and the + * define to pass to the app's development build. The CLI does not build the + * app, so the build injects it. + */ +export function cliLanLines(url: string): string[] { + const value = `'${url}'`; + return [ + ` LAN address for a device build: ${url}`, + ` Inject it as ${LAN_URL_DEFINE}, for example in an Angular CLI app:`, + ` ng build --configuration development --define "${LAN_URL_DEFINE}=${value}"`, + ]; +} diff --git a/packages/devtools/src/lan-url.ts b/packages/devtools/src/lan-url.ts new file mode 100644 index 00000000..256ab072 --- /dev/null +++ b/packages/devtools/src/lan-url.ts @@ -0,0 +1,25 @@ +// The devtools server's LAN address, injected into an app's development build +// by the Vite plugin's `lanAddress` option or the define `pangular dev +// --lan-address` prints. A phone app (Capacitor, NativeScript) has no +// `localhost` to reach the server on, so it connects there instead. + +/** The global constant a build defines with the devtools URL on the LAN. */ +export const LAN_URL_DEFINE = '__PANGULAR_LAN_URL__'; + +declare const __PANGULAR_LAN_URL__: unknown; + +/** + * The devtools server URL on the LAN that the build injected, or `undefined` + * when it injected none. Bundlers replace the constant at build time; Vite's + * dev server may define it on `globalThis` instead, so both are read. + */ +export function lanDevtoolsURL(): string | undefined { + let value: unknown; + try { + value = typeof __PANGULAR_LAN_URL__ === 'undefined' ? undefined : __PANGULAR_LAN_URL__; + } catch { + value = undefined; + } + if (value === undefined) value = (globalThis as Record)[LAN_URL_DEFINE]; + return typeof value === 'string' && /^https?:\/\//.test(value) ? value : undefined; +} diff --git a/packages/devtools/src/overlay-nativescript.ts b/packages/devtools/src/overlay-nativescript.ts index 1eaaf780..cdf2d20c 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -48,6 +48,9 @@ import { type NativeView, } from './overlay-nativescript-views.ts'; import { claimPageOnServer, newPageId, newPageKey } from './page-id.ts'; +import { lanDevtoolsURL } from './lan-url.ts'; + +export { lanDevtoolsURL }; type DebugNg = SignalDebugNg & ComponentDebugNg & NativeScriptDebugNg; @@ -60,7 +63,10 @@ const FLASH_MS = 2000; class UntrustedError extends Error {} export interface NativeScriptOverlayOptions { - /** Absolute URL of the devtools server. Defaults to `defaultDevtoolsBaseURL()`. */ + /** + * Absolute URL of the devtools server. Defaults to the LAN address the build + * injected (`lanDevtoolsURL()`), then to `defaultDevtoolsBaseURL()`. + */ baseURL?: string; /** How often the app reports, in milliseconds. Defaults to the server's `limits.refreshMs`. */ intervalMs?: number; @@ -79,7 +85,7 @@ export function defaultDevtoolsBaseURL(port = 9999): string { } export function initNativeScriptOverlay(options: NativeScriptOverlayOptions = {}): () => void { - const baseURL = options.baseURL ?? defaultDevtoolsBaseURL(); + const baseURL = options.baseURL ?? lanDevtoolsURL() ?? defaultDevtoolsBaseURL(); gateInjectorProfiler(); installWebShims(baseURL); // Before the app starts, so stores that look for the Redux DevTools extension find the stand-in. diff --git a/packages/devtools/src/overlay.ts b/packages/devtools/src/overlay.ts index 5c451fde..21a3bbe9 100644 --- a/packages/devtools/src/overlay.ts +++ b/packages/devtools/src/overlay.ts @@ -9,6 +9,7 @@ import { } from './overlay-connection.ts'; import { createOverlayLink, type LinkClient } from './overlay-link.ts'; export { registerNgrxSignals } from './ngrx-register.ts'; +export { lanDevtoolsURL } from './lan-url.ts'; import { attachAnalog } from './analog-runtime.ts'; import { attachForms } from './forms-collector.ts'; import { attachPipes } from './pipes-collector.ts'; diff --git a/packages/devtools/src/vite-lan.ts b/packages/devtools/src/vite-lan.ts new file mode 100644 index 00000000..a2027c4b --- /dev/null +++ b/packages/devtools/src/vite-lan.ts @@ -0,0 +1,127 @@ +// The Vite plugin's `lanAddress` option: the dev server's LAN address, +// injected as `__PANGULAR_LAN_URL__` into the app the dev server serves and +// into development builds. It only injects an address the hub answers on: +// `allowLan` must be in effect and `server.host` on the network. +import type { AddressInfo } from 'node:net'; +import type { networkInterfaces } from 'node:os'; +import type { UserConfig } from 'vite'; +import { normalizeAllowedOrigins } from './allowed-origins.ts'; +import { isAuthTokens, type PangularAuthTokens } from './auth-tokens.ts'; +import { resolveLanAddress } from './config.ts'; +import { allowsRemoteOrigins, type HubOriginPolicy } from './hub-gate.ts'; +import { lanDefine, lanUrl, viteBindHost } from './lan-address.ts'; + +/** Whether `allowLan` takes effect: only with the one-time code on and a remote origin listed. */ +export function lanAllowed( + allowLan: unknown, + authOn: boolean, + policy: HubOriginPolicy, + warn: (message: string) => void = () => undefined, +): boolean { + if (allowLan !== true) { + if (allowLan !== undefined && allowLan !== false) { + warn(`[pangular] Ignoring allowLan: ${JSON.stringify(allowLan)}. Pass true to turn it on.`); + } + return false; + } + if (!authOn) { + warn( + '[pangular] Ignoring allowLan: it needs the one-time code, and auth is false. Only this machine can reach the devtools.', + ); + return false; + } + if (!allowsRemoteOrigins({ allowedOrigins: policy.allowedOrigins })) { + warn( + '[pangular] allowLan has no effect without a network origin in allowedOrigins, such as http://192.168.1.20:5173.', + ); + return false; + } + return true; +} + +/** The plugin options `lanAddress` reads. */ +export interface ViteLanOptions { + lanAddress?: unknown; + allowLan?: unknown; + auth?: boolean | PangularAuthTokens; + allowedOrigins?: string[]; +} + +interface Logger { + warn(message: string): void; +} + +const VITE_PORT = 5173; +const SKIPPED = '[pangular] Not injecting the LAN address:'; + +/** + * The `lanAddress` state of one plugin instance. `define()` runs in the + * `config` hook, before Vite has a logger, so its warnings wait for `report()`. + */ +export function viteLanAddress( + options: ViteLanOptions, + base: string, + interfaces?: ReturnType, +) { + const optionWarnings: string[] = []; + const lan = resolveLanAddress(options.lanAddress, (message) => optionWarnings.push(message)); + let pending: string[] = []; + let port: number | undefined; + const state = { + /** Whether `lanAddress` is on. */ + enabled: lan.on, + /** The injected URL, once `define()` ran and injected one. */ + url: undefined as string | undefined, + /** Whether a `vite build` in `mode` gets the plugin: never for production. */ + appliesToBuild(mode: string): boolean { + return lan.on && mode !== 'production'; + }, + /** The `define` entries for the resolved config, or `undefined` when nothing is injected. */ + define(user: UserConfig): Record | undefined { + pending = [...optionWarnings]; + state.url = undefined; + port = undefined; + if (!lan.on) return undefined; + const authOn = isAuthTokens(options.auth) || (options.auth ?? true); + const policy: HubOriginPolicy = { + allowedHosts: user.server?.allowedHosts, + allowedOrigins: normalizeAllowedOrigins(options.allowedOrigins, () => undefined), + }; + if (!lanAllowed(options.allowLan, authOn, policy)) { + pending.push( + `${SKIPPED} the devtools don't answer other machines. Pass allowLan: true and a network origin in allowedOrigins, and keep auth on.`, + ); + return undefined; + } + const result = lanUrl({ + lan, + host: viteBindHost(user.server?.host), + port: user.server?.port ?? VITE_PORT, + path: `${base}pangular/`, + https: !!user.server?.https, + interfaces, + }); + if ('problem' in result) { + pending.push(`${SKIPPED} ${result.problem}`); + return undefined; + } + state.url = result.url; + port = user.server?.port ?? VITE_PORT; + return lanDefine(result.url); + }, + /** Logs the warnings `define()` collected. */ + report(logger: Logger): void { + for (const message of pending.splice(0)) logger.warn(message); + }, + /** Warns when Vite moved to another port than the one in the injected URL. */ + checkPort(address: AddressInfo | string | null, logger: Logger): void { + if (port === undefined || !address || typeof address === 'string') return; + if (address.port !== port) { + logger.warn( + `[pangular] The injected LAN address uses port ${port}, but the dev server listens on ${address.port}. Set server.strictPort, or free port ${port}.`, + ); + } + }, + }; + return state; +} diff --git a/packages/devtools/src/vite.ts b/packages/devtools/src/vite.ts index e68c9343..986fd6c1 100644 --- a/packages/devtools/src/vite.ts +++ b/packages/devtools/src/vite.ts @@ -27,6 +27,7 @@ import { pickPangularConfig, resolvePangularConfig, type PangularConfig } from ' import { overlayTags } from './vite-overlay.ts'; import { signalPrimitivesAlias } from './vite-signals-stub.ts'; import { terminalsEnabled } from './hub-terminals.ts'; +import { lanAllowed, viteLanAddress } from './vite-lan.ts'; export type { PangularConfig } from './config.ts'; import type { PangularServerPlugin } from './plugin.ts'; @@ -57,6 +58,14 @@ export interface PangularViteOptions extends PangularConfig { * default. Ignored with `allowLan`. */ terminals?: boolean; + /** + * Inject the dev server's LAN address into the app as `__PANGULAR_LAN_URL__`, + * so a phone app (Capacitor, NativeScript) built in development mode finds + * the devtools. `true` picks this machine's address; a string names one, + * such as `'192.168.1.20'`. Off by default. Needs `allowLan` in effect and + * `server.host` on the network; it changes nothing about who may connect. + */ + lanAddress?: boolean | string; /** Pangular plugins' server sides: their agent tools and shared state. */ serverPlugins?: readonly PangularServerPlugin[]; } @@ -72,38 +81,11 @@ export { isAllowedHubOrigin, isAllowedHubRequest, isHubPath, + lanAllowed, normalizeAllowedOrigins, type HubOriginPolicy, }; -/** Whether `allowLan` takes effect: only with the one-time code on and a remote origin listed. */ -export function lanAllowed( - allowLan: unknown, - authOn: boolean, - policy: HubOriginPolicy, - warn: (message: string) => void = () => undefined, -): boolean { - if (allowLan !== true) { - if (allowLan !== undefined && allowLan !== false) { - warn(`[pangular] Ignoring allowLan: ${JSON.stringify(allowLan)}. Pass true to turn it on.`); - } - return false; - } - if (!authOn) { - warn( - '[pangular] Ignoring allowLan: it needs the one-time code, and auth is false. Only this machine can reach the devtools.', - ); - return false; - } - if (!allowsRemoteOrigins({ allowedOrigins: policy.allowedOrigins })) { - warn( - '[pangular] allowLan has no effect without a network origin in allowedOrigins, such as http://192.168.1.20:5173.', - ); - return false; - } - return true; -} - /** The one-time code is on unless `auth` is `false`. */ export function hubAuthFor(auth?: boolean): boolean { return auth ?? true; @@ -126,19 +108,32 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin const base = normalizeHubBase(options.base ?? PANGULAR_HUB_BASE); const { config } = pickPangularConfig(options); const analog = resolvePangularConfig(config).inspectors.analog; + const lan = viteLanAddress(options, base); + let serving = true; return { name: 'pangular', - apply: 'serve', + // A development build also gets the plugin when it injects the LAN address, + // for an app bundled into a phone app. Then only the `define` applies. + apply: lan.enabled + ? (_config, env) => env.command === 'serve' || lan.appliesToBuild(env.mode) + : 'serve', enforce: 'pre', - config(user) { + config(user, env) { + serving = env?.command !== 'build'; + const define = lan.define(user); + if (!serving) return define ? { define } : undefined; // Vite allows a relative `root`, and `createRequire` needs an absolute path. const alias = signalPrimitivesAlias(resolve(process.cwd(), user.root ?? '')); - return alias.length ? { resolve: { alias } } : undefined; + if (!alias.length && !define) return undefined; + return { ...(alias.length ? { resolve: { alias } } : {}), ...(define ? { define } : {}) }; + }, + configResolved(resolved) { + lan.report(resolved.logger); }, // `pre`, so Vite's own HTML pass resolves the injected import like any other. transformIndexHtml: { order: 'pre', - handler: (html) => (options.overlay === false ? [] : overlayTags(html, base)), + handler: (html) => (options.overlay === false || !serving ? [] : overlayTags(html, base)), }, configureServer(server) { const logger = server.config.logger; @@ -182,6 +177,7 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin server.middlewares.use(devtools.nodeMiddleware); httpServer?.once('listening', () => { setDevOrigin(server.resolvedUrls?.local[0]); + lan.checkPort(httpServer.address(), logger); }); // Add the devtools URL to the URLs Vite prints at startup. const printUrls = server.printUrls?.bind(server); @@ -190,6 +186,7 @@ export default function pangularVite(options: PangularViteOptions = {}): Plugin printUrls(); const url = devtoolsUrl(server.resolvedUrls?.local[0], base); if (url) logger.info(` \u279C Pangular Inspector: ${url}`); + if (lan.url) logger.info(` \u279C Pangular Inspector (LAN): ${lan.url}`); }; } httpServer?.once('close', () => { From 3c513ba3dcc0cbff7d2433d098f14c3c272ee738 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 18:54:04 +0000 Subject: [PATCH 02/13] feat: add an ng add schematic that wires the devtools in Setting up the devtools meant copying snippets into server.ts, main.ts or vite.config.ts by hand. ng add @pangular-inspector/devtools now finds the setup and makes the documented edits: the Vite plugin for a Vite or Analog app, initPangularHub() and its middleware in an Express server.ts, or the standalone CLI steps otherwise, plus the overlay in main.ts behind ngDevMode. It only inserts lines next to code it recognizes, prints the snippet and changes nothing when a file looks different, and skips files that already import the devtools, so a second run is a no-op. The build ships it as CommonJS in dist/schematics, since the Angular CLI loads factories with require(), and it imports nothing at runtime. Refs #373 --- packages/devtools/package.json | 1 + .../src/__tests__/schematics-ng-add.test.ts | 401 ++++++++++++++++++ .../devtools/src/schematics/collection.json | 9 + .../devtools/src/schematics/ng-add/index.ts | 378 +++++++++++++++++ .../src/schematics/ng-add/schema.json | 16 + packages/devtools/tsdown.config.ts | 21 +- 6 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 packages/devtools/src/__tests__/schematics-ng-add.test.ts create mode 100644 packages/devtools/src/schematics/collection.json create mode 100644 packages/devtools/src/schematics/ng-add/index.ts create mode 100644 packages/devtools/src/schematics/ng-add/schema.json diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 9a8e4d50..d72ab587 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -30,6 +30,7 @@ "bin": { "pangular": "./bin.mjs" }, + "schematics": "./dist/schematics/collection.json", "exports": { ".": "./src/entries/devframe.ts", "./devframe": "./src/entries/devframe.ts", diff --git a/packages/devtools/src/__tests__/schematics-ng-add.test.ts b/packages/devtools/src/__tests__/schematics-ng-add.test.ts new file mode 100644 index 00000000..d59f491b --- /dev/null +++ b/packages/devtools/src/__tests__/schematics-ng-add.test.ts @@ -0,0 +1,401 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HostTree, type SchematicContext } from '@angular-devkit/schematics'; +import { SchematicTestRunner } from '@angular-devkit/schematics/testing'; +import { build } from 'tsdown'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { + detectSetup, + ngAdd, + wireExpressServer, + wireOverlay, + wireVitePlugin, +} from '../schematics/ng-add/index.ts'; +import { schematicsConfig } from '../../tsdown.config.ts'; + +const packageDir = join(import.meta.dirname, '../..'); +const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + schematics?: string; + files: string[]; +}; + +/** `src/server.ts` as `ng new --ssr` writes it on Angular 20 to 22 (excerpt). */ +const SERVER = `import { + AngularNodeAppEngine, + createNodeRequestHandler, + isMainModule, + writeResponseToNodeResponse, +} from '@angular/ssr/node'; +import express from 'express'; +import { join } from 'node:path'; + +const browserDistFolder = join(import.meta.dirname, '../browser'); + +const app = express(); +const angularApp = new AngularNodeAppEngine(); + +app.use( + express.static(browserDistFolder, { + maxAge: '1y', + index: false, + redirect: false, + }), +); +`; + +/** `src/main.ts` as `ng new` writes it. */ +const MAIN = `import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .catch((err) => console.error(err)); +`; + +/** `vite.config.ts` as `create-analog` writes it (excerpt). */ +const VITE = `/// + +import analog from '@analogjs/platform'; +import { defineConfig } from 'vite'; + +// https://vitejs.dev/config/ +export default defineConfig(({ mode }) => ({ + build: { + target: ['es2020'], + }, + resolve: { + mainFields: ['module'], + }, + plugins: [ + analog({ + ssr: false, + prerender: { routes: [] }, + }), + ], +})); +`; + +const WORKSPACE = { + version: 1, + projects: { + web: { + projectType: 'application', + root: '', + sourceRoot: 'src', + architect: { + build: { + builder: '@angular/build:application', + options: { browser: 'src/main.ts', ssr: { entry: 'src/server.ts' } }, + }, + }, + }, + }, +}; + +function workspace(files: Record, angular: object | null = WORKSPACE) { + const tree = new HostTree(); + if (angular) tree.create('angular.json', JSON.stringify(angular, null, 2)); + for (const [path, text] of Object.entries(files)) tree.create(path, text); + return tree; +} + +function context() { + const info = vi.fn(); + const warn = vi.fn(); + const ctx = { logger: { info, warn } } as unknown as SchematicContext; + const logs = () => [...info.mock.calls, ...warn.mock.calls].map(([m]) => String(m)).join('\n'); + return { ctx, info, warn, logs }; +} + +function run(tree: HostTree, options = {}) { + const log = context(); + ngAdd(options)(tree, log.ctx); + return log; +} + +const read = (tree: HostTree, path: string) => tree.readText(path); + +describe('detectSetup', () => { + it('prefers a Vite config, then an Express server, then the CLI', () => { + expect(detectSetup(workspace({ 'vite.config.ts': VITE, 'src/server.ts': SERVER }))).toEqual( + expect.objectContaining({ kind: 'vite', viteConfig: 'vite.config.ts', project: 'web' }), + ); + expect(detectSetup(workspace({ 'src/server.ts': SERVER, 'src/main.ts': MAIN }))).toEqual( + expect.objectContaining({ kind: 'express', server: 'src/server.ts', main: 'src/main.ts' }), + ); + expect(detectSetup(workspace({ 'src/main.ts': MAIN }))).toEqual( + expect.objectContaining({ kind: 'cli', main: 'src/main.ts' }), + ); + }); + + it('tells a server that is not Express apart', () => { + const hono = `import { Hono } from 'hono';\nconst app = new Hono();\n`; + expect(detectSetup(workspace({ 'src/server.ts': hono })).kind).toBe('server'); + }); + + it('reads the project paths from angular.json and takes --project', () => { + const angular = { + projects: { + lib: { projectType: 'library', root: 'projects/lib' }, + admin: { + projectType: 'application', + root: 'projects/admin', + sourceRoot: 'projects/admin/src', + architect: { build: { options: { browser: 'projects/admin/src/main.ts' } } }, + }, + shop: { projectType: 'application', root: 'projects/shop' }, + }, + }; + const tree = workspace({ 'projects/shop/vite.config.mts': VITE }, angular); + expect(detectSetup(tree)).toEqual( + expect.objectContaining({ + project: 'admin', + kind: 'cli', + main: 'projects/admin/src/main.ts', + }), + ); + expect(detectSetup(tree, { project: 'shop' })).toEqual( + expect.objectContaining({ kind: 'vite', viteConfig: 'projects/shop/vite.config.mts' }), + ); + expect(() => detectSetup(tree, { project: 'nope' })).toThrow(/No project "nope"/); + }); + + it('works without angular.json', () => { + expect(detectSetup(workspace({ 'vite.config.ts': VITE }, null))).toEqual( + expect.objectContaining({ kind: 'vite', main: 'src/main.ts' }), + ); + }); +}); + +describe('wireExpressServer', () => { + it('adds the hub as the Express docs show, in the file style', () => { + const result = wireExpressServer(SERVER); + expect(result.status).toBe('added'); + expect(result.text).toContain( + "import express from 'express';\nimport { initPangularHub } from '@pangular-inspector/devtools/hub';\n", + ); + expect(result.text).toContain( + 'const app = express();\n// Pangular Inspector. The hub turns itself off in production.\nconst devtools = initPangularHub({ ws: false });\napp.use(devtools.nodeMiddleware);\nconst angularApp', + ); + }); + + it('is idempotent', () => { + const once = wireExpressServer(SERVER).text!; + expect(wireExpressServer(once)).toEqual({ status: 'present' }); + }); + + it('keeps double quotes, no semicolons and the app name', () => { + const text = `import express from "express"\nconst server = express()\n`; + expect(wireExpressServer(text).text).toBe( + `import express from "express"\nimport {initPangularHub} from "@pangular-inspector/devtools/hub"\nconst server = express()\n// Pangular Inspector. The hub turns itself off in production.\nconst devtools = initPangularHub({ws: false})\nserver.use(devtools.nodeMiddleware)\n`, + ); + }); + + it('leaves code it does not recognize alone', () => { + for (const text of [ + `import express from 'express';\nconst a = express();\nconst b = express();\n`, + `const express = require('express');\nconst app = express();\n`, + `import express from 'express';\nexport function create() { return express(); }\n`, + `import express from 'express';\nconst devtools = 1;\nconst app = express();\n`, + ]) { + expect(wireExpressServer(text)).toEqual({ status: 'unsure' }); + } + }); +}); + +describe('wireOverlay', () => { + it('loads the overlay after bootstrap behind ngDevMode, as the installation page shows', () => { + const result = wireOverlay(MAIN); + expect(result.status).toBe('added'); + expect(result.text).toBe(`import { bootstrapApplication } from '@angular/platform-browser'; +import { appConfig } from './app/app.config'; +import { App } from './app/app'; + +bootstrapApplication(App, appConfig) + .then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return import('@pangular-inspector/devtools/overlay'); + } + return undefined; + }) + .catch((err) => console.error(err)); +`); + expect(wireOverlay(result.text!)).toEqual({ status: 'present' }); + }); + + it('handles a one-line call, a bare call and an NgModule bootstrap', () => { + const oneLine = wireOverlay( + `bootstrapApplication(App, appConfig).catch((err) => console.error(err));\n`, + ).text!; + expect(oneLine).toMatch(/^bootstrapApplication\(App, appConfig\)\n {2}\.then\(\(\) => \{/); + expect(oneLine).toContain('\n })\n .catch((err) => console.error(err));\n'); + const bare = wireOverlay(`bootstrapApplication(App, appConfig);\n`).text!; + expect(bare.endsWith(' return undefined;\n });\n')).toBe(true); + const ngModule = wireOverlay( + `platformBrowserDynamic()\n .bootstrapModule(AppModule)\n .catch((err) => console.error(err));\n`, + ); + expect(ngModule.status).toBe('added'); + expect(ngModule.text).toContain('.bootstrapModule(AppModule)\n .then('); + }); + + it('runs the overlay import only in development', async () => { + const code = wireOverlay(`bootstrapApplication(App, appConfig);`).text!.replace( + "import('@pangular-inspector/devtools/overlay')", + 'load()', + ); + const load = vi.fn(() => Promise.resolve()); + const bootstrap = () => Promise.resolve(); + for (const dev of [true, false]) { + load.mockClear(); + await new Function( + 'bootstrapApplication', + 'App', + 'appConfig', + 'ngDevMode', + 'load', + `return ${code}`, + )(bootstrap, {}, {}, dev, load); + expect(load).toHaveBeenCalledTimes(dev ? 1 : 0); + } + }); + + it('leaves an unknown bootstrap alone', () => { + expect(wireOverlay(`start();\n`)).toEqual({ status: 'unsure' }); + expect(wireOverlay(`bootstrapApplication(A, c);\nbootstrapApplication(B, c);\n`)).toEqual({ + status: 'unsure', + }); + }); +}); + +describe('wireVitePlugin', () => { + it('adds the import and pangular() after analog(), keeping the layout', () => { + const result = wireVitePlugin(VITE); + expect(result.status).toBe('added'); + expect(result.text).toContain( + "import { defineConfig } from 'vite';\nimport pangular from '@pangular-inspector/devtools/vite';\n", + ); + expect(result.text).toContain( + ` prerender: { routes: [] },\n }),\n pangular(),\n ],\n}));`, + ); + expect(wireVitePlugin(result.text!)).toEqual({ status: 'present' }); + }); + + it('handles one-line and empty plugin lists', () => { + expect( + wireVitePlugin( + `import analog from '@analogjs/platform';\nexport default { plugins: [analog()] };\n`, + ).text, + ).toBe( + `import analog from '@analogjs/platform';\nimport pangular from '@pangular-inspector/devtools/vite';\nexport default { plugins: [analog(), pangular()] };\n`, + ); + expect(wireVitePlugin(`export default { plugins: [] };\n`).text).toBe( + `import pangular from '@pangular-inspector/devtools/vite';\nexport default { plugins: [pangular()] };\n`, + ); + }); + + it('skips brackets inside strings and comments', () => { + const text = `import a from 'a';\nexport default { plugins: [a(']'), /* ] */ a("[")] };\n`; + expect(wireVitePlugin(text).text).toContain(`plugins: [a(']'), /* ] */ a("["), pangular()]`); + }); + + it('leaves configs it does not recognize alone', () => { + for (const text of [ + `export default { plugins };\n`, + `const a = { plugins: [] };\nconst b = { plugins: [] };\n`, + `import pangular from './local';\nexport default { plugins: [pangular()] };\n`, + ]) { + expect(wireVitePlugin(text)).toEqual({ status: 'unsure' }); + } + }); +}); + +describe('ngAdd', () => { + it('wires an Express SSR app, and a second run changes nothing', () => { + const tree = workspace({ 'src/server.ts': SERVER, 'src/main.ts': MAIN }); + const first = run(tree); + const server = read(tree, 'src/server.ts'); + const main = read(tree, 'src/main.ts'); + expect(server).toContain('initPangularHub({ ws: false })'); + expect(main).toContain("import('@pangular-inspector/devtools/overlay')"); + expect(first.logs()).toContain('Added the devtools hub to src/server.ts'); + const second = run(tree); + expect(read(tree, 'src/server.ts')).toBe(server); + expect(read(tree, 'src/main.ts')).toBe(main); + expect(second.logs()).toContain('already has the devtools hub'); + expect(second.warn).not.toHaveBeenCalled(); + }); + + it('adds only the Vite plugin to an Analog app, since the plugin loads the overlay', () => { + const analogMain = `import { bootstrapApplication } from '@angular/platform-browser';\nbootstrapApplication(App, appConfig);\n`; + const tree = workspace({ 'vite.config.ts': VITE, 'src/main.ts': analogMain }); + run(tree); + expect(read(tree, 'vite.config.ts')).toContain('pangular(),'); + expect(read(tree, 'src/main.ts')).toBe(analogMain); + const config = read(tree, 'vite.config.ts'); + run(tree); + expect(read(tree, 'vite.config.ts')).toBe(config); + }); + + it('adds the overlay and prints the CLI steps for a client-only app', () => { + const tree = workspace({ 'src/main.ts': MAIN }); + const { logs } = run(tree); + expect(read(tree, 'src/main.ts')).toContain('@pangular-inspector/devtools/overlay'); + expect(logs()).toContain('npx pangular dev --port 9999 --proxy-origin http://localhost:4200'); + expect(logs()).toContain('"/__pangular"'); + expect(tree.exists('proxy.conf.json')).toBe(false); + }); + + it('prints the snippet and changes nothing when unsure', () => { + const server = `import express from 'express';\nexport const app = makeApp(express);\n`; + const main = `start();\n`; + const tree = workspace({ 'src/server.ts': server, 'src/main.ts': main }); + const { warn } = run(tree); + expect(read(tree, 'src/server.ts')).toBe(server); + expect(read(tree, 'src/main.ts')).toBe(main); + const warned = warn.mock.calls.map(([m]) => String(m)).join('\n'); + expect(warned).toContain("import {initPangularHub} from '@pangular-inspector/devtools/hub';"); + expect(warned).toContain("return import('@pangular-inspector/devtools/overlay');"); + }); + + it('points a server that is not Express at the other servers page', () => { + const tree = workspace({ + 'src/server.ts': `import { Hono } from 'hono';\n`, + 'src/main.ts': MAIN, + }); + const { warn } = run(tree); + expect(read(tree, 'src/server.ts')).toBe(`import { Hono } from 'hono';\n`); + expect(String(warn.mock.calls[0]?.[0])).toContain('/getting-started/other-servers'); + }); +}); + +describe('the published schematic', () => { + const work = mkdtempSync(join(tmpdir(), 'pangular-schematics-')); + afterAll(() => rmSync(work, { recursive: true, force: true })); + + it('is listed in package.json and shipped in dist', () => { + expect(manifest.schematics).toBe('./dist/schematics/collection.json'); + expect(manifest.files).toContain('dist'); + }); + + it('builds a CommonJS collection the Angular CLI can load and run', async () => { + await build({ + ...schematicsConfig, + config: false, + cwd: packageDir, + outDir: work, + logLevel: 'silent', + }); + const collection = join(work, 'schematics/collection.json'); + const runner = new SchematicTestRunner('@pangular-inspector/devtools', collection); + const tree = await runner.runSchematic( + 'ng-add', + { project: 'web' }, + workspace({ 'src/server.ts': SERVER, 'src/main.ts': MAIN }), + ); + expect(tree.readContent('src/server.ts')).toContain('app.use(devtools.nodeMiddleware);'); + expect(tree.readContent('src/main.ts')).toContain('@pangular-inspector/devtools/overlay'); + const again = await runner.runSchematic('ng-add', {}, tree); + expect(again.readContent('src/server.ts')).toBe(tree.readContent('src/server.ts')); + }, 60_000); +}); diff --git a/packages/devtools/src/schematics/collection.json b/packages/devtools/src/schematics/collection.json new file mode 100644 index 00000000..22c50e22 --- /dev/null +++ b/packages/devtools/src/schematics/collection.json @@ -0,0 +1,9 @@ +{ + "schematics": { + "ng-add": { + "description": "Wire Pangular Inspector into the workspace: the Express hub, the Vite plugin or the standalone CLI, and the overlay in main.ts.", + "factory": "./ng-add/index.cjs#ngAdd", + "schema": "./ng-add/schema.json" + } + } +} diff --git a/packages/devtools/src/schematics/ng-add/index.ts b/packages/devtools/src/schematics/ng-add/index.ts new file mode 100644 index 00000000..4f872769 --- /dev/null +++ b/packages/devtools/src/schematics/ng-add/index.ts @@ -0,0 +1,378 @@ +// The `ng add @pangular-inspector/devtools` schematic. It finds the setup the +// workspace uses and wires the devtools in the way the docs recommend: +// +// - a Vite config (Analog): the Vite plugin, which also loads the overlay; +// - an Express `server.ts` (Angular SSR): `initPangularHub()` and its +// middleware, and the overlay in `main.ts` behind a development check; +// - anything else: the overlay in `main.ts`, and the steps to run the +// standalone CLI next to `ng serve`, printed. +// +// Every edit is idempotent and only inserts lines next to code it recognizes. +// When a file doesn't look the way the docs assume, it changes nothing and +// prints the snippet to add by hand. +// +// It imports nothing at runtime, so it runs on the `@angular-devkit/schematics` +// copy of whichever Angular CLI calls it. The build ships it as CommonJS, +// since that CLI loads factories with `require()`. +import type { Rule, SchematicContext, Tree } from '@angular-devkit/schematics'; + +export interface NgAddOptions { + /** The project to set up. Defaults to the workspace's only application, or its first one. */ + project?: string; +} + +/** What one edit did: inserted the lines, found them there already, or left the file alone. */ +export type EditStatus = 'added' | 'present' | 'unsure'; + +export interface EditResult { + status: EditStatus; + /** The new file content, for `added`. */ + text?: string; +} + +export const PACKAGE = '@pangular-inspector/devtools'; +const DOCS = 'https://pangular-inspector.dev/getting-started'; +const VITE_CONFIGS = ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']; + +export const OVERLAY_SNIPPET = `bootstrapApplication(App, appConfig) + .then(() => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + return import('${PACKAGE}/overlay'); + } + return undefined; + }) + .catch((err) => console.error(err));`; + +export const SERVER_SNIPPET = `import {initPangularHub} from '${PACKAGE}/hub'; + +const app = express(); +const devtools = initPangularHub({ws: false}); +app.use(devtools.nodeMiddleware);`; + +export const VITE_SNIPPET = `import pangular from '${PACKAGE}/vite'; + +export default defineConfig({ + plugins: [analog(), pangular()], +});`; + +export const CLI_STEPS = [ + 'This workspace has no Express server.ts and no Vite config, so run the standalone CLI next to ng serve:', + ' 1. Start it: npx pangular dev --port 9999 --proxy-origin http://localhost:4200', + ' 2. Save proxy.conf.json next to angular.json:', + ' {"/__pangular": {"target": "http://localhost:9999", "pathRewrite": {"^/__pangular": ""}, "ws": true}}', + ' 3. Set "proxyConfig": "proxy.conf.json" in the serve options of angular.json, or run ng serve --proxy-config proxy.conf.json', + `See ${DOCS}/installation#client-only-angular-cli-app`, +]; + +/** The setup found in the workspace and the files the edits go to. */ +export interface Setup { + kind: 'vite' | 'express' | 'server' | 'cli'; + project?: string; + main?: string; + server?: string; + viteConfig?: string; +} + +function join(...parts: (string | undefined)[]): string { + return parts + .filter((part): part is string => !!part && part !== '.') + .join('/') + .replace(/\/+/g, '/') + .replace(/^\.\//, '') + .replace(/^\//, ''); +} + +function readText(tree: Tree, path: string): string | undefined { + return tree.exists(path) ? (tree.read(path)?.toString('utf8') ?? undefined) : undefined; +} + +type Json = Record; +const isObject = (value: unknown): value is Json => + !!value && typeof value === 'object' && !Array.isArray(value); + +/** The project named in `options`, or the only application, or the first one. */ +function pickProject(workspace: Json, name: string | undefined) { + const projects = isObject(workspace['projects']) ? workspace['projects'] : {}; + if (name) { + const project = projects[name]; + if (!isObject(project)) throw new Error(`[pangular] No project "${name}" in angular.json.`); + return { name, project }; + } + const entries = Object.entries(projects).filter((entry): entry is [string, Json] => + isObject(entry[1]), + ); + const apps = entries.filter(([, project]) => project['projectType'] !== 'library'); + const fallback = + typeof workspace['defaultProject'] === 'string' ? workspace['defaultProject'] : ''; + const chosen = apps.find(([key]) => key === fallback) ?? apps[0] ?? entries[0]; + return chosen ? { name: chosen[0], project: chosen[1] } : undefined; +} + +/** Reads the setup from `angular.json`, the Vite config and `server.ts`. */ +export function detectSetup(tree: Tree, options: NgAddOptions = {}): Setup { + let root = ''; + let sourceRoot = 'src'; + let main: string | undefined; + let server: string | undefined; + let project: string | undefined; + const workspaceText = readText(tree, 'angular.json'); + if (workspaceText) { + const picked = pickProject(JSON.parse(workspaceText) as Json, options.project); + if (picked) { + project = picked.name; + root = typeof picked.project['root'] === 'string' ? picked.project['root'] : ''; + sourceRoot = + typeof picked.project['sourceRoot'] === 'string' + ? picked.project['sourceRoot'] + : join(root, 'src'); + const architect = isObject(picked.project['architect']) ? picked.project['architect'] : {}; + const build = isObject(architect['build']) ? architect['build'] : {}; + const buildOptions = isObject(build['options']) ? build['options'] : {}; + const entry = buildOptions['browser'] ?? buildOptions['main']; + if (typeof entry === 'string') main = entry; + const ssr = buildOptions['ssr']; + if (isObject(ssr) && typeof ssr['entry'] === 'string') server = ssr['entry']; + } + } else if (options.project) { + throw new Error(`[pangular] No angular.json, so there is no project "${options.project}".`); + } + main ??= join(sourceRoot, 'main.ts'); + if (!server || !tree.exists(server)) { + server = [join(sourceRoot, 'server.ts'), join(root, 'server.ts')].find((path) => + tree.exists(path), + ); + } + const viteConfig = [root, ''] + .flatMap((dir) => VITE_CONFIGS.map((file) => join(dir, file))) + .find((path) => tree.exists(path)); + if (viteConfig) return { kind: 'vite', project, main, viteConfig }; + if (server) { + const text = readText(tree, server) ?? ''; + const express = /from\s+['"]express['"]/.test(text); + return { kind: express ? 'express' : 'server', project, main, server }; + } + return { kind: 'cli', project, main }; +} + +/** Whether the file writes `import { a }` (with spaces) rather than `import {a}`. */ +function spacedBraces(text: string): boolean { + return /import\s+\{\s/.test(text); +} + +function braces(text: string, inner: string): string { + return spacedBraces(text) ? `{ ${inner} }` : `{${inner}}`; +} + +/** Whether `name` is already declared or imported in `text`. */ +function declares(text: string, name: string): boolean { + return new RegExp( + `\\b(?:const|let|var|function|class)\\s+${name}\\b|import[^;]*\\b${name}\\b[^;]*from`, + ).test(text); +} + +/** + * Adds `initPangularHub()` and its middleware to an Express `server.ts`, right + * after `import express from 'express'` and `const app = express()`. + */ +export function wireExpressServer(text: string): EditResult { + if (text.includes(`${PACKAGE}/hub`)) return { status: 'present' }; + const imports = [ + ...text.matchAll(/^import\s+express\b[^;\n]*\bfrom\s+(['"])express\1;?[ \t]*$/gm), + ]; + const apps = [ + ...text.matchAll(/^([ \t]*)const\s+([A-Za-z_$][\w$]*)\s*=\s*express\(\s*\);?[ \t]*$/gm), + ]; + if (imports.length !== 1 || apps.length !== 1) return { status: 'unsure' }; + if (declares(text, 'devtools') || declares(text, 'initPangularHub')) return { status: 'unsure' }; + const [importLine] = imports; + const [appLine] = apps; + if (importLine?.index === undefined || appLine?.index === undefined) return { status: 'unsure' }; + if (importLine.index > appLine.index) return { status: 'unsure' }; + const quote = importLine[1] ?? "'"; + const indent = appLine[1] ?? ''; + const app = appLine[2]; + const semi = appLine[0].trimEnd().endsWith(';') ? ';' : ''; + const hubImport = `import ${braces(text, 'initPangularHub')} from ${quote}${PACKAGE}/hub${quote}${semi}`; + const hub = [ + `${indent}// Pangular Inspector. The hub turns itself off in production.`, + `${indent}const devtools = initPangularHub(${braces(text, 'ws: false')})${semi}`, + `${indent}${app}.use(devtools.nodeMiddleware)${semi}`, + ].join('\n'); + const appEnd = appLine.index + appLine[0].length; + const importEnd = importLine.index + importLine[0].length; + const out = + text.slice(0, importEnd) + + `\n${hubImport}` + + text.slice(importEnd, appEnd) + + `\n${hub}` + + text.slice(appEnd); + return { status: 'added', text: out }; +} + +const BOOTSTRAP = + /(bootstrapApplication\([^;]*?\)|platformBrowser(?:Dynamic)?\(\)\s*\.bootstrapModule\([^;]*?\))(\s*)(\.catch\(|;)/g; + +/** + * Loads the overlay after bootstrap in development builds, as the docs show: + * a `.then()` between the bootstrap call and its `.catch()`, or before the `;` + * of a bare bootstrap call. + */ +export function wireOverlay(text: string): EditResult { + if (text.includes(`${PACKAGE}/overlay`)) return { status: 'present' }; + const calls = [...text.matchAll(BOOTSTRAP)]; + if (calls.length !== 1) return { status: 'unsure' }; + const [call] = calls; + if (call?.index === undefined) return { status: 'unsure' }; + const [whole, bootstrap = '', space = '', next = ''] = call; + const lineStart = text.lastIndexOf('\n', call.index) + 1; + const base = /^[ \t]*/.exec(text.slice(lineStart))?.[0] ?? ''; + const quote = /from\s+(['"])@angular\//.exec(text)?.[1] ?? "'"; + const step = space.includes('\n') ? space.slice(space.lastIndexOf('\n') + 1) : `${base} `; + const then = [ + `.then(() => {`, + `${step} if (typeof ngDevMode === 'undefined' || ngDevMode) {`, + `${step} return import(${quote}${PACKAGE}/overlay${quote});`, + `${step} }`, + `${step} return undefined;`, + `${step}})`, + ].join('\n'); + const replacement = `${bootstrap}\n${step}${then}${next === ';' ? ';' : `\n${step}${next}`}`; + return { + status: 'added', + text: text.slice(0, call.index) + replacement + text.slice(call.index + whole.length), + }; +} + +/** The index just past the last top-level `import` statement, or 0. */ +function afterImports(text: string): number { + let end = 0; + for (const match of text.matchAll(/^import\b[^;]*?(?:from\s*)?(['"])[^'"\n]+\1;?[ \t]*$/gm)) { + if (match.index !== undefined) end = Math.max(end, match.index + match[0].length); + } + return end; +} + +/** The index of the `]` that closes the `[` at `open`, skipping strings and comments. */ +function closingBracket(text: string, open: number): number { + let depth = 0; + for (let i = open; i < text.length; i++) { + const char = text[i]; + if (char === '"' || char === "'" || char === '`') { + const end = text.indexOf(char, i + 1); + if (end < 0) return -1; + i = end; + } else if (char === '/' && text[i + 1] === '/') { + const end = text.indexOf('\n', i); + i = end < 0 ? text.length : end; + } else if (char === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + if (end < 0) return -1; + i = end + 1; + } else if (char === '[' || char === '(' || char === '{') { + depth++; + } else if (char === ']' || char === ')' || char === '}') { + depth--; + if (depth === 0) return char === ']' ? i : -1; + } + } + return -1; +} + +/** Adds the Vite plugin: its import after the last import, and `pangular()` at the end of `plugins`. */ +export function wireVitePlugin(text: string): EditResult { + if (text.includes(`${PACKAGE}/vite`)) return { status: 'present' }; + if (/\bpangular\b/.test(text)) return { status: 'unsure' }; + const lists = [...text.matchAll(/\bplugins\s*:\s*\[/g)]; + if (lists.length !== 1) return { status: 'unsure' }; + const [list] = lists; + if (list?.index === undefined) return { status: 'unsure' }; + const open = list.index + list[0].length - 1; + const close = closingBracket(text, open); + if (close < 0) return { status: 'unsure' }; + const inner = text.slice(open + 1, close); + const quote = /from\s+(['"])/.exec(text)?.[1] ?? "'"; + const semi = /from\s+['"][^'"]+['"];/.test(text) || !/from\s+['"]/.test(text) ? ';' : ''; + let entry: string; + if (!inner.trim()) { + entry = 'pangular()'; + } else if (inner.includes('\n')) { + const last = inner.replace(/\s+$/, ''); + const lines = last.split('\n'); + const indent = /^[ \t]*/.exec(lines[lines.length - 1] ?? '')?.[0] ?? ''; + const comma = last.endsWith(',') ? '' : ','; + const trailing = inner.slice(last.length); + entry = `${last}${comma}\n${indent}pangular(),${trailing}`; + } else { + const last = inner.replace(/\s+$/, ''); + entry = `${last.replace(/,$/, '')}, pangular()${inner.slice(last.length)}`; + } + const withPlugin = text.slice(0, open + 1) + entry + text.slice(close); + const at = afterImports(withPlugin); + const line = `import pangular from ${quote}${PACKAGE}/vite${quote}${semi}`; + const out = at + ? `${withPlugin.slice(0, at)}\n${line}${withPlugin.slice(at)}` + : `${line}\n${withPlugin}`; + return { status: 'added', text: out }; +} + +function apply( + tree: Tree, + context: SchematicContext, + path: string | undefined, + edit: (text: string) => EditResult, + what: string, + snippet: string, +): EditStatus { + const text = path ? readText(tree, path) : undefined; + const result = text === undefined ? { status: 'unsure' as const } : edit(text); + if (result.status === 'added' && path && result.text !== undefined) { + tree.overwrite(path, result.text); + context.logger.info(`[pangular] Added ${what} to ${path}.`); + } else if (result.status === 'present') { + context.logger.info(`[pangular] ${path} already has ${what}; left it as it is.`); + } else { + const where = path && text !== undefined ? path : `${path ?? 'the file'} (not found)`; + context.logger.warn( + `[pangular] Left ${where} alone: it doesn't look the way this schematic expects. Add ${what} by hand:\n\n${snippet}\n`, + ); + } + return result.status; +} + +/** The `ng-add` schematic. */ +export function ngAdd(options: NgAddOptions = {}): Rule { + return (tree: Tree, context: SchematicContext) => { + const setup = detectSetup(tree, options); + const label = setup.project ? ` for ${setup.project}` : ''; + switch (setup.kind) { + case 'vite': + context.logger.info( + `[pangular] Found ${setup.viteConfig}: adding the Vite plugin${label}.`, + ); + apply(tree, context, setup.viteConfig, wireVitePlugin, 'the Vite plugin', VITE_SNIPPET); + context.logger.info( + `[pangular] The plugin loads the overlay on the pages the dev server serves. See ${DOCS}/vite`, + ); + break; + case 'express': + context.logger.info(`[pangular] Found an Express server in ${setup.server}${label}.`); + apply(tree, context, setup.server, wireExpressServer, 'the devtools hub', SERVER_SNIPPET); + apply(tree, context, setup.main, wireOverlay, 'the overlay', OVERLAY_SNIPPET); + context.logger.info( + `[pangular] Start the app in development mode and open the Pangular Inspector button. See ${DOCS}/express`, + ); + break; + case 'server': + context.logger.warn( + `[pangular] ${setup.server} is not an Express server, so it was left alone. Mount the devtools there as ${DOCS}/other-servers shows, or run the standalone CLI.`, + ); + apply(tree, context, setup.main, wireOverlay, 'the overlay', OVERLAY_SNIPPET); + break; + case 'cli': + apply(tree, context, setup.main, wireOverlay, 'the overlay', OVERLAY_SNIPPET); + context.logger.info(`[pangular] ${CLI_STEPS.join('\n')}`); + break; + } + return tree; + }; +} diff --git a/packages/devtools/src/schematics/ng-add/schema.json b/packages/devtools/src/schematics/ng-add/schema.json new file mode 100644 index 00000000..be2a913c --- /dev/null +++ b/packages/devtools/src/schematics/ng-add/schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "PangularInspectorNgAdd", + "title": "Pangular Inspector ng-add options", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "The project to set up. Defaults to the workspace's only application, or its first one.", + "$default": { + "$source": "projectName" + } + } + }, + "additionalProperties": false +} diff --git a/packages/devtools/tsdown.config.ts b/packages/devtools/tsdown.config.ts index aa65438d..e4037b3e 100644 --- a/packages/devtools/tsdown.config.ts +++ b/packages/devtools/tsdown.config.ts @@ -59,4 +59,23 @@ export const nodeConfig = { }, } satisfies UserConfig; -export default defineConfig([browserConfig, nodeConfig]); +/** + * The `ng add` schematic, as CommonJS: the Angular CLI loads schematic + * factories with `require()`. Its JSON files are copied next to it, and + * `schematics` in `package.json` points at the collection. + */ +export const schematicsConfig = { + name: 'schematics', + entry: { 'schematics/ng-add/index': 'src/schematics/ng-add/index.ts' }, + format: 'cjs', + platform: 'node', + dts: false, + external: [/^@angular-devkit\//], + outExtensions: () => ({ js: '.cjs' }), + copy: (resolved) => [ + { from: 'src/schematics/collection.json', to: `${resolved.outDir}/schematics` }, + { from: 'src/schematics/ng-add/schema.json', to: `${resolved.outDir}/schematics/ng-add` }, + ], +} satisfies UserConfig; + +export default defineConfig([browserConfig, nodeConfig, schematicsConfig]); From 419b430dea6fb9c63f0780812c5da09cf8fb8829 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 18:54:05 +0000 Subject: [PATCH 03/13] docs: document ng add, LAN address injection and device network settings Add ng add to the installation and quick start pages, lanAddress and --lan-address to the Vite, CLI, configuration and security pages, and the injected LAN address to the Capacitor and NativeScript guides. The native guides also show the Android network security config and the iOS App Transport Security and local network settings a device needs to reach the devtools over the LAN. Refs #373 --- apps/docs/src/content/getting-started/cli.md | 21 +++++ .../content/getting-started/configuration.md | 10 +++ .../content/getting-started/installation.md | 21 +++++ .../content/getting-started/nativescript.md | 14 ++-- .../content/getting-started/quick-start.md | 2 + apps/docs/src/content/getting-started/vite.md | 40 +++++++++- apps/docs/src/content/guides/capacitor.md | 59 ++++++++++++++ apps/docs/src/content/guides/nativescript.md | 76 +++++++++++++++++-- apps/docs/src/content/security.md | 4 +- packages/devtools/CHANGELOG.md | 4 + 10 files changed, 235 insertions(+), 16 deletions(-) diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md index e7871d0a..2cca7414 100644 --- a/apps/docs/src/content/getting-started/cli.md +++ b/apps/docs/src/content/getting-started/cli.md @@ -126,11 +126,32 @@ When the server listens on your network (`--host 0.0.0.0` or a network address), | `--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, and the server accepts requests from that origin. 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. | +| `--lan-address [address]` | Print the server's URL on your network and the define that injects it into a development build of a phone app. Off by default. Without a value it picks this machine's first private IPv4 address. Needs `--host` on the network. See [Inject the LAN address](#inject-the-lan-address). | 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. +### Inject the LAN address + +A phone app (Capacitor, NativeScript) needs the server's address on your network. The CLI doesn't build your app, so `--lan-address` prints the URL and the define to pass to your app's build. Start the server on the network with it: + +```bash +npx pangular dev --host 0.0.0.0 --lan-address +``` + +It prints lines like these after the startup lines: + +```text + LAN address for a device build: http://192.168.1.20:9999/ + Inject it as __PANGULAR_LAN_URL__, for example in an Angular CLI app: + ng build --configuration development --define "__PANGULAR_LAN_URL__='http://192.168.1.20:9999/'" +``` + +The `--define` flag needs the `application` builder of Angular 17.2 or later. The NativeScript overlay connects to the injected URL when you pass no `baseURL`, and `lanDevtoolsURL()` from `@pangular-inspector/devtools/overlay-manual` reads it in a Capacitor app. See [Set up Capacitor](../guides/capacitor.md#use-the-injected-lan-address) and [Set up NativeScript](../guides/nativescript.md#use-the-injected-lan-address). + +`--lan-address` only prints. It doesn't change the host the server binds to, the origin check or the one-time code. With `--host localhost`, the default, it prints a warning and no address, since a phone can't reach that server. `--lan-address 192.168.1.20` names the address, for a machine on several networks. A loopback address is refused. + ### What it shows When no app is connected, the tabs show what your source declares. An [Angular Native](./angular-native.md) app connects through the **Angular Native apps** URL above and shows live data there: diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md index b9ce5f84..f8420dc9 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -265,6 +265,16 @@ The server plugins themselves go in `serverPlugins`, an option of the Express hu It is off by default because it starts processes on your machine. A value other than `true` or `false` prints a warning and keeps it off. +### LAN address + +`lanAddress` is an option of the Vite plugin only, and `--lan-address` the matching flag of `pangular dev`. Like `terminals`, it is not part of `PangularConfig`, so `createPangular()` and the CLI config file don't take it. + +| Option | Default | What it does | +| ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lanAddress` | `false` | `true` injects the dev server's devtools URL on this machine's first private IPv4 address as `__PANGULAR_LAN_URL__`. An address such as `'192.168.1.20'` uses that one. Needs `allowLan` in effect. See [Inject the LAN address](./vite.md#inject-the-lan-address). | + +A loopback address, `0.0.0.0` or any other value prints a warning and keeps it off. The CLI flag only prints the URL and the define for your build. See [Inject the LAN address](./cli.md#inject-the-lan-address). + ### Lint rules | Option | Default | What it does | diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index d8410af2..d561e407 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -122,6 +122,27 @@ bun add -D @pangular-inspector/devtools MCP agent support (`@devframes/agentic`) and Devframe itself are included. You don't install them separately. +### Set up with ng add + +In an Angular CLI workspace, `ng add` installs the package and wires it in for you: + +```bash +ng add @pangular-inspector/devtools +``` + +It looks at the project and picks the setup this page describes for it: + +| The project has | What `ng add` does | +| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A `vite.config.ts` (Analog) | Adds `pangular()` to the `plugins` of the [Vite plugin](./vite.md). The plugin loads the overlay, so `main.ts` stays as it is. | +| An Express `server.ts` (Angular SSR) | Adds `initPangularHub({ws: false})` and `app.use(devtools.nodeMiddleware)` right after `const app = express()`, as in [Angular CLI and Express](./express.md). Loads the overlay in `main.ts`. | +| Neither | Loads the overlay in `main.ts`, and prints the steps to run the standalone CLI behind the `ng serve` proxy, as in [Client-only Angular CLI app](#client-only-angular-cli-app). | +| A `server.ts` on another server, such as Hono | Loads the overlay in `main.ts`, leaves `server.ts` alone and points at [Hono, h3 and Fastify](./other-servers.md). | + +The overlay goes after bootstrap, behind the `ngDevMode` check shown in [Browser part](#browser-part). Pass `--project ` in a workspace with several applications. Without it, `ng add` takes the only application, or the first one. + +`ng add` only inserts lines next to code it recognizes, such as `import express from 'express'` and `const app = express()`. When a file looks different, it leaves the file alone and prints the snippet to add by hand. Running it again changes nothing: it skips files that already import the devtools. It saves the package as a dependency, which every setup works with. See [Dependency or dev dependency](#dependency-or-dev-dependency) to move it. + ### Dependency or dev dependency | Setup | Install it as | diff --git a/apps/docs/src/content/getting-started/nativescript.md b/apps/docs/src/content/getting-started/nativescript.md index 15fa2502..bab9b231 100644 --- a/apps/docs/src/content/getting-started/nativescript.md +++ b/apps/docs/src/content/getting-started/nativescript.md @@ -76,11 +76,11 @@ The overlay marks its component reports with `platform: 'nativescript'`, the URL `initNativeScriptOverlay()` takes an optional object: -| Option | Default | What it does | -| ------------ | ------------------------------- | ----------------------------------------------------------------------------- | -| `baseURL` | `defaultDevtoolsBaseURL()` | Absolute URL of the devtools server. | -| `intervalMs` | The server's `limits.refreshMs` | How often the app reports, in milliseconds. | -| `retryMs` | `5000` | How long to wait before connecting again after a failure or a dropped socket. | +| Option | Default | What it does | +| ------------ | --------------------------------------------------- | ----------------------------------------------------------------------------- | +| `baseURL` | `lanDevtoolsURL()`, then `defaultDevtoolsBaseURL()` | Absolute URL of the devtools server. | +| `intervalMs` | The server's `limits.refreshMs` | How often the app reports, in milliseconds. | +| `retryMs` | `5000` | How long to wait before connecting again after a failure or a dropped socket. | `defaultDevtoolsBaseURL(port = 9999)` is exported from the same entry point and picks the address by platform: @@ -89,7 +89,9 @@ The overlay marks its component reports with `platform: 'nativescript'`, the URL | iOS | `http://localhost:9999/` | | Android | `http://10.0.2.2:9999/` | -`10.0.2.2` is how the Android emulator reaches its host. The devtools server answers requests sent to it (and to `10.0.3.2`, Genymotion's address for the host) from the emulator without any option, as it does `localhost`. A physical device needs your machine's LAN address as `baseURL`. +`10.0.2.2` is how the Android emulator reaches its host. The devtools server answers requests sent to it (and to `10.0.3.2`, Genymotion's address for the host) from the emulator without any option, as it does `localhost`. A physical device needs your machine's LAN address, as `baseURL` or injected by the build. + +`lanDevtoolsURL()`, exported from the same entry point, returns the URL a development build defined as `__PANGULAR_LAN_URL__`, or `undefined`. When it returns one, the overlay connects there instead of to `defaultDevtoolsBaseURL()`. See [Use the injected LAN address](../guides/nativescript.md#use-the-injected-lan-address) and [Allow plain HTTP to your machine](../guides/nativescript.md#allow-plain-http-to-your-machine). `initNativeScriptOverlay()` returns a function that stops the overlay and tells the server to forget every report the app sent. diff --git a/apps/docs/src/content/getting-started/quick-start.md b/apps/docs/src/content/getting-started/quick-start.md index f92dc8d4..7c0c3a2b 100644 --- a/apps/docs/src/content/getting-started/quick-start.md +++ b/apps/docs/src/content/getting-started/quick-start.md @@ -39,6 +39,8 @@ yarn add @pangular-inspector/devtools bun add @pangular-inspector/devtools ``` +To install the package and make the edits of the next two steps in one command, run `ng add @pangular-inspector/devtools`. See [Set up with ng add](./installation.md#set-up-with-ng-add). Then skip to [Open the panel](#open-the-panel). + ## Mount the hub Open `src/server.ts`. Import `initPangularHub` and mount its middleware right after `const app = express()`, before the generated `express.static` and Angular handlers: diff --git a/apps/docs/src/content/getting-started/vite.md b/apps/docs/src/content/getting-started/vite.md index 194c3e30..29d5614a 100644 --- a/apps/docs/src/content/getting-started/vite.md +++ b/apps/docs/src/content/getting-started/vite.md @@ -100,6 +100,7 @@ pangular({ | `allowedOrigins` | none | Extra exact origins allowed to reach the devtools, for example a tunnel. | | `auth` | on | Whether the devtools ask for the one-time code, and its pairing tokens. | | `allowLan` | `false` | Answer other machines, such as a phone on Wi-Fi, from listed origins. | +| `lanAddress` | `false` | Inject the dev server's LAN address into the app, for a phone app. | | `overlay` | `true` | Add the overlay to the pages the dev server serves. | | `terminals` | `false` | Add a dock that runs the build and test commands. See [Terminals](./terminals.md). | @@ -210,6 +211,43 @@ Start Vite on every interface too, with `server.host: true`. A request from anot With allowLan, any device on the network can send requests that claim a listed origin. Only the one-time code, or a pairing token, keeps it from reading your app's data. Use it on a trusted network, and turn it off when you're done. +### Inject the LAN address + +A phone app built from the same Vite config, such as a bundled [Capacitor](../guides/capacitor.md#use-the-injected-lan-address) build, has no `localhost` to find the devtools on. `lanAddress` injects the dev server's devtools URL on your network into the app as the global constant `__PANGULAR_LAN_URL__`, for example `http://192.168.1.20:5173/__devframes/pangular/`. `lanDevtoolsURL()` from `@pangular-inspector/devtools/overlay-manual` reads it. + +```ts +// vite.config.ts +import analog from '@analogjs/platform'; +import pangular from '@pangular-inspector/devtools/vite'; +import {defineConfig} from 'vite'; + +export default defineConfig({ + server: {host: true, port: 5173, strictPort: true}, + plugins: [ + analog(), + pangular({ + allowLan: true, + allowedOrigins: ['capacitor://localhost'], + lanAddress: true, + }), + ], +}); +``` + +| `lanAddress` | What it injects | +| ---------------- | ----------------------------------------------------------------------------------------------- | +| `false` or unset | Nothing. The default. | +| `true` | The first private IPv4 address of this machine (`10.x`, `172.16.x` to `172.31.x`, `192.168.x`). | +| `'192.168.1.20'` | That address. Use it when the machine has several networks. | + +The plugin injects an address only where the devtools already answer it, and changes nothing about who may connect: + +- `allowLan` must be in effect: `allowLan: true`, the one-time code on, and a network origin in `allowedOrigins`. Otherwise the plugin prints `Not injecting the LAN address` with the reason. +- `server.host` must put the dev server on the network, such as `true`. Set it in the config file, since a `vite build` doesn't see the `--host` flag of the dev server you run next to it. +- The URL carries `server.port`, or `5173`. If Vite moves to another port because that one is taken, the plugin warns. `server.strictPort: true` makes Vite stop instead. + +The constant reaches the pages the dev server serves and development builds, such as `vite build --mode development`. A production build, the default mode of `vite build`, gets nothing. The dev server also prints the URL as **Pangular Inspector (LAN)**. + ## Angular CLI apps @@ -220,7 +258,7 @@ Start Vite on every interface too, with `server.host: true`. A request from anot - No. It applies to the dev server only, so the overlay script it adds is not part of a build. + No. It applies to the dev server only, so the overlay script it adds is not part of a build. With lanAddress, a build in a mode other than production also gets the __PANGULAR_LAN_URL__ constant, and nothing else. The request did not come from your machine, or its origin or the host it was sent to is not trusted. The terminal names a refused origin or host. Open the app on localhost, list your hostname in server.allowedHosts, or add the origin to allowedOrigins. diff --git a/apps/docs/src/content/guides/capacitor.md b/apps/docs/src/content/guides/capacitor.md index 16ddab80..2d46246d 100644 --- a/apps/docs/src/content/guides/capacitor.md +++ b/apps/docs/src/content/guides/capacitor.md @@ -139,6 +139,37 @@ The short forms above carry no `configs`, so the overlay uses the default inspec The standalone CLI and the Express hub accept `capacitor://localhost` and `https://localhost` without an `allowedOrigins` entry, because their host is `localhost`. The Vite plugin accepts `https://localhost` the same way, and `capacitor://localhost` once it is in `allowedOrigins`. A phone on Wi-Fi also needs `allowLan: true`, on the Express hub as on the Vite plugin. +### Use the injected LAN address + +Instead of writing your machine's address into `main.ts`, let the development build inject it. The build defines `__PANGULAR_LAN_URL__`, and `lanDevtoolsURL()` returns it, or `undefined` in a build that injected nothing: + +```ts {9,11} +// src/main.ts +import {bootstrapApplication} from '@angular/platform-browser'; +import {App} from './app/app'; +import {appConfig} from './app/app.config'; + +bootstrapApplication(App, appConfig) + .then(async () => { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + const {initOverlay, lanDevtoolsURL} = + await import('@pangular-inspector/devtools/overlay-manual'); + await initOverlay({ + baseURL: lanDevtoolsURL() ?? 'http://localhost:9999/', + connectionMeta: {backend: 'websocket', websocket: {path: '__ws'}}, + }); + } + }) + .catch((err) => console.error(err)); +``` + +| Server | How the build gets the address | +| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Standalone CLI | `npx pangular dev --host 0.0.0.0 --lan-address` prints an `ng build --configuration development --define "__PANGULAR_LAN_URL__='http://192.168.1.20:9999/'"` command. Run it, then `npx cap sync`. See [Inject the LAN address](../getting-started/cli.md#inject-the-lan-address). | +| Vite plugin | `lanAddress: true`, with `allowLan`, `capacitor://localhost` in `allowedOrigins` and `server.host: true`. `vite build --mode development` injects the hub URL. See [Inject the LAN address](../getting-started/vite.md#inject-the-lan-address). | + +With the Vite plugin, pass its `connectionMeta` from the table in [The connection info](#the-connection-info) instead. The address is fixed when you build. If your machine gets another address on the network, build and sync again. Neither option changes who may connect: the device still needs the one-time code or a pairing token. + ### Let the app reach your machine The page is `https://localhost` on Android and the server is plain HTTP, so the Android WebView can block the connection as mixed content. Android also blocks cleartext traffic, even to `localhost` through `adb reverse`, so without `server.cleartext` the connection fails with `net::ERR_CLEARTEXT_NOT_PERMITTED`. Allow both in a development build: @@ -162,6 +193,30 @@ If your config already has a `server` or `android` block, add the keys to it ins With `adb reverse`, the server is at `http://localhost`, which Chromium may not count as mixed content, but cleartext is still blocked, so `server.cleartext` is required. Keep `allowMixedContent` too, for a server on a network address. Capacitor applies both on `npx cap sync`, which `npx cap run` also runs, so set the flag there: `CAPACITOR_DEVTOOLS=1 npx cap run android`. `server.cleartext` sets `android:usesCleartextTraffic` in the manifest of the `capacitor-cordova-android-plugins` module, which Android merges into the app. Setting `server.androidScheme` to `'http'` also avoids mixed content, but it changes the app's origin, so the WebView no longer sees the data the app stored under `https://localhost`. +To allow cleartext only to your dev server instead of to every host, use a network security config in the Android debug build. Save it as `android/app/src/debug/res/xml/network_security_config.xml`, with your machine's address: + +```html + + + + + 192.168.1.20 + localhost + + +``` + +Point the debug manifest at it. Android merges `android/app/src/debug/AndroidManifest.xml` into debug builds only, so release builds keep the default: + +```html + + + + +``` + +On Android 7 and later, a network security config replaces `android:usesCleartextTraffic`, which `server.cleartext` sets, so use one of the two. Keep `android.allowMixedContent` either way. When your machine's address changes, update the `domain` entry. + On iOS, App Transport Security can block plain HTTP to a network address. If it does, allow local networking in a development build's `ios/App/App/Info.plist`: ```html @@ -171,8 +226,12 @@ On iOS, App Transport Security can block plain HTTP to a network address. If it NSAllowsLocalNetworking +NSLocalNetworkUsageDescription +Connects to Pangular Inspector on your computer during development. ``` +`NSAllowsLocalNetworking` exempts local network addresses from App Transport Security and keeps it on for every other host. On iOS 14 and later, the first connection to an address on your network can show a local network permission prompt, with the `NSLocalNetworkUsageDescription` text. If the app was denied, turn it back on in **Settings** > **Privacy & Security** > **Local Network**. + ### Run the server where the device reaches it The simulator, the emulator and `adb reverse` reach your machine's `localhost`. A phone on Wi-Fi needs the server on your network address: diff --git a/apps/docs/src/content/guides/nativescript.md b/apps/docs/src/content/guides/nativescript.md index ab172b5c..bdfdd923 100644 --- a/apps/docs/src/content/guides/nativescript.md +++ b/apps/docs/src/content/guides/nativescript.md @@ -87,13 +87,73 @@ Call it before `runNativeScriptAngularApp()`. The injector inspector relies on * ### Where the overlay connects -| Target | Default URL | -| ---------------- | ------------------------ | -| iOS simulator | `http://localhost:9999/` | -| Android emulator | `http://10.0.2.2:9999/` | -| Physical device | Pass `{ baseURL }` | +| Target | Default URL | +| ---------------- | --------------------------------------------------------------------------------------- | +| iOS simulator | `http://localhost:9999/` | +| Android emulator | `http://10.0.2.2:9999/` | +| Physical device | Pass `{ baseURL }`, or [inject the LAN address](#use-the-injected-lan-address) at build | + +A build that defines `__PANGULAR_LAN_URL__` takes that URL over the defaults above. `baseURL` still wins over both. + +### Use the injected LAN address + +For a physical device, pass the address of your machine as `baseURL`, or let the build inject it, so `main.ts` keeps no address. Start the server with `--lan-address` (see [Step 4](#step-4-run-the-devtools-server)) to print the URL, then define it in `webpack.config.js` from an environment variable: + +```js {6-13} +// webpack.config.js +const webpack = require('@nativescript/webpack'); + +module.exports = (env) => { + webpack.init(env); + const lanUrl = process.env['PANGULAR_LAN_URL']; + if (lanUrl && !env.production) { + webpack.chainWebpack((config) => { + config.plugin('DefinePlugin').tap((args) => { + Object.assign(args[0], {__PANGULAR_LAN_URL__: JSON.stringify(lanUrl)}); + return args; + }); + }); + } + return webpack.resolveConfig(); +}; +``` + +Run the app with the URL the server printed, for example `PANGULAR_LAN_URL=http://192.168.1.20:9999/ ns run android`. `initNativeScriptOverlay()` without `baseURL` then connects there. A release build (`--env.production`) gets no address. + +### Allow plain HTTP to your machine + +The devtools server speaks plain `http`. Android 9 and later block cleartext traffic, and iOS App Transport Security can block it, so allow it to your machine in development builds. + +On Android, list the hosts the app may reach over cleartext in `App_Resources/Android/src/main/res/xml/network_security_config.xml`. The emulator reaches your machine at `10.0.2.2`, and a device at your machine's address: + +```html + + + + + localhost + 10.0.2.2 + 192.168.1.20 + + +``` + +Then set `android:networkSecurityConfig="@xml/network_security_config"` on the `` element of `App_Resources/Android/src/main/AndroidManifest.xml`. If the manifest already sets `android:usesCleartextTraffic="true"`, every host is allowed already. `examples/nativescript` ships a network security config. Keep these entries out of release builds. + +On iOS, add the local networking exception to `App_Resources/iOS/Info.plist`: + +```html + +NSAppTransportSecurity + + NSAllowsLocalNetworking + + +NSLocalNetworkUsageDescription +Connects to Pangular Inspector on your computer during development. +``` -For a physical device, pass the address of your machine as `baseURL`. Android 9 and later and iOS App Transport Security block plain `http` to a LAN address, so allow it for development builds: set `android:usesCleartextTraffic="true"` in `AndroidManifest.xml`, and add an `NSAppTransportSecurity` exception for the address in `Info.plist`. +On iOS 14 and later, the first connection to an address on your network shows a local network permission prompt with that text. If the app was denied, turn it back on in **Settings** > **Privacy & Security** > **Local Network**. ## Step 4: Run the devtools server @@ -114,10 +174,10 @@ The server listens on `localhost` only, which the iOS simulator and the Android A physical device reaches your machine over the network, so the server has to listen on an interface the device can reach: ```bash -npx @pangular-inspector/devtools dev --host 192.168.1.20 --allowed-origin http://192.168.1.20:9999 --no-auth +npx @pangular-inspector/devtools dev --host 192.168.1.20 --allowed-origin http://192.168.1.20:9999 --no-auth --lan-address ``` -The server then listens on that address only, not on `localhost`, so open the panel at `http://192.168.1.20:9999/`. `--allowed-origin` lets the panel's own origin through there. Use your machine's address and the server's port. +The server then listens on that address only, not on `localhost`, so open the panel at `http://192.168.1.20:9999/`. `--allowed-origin` lets the panel's own origin through there. `--lan-address` prints the URL to [inject into the app](#use-the-injected-lan-address). Use your machine's address and the server's port. With --host and --no-auth, every host that can reach that address can call the devtools RPC and MCP endpoints without a code. Use it on a trusted network only. Binding to the one address the device uses, as above, keeps the server off your other interfaces, which 0.0.0.0 would not. diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md index 08096416..32cfed21 100644 --- a/apps/docs/src/content/security.md +++ b/apps/docs/src/content/security.md @@ -53,6 +53,8 @@ Other requests get `403` with the message "Pangular Inspector only answers reque With `allowLan: true`, the plugin also answers other machines, such as a phone on Wi-Fi, whose origin is in `allowedOrigins`. It needs the one-time code on, and ignores `allowLan` with `auth: false`. See [Other devices on your network](./getting-started/vite.md#other-devices-on-your-network). +`lanAddress` injects the dev server's address on your network into the app's development build, for a phone app. It is off by default and grants no access: the plugin injects an address only when `allowLan` is in effect and `server.host` puts Vite on the network, so the gate, the one-time code and `allowedOrigins` decide who connects, as without it. A production build gets no address. See [Inject the LAN address](./getting-started/vite.md#inject-the-lan-address). + If you open the dev server through another hostname that points to your machine (for example `myapp.test`), list it in Vite's `server.allowedHosts` and the devtools trust it too, as `Host` and as origin. Add other origins with `allowedOrigins`: ```ts {7-8} @@ -137,7 +139,7 @@ A list keeps loopback origins and the Pangular Inspector extension. So does a cu ### Standalone CLI -The CLI server binds to `localhost` and asks for a one-time code. `--host` changes the bind address and `--no-auth` turns the code off. `--allowed-origin` adds an origin to the loopback origins it accepts, and `--auth-token` adds [pairing tokens](#pairing-tokens). See [Standalone CLI](./getting-started/cli.md). +The CLI server binds to `localhost` and asks for a one-time code. `--host` changes the bind address and `--no-auth` turns the code off. `--allowed-origin` adds an origin to the loopback origins it accepts, and `--auth-token` adds [pairing tokens](#pairing-tokens). `--lan-address` only prints the server's network URL for a device build, and changes none of these. See [Standalone CLI](./getting-started/cli.md). It runs the same request gate as the Vite plugin and the Express hub in front of every route, the panel and `__connection.json` included: a foreign `Origin`, `Origin: null`, a `Host` that is not this machine and a cross-site request without `Origin` get a `403`. With `--host` set to a network address, other machines get through when their `Origin` is allowed or they send none, such as a device app, and their `Host` is an IP address, a `.local` name or an allowed host. diff --git a/packages/devtools/CHANGELOG.md b/packages/devtools/CHANGELOG.md index e53ef448..41517d3f 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -133,6 +133,8 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - A splitter for the docked popup, pop-out into its own window, and the `popup.hotkey` option. - Item links (`#tab=&id=&page=`) and the `open-in-panel` agent tool. - A **Terminals** dock that runs the project's build and test commands (opt in with `terminals: true`). +- `ng add @pangular-inspector/devtools` wires the devtools in: the Vite plugin for a Vite or Analog app, `initPangularHub()` in an Express `server.ts`, or the standalone CLI steps otherwise, and the overlay in `main.ts` behind `ngDevMode`. It leaves files it does not recognize alone and prints the snippet, and a second run changes nothing (#373). +- The Vite plugin's `lanAddress` option (off by default) injects the dev server's LAN address as `__PANGULAR_LAN_URL__` into the app and into development builds, so a Capacitor or NativeScript app on a phone finds the devtools. It needs `allowLan` in effect and `server.host` on the network. `lanDevtoolsURL()` reads it, and the NativeScript overlay connects there when no `baseURL` is given (#373). - Hub messages and toasts, a dock group with badges, and an **All tools** dock. - New entry points: `/overlay-manual` (starts nothing on import), `/plugin`, `/capacitor-hook`, `/store-hook`, `/startup-hook`, `/ssr` and `/cd-data`. - New config options: `agent.browserTools`, `limits.capacitorCalls`, `limits.idleAfterMs`, `popup` and `plugins`. @@ -198,6 +200,7 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - `pangular lint` runs the source lints and prints JSON or SARIF (#418). - `pangular skills` lists or installs the bundled agent skills. - `pangular dev` takes `--proxy-origin`, `--allowed-origin` and `--auth-token`. +- `pangular dev --lan-address` prints the server's LAN URL and the `--define` that injects it into a device build. It needs `--host` on the network and changes no access setting (#373). - `pangular build` refuses an `--outDir` that would delete files that are not a previous report, unless `--force` is passed. - The source scan reads Nx projects. @@ -264,6 +267,7 @@ What remains: `/config` exports `DEFAULT_POPUP_HOTKEY`, `PANGULAR_ACTIONS`, `PAN - A quick start, and a panel screenshot on every inspector page. - Pages for the command palette, settings, terminals, the popup and hub, configuration, other servers (Hono, h3, Fastify), Capacitor, the timeline, errors, audit, TanStack Query, agent skills, browser agents and writing a plugin. - A roadmap page. +- The Capacitor and NativeScript guides show the Android network security config and the iOS App Transport Security and local network settings a device needs to reach the devtools over the LAN (#373). - The Express and other servers pages say which port the WebSocket side-car uses (the first free one from 9777), how to pin it with `ws: {port}`, and how to serve the socket on the app's own server with `attach(server)` (#426). - A **Renamed tools** table in the agent tools page, and the public API and the supported versions in the publishing guide. - Corrected guides and samples from the readiness review: the Vite auth default in the Analog guide and the glossary, JSON samples without a path comment, no separate devframe install in the package README, and `ws: false` for a panel-only devframe host. From 0b25202c1073eb5df2c8d6e3095fa86562a1782f Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:15:58 +0000 Subject: [PATCH 04/13] fix: read angular.json with comments and trailing commas in ng add The schematic called JSON.parse on angular.json, which the Angular CLI lets hold comments and trailing commas, so ng add crashed with a bare SyntaxError on such a workspace. It now strips comments and trailing commas outside strings before parsing, without a runtime import. When the file still is not JSON, it changes nothing and prints the overlay snippet, and with --project it throws an error that names angular.json. Tests cover both cases. --- .../src/__tests__/schematics-ng-add.test.ts | 38 +++++++++++++ .../devtools/src/schematics/ng-add/index.ts | 53 +++++++++++++++++-- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/packages/devtools/src/__tests__/schematics-ng-add.test.ts b/packages/devtools/src/__tests__/schematics-ng-add.test.ts index d59f491b..2237672e 100644 --- a/packages/devtools/src/__tests__/schematics-ng-add.test.ts +++ b/packages/devtools/src/__tests__/schematics-ng-add.test.ts @@ -7,6 +7,8 @@ import { build } from 'tsdown'; import { afterAll, describe, expect, it, vi } from 'vitest'; import { detectSetup, + OVERLAY_SNIPPET, + parseWorkspaceJson, ngAdd, wireExpressServer, wireOverlay, @@ -161,6 +163,42 @@ describe('detectSetup', () => { expect(() => detectSetup(tree, { project: 'nope' })).toThrow(/No project "nope"/); }); + it('reads an angular.json with comments and trailing commas, as the Angular CLI does', () => { + const tree = workspace({ 'src/main.ts': MAIN }, null); + tree.create( + 'angular.json', + `{ + // The CLI allows comments. + "projects": { + /* and block comments */ + "shop": { + "projectType": "application", + "root": "", + "sourceRoot": "src", // trailing comma next + "architect": {"build": {"options": {"browser": "src/main.ts", "baseHref": "http://x//y"}}}, + }, + }, +}`, + ); + expect(detectSetup(tree)).toEqual( + expect.objectContaining({ kind: 'cli', project: 'shop', main: 'src/main.ts' }), + ); + expect(parseWorkspaceJson('{"url": "http://a//b", }')).toEqual({ url: 'http://a//b' }); + }); + + it('changes nothing and prints the snippet when angular.json cannot be read', () => { + const tree = workspace({ 'src/main.ts': MAIN }, null); + tree.create('angular.json', '{ "projects": '); + expect(detectSetup(tree)).toEqual({ kind: 'unreadable' }); + const { logs } = run(tree); + expect(logs()).toMatch(/Could not read angular\.json/); + expect(logs()).toContain(OVERLAY_SNIPPET); + expect(tree.readText('src/main.ts')).toBe(MAIN); + expect(() => detectSetup(tree, { project: 'shop' })).toThrow( + /Could not read angular\.json.*project "shop"/, + ); + }); + it('works without angular.json', () => { expect(detectSetup(workspace({ 'vite.config.ts': VITE }, null))).toEqual( expect.objectContaining({ kind: 'vite', main: 'src/main.ts' }), diff --git a/packages/devtools/src/schematics/ng-add/index.ts b/packages/devtools/src/schematics/ng-add/index.ts index 4f872769..2e5827af 100644 --- a/packages/devtools/src/schematics/ng-add/index.ts +++ b/packages/devtools/src/schematics/ng-add/index.ts @@ -66,7 +66,8 @@ export const CLI_STEPS = [ /** The setup found in the workspace and the files the edits go to. */ export interface Setup { - kind: 'vite' | 'express' | 'server' | 'cli'; + /** `unreadable`: angular.json is there but could not be parsed, so nothing is changed. */ + kind: 'vite' | 'express' | 'server' | 'cli' | 'unreadable'; project?: string; main?: string; server?: string; @@ -87,6 +88,38 @@ function readText(tree: Tree, path: string): string | undefined { } type Json = Record; + +/** + * Parses JSON the way the Angular CLI reads angular.json: `//` and block + * comments and trailing commas are allowed. Returns undefined when the text + * still is not JSON. Strings are copied as they are, so a `//` inside a URL stays. + */ +export function parseWorkspaceJson(text: string): unknown { + let out = ''; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + let j = i + 1; + while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1; + out += text.slice(i, j + 1); + i = j; + } else if (ch === '/' && text[i + 1] === '/') { + while (i < text.length && text[i] !== '\n') i++; + out += '\n'; + } else if (ch === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2); + if (end < 0) return undefined; + i = end + 1; + } else { + out += ch; + } + } + try { + return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')); + } catch { + return undefined; + } +} const isObject = (value: unknown): value is Json => !!value && typeof value === 'object' && !Array.isArray(value); @@ -116,8 +149,17 @@ export function detectSetup(tree: Tree, options: NgAddOptions = {}): Setup { let server: string | undefined; let project: string | undefined; const workspaceText = readText(tree, 'angular.json'); - if (workspaceText) { - const picked = pickProject(JSON.parse(workspaceText) as Json, options.project); + const workspace = workspaceText ? parseWorkspaceJson(workspaceText) : undefined; + if (workspaceText && !isObject(workspace)) { + if (options.project) { + throw new Error( + `[pangular] Could not read angular.json: it is not valid JSON, even with comments and trailing commas allowed. Fix it, then run ng add again for project "${options.project}".`, + ); + } + return { kind: 'unreadable' }; + } + if (isObject(workspace)) { + const picked = pickProject(workspace, options.project); if (picked) { project = picked.name; root = typeof picked.project['root'] === 'string' ? picked.project['root'] : ''; @@ -372,6 +414,11 @@ export function ngAdd(options: NgAddOptions = {}): Rule { apply(tree, context, setup.main, wireOverlay, 'the overlay', OVERLAY_SNIPPET); context.logger.info(`[pangular] ${CLI_STEPS.join('\n')}`); break; + case 'unreadable': + context.logger.warn( + `[pangular] Could not read angular.json: it is not valid JSON, even with comments and trailing commas allowed. Nothing was changed. Add the overlay to main.ts by hand:\n\n${OVERLAY_SNIPPET}\n\nSee ${DOCS}/installation`, + ); + break; } return tree; }; From 34d542b544e2216e062f7827f9976fdfd9dc1400 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:16:00 +0000 Subject: [PATCH 05/13] docs: say how ng add reads angular.json --- apps/docs/src/content/getting-started/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/src/content/getting-started/installation.md b/apps/docs/src/content/getting-started/installation.md index d561e407..fa8bffbf 100644 --- a/apps/docs/src/content/getting-started/installation.md +++ b/apps/docs/src/content/getting-started/installation.md @@ -141,7 +141,7 @@ It looks at the project and picks the setup this page describes for it: The overlay goes after bootstrap, behind the `ngDevMode` check shown in [Browser part](#browser-part). Pass `--project ` in a workspace with several applications. Without it, `ng add` takes the only application, or the first one. -`ng add` only inserts lines next to code it recognizes, such as `import express from 'express'` and `const app = express()`. When a file looks different, it leaves the file alone and prints the snippet to add by hand. Running it again changes nothing: it skips files that already import the devtools. It saves the package as a dependency, which every setup works with. See [Dependency or dev dependency](#dependency-or-dev-dependency) to move it. +`ng add` only inserts lines next to code it recognizes, such as `import express from 'express'` and `const app = express()`. When a file looks different, it leaves the file alone and prints the snippet to add by hand. It reads `angular.json` the way the Angular CLI does, with comments and trailing commas. When it can't read it, it changes nothing and prints the overlay snippet, and with `--project` it stops with an error that names `angular.json`. Running it again changes nothing: it skips files that already import the devtools. It saves the package as a dependency, which every setup works with. See [Dependency or dev dependency](#dependency-or-dev-dependency) to move it. ### Dependency or dev dependency From 03ea7aab961668d03906a4f4479f8ff4e995525b Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:35:26 +0000 Subject: [PATCH 06/13] fix(vite): describe an invalid lanAddress without JSON.stringify The warning for an unsupported lanAddress used JSON.stringify, which throws on a bigint or a cyclic value and so aborted the Vite setup. It now uses the describe() helper the other option warnings use. A test covers a bigint and a cyclic object. --- packages/devtools/src/__tests__/lan-address.test.ts | 12 ++++++++++++ packages/devtools/src/config.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/devtools/src/__tests__/lan-address.test.ts b/packages/devtools/src/__tests__/lan-address.test.ts index ea77b5cb..bc6d5570 100644 --- a/packages/devtools/src/__tests__/lan-address.test.ts +++ b/packages/devtools/src/__tests__/lan-address.test.ts @@ -49,6 +49,18 @@ describe('resolveLanAddress', () => { expect(resolveLanAddress('192.168.1.20')).toEqual({ on: true, address: '192.168.1.20' }); }); + it('warns about a bigint or a cyclic value without throwing', () => { + const warn = vi.fn(); + const cyclic: Record = {}; + cyclic['self'] = cyclic; + expect(resolveLanAddress(10n, warn)).toEqual({ on: false }); + expect(resolveLanAddress(cyclic, warn)).toEqual({ on: false }); + expect(warn.mock.calls.map(([m]) => m)).toEqual([ + expect.stringContaining('got a bigint'), + expect.stringContaining('got a object'), + ]); + }); + it('refuses loopback, unspecified and malformed values with a warning', () => { for (const value of ['127.0.0.1', '0.0.0.0', 'localhost', '192.168.1', 1, {}]) { const warn = vi.fn(); diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index 94a20924..70f64399 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -1035,7 +1035,7 @@ export function resolveLanAddress( return { on: true, address: value }; } warn( - `[pangular] Ignoring lanAddress: ${JSON.stringify(value)}. Pass true, or your machine's LAN address such as 192.168.1.20.`, + `[pangular] Ignoring lanAddress: got ${describe(value)}. Pass true, or your machine's LAN address such as 192.168.1.20.`, ); return { on: false }; } From 12b7dfd269a91c4d37fe947b283d814c5f399519 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:35:28 +0000 Subject: [PATCH 07/13] fix: keep string content and refuse an empty angular.json in ng add The trailing-comma pass ran as a regex over the whole text, so a string such as "src,]" lost its comma. Comments and trailing commas are now handled in one pass that tracks strings and escapes. An empty or blank angular.json took the missing-file path and the schematic edited the default files; it is now unreadable, so nothing changes and the snippet is printed. Tests cover both. --- .../src/__tests__/schematics-ng-add.test.ts | 20 ++++++++++++++++ .../devtools/src/schematics/ng-add/index.ts | 24 +++++++++++++++---- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/devtools/src/__tests__/schematics-ng-add.test.ts b/packages/devtools/src/__tests__/schematics-ng-add.test.ts index 2237672e..857220fb 100644 --- a/packages/devtools/src/__tests__/schematics-ng-add.test.ts +++ b/packages/devtools/src/__tests__/schematics-ng-add.test.ts @@ -186,6 +186,26 @@ describe('detectSetup', () => { expect(parseWorkspaceJson('{"url": "http://a//b", }')).toEqual({ url: 'http://a//b' }); }); + it('keeps commas, brackets and slashes inside strings as written', () => { + expect( + parseWorkspaceJson( + '{"sourceRoot": "src,]", "a": "x,}", "url": "http://a//b", /* c */ "n": [1, 2,],}', + ), + ).toEqual({ sourceRoot: 'src,]', a: 'x,}', url: 'http://a//b', n: [1, 2] }); + expect(parseWorkspaceJson('{"q": "say \\"hi\\", ]"}')).toEqual({ q: 'say "hi", ]' }); + }); + + it('treats an empty angular.json as unreadable and changes nothing', () => { + for (const text of ['', ' \n']) { + const tree = workspace({ 'src/main.ts': MAIN }, null); + tree.create('angular.json', text); + expect(detectSetup(tree)).toEqual({ kind: 'unreadable' }); + const { logs } = run(tree); + expect(logs()).toMatch(/Could not read angular\.json/); + expect(tree.readText('src/main.ts')).toBe(MAIN); + } + }); + it('changes nothing and prints the snippet when angular.json cannot be read', () => { const tree = workspace({ 'src/main.ts': MAIN }, null); tree.create('angular.json', '{ "projects": '); diff --git a/packages/devtools/src/schematics/ng-add/index.ts b/packages/devtools/src/schematics/ng-add/index.ts index 2e5827af..56af44d3 100644 --- a/packages/devtools/src/schematics/ng-add/index.ts +++ b/packages/devtools/src/schematics/ng-add/index.ts @@ -95,13 +95,16 @@ type Json = Record; * still is not JSON. Strings are copied as they are, so a `//` inside a URL stays. */ export function parseWorkspaceJson(text: string): unknown { + // One pass that knows when it is inside a string, so string content stays as written. let out = ''; + let pendingComma = ''; for (let i = 0; i < text.length; i++) { const ch = text[i]; if (ch === '"') { let j = i + 1; while (j < text.length && text[j] !== '"') j += text[j] === '\\' ? 2 : 1; - out += text.slice(i, j + 1); + out += pendingComma + text.slice(i, j + 1); + pendingComma = ''; i = j; } else if (ch === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; @@ -110,12 +113,22 @@ export function parseWorkspaceJson(text: string): unknown { const end = text.indexOf('*/', i + 2); if (end < 0) return undefined; i = end + 1; - } else { + } else if (ch === ',') { + // Held back until the next token shows whether it is a trailing comma. + out += pendingComma; + pendingComma = ','; + } else if (ch === '}' || ch === ']') { + pendingComma = ''; + out += ch; + } else if (/\s/.test(ch)) { out += ch; + } else { + out += pendingComma + ch; + pendingComma = ''; } } try { - return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1')); + return JSON.parse(out + pendingComma); } catch { return undefined; } @@ -149,8 +162,9 @@ export function detectSetup(tree: Tree, options: NgAddOptions = {}): Setup { let server: string | undefined; let project: string | undefined; const workspaceText = readText(tree, 'angular.json'); - const workspace = workspaceText ? parseWorkspaceJson(workspaceText) : undefined; - if (workspaceText && !isObject(workspace)) { + // An empty angular.json is there but unreadable, not missing. + const workspace = workspaceText !== undefined ? parseWorkspaceJson(workspaceText) : undefined; + if (workspaceText !== undefined && !isObject(workspace)) { if (options.project) { throw new Error( `[pangular] Could not read angular.json: it is not valid JSON, even with comments and trailing commas allowed. Fix it, then run ng add again for project "${options.project}".`, From 17d4211a373d45e0a359304720acdaedee30fd1f Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:35:29 +0000 Subject: [PATCH 08/13] docs(vite): say which lanAddress values warn and which turn it on --- apps/docs/src/content/getting-started/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md index c62c1886..b7cfd971 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -275,7 +275,7 @@ It is off by default because it starts processes on your machine. A value other | ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lanAddress` | `false` | `true` injects the dev server's devtools URL on this machine's first private IPv4 address as `__PANGULAR_LAN_URL__`. An address such as `'192.168.1.20'` uses that one. Needs `allowLan` in effect. See [Inject the LAN address](./vite.md#inject-the-lan-address). | -A loopback address, `0.0.0.0` or any other value prints a warning and keeps it off. The CLI flag only prints the URL and the define for your build. See [Inject the LAN address](./cli.md#inject-the-lan-address). +`false` keeps it off with no warning. `true` and a network IPv4 address turn it on. Any other value, such as a loopback address, `0.0.0.0` or a value that is not a boolean or an IPv4 address, prints a warning and keeps it off. The CLI flag only prints the URL and the define for your build. See [Inject the LAN address](./cli.md#inject-the-lan-address). ### Lint rules From 7e3cd53b84d2960238691dbdda18aed2166be2a0 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:49:08 +0000 Subject: [PATCH 09/13] fix(vite): refuse lanAddress octets with leading zeros The IPv4 check accepted zero-padded octets, so 010.000.000.001 passed as an address other tools read differently, and 00.0.0.0 slipped past the unspecified-address check. Octets with a leading zero are now refused with the usual warning. Tests cover zero-prefixed addresses. --- packages/devtools/src/__tests__/lan-address.test.ts | 12 +++++++++++- packages/devtools/src/config.ts | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/devtools/src/__tests__/lan-address.test.ts b/packages/devtools/src/__tests__/lan-address.test.ts index bc6d5570..9c4dd2e2 100644 --- a/packages/devtools/src/__tests__/lan-address.test.ts +++ b/packages/devtools/src/__tests__/lan-address.test.ts @@ -62,7 +62,17 @@ describe('resolveLanAddress', () => { }); it('refuses loopback, unspecified and malformed values with a warning', () => { - for (const value of ['127.0.0.1', '0.0.0.0', 'localhost', '192.168.1', 1, {}]) { + for (const value of [ + '127.0.0.1', + '0.0.0.0', + '00.0.0.0', + '010.000.000.001', + '192.168.01.20', + 'localhost', + '192.168.1', + 1, + {}, + ]) { const warn = vi.fn(); expect(resolveLanAddress(value, warn)).toEqual({ on: false }); expect(warn).toHaveBeenCalledWith(expect.stringMatching(/^\[pangular\] Ignoring lanAddress/)); diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts index 70f64399..616a06ed 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -1013,7 +1013,8 @@ export type LanAddressOption = boolean | string; /** `lanAddress` once read: off, or on with the address to use when one was given. */ export type ResolvedLanAddress = { on: false } | { on: true; address?: string }; -const IPV4 = /^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/; +// No leading zeros: `010.0.0.1` would read as octal in some tools, and `00.0.0.0` would dodge the unspecified check. +const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/; /** * Reads `lanAddress`. Anything but `true` or a network IPv4 address, such as `192.168.1.20`, From 972aa92bf2129c315c8cf912d72e1b834a1f27c3 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:49:09 +0000 Subject: [PATCH 10/13] docs(vite): say lanAddress true falls back to the first network address --- apps/docs/src/content/getting-started/configuration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/docs/src/content/getting-started/configuration.md b/apps/docs/src/content/getting-started/configuration.md index b7cfd971..88a864c9 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -271,9 +271,9 @@ It is off by default because it starts processes on your machine. A value other `lanAddress` is an option of the Vite plugin only, and `--lan-address` the matching flag of `pangular dev`. Like `terminals`, it is not part of `PangularConfig`, so `createPangular()` and the CLI config file don't take it. -| Option | Default | What it does | -| ------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lanAddress` | `false` | `true` injects the dev server's devtools URL on this machine's first private IPv4 address as `__PANGULAR_LAN_URL__`. An address such as `'192.168.1.20'` uses that one. Needs `allowLan` in effect. See [Inject the LAN address](./vite.md#inject-the-lan-address). | +| Option | Default | What it does | +| ------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lanAddress` | `false` | `true` injects the dev server's devtools URL on this machine's first private IPv4 address, or its first network address when none is private, as `__PANGULAR_LAN_URL__`. An address such as `'192.168.1.20'` uses that one. Needs `allowLan` in effect. See [Inject the LAN address](./vite.md#inject-the-lan-address). | `false` keeps it off with no warning. `true` and a network IPv4 address turn it on. Any other value, such as a loopback address, `0.0.0.0` or a value that is not a boolean or an IPv4 address, prints a warning and keeps it off. The CLI flag only prints the URL and the define for your build. See [Inject the LAN address](./cli.md#inject-the-lan-address). From 5d8ba86ac4cb75a7afc86dec9859d029d48ee675 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:50:07 +0000 Subject: [PATCH 11/13] fix: keep tokens apart where ng add strips a block comment The angular.json parser dropped a block comment without a trace, so 1/* c */2 became 12 and parsed as valid JSON. It now leaves a space in its place, so such a file stays unreadable, as it is for the Angular CLI. A test covers it. --- packages/devtools/src/__tests__/schematics-ng-add.test.ts | 7 +++++++ packages/devtools/src/schematics/ng-add/index.ts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/packages/devtools/src/__tests__/schematics-ng-add.test.ts b/packages/devtools/src/__tests__/schematics-ng-add.test.ts index 857220fb..bf5fce82 100644 --- a/packages/devtools/src/__tests__/schematics-ng-add.test.ts +++ b/packages/devtools/src/__tests__/schematics-ng-add.test.ts @@ -195,6 +195,13 @@ describe('detectSetup', () => { expect(parseWorkspaceJson('{"q": "say \\"hi\\", ]"}')).toEqual({ q: 'say "hi", ]' }); }); + it('does not join two tokens around a block comment', () => { + expect(parseWorkspaceJson('{"projects": {}, "version": 1/* c */2}')).toBeUndefined(); + const tree = workspace({ 'src/main.ts': MAIN }, null); + tree.create('angular.json', '{"projects": {}, "version": 1/* c */2}'); + expect(detectSetup(tree)).toEqual({ kind: 'unreadable' }); + }); + it('treats an empty angular.json as unreadable and changes nothing', () => { for (const text of ['', ' \n']) { const tree = workspace({ 'src/main.ts': MAIN }, null); diff --git a/packages/devtools/src/schematics/ng-add/index.ts b/packages/devtools/src/schematics/ng-add/index.ts index 56af44d3..3a71dc6c 100644 --- a/packages/devtools/src/schematics/ng-add/index.ts +++ b/packages/devtools/src/schematics/ng-add/index.ts @@ -112,6 +112,8 @@ export function parseWorkspaceJson(text: string): unknown { } else if (ch === '/' && text[i + 1] === '*') { const end = text.indexOf('*/', i + 2); if (end < 0) return undefined; + // A space in its place, so `1/* c */2` stays two tokens and fails like the CLI would. + out += ' '; i = end + 1; } else if (ch === ',') { // Held back until the next token shows whether it is a trailing comma. From ca58d38fea345617ae60c1132d14cb55712ec8b0 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 19:59:11 +0000 Subject: [PATCH 12/13] fix: leave another app's workspace Vite config alone in ng add detectSetup took a workspace-root vite.config for any project without its own, so ng add on a nested app that does not use Vite edited the root app's config. A root config now counts only when the project lives at the workspace root, or when the config names the project's root or sourceRoot. Otherwise the project's own server or CLI setup is used. A test covers a root Vite app next to a nested app without Vite. --- .../src/__tests__/schematics-ng-add.test.ts | 30 +++++++++++++++++++ .../devtools/src/schematics/ng-add/index.ts | 23 ++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/devtools/src/__tests__/schematics-ng-add.test.ts b/packages/devtools/src/__tests__/schematics-ng-add.test.ts index bf5fce82..59faccae 100644 --- a/packages/devtools/src/__tests__/schematics-ng-add.test.ts +++ b/packages/devtools/src/__tests__/schematics-ng-add.test.ts @@ -226,6 +226,36 @@ describe('detectSetup', () => { ); }); + it('leaves a workspace-root Vite config to the root app when the nested project does not use Vite', () => { + const angular = { + projects: { + site: { projectType: 'application', root: '', sourceRoot: 'src' }, + admin: { + projectType: 'application', + root: 'projects/admin', + sourceRoot: 'projects/admin/src', + architect: { build: { options: { browser: 'projects/admin/src/main.ts' } } }, + }, + }, + }; + const tree = workspace( + { 'vite.config.ts': VITE, 'projects/admin/src/main.ts': MAIN, 'src/main.ts': MAIN }, + angular, + ); + expect(detectSetup(tree, { project: 'site' })).toEqual( + expect.objectContaining({ kind: 'vite', viteConfig: 'vite.config.ts' }), + ); + expect(detectSetup(tree, { project: 'admin' })).toEqual( + expect.objectContaining({ kind: 'cli', main: 'projects/admin/src/main.ts' }), + ); + const before = tree.readText('vite.config.ts'); + run(tree, { project: 'admin' }); + expect(tree.readText('vite.config.ts')).toBe(before); + // A root config that names the nested project's folder belongs to it. + const named = workspace({ 'vite.config.ts': `${VITE}\n// root: 'projects/admin'\n` }, angular); + expect(detectSetup(named, { project: 'admin' }).kind).toBe('vite'); + }); + it('works without angular.json', () => { expect(detectSetup(workspace({ 'vite.config.ts': VITE }, null))).toEqual( expect.objectContaining({ kind: 'vite', main: 'src/main.ts' }), diff --git a/packages/devtools/src/schematics/ng-add/index.ts b/packages/devtools/src/schematics/ng-add/index.ts index 3a71dc6c..7295c727 100644 --- a/packages/devtools/src/schematics/ng-add/index.ts +++ b/packages/devtools/src/schematics/ng-add/index.ts @@ -156,6 +156,16 @@ function pickProject(workspace: Json, name: string | undefined) { return chosen ? { name: chosen[0], project: chosen[1] } : undefined; } +/** Whether a config file names one of `folders` as a quoted path, such as `root: 'projects/shop'`. */ +function namesFolder(text: string, folders: string[]): boolean { + return folders + .filter((folder) => !!folder) + .some((folder) => { + const escaped = folder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`['"\`](?:\\./)?${escaped}/?['"\`]`).test(text); + }); +} + /** Reads the setup from `angular.json`, the Vite config and `server.ts`. */ export function detectSetup(tree: Tree, options: NgAddOptions = {}): Setup { let root = ''; @@ -200,9 +210,16 @@ export function detectSetup(tree: Tree, options: NgAddOptions = {}): Setup { tree.exists(path), ); } - const viteConfig = [root, ''] - .flatMap((dir) => VITE_CONFIGS.map((file) => join(dir, file))) - .find((path) => tree.exists(path)); + const own = VITE_CONFIGS.map((file) => join(root, file)).find((path) => tree.exists(path)); + // A workspace-root Vite config belongs to the project only when the project + // lives at the workspace root, or when the config names the project's folder. + // Otherwise it serves another app, and editing it would wire the wrong one. + const shared = own + ? undefined + : VITE_CONFIGS.filter((file) => tree.exists(file)).find( + (file) => !root || namesFolder(readText(tree, file) ?? '', [root, sourceRoot]), + ); + const viteConfig = own ?? shared; if (viteConfig) return { kind: 'vite', project, main, viteConfig }; if (server) { const text = readText(tree, server) ?? ''; From 01c3b9f3bd9af8095b751d3255c09dc818f34af4 Mon Sep 17 00:00:00 2001 From: erKam Date: Sat, 7 Nov 2026 20:09:24 +0000 Subject: [PATCH 13/13] fix: leave a Vite plugin list that ends in a line comment alone in ng add When the last plugin line ended in a // comment, the comma ng add added landed inside the comment and broke vite.config. The schematic now leaves such a list alone and prints the snippet. A test covers it. --- packages/devtools/src/__tests__/schematics-ng-add.test.ts | 1 + packages/devtools/src/schematics/ng-add/index.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/devtools/src/__tests__/schematics-ng-add.test.ts b/packages/devtools/src/__tests__/schematics-ng-add.test.ts index 59faccae..66784899 100644 --- a/packages/devtools/src/__tests__/schematics-ng-add.test.ts +++ b/packages/devtools/src/__tests__/schematics-ng-add.test.ts @@ -399,6 +399,7 @@ describe('wireVitePlugin', () => { `export default { plugins };\n`, `const a = { plugins: [] };\nconst b = { plugins: [] };\n`, `import pangular from './local';\nexport default { plugins: [pangular()] };\n`, + `export default defineConfig({\n plugins: [\n a() // note\n ],\n});\n`, ]) { expect(wireVitePlugin(text)).toEqual({ status: 'unsure' }); } diff --git a/packages/devtools/src/schematics/ng-add/index.ts b/packages/devtools/src/schematics/ng-add/index.ts index 7295c727..57171795 100644 --- a/packages/devtools/src/schematics/ng-add/index.ts +++ b/packages/devtools/src/schematics/ng-add/index.ts @@ -373,6 +373,8 @@ export function wireVitePlugin(text: string): EditResult { } else if (inner.includes('\n')) { const last = inner.replace(/\s+$/, ''); const lines = last.split('\n'); + // A comma after a line comment would land inside the comment. + if ((lines[lines.length - 1] ?? '').includes('//')) return { status: 'unsure' }; const indent = /^[ \t]*/.exec(lines[lines.length - 1] ?? '')?.[0] ?? ''; const comma = last.endsWith(',') ? '' : ','; const trailing = inner.slice(last.length);