diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md index a237af59..47ddc29c 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 b58b442f..82d51a62 100644 --- a/apps/docs/src/content/getting-started/configuration.md +++ b/apps/docs/src/content/getting-started/configuration.md @@ -289,6 +289,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, 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). + ### 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 60d2f937..08bb50c2 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. 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 | 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 53e9f26c..f462dfe4 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 bc443d3a..43afea64 100644 --- a/packages/devtools/CHANGELOG.md +++ b/packages/devtools/CHANGELOG.md @@ -140,6 +140,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`, `plugins` and `redaction.entropy`. @@ -228,6 +230,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. - `buildReport()` from `@pangular-inspector/devtools/cli` writes the static report from a script, with the output folder checks of `pangular build`. Unlike `pangular build`, it resolves `outDir` against the working directory, and `config` takes a config object, not a file path. - The source scan reads Nx projects. @@ -296,6 +299,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. diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 0650b9a2..f093aac9 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__/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..9c4dd2e2 --- /dev/null +++ b/packages/devtools/src/__tests__/lan-address.test.ts @@ -0,0 +1,287 @@ +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('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', + '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/)); + } + }); +}); + +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/__tests__/schematics-ng-add.test.ts b/packages/devtools/src/__tests__/schematics-ng-add.test.ts new file mode 100644 index 00000000..66784899 --- /dev/null +++ b/packages/devtools/src/__tests__/schematics-ng-add.test.ts @@ -0,0 +1,497 @@ +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, + OVERLAY_SNIPPET, + parseWorkspaceJson, + 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('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('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('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); + 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": '); + 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('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' }), + ); + }); +}); + +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`, + `export default defineConfig({\n plugins: [\n a() // note\n ],\n});\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/cli.ts b/packages/devtools/src/cli.ts index 23f1d640..7adc0051 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'; @@ -306,6 +309,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' }, @@ -357,9 +361,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'; @@ -401,6 +413,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", @@ -436,6 +452,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 88007392..95ad19b9 100644 --- a/packages/devtools/src/config.ts +++ b/packages/devtools/src/config.ts @@ -1105,6 +1105,43 @@ export function pickPangularConfig( }; } +/** + * 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 }; + +// 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`, + * 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: got ${describe(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 cbd1652f..08518832 100644 --- a/packages/devtools/src/overlay-nativescript.ts +++ b/packages/devtools/src/overlay-nativescript.ts @@ -49,6 +49,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; @@ -61,7 +64,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; @@ -80,7 +86,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 e6af8de3..6d623120 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/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..57171795 --- /dev/null +++ b/packages/devtools/src/schematics/ng-add/index.ts @@ -0,0 +1,460 @@ +// 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 { + /** `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; + 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; + +/** + * 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 { + // 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 += pendingComma + text.slice(i, j + 1); + pendingComma = ''; + 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; + // 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. + 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 + pendingComma); + } catch { + return undefined; + } +} +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; +} + +/** 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 = ''; + let sourceRoot = 'src'; + let main: string | undefined; + let server: string | undefined; + let project: string | undefined; + const workspaceText = readText(tree, 'angular.json'); + // 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}".`, + ); + } + 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'] : ''; + 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 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) ?? ''; + 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'); + // 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); + 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; + 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; + }; +} 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/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 36bc3cd9..ab872e17 100644 --- a/packages/devtools/src/vite.ts +++ b/packages/devtools/src/vite.ts @@ -33,6 +33,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'; @@ -63,6 +64,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[]; } @@ -78,38 +87,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; @@ -132,19 +114,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; @@ -192,6 +187,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); @@ -200,6 +196,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', () => { 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]);