-
Notifications
You must be signed in to change notification settings - Fork 3.6k
Inspector CLI #18229
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Inspector CLI #18229
Changes from all commits
Commits
Show all changes
47 commits
Select commit
Hold shift + click to select a range
78c309f
Initial pass
ryantrem 64c3756
Move files into cli directory
ryantrem b6abefe
Protocol
ryantrem f0a490d
Fix unit tests
ryantrem 87f6f68
Add query mesh command
ryantrem 838d936
SessionId is optional when there is only one session
ryantrem 7f54d5e
Execute with --command and include per command help
ryantrem c3f7395
Add more entity commands
ryantrem 9ef8a1e
Fix lint
ryantrem 159df9d
Add support for listing entities with basic info
ryantrem 7c86fb1
Add screenshot command
ryantrem 6e6c4c4
Aligned descriptions
ryantrem 3d4bbab
Add missing entity types
ryantrem 0052116
Merge master
ryantrem dcc21b4
Revert inadvertent committed files
ryantrem 48b237c
Add stats
ryantrem eddfee6
Add perf trace commands
ryantrem 97f1ee1
Make StartInspectable ref counted
ryantrem 70903b5
Add ServiceContainer parent support, and use it to have a longer live…
ryantrem c782926
Add toolbar icon for cli connection status
ryantrem d394f25
Colored buttons
ryantrem db2374d
Wait for connection if needed
ryantrem c396381
Add get-shader-code
ryantrem df0816f
Update readme files
ryantrem a9af16a
Update package-lock.json
ryantrem dcd4983
Simplify CLI top level params
ryantrem 682511f
Cleanup
ryantrem 8dc50ac
Merge master and fix issues from TypeScript 6 compiler
ryantrem d28d073
Fix bridge ws imports after ts 6 fixes
ryantrem b28a9da
Cleanup WebSocket imports more
ryantrem 77933e8
Move ugly WebSocket imports into webSocket.ts
ryantrem 3006b0b
Merge remote-tracking branch 'origin/master' into inspector-v2/cli
ryantrem 74b259a
Remove plan
ryantrem 0055803
Add cli unit tests
ryantrem bf8f653
ServiceContainer unit tests
ryantrem 4912ec8
Revert inadvertent files
ryantrem 58e6661
Inspectable extensibility
ryantrem c4d95c3
Better errors for invalid sessions
ryantrem d0e3d0c
Add @experimental doc flags
ryantrem 071dfc1
Merge master
ryantrem 2d4c953
Update packages/dev/inspector-v2/src/services/cli/screenshotCommandSe…
ryantrem 46aa208
PR feedback
ryantrem 528fedc
PR feedback
ryantrem b006123
PR feedback
ryantrem b3405cb
PR feedback
ryantrem 2a653e0
Merge remote-tracking branch 'ryantrem/inspector-v2/cli' into inspect…
ryantrem c401d19
Allow multiple calls to StartInspectable to add transient services
ryantrem File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,258 @@ | ||
| /* eslint-disable no-console */ | ||
| import { fileURLToPath } from "url"; | ||
| import { type WebSocket, WebSocketServer } from "./webSocket.js"; | ||
| import { LoadConfig } from "./config.js"; | ||
| import { type BrowserRequest, type BrowserResponse, type CliRequest, type CliResponse, type SessionInfo } from "./protocol.js"; | ||
|
|
||
| interface ISession extends SessionInfo { | ||
| /** The WebSocket connection for this session. */ | ||
| ws: WebSocket; | ||
| } | ||
|
|
||
| /** | ||
| * Configuration for starting the bridge. | ||
| */ | ||
| export interface IBridgeConfig { | ||
| /** WebSocket port for browser sessions. Use 0 for OS-assigned port. */ | ||
| browserPort: number; | ||
| /** WebSocket port for CLI connections. Use 0 for OS-assigned port. */ | ||
| cliPort: number; | ||
| /** Timeout in ms for waiting for an initial session on a sessions request. Defaults to 5000. */ | ||
| sessionWaitTimeoutMs?: number; | ||
| } | ||
|
|
||
| /** | ||
| * Handle returned by {@link startBridge} to control and inspect the running bridge. | ||
| */ | ||
| export interface IBridgeHandle { | ||
| /** The actual port the browser WebSocket server is listening on. */ | ||
| browserPort: number; | ||
| /** The actual port the CLI WebSocket server is listening on. */ | ||
| cliPort: number; | ||
| /** Shuts down the bridge, closing all connections and servers. */ | ||
| shutdown: () => void; | ||
| } | ||
|
|
||
| /** | ||
| * Starts the Inspector bridge with the given configuration. | ||
| * @param config The ports to listen on. Use port 0 for OS-assigned ports. | ||
| * @returns A promise that resolves with a handle to control the running bridge. | ||
| */ | ||
| export async function StartBridge(config: IBridgeConfig): Promise<IBridgeHandle> { | ||
| let nextSessionId = 1; | ||
| const sessions = new Map<number, ISession>(); | ||
| const pendingBrowserRequests = new Map<string, (response: string) => void>(); | ||
| const sessionAddedListeners: (() => void)[] = []; | ||
| let requestCounter = 0; | ||
|
|
||
| function generateRequestId(): string { | ||
| return `bridge-req-${++requestCounter}`; | ||
| } | ||
|
|
||
| const sessionWaitTimeout = config.sessionWaitTimeoutMs ?? 5000; | ||
|
|
||
| async function waitForSession(): Promise<void> { | ||
| if (sessions.size > 0) { | ||
| return; | ||
| } | ||
| return await new Promise<void>((resolve) => { | ||
| const timer = setTimeout(() => { | ||
| const index = sessionAddedListeners.indexOf(listener); | ||
| if (index !== -1) { | ||
| sessionAddedListeners.splice(index, 1); | ||
| } | ||
| resolve(); | ||
| }, sessionWaitTimeout); | ||
|
|
||
| const listener = () => { | ||
| clearTimeout(timer); | ||
| const index = sessionAddedListeners.indexOf(listener); | ||
| if (index !== -1) { | ||
| sessionAddedListeners.splice(index, 1); | ||
| } | ||
| resolve(); | ||
| }; | ||
| sessionAddedListeners.push(listener); | ||
| }); | ||
| } | ||
|
|
||
| async function waitForBrowserResponse(requestId: string, timeoutMs = 30000): Promise<string> { | ||
| return await new Promise((resolve, reject) => { | ||
| const timer = setTimeout(() => { | ||
| pendingBrowserRequests.delete(requestId); | ||
| reject(new Error("Timeout")); | ||
| }, timeoutMs); | ||
|
|
||
| pendingBrowserRequests.set(requestId, (response) => { | ||
| clearTimeout(timer); | ||
| resolve(response); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // Browser-facing WebSocket server. | ||
| const browserWss = new WebSocketServer({ host: "127.0.0.1", port: config.browserPort }); | ||
|
|
||
| // CLI-facing WebSocket server. | ||
| const cliWss = new WebSocketServer({ host: "127.0.0.1", port: config.cliPort }); | ||
|
|
||
| browserWss.on("connection", (socket) => { | ||
| let session: ISession | null = null; | ||
|
|
||
| socket.on("message", (data) => { | ||
| let message: BrowserRequest; | ||
| try { | ||
| message = JSON.parse(data.toString()); | ||
| } catch { | ||
| return; | ||
| } | ||
|
|
||
| switch (message.type) { | ||
| case "register": { | ||
| const id = nextSessionId++; | ||
| session = { | ||
| id, | ||
| name: message.name, | ||
| connectedAt: new Date().toISOString(), | ||
| ws: socket, | ||
| }; | ||
| sessions.set(id, session); | ||
| console.log(`Session ${id} registered: "${session.name}"`); | ||
| for (const listener of sessionAddedListeners.splice(0)) { | ||
| listener(); | ||
| } | ||
| break; | ||
| } | ||
| case "commandListResponse": | ||
| case "commandResponse": { | ||
| // Forward response back to the CLI that requested it. | ||
| const resolve = pendingBrowserRequests.get(message.requestId); | ||
| if (resolve) { | ||
| pendingBrowserRequests.delete(message.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", async (data) => { | ||
| let message: CliRequest; | ||
| try { | ||
| message = JSON.parse(data.toString()); | ||
| } catch { | ||
| return; | ||
| } | ||
|
|
||
| function sendCliResponse(response: CliResponse) { | ||
| socket.send(JSON.stringify(response)); | ||
| } | ||
|
|
||
| function sendBrowserRequest(target: ISession, request: BrowserResponse) { | ||
| target.ws.send(JSON.stringify(request)); | ||
| } | ||
|
|
||
| switch (message.type) { | ||
| case "sessions": { | ||
| // Wait for at least one session to connect before responding. | ||
| await waitForSession(); | ||
| const sessionList: SessionInfo[] = Array.from(sessions.values()).map((s) => ({ | ||
| id: s.id, | ||
| name: s.name, | ||
| connectedAt: s.connectedAt, | ||
| })); | ||
| sendCliResponse({ type: "sessionsResponse", sessions: sessionList }); | ||
| break; | ||
| } | ||
| case "commands": { | ||
| const session = sessions.get(message.sessionId); | ||
| if (!session) { | ||
| sendCliResponse({ type: "commandsResponse", error: `No session with id ${message.sessionId}` }); | ||
| break; | ||
| } | ||
| const requestId = generateRequestId(); | ||
| sendBrowserRequest(session, { type: "listCommands", requestId }); | ||
| 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); | ||
| if (!session) { | ||
| sendCliResponse({ type: "execResponse", error: `No session with id ${message.sessionId}` }); | ||
| break; | ||
| } | ||
| const requestId = generateRequestId(); | ||
| sendBrowserRequest(session, { | ||
| type: "execCommand", | ||
| requestId, | ||
| commandId: message.commandId, | ||
| args: message.args, | ||
| }); | ||
| 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(); | ||
| break; | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| function shutdown(): void { | ||
| console.log("Inspector bridge shutting down."); | ||
|
|
||
| for (const session of sessions.values()) { | ||
| session.ws.close(); | ||
| } | ||
| sessions.clear(); | ||
|
|
||
| browserWss.close(); | ||
| cliWss.close(); | ||
| } | ||
|
|
||
| // Wait for both servers to be listening before returning. | ||
| await Promise.all([new Promise<void>((resolve) => browserWss.on("listening", resolve)), new Promise<void>((resolve) => cliWss.on("listening", resolve))]); | ||
|
|
||
| const actualBrowserPort = (browserWss.address() as import("net").AddressInfo).port; | ||
| const actualCliPort = (cliWss.address() as import("net").AddressInfo).port; | ||
|
|
||
| console.log(`Inspector bridge started.`); | ||
| console.log(` Browser port: ${actualBrowserPort}`); | ||
| console.log(` CLI port: ${actualCliPort}`); | ||
|
|
||
| return { | ||
| browserPort: actualBrowserPort, | ||
| cliPort: actualCliPort, | ||
| shutdown, | ||
| }; | ||
| } | ||
|
|
||
| // Auto-start when run directly (not when imported for testing). | ||
| if (process.argv[1] === fileURLToPath(import.meta.url)) { | ||
| void (async () => { | ||
| const handle = await StartBridge(LoadConfig()); | ||
| process.on("SIGTERM", () => handle.shutdown()); | ||
| process.on("SIGINT", () => handle.shutdown()); | ||
| })(); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.