From 78c309f87f6c9175d5f309d9a22a21a67342783c Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 13:30:00 -0700 Subject: [PATCH 01/42] Initial pass --- package-lock.json | 212 +++++++++++++++- .../dev/inspector-v2/inspector-cli-plan.md | 226 ++++++++++++++++++ packages/dev/inspector-v2/package.json | 3 +- packages/dev/inspector-v2/src/cli/bridge.ts | 179 ++++++++++++++ packages/dev/inspector-v2/src/cli/cli.ts | 203 ++++++++++++++++ packages/dev/inspector-v2/src/cli/config.ts | 76 ++++++ packages/dev/inspector-v2/src/index.ts | 3 + packages/dev/inspector-v2/src/inspectable.ts | 101 ++++++++ .../src/services/inspectableBridgeService.ts | 171 +++++++++++++ .../services/inspectableCommandRegistry.ts | 66 +++++ .../inspector-v2/test/unit/cli/config.test.ts | 61 +++++ .../services/inspectableBridgeService.test.ts | 85 +++++++ .../public/@babylonjs/inspector-v2/.gitignore | 1 + .../@babylonjs/inspector-v2/package.json | 13 +- .../inspector-v2/rollup.config.cli.mjs | 40 ++++ .../@babylonjs/inspector-v2/tsconfig.cli.json | 16 ++ 16 files changed, 1451 insertions(+), 5 deletions(-) create mode 100644 packages/dev/inspector-v2/inspector-cli-plan.md create mode 100644 packages/dev/inspector-v2/src/cli/bridge.ts create mode 100644 packages/dev/inspector-v2/src/cli/cli.ts create mode 100644 packages/dev/inspector-v2/src/cli/config.ts create mode 100644 packages/dev/inspector-v2/src/inspectable.ts create mode 100644 packages/dev/inspector-v2/src/services/inspectableBridgeService.ts create mode 100644 packages/dev/inspector-v2/src/services/inspectableCommandRegistry.ts create mode 100644 packages/dev/inspector-v2/test/unit/cli/config.test.ts create mode 100644 packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts create mode 100644 packages/public/@babylonjs/inspector-v2/.gitignore create mode 100644 packages/public/@babylonjs/inspector-v2/rollup.config.cli.mjs create mode 100644 packages/public/@babylonjs/inspector-v2/tsconfig.cli.json diff --git a/package-lock.json b/package-lock.json index 6710f8c60298..f585936349df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7894,6 +7894,10 @@ "resolved": "packages/tools/devHost", "link": true }, + "node_modules/@tools/flow-graph-editor": { + "resolved": "packages/tools/flowGraphEditor", + "link": true + }, "node_modules/@tools/gui-editor": { "resolved": "packages/tools/guiEditor", "link": true @@ -11351,6 +11355,19 @@ "node": ">=0.3.1" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dnd-core": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-15.0.1.tgz", @@ -13885,6 +13902,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/globby": { + "version": "13.2.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-13.2.2.tgz", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -18553,6 +18600,16 @@ ], "license": "MIT" }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -20032,6 +20089,19 @@ "node": ">= 10" } }, + "node_modules/slash": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sleep": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/sleep/-/sleep-6.1.0.tgz", @@ -23832,6 +23902,9 @@ "name": "@babylonjs/inspector", "version": "9.0.0", "license": "Apache-2.0", + "bin": { + "babylon-inspector": "bin/inspector-cli.mjs" + }, "devDependencies": { "@dev/build-tools": "^1.0.0", "@dev/inspector": "1.0.0", @@ -23839,9 +23912,11 @@ "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", + "@types/ws": "^8.5.0", "chalk": "^5.3.0", "rollup": "^4.59.0", - "rollup-plugin-dts": "^6.1.1" + "rollup-plugin-dts": "^6.1.1", + "ws": "^8.18.0" }, "peerDependencies": { "@babylonjs/addons": "^9.0.0", @@ -23859,6 +23934,28 @@ "usehooks-ts": "^3.1.1" } }, + "packages/public/@babylonjs/inspector-v2/node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/public/@babylonjs/ktx2decoder": { "version": "9.0.0", "license": "Apache-2.0", @@ -24626,6 +24723,119 @@ "eslint": "^9.0.0 || ^10.0.0" } }, + "packages/tools/flowGraphEditor": { + "name": "@tools/flow-graph-editor", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@dev/build-tools": "1.0.0", + "@dev/core": "1.0.0", + "@dev/shared-ui-components": "1.0.0", + "@svgr/webpack": "^7.0.0", + "@tools/snippet-loader": "1.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^7.1.0", + "html-webpack-plugin": "^5.4.0", + "mini-css-extract-plugin": "^2.4.3", + "sass-loader": "^13.0.0", + "split.js": "^1.6.5", + "style-loader": "^3.3.0", + "webpack": "^5.103.0", + "webpack-cli": "6.0.1", + "webpack-merge": "^5.8.0" + } + }, + "packages/tools/flowGraphEditor/node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "packages/tools/flowGraphEditor/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "packages/tools/flowGraphEditor/node_modules/sass-loader": { + "version": "13.3.3", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-13.3.3.tgz", + "integrity": "sha512-mt5YN2F1MOZr3d/wBRcZxeFgwgkH44wVc2zohO2YF6JiOMkiXe4BYRZpSu2sO1g71mo/j16txzUhsKZlqjVGzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } + } + }, + "packages/tools/flowGraphEditor/node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, "packages/tools/guiEditor": { "name": "@tools/gui-editor", "version": "1.0.0", diff --git a/packages/dev/inspector-v2/inspector-cli-plan.md b/packages/dev/inspector-v2/inspector-cli-plan.md new file mode 100644 index 000000000000..69b41ae559a1 --- /dev/null +++ b/packages/dev/inspector-v2/inspector-cli-plan.md @@ -0,0 +1,226 @@ +# Inspector CLI Plan + +## Problem + +There is currently no way to interact with a running Babylon.js scene from the terminal. The Inspector UI is browser-only. A CLI tool would enable terminal-based workflows: listing active scenes, querying available commands, and invoking them — all from the command line. **The primary consumer of this CLI is intended to be AI agents**, so the CLI should be designed with machine-friendly output and straightforward invocation patterns well suited for that scenario. + +## Proposed Approach + +Introduce a two-part architecture: + +1. **CLI** (`src/cli/cli.ts` → bundled to `bin/inspector-cli.mjs`) — A short-lived Node.js script shipped in the `@babylonjs/inspector` published package's `bin` field so it can be invoked via `npx @babylonjs/inspector` or as an npm-installed binary. Each invocation connects to the bridge over WebSocket and exits. + +2. **Bridge** (`src/cli/bridge.ts` → bundled to `bin/inspector-bridge.mjs`) — A long-lived Node.js process started on-demand by the CLI if not already running. It hosts **two WebSocket servers**: one for browser sessions (the "browser port") and one for CLI connections (the "CLI port"). The bridge acts as a relay between the CLI and the browser. The bridge assigns each browser session a unique numeric id (incrementing from 1). + +3. **Browser-side `StartInspectable`** — A new public API analogous to `ShowInspector`. It creates a `ServiceContainer`, registers a primary "InspectableService" that opens a WebSocket to the bridge, and exposes a contract for registering CLI-invocable commands. + +``` +┌────────┐ WebSocket ┌────────────┐ WebSocket ┌─────────────┐ +│ CLI │ ───────────────────► │ Bridge │ ◄──────────────────► │ Browser │ +│ (node) │ (CLI port, e.g. │ (node ws) │ (browser port, e.g. │ (scene) │ +│ │ 4401) │ │ 4400) │ │ +└────────┘ └────────────┘ └─────────────┘ +``` + +All source is **TypeScript**, compiled to `.mjs` files. The CLI and bridge are bundled (with `ws` inlined) via separate rollup configs so they are self-contained and easy to run. + +--- + +## Configuration: `.babyloninspector` + +A JSON file that can live anywhere in the directory parent chain (searched upward from `cwd`). If not found, defaults apply. + +```jsonc +{ + "browserPort": 4400, // WebSocket port for browser sessions + "cliPort": 4401 // WebSocket port for CLI connections +} +``` + +Resolution order: walk from `process.cwd()` up to filesystem root, first `.babyloninspector` file wins. + +--- + +## Todos + +### 1. Config loader (`src/cli/config.ts`) + +Create a utility that: +- Walks the directory parent chain from `cwd` looking for `.babyloninspector`. +- Parses it as JSON. +- Merges with defaults (`{ browserPort: 4400, cliPort: 4401 }`). +- Exported as a pure function usable by both CLI and bridge. + +### 2. Bridge process (`src/cli/bridge.ts`) + +A Node.js entry point that: +- Reads config via the config loader. +- Starts two `ws` WebSocket servers: one on `browserPort` (for browser sessions) and one on `cliPort` (for CLI connections). +- Tracks connected browser sessions. Each browser session sends a registration message with a `name` (defaults to `document.title` on the browser side). The bridge assigns each session a unique numeric `id` (incrementing from 1, never reused within a bridge lifetime). +- Responds to CLI WebSocket messages: + - `{ type: "sessions" }` → returns list of active sessions. + - `{ type: "commands", sessionId }` → forwards a "list commands" request to the browser session, awaits the response, returns it to CLI. + - `{ type: "exec", sessionId, commandId, args }` → forwards a command invocation to the browser session, awaits the response, returns it. + - `{ type: "stop" }` → shuts down the bridge gracefully. +- Graceful shutdown on `SIGTERM`/`SIGINT` or when receiving a stop command. +- **No lock file needed** — the CLI determines if the bridge is running by attempting to connect to the CLI port. If the connection fails, the bridge is not running. + +### 3. CLI entry point (`src/cli/cli.ts`) + +A Node.js script with `#!/usr/bin/env node` shebang in the source. It: +- Parses `process.argv` using `node:util` `parseArgs`. +- Reads config via the config loader. +- **`--help`**: Prints usage including fixed options and the pattern `-- --help`. +- **`--sessions`**: Connects to the bridge's CLI port, sends a sessions request, prints active sessions (id, name, connected since, etc.). +- **`--stop`**: Sends a stop command to the bridge, which shuts it down cleanly. +- **`--commands `**: Sends a commands request for the session, prints available commands with their ids, and indicates `-- --help` for more info. +- **`-- [args...]`**: Forwards to the bridge → browser session, prints result. +- If the bridge is not running (connection attempt fails), spawns it as a detached child process (`child_process.spawn` with `detached: true`, `stdio: 'ignore'`, `unref()`), retries connection until ready, then proceeds. + +### 4. Package.json `bin` field + +Add to `packages/public/@babylonjs/inspector/package.json` only (the dev package does not need a `bin` entry since `npm exec` resolves through the monorepo): +```json +"bin": { + "babylon-inspector": "./bin/inspector-cli.mjs" +} +``` + +### 5. Browser-side: `StartInspectable` function (`src/inspectable.ts`) + +Public API: +```ts +export function StartInspectable(scene: Scene, options?: Partial): InspectableToken +``` + +Behavior: +- If there is already an `InspectableToken` for this scene, return the existing one (or dispose and recreate — TBD, likely just return existing). +- Create a `ServiceContainer` (like `MakeModularTool` does, but headless — no React rendering). +- Register the **InspectableBridgeService** as the primary service. +- Return an `InspectableToken` (which is `IDisposable`). +- Track tokens in a `Map` (or `WeakMap`). + +`InspectableToken`: +```ts +export type InspectableToken = IDisposable & { + readonly isDisposed: boolean; +}; +``` + +`InspectableOptions`: +```ts +export type InspectableOptions = { + /** WebSocket port for the bridge's browser port. Defaults to 4400. */ + port?: number; + /** Session display name. Defaults to document.title. */ + name?: string; +}; +``` + +### 6. Browser-side: InspectableBridgeService (`src/services/inspectableBridgeService.ts`) + +A service definition that: +- Opens a WebSocket connection to the bridge on the configured browser port. +- Sends a registration message (`{ type: "register", name }`). +- Listens for incoming messages from the bridge (command list requests, command invocations). +- Produces an `IInspectableCommandRegistry` contract: + ```ts + export interface IInspectableCommandRegistry { + addCommand(descriptor: InspectableCommandDescriptor): IDisposable; + } + + export type InspectableCommandDescriptor = { + id: string; + description: string; + args?: InspectableCommandArg[]; + execute: (args: Record) => Promise; + }; + + export type InspectableCommandArg = { + name: string; + description: string; + required?: boolean; + }; + ``` +- When the bridge asks for the command list, the service responds with all registered command descriptors (id, description, args). +- When the bridge asks to execute a command, the service finds the registered command and calls `execute`, then sends the result back. +- On dispose, closes the WebSocket. + +### 7. Wire up exports (`src/index.ts`) + +Export `StartInspectable`, `InspectableToken`, `InspectableOptions`, `IInspectableCommandRegistry`, and `InspectableCommandDescriptor` from the package index. + +### 8. Build integration + +- The CLI source files (`src/cli/*.ts`) are TypeScript, compiled and bundled into self-contained `.mjs` files via a **separate `rollup.config.cli.mjs`** with two entry points (one for the CLI, one for the bridge) that lives in the published package directory (`packages/public/@babylonjs/inspector/`). +- The `ws` package is **bundled into** the CLI and bridge `.mjs` files (not an external dependency), making them self-contained and easy to run. +- The CLI entry point (`src/cli/cli.ts`) has `#!/usr/bin/env node` directly in the source — no custom post-build step needed. +- The browser-side files (`src/inspectable.ts`, `src/services/inspectableBridgeService.ts`) use the native browser `WebSocket` API and are compiled normally with the rest of the inspector package via `tsc`. +- Output for CLI/bridge bundles goes to `bin/` in the published package directory. + +### 9. Tests + +- **Unit tests** for config loader (finding `.babyloninspector`, merging defaults). +- **Unit tests** for CLI arg parsing. +- **Unit tests** for InspectableCommandRegistry (registering commands, listing, disposing). +- **Integration test** for the bridge ↔ browser WebSocket protocol (can use `ws` on both sides in Node.js for testing). + +--- + +## Protocol Sketch + +### Browser ↔ Bridge (WebSocket on browser port) + +```jsonc +// Browser → Bridge: Registration (bridge internally assigns a numeric id) +{ "type": "register", "name": "My Game" } + +// Bridge → Browser: Request command list +{ "type": "listCommands", "requestId": "req-1" } + +// Browser → Bridge: Command list response +{ "type": "commandListResponse", "requestId": "req-1", "commands": [ + { "id": "screenshot", "description": "Capture a screenshot", "args": [...] } +]} + +// Bridge → Browser: Execute command +{ "type": "execCommand", "requestId": "req-2", "commandId": "screenshot", "args": {} } + +// Browser → Bridge: Command execution response +{ "type": "commandResponse", "requestId": "req-2", "result": "Screenshot saved to ..." } +``` + +### CLI ↔ Bridge (WebSocket on CLI port) + +```jsonc +// CLI → Bridge: List sessions +{ "type": "sessions" } + +// Bridge → CLI: Sessions response +{ "type": "sessionsResponse", "sessions": [ + { "id": 1, "name": "My Game", "connectedAt": "2026-03-25T..." } +]} + +// CLI → Bridge: List commands for session +{ "type": "commands", "sessionId": 1 } + +// Bridge → CLI: Commands response (forwarded from browser) +{ "type": "commandsResponse", "commands": [ + { "id": "screenshot", "description": "Capture a screenshot", "args": [...] } +]} + +// CLI → Bridge: Execute command +{ "type": "exec", "sessionId": 1, "commandId": "screenshot", "args": {} } + +// Bridge → CLI: Execution response (forwarded from browser) +{ "type": "execResponse", "result": "Screenshot saved to ..." } + +// CLI → Bridge: Stop bridge +{ "type": "stop" } +``` + +--- + +## Open Questions + +1. **Authentication/security**: For the initial version, the bridge only listens on localhost. Future work could add token-based auth for remote scenarios. diff --git a/packages/dev/inspector-v2/package.json b/packages/dev/inspector-v2/package.json index d7108a5aaa46..b342935fc90a 100644 --- a/packages/dev/inspector-v2/package.json +++ b/packages/dev/inspector-v2/package.json @@ -14,7 +14,8 @@ "serve": "webpack serve --mode development", "watch": "tsc -b tsconfig.build.json -w", "watch:dev": "npm run watch", - "makeAvatar": "node scripts/makeAvatar.mjs" + "makeAvatar": "node scripts/makeAvatar.mjs", + "babylon-inspector": "node --no-warnings dist/cli/cli.js --bridge-script dist/cli/bridge.js" }, "devDependencies": { "@dev/addons": "1.0.0", diff --git a/packages/dev/inspector-v2/src/cli/bridge.ts b/packages/dev/inspector-v2/src/cli/bridge.ts new file mode 100644 index 000000000000..71cfdd50984a --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/bridge.ts @@ -0,0 +1,179 @@ +import ws from "ws"; +import { loadConfig } from "./config.js"; + +type WebSocket = ws; +type WebSocketServerType = ws.Server; + +interface Session { + id: number; + name: string; + connectedAt: string; + ws: WebSocket; +} + +let nextSessionId = 1; +const sessions = new Map(); +const pendingBrowserRequests = new Map void>(); +let requestCounter = 0; + +function generateRequestId(): string { + return `bridge-req-${++requestCounter}`; +} + +function startBridge(): void { + const config = loadConfig(); + + // Browser-facing WebSocket server. + const browserWss = new ws.Server({ host: "127.0.0.1", port: config.browserPort }); + + // CLI-facing WebSocket server. + const cliWss = new ws.Server({ host: "127.0.0.1", port: config.cliPort }); + + console.log(`Inspector bridge started.`); + console.log(` Browser port: ${config.browserPort}`); + console.log(` CLI port: ${config.cliPort}`); + + browserWss.on("connection", (socket) => { + let session: Session | null = null; + + socket.on("message", (data) => { + let message: { type: string; name?: string; requestId?: string; commands?: unknown; result?: string; error?: string }; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + switch (message.type) { + case "register": { + const id = nextSessionId++; + session = { + id, + name: message.name ?? "Unknown", + connectedAt: new Date().toISOString(), + ws: socket, + }; + sessions.set(id, session); + console.log(`Session ${id} registered: "${session.name}"`); + break; + } + case "commandListResponse": + case "commandResponse": { + // Forward response back to the CLI that requested it. + const requestId = message.requestId; + if (requestId) { + const resolve = pendingBrowserRequests.get(requestId); + if (resolve) { + pendingBrowserRequests.delete(requestId); + resolve(JSON.stringify(message)); + } + } + break; + } + } + }); + + socket.on("close", () => { + if (session) { + console.log(`Session ${session.id} disconnected: "${session.name}"`); + sessions.delete(session.id); + } + }); + }); + + cliWss.on("connection", (socket) => { + socket.on("message", (data) => { + let message: { type: string; sessionId?: number; commandId?: string; args?: Record }; + try { + message = JSON.parse(data.toString()); + } catch { + return; + } + + switch (message.type) { + case "sessions": { + const sessionList = Array.from(sessions.values()).map((s) => ({ + id: s.id, + name: s.name, + connectedAt: s.connectedAt, + })); + socket.send(JSON.stringify({ type: "sessionsResponse", sessions: sessionList })); + break; + } + case "commands": { + const session = sessions.get(message.sessionId ?? -1); + if (!session) { + socket.send(JSON.stringify({ type: "commandsResponse", error: `No session with id ${message.sessionId}` })); + break; + } + const requestId = generateRequestId(); + session.ws.send(JSON.stringify({ type: "listCommands", requestId })); + waitForBrowserResponse(requestId).then( + (response) => socket.send(response), + () => socket.send(JSON.stringify({ type: "commandsResponse", error: "Timeout waiting for browser response" })) + ); + break; + } + case "exec": { + const session = sessions.get(message.sessionId ?? -1); + if (!session) { + socket.send(JSON.stringify({ type: "execResponse", error: `No session with id ${message.sessionId}` })); + break; + } + const requestId = generateRequestId(); + session.ws.send( + JSON.stringify({ + type: "execCommand", + requestId, + commandId: message.commandId, + args: message.args, + }) + ); + waitForBrowserResponse(requestId).then( + (response) => socket.send(response), + () => socket.send(JSON.stringify({ type: "execResponse", error: "Timeout waiting for browser response" })) + ); + break; + } + case "stop": { + socket.send(JSON.stringify({ type: "stopResponse", success: true })); + shutdown(browserWss, cliWss); + break; + } + } + }); + }); + + process.on("SIGTERM", () => shutdown(browserWss, cliWss)); + process.on("SIGINT", () => shutdown(browserWss, cliWss)); +} + +function waitForBrowserResponse(requestId: string, timeoutMs = 30000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingBrowserRequests.delete(requestId); + reject(new Error("Timeout")); + }, timeoutMs); + + pendingBrowserRequests.set(requestId, (response) => { + clearTimeout(timer); + resolve(response); + }); + }); +} + +function shutdown(browserWss: WebSocketServerType, cliWss: WebSocketServerType): void { + console.log("Inspector bridge shutting down."); + + for (const session of sessions.values()) { + session.ws.close(); + } + sessions.clear(); + + browserWss.close(); + cliWss.close(); + + process.exit(0); +} + +startBridge(); diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts new file mode 100644 index 000000000000..c08e2fd1697b --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -0,0 +1,203 @@ +import { spawn } from "child_process"; +import { parseArgs } from "util"; +import { fileURLToPath } from "url"; +import { dirname, join, resolve } from "path"; +import ws from "ws"; +import { loadConfig } from "./config.js"; + +type WebSocket = ws; + +const config = loadConfig(); + +const HELP_TEXT = `babylon-inspector — Interact with running Babylon.js scenes from the terminal. + +USAGE + babylon-inspector [options] + +OPTIONS + --help Show this help message. + --sessions List active browser sessions connected to the bridge. + --stop Stop the bridge process. + --commands List commands available from a specific session. + Use -- --help for help on a specific command. + +CONFIGURATION + Place a .babyloninspector JSON file anywhere in the directory parent chain: + { "browserPort": 4400, "cliPort": 4401 } + +EXAMPLES + babylon-inspector --sessions + babylon-inspector --commands 1 +`; + +interface ParsedArgs { + help: boolean; + sessions: boolean; + stop: boolean; + commands?: string; + bridgeScript?: string; +} + +function parseCliArgs(): ParsedArgs { + const { values } = parseArgs({ + options: { + help: { type: "boolean", default: false }, + sessions: { type: "boolean", default: false }, + stop: { type: "boolean", default: false }, + commands: { type: "string" }, + "bridge-script": { type: "string" }, + }, + strict: false, + allowPositionals: true, + }); + + return { + help: !!values.help, + sessions: !!values.sessions, + stop: !!values.stop, + commands: values.commands as string | undefined, + bridgeScript: values["bridge-script"] as string | undefined, + }; +} + +function connectToBridge(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = new ws(`ws://127.0.0.1:${port}`); + socket.on("open", () => resolve(socket)); + socket.on("error", (err) => reject(err)); + }); +} + +function sendAndReceive(socket: WebSocket, message: object): Promise> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for bridge response.")); + }, 15000); + + socket.once("message", (data) => { + clearTimeout(timeout); + try { + resolve(JSON.parse(data.toString())); + } catch { + reject(new Error("Failed to parse bridge response.")); + } + }); + + socket.send(JSON.stringify(message)); + }); +} + +function spawnBridge(bridgeScript?: string): void { + const bridgePath = bridgeScript + ? resolve(bridgeScript) + : join(dirname(fileURLToPath(import.meta.url)), "inspector-bridge.mjs"); + const child = spawn(process.execPath, [bridgePath], { + detached: true, + stdio: "ignore", + }); + child.unref(); +} + +async function ensureBridge(port: number, bridgeScript?: string, maxRetries = 10, retryDelayMs = 500): Promise { + try { + return await connectToBridge(port); + } catch { + // Bridge not running — spawn it. + spawnBridge(bridgeScript); + } + + for (let i = 0; i < maxRetries; i++) { + await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); + try { + return await connectToBridge(port); + } catch { + // Keep retrying. + } + } + + throw new Error(`Unable to connect to the Inspector bridge on port ${port} after spawning it.`); +} + +async function main(): Promise { + const args = parseCliArgs(); + + if (args.help) { + console.log(HELP_TEXT); + return; + } + + if (args.sessions) { + const socket = await ensureBridge(config.cliPort, args.bridgeScript); + try { + const response = await sendAndReceive(socket, { type: "sessions" }); + const sessions = response.sessions as Array<{ id: number; name: string; connectedAt: string }>; + if (!sessions || sessions.length === 0) { + console.log("No active sessions."); + } else { + console.log("Active sessions:"); + for (const session of sessions) { + console.log(` [${session.id}] ${session.name} (connected: ${session.connectedAt})`); + } + } + } finally { + socket.close(); + } + return; + } + + if (args.stop) { + try { + const socket = await connectToBridge(config.cliPort); + await sendAndReceive(socket, { type: "stop" }); + socket.close(); + console.log("Bridge stopped."); + } catch { + console.log("Bridge is not running."); + } + return; + } + + if (args.commands !== undefined) { + const sessionId = parseInt(args.commands, 10); + if (isNaN(sessionId)) { + console.error("Error: --commands requires a numeric session id."); + process.exitCode = 1; + return; + } + + const socket = await ensureBridge(config.cliPort, args.bridgeScript); + try { + const response = await sendAndReceive(socket, { type: "commands", sessionId }); + if (response.error) { + console.error(`Error: ${response.error}`); + process.exitCode = 1; + return; + } + const commands = response.commands as Array<{ id: string; description: string; args?: Array<{ name: string; description: string; required?: boolean }> }>; + if (!commands || commands.length === 0) { + console.log("No commands available for this session."); + } else { + console.log(`Commands for session ${sessionId}:`); + for (const cmd of commands) { + console.log(` --${cmd.id} ${cmd.description}`); + if (cmd.args && cmd.args.length > 0) { + for (const arg of cmd.args) { + console.log(` --${arg.name}${arg.required ? " (required)" : ""} ${arg.description}`); + } + } + } + } + } finally { + socket.close(); + } + return; + } + + // No recognized option — show help. + console.log(HELP_TEXT); +} + +main().catch((error: unknown) => { + console.error(`Error: ${error}`); + process.exitCode = 1; +}); diff --git a/packages/dev/inspector-v2/src/cli/config.ts b/packages/dev/inspector-v2/src/cli/config.ts new file mode 100644 index 000000000000..e9e0d404641d --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/config.ts @@ -0,0 +1,76 @@ +import { existsSync, readFileSync } from "fs"; +import { dirname, join } from "path"; + +const CONFIG_FILENAME = ".babyloninspector"; + +const DEFAULT_BROWSER_PORT = 4400; +const DEFAULT_CLI_PORT = 4401; + +/** + * Configuration for the Inspector CLI bridge. + */ +export interface InspectorBridgeConfig { + /** + * WebSocket port for browser sessions to connect to the bridge. + */ + browserPort: number; + + /** + * WebSocket port for CLI connections to the bridge. + */ + cliPort: number; +} + +/** + * Searches for a `.babyloninspector` config file starting from the given directory + * and walking up the parent chain. Returns the path to the first file found, or + * undefined if none is found. + * @param startDir The directory to start searching from. + * @returns The absolute path to the config file, or undefined. + */ +function findConfigFile(startDir: string): string | undefined { + let current = startDir; + for (;;) { + const candidate = join(current, CONFIG_FILENAME); + if (existsSync(candidate)) { + return candidate; + } + const parent = dirname(current); + if (parent === current) { + // Reached filesystem root. + return undefined; + } + current = parent; + } +} + +/** + * Loads the Inspector bridge configuration by searching for a `.babyloninspector` + * file in the directory parent chain starting from `cwd`. If no file is found, + * or if fields are missing, defaults are used. + * @param cwd The working directory to start the search from. Defaults to `process.cwd()`. + * @returns The resolved configuration. + */ +export function loadConfig(cwd?: string): InspectorBridgeConfig { + const defaults: InspectorBridgeConfig = { + browserPort: DEFAULT_BROWSER_PORT, + cliPort: DEFAULT_CLI_PORT, + }; + + const configPath = findConfigFile(cwd ?? process.cwd()); + if (!configPath) { + return defaults; + } + + try { + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + return { + browserPort: typeof parsed.browserPort === "number" ? parsed.browserPort : defaults.browserPort, + cliPort: typeof parsed.cliPort === "number" ? parsed.cliPort : defaults.cliPort, + }; + } catch { + // If the file is malformed, fall back to defaults. + return defaults; + } +} diff --git a/packages/dev/inspector-v2/src/index.ts b/packages/dev/inspector-v2/src/index.ts index 96fd3cc1d89f..1eac7a8189c3 100644 --- a/packages/dev/inspector-v2/src/index.ts +++ b/packages/dev/inspector-v2/src/index.ts @@ -49,6 +49,9 @@ export * from "./services/settingsStore"; export type { IShellService, ToolbarItemDefinition, SidePaneDefinition, CentralContentDefinition } from "./services/shellService"; export { ShellServiceIdentity } from "./services/shellService"; export * from "./inspector"; +export * from "./inspectable"; +export type { IInspectableCommandRegistry, InspectableCommandDescriptor, InspectableCommandArg } from "./services/inspectableCommandRegistry"; +export { InspectableCommandRegistryIdentity } from "./services/inspectableCommandRegistry"; export { ConvertOptions, Inspector } from "./legacy/inspector"; export { AttachDebugLayer, DetachDebugLayer } from "./legacy/debugLayer"; diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts new file mode 100644 index 000000000000..50c9a13b088c --- /dev/null +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -0,0 +1,101 @@ +import type { IDisposable } from "core/index"; +import type { Scene } from "core/scene"; + +import { Logger } from "core/Misc/logger"; +import { ServiceContainer } from "./modularity/serviceContainer"; +import { MakeInspectableBridgeServiceDefinition } from "./services/inspectableBridgeService"; + +const DEFAULT_PORT = 4400; + +/** + * Options for making a scene inspectable via the Inspector CLI. + */ +export type InspectableOptions = { + /** + * WebSocket port for the bridge's browser port. Defaults to 4400. + */ + port?: number; + + /** + * Session display name reported to the bridge. Defaults to `document.title`. + */ + name?: string; +}; + +/** + * A token returned by {@link StartInspectable} that can be disposed to disconnect + * the scene from the Inspector CLI bridge. + */ +export type InspectableToken = IDisposable & { + /** + * Whether this token has been disposed. + */ + readonly isDisposed: boolean; +}; + +// Track one token per scene so we can return the existing one or clean up on re-entry. +const InspectableTokens = new Map(); + +/** + * Makes a scene inspectable by connecting it to the Inspector CLI bridge. + * This creates a headless {@link ServiceContainer} (no UI) and registers the + * {@link InspectableBridgeService} which opens a WebSocket to the bridge and + * exposes a command registry for CLI-invocable commands. + * + * If the scene is already inspectable, the existing token is returned. + * + * @param scene The scene to make inspectable. + * @param options Optional configuration. + * @returns An {@link InspectableToken} that can be disposed to disconnect. + */ +export function StartInspectable(scene: Scene, options?: Partial): InspectableToken { + // If there is already an active token for this scene, return it. + const existing = InspectableTokens.get(scene); + if (existing && !existing.isDisposed) { + return existing; + } + + const port = options?.port ?? DEFAULT_PORT; + const name = options?.name ?? (typeof document !== "undefined" ? document.title : "Babylon.js Scene"); + + const serviceContainer = new ServiceContainer("InspectableContainer"); + + let disposed = false; + + const token: InspectableToken = { + get isDisposed() { + return disposed; + }, + dispose() { + if (disposed) { + return; + } + disposed = true; + serviceContainer.dispose(); + InspectableTokens.delete(scene); + sceneDisposeObserver.remove(); + }, + }; + + InspectableTokens.set(scene, token); + + // Auto-dispose when the scene is disposed. + const sceneDisposeObserver = scene.onDisposeObservable.addOnce(() => { + token.dispose(); + }); + + // Initialize the service container asynchronously. + serviceContainer + .addServiceAsync( + MakeInspectableBridgeServiceDefinition({ + port, + name, + }) + ) + .catch((error: unknown) => { + Logger.Error(`Failed to initialize InspectableBridgeService: ${error}`); + token.dispose(); + }); + + return token; +} diff --git a/packages/dev/inspector-v2/src/services/inspectableBridgeService.ts b/packages/dev/inspector-v2/src/services/inspectableBridgeService.ts new file mode 100644 index 000000000000..df5505068d1e --- /dev/null +++ b/packages/dev/inspector-v2/src/services/inspectableBridgeService.ts @@ -0,0 +1,171 @@ +import type { IDisposable } from "core/index"; +import type { ServiceDefinition } from "../modularity/serviceDefinition"; + +import type { IInspectableCommandRegistry, InspectableCommandDescriptor } from "./inspectableCommandRegistry"; + +import { Logger } from "core/Misc/logger"; +import { InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +/** + * Options for the inspectable bridge service. + */ +export interface InspectableBridgeServiceOptions { + /** + * The WebSocket port for the bridge's browser port. + */ + port: number; + + /** + * The session display name sent to the bridge. + */ + name: string; +} + +/** + * Creates the service definition for the InspectableBridgeService. + * @param options The options for connecting to the bridge. + * @returns A service definition that produces an IInspectableCommandRegistry. + */ +export function MakeInspectableBridgeServiceDefinition(options: InspectableBridgeServiceOptions): ServiceDefinition<[IInspectableCommandRegistry], []> { + return { + friendlyName: "Inspectable Bridge Service", + produces: [InspectableCommandRegistryIdentity], + factory: () => { + const commands = new Map(); + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let disposed = false; + + function connect() { + if (disposed) { + return; + } + + try { + ws = new WebSocket(`ws://localhost:${options.port}`); + } catch { + scheduleReconnect(); + return; + } + + ws.onopen = () => { + ws?.send(JSON.stringify({ type: "register", name: options.name })); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + handleMessage(message); + } catch { + Logger.Warn("InspectableBridgeService: Failed to parse message from bridge."); + } + }; + + ws.onclose = () => { + ws = null; + scheduleReconnect(); + }; + + ws.onerror = () => { + // onclose will fire after onerror, which handles reconnection. + }; + } + + function scheduleReconnect() { + if (disposed || reconnectTimer !== null) { + return; + } + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connect(); + }, 3000); + } + + function handleMessage(message: { type: string; requestId?: string; commandId?: string; args?: Record }) { + switch (message.type) { + case "listCommands": { + const commandList = Array.from(commands.values()).map((cmd) => ({ + id: cmd.id, + description: cmd.description, + args: cmd.args, + })); + ws?.send( + JSON.stringify({ + type: "commandListResponse", + requestId: message.requestId, + commands: commandList, + }) + ); + break; + } + case "execCommand": { + const command = commands.get(message.commandId ?? ""); + if (!command) { + ws?.send( + JSON.stringify({ + type: "commandResponse", + requestId: message.requestId, + error: `Unknown command: ${message.commandId}`, + }) + ); + break; + } + command + .execute(message.args ?? {}) + .then((result) => { + ws?.send( + JSON.stringify({ + type: "commandResponse", + requestId: message.requestId, + result, + }) + ); + }) + .catch((error: unknown) => { + ws?.send( + JSON.stringify({ + type: "commandResponse", + requestId: message.requestId, + error: String(error), + }) + ); + }); + break; + } + } + } + + // Initiate connection. + connect(); + + const registry: IInspectableCommandRegistry & IDisposable = { + addCommand(descriptor: InspectableCommandDescriptor): IDisposable { + if (commands.has(descriptor.id)) { + throw new Error(`Command '${descriptor.id}' is already registered.`); + } + commands.set(descriptor.id, descriptor); + return { + dispose: () => { + commands.delete(descriptor.id); + }, + }; + }, + dispose: () => { + disposed = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + commands.clear(); + if (ws) { + ws.onclose = null; + ws.close(); + ws = null; + } + }, + }; + + return registry; + }, + }; +} diff --git a/packages/dev/inspector-v2/src/services/inspectableCommandRegistry.ts b/packages/dev/inspector-v2/src/services/inspectableCommandRegistry.ts new file mode 100644 index 000000000000..81cb1cb4af0c --- /dev/null +++ b/packages/dev/inspector-v2/src/services/inspectableCommandRegistry.ts @@ -0,0 +1,66 @@ +import type { IDisposable } from "core/index"; +import type { IService } from "../modularity/serviceDefinition"; + +/** + * Describes an argument for an inspectable command. + */ +export type InspectableCommandArg = { + /** + * The name of the argument. + */ + name: string; + + /** + * A description of the argument. + */ + description: string; + + /** + * Whether the argument is required. + */ + required?: boolean; +}; + +/** + * Describes a command that can be invoked from the CLI. + */ +export type InspectableCommandDescriptor = { + /** + * A unique identifier for the command. + */ + id: string; + + /** + * A human-readable description of what the command does. + */ + description: string; + + /** + * The arguments that this command accepts. + */ + args?: InspectableCommandArg[]; + + /** + * Executes the command with the given arguments and returns a result string. + * @param args A map of argument names to their values. + * @returns A promise that resolves to the result string. + */ + execute: (args: Record) => Promise; +}; + +/** + * The service identity for the inspectable command registry. + */ +export const InspectableCommandRegistryIdentity = Symbol("InspectableCommandRegistry"); + +/** + * A registry for commands that can be invoked from the Inspector CLI. + */ +export interface IInspectableCommandRegistry extends IService { + /** + * Registers a command that can be invoked from the Inspector CLI. + * @param descriptor The command descriptor. + * @returns A disposable token that unregisters the command when disposed. + */ + addCommand(descriptor: InspectableCommandDescriptor): IDisposable; +} diff --git a/packages/dev/inspector-v2/test/unit/cli/config.test.ts b/packages/dev/inspector-v2/test/unit/cli/config.test.ts new file mode 100644 index 000000000000..aeaccc46b8a1 --- /dev/null +++ b/packages/dev/inspector-v2/test/unit/cli/config.test.ts @@ -0,0 +1,61 @@ +import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { loadConfig } from "../../../src/cli/config"; + +describe("Config Loader", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = join(tmpdir(), `inspector-config-test-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns defaults when no config file exists", () => { + const config = loadConfig(tempDir); + expect(config.browserPort).toBe(4400); + expect(config.cliPort).toBe(4401); + }); + + it("reads config from .babyloninspector in the given directory", () => { + writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ browserPort: 5500, cliPort: 5501 })); + const config = loadConfig(tempDir); + expect(config.browserPort).toBe(5500); + expect(config.cliPort).toBe(5501); + }); + + it("walks up parent directories to find config", () => { + const childDir = join(tempDir, "a", "b", "c"); + mkdirSync(childDir, { recursive: true }); + writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ browserPort: 6600 })); + const config = loadConfig(childDir); + expect(config.browserPort).toBe(6600); + expect(config.cliPort).toBe(4401); // default + }); + + it("merges partial config with defaults", () => { + writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ cliPort: 9999 })); + const config = loadConfig(tempDir); + expect(config.browserPort).toBe(4400); // default + expect(config.cliPort).toBe(9999); + }); + + it("returns defaults for malformed JSON", () => { + writeFileSync(join(tempDir, ".babyloninspector"), "not valid json{{{"); + const config = loadConfig(tempDir); + expect(config.browserPort).toBe(4400); + expect(config.cliPort).toBe(4401); + }); + + it("ignores non-numeric port values", () => { + writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ browserPort: "abc", cliPort: true })); + const config = loadConfig(tempDir); + expect(config.browserPort).toBe(4400); + expect(config.cliPort).toBe(4401); + }); +}); diff --git a/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts new file mode 100644 index 000000000000..8fee72826dff --- /dev/null +++ b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from "vitest"; +import { InspectableCommandRegistryIdentity } from "../../../src/services/inspectableCommandRegistry"; +import { MakeInspectableBridgeServiceDefinition } from "../../../src/services/inspectableBridgeService"; + +describe("InspectableBridgeService", () => { + describe("service definition", () => { + it("produces InspectableCommandRegistryIdentity", () => { + const definition = MakeInspectableBridgeServiceDefinition({ port: 4400, name: "test" }); + expect(definition.produces).toContain(InspectableCommandRegistryIdentity); + }); + + it("has a friendly name", () => { + const definition = MakeInspectableBridgeServiceDefinition({ port: 4400, name: "test" }); + expect(definition.friendlyName).toBe("Inspectable Bridge Service"); + }); + }); + + describe("command registry", () => { + it("can register and unregister a command", () => { + const definition = MakeInspectableBridgeServiceDefinition({ port: 0, name: "test" }); + // Call factory to get the registry (it will try to connect but that's fine — it won't crash on failure). + const registry = definition.factory() as ReturnType & { dispose: () => void }; + + const disposal = registry.addCommand({ + id: "test-cmd", + description: "A test command", + execute: async () => "ok", + }); + + expect(disposal).toBeDefined(); + expect(disposal.dispose).toBeInstanceOf(Function); + + // Should not throw. + disposal.dispose(); + + // Clean up. + registry.dispose(); + }); + + it("throws when registering a duplicate command id", () => { + const definition = MakeInspectableBridgeServiceDefinition({ port: 0, name: "test" }); + const registry = definition.factory() as ReturnType & { dispose: () => void }; + + registry.addCommand({ + id: "dup-cmd", + description: "First", + execute: async () => "first", + }); + + expect(() => { + registry.addCommand({ + id: "dup-cmd", + description: "Second", + execute: async () => "second", + }); + }).toThrow("Command 'dup-cmd' is already registered."); + + registry.dispose(); + }); + + it("allows re-registration after disposal", () => { + const definition = MakeInspectableBridgeServiceDefinition({ port: 0, name: "test" }); + const registry = definition.factory() as ReturnType & { dispose: () => void }; + + const token = registry.addCommand({ + id: "reuse-cmd", + description: "Reusable", + execute: async () => "ok", + }); + + token.dispose(); + + // Should not throw since we disposed the first registration. + const token2 = registry.addCommand({ + id: "reuse-cmd", + description: "Reusable again", + execute: async () => "ok again", + }); + + expect(token2).toBeDefined(); + token2.dispose(); + registry.dispose(); + }); + }); +}); diff --git a/packages/public/@babylonjs/inspector-v2/.gitignore b/packages/public/@babylonjs/inspector-v2/.gitignore new file mode 100644 index 000000000000..44519b5db33e --- /dev/null +++ b/packages/public/@babylonjs/inspector-v2/.gitignore @@ -0,0 +1 @@ +bin/ diff --git a/packages/public/@babylonjs/inspector-v2/package.json b/packages/public/@babylonjs/inspector-v2/package.json index 66b07eff3e5a..b68976b0900e 100644 --- a/packages/public/@babylonjs/inspector-v2/package.json +++ b/packages/public/@babylonjs/inspector-v2/package.json @@ -12,7 +12,11 @@ }, "./package.json": "./package.json" }, + "bin": { + "babylon-inspector": "./bin/inspector-cli.mjs" + }, "files": [ + "bin/**/*.mjs", "lib/**/*.js", "lib/**/*.d.ts", "lib/**/*.map", @@ -21,9 +25,10 @@ ], "scripts": { "build": "npm run clean && npm run bundle", - "clean": "rimraf lib && rimraf dist && rimraf *.tsbuildinfo -g", - "bundle": "npm run bundle:lib", + "clean": "rimraf lib && rimraf bin && rimraf dist && rimraf *.tsbuildinfo -g", + "bundle": "npm run bundle:lib && npm run bundle:cli", "bundle:lib": "rollup -c rollup.config.lib.mjs", + "bundle:cli": "rollup -c rollup.config.cli.mjs", "pack": "npm run build && npm pack" }, "peerDependencies": { @@ -48,9 +53,11 @@ "@rollup/plugin-commonjs": "^26.0.1", "@rollup/plugin-node-resolve": "^15.2.3", "@rollup/plugin-typescript": "^11.1.6", + "@types/ws": "^8.5.0", "chalk": "^5.3.0", "rollup": "^4.59.0", - "rollup-plugin-dts": "^6.1.1" + "rollup-plugin-dts": "^6.1.1", + "ws": "^8.18.0" }, "keywords": [ "3D", diff --git a/packages/public/@babylonjs/inspector-v2/rollup.config.cli.mjs b/packages/public/@babylonjs/inspector-v2/rollup.config.cli.mjs new file mode 100644 index 000000000000..2e8d8754e723 --- /dev/null +++ b/packages/public/@babylonjs/inspector-v2/rollup.config.cli.mjs @@ -0,0 +1,40 @@ +import typescript from "@rollup/plugin-typescript"; +import { nodeResolve } from "@rollup/plugin-node-resolve"; +import commonjs from "@rollup/plugin-commonjs"; + +const commonPlugins = [ + typescript({ tsconfig: "tsconfig.cli.json" }), + nodeResolve({ preferBuiltins: true }), + commonjs(), +]; + +const cliConfig = { + input: "../../../dev/inspector-v2/src/cli/cli.ts", + output: { + file: "bin/inspector-cli.mjs", + format: "es", + sourcemap: true, + banner: "#!/usr/bin/env node", + }, + plugins: commonPlugins, + onwarn(warning, warn) { + // Treat all other warnings as errors. + throw new Error(warning.message); + }, +}; + +const bridgeConfig = { + input: "../../../dev/inspector-v2/src/cli/bridge.ts", + output: { + file: "bin/inspector-bridge.mjs", + format: "es", + sourcemap: true, + }, + plugins: commonPlugins, + onwarn(warning, warn) { + // Treat all other warnings as errors. + throw new Error(warning.message); + }, +}; + +export default [cliConfig, bridgeConfig]; diff --git a/packages/public/@babylonjs/inspector-v2/tsconfig.cli.json b/packages/public/@babylonjs/inspector-v2/tsconfig.cli.json new file mode 100644 index 000000000000..03331b43b1b4 --- /dev/null +++ b/packages/public/@babylonjs/inspector-v2/tsconfig.cli.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "outDir": "./bin", + "rootDir": "../../../dev/inspector-v2/src/cli", + "declaration": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["../../../dev/inspector-v2/src/cli/**/*"] +} From 64c3756139220b56d978f45190f9e31b3d67987d Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 13:40:03 -0700 Subject: [PATCH 02/42] Move files into cli directory --- packages/dev/inspector-v2/src/index.ts | 4 ++-- packages/dev/inspector-v2/src/inspectable.ts | 2 +- .../src/services/{ => cli}/inspectableBridgeService.ts | 2 +- .../src/services/{ => cli}/inspectableCommandRegistry.ts | 2 +- .../test/unit/services/inspectableBridgeService.test.ts | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) rename packages/dev/inspector-v2/src/services/{ => cli}/inspectableBridgeService.ts (96%) rename packages/dev/inspector-v2/src/services/{ => cli}/inspectableCommandRegistry.ts (92%) diff --git a/packages/dev/inspector-v2/src/index.ts b/packages/dev/inspector-v2/src/index.ts index 1eac7a8189c3..09b28a3d67c0 100644 --- a/packages/dev/inspector-v2/src/index.ts +++ b/packages/dev/inspector-v2/src/index.ts @@ -50,8 +50,8 @@ export type { IShellService, ToolbarItemDefinition, SidePaneDefinition, CentralC export { ShellServiceIdentity } from "./services/shellService"; export * from "./inspector"; export * from "./inspectable"; -export type { IInspectableCommandRegistry, InspectableCommandDescriptor, InspectableCommandArg } from "./services/inspectableCommandRegistry"; -export { InspectableCommandRegistryIdentity } from "./services/inspectableCommandRegistry"; +export type { IInspectableCommandRegistry, InspectableCommandDescriptor, InspectableCommandArg } from "./services/cli/inspectableCommandRegistry"; +export { InspectableCommandRegistryIdentity } from "./services/cli/inspectableCommandRegistry"; export { ConvertOptions, Inspector } from "./legacy/inspector"; export { AttachDebugLayer, DetachDebugLayer } from "./legacy/debugLayer"; diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index 50c9a13b088c..a2196d994968 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -3,7 +3,7 @@ import type { Scene } from "core/scene"; import { Logger } from "core/Misc/logger"; import { ServiceContainer } from "./modularity/serviceContainer"; -import { MakeInspectableBridgeServiceDefinition } from "./services/inspectableBridgeService"; +import { MakeInspectableBridgeServiceDefinition } from "./services/cli/inspectableBridgeService"; const DEFAULT_PORT = 4400; diff --git a/packages/dev/inspector-v2/src/services/inspectableBridgeService.ts b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts similarity index 96% rename from packages/dev/inspector-v2/src/services/inspectableBridgeService.ts rename to packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts index df5505068d1e..dbf23475c0d8 100644 --- a/packages/dev/inspector-v2/src/services/inspectableBridgeService.ts +++ b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts @@ -1,5 +1,5 @@ import type { IDisposable } from "core/index"; -import type { ServiceDefinition } from "../modularity/serviceDefinition"; +import type { ServiceDefinition } from "../../modularity/serviceDefinition"; import type { IInspectableCommandRegistry, InspectableCommandDescriptor } from "./inspectableCommandRegistry"; diff --git a/packages/dev/inspector-v2/src/services/inspectableCommandRegistry.ts b/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts similarity index 92% rename from packages/dev/inspector-v2/src/services/inspectableCommandRegistry.ts rename to packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts index 81cb1cb4af0c..94824d670e0e 100644 --- a/packages/dev/inspector-v2/src/services/inspectableCommandRegistry.ts +++ b/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts @@ -1,5 +1,5 @@ import type { IDisposable } from "core/index"; -import type { IService } from "../modularity/serviceDefinition"; +import type { IService } from "../../modularity/serviceDefinition"; /** * Describes an argument for an inspectable command. diff --git a/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts index 8fee72826dff..b17c9bf0ae33 100644 --- a/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts +++ b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { InspectableCommandRegistryIdentity } from "../../../src/services/inspectableCommandRegistry"; -import { MakeInspectableBridgeServiceDefinition } from "../../../src/services/inspectableBridgeService"; +import { InspectableCommandRegistryIdentity } from "../../../src/services/cli/inspectableCommandRegistry"; +import { MakeInspectableBridgeServiceDefinition } from "../../../src/services/cli/inspectableBridgeService"; describe("InspectableBridgeService", () => { describe("service definition", () => { From b6abefea09ff15ab7be257677465231ff097ff1c Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 13:48:43 -0700 Subject: [PATCH 03/42] Protocol --- packages/dev/inspector-v2/src/cli/bridge.ts | 71 ++++---- packages/dev/inspector-v2/src/cli/cli.ts | 19 +- packages/dev/inspector-v2/src/cli/protocol.ts | 163 ++++++++++++++++++ .../services/cli/inspectableBridgeService.ts | 63 ++++--- 4 files changed, 241 insertions(+), 75 deletions(-) create mode 100644 packages/dev/inspector-v2/src/cli/protocol.ts diff --git a/packages/dev/inspector-v2/src/cli/bridge.ts b/packages/dev/inspector-v2/src/cli/bridge.ts index 71cfdd50984a..d8901fe1b2bd 100644 --- a/packages/dev/inspector-v2/src/cli/bridge.ts +++ b/packages/dev/inspector-v2/src/cli/bridge.ts @@ -1,13 +1,17 @@ import ws from "ws"; import { loadConfig } from "./config.js"; +import type { + BrowserRequest, + BrowserResponse, + CliRequest, + CliResponse, + SessionInfo, +} from "./protocol.js"; type WebSocket = ws; type WebSocketServerType = ws.Server; -interface Session { - id: number; - name: string; - connectedAt: string; +interface Session extends SessionInfo { ws: WebSocket; } @@ -37,7 +41,7 @@ function startBridge(): void { let session: Session | null = null; socket.on("message", (data) => { - let message: { type: string; name?: string; requestId?: string; commands?: unknown; result?: string; error?: string }; + let message: BrowserRequest; try { message = JSON.parse(data.toString()); } catch { @@ -49,7 +53,7 @@ function startBridge(): void { const id = nextSessionId++; session = { id, - name: message.name ?? "Unknown", + name: message.name, connectedAt: new Date().toISOString(), ws: socket, }; @@ -60,13 +64,10 @@ function startBridge(): void { case "commandListResponse": case "commandResponse": { // Forward response back to the CLI that requested it. - const requestId = message.requestId; - if (requestId) { - const resolve = pendingBrowserRequests.get(requestId); - if (resolve) { - pendingBrowserRequests.delete(requestId); - resolve(JSON.stringify(message)); - } + const resolve = pendingBrowserRequests.get(message.requestId); + if (resolve) { + pendingBrowserRequests.delete(message.requestId); + resolve(JSON.stringify(message)); } break; } @@ -83,60 +84,66 @@ function startBridge(): void { cliWss.on("connection", (socket) => { socket.on("message", (data) => { - let message: { type: string; sessionId?: number; commandId?: string; args?: Record }; + let message: CliRequest; try { message = JSON.parse(data.toString()); } catch { return; } + function sendCliResponse(response: CliResponse) { + socket.send(JSON.stringify(response)); + } + + function sendBrowserRequest(target: Session, request: BrowserResponse) { + target.ws.send(JSON.stringify(request)); + } + switch (message.type) { case "sessions": { - const sessionList = Array.from(sessions.values()).map((s) => ({ + const sessionList: SessionInfo[] = Array.from(sessions.values()).map((s) => ({ id: s.id, name: s.name, connectedAt: s.connectedAt, })); - socket.send(JSON.stringify({ type: "sessionsResponse", sessions: sessionList })); + sendCliResponse({ type: "sessionsResponse", sessions: sessionList }); break; } case "commands": { - const session = sessions.get(message.sessionId ?? -1); + const session = sessions.get(message.sessionId); if (!session) { - socket.send(JSON.stringify({ type: "commandsResponse", error: `No session with id ${message.sessionId}` })); + sendCliResponse({ type: "commandsResponse", error: `No session with id ${message.sessionId}` }); break; } const requestId = generateRequestId(); - session.ws.send(JSON.stringify({ type: "listCommands", requestId })); + sendBrowserRequest(session, { type: "listCommands", requestId }); waitForBrowserResponse(requestId).then( (response) => socket.send(response), - () => socket.send(JSON.stringify({ type: "commandsResponse", error: "Timeout waiting for browser response" })) + () => sendCliResponse({ type: "commandsResponse", error: "Timeout waiting for browser response" }) ); break; } case "exec": { - const session = sessions.get(message.sessionId ?? -1); + const session = sessions.get(message.sessionId); if (!session) { - socket.send(JSON.stringify({ type: "execResponse", error: `No session with id ${message.sessionId}` })); + sendCliResponse({ type: "execResponse", error: `No session with id ${message.sessionId}` }); break; } const requestId = generateRequestId(); - session.ws.send( - JSON.stringify({ - type: "execCommand", - requestId, - commandId: message.commandId, - args: message.args, - }) - ); + sendBrowserRequest(session, { + type: "execCommand", + requestId, + commandId: message.commandId, + args: message.args, + }); waitForBrowserResponse(requestId).then( (response) => socket.send(response), - () => socket.send(JSON.stringify({ type: "execResponse", error: "Timeout waiting for browser response" })) + () => sendCliResponse({ type: "execResponse", error: "Timeout waiting for browser response" }) ); break; } case "stop": { - socket.send(JSON.stringify({ type: "stopResponse", success: true })); + sendCliResponse({ type: "stopResponse", success: true }); shutdown(browserWss, cliWss); break; } diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index c08e2fd1697b..05e104e9042a 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "url"; import { dirname, join, resolve } from "path"; import ws from "ws"; import { loadConfig } from "./config.js"; +import type { CliRequest, CliResponse, CommandsResponse, SessionsResponse } from "./protocol.js"; type WebSocket = ws; @@ -68,7 +69,7 @@ function connectToBridge(port: number): Promise { }); } -function sendAndReceive(socket: WebSocket, message: object): Promise> { +function sendAndReceive(socket: WebSocket, message: CliRequest): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Timeout waiting for bridge response.")); @@ -77,7 +78,7 @@ function sendAndReceive(socket: WebSocket, message: object): Promise { clearTimeout(timeout); try { - resolve(JSON.parse(data.toString())); + resolve(JSON.parse(data.toString()) as T); } catch { reject(new Error("Failed to parse bridge response.")); } @@ -129,13 +130,12 @@ async function main(): Promise { if (args.sessions) { const socket = await ensureBridge(config.cliPort, args.bridgeScript); try { - const response = await sendAndReceive(socket, { type: "sessions" }); - const sessions = response.sessions as Array<{ id: number; name: string; connectedAt: string }>; - if (!sessions || sessions.length === 0) { + const response = await sendAndReceive(socket, { type: "sessions" }); + if (response.sessions.length === 0) { console.log("No active sessions."); } else { console.log("Active sessions:"); - for (const session of sessions) { + for (const session of response.sessions) { console.log(` [${session.id}] ${session.name} (connected: ${session.connectedAt})`); } } @@ -167,18 +167,17 @@ async function main(): Promise { const socket = await ensureBridge(config.cliPort, args.bridgeScript); try { - const response = await sendAndReceive(socket, { type: "commands", sessionId }); + const response = await sendAndReceive(socket, { type: "commands", sessionId }); if (response.error) { console.error(`Error: ${response.error}`); process.exitCode = 1; return; } - const commands = response.commands as Array<{ id: string; description: string; args?: Array<{ name: string; description: string; required?: boolean }> }>; - if (!commands || commands.length === 0) { + if (!response.commands || response.commands.length === 0) { console.log("No commands available for this session."); } else { console.log(`Commands for session ${sessionId}:`); - for (const cmd of commands) { + for (const cmd of response.commands) { console.log(` --${cmd.id} ${cmd.description}`); if (cmd.args && cmd.args.length > 0) { for (const arg of cmd.args) { diff --git a/packages/dev/inspector-v2/src/cli/protocol.ts b/packages/dev/inspector-v2/src/cli/protocol.ts new file mode 100644 index 000000000000..3df6cc063c47 --- /dev/null +++ b/packages/dev/inspector-v2/src/cli/protocol.ts @@ -0,0 +1,163 @@ +// ---- Shared types ---- + +/** + * Serializable description of a command argument, used in protocol messages. + */ +export type CommandArgInfo = { + name: string; + description: string; + required?: boolean; +}; + +/** + * Serializable description of a command, used in protocol messages. + */ +export type CommandInfo = { + id: string; + description: string; + args?: CommandArgInfo[]; +}; + +/** + * Serializable description of a session, used in protocol messages. + */ +export type SessionInfo = { + id: number; + name: string; + connectedAt: string; +}; + +// ---- CLI ↔ Bridge (CLI port) ---- + +/** + * CLI → Bridge: Request the list of active browser sessions. + */ +export type SessionsRequest = { + type: "sessions"; +}; + +/** + * CLI → Bridge: Request the list of commands available from a session. + */ +export type CommandsRequest = { + type: "commands"; + sessionId: number; +}; + +/** + * CLI → Bridge: Execute a command on a session. + */ +export type ExecRequest = { + type: "exec"; + sessionId: number; + commandId: string; + args: Record; +}; + +/** + * CLI → Bridge: Stop the bridge process. + */ +export type StopRequest = { + type: "stop"; +}; + +/** + * All messages that the CLI sends to the bridge. + */ +export type CliRequest = SessionsRequest | CommandsRequest | ExecRequest | StopRequest; + +/** + * Bridge → CLI: Response with the list of active sessions. + */ +export type SessionsResponse = { + type: "sessionsResponse"; + sessions: SessionInfo[]; +}; + +/** + * Bridge → CLI: Response with the list of commands from a session. + */ +export type CommandsResponse = { + type: "commandsResponse"; + commands?: CommandInfo[]; + error?: string; +}; + +/** + * Bridge → CLI: Response with the result of a command execution. + */ +export type ExecResponse = { + type: "execResponse"; + result?: string; + error?: string; +}; + +/** + * Bridge → CLI: Acknowledgement that the bridge is stopping. + */ +export type StopResponse = { + type: "stopResponse"; + success: boolean; +}; + +/** + * All messages that the bridge sends to the CLI. + */ +export type CliResponse = SessionsResponse | CommandsResponse | ExecResponse | StopResponse; + +// ---- Bridge ↔ Browser (browser port) ---- + +/** + * Browser → Bridge: Register a new session. + */ +export type RegisterRequest = { + type: "register"; + name: string; +}; + +/** + * Browser → Bridge: Response to a listCommands request from the bridge. + */ +export type CommandListResponse = { + type: "commandListResponse"; + requestId: string; + commands: CommandInfo[]; +}; + +/** + * Browser → Bridge: Response to an execCommand request from the bridge. + */ +export type CommandResponse = { + type: "commandResponse"; + requestId: string; + result?: string; + error?: string; +}; + +/** + * All messages that the browser sends to the bridge. + */ +export type BrowserRequest = RegisterRequest | CommandListResponse | CommandResponse; + +/** + * Bridge → Browser: Request the list of registered commands. + */ +export type ListCommandsRequest = { + type: "listCommands"; + requestId: string; +}; + +/** + * Bridge → Browser: Request execution of a command. + */ +export type ExecCommandRequest = { + type: "execCommand"; + requestId: string; + commandId: string; + args: Record; +}; + +/** + * All messages that the bridge sends to the browser. + */ +export type BrowserResponse = ListCommandsRequest | ExecCommandRequest; diff --git a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts index dbf23475c0d8..5484ac434973 100644 --- a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts +++ b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts @@ -2,6 +2,7 @@ import type { IDisposable } from "core/index"; import type { ServiceDefinition } from "../../modularity/serviceDefinition"; import type { IInspectableCommandRegistry, InspectableCommandDescriptor } from "./inspectableCommandRegistry"; +import type { BrowserRequest, BrowserResponse, CommandInfo } from "../../cli/protocol"; import { Logger } from "core/Misc/logger"; import { InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; @@ -36,6 +37,10 @@ export function MakeInspectableBridgeServiceDefinition(options: InspectableBridg let reconnectTimer: ReturnType | null = null; let disposed = false; + function sendToBridge(message: BrowserRequest) { + ws?.send(JSON.stringify(message)); + } + function connect() { if (disposed) { return; @@ -49,7 +54,7 @@ export function MakeInspectableBridgeServiceDefinition(options: InspectableBridg } ws.onopen = () => { - ws?.send(JSON.stringify({ type: "register", name: options.name })); + sendToBridge({ type: "register", name: options.name }); }; ws.onmessage = (event) => { @@ -81,54 +86,46 @@ export function MakeInspectableBridgeServiceDefinition(options: InspectableBridg }, 3000); } - function handleMessage(message: { type: string; requestId?: string; commandId?: string; args?: Record }) { + function handleMessage(message: BrowserResponse) { switch (message.type) { case "listCommands": { - const commandList = Array.from(commands.values()).map((cmd) => ({ + const commandList: CommandInfo[] = Array.from(commands.values()).map((cmd) => ({ id: cmd.id, description: cmd.description, args: cmd.args, })); - ws?.send( - JSON.stringify({ - type: "commandListResponse", - requestId: message.requestId, - commands: commandList, - }) - ); + sendToBridge({ + type: "commandListResponse", + requestId: message.requestId, + commands: commandList, + }); break; } case "execCommand": { - const command = commands.get(message.commandId ?? ""); + const command = commands.get(message.commandId); if (!command) { - ws?.send( - JSON.stringify({ - type: "commandResponse", - requestId: message.requestId, - error: `Unknown command: ${message.commandId}`, - }) - ); + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + error: `Unknown command: ${message.commandId}`, + }); break; } command - .execute(message.args ?? {}) + .execute(message.args) .then((result) => { - ws?.send( - JSON.stringify({ - type: "commandResponse", - requestId: message.requestId, - result, - }) - ); + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + result, + }); }) .catch((error: unknown) => { - ws?.send( - JSON.stringify({ - type: "commandResponse", - requestId: message.requestId, - error: String(error), - }) - ); + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + error: String(error), + }); }); break; } From f0a490d434916fdacb50b60191a0d5182a987a33 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 13:52:57 -0700 Subject: [PATCH 04/42] Fix unit tests --- .../services/inspectableBridgeService.test.ts | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts index b17c9bf0ae33..f45d9cd5fc43 100644 --- a/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts +++ b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts @@ -16,10 +16,9 @@ describe("InspectableBridgeService", () => { }); describe("command registry", () => { - it("can register and unregister a command", () => { + it("can register and unregister a command", async () => { const definition = MakeInspectableBridgeServiceDefinition({ port: 0, name: "test" }); - // Call factory to get the registry (it will try to connect but that's fine — it won't crash on failure). - const registry = definition.factory() as ReturnType & { dispose: () => void }; + const registry = await definition.factory(); const disposal = registry.addCommand({ id: "test-cmd", @@ -30,16 +29,13 @@ describe("InspectableBridgeService", () => { expect(disposal).toBeDefined(); expect(disposal.dispose).toBeInstanceOf(Function); - // Should not throw. disposal.dispose(); - - // Clean up. - registry.dispose(); + registry.dispose?.(); }); - it("throws when registering a duplicate command id", () => { + it("throws when registering a duplicate command id", async () => { const definition = MakeInspectableBridgeServiceDefinition({ port: 0, name: "test" }); - const registry = definition.factory() as ReturnType & { dispose: () => void }; + const registry = await definition.factory(); registry.addCommand({ id: "dup-cmd", @@ -55,12 +51,12 @@ describe("InspectableBridgeService", () => { }); }).toThrow("Command 'dup-cmd' is already registered."); - registry.dispose(); + registry.dispose?.(); }); - it("allows re-registration after disposal", () => { + it("allows re-registration after disposal", async () => { const definition = MakeInspectableBridgeServiceDefinition({ port: 0, name: "test" }); - const registry = definition.factory() as ReturnType & { dispose: () => void }; + const registry = await definition.factory(); const token = registry.addCommand({ id: "reuse-cmd", @@ -70,7 +66,6 @@ describe("InspectableBridgeService", () => { token.dispose(); - // Should not throw since we disposed the first registration. const token2 = registry.addCommand({ id: "reuse-cmd", description: "Reusable again", @@ -79,7 +74,7 @@ describe("InspectableBridgeService", () => { expect(token2).toBeDefined(); token2.dispose(); - registry.dispose(); + registry.dispose?.(); }); }); }); From 87f6f68fa57945449b80f4a308730976000c331f Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 14:24:00 -0700 Subject: [PATCH 05/42] Add query mesh command --- packages/dev/core/src/Meshes/mesh.ts | 2 +- packages/dev/inspector-v2/src/cli/cli.ts | 103 +++++++++++++++++- packages/dev/inspector-v2/src/inspectable.ts | 22 +++- .../src/services/cli/entityQueryService.ts | 51 +++++++++ 4 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 packages/dev/inspector-v2/src/services/cli/entityQueryService.ts diff --git a/packages/dev/core/src/Meshes/mesh.ts b/packages/dev/core/src/Meshes/mesh.ts index 7d86f3d5724c..20d76b1c21f1 100644 --- a/packages/dev/core/src/Meshes/mesh.ts +++ b/packages/dev/core/src/Meshes/mesh.ts @@ -4195,7 +4195,7 @@ export class Mesh extends AbstractMesh implements IGetSetVerticesData { // Physics //TODO implement correct serialization for physics impostors. if (this.getScene()._getComponent(SceneComponentConstants.NAME_PHYSICSENGINE)) { - const impostor = this.getPhysicsImpostor(); + const impostor = this.getPhysicsImpostor?.(); if (impostor) { serializationObject.physicsMass = impostor.getParam("mass"); serializationObject.physicsFriction = impostor.getParam("friction"); diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index 05e104e9042a..f35af3f0edf7 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "url"; import { dirname, join, resolve } from "path"; import ws from "ws"; import { loadConfig } from "./config.js"; -import type { CliRequest, CliResponse, CommandsResponse, SessionsResponse } from "./protocol.js"; +import type { CliRequest, CliResponse, CommandsResponse, ExecResponse, SessionsResponse } from "./protocol.js"; type WebSocket = ws; @@ -14,13 +14,16 @@ const HELP_TEXT = `babylon-inspector — Interact with running Babylon.js scenes USAGE babylon-inspector [options] + babylon-inspector --exec [--arg value ...] OPTIONS --help Show this help message. --sessions List active browser sessions connected to the bridge. --stop Stop the bridge process. --commands List commands available from a specific session. - Use -- --help for help on a specific command. + --exec [--arg value ...] + Execute a command on a session. Extra --key value + pairs are forwarded as command arguments. CONFIGURATION Place a .babyloninspector JSON file anywhere in the directory parent chain: @@ -29,6 +32,7 @@ CONFIGURATION EXAMPLES babylon-inspector --sessions babylon-inspector --commands 1 + babylon-inspector --exec 1 query-mesh --uniqueId 42 `; interface ParsedArgs { @@ -36,28 +40,77 @@ interface ParsedArgs { sessions: boolean; stop: boolean; commands?: string; + exec?: string; bridgeScript?: string; + rest: string[]; } function parseCliArgs(): ParsedArgs { - const { values } = parseArgs({ + const { values, tokens } = parseArgs({ options: { help: { type: "boolean", default: false }, sessions: { type: "boolean", default: false }, stop: { type: "boolean", default: false }, commands: { type: "string" }, + exec: { type: "string" }, "bridge-script": { type: "string" }, }, strict: false, allowPositionals: true, + tokens: true, }); + // Collect all unknown --key value pairs that appear after --exec's positional command id. + // With strict:false, parseArgs treats unknown options as booleans and their values as positionals, + // so we re-pair them using the raw token stream. + const rest: string[] = []; + if (tokens) { + let pastExecCommandId = false; + let pendingOptionName: string | null = null; + for (const token of tokens) { + if (token.kind === "positional" && !pastExecCommandId && values.exec !== undefined) { + // First positional after --exec is the command id. + pastExecCommandId = true; + rest.push(token.value); + continue; + } + if (!pastExecCommandId) { + continue; + } + if (token.kind === "option" && token.name !== "bridge-script") { + if (pendingOptionName !== null) { + // Previous option had no value — treat as a boolean flag. + rest.push(`--${pendingOptionName}`); + } + if (token.value !== undefined) { + rest.push(`--${token.name}`, token.value); + } else { + pendingOptionName = token.name; + } + continue; + } + if (token.kind === "positional" && pendingOptionName !== null) { + rest.push(`--${pendingOptionName}`, token.value); + pendingOptionName = null; + continue; + } + if (token.kind === "positional") { + rest.push(token.value); + } + } + if (pendingOptionName !== null) { + rest.push(`--${pendingOptionName}`); + } + } + return { help: !!values.help, sessions: !!values.sessions, stop: !!values.stop, commands: values.commands as string | undefined, + exec: values.exec as string | undefined, bridgeScript: values["bridge-script"] as string | undefined, + rest, }; } @@ -192,6 +245,50 @@ async function main(): Promise { return; } + if (args.exec !== undefined) { + const sessionId = parseInt(args.exec, 10); + if (isNaN(sessionId)) { + console.error("Error: --exec requires a numeric session id as the first argument."); + process.exitCode = 1; + return; + } + + const commandId = args.rest[0]; + if (!commandId) { + console.error("Error: --exec requires a command id as the second argument."); + process.exitCode = 1; + return; + } + + // Parse remaining --key value pairs into a Record. + const commandArgs: Record = {}; + for (let i = 1; i < args.rest.length; i++) { + const token = args.rest[i]; + if (token.startsWith("--") && i + 1 < args.rest.length) { + commandArgs[token.slice(2)] = args.rest[++i]; + } + } + + const socket = await ensureBridge(config.cliPort, args.bridgeScript); + try { + const response = await sendAndReceive(socket, { + type: "exec", + sessionId, + commandId, + args: commandArgs, + }); + if (response.error) { + console.error(`Error: ${response.error}`); + process.exitCode = 1; + } else { + console.log(response.result ?? ""); + } + } finally { + socket.close(); + } + return; + } + // No recognized option — show help. console.log(HELP_TEXT); } diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index a2196d994968..2fe038f3f8bc 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -1,9 +1,14 @@ -import type { IDisposable } from "core/index"; +import type { IDisposable, Nullable } from "core/index"; import type { Scene } from "core/scene"; +import type { ServiceDefinition } from "./modularity/serviceDefinition"; +import type { ISceneContext } from "./services/sceneContext"; import { Logger } from "core/Misc/logger"; +import { Observable } from "core/Misc/observable"; import { ServiceContainer } from "./modularity/serviceContainer"; +import { SceneContextIdentity } from "./services/sceneContext"; import { MakeInspectableBridgeServiceDefinition } from "./services/cli/inspectableBridgeService"; +import { EntityQueryServiceDefinition } from "./services/cli/entityQueryService"; const DEFAULT_PORT = 4400; @@ -85,12 +90,23 @@ export function StartInspectable(scene: Scene, options?: Partial = { + friendlyName: "Inspectable Scene Context", + produces: [SceneContextIdentity], + factory: () => ({ + currentScene: scene, + currentSceneObservable: new Observable>(), + }), + }; + serviceContainer - .addServiceAsync( + .addServicesAsync( + sceneContextServiceDefinition, MakeInspectableBridgeServiceDefinition({ port, name, - }) + }), + EntityQueryServiceDefinition ) .catch((error: unknown) => { Logger.Error(`Failed to initialize InspectableBridgeService: ${error}`); diff --git a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts new file mode 100644 index 000000000000..28e5511639f2 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts @@ -0,0 +1,51 @@ +import type { ServiceDefinition } from "../../modularity/serviceDefinition"; +import type { IInspectableCommandRegistry } from "./inspectableCommandRegistry"; +import type { ISceneContext } from "../sceneContext"; + +import { InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; +import { SceneContextIdentity } from "../sceneContext"; + +/** + * Service that registers a CLI command for querying mesh data by uniqueId. + */ +export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { + friendlyName: "Entity Query Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + const registration = commandRegistry.addCommand({ + id: "query-mesh", + description: "Query a mesh by uniqueId and return its serialized data.", + args: [ + { + name: "uniqueId", + description: "The uniqueId of the mesh to query.", + required: true, + }, + ], + execute: async (args) => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const uniqueId = parseInt(args.uniqueId, 10); + if (isNaN(uniqueId)) { + throw new Error("uniqueId must be a number."); + } + + const mesh = scene.meshes.find((m) => m.uniqueId === uniqueId); + if (!mesh) { + throw new Error(`No mesh found with uniqueId ${uniqueId}.`); + } + + return JSON.stringify(mesh.serialize(), null, 2); + }, + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; From 838d936e385742aeeaa589293fa8379ac88b0f0c Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 14:39:22 -0700 Subject: [PATCH 06/42] SessionId is optional when there is only one session --- packages/dev/inspector-v2/src/cli/cli.ts | 129 +++++++++++++---------- 1 file changed, 75 insertions(+), 54 deletions(-) diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index f35af3f0edf7..0ba0b6c41ad5 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -14,24 +14,28 @@ const HELP_TEXT = `babylon-inspector — Interact with running Babylon.js scenes USAGE babylon-inspector [options] - babylon-inspector --exec [--arg value ...] + babylon-inspector --exec [session-id] [--arg value ...] OPTIONS --help Show this help message. --sessions List active browser sessions connected to the bridge. --stop Stop the bridge process. - --commands List commands available from a specific session. - --exec [--arg value ...] + --commands [session-id] List commands available from a session. + --exec [session-id] [--arg value ...] Execute a command on a session. Extra --key value pairs are forwarded as command arguments. + Session id is optional when only one session is active. + CONFIGURATION Place a .babyloninspector JSON file anywhere in the directory parent chain: { "browserPort": 4400, "cliPort": 4401 } EXAMPLES babylon-inspector --sessions + babylon-inspector --commands babylon-inspector --commands 1 + babylon-inspector --exec query-mesh --uniqueId 42 babylon-inspector --exec 1 query-mesh --uniqueId 42 `; @@ -39,8 +43,8 @@ interface ParsedArgs { help: boolean; sessions: boolean; stop: boolean; - commands?: string; - exec?: string; + commands: boolean; + exec: boolean; bridgeScript?: string; rest: string[]; } @@ -51,8 +55,8 @@ function parseCliArgs(): ParsedArgs { help: { type: "boolean", default: false }, sessions: { type: "boolean", default: false }, stop: { type: "boolean", default: false }, - commands: { type: "string" }, - exec: { type: "string" }, + commands: { type: "boolean", default: false }, + exec: { type: "boolean", default: false }, "bridge-script": { type: "string" }, }, strict: false, @@ -60,26 +64,13 @@ function parseCliArgs(): ParsedArgs { tokens: true, }); - // Collect all unknown --key value pairs that appear after --exec's positional command id. - // With strict:false, parseArgs treats unknown options as booleans and their values as positionals, - // so we re-pair them using the raw token stream. + // Collect positionals and unknown --key value pairs from the token stream. const rest: string[] = []; if (tokens) { - let pastExecCommandId = false; let pendingOptionName: string | null = null; for (const token of tokens) { - if (token.kind === "positional" && !pastExecCommandId && values.exec !== undefined) { - // First positional after --exec is the command id. - pastExecCommandId = true; - rest.push(token.value); - continue; - } - if (!pastExecCommandId) { - continue; - } - if (token.kind === "option" && token.name !== "bridge-script") { + if (token.kind === "option" && token.name !== "bridge-script" && token.name !== "help" && token.name !== "sessions" && token.name !== "stop" && token.name !== "commands" && token.name !== "exec") { if (pendingOptionName !== null) { - // Previous option had no value — treat as a boolean flag. rest.push(`--${pendingOptionName}`); } if (token.value !== undefined) { @@ -107,8 +98,8 @@ function parseCliArgs(): ParsedArgs { help: !!values.help, sessions: !!values.sessions, stop: !!values.stop, - commands: values.commands as string | undefined, - exec: values.exec as string | undefined, + commands: !!values.commands, + exec: !!values.exec, bridgeScript: values["bridge-script"] as string | undefined, rest, }; @@ -172,6 +163,31 @@ async function ensureBridge(port: number, bridgeScript?: string, maxRetries = 10 throw new Error(`Unable to connect to the Inspector bridge on port ${port} after spawning it.`); } +/** + * Resolves the session id to use. If an explicit id is provided, returns it. + * If not, queries the bridge: returns the sole session's id when exactly one + * is active, or errors if zero or multiple sessions are active. + */ +async function resolveSessionId(socket: WebSocket, explicitId?: string): Promise { + if (explicitId !== undefined) { + const parsed = parseInt(explicitId, 10); + if (isNaN(parsed)) { + throw new Error("Session id must be a number."); + } + return parsed; + } + + const response = await sendAndReceive(socket, { type: "sessions" }); + if (response.sessions.length === 0) { + throw new Error("No active sessions. Make sure a browser is running with StartInspectable enabled."); + } + if (response.sessions.length > 1) { + const list = response.sessions.map((s) => ` [${s.id}] ${s.name}`).join("\n"); + throw new Error(`Multiple active sessions. Specify a session id:\n${list}`); + } + return response.sessions[0].id; +} + async function main(): Promise { const args = parseCliArgs(); @@ -210,16 +226,11 @@ async function main(): Promise { return; } - if (args.commands !== undefined) { - const sessionId = parseInt(args.commands, 10); - if (isNaN(sessionId)) { - console.error("Error: --commands requires a numeric session id."); - process.exitCode = 1; - return; - } - + if (args.commands) { const socket = await ensureBridge(config.cliPort, args.bridgeScript); try { + // Optional positional: session id. + const sessionId = await resolveSessionId(socket, args.rest[0]); const response = await sendAndReceive(socket, { type: "commands", sessionId }); if (response.error) { console.error(`Error: ${response.error}`); @@ -245,32 +256,42 @@ async function main(): Promise { return; } - if (args.exec !== undefined) { - const sessionId = parseInt(args.exec, 10); - if (isNaN(sessionId)) { - console.error("Error: --exec requires a numeric session id as the first argument."); - process.exitCode = 1; - return; - } + if (args.exec) { + const socket = await ensureBridge(config.cliPort, args.bridgeScript); + try { + // Positionals in rest: [sessionId?] + // If the first positional is a number, treat it as session id and the next as command id. + // Otherwise, auto-resolve session and treat the first positional as command id. + let commandId: string | undefined; + let argsStartIndex: number; + let explicitSessionId: string | undefined; - const commandId = args.rest[0]; - if (!commandId) { - console.error("Error: --exec requires a command id as the second argument."); - process.exitCode = 1; - return; - } + if (args.rest.length > 0 && !isNaN(parseInt(args.rest[0], 10)) && args.rest.length > 1) { + explicitSessionId = args.rest[0]; + commandId = args.rest[1]; + argsStartIndex = 2; + } else { + commandId = args.rest[0]; + argsStartIndex = 1; + } - // Parse remaining --key value pairs into a Record. - const commandArgs: Record = {}; - for (let i = 1; i < args.rest.length; i++) { - const token = args.rest[i]; - if (token.startsWith("--") && i + 1 < args.rest.length) { - commandArgs[token.slice(2)] = args.rest[++i]; + if (!commandId) { + console.error("Error: --exec requires a command id."); + process.exitCode = 1; + return; + } + + const sessionId = await resolveSessionId(socket, explicitSessionId); + + // Parse remaining --key value pairs into a Record. + const commandArgs: Record = {}; + for (let i = argsStartIndex; i < args.rest.length; i++) { + const token = args.rest[i]; + if (token.startsWith("--") && i + 1 < args.rest.length) { + commandArgs[token.slice(2)] = args.rest[++i]; + } } - } - const socket = await ensureBridge(config.cliPort, args.bridgeScript); - try { const response = await sendAndReceive(socket, { type: "exec", sessionId, From 7f54d5e119593a2cb2c542d9aa45fce90e6315f4 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 14:50:04 -0700 Subject: [PATCH 07/42] Execute with --command and include per command help --- packages/dev/inspector-v2/src/cli/cli.ts | 83 ++++++++++++++++-------- 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index 0ba0b6c41ad5..c1566506e5fe 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -14,16 +14,16 @@ const HELP_TEXT = `babylon-inspector — Interact with running Babylon.js scenes USAGE babylon-inspector [options] - babylon-inspector --exec [session-id] [--arg value ...] + babylon-inspector --command [session-id] [--arg value ...] OPTIONS --help Show this help message. --sessions List active browser sessions connected to the bridge. --stop Stop the bridge process. - --commands [session-id] List commands available from a session. - --exec [session-id] [--arg value ...] - Execute a command on a session. Extra --key value - pairs are forwarded as command arguments. + --commands [session-id] List available commands. + --command [session-id] [--arg value ...] + Execute a command. Use --command --help to + see its arguments. Session id is optional when only one session is active. @@ -34,17 +34,19 @@ CONFIGURATION EXAMPLES babylon-inspector --sessions babylon-inspector --commands - babylon-inspector --commands 1 - babylon-inspector --exec query-mesh --uniqueId 42 - babylon-inspector --exec 1 query-mesh --uniqueId 42 + babylon-inspector --command query-mesh --help + babylon-inspector --command query-mesh --uniqueId 42 + babylon-inspector --command 1 query-mesh --uniqueId 42 `; +const KNOWN_OPTIONS = new Set(["help", "sessions", "stop", "commands", "command", "bridge-script"]); + interface ParsedArgs { help: boolean; sessions: boolean; stop: boolean; commands: boolean; - exec: boolean; + command: boolean; bridgeScript?: string; rest: string[]; } @@ -56,7 +58,7 @@ function parseCliArgs(): ParsedArgs { sessions: { type: "boolean", default: false }, stop: { type: "boolean", default: false }, commands: { type: "boolean", default: false }, - exec: { type: "boolean", default: false }, + command: { type: "boolean", default: false }, "bridge-script": { type: "string" }, }, strict: false, @@ -69,7 +71,7 @@ function parseCliArgs(): ParsedArgs { if (tokens) { let pendingOptionName: string | null = null; for (const token of tokens) { - if (token.kind === "option" && token.name !== "bridge-script" && token.name !== "help" && token.name !== "sessions" && token.name !== "stop" && token.name !== "commands" && token.name !== "exec") { + if (token.kind === "option" && !KNOWN_OPTIONS.has(token.name)) { if (pendingOptionName !== null) { rest.push(`--${pendingOptionName}`); } @@ -99,7 +101,7 @@ function parseCliArgs(): ParsedArgs { sessions: !!values.sessions, stop: !!values.stop, commands: !!values.commands, - exec: !!values.exec, + command: !!values.command, bridgeScript: values["bridge-script"] as string | undefined, rest, }; @@ -191,7 +193,7 @@ async function resolveSessionId(socket: WebSocket, explicitId?: string): Promise async function main(): Promise { const args = parseCliArgs(); - if (args.help) { + if (args.help && !args.command) { console.log(HELP_TEXT); return; } @@ -229,7 +231,6 @@ async function main(): Promise { if (args.commands) { const socket = await ensureBridge(config.cliPort, args.bridgeScript); try { - // Optional positional: session id. const sessionId = await resolveSessionId(socket, args.rest[0]); const response = await sendAndReceive(socket, { type: "commands", sessionId }); if (response.error) { @@ -238,17 +239,14 @@ async function main(): Promise { return; } if (!response.commands || response.commands.length === 0) { - console.log("No commands available for this session."); + console.log("No commands available."); } else { - console.log(`Commands for session ${sessionId}:`); + console.log("Available commands:"); for (const cmd of response.commands) { - console.log(` --${cmd.id} ${cmd.description}`); - if (cmd.args && cmd.args.length > 0) { - for (const arg of cmd.args) { - console.log(` --${arg.name}${arg.required ? " (required)" : ""} ${arg.description}`); - } - } + console.log(` ${cmd.id} ${cmd.description}`); } + console.log("\nRun --command --help to see arguments for a command."); + console.log("Run --command [--arg value ...] to execute a command."); } } finally { socket.close(); @@ -256,12 +254,10 @@ async function main(): Promise { return; } - if (args.exec) { + if (args.command) { const socket = await ensureBridge(config.cliPort, args.bridgeScript); try { // Positionals in rest: [sessionId?] - // If the first positional is a number, treat it as session id and the next as command id. - // Otherwise, auto-resolve session and treat the first positional as command id. let commandId: string | undefined; let argsStartIndex: number; let explicitSessionId: string | undefined; @@ -276,7 +272,7 @@ async function main(): Promise { } if (!commandId) { - console.error("Error: --exec requires a command id."); + console.error("Error: --command requires a command id."); process.exitCode = 1; return; } @@ -285,13 +281,46 @@ async function main(): Promise { // Parse remaining --key value pairs into a Record. const commandArgs: Record = {}; + let wantsHelp = args.help; for (let i = argsStartIndex; i < args.rest.length; i++) { const token = args.rest[i]; - if (token.startsWith("--") && i + 1 < args.rest.length) { + if (token === "--help") { + wantsHelp = true; + } else if (token.startsWith("--") && i + 1 < args.rest.length) { commandArgs[token.slice(2)] = args.rest[++i]; } } + // Fetch the command descriptor to check for --help or missing required args. + const commandsResponse = await sendAndReceive(socket, { type: "commands", sessionId }); + const descriptor = commandsResponse.commands?.find((c) => c.id === commandId); + + if (!descriptor) { + console.error(`Error: Unknown command "${commandId}".`); + process.exitCode = 1; + return; + } + + // Check for --help or missing required arguments. + const missingRequired = (descriptor.args ?? []).filter((a) => a.required && !(a.name in commandArgs)); + + if (wantsHelp || missingRequired.length > 0) { + if (missingRequired.length > 0 && !wantsHelp) { + console.error(`Missing required argument(s): ${missingRequired.map((a) => `--${a.name}`).join(", ")}\n`); + } + console.log(`${commandId}: ${descriptor.description}\n`); + if (descriptor.args && descriptor.args.length > 0) { + console.log("Arguments:"); + for (const arg of descriptor.args) { + console.log(` --${arg.name}${arg.required ? " (required)" : ""} ${arg.description}`); + } + } + if (missingRequired.length > 0 && !wantsHelp) { + process.exitCode = 1; + } + return; + } + const response = await sendAndReceive(socket, { type: "exec", sessionId, From c3f739563517efdc517b446a83870b4e6a95bd17 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 15:39:55 -0700 Subject: [PATCH 08/42] Add more entity commands --- .../src/services/cli/entityQueryService.ts | 256 ++++++++++++++++-- .../services/cli/inspectableBridgeService.ts | 2 +- .../cli/inspectableCommandRegistry.ts | 2 +- 3 files changed, 228 insertions(+), 32 deletions(-) diff --git a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts index 28e5511639f2..884cc7f42a4d 100644 --- a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts +++ b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts @@ -1,50 +1,246 @@ +import type { IDisposable } from "core/index"; import type { ServiceDefinition } from "../../modularity/serviceDefinition"; -import type { IInspectableCommandRegistry } from "./inspectableCommandRegistry"; +import type { IInspectableCommandRegistry, InspectableCommandDescriptor } from "./inspectableCommandRegistry"; import type { ISceneContext } from "../sceneContext"; import { InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; import { SceneContextIdentity } from "../sceneContext"; +const uniqueIdArg = { + name: "uniqueId", + description: "The uniqueId of the entity to query.", + required: true, +} as const; + +function parseUniqueId(args: Record): number { + const id = parseInt(args.uniqueId, 10); + if (isNaN(id)) { + throw new Error("uniqueId must be a number."); + } + return id; +} + /** - * Service that registers a CLI command for querying mesh data by uniqueId. + * Service that registers CLI commands for querying scene entities by uniqueId. */ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { friendlyName: "Entity Query Service", consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], factory: (commandRegistry, sceneContext) => { - const registration = commandRegistry.addCommand({ - id: "query-mesh", - description: "Query a mesh by uniqueId and return its serialized data.", - args: [ - { - name: "uniqueId", - description: "The uniqueId of the mesh to query.", - required: true, - }, - ], - execute: async (args) => { - const scene = sceneContext.currentScene; - if (!scene) { - throw new Error("No active scene."); - } - - const uniqueId = parseInt(args.uniqueId, 10); - if (isNaN(uniqueId)) { - throw new Error("uniqueId must be a number."); - } + function getScene() { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + return scene; + } - const mesh = scene.meshes.find((m) => m.uniqueId === uniqueId); - if (!mesh) { - throw new Error(`No mesh found with uniqueId ${uniqueId}.`); - } - - return JSON.stringify(mesh.serialize(), null, 2); + const commands: InspectableCommandDescriptor[] = [ + { + id: "query-mesh", + description: "Query a mesh by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.meshes.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No mesh found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-light", + description: "Query a light by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.lights.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No light found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-camera", + description: "Query a camera by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.cameras.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No camera found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-material", + description: "Query a material by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.materials.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No material found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-texture", + description: "Query a texture by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.textures.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No texture found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-transformNode", + description: "Query a transform node by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.transformNodes.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No transform node found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, }, - }); + { + id: "query-geometry", + description: "Query a geometry by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.geometries?.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No geometry found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-skeleton", + description: "Query a skeleton by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.skeletons.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No skeleton found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-animation", + description: "Query an animation by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.animations.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No animation found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-animationGroup", + description: "Query an animation group by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.animationGroups.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No animation group found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-particleSystem", + description: "Query a particle system by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.particleSystems.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No particle system found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(false), null, 2); + }, + }, + { + id: "query-morphTargetManager", + description: "Query a morph target manager by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.morphTargetManagers.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No morph target manager found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-multiMaterial", + description: "Query a multi-material by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.multiMaterials.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No multi-material found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + { + id: "query-postProcess", + description: "Query a post-process by uniqueId and return its serialized data.", + args: [uniqueIdArg], + executeAsync: async (args) => { + const scene = getScene(); + const id = parseUniqueId(args); + const entity = scene.postProcesses.find((e) => e.uniqueId === id); + if (!entity) { + throw new Error(`No post-process found with uniqueId ${id}.`); + } + return JSON.stringify(entity.serialize(), null, 2); + }, + }, + ]; + + const registrations: IDisposable[] = commands.map((cmd) => commandRegistry.addCommand(cmd)); return { dispose: () => { - registration.dispose(); + for (const reg of registrations) { + reg.dispose(); + } }, }; }, diff --git a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts index 5484ac434973..64f4379ff153 100644 --- a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts +++ b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts @@ -112,7 +112,7 @@ export function MakeInspectableBridgeServiceDefinition(options: InspectableBridg break; } command - .execute(message.args) + .executeAsync(message.args) .then((result) => { sendToBridge({ type: "commandResponse", diff --git a/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts b/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts index 94824d670e0e..00c519ed038d 100644 --- a/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts +++ b/packages/dev/inspector-v2/src/services/cli/inspectableCommandRegistry.ts @@ -45,7 +45,7 @@ export type InspectableCommandDescriptor = { * @param args A map of argument names to their values. * @returns A promise that resolves to the result string. */ - execute: (args: Record) => Promise; + executeAsync: (args: Record) => Promise; }; /** From 9ef8a1edc15f37f1ab89561651b361934a495fd4 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 15:42:42 -0700 Subject: [PATCH 09/42] Fix lint --- packages/dev/inspector-v2/src/cli/bridge.ts | 98 +++++++++---------- packages/dev/inspector-v2/src/cli/cli.ts | 90 ++++++++++------- packages/dev/inspector-v2/src/cli/config.ts | 20 ++-- packages/dev/inspector-v2/src/cli/protocol.ts | 42 ++++++++ packages/dev/inspector-v2/src/inspectable.ts | 30 +++--- .../src/services/cli/entityQueryService.ts | 60 ++++++------ .../services/cli/inspectableBridgeService.ts | 36 ++++--- .../inspector-v2/test/unit/cli/config.test.ts | 14 +-- 8 files changed, 224 insertions(+), 166 deletions(-) diff --git a/packages/dev/inspector-v2/src/cli/bridge.ts b/packages/dev/inspector-v2/src/cli/bridge.ts index d8901fe1b2bd..ad5c7bd623cb 100644 --- a/packages/dev/inspector-v2/src/cli/bridge.ts +++ b/packages/dev/inspector-v2/src/cli/bridge.ts @@ -1,31 +1,27 @@ +/* eslint-disable no-console */ import ws from "ws"; -import { loadConfig } from "./config.js"; -import type { - BrowserRequest, - BrowserResponse, - CliRequest, - CliResponse, - SessionInfo, -} from "./protocol.js"; +import { LoadConfig } from "./config.js"; +import type { BrowserRequest, BrowserResponse, CliRequest, CliResponse, SessionInfo } from "./protocol.js"; type WebSocket = ws; type WebSocketServerType = ws.Server; -interface Session extends SessionInfo { +interface ISession extends SessionInfo { + /** The WebSocket connection for this session. */ ws: WebSocket; } -let nextSessionId = 1; -const sessions = new Map(); -const pendingBrowserRequests = new Map void>(); -let requestCounter = 0; +let NextSessionId = 1; +const Sessions = new Map(); +const PendingBrowserRequests = new Map void>(); +let RequestCounter = 0; -function generateRequestId(): string { - return `bridge-req-${++requestCounter}`; +function GenerateRequestId(): string { + return `bridge-req-${++RequestCounter}`; } -function startBridge(): void { - const config = loadConfig(); +function StartBridge(): void { + const config = LoadConfig(); // Browser-facing WebSocket server. const browserWss = new ws.Server({ host: "127.0.0.1", port: config.browserPort }); @@ -38,7 +34,7 @@ function startBridge(): void { console.log(` CLI port: ${config.cliPort}`); browserWss.on("connection", (socket) => { - let session: Session | null = null; + let session: ISession | null = null; socket.on("message", (data) => { let message: BrowserRequest; @@ -50,23 +46,23 @@ function startBridge(): void { switch (message.type) { case "register": { - const id = nextSessionId++; + const id = NextSessionId++; session = { id, name: message.name, connectedAt: new Date().toISOString(), ws: socket, }; - sessions.set(id, session); + Sessions.set(id, session); console.log(`Session ${id} registered: "${session.name}"`); break; } case "commandListResponse": case "commandResponse": { // Forward response back to the CLI that requested it. - const resolve = pendingBrowserRequests.get(message.requestId); + const resolve = PendingBrowserRequests.get(message.requestId); if (resolve) { - pendingBrowserRequests.delete(message.requestId); + PendingBrowserRequests.delete(message.requestId); resolve(JSON.stringify(message)); } break; @@ -77,13 +73,13 @@ function startBridge(): void { socket.on("close", () => { if (session) { console.log(`Session ${session.id} disconnected: "${session.name}"`); - sessions.delete(session.id); + Sessions.delete(session.id); } }); }); cliWss.on("connection", (socket) => { - socket.on("message", (data) => { + socket.on("message", async (data) => { let message: CliRequest; try { message = JSON.parse(data.toString()); @@ -95,13 +91,13 @@ function startBridge(): void { socket.send(JSON.stringify(response)); } - function sendBrowserRequest(target: Session, request: BrowserResponse) { + function sendBrowserRequest(target: ISession, request: BrowserResponse) { target.ws.send(JSON.stringify(request)); } switch (message.type) { case "sessions": { - const sessionList: SessionInfo[] = Array.from(sessions.values()).map((s) => ({ + const sessionList: SessionInfo[] = Array.from(Sessions.values()).map((s) => ({ id: s.id, name: s.name, connectedAt: s.connectedAt, @@ -110,72 +106,76 @@ function startBridge(): void { break; } case "commands": { - const session = sessions.get(message.sessionId); + const session = Sessions.get(message.sessionId); if (!session) { sendCliResponse({ type: "commandsResponse", error: `No session with id ${message.sessionId}` }); break; } - const requestId = generateRequestId(); + const requestId = GenerateRequestId(); sendBrowserRequest(session, { type: "listCommands", requestId }); - waitForBrowserResponse(requestId).then( - (response) => socket.send(response), - () => sendCliResponse({ type: "commandsResponse", error: "Timeout waiting for browser response" }) - ); + try { + const response = await WaitForBrowserResponse(requestId); + socket.send(response); + } catch { + sendCliResponse({ type: "commandsResponse", error: "Timeout waiting for browser response" }); + } break; } case "exec": { - const session = sessions.get(message.sessionId); + const session = Sessions.get(message.sessionId); if (!session) { sendCliResponse({ type: "execResponse", error: `No session with id ${message.sessionId}` }); break; } - const requestId = generateRequestId(); + const requestId = GenerateRequestId(); sendBrowserRequest(session, { type: "execCommand", requestId, commandId: message.commandId, args: message.args, }); - waitForBrowserResponse(requestId).then( - (response) => socket.send(response), - () => sendCliResponse({ type: "execResponse", error: "Timeout waiting for browser response" }) - ); + try { + const response = await WaitForBrowserResponse(requestId); + socket.send(response); + } catch { + sendCliResponse({ type: "execResponse", error: "Timeout waiting for browser response" }); + } break; } case "stop": { sendCliResponse({ type: "stopResponse", success: true }); - shutdown(browserWss, cliWss); + Shutdown(browserWss, cliWss); break; } } }); }); - process.on("SIGTERM", () => shutdown(browserWss, cliWss)); - process.on("SIGINT", () => shutdown(browserWss, cliWss)); + process.on("SIGTERM", () => Shutdown(browserWss, cliWss)); + process.on("SIGINT", () => Shutdown(browserWss, cliWss)); } -function waitForBrowserResponse(requestId: string, timeoutMs = 30000): Promise { - return new Promise((resolve, reject) => { +async function WaitForBrowserResponse(requestId: string, timeoutMs = 30000): Promise { + return await new Promise((resolve, reject) => { const timer = setTimeout(() => { - pendingBrowserRequests.delete(requestId); + PendingBrowserRequests.delete(requestId); reject(new Error("Timeout")); }, timeoutMs); - pendingBrowserRequests.set(requestId, (response) => { + PendingBrowserRequests.set(requestId, (response) => { clearTimeout(timer); resolve(response); }); }); } -function shutdown(browserWss: WebSocketServerType, cliWss: WebSocketServerType): void { +function Shutdown(browserWss: WebSocketServerType, cliWss: WebSocketServerType): void { console.log("Inspector bridge shutting down."); - for (const session of sessions.values()) { + for (const session of Sessions.values()) { session.ws.close(); } - sessions.clear(); + Sessions.clear(); browserWss.close(); cliWss.close(); @@ -183,4 +183,4 @@ function shutdown(browserWss: WebSocketServerType, cliWss: WebSocketServerType): process.exit(0); } -startBridge(); +StartBridge(); diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index c1566506e5fe..f3019f2031a3 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -1,14 +1,15 @@ +/* eslint-disable no-console */ import { spawn } from "child_process"; import { parseArgs } from "util"; import { fileURLToPath } from "url"; import { dirname, join, resolve } from "path"; import ws from "ws"; -import { loadConfig } from "./config.js"; +import { LoadConfig } from "./config.js"; import type { CliRequest, CliResponse, CommandsResponse, ExecResponse, SessionsResponse } from "./protocol.js"; type WebSocket = ws; -const config = loadConfig(); +const Config = LoadConfig(); const HELP_TEXT = `babylon-inspector — Interact with running Babylon.js scenes from the terminal. @@ -39,19 +40,26 @@ EXAMPLES babylon-inspector --command 1 query-mesh --uniqueId 42 `; -const KNOWN_OPTIONS = new Set(["help", "sessions", "stop", "commands", "command", "bridge-script"]); +const KnownOptions = new Set(["help", "sessions", "stop", "commands", "command", "bridge-script"]); -interface ParsedArgs { +interface IParsedArgs { + /** Whether the user requested help. */ help: boolean; + /** Whether the user requested the sessions list. */ sessions: boolean; + /** Whether the user requested the bridge to stop. */ stop: boolean; + /** Whether the user requested the commands list. */ commands: boolean; + /** Whether the user is executing a command. */ command: boolean; + /** Optional path to the bridge script. */ bridgeScript?: string; + /** Remaining positional and unknown arguments. */ rest: string[]; } -function parseCliArgs(): ParsedArgs { +function ParseCliArgs(): IParsedArgs { const { values, tokens } = parseArgs({ options: { help: { type: "boolean", default: false }, @@ -59,6 +67,7 @@ function parseCliArgs(): ParsedArgs { stop: { type: "boolean", default: false }, commands: { type: "boolean", default: false }, command: { type: "boolean", default: false }, + // eslint-disable-next-line @typescript-eslint/naming-convention "bridge-script": { type: "string" }, }, strict: false, @@ -71,7 +80,7 @@ function parseCliArgs(): ParsedArgs { if (tokens) { let pendingOptionName: string | null = null; for (const token of tokens) { - if (token.kind === "option" && !KNOWN_OPTIONS.has(token.name)) { + if (token.kind === "option" && !KnownOptions.has(token.name)) { if (pendingOptionName !== null) { rest.push(`--${pendingOptionName}`); } @@ -107,16 +116,16 @@ function parseCliArgs(): ParsedArgs { }; } -function connectToBridge(port: number): Promise { - return new Promise((resolve, reject) => { +async function ConnectToBridge(port: number): Promise { + return await new Promise((resolve, reject) => { const socket = new ws(`ws://127.0.0.1:${port}`); socket.on("open", () => resolve(socket)); socket.on("error", (err) => reject(err)); }); } -function sendAndReceive(socket: WebSocket, message: CliRequest): Promise { - return new Promise((resolve, reject) => { +async function SendAndReceive(socket: WebSocket, message: CliRequest): Promise { + return await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Timeout waiting for bridge response.")); }, 15000); @@ -134,10 +143,8 @@ function sendAndReceive(socket: WebSocket, message: CliRe }); } -function spawnBridge(bridgeScript?: string): void { - const bridgePath = bridgeScript - ? resolve(bridgeScript) - : join(dirname(fileURLToPath(import.meta.url)), "inspector-bridge.mjs"); +function SpawnBridge(bridgeScript?: string): void { + const bridgePath = bridgeScript ? resolve(bridgeScript) : join(dirname(fileURLToPath(import.meta.url)), "inspector-bridge.mjs"); const child = spawn(process.execPath, [bridgePath], { detached: true, stdio: "ignore", @@ -145,18 +152,20 @@ function spawnBridge(bridgeScript?: string): void { child.unref(); } -async function ensureBridge(port: number, bridgeScript?: string, maxRetries = 10, retryDelayMs = 500): Promise { +async function EnsureBridge(port: number, bridgeScript?: string, maxRetries = 10, retryDelayMs = 500): Promise { try { - return await connectToBridge(port); + return await ConnectToBridge(port); } catch { // Bridge not running — spawn it. - spawnBridge(bridgeScript); + SpawnBridge(bridgeScript); } for (let i = 0; i < maxRetries; i++) { + // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); try { - return await connectToBridge(port); + // eslint-disable-next-line no-await-in-loop + return await ConnectToBridge(port); } catch { // Keep retrying. } @@ -169,8 +178,11 @@ async function ensureBridge(port: number, bridgeScript?: string, maxRetries = 10 * Resolves the session id to use. If an explicit id is provided, returns it. * If not, queries the bridge: returns the sole session's id when exactly one * is active, or errors if zero or multiple sessions are active. + * @param socket The WebSocket connection to the bridge. + * @param explicitId An optional explicit session id string. + * @returns The resolved numeric session id. */ -async function resolveSessionId(socket: WebSocket, explicitId?: string): Promise { +async function ResolveSessionId(socket: WebSocket, explicitId?: string): Promise { if (explicitId !== undefined) { const parsed = parseInt(explicitId, 10); if (isNaN(parsed)) { @@ -179,7 +191,7 @@ async function resolveSessionId(socket: WebSocket, explicitId?: string): Promise return parsed; } - const response = await sendAndReceive(socket, { type: "sessions" }); + const response = await SendAndReceive(socket, { type: "sessions" }); if (response.sessions.length === 0) { throw new Error("No active sessions. Make sure a browser is running with StartInspectable enabled."); } @@ -190,8 +202,8 @@ async function resolveSessionId(socket: WebSocket, explicitId?: string): Promise return response.sessions[0].id; } -async function main(): Promise { - const args = parseCliArgs(); +async function Main(): Promise { + const args = ParseCliArgs(); if (args.help && !args.command) { console.log(HELP_TEXT); @@ -199,9 +211,9 @@ async function main(): Promise { } if (args.sessions) { - const socket = await ensureBridge(config.cliPort, args.bridgeScript); + const socket = await EnsureBridge(Config.cliPort, args.bridgeScript); try { - const response = await sendAndReceive(socket, { type: "sessions" }); + const response = await SendAndReceive(socket, { type: "sessions" }); if (response.sessions.length === 0) { console.log("No active sessions."); } else { @@ -218,8 +230,8 @@ async function main(): Promise { if (args.stop) { try { - const socket = await connectToBridge(config.cliPort); - await sendAndReceive(socket, { type: "stop" }); + const socket = await ConnectToBridge(Config.cliPort); + await SendAndReceive(socket, { type: "stop" }); socket.close(); console.log("Bridge stopped."); } catch { @@ -229,10 +241,10 @@ async function main(): Promise { } if (args.commands) { - const socket = await ensureBridge(config.cliPort, args.bridgeScript); + const socket = await EnsureBridge(Config.cliPort, args.bridgeScript); try { - const sessionId = await resolveSessionId(socket, args.rest[0]); - const response = await sendAndReceive(socket, { type: "commands", sessionId }); + const sessionId = await ResolveSessionId(socket, args.rest[0]); + const response = await SendAndReceive(socket, { type: "commands", sessionId }); if (response.error) { console.error(`Error: ${response.error}`); process.exitCode = 1; @@ -255,7 +267,7 @@ async function main(): Promise { } if (args.command) { - const socket = await ensureBridge(config.cliPort, args.bridgeScript); + const socket = await EnsureBridge(Config.cliPort, args.bridgeScript); try { // Positionals in rest: [sessionId?] let commandId: string | undefined; @@ -277,7 +289,7 @@ async function main(): Promise { return; } - const sessionId = await resolveSessionId(socket, explicitSessionId); + const sessionId = await ResolveSessionId(socket, explicitSessionId); // Parse remaining --key value pairs into a Record. const commandArgs: Record = {}; @@ -292,7 +304,7 @@ async function main(): Promise { } // Fetch the command descriptor to check for --help or missing required args. - const commandsResponse = await sendAndReceive(socket, { type: "commands", sessionId }); + const commandsResponse = await SendAndReceive(socket, { type: "commands", sessionId }); const descriptor = commandsResponse.commands?.find((c) => c.id === commandId); if (!descriptor) { @@ -321,7 +333,7 @@ async function main(): Promise { return; } - const response = await sendAndReceive(socket, { + const response = await SendAndReceive(socket, { type: "exec", sessionId, commandId, @@ -343,7 +355,11 @@ async function main(): Promise { console.log(HELP_TEXT); } -main().catch((error: unknown) => { - console.error(`Error: ${error}`); - process.exitCode = 1; -}); +void (async () => { + try { + await Main(); + } catch (error: unknown) { + console.error(`Error: ${error}`); + process.exitCode = 1; + } +})(); diff --git a/packages/dev/inspector-v2/src/cli/config.ts b/packages/dev/inspector-v2/src/cli/config.ts index e9e0d404641d..3cc9e091bf9f 100644 --- a/packages/dev/inspector-v2/src/cli/config.ts +++ b/packages/dev/inspector-v2/src/cli/config.ts @@ -3,13 +3,13 @@ import { dirname, join } from "path"; const CONFIG_FILENAME = ".babyloninspector"; -const DEFAULT_BROWSER_PORT = 4400; -const DEFAULT_CLI_PORT = 4401; +const DefaultBrowserPort = 4400; +const DefaultCliPort = 4401; /** * Configuration for the Inspector CLI bridge. */ -export interface InspectorBridgeConfig { +export interface IInspectorBridgeConfig { /** * WebSocket port for browser sessions to connect to the bridge. */ @@ -28,7 +28,7 @@ export interface InspectorBridgeConfig { * @param startDir The directory to start searching from. * @returns The absolute path to the config file, or undefined. */ -function findConfigFile(startDir: string): string | undefined { +function FindConfigFile(startDir: string): string | undefined { let current = startDir; for (;;) { const candidate = join(current, CONFIG_FILENAME); @@ -51,20 +51,20 @@ function findConfigFile(startDir: string): string | undefined { * @param cwd The working directory to start the search from. Defaults to `process.cwd()`. * @returns The resolved configuration. */ -export function loadConfig(cwd?: string): InspectorBridgeConfig { - const defaults: InspectorBridgeConfig = { - browserPort: DEFAULT_BROWSER_PORT, - cliPort: DEFAULT_CLI_PORT, +export function LoadConfig(cwd?: string): IInspectorBridgeConfig { + const defaults: IInspectorBridgeConfig = { + browserPort: DefaultBrowserPort, + cliPort: DefaultCliPort, }; - const configPath = findConfigFile(cwd ?? process.cwd()); + const configPath = FindConfigFile(cwd ?? process.cwd()); if (!configPath) { return defaults; } try { const raw = readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as Partial; return { browserPort: typeof parsed.browserPort === "number" ? parsed.browserPort : defaults.browserPort, cliPort: typeof parsed.cliPort === "number" ? parsed.cliPort : defaults.cliPort, diff --git a/packages/dev/inspector-v2/src/cli/protocol.ts b/packages/dev/inspector-v2/src/cli/protocol.ts index 3df6cc063c47..9d87995eef77 100644 --- a/packages/dev/inspector-v2/src/cli/protocol.ts +++ b/packages/dev/inspector-v2/src/cli/protocol.ts @@ -4,8 +4,11 @@ * Serializable description of a command argument, used in protocol messages. */ export type CommandArgInfo = { + /** The name of the argument. */ name: string; + /** A human-readable description of the argument. */ description: string; + /** Whether this argument is required. */ required?: boolean; }; @@ -13,8 +16,11 @@ export type CommandArgInfo = { * Serializable description of a command, used in protocol messages. */ export type CommandInfo = { + /** A unique identifier for the command. */ id: string; + /** A human-readable description of the command. */ description: string; + /** The arguments this command accepts. */ args?: CommandArgInfo[]; }; @@ -22,8 +28,11 @@ export type CommandInfo = { * Serializable description of a session, used in protocol messages. */ export type SessionInfo = { + /** The numeric session identifier. */ id: number; + /** The display name of the session. */ name: string; + /** ISO 8601 timestamp of when the session connected. */ connectedAt: string; }; @@ -33,6 +42,7 @@ export type SessionInfo = { * CLI → Bridge: Request the list of active browser sessions. */ export type SessionsRequest = { + /** The message type discriminator. */ type: "sessions"; }; @@ -40,7 +50,9 @@ export type SessionsRequest = { * CLI → Bridge: Request the list of commands available from a session. */ export type CommandsRequest = { + /** The message type discriminator. */ type: "commands"; + /** The session to query for commands. */ sessionId: number; }; @@ -48,9 +60,13 @@ export type CommandsRequest = { * CLI → Bridge: Execute a command on a session. */ export type ExecRequest = { + /** The message type discriminator. */ type: "exec"; + /** The session to execute the command on. */ sessionId: number; + /** The identifier of the command to execute. */ commandId: string; + /** Key-value pairs of arguments for the command. */ args: Record; }; @@ -58,6 +74,7 @@ export type ExecRequest = { * CLI → Bridge: Stop the bridge process. */ export type StopRequest = { + /** The message type discriminator. */ type: "stop"; }; @@ -70,7 +87,9 @@ export type CliRequest = SessionsRequest | CommandsRequest | ExecRequest | StopR * Bridge → CLI: Response with the list of active sessions. */ export type SessionsResponse = { + /** The message type discriminator. */ type: "sessionsResponse"; + /** The list of active sessions. */ sessions: SessionInfo[]; }; @@ -78,8 +97,11 @@ export type SessionsResponse = { * Bridge → CLI: Response with the list of commands from a session. */ export type CommandsResponse = { + /** The message type discriminator. */ type: "commandsResponse"; + /** The list of available commands, if successful. */ commands?: CommandInfo[]; + /** An error message, if the request failed. */ error?: string; }; @@ -87,8 +109,11 @@ export type CommandsResponse = { * Bridge → CLI: Response with the result of a command execution. */ export type ExecResponse = { + /** The message type discriminator. */ type: "execResponse"; + /** The result of the command execution, if successful. */ result?: string; + /** An error message, if the execution failed. */ error?: string; }; @@ -96,7 +121,9 @@ export type ExecResponse = { * Bridge → CLI: Acknowledgement that the bridge is stopping. */ export type StopResponse = { + /** The message type discriminator. */ type: "stopResponse"; + /** Whether the bridge stopped successfully. */ success: boolean; }; @@ -111,7 +138,9 @@ export type CliResponse = SessionsResponse | CommandsResponse | ExecResponse | S * Browser → Bridge: Register a new session. */ export type RegisterRequest = { + /** The message type discriminator. */ type: "register"; + /** The display name for this session. */ name: string; }; @@ -119,8 +148,11 @@ export type RegisterRequest = { * Browser → Bridge: Response to a listCommands request from the bridge. */ export type CommandListResponse = { + /** The message type discriminator. */ type: "commandListResponse"; + /** The identifier of the original request. */ requestId: string; + /** The list of registered commands. */ commands: CommandInfo[]; }; @@ -128,9 +160,13 @@ export type CommandListResponse = { * Browser → Bridge: Response to an execCommand request from the bridge. */ export type CommandResponse = { + /** The message type discriminator. */ type: "commandResponse"; + /** The identifier of the original request. */ requestId: string; + /** The result of the command execution, if successful. */ result?: string; + /** An error message, if the execution failed. */ error?: string; }; @@ -143,7 +179,9 @@ export type BrowserRequest = RegisterRequest | CommandListResponse | CommandResp * Bridge → Browser: Request the list of registered commands. */ export type ListCommandsRequest = { + /** The message type discriminator. */ type: "listCommands"; + /** A unique identifier for this request. */ requestId: string; }; @@ -151,9 +189,13 @@ export type ListCommandsRequest = { * Bridge → Browser: Request execution of a command. */ export type ExecCommandRequest = { + /** The message type discriminator. */ type: "execCommand"; + /** A unique identifier for this request. */ requestId: string; + /** The identifier of the command to execute. */ commandId: string; + /** Key-value pairs of arguments for the command. */ args: Record; }; diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index 2fe038f3f8bc..8143a8d61480 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -10,7 +10,7 @@ import { SceneContextIdentity } from "./services/sceneContext"; import { MakeInspectableBridgeServiceDefinition } from "./services/cli/inspectableBridgeService"; import { EntityQueryServiceDefinition } from "./services/cli/entityQueryService"; -const DEFAULT_PORT = 4400; +const DefaultPort = 4400; /** * Options for making a scene inspectable via the Inspector CLI. @@ -60,7 +60,7 @@ export function StartInspectable(scene: Scene, options?: Partial { - Logger.Error(`Failed to initialize InspectableBridgeService: ${error}`); + void (async () => { + try { + await serviceContainer.addServicesAsync( + sceneContextServiceDefinition, + MakeInspectableBridgeServiceDefinition({ + port, + name, + }), + EntityQueryServiceDefinition + ); + } catch (error: unknown) { + Logger.Error(`Failed to initialize Inspectable: ${error}`); token.dispose(); - }); + } + })(); return token; } diff --git a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts index 884cc7f42a4d..15a77e6d2c4f 100644 --- a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts +++ b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts @@ -6,13 +6,13 @@ import type { ISceneContext } from "../sceneContext"; import { InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; import { SceneContextIdentity } from "../sceneContext"; -const uniqueIdArg = { +const UniqueIdArg = { name: "uniqueId", description: "The uniqueId of the entity to query.", required: true, } as const; -function parseUniqueId(args: Record): number { +function ParseUniqueId(args: Record): number { const id = parseInt(args.uniqueId, 10); if (isNaN(id)) { throw new Error("uniqueId must be a number."); @@ -39,10 +39,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-mesh", description: "Query a mesh by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.meshes.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No mesh found with uniqueId ${id}.`); @@ -53,10 +53,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-light", description: "Query a light by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.lights.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No light found with uniqueId ${id}.`); @@ -67,10 +67,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-camera", description: "Query a camera by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.cameras.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No camera found with uniqueId ${id}.`); @@ -81,10 +81,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-material", description: "Query a material by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.materials.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No material found with uniqueId ${id}.`); @@ -95,10 +95,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-texture", description: "Query a texture by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.textures.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No texture found with uniqueId ${id}.`); @@ -109,10 +109,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-transformNode", description: "Query a transform node by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.transformNodes.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No transform node found with uniqueId ${id}.`); @@ -123,10 +123,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-geometry", description: "Query a geometry by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.geometries?.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No geometry found with uniqueId ${id}.`); @@ -137,10 +137,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-skeleton", description: "Query a skeleton by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.skeletons.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No skeleton found with uniqueId ${id}.`); @@ -151,10 +151,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-animation", description: "Query an animation by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.animations.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No animation found with uniqueId ${id}.`); @@ -165,10 +165,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-animationGroup", description: "Query an animation group by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.animationGroups.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No animation group found with uniqueId ${id}.`); @@ -179,10 +179,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-particleSystem", description: "Query a particle system by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.particleSystems.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No particle system found with uniqueId ${id}.`); @@ -193,10 +193,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-morphTargetManager", description: "Query a morph target manager by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.morphTargetManagers.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No morph target manager found with uniqueId ${id}.`); @@ -207,10 +207,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-multiMaterial", description: "Query a multi-material by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.multiMaterials.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No multi-material found with uniqueId ${id}.`); @@ -221,10 +221,10 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo { id: "query-postProcess", description: "Query a post-process by uniqueId and return its serialized data.", - args: [uniqueIdArg], + args: [UniqueIdArg], executeAsync: async (args) => { const scene = getScene(); - const id = parseUniqueId(args); + const id = ParseUniqueId(args); const entity = scene.postProcesses.find((e) => e.uniqueId === id); if (!entity) { throw new Error(`No post-process found with uniqueId ${id}.`); diff --git a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts index 64f4379ff153..61580441366f 100644 --- a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts +++ b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts @@ -10,7 +10,7 @@ import { InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry /** * Options for the inspectable bridge service. */ -export interface InspectableBridgeServiceOptions { +export interface IInspectableBridgeServiceOptions { /** * The WebSocket port for the bridge's browser port. */ @@ -27,7 +27,7 @@ export interface InspectableBridgeServiceOptions { * @param options The options for connecting to the bridge. * @returns A service definition that produces an IInspectableCommandRegistry. */ -export function MakeInspectableBridgeServiceDefinition(options: InspectableBridgeServiceOptions): ServiceDefinition<[IInspectableCommandRegistry], []> { +export function MakeInspectableBridgeServiceDefinition(options: IInspectableBridgeServiceOptions): ServiceDefinition<[IInspectableCommandRegistry], []> { return { friendlyName: "Inspectable Bridge Service", produces: [InspectableCommandRegistryIdentity], @@ -60,7 +60,7 @@ export function MakeInspectableBridgeServiceDefinition(options: InspectableBridg ws.onmessage = (event) => { try { const message = JSON.parse(event.data as string); - handleMessage(message); + void handleMessage(message); } catch { Logger.Warn("InspectableBridgeService: Failed to parse message from bridge."); } @@ -86,7 +86,7 @@ export function MakeInspectableBridgeServiceDefinition(options: InspectableBridg }, 3000); } - function handleMessage(message: BrowserResponse) { + async function handleMessage(message: BrowserResponse) { switch (message.type) { case "listCommands": { const commandList: CommandInfo[] = Array.from(commands.values()).map((cmd) => ({ @@ -111,22 +111,20 @@ export function MakeInspectableBridgeServiceDefinition(options: InspectableBridg }); break; } - command - .executeAsync(message.args) - .then((result) => { - sendToBridge({ - type: "commandResponse", - requestId: message.requestId, - result, - }); - }) - .catch((error: unknown) => { - sendToBridge({ - type: "commandResponse", - requestId: message.requestId, - error: String(error), - }); + try { + const result = await command.executeAsync(message.args); + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + result, + }); + } catch (error: unknown) { + sendToBridge({ + type: "commandResponse", + requestId: message.requestId, + error: String(error), }); + } break; } } diff --git a/packages/dev/inspector-v2/test/unit/cli/config.test.ts b/packages/dev/inspector-v2/test/unit/cli/config.test.ts index aeaccc46b8a1..42e93d364a13 100644 --- a/packages/dev/inspector-v2/test/unit/cli/config.test.ts +++ b/packages/dev/inspector-v2/test/unit/cli/config.test.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; import { tmpdir } from "os"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { loadConfig } from "../../../src/cli/config"; +import { LoadConfig } from "../../../src/cli/config"; describe("Config Loader", () => { let tempDir: string; @@ -17,14 +17,14 @@ describe("Config Loader", () => { }); it("returns defaults when no config file exists", () => { - const config = loadConfig(tempDir); + const config = LoadConfig(tempDir); expect(config.browserPort).toBe(4400); expect(config.cliPort).toBe(4401); }); it("reads config from .babyloninspector in the given directory", () => { writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ browserPort: 5500, cliPort: 5501 })); - const config = loadConfig(tempDir); + const config = LoadConfig(tempDir); expect(config.browserPort).toBe(5500); expect(config.cliPort).toBe(5501); }); @@ -33,28 +33,28 @@ describe("Config Loader", () => { const childDir = join(tempDir, "a", "b", "c"); mkdirSync(childDir, { recursive: true }); writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ browserPort: 6600 })); - const config = loadConfig(childDir); + const config = LoadConfig(childDir); expect(config.browserPort).toBe(6600); expect(config.cliPort).toBe(4401); // default }); it("merges partial config with defaults", () => { writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ cliPort: 9999 })); - const config = loadConfig(tempDir); + const config = LoadConfig(tempDir); expect(config.browserPort).toBe(4400); // default expect(config.cliPort).toBe(9999); }); it("returns defaults for malformed JSON", () => { writeFileSync(join(tempDir, ".babyloninspector"), "not valid json{{{"); - const config = loadConfig(tempDir); + const config = LoadConfig(tempDir); expect(config.browserPort).toBe(4400); expect(config.cliPort).toBe(4401); }); it("ignores non-numeric port values", () => { writeFileSync(join(tempDir, ".babyloninspector"), JSON.stringify({ browserPort: "abc", cliPort: true })); - const config = loadConfig(tempDir); + const config = LoadConfig(tempDir); expect(config.browserPort).toBe(4400); expect(config.cliPort).toBe(4401); }); From 159df9df70879d85efb60685529d812ec8e491fe Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 16:14:30 -0700 Subject: [PATCH 10/42] Add support for listing entities with basic info --- .../src/services/cli/entityQueryService.ts | 343 +++++++++--------- .../services/inspectableBridgeService.test.ts | 10 +- 2 files changed, 172 insertions(+), 181 deletions(-) diff --git a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts index 15a77e6d2c4f..0dcc043c9201 100644 --- a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts +++ b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts @@ -1,4 +1,5 @@ import type { IDisposable } from "core/index"; +import type { Scene } from "core/scene"; import type { ServiceDefinition } from "../../modularity/serviceDefinition"; import type { IInspectableCommandRegistry, InspectableCommandDescriptor } from "./inspectableCommandRegistry"; import type { ISceneContext } from "../sceneContext"; @@ -8,233 +9,223 @@ import { SceneContextIdentity } from "../sceneContext"; const UniqueIdArg = { name: "uniqueId", - description: "The uniqueId of the entity to query.", - required: true, + description: "The uniqueId of the entity to query. Omit to list all entities of this type.", + required: false, } as const; -function ParseUniqueId(args: Record): number { - const id = parseInt(args.uniqueId, 10); - if (isNaN(id)) { - throw new Error("uniqueId must be a number."); - } - return id; +interface IEntitySummary { + /** The unique id. */ + uniqueId: number; + /** The entity name, if available. */ + name?: string; + /** The class name from getClassName(), if available. */ + className?: string; + /** The parent's uniqueId, if the entity is hierarchical. */ + parentId?: number; +} + +interface IEntityCollection { + /** The command id. */ + id: string; + /** The command description. */ + description: string; + /** Accessor for the entity array from the scene. */ + getEntities: (scene: Scene) => T[] | undefined; + /** Gets the uniqueId from an entity. */ + getUniqueId: (entity: T) => number; + /** Builds a summary for listing. */ + getSummary: (entity: T) => IEntitySummary; + /** Serializes a single entity to a plain object. */ + serialize: (entity: T) => unknown; +} + +function NodeSummary(entity: { uniqueId: number; name: string; getClassName(): string; parent?: { uniqueId: number } | null }): IEntitySummary { + return { + uniqueId: entity.uniqueId, + name: entity.name, + className: entity.getClassName(), + parentId: entity.parent?.uniqueId, + }; +} + +function NamedSummary(entity: { uniqueId: number; name: string; getClassName(): string }): IEntitySummary { + return { + uniqueId: entity.uniqueId, + name: entity.name, + className: entity.getClassName(), + }; +} + +function MinimalSummary(entity: { uniqueId: number; name?: string }): IEntitySummary { + return { + uniqueId: entity.uniqueId, + name: entity.name, + }; +} + +function MakeQueryCommand(collection: IEntityCollection, sceneContext: ISceneContext): InspectableCommandDescriptor { + return { + id: collection.id, + description: collection.description, + args: [UniqueIdArg], + executeAsync: async (args) => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const entities = collection.getEntities(scene); + if (!entities) { + return JSON.stringify([], null, 2); + } + + if (!args.uniqueId) { + return JSON.stringify( + entities.map((e) => collection.getSummary(e)), + null, + 2 + ); + } + + const id = parseInt(args.uniqueId, 10); + if (isNaN(id)) { + throw new Error("uniqueId must be a number."); + } + + const entity = entities.find((e) => collection.getUniqueId(e) === id); + if (!entity) { + throw new Error(`No ${collection.id.replace("query-", "")} found with uniqueId ${id}.`); + } + + return JSON.stringify(collection.serialize(entity), null, 2); + }, + }; } /** * Service that registers CLI commands for querying scene entities by uniqueId. + * When uniqueId is omitted, returns a summary list of all entities of that type. */ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { friendlyName: "Entity Query Service", consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], factory: (commandRegistry, sceneContext) => { - function getScene() { - const scene = sceneContext.currentScene; - if (!scene) { - throw new Error("No active scene."); - } - return scene; - } - - const commands: InspectableCommandDescriptor[] = [ + const collections: IEntityCollection[] = [ { id: "query-mesh", - description: "Query a mesh by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.meshes.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No mesh found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List meshes, or query a specific mesh by uniqueId.", + getEntities: (scene) => scene.meshes, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), }, { id: "query-light", - description: "Query a light by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.lights.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No light found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List lights, or query a specific light by uniqueId.", + getEntities: (scene) => scene.lights, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), }, { id: "query-camera", - description: "Query a camera by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.cameras.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No camera found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List cameras, or query a specific camera by uniqueId.", + getEntities: (scene) => scene.cameras, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), + }, + { + id: "query-transformNode", + description: "List transform nodes, or query a specific transform node by uniqueId.", + getEntities: (scene) => scene.transformNodes, + getUniqueId: (e) => e.uniqueId, + getSummary: NodeSummary, + serialize: (e) => e.serialize(), }, { id: "query-material", - description: "Query a material by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.materials.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No material found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List materials, or query a specific material by uniqueId.", + getEntities: (scene) => scene.materials, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), }, { id: "query-texture", - description: "Query a texture by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.textures.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No texture found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List textures, or query a specific texture by uniqueId.", + getEntities: (scene) => scene.textures, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), }, { - id: "query-transformNode", - description: "Query a transform node by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.transformNodes.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No transform node found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + id: "query-skeleton", + description: "List skeletons, or query a specific skeleton by uniqueId.", + getEntities: (scene) => scene.skeletons, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), }, { id: "query-geometry", - description: "Query a geometry by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.geometries?.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No geometry found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, - }, - { - id: "query-skeleton", - description: "Query a skeleton by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.skeletons.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No skeleton found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List geometries, or query a specific geometry by uniqueId.", + getEntities: (scene) => scene.geometries, + getUniqueId: (e) => e.uniqueId, + getSummary: MinimalSummary, + serialize: (e) => e.serialize(), }, { id: "query-animation", - description: "Query an animation by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.animations.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No animation found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List animations, or query a specific animation by uniqueId.", + getEntities: (scene) => scene.animations, + getUniqueId: (e) => e.uniqueId, + getSummary: MinimalSummary, + serialize: (e) => e.serialize(), }, { id: "query-animationGroup", - description: "Query an animation group by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.animationGroups.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No animation group found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List animation groups, or query a specific animation group by uniqueId.", + getEntities: (scene) => scene.animationGroups, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), }, { id: "query-particleSystem", - description: "Query a particle system by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.particleSystems.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No particle system found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(false), null, 2); - }, + description: "List particle systems, or query a specific particle system by uniqueId.", + getEntities: (scene) => scene.particleSystems, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(false), }, { id: "query-morphTargetManager", - description: "Query a morph target manager by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.morphTargetManagers.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No morph target manager found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List morph target managers, or query a specific morph target manager by uniqueId.", + getEntities: (scene) => scene.morphTargetManagers, + getUniqueId: (e) => e.uniqueId, + getSummary: MinimalSummary, + serialize: (e) => e.serialize(), }, { id: "query-multiMaterial", - description: "Query a multi-material by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.multiMaterials.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No multi-material found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List multi-materials, or query a specific multi-material by uniqueId.", + getEntities: (scene) => scene.multiMaterials, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), }, { id: "query-postProcess", - description: "Query a post-process by uniqueId and return its serialized data.", - args: [UniqueIdArg], - executeAsync: async (args) => { - const scene = getScene(); - const id = ParseUniqueId(args); - const entity = scene.postProcesses.find((e) => e.uniqueId === id); - if (!entity) { - throw new Error(`No post-process found with uniqueId ${id}.`); - } - return JSON.stringify(entity.serialize(), null, 2); - }, + description: "List post-processes, or query a specific post-process by uniqueId.", + getEntities: (scene) => scene.postProcesses, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(), }, ]; - const registrations: IDisposable[] = commands.map((cmd) => commandRegistry.addCommand(cmd)); + const registrations: IDisposable[] = collections.map((col) => commandRegistry.addCommand(MakeQueryCommand(col, sceneContext))); return { dispose: () => { diff --git a/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts index f45d9cd5fc43..d868edaa4603 100644 --- a/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts +++ b/packages/dev/inspector-v2/test/unit/services/inspectableBridgeService.test.ts @@ -23,7 +23,7 @@ describe("InspectableBridgeService", () => { const disposal = registry.addCommand({ id: "test-cmd", description: "A test command", - execute: async () => "ok", + executeAsync: async () => "ok", }); expect(disposal).toBeDefined(); @@ -40,14 +40,14 @@ describe("InspectableBridgeService", () => { registry.addCommand({ id: "dup-cmd", description: "First", - execute: async () => "first", + executeAsync: async () => "first", }); expect(() => { registry.addCommand({ id: "dup-cmd", description: "Second", - execute: async () => "second", + executeAsync: async () => "second", }); }).toThrow("Command 'dup-cmd' is already registered."); @@ -61,7 +61,7 @@ describe("InspectableBridgeService", () => { const token = registry.addCommand({ id: "reuse-cmd", description: "Reusable", - execute: async () => "ok", + executeAsync: async () => "ok", }); token.dispose(); @@ -69,7 +69,7 @@ describe("InspectableBridgeService", () => { const token2 = registry.addCommand({ id: "reuse-cmd", description: "Reusable again", - execute: async () => "ok again", + executeAsync: async () => "ok again", }); expect(token2).toBeDefined(); From 7c86fb18b11135208d38c23cd1ea86a4202f124c Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 16:58:14 -0700 Subject: [PATCH 11/42] Add screenshot command --- packages/dev/inspector-v2/src/inspectable.ts | 4 +- .../services/cli/screenshotCommandService.ts | 91 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/dev/inspector-v2/src/services/cli/screenshotCommandService.ts diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index 8143a8d61480..0b6529bf2da7 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -9,6 +9,7 @@ import { ServiceContainer } from "./modularity/serviceContainer"; import { SceneContextIdentity } from "./services/sceneContext"; import { MakeInspectableBridgeServiceDefinition } from "./services/cli/inspectableBridgeService"; import { EntityQueryServiceDefinition } from "./services/cli/entityQueryService"; +import { ScreenshotCommandServiceDefinition } from "./services/cli/screenshotCommandService"; const DefaultPort = 4400; @@ -107,7 +108,8 @@ export function StartInspectable(scene: Scene, options?: Partial = { + friendlyName: "Screenshot Command Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + const registration = commandRegistry.addCommand({ + id: "take-screenshot", + description: "Capture a screenshot of the scene. Returns base64-encoded PNG data.", + args: [ + { + name: "cameraUniqueId", + description: "The uniqueId of the camera to use. Defaults to the active camera.", + required: false, + }, + { + name: "width", + description: "Screenshot width in pixels. When set, uses custom size mode.", + required: false, + }, + { + name: "height", + description: "Screenshot height in pixels. When set, uses custom size mode.", + required: false, + }, + { + name: "precision", + description: "Resolution multiplier (e.g. 2 for double resolution). Defaults to 1.", + required: false, + }, + ], + executeAsync: async (args) => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const engine = scene.getEngine(); + + // Resolve camera: explicit uniqueId, or active/frame-graph camera. + let camera; + if (args.cameraUniqueId) { + const cameraId = parseInt(args.cameraUniqueId, 10); + if (isNaN(cameraId)) { + throw new Error("cameraUniqueId must be a number."); + } + camera = scene.cameras.find((c) => c.uniqueId === cameraId); + if (!camera) { + throw new Error(`No camera found with uniqueId ${cameraId}.`); + } + } else { + camera = scene.frameGraph ? FrameGraphUtils.FindMainCamera(scene.frameGraph) : scene.activeCamera; + } + + if (!camera) { + throw new Error("No camera available for screenshot."); + } + + const precision = args.precision ? parseFloat(args.precision) : 1; + const width = args.width ? parseInt(args.width, 10) : undefined; + const height = args.height ? parseInt(args.height, 10) : undefined; + + const screenshotSize = width !== undefined && height !== undefined ? { width, height, precision } : { precision }; + + // Omit fileName to get data URL back without triggering a download. + const dataUrl = await CreateScreenshotUsingRenderTargetAsync(engine, camera, screenshotSize, "image/png"); + + // Strip the data URI prefix to return raw base64, which is what AI agent APIs expect. + const commaIndex = dataUrl.indexOf(","); + return commaIndex !== -1 ? dataUrl.substring(commaIndex + 1) : dataUrl; + }, + }); + + return { + dispose: () => { + registration.dispose(); + }, + }; + }, +}; From 6e6c4c44d607ed5cb63708c4da8f20b0f638a7da Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 17:03:03 -0700 Subject: [PATCH 12/42] Aligned descriptions --- packages/dev/inspector-v2/src/cli/cli.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index f3019f2031a3..d989ebe96427 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -254,8 +254,9 @@ async function Main(): Promise { console.log("No commands available."); } else { console.log("Available commands:"); + const maxLen = Math.max(...response.commands.map((c) => c.id.length)); for (const cmd of response.commands) { - console.log(` ${cmd.id} ${cmd.description}`); + console.log(` ${cmd.id.padEnd(maxLen)} ${cmd.description}`); } console.log("\nRun --command --help to see arguments for a command."); console.log("Run --command [--arg value ...] to execute a command."); @@ -323,8 +324,10 @@ async function Main(): Promise { console.log(`${commandId}: ${descriptor.description}\n`); if (descriptor.args && descriptor.args.length > 0) { console.log("Arguments:"); + const maxLen = Math.max(...descriptor.args.map((a) => `--${a.name}${a.required ? " (required)" : ""}`.length)); for (const arg of descriptor.args) { - console.log(` --${arg.name}${arg.required ? " (required)" : ""} ${arg.description}`); + const label = `--${arg.name}${arg.required ? " (required)" : ""}`; + console.log(` ${label.padEnd(maxLen)} ${arg.description}`); } } if (missingRequired.length > 0 && !wantsHelp) { From 3d4bbabfe4e42570bd6166f67e0df202f4951cd9 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 17:18:06 -0700 Subject: [PATCH 13/42] Add missing entity types --- .../src/services/cli/entityQueryService.ts | 61 +++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts index 0dcc043c9201..fd2c7391eb57 100644 --- a/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts +++ b/packages/dev/inspector-v2/src/services/cli/entityQueryService.ts @@ -4,6 +4,7 @@ import type { ServiceDefinition } from "../../modularity/serviceDefinition"; import type { IInspectableCommandRegistry, InspectableCommandDescriptor } from "./inspectableCommandRegistry"; import type { ISceneContext } from "../sceneContext"; +import { UniqueIdGenerator } from "core/Misc/uniqueIdGenerator"; import { InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; import { SceneContextIdentity } from "../sceneContext"; @@ -13,6 +14,20 @@ const UniqueIdArg = { required: false, } as const; +const SyntheticUniqueIds = new WeakMap(); + +function GetEntityId(entity: object): number { + if ("uniqueId" in entity && typeof entity.uniqueId === "number") { + return entity.uniqueId; + } + + let id = SyntheticUniqueIds.get(entity); + if (!id) { + SyntheticUniqueIds.set(entity, (id = UniqueIdGenerator.UniqueId)); + } + return id; +} + interface IEntitySummary { /** The unique id. */ uniqueId: number; @@ -31,12 +46,12 @@ interface IEntityCollection { description: string; /** Accessor for the entity array from the scene. */ getEntities: (scene: Scene) => T[] | undefined; - /** Gets the uniqueId from an entity. */ + /** Gets the uniqueId from an entity (uses synthetic ids for entities without a native uniqueId). */ getUniqueId: (entity: T) => number; /** Builds a summary for listing. */ getSummary: (entity: T) => IEntitySummary; - /** Serializes a single entity to a plain object. */ - serialize: (entity: T) => unknown; + /** Serializes a single entity to a plain object. If absent, querying by id returns the summary. */ + serialize?: (entity: T) => unknown; } function NodeSummary(entity: { uniqueId: number; name: string; getClassName(): string; parent?: { uniqueId: number } | null }): IEntitySummary { @@ -97,7 +112,7 @@ function MakeQueryCommand(collection: IEntityCollection, sceneContext: ISc throw new Error(`No ${collection.id.replace("query-", "")} found with uniqueId ${id}.`); } - return JSON.stringify(collection.serialize(entity), null, 2); + return JSON.stringify(collection.serialize ? collection.serialize(entity) : collection.getSummary(entity), null, 2); }, }; } @@ -223,6 +238,44 @@ export const EntityQueryServiceDefinition: ServiceDefinition<[], [IInspectableCo getSummary: NamedSummary, serialize: (e) => e.serialize(), }, + { + id: "query-frameGraph", + description: "List frame graphs, or query a specific frame graph by uniqueId.", + getEntities: (scene) => scene.frameGraphs, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + }, + { + id: "query-effectLayer", + description: "List effect layers, or query a specific effect layer by uniqueId.", + getEntities: (scene) => scene.effectLayers, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize?.(), + }, + { + id: "query-spriteManager", + description: "List sprite managers, or query a specific sprite manager by uniqueId.", + getEntities: (scene) => scene.spriteManagers, + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + serialize: (e) => e.serialize(false), + }, + { + id: "query-sound", + description: "List sounds in the main sound track, or query a specific sound by uniqueId.", + getEntities: (scene) => scene.mainSoundTrack?.soundCollection ?? [], + getUniqueId: (e) => GetEntityId(e), + getSummary: (e) => ({ uniqueId: GetEntityId(e), name: e.name, className: e.getClassName() }), + serialize: (e) => e.serialize(), + }, + { + id: "query-renderingPipeline", + description: "List rendering pipelines, or query a specific rendering pipeline by uniqueId.", + getEntities: (scene) => scene.postProcessRenderPipelineManager?.supportedPipelines ?? [], + getUniqueId: (e) => e.uniqueId, + getSummary: NamedSummary, + }, ]; const registrations: IDisposable[] = collections.map((col) => commandRegistry.addCommand(MakeQueryCommand(col, sceneContext))); From dcc21b4d5ddf14cf38e3fcad761c0bea2d8b6131 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Mon, 30 Mar 2026 17:32:34 -0700 Subject: [PATCH 14/42] Revert inadvertent committed files --- .../Textures/Loaders/textureLoaderManager.ts | 47 ++++++++++++------- packages/dev/core/src/Misc/lazy.ts | 6 +-- .../tools/viewer/test/apps/web/index.html | 36 +++++++++++++- 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/packages/dev/core/src/Materials/Textures/Loaders/textureLoaderManager.ts b/packages/dev/core/src/Materials/Textures/Loaders/textureLoaderManager.ts index 4e5f1d12c69b..21912afa476b 100644 --- a/packages/dev/core/src/Materials/Textures/Loaders/textureLoaderManager.ts +++ b/packages/dev/core/src/Materials/Textures/Loaders/textureLoaderManager.ts @@ -3,24 +3,7 @@ import { type IInternalTextureLoader } from "./internalTextureLoader"; import { type Nullable } from "../../../types"; import { Logger } from "core/Misc/logger"; -// Initialize the the default / well-known texture loaders. -const RegisteredTextureLoaders = new Map IInternalTextureLoader | Promise>([ - /* eslint-disable @typescript-eslint/naming-convention */ - [".ies", async () => await import("./iesTextureLoader").then(({ _IESTextureLoader }) => new _IESTextureLoader())], - [".dds", async () => await import("./ddsTextureLoader").then(({ _DDSTextureLoader }) => new _DDSTextureLoader())], - [".basis", async () => await import("./basisTextureLoader").then(({ _BasisTextureLoader }) => new _BasisTextureLoader())], - [".env", async () => await import("./envTextureLoader").then(({ _ENVTextureLoader }) => new _ENVTextureLoader())], - [".hdr", async () => await import("./hdrTextureLoader").then(({ _HDRTextureLoader }) => new _HDRTextureLoader())], - [".ktx", async () => await import("./ktxTextureLoader").then(({ _KTXTextureLoader }) => new _KTXTextureLoader())], - [".ktx2", async () => await import("./ktxTextureLoader").then(({ _KTXTextureLoader }) => new _KTXTextureLoader())], - [".tga", async () => await import("./tgaTextureLoader").then(({ _TGATextureLoader }) => new _TGATextureLoader())], - [".exr", async () => await import("./exrTextureLoader").then(({ _ExrTextureLoader }) => new _ExrTextureLoader())], - /* eslint-enable @typescript-eslint/naming-convention */ -]); - -export function GetRegisteredTextureLoaders(): readonly string[] { - return Array.from(RegisteredTextureLoaders.keys()); -} +const RegisteredTextureLoaders = new Map IInternalTextureLoader | Promise>(); /** * Registers a texture loader. @@ -56,6 +39,34 @@ export function _GetCompatibleTextureLoader(extension: string, mimeType?: string if (mimeType === "image/ktx" || mimeType === "image/ktx2") { extension = ".ktx"; } + if (!RegisteredTextureLoaders.has(extension)) { + if (extension.endsWith(".ies")) { + registerTextureLoader(".ies", async () => await import("./iesTextureLoader").then((module) => new module._IESTextureLoader())); + } + if (extension.endsWith(".dds")) { + registerTextureLoader(".dds", async () => await import("./ddsTextureLoader").then((module) => new module._DDSTextureLoader())); + } + if (extension.endsWith(".basis")) { + registerTextureLoader(".basis", async () => await import("./basisTextureLoader").then((module) => new module._BasisTextureLoader())); + } + if (extension.endsWith(".env")) { + registerTextureLoader(".env", async () => await import("./envTextureLoader").then((module) => new module._ENVTextureLoader())); + } + if (extension.endsWith(".hdr")) { + registerTextureLoader(".hdr", async () => await import("./hdrTextureLoader").then((module) => new module._HDRTextureLoader())); + } + // The ".ktx2" file extension is still up for debate: https://github.com/KhronosGroup/KTX-Specification/issues/18 + if (extension.endsWith(".ktx") || extension.endsWith(".ktx2")) { + registerTextureLoader(".ktx", async () => await import("./ktxTextureLoader").then((module) => new module._KTXTextureLoader())); + registerTextureLoader(".ktx2", async () => await import("./ktxTextureLoader").then((module) => new module._KTXTextureLoader())); + } + if (extension.endsWith(".tga")) { + registerTextureLoader(".tga", async () => await import("./tgaTextureLoader").then((module) => new module._TGATextureLoader())); + } + if (extension.endsWith(".exr")) { + registerTextureLoader(".exr", async () => await import("./exrTextureLoader").then((module) => new module._ExrTextureLoader())); + } + } const registered = RegisteredTextureLoaders.get(extension); return registered ? Promise.resolve(registered(mimeType)) : null; } diff --git a/packages/dev/core/src/Misc/lazy.ts b/packages/dev/core/src/Misc/lazy.ts index a74e460d1a03..2a7acc48a51c 100644 --- a/packages/dev/core/src/Misc/lazy.ts +++ b/packages/dev/core/src/Misc/lazy.ts @@ -13,15 +13,11 @@ export class Lazy { this._factory = factory; } - public get hasValue() { - // If the factory function is still defined, it means we haven't called it yet. - return this._factory === undefined; - } - /** * Gets the lazily initialized value. */ public get value(): T { + // If the factory function is still defined, it means we haven't called it yet. if (this._factory) { this._value = this._factory(); // Set the factory function to undefined to allow it to be garbage collected. diff --git a/packages/tools/viewer/test/apps/web/index.html b/packages/tools/viewer/test/apps/web/index.html index af9447feeb9c..c4132971a663 100644 --- a/packages/tools/viewer/test/apps/web/index.html +++ b/packages/tools/viewer/test/apps/web/index.html @@ -79,7 +79,41 @@
- + From 48b237cd720bf548576064a8e14f7b2a884a03ac Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Tue, 31 Mar 2026 09:23:56 -0700 Subject: [PATCH 15/42] Add stats --- packages/dev/inspector-v2/src/cli/cli.ts | 71 +++--- packages/dev/inspector-v2/src/inspectable.ts | 4 +- .../src/services/cli/statsCommandService.ts | 203 ++++++++++++++++++ 3 files changed, 238 insertions(+), 40 deletions(-) create mode 100644 packages/dev/inspector-v2/src/services/cli/statsCommandService.ts diff --git a/packages/dev/inspector-v2/src/cli/cli.ts b/packages/dev/inspector-v2/src/cli/cli.ts index 4b8e0d8beea4..7bfa4beeba21 100644 --- a/packages/dev/inspector-v2/src/cli/cli.ts +++ b/packages/dev/inspector-v2/src/cli/cli.ts @@ -240,53 +240,46 @@ async function Main(): Promise { return; } - if (args.commands) { + if (args.commands || args.command) { const socket = await EnsureBridge(Config.cliPort, args.bridgeScript); try { - const sessionId = await ResolveSessionId(socket, args.rest[0]); - const response = await SendAndReceive(socket, { type: "commands", sessionId }); - if (response.error) { - console.error(`Error: ${response.error}`); - process.exitCode = 1; - return; - } - if (!response.commands || response.commands.length === 0) { - console.log("No commands available."); - } else { - console.log("Available commands:"); - const maxLen = Math.max(...response.commands.map((c) => c.id.length)); - for (const cmd of response.commands) { - console.log(` ${cmd.id.padEnd(maxLen)} ${cmd.description}`); - } - console.log("\nRun --command --help to see arguments for a command."); - console.log("Run --command [--arg value ...] to execute a command."); - } - } finally { - socket.close(); - } - return; - } - - if (args.command) { - const socket = await EnsureBridge(Config.cliPort, args.bridgeScript); - try { - // Positionals in rest: [sessionId?] + // If --command was given with a command id, execute it. + // Otherwise (--commands, or --command with no id), list available commands. let commandId: string | undefined; - let argsStartIndex: number; + let argsStartIndex = 0; let explicitSessionId: string | undefined; - if (args.rest.length > 0 && !isNaN(parseInt(args.rest[0], 10)) && args.rest.length > 1) { - explicitSessionId = args.rest[0]; - commandId = args.rest[1]; - argsStartIndex = 2; - } else { - commandId = args.rest[0]; - argsStartIndex = 1; + if (args.command && args.rest.length > 0) { + if (!isNaN(parseInt(args.rest[0], 10)) && args.rest.length > 1) { + explicitSessionId = args.rest[0]; + commandId = args.rest[1]; + argsStartIndex = 2; + } else { + commandId = args.rest[0]; + argsStartIndex = 1; + } } if (!commandId) { - console.error("Error: --command requires a command id."); - process.exitCode = 1; + // List available commands. + const sessionId = await ResolveSessionId(socket, args.rest[0]); + const response = await SendAndReceive(socket, { type: "commands", sessionId }); + if (response.error) { + console.error(`Error: ${response.error}`); + process.exitCode = 1; + return; + } + if (!response.commands || response.commands.length === 0) { + console.log("No commands available."); + } else { + console.log("Available commands:"); + const maxLen = Math.max(...response.commands.map((c) => c.id.length)); + for (const cmd of response.commands) { + console.log(` ${cmd.id.padEnd(maxLen)} ${cmd.description}`); + } + console.log("\nRun --command --help to see arguments for a command."); + console.log("Run --command [--arg value ...] to execute a command."); + } return; } diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index 73485e485fbe..1ba138ca3f95 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -7,6 +7,7 @@ import { type ServiceDefinition } from "./modularity/serviceDefinition"; import { EntityQueryServiceDefinition } from "./services/cli/entityQueryService"; import { MakeInspectableBridgeServiceDefinition } from "./services/cli/inspectableBridgeService"; import { ScreenshotCommandServiceDefinition } from "./services/cli/screenshotCommandService"; +import { StatsCommandServiceDefinition } from "./services/cli/statsCommandService"; import { type ISceneContext, SceneContextIdentity } from "./services/sceneContext"; const DefaultPort = 4400; @@ -107,7 +108,8 @@ export function StartInspectable(scene: Scene, options?: Partial = { + friendlyName: "Stats Command Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + let sceneInstrumentation: SceneInstrumentation | undefined; + let engineInstrumentation: EngineInstrumentation | undefined; + + function disposeInstrumentation() { + sceneInstrumentation?.dispose(); + sceneInstrumentation = undefined; + engineInstrumentation?.dispose(); + engineInstrumentation = undefined; + } + + const startPerfReg = commandRegistry.addCommand({ + id: "start-perf-instrumentation", + description: "Start scene and engine performance instrumentation for frame stats collection.", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + // Dispose any stale instrumentation (e.g. scene changed). + if (sceneInstrumentation && sceneInstrumentation.scene !== scene) { + disposeInstrumentation(); + } + + if (sceneInstrumentation) { + return "Performance instrumentation is already running."; + } + + sceneInstrumentation = new SceneInstrumentation(scene); + sceneInstrumentation.captureActiveMeshesEvaluationTime = true; + sceneInstrumentation.captureRenderTargetsRenderTime = true; + sceneInstrumentation.captureFrameTime = true; + sceneInstrumentation.captureRenderTime = true; + sceneInstrumentation.captureInterFrameTime = true; + sceneInstrumentation.captureParticlesRenderTime = true; + sceneInstrumentation.captureSpritesRenderTime = true; + sceneInstrumentation.capturePhysicsTime = true; + sceneInstrumentation.captureAnimationsTime = true; + + engineInstrumentation = new EngineInstrumentation(scene.getEngine()); + engineInstrumentation.captureGPUFrameTime = true; + + return "Performance instrumentation started."; + }, + }); + + const stopPerfReg = commandRegistry.addCommand({ + id: "stop-perf-instrumentation", + description: "Stop scene and engine performance instrumentation.", + executeAsync: async () => { + if (!sceneInstrumentation && !engineInstrumentation) { + return "Performance instrumentation is not running."; + } + + disposeInstrumentation(); + return "Performance instrumentation stopped."; + }, + }); + + const countStatsReg = commandRegistry.addCommand({ + id: "get-count-stats", + description: "Get scene entity counts (meshes, lights, vertices, draw calls, etc.).", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + let activeMeshesCount = scene.getActiveMeshes().length; + for (const objectRenderer of scene.objectRenderers) { + activeMeshesCount += objectRenderer.getActiveMeshes().length; + } + + const activeIndices = scene.getActiveIndices(); + + return JSON.stringify( + { + totalMeshes: scene.meshes.length, + activeMeshes: activeMeshesCount, + activeIndices, + activeFaces: Math.floor(activeIndices / 3), + activeBones: scene.getActiveBones(), + activeParticles: scene.getActiveParticles(), + drawCalls: scene.getEngine()._drawCalls.current, + totalLights: scene.lights.length, + totalVertices: scene.getTotalVertices(), + totalMaterials: scene.materials.length, + totalTextures: scene.textures.length, + }, + null, + 2 + ); + }, + }); + + const frameStatsReg = commandRegistry.addCommand({ + id: "get-frame-stats", + description: "Get frame timing statistics. Requires start-perf-instrumentation to be run first.", + executeAsync: async () => { + if (!sceneInstrumentation || !engineInstrumentation) { + throw new Error("Performance instrumentation is not running. Run start-perf-instrumentation first."); + } + + const si = sceneInstrumentation; + const ei = engineInstrumentation; + + const round = (v: number) => Math.round(v * 100) / 100; + + return JSON.stringify( + { + absoluteFPS: Math.floor(1000.0 / si.frameTimeCounter.lastSecAverage), + meshesSelectionMs: round(si.activeMeshesEvaluationTimeCounter.lastSecAverage), + renderTargetsMs: round(si.renderTargetsRenderTimeCounter.lastSecAverage), + particlesMs: round(si.particlesRenderTimeCounter.lastSecAverage), + spritesMs: round(si.spritesRenderTimeCounter.lastSecAverage), + animationsMs: round(si.animationsTimeCounter.lastSecAverage), + physicsMs: round(si.physicsTimeCounter.lastSecAverage), + renderMs: round(si.renderTimeCounter.lastSecAverage), + frameMs: round(si.frameTimeCounter.lastSecAverage), + interFrameMs: round(si.interFrameTimeCounter.lastSecAverage), + gpuFrameMs: round(ei.gpuFrameTimeCounter.lastSecAverage * 0.000001), + gpuFrameAverageMs: round(ei.gpuFrameTimeCounter.average * 0.000001), + }, + null, + 2 + ); + }, + }); + + const systemStatsReg = commandRegistry.addCommand({ + id: "get-system-stats", + description: "Get engine capabilities and system information.", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + const engine = scene.getEngine(); + const caps = engine.getCaps(); + + return JSON.stringify( + { + resolution: `${engine.getRenderWidth()} x ${engine.getRenderHeight()}`, + hardwareScalingLevel: engine.getHardwareScalingLevel(), + engine: engine.description, + driver: engine.extractDriverInfo(), + capabilities: { + stdDerivatives: caps.standardDerivatives, + compressedTextures: caps.s3tc !== undefined, + hardwareInstances: caps.instancedArrays, + textureFloat: caps.textureFloat, + textureHalfFloat: caps.textureHalfFloat, + renderToTextureFloat: caps.textureFloatRender, + renderToTextureHalfFloat: caps.textureHalfFloatRender, + indices32Bit: caps.uintIndices, + fragmentDepth: caps.fragmentDepthSupported, + highPrecisionShaders: caps.highPrecisionShaderSupported, + drawBuffers: caps.drawBuffersExtension, + vertexArrayObject: caps.vertexArrayObject, + timerQuery: caps.timerQuery !== undefined, + stencil: engine.isStencilEnable, + parallelShaderCompilation: caps.parallelShaderCompile != null, + maxTexturesUnits: caps.maxTexturesImageUnits, + maxTexturesSize: caps.maxTextureSize, + maxAnisotropy: caps.maxAnisotropy, + }, + }, + null, + 2 + ); + }, + }); + + return { + dispose: () => { + startPerfReg.dispose(); + stopPerfReg.dispose(); + countStatsReg.dispose(); + frameStatsReg.dispose(); + systemStatsReg.dispose(); + disposeInstrumentation(); + }, + }; + }, +}; From eddfee623e073b6a38f823a8ec04eb533857a616 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Tue, 31 Mar 2026 10:06:27 -0700 Subject: [PATCH 16/42] Add perf trace commands --- .../src/components/stats/performanceStats.tsx | 50 ++--------- packages/dev/inspector-v2/src/inspectable.ts | 4 +- .../src/misc/defaultPerfStrategies.ts | 43 ++++++++++ .../services/cli/perfTraceCommandService.ts | 85 +++++++++++++++++++ 4 files changed, 137 insertions(+), 45 deletions(-) create mode 100644 packages/dev/inspector-v2/src/misc/defaultPerfStrategies.ts create mode 100644 packages/dev/inspector-v2/src/services/cli/perfTraceCommandService.ts diff --git a/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx b/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx index 71889afa2f57..c91c243b5dea 100644 --- a/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx +++ b/packages/dev/inspector-v2/src/components/stats/performanceStats.tsx @@ -15,22 +15,15 @@ import { ChildWindow } from "shared-ui-components/fluent/hoc/childWindow"; import { FileUploadLine } from "shared-ui-components/fluent/hoc/fileUploadLine"; import { type PerfLayoutSize } from "../performanceViewer/graphSupportingTypes"; import { PerformanceViewer } from "../performanceViewer/performanceViewer"; +import { DefaultPerfStrategies, PerfMetadataCategory } from "../../misc/defaultPerfStrategies"; +/** + * Adds default and platform-specific performance collection strategies to the collector. + * @param perfCollector - The performance viewer collector to add strategies to. + */ function AddStrategies(perfCollector: PerformanceViewerCollector) { - perfCollector.addCollectionStrategies(...DefaultStrategiesList); + perfCollector.addCollectionStrategies(...DefaultPerfStrategies); if (PressureObserverWrapper.IsAvailable) { - // Do not enable for now as the Pressure API does not - // report factors at the moment. - // perfCollector.addCollectionStrategies({ - // strategyCallback: PerfCollectionStrategy.ThermalStrategy(), - // category: IPerfMetadataCategory.FrameSteps, - // hidden: true, - // }); - // perfCollector.addCollectionStrategies({ - // strategyCallback: PerfCollectionStrategy.PowerSupplyStrategy(), - // category: IPerfMetadataCategory.FrameSteps, - // hidden: true, - // }); perfCollector.addCollectionStrategies({ strategyCallback: PerfCollectionStrategy.PressureStrategy(), category: PerfMetadataCategory.FrameSteps, @@ -39,37 +32,6 @@ function AddStrategies(perfCollector: PerformanceViewerCollector) { } } -const enum PerfMetadataCategory { - Count = "Count", - FrameSteps = "Frame Steps Duration", -} - -// list of strategies to add to perf graph automatically. -const DefaultStrategiesList = [ - { strategyCallback: PerfCollectionStrategy.FpsStrategy() }, - { strategyCallback: PerfCollectionStrategy.TotalMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveIndicesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveBonesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ActiveParticlesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.DrawCallsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalLightsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalVerticesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalMaterialsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.TotalTexturesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, - { strategyCallback: PerfCollectionStrategy.AbsoluteFpsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.MeshesSelectionStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.RenderTargetsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.ParticlesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.SpritesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.AnimationsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.PhysicsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.RenderStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.FrameTotalStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.InterFrameStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, - { strategyCallback: PerfCollectionStrategy.GpuFrameTimeStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, -] as const; - // arbitrary window size const InitialWindowSize = { width: 1024, height: 512 }; const InitialGraphSize = new Vector2(724, 512); diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index 1ba138ca3f95..d93067701b87 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -6,6 +6,7 @@ import { ServiceContainer } from "./modularity/serviceContainer"; import { type ServiceDefinition } from "./modularity/serviceDefinition"; import { EntityQueryServiceDefinition } from "./services/cli/entityQueryService"; import { MakeInspectableBridgeServiceDefinition } from "./services/cli/inspectableBridgeService"; +import { PerfTraceCommandServiceDefinition } from "./services/cli/perfTraceCommandService"; import { ScreenshotCommandServiceDefinition } from "./services/cli/screenshotCommandService"; import { StatsCommandServiceDefinition } from "./services/cli/statsCommandService"; import { type ISceneContext, SceneContextIdentity } from "./services/sceneContext"; @@ -109,7 +110,8 @@ export function StartInspectable(scene: Scene, options?: Partial[number]; + +/** + * Performance metadata categories for grouping strategies. + */ +export const enum PerfMetadataCategory { + Count = "Count", + FrameSteps = "Frame Steps Duration", +} + +/** + * Default list of performance collection strategies used by the performance viewer and CLI perf trace. + */ +export const DefaultPerfStrategies: readonly PerfStrategyParameter[] = [ + { strategyCallback: PerfCollectionStrategy.FpsStrategy() }, + { strategyCallback: PerfCollectionStrategy.TotalMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveMeshesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveIndicesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveBonesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ActiveParticlesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.DrawCallsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalLightsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalVerticesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalMaterialsStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.TotalTexturesStrategy(), category: PerfMetadataCategory.Count, hidden: true }, + { strategyCallback: PerfCollectionStrategy.AbsoluteFpsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.MeshesSelectionStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.RenderTargetsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.ParticlesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.SpritesStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.AnimationsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.PhysicsStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.RenderStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.FrameTotalStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.InterFrameStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, + { strategyCallback: PerfCollectionStrategy.GpuFrameTimeStrategy(), category: PerfMetadataCategory.FrameSteps, hidden: true }, +]; diff --git a/packages/dev/inspector-v2/src/services/cli/perfTraceCommandService.ts b/packages/dev/inspector-v2/src/services/cli/perfTraceCommandService.ts new file mode 100644 index 000000000000..11f5e8acdabd --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/perfTraceCommandService.ts @@ -0,0 +1,85 @@ +import { PerformanceViewerCollector } from "core/Misc/PerformanceViewer/performanceViewerCollector"; +import { DefaultPerfStrategies } from "../../misc/defaultPerfStrategies"; +import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ISceneContext, SceneContextIdentity } from "../sceneContext"; +import { type IInspectableCommandRegistry, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; + +import "core/Misc/PerformanceViewer/performanceViewerSceneExtension"; + +/** + * Service that registers CLI commands for performance tracing using the PerformanceViewerCollector. + * start-perf-trace begins collecting data, stop-perf-trace stops and returns the collected data as JSON. + */ +export const PerfTraceCommandServiceDefinition: ServiceDefinition<[], [IInspectableCommandRegistry, ISceneContext]> = { + friendlyName: "Perf Trace Command Service", + consumes: [InspectableCommandRegistryIdentity, SceneContextIdentity], + factory: (commandRegistry, sceneContext) => { + let perfCollector: PerformanceViewerCollector | undefined; + + const startReg = commandRegistry.addCommand({ + id: "start-perf-trace", + description: "Start collecting performance trace data.", + executeAsync: async () => { + const scene = sceneContext.currentScene; + if (!scene) { + throw new Error("No active scene."); + } + + if (perfCollector?.isStarted) { + return "Performance trace is already running."; + } + + perfCollector = scene.getPerfCollector(); + perfCollector.stop(); + perfCollector.clear(false); + perfCollector.addCollectionStrategies(...DefaultPerfStrategies); + perfCollector.start(true); + + return "Performance trace started."; + }, + }); + + const stopReg = commandRegistry.addCommand({ + id: "stop-perf-trace", + description: "Stop collecting performance trace data and return the results as JSON.", + executeAsync: async () => { + if (!perfCollector || !perfCollector.isStarted) { + throw new Error("Performance trace is not running. Run start-perf-trace first."); + } + + perfCollector.stop(); + + const datasets = perfCollector.datasets; + const ids = datasets.ids; + const rawData = datasets.data.subarray(0, datasets.data.itemLength); + const sliceSize = ids.length + PerformanceViewerCollector.SliceDataOffset; + + const samples: Record[] = []; + for (let i = 0; i < rawData.length; i += sliceSize) { + const timestamp = rawData[i]; + const sample: Record = { timestamp }; + for (let j = 0; j < ids.length; j++) { + sample[ids[j]] = rawData[i + PerformanceViewerCollector.SliceDataOffset + j]; + } + samples.push(sample); + } + + perfCollector.clear(false); + perfCollector = undefined; + + return JSON.stringify({ strategies: ids, sampleCount: samples.length, samples }, null, 2); + }, + }); + + return { + dispose: () => { + startReg.dispose(); + stopReg.dispose(); + if (perfCollector?.isStarted) { + perfCollector.stop(); + } + perfCollector = undefined; + }, + }; + }, +}; From 97f1ee14201ddc39fbec692d058dd40300ba2202 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Tue, 31 Mar 2026 10:29:26 -0700 Subject: [PATCH 17/42] Make StartInspectable ref counted --- packages/dev/inspector-v2/src/inspectable.ts | 129 +++++++++++-------- 1 file changed, 78 insertions(+), 51 deletions(-) diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index d93067701b87..23d4a63ecbaf 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -39,8 +39,15 @@ export type InspectableToken = IDisposable & { readonly isDisposed: boolean; }; -// Track one token per scene so we can return the existing one or clean up on re-entry. -const InspectableTokens = new Map(); +// Track shared state per scene: the service container, ref count, and teardown logic. +type InspectableState = { + refCount: number; + serviceContainer: ServiceContainer; + sceneDisposeObserver: { remove: () => void }; + fullyDispose: () => void; +}; + +const InspectableStates = new Map(); /** * Makes a scene inspectable by connecting it to the Inspector CLI bridge. @@ -48,26 +55,81 @@ const InspectableTokens = new Map(); * {@link InspectableBridgeService} which opens a WebSocket to the bridge and * exposes a command registry for CLI-invocable commands. * - * If the scene is already inspectable, the existing token is returned. + * Multiple callers may call this for the same scene. Each returned token is + * ref-counted — the underlying connection is only torn down when all tokens + * have been disposed. * * @param scene The scene to make inspectable. * @param options Optional configuration. * @returns An {@link InspectableToken} that can be disposed to disconnect. */ export function StartInspectable(scene: Scene, options?: Partial): InspectableToken { - // If there is already an active token for this scene, return it. - const existing = InspectableTokens.get(scene); - if (existing && !existing.isDisposed) { - return existing; - } + let state = InspectableStates.get(scene); - const port = options?.port ?? DefaultPort; - const name = options?.name ?? (typeof document !== "undefined" ? document.title : "Babylon.js Scene"); + if (!state) { + const port = options?.port ?? DefaultPort; + const name = options?.name ?? (typeof document !== "undefined" ? document.title : "Babylon.js Scene"); - const serviceContainer = new ServiceContainer("InspectableContainer"); + const serviceContainer = new ServiceContainer("InspectableContainer"); - let disposed = false; + const fullyDispose = () => { + InspectableStates.delete(scene); + serviceContainer.dispose(); + sceneDisposeObserver.remove(); + }; + state = { + refCount: 0, + serviceContainer, + sceneDisposeObserver: { remove: () => {} }, + fullyDispose, + }; + + InspectableStates.set(scene, state); + + // Auto-dispose when the scene is disposed. + const capturedState = state; + const sceneDisposeObserver = scene.onDisposeObservable.addOnce(() => { + capturedState.refCount = 0; + capturedState.fullyDispose(); + }); + state.sceneDisposeObserver = sceneDisposeObserver; + + // Initialize the service container asynchronously. + const sceneContextServiceDefinition: ServiceDefinition<[ISceneContext], []> = { + friendlyName: "Inspectable Scene Context", + produces: [SceneContextIdentity], + factory: () => ({ + currentScene: scene, + currentSceneObservable: new Observable>(), + }), + }; + + void (async () => { + try { + await serviceContainer.addServicesAsync( + sceneContextServiceDefinition, + MakeInspectableBridgeServiceDefinition({ + port, + name, + }), + EntityQueryServiceDefinition, + ScreenshotCommandServiceDefinition, + StatsCommandServiceDefinition, + PerfTraceCommandServiceDefinition + ); + } catch (error: unknown) { + Logger.Error(`Failed to initialize Inspectable: ${error}`); + capturedState.refCount = 0; + capturedState.fullyDispose(); + } + })(); + } + + state.refCount++; + const owningState = state; + + let disposed = false; const token: InspectableToken = { get isDisposed() { return disposed; @@ -77,47 +139,12 @@ export function StartInspectable(scene: Scene, options?: Partial { - token.dispose(); - }); - - // Initialize the service container asynchronously. - const sceneContextServiceDefinition: ServiceDefinition<[ISceneContext], []> = { - friendlyName: "Inspectable Scene Context", - produces: [SceneContextIdentity], - factory: () => ({ - currentScene: scene, - currentSceneObservable: new Observable>(), - }), - }; - - void (async () => { - try { - await serviceContainer.addServicesAsync( - sceneContextServiceDefinition, - MakeInspectableBridgeServiceDefinition({ - port, - name, - }), - EntityQueryServiceDefinition, - ScreenshotCommandServiceDefinition, - StatsCommandServiceDefinition, - PerfTraceCommandServiceDefinition - ); - } catch (error: unknown) { - Logger.Error(`Failed to initialize Inspectable: ${error}`); - token.dispose(); - } - })(); - return token; } From 70903b51afce70c45bf95cd63f33913de1f5c2d6 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Tue, 31 Mar 2026 13:23:50 -0700 Subject: [PATCH 18/42] Add ServiceContainer parent support, and use it to have a longer lived Inspectable container that Inspector builds on top of --- packages/dev/inspector-v2/src/index.ts | 2 +- packages/dev/inspector-v2/src/inspectable.ts | 53 ++++++--- packages/dev/inspector-v2/src/inspector.tsx | 24 ++-- packages/dev/inspector-v2/src/modularTool.tsx | 10 +- .../src/modularity/serviceContainer.ts | 104 ++++++++++++------ 5 files changed, 130 insertions(+), 63 deletions(-) diff --git a/packages/dev/inspector-v2/src/index.ts b/packages/dev/inspector-v2/src/index.ts index 09b28a3d67c0..a749279d026d 100644 --- a/packages/dev/inspector-v2/src/index.ts +++ b/packages/dev/inspector-v2/src/index.ts @@ -49,7 +49,7 @@ export * from "./services/settingsStore"; export type { IShellService, ToolbarItemDefinition, SidePaneDefinition, CentralContentDefinition } from "./services/shellService"; export { ShellServiceIdentity } from "./services/shellService"; export * from "./inspector"; -export * from "./inspectable"; +export { StartInspectable, type InspectableToken, type InspectableOptions } from "./inspectable"; export type { IInspectableCommandRegistry, InspectableCommandDescriptor, InspectableCommandArg } from "./services/cli/inspectableCommandRegistry"; export { InspectableCommandRegistryIdentity } from "./services/cli/inspectableCommandRegistry"; export { ConvertOptions, Inspector } from "./legacy/inspector"; diff --git a/packages/dev/inspector-v2/src/inspectable.ts b/packages/dev/inspector-v2/src/inspectable.ts index 23d4a63ecbaf..45040b993539 100644 --- a/packages/dev/inspector-v2/src/inspectable.ts +++ b/packages/dev/inspector-v2/src/inspectable.ts @@ -39,6 +39,18 @@ export type InspectableToken = IDisposable & { readonly isDisposed: boolean; }; +/** + * @internal + * An internal token that also exposes the underlying ServiceContainer, + * allowing ShowInspector to use it as a parent container. + */ +export type InternalInspectableToken = InspectableToken & { + /** + * The ServiceContainer backing this inspectable session. + */ + readonly serviceContainer: ServiceContainer; +}; + // Track shared state per scene: the service container, ref count, and teardown logic. type InspectableState = { refCount: number; @@ -50,20 +62,11 @@ type InspectableState = { const InspectableStates = new Map(); /** - * Makes a scene inspectable by connecting it to the Inspector CLI bridge. - * This creates a headless {@link ServiceContainer} (no UI) and registers the - * {@link InspectableBridgeService} which opens a WebSocket to the bridge and - * exposes a command registry for CLI-invocable commands. - * - * Multiple callers may call this for the same scene. Each returned token is - * ref-counted — the underlying connection is only torn down when all tokens - * have been disposed. - * - * @param scene The scene to make inspectable. - * @param options Optional configuration. - * @returns An {@link InspectableToken} that can be disposed to disconnect. + * @internal + * Internal implementation that returns an {@link InternalInspectableToken} with access + * to the underlying ServiceContainer. Used by ShowInspector to set up a parent container relationship. */ -export function StartInspectable(scene: Scene, options?: Partial): InspectableToken { +export function _StartInspectable(scene: Scene, options?: Partial): InternalInspectableToken { let state = InspectableStates.get(scene); if (!state) { @@ -127,13 +130,17 @@ export function StartInspectable(scene: Scene, options?: Partial): InspectableToken { + return _StartInspectable(scene, options); +} diff --git a/packages/dev/inspector-v2/src/inspector.tsx b/packages/dev/inspector-v2/src/inspector.tsx index 0a6e78d3799c..e59f03c01563 100644 --- a/packages/dev/inspector-v2/src/inspector.tsx +++ b/packages/dev/inspector-v2/src/inspector.tsx @@ -1,10 +1,10 @@ -import { type IDisposable, type IReadonlyObservable, type Nullable, type Scene } from "core/index"; +import { type IDisposable, type IReadonlyObservable, type Scene } from "core/index"; import { type WeaklyTypedServiceDefinition } from "./modularity/serviceContainer"; import { type ServiceDefinition } from "./modularity/serviceDefinition"; import { type ModularToolOptions, MakeModularTool } from "./modularTool"; -import { type ISceneContext, SceneContextIdentity } from "./services/sceneContext"; import { type IShellService, ShellServiceIdentity } from "./services/shellService"; +import { _StartInspectable } from "./inspectable"; import { AsyncLock } from "core/Misc/asyncLock"; import { Logger } from "core/Misc/logger"; import { Observable } from "core/Misc/observable"; @@ -228,6 +228,12 @@ export function ShowInspector(scene: Scene, options: Partial = // This array will contain all the default Inspector service definitions. const serviceDefinitions: WeaklyTypedServiceDefinition[] = []; + // Ensure the inspectable bridge is running for this scene. The inspector's + // ServiceContainer will use the inspectable container as a parent, inheriting + // services like ISceneContext and IInspectableCommandRegistry. + const inspectableToken = _StartInspectable(scene); + disposeActions.push(() => inspectableToken.dispose()); + // Create a container element for the inspector UI. // This element will become the root React node, so it must be a new empty node // since React will completely take over its contents. @@ -285,19 +291,6 @@ export function ShowInspector(scene: Scene, options: Partial = parentElement.removeChild(containerElement); }); - // This service exposes the scene that was passed into Inspector through ISceneContext, which is used by other services that may be used in other contexts outside of Inspector. - const sceneContextServiceDefinition: ServiceDefinition<[ISceneContext], []> = { - friendlyName: "Inspector Scene Context", - produces: [SceneContextIdentity], - factory: () => { - return { - currentScene: scene, - currentSceneObservable: new Observable>(), - }; - }, - }; - serviceDefinitions.push(sceneContextServiceDefinition); - if (options.autoResizeEngine) { const observer = scene.onBeforeRenderObservable.add(() => scene.getEngine().resize()); disposeActions.push(() => observer.remove()); @@ -399,6 +392,7 @@ export function ShowInspector(scene: Scene, options: Partial = const modularTool = MakeModularTool({ namespace: "Inspector", containerElement, + parentContainer: inspectableToken.serviceContainer, serviceDefinitions: [ // Default Inspector services. ...serviceDefinitions, diff --git a/packages/dev/inspector-v2/src/modularTool.tsx b/packages/dev/inspector-v2/src/modularTool.tsx index 385ecf1587af..ea08d8d495c9 100644 --- a/packages/dev/inspector-v2/src/modularTool.tsx +++ b/packages/dev/inspector-v2/src/modularTool.tsx @@ -93,6 +93,12 @@ export type ModularToolOptions = { * The extension feeds that provide optional extensions the user can install. */ extensionFeeds?: readonly IExtensionFeed[]; + + /** + * An optional parent ServiceContainer. Dependencies not found in the tool's own container + * will be resolved from this parent. + */ + parentContainer?: ServiceContainer; } & ShellServiceOptions; /** @@ -101,7 +107,7 @@ export type ModularToolOptions = { * @returns A token that can be used to dispose of the tool. */ export function MakeModularTool(options: ModularToolOptions): IDisposable { - const { namespace, containerElement, serviceDefinitions, themeMode, showThemeSelector = true, extensionFeeds = [] } = options; + const { namespace, containerElement, serviceDefinitions, themeMode, showThemeSelector = true, extensionFeeds = [], parentContainer } = options; // Create the settings store immediately as it will be exposed to services and through React context. const settingsStore = new SettingsStore(namespace); @@ -123,7 +129,7 @@ export function MakeModularTool(options: ModularToolOptions): IDisposable { // This is the main async initialization. useEffect(() => { const initializeExtensionManagerAsync = async () => { - const serviceContainer = new ServiceContainer("ModularToolContainer"); + const serviceContainer = new ServiceContainer("ModularToolContainer", parentContainer); // Expose the settings store as a service so other services can read/write settings. await serviceContainer.addServiceAsync<[ISettingsStore], []>({ diff --git a/packages/dev/inspector-v2/src/modularity/serviceContainer.ts b/packages/dev/inspector-v2/src/modularity/serviceContainer.ts index 543a2e03745f..ff7a944a966d 100644 --- a/packages/dev/inspector-v2/src/modularity/serviceContainer.ts +++ b/packages/dev/inspector-v2/src/modularity/serviceContainer.ts @@ -40,8 +40,19 @@ export class ServiceContainer implements IDisposable { private readonly _serviceDefinitions = new Map(); private readonly _serviceDependents = new Map>(); private readonly _serviceInstances = new Map & Partial) | void>(); + private readonly _children = new Set(); - public constructor(private readonly _friendlyName: string) {} + /** + * Creates a new ServiceContainer. + * @param _friendlyName A human-readable name for debugging. + * @param _parent An optional parent container. Dependencies not found locally will be resolved from the parent. + */ + public constructor( + private readonly _friendlyName: string, + private readonly _parent?: ServiceContainer + ) { + _parent?._children.add(this); + } /** * Adds a set of service definitions in the service container. @@ -115,28 +126,61 @@ export class ServiceContainer implements IDisposable { this._serviceDefinitions.set(contract, service); }); - const dependencies = - service.consumes?.map((dependency) => { - const dependencyDefinition = this._serviceDefinitions.get(dependency); - if (!dependencyDefinition) { - throw new Error(`Service '${dependency.toString()}' has not been registered in the '${this._friendlyName}' container.`); - } + const dependencies = service.consumes?.map((contract) => this._resolveDependency(contract, service)) ?? []; - let dependentDefinitions = this._serviceDependents.get(dependencyDefinition); - if (!dependentDefinitions) { - this._serviceDependents.set(dependencyDefinition, (dependentDefinitions = new Set())); - } - dependentDefinitions.add(service); + this._serviceInstances.set(service, await service.factory(...dependencies, abortSignal)); + } - const dependencyInstance = this._serviceInstances.get(dependencyDefinition); - if (!dependencyInstance) { - throw new Error(`Service '${dependency.toString()}' has not been instantiated in the '${this._friendlyName}' container.`); - } + /** + * Resolves a dependency by contract identity for a consuming service. + * Checks local services first, then walks up the parent chain. + * Registers the consumer as a dependent in whichever container owns the dependency. + * @param contract The contract identity to resolve. + * @param consumer The service definition that consumes this dependency. + * @returns The resolved service instance. + */ + private _resolveDependency(contract: symbol, consumer: WeaklyTypedServiceDefinition): IService & Partial { + const definition = this._serviceDefinitions.get(contract); + if (definition) { + let dependentDefinitions = this._serviceDependents.get(definition); + if (!dependentDefinitions) { + this._serviceDependents.set(definition, (dependentDefinitions = new Set())); + } + dependentDefinitions.add(consumer); - return dependencyInstance; - }) ?? []; + const instance = this._serviceInstances.get(definition); + if (!instance) { + throw new Error(`Service '${contract.toString()}' has not been instantiated in the '${this._friendlyName}' container.`); + } + return instance; + } - this._serviceInstances.set(service, await service.factory(...dependencies, abortSignal)); + if (this._parent) { + return this._parent._resolveDependency(contract, consumer); + } + + throw new Error(`Service '${contract.toString()}' has not been registered in the '${this._friendlyName}' container.`); + } + + /** + * Removes a consumer from the dependent set for a given contract, checking locally first then the parent chain. + * @param contract The contract identity. + * @param consumer The service definition to remove as a dependent. + */ + private _removeDependentFromChain(contract: symbol, consumer: WeaklyTypedServiceDefinition): void { + const definition = this._serviceDefinitions.get(contract); + if (definition) { + const dependentDefinitions = this._serviceDependents.get(definition); + if (dependentDefinitions) { + dependentDefinitions.delete(consumer); + if (dependentDefinitions.size === 0) { + this._serviceDependents.delete(definition); + } + } + return; + } + + this._parent?._removeDependentFromChain(contract, consumer); } private _removeService(service: WeaklyTypedServiceDefinition) { @@ -145,7 +189,7 @@ export class ServiceContainer implements IDisposable { } const serviceDependents = this._serviceDependents.get(service); - if (serviceDependents) { + if (serviceDependents && serviceDependents.size > 0) { throw new Error( `Service '${service.friendlyName}' has dependents: ${Array.from(serviceDependents) .map((dependent) => dependent.friendlyName) @@ -162,28 +206,26 @@ export class ServiceContainer implements IDisposable { this._serviceDefinitions.delete(contract); }); - service.consumes?.forEach((dependency) => { - const dependencyDefinition = this._serviceDefinitions.get(dependency); - if (dependencyDefinition) { - const dependentDefinitions = this._serviceDependents.get(dependencyDefinition); - if (dependentDefinitions) { - dependentDefinitions.delete(service); - if (dependentDefinitions.size === 0) { - this._serviceDependents.delete(dependencyDefinition); - } - } - } + // Remove this service as a dependent from each of its consumed dependencies (local or in parent chain). + service.consumes?.forEach((contract) => { + this._removeDependentFromChain(contract, service); }); } /** * Disposes the service container and all contained services. + * Throws if this container is still a parent of any live child containers. */ public dispose() { + if (this._children.size > 0) { + throw new Error(`'${this._friendlyName}' container cannot be disposed because it has ${this._children.size} active child container(s).`); + } + Array.from(this._serviceInstances.keys()).reverse().forEach(this._removeService.bind(this)); this._serviceInstances.clear(); this._serviceDependents.clear(); this._serviceDefinitions.clear(); + this._parent?._children.delete(this); this._isDisposed = true; } } From c78292676e06dd4d20579f68763f6bcb11a97563 Mon Sep 17 00:00:00 2001 From: Ryan Tremblay Date: Tue, 31 Mar 2026 13:48:11 -0700 Subject: [PATCH 19/42] Add toolbar icon for cli connection status --- packages/dev/inspector-v2/src/inspector.tsx | 6 +++- .../src/services/cli/cliConnectionStatus.ts | 22 ++++++++++++ .../services/cli/inspectableBridgeService.ts | 25 +++++++++++-- .../services/cliConnectionStatusService.tsx | 35 +++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 packages/dev/inspector-v2/src/services/cli/cliConnectionStatus.ts create mode 100644 packages/dev/inspector-v2/src/services/cliConnectionStatusService.tsx diff --git a/packages/dev/inspector-v2/src/inspector.tsx b/packages/dev/inspector-v2/src/inspector.tsx index e59f03c01563..d418784492bb 100644 --- a/packages/dev/inspector-v2/src/inspector.tsx +++ b/packages/dev/inspector-v2/src/inspector.tsx @@ -4,13 +4,14 @@ import { type ServiceDefinition } from "./modularity/serviceDefinition"; import { type ModularToolOptions, MakeModularTool } from "./modularTool"; import { type IShellService, ShellServiceIdentity } from "./services/shellService"; -import { _StartInspectable } from "./inspectable"; import { AsyncLock } from "core/Misc/asyncLock"; import { Logger } from "core/Misc/logger"; import { Observable } from "core/Misc/observable"; import { useEffect, useRef } from "react"; import { DefaultInspectorExtensionFeed } from "./extensibility/defaultInspectorExtensionFeed"; +import { _StartInspectable } from "./inspectable"; import { LegacyInspectableObjectPropertiesServiceDefinition } from "./legacy/inspectableCustomPropertiesService"; +import { CliConnectionStatusServiceDefinition } from "./services/cliConnectionStatusService"; import { GizmoServiceDefinition } from "./services/gizmoService"; import { GizmoToolbarServiceDefinition } from "./services/gizmoToolbarService"; import { HighlightServiceDefinition } from "./services/highlightService"; @@ -382,6 +383,9 @@ export function ShowInspector(scene: Scene, options: Partial = // Adds entry points for user feedback on Inspector v2 (probably eventually will be removed). UserFeedbackServiceDefinition, + // Shows CLI bridge connection status in the toolbar. + CliConnectionStatusServiceDefinition, + // Adds always present "mini stats" (like fps) to the toolbar, etc. MiniStatsServiceDefinition, diff --git a/packages/dev/inspector-v2/src/services/cli/cliConnectionStatus.ts b/packages/dev/inspector-v2/src/services/cli/cliConnectionStatus.ts new file mode 100644 index 000000000000..49693ccadf56 --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cli/cliConnectionStatus.ts @@ -0,0 +1,22 @@ +import { type IReadonlyObservable } from "core/index"; +import { type IService } from "../../modularity/serviceDefinition"; + +/** + * The service identity for the CLI connection status. + */ +export const CliConnectionStatusIdentity = Symbol("CliConnectionStatus"); + +/** + * Provides the connection status of the Inspector CLI bridge. + */ +export interface ICliConnectionStatus extends IService { + /** + * Whether the bridge WebSocket is currently connected. + */ + readonly isConnected: boolean; + + /** + * Observable that fires when the connection status changes. + */ + readonly onConnectionStatusChanged: IReadonlyObservable; +} diff --git a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts index c5aa56da989c..7a6d4df3c080 100644 --- a/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts +++ b/packages/dev/inspector-v2/src/services/cli/inspectableBridgeService.ts @@ -1,6 +1,8 @@ import { type IDisposable } from "core/index"; +import { Observable } from "core/Misc/observable"; import { type BrowserRequest, type BrowserResponse, type CommandInfo } from "../../cli/protocol"; import { type ServiceDefinition } from "../../modularity/serviceDefinition"; +import { type ICliConnectionStatus, CliConnectionStatusIdentity } from "./cliConnectionStatus"; import { type IInspectableCommandRegistry, type InspectableCommandDescriptor, InspectableCommandRegistryIdentity } from "./inspectableCommandRegistry"; import { Logger } from "core/Misc/logger"; @@ -25,15 +27,24 @@ export interface IInspectableBridgeServiceOptions { * @param options The options for connecting to the bridge. * @returns A service definition that produces an IInspectableCommandRegistry. */ -export function MakeInspectableBridgeServiceDefinition(options: IInspectableBridgeServiceOptions): ServiceDefinition<[IInspectableCommandRegistry], []> { +export function MakeInspectableBridgeServiceDefinition(options: IInspectableBridgeServiceOptions): ServiceDefinition<[IInspectableCommandRegistry, ICliConnectionStatus], []> { return { friendlyName: "Inspectable Bridge Service", - produces: [InspectableCommandRegistryIdentity], + produces: [InspectableCommandRegistryIdentity, CliConnectionStatusIdentity], factory: () => { const commands = new Map(); let ws: WebSocket | null = null; let reconnectTimer: ReturnType | null = null; let disposed = false; + let connected = false; + const onConnectionStatusChanged = new Observable(); + + function setConnected(value: boolean) { + if (connected !== value) { + connected = value; + onConnectionStatusChanged.notifyObservers(value); + } + } function sendToBridge(message: BrowserRequest) { ws?.send(JSON.stringify(message)); @@ -52,6 +63,7 @@ export function MakeInspectableBridgeServiceDefinition(options: IInspectableBrid } ws.onopen = () => { + setConnected(true); sendToBridge({ type: "register", name: options.name }); }; @@ -66,6 +78,7 @@ export function MakeInspectableBridgeServiceDefinition(options: IInspectableBrid ws.onclose = () => { ws = null; + setConnected(false); scheduleReconnect(); }; @@ -131,7 +144,7 @@ export function MakeInspectableBridgeServiceDefinition(options: IInspectableBrid // Initiate connection. connect(); - const registry: IInspectableCommandRegistry & IDisposable = { + const registry: IInspectableCommandRegistry & ICliConnectionStatus & IDisposable = { addCommand(descriptor: InspectableCommandDescriptor): IDisposable { if (commands.has(descriptor.id)) { throw new Error(`Command '${descriptor.id}' is already registered.`); @@ -143,6 +156,10 @@ export function MakeInspectableBridgeServiceDefinition(options: IInspectableBrid }, }; }, + get isConnected() { + return connected; + }, + onConnectionStatusChanged, dispose: () => { disposed = true; if (reconnectTimer !== null) { @@ -150,6 +167,8 @@ export function MakeInspectableBridgeServiceDefinition(options: IInspectableBrid reconnectTimer = null; } commands.clear(); + setConnected(false); + onConnectionStatusChanged.clear(); if (ws) { ws.onclose = null; ws.close(); diff --git a/packages/dev/inspector-v2/src/services/cliConnectionStatusService.tsx b/packages/dev/inspector-v2/src/services/cliConnectionStatusService.tsx new file mode 100644 index 000000000000..a59c02a79a3e --- /dev/null +++ b/packages/dev/inspector-v2/src/services/cliConnectionStatusService.tsx @@ -0,0 +1,35 @@ +import { PlugConnectedRegular, PlugDisconnectedRegular } from "@fluentui/react-icons"; + +import { Button } from "shared-ui-components/fluent/primitives/button"; +import { Tooltip } from "shared-ui-components/fluent/primitives/tooltip"; +import { useObservableState } from "../hooks/observableHooks"; +import { type ServiceDefinition } from "../modularity/serviceDefinition"; +import { type ICliConnectionStatus, CliConnectionStatusIdentity } from "./cli/cliConnectionStatus"; +import { DefaultToolbarItemOrder } from "./defaultToolbarMetadata"; +import { type IShellService, ShellServiceIdentity } from "./shellService"; + +export const CliConnectionStatusServiceDefinition: ServiceDefinition<[], [IShellService, ICliConnectionStatus]> = { + friendlyName: "CLI Connection Status", + consumes: [ShellServiceIdentity, CliConnectionStatusIdentity], + factory: (shellService, cliConnectionStatus) => { + shellService.addToolbarItem({ + key: "CLI Connection Status", + verticalLocation: "bottom", + horizontalLocation: "right", + order: DefaultToolbarItemOrder.Feedback - 10, + component: () => { + const isConnected = useObservableState(() => cliConnectionStatus.isConnected, cliConnectionStatus.onConnectionStatusChanged); + + return ( + +