Skip to content
23 changes: 21 additions & 2 deletions packages/dev/inspector-v2/src/cli/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,8 @@ export async function StartBridge(config: IBridgeConfig): Promise<IBridgeHandle>
break;
}
case "commandListResponse":
case "commandResponse": {
case "commandResponse":
case "infoResponse": {
// Forward response back to the CLI that requested it.
const resolve = pendingBrowserRequests.get(message.requestId);
if (resolve) {
Expand Down Expand Up @@ -165,9 +166,27 @@ export async function StartBridge(config: IBridgeConfig): Promise<IBridgeHandle>
case "sessions": {
// Wait for at least one session to connect before responding.
await waitForSession();

// Query each session for its current name.
const updatedNames = new Map<number, string>();
const infoPromises = Array.from(sessions.values()).map(async (s) => {
const requestId = generateRequestId();
sendBrowserRequest(s, { type: "getInfo", requestId });
try {
const raw = await waitForBrowserResponse(requestId, 5000);
const info = JSON.parse(raw);
if (info.type === "infoResponse" && typeof info.name === "string") {
updatedNames.set(s.id, info.name);
}
} catch {
// Keep the existing name if the session doesn't respond.
}
});
await Promise.all(infoPromises);

const sessionList: SessionInfo[] = Array.from(sessions.values()).map((s) => ({
id: s.id,
name: s.name,
name: updatedNames.get(s.id) ?? s.name,
connectedAt: s.connectedAt,
}));
sendCliResponse({ type: "sessionsResponse", sessions: sessionList });
Expand Down
18 changes: 17 additions & 1 deletion packages/dev/inspector-v2/src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable no-console */
import { spawn } from "child_process";
import { dirname, join, resolve } from "path";
import { readFileSync } from "fs";
import { fileURLToPath } from "url";
import { parseArgs } from "util";
import { WebSocket } from "./webSocket.js";
Expand Down Expand Up @@ -320,7 +321,8 @@ export function PrintCommandHelp(commandId: string, descriptor: CommandInfo, mis
const maxLen = Math.max(...descriptor.args.map((a) => `--${a.name}${a.required ? " (required)" : ""}`.length));
for (const arg of descriptor.args) {
const label = `--${arg.name}${arg.required ? " (required)" : ""}`;
console.log(` ${label.padEnd(maxLen)} ${arg.description}`);
const typeHint = arg.type === "file" ? " [reads file content from path]" : "";
console.log(` ${label.padEnd(maxLen)} ${arg.description}${typeHint}`);
}
}
if (missingRequired.length > 0 && !wantsHelp) {
Expand Down Expand Up @@ -404,6 +406,20 @@ async function HandleCommand(socket: WebSocket, args: IParsedArgs): Promise<void
return;
}

// Resolve "file" type arguments: read the file and replace the value with its contents.
for (const argDef of descriptor.args ?? []) {
if (argDef.type === "file" && argDef.name in commandArgs) {
const filePath = resolve(commandArgs[argDef.name]);
try {
commandArgs[argDef.name] = readFileSync(filePath, "utf-8");
} catch (err: unknown) {
console.error(`Error reading file for --${argDef.name}: ${err}`);
process.exitCode = 1;
return;
}
}
}

const response = await SendAndReceive<ExecResponse>(socket, {
type: "exec",
sessionId,
Expand Down
28 changes: 26 additions & 2 deletions packages/dev/inspector-v2/src/cli/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type CommandArgInfo = {
description: string;
/** Whether this argument is required. */
required?: boolean;
/** The type of the argument. Defaults to "string". When "file", the CLI reads the file and sends its contents. */
type?: "string" | "file";
};

/**
Expand Down Expand Up @@ -170,10 +172,22 @@ export type CommandResponse = {
error?: string;
};

/**
* Browser → Bridge: Response to a getInfo request from the bridge.
*/
export type InfoResponse = {
/** The message type discriminator. */
type: "infoResponse";
/** The identifier of the original request. */
requestId: string;
/** The current display name of the session. */
name: string;
};

/**
* All messages that the browser sends to the bridge.
*/
export type BrowserRequest = RegisterRequest | CommandListResponse | CommandResponse;
export type BrowserRequest = RegisterRequest | CommandListResponse | CommandResponse | InfoResponse;

/**
* Bridge → Browser: Request the list of registered commands.
Expand All @@ -199,7 +213,17 @@ export type ExecCommandRequest = {
args: Record<string, string>;
};

/**
* Bridge → Browser: Request current session information.
*/
export type GetInfoRequest = {
/** The message type discriminator. */
type: "getInfo";
/** A unique identifier for this request. */
requestId: string;
};

/**
* All messages that the bridge sends to the browser.
*/
export type BrowserResponse = ListCommandsRequest | ExecCommandRequest;
export type BrowserResponse = ListCommandsRequest | ExecCommandRequest | GetInfoRequest;
17 changes: 11 additions & 6 deletions packages/dev/inspector-v2/src/inspectable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export type InternalInspectableToken = InspectableToken & {
// Track shared state per scene: the service container, ref count, and teardown logic.
type InspectableState = {
refCount: number;
readonly fullyDisposed: boolean;
serviceContainer: ServiceContainer;
sceneDisposeObserver: { remove: () => void };
fullyDispose: () => void;
Expand All @@ -80,13 +81,12 @@ export function _StartInspectable(scene: Scene, options?: Partial<InspectableOpt
let state = InspectableStates.get(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");

let fullyDisposed = false;
const fullyDispose = () => {
InspectableStates.delete(scene);
fullyDisposed = true;
serviceContainer.dispose();
sceneDisposeObserver.remove();
};
Expand All @@ -105,8 +105,10 @@ export function _StartInspectable(scene: Scene, options?: Partial<InspectableOpt
await serviceContainer.addServicesAsync(
sceneContextServiceDefinition,
MakeInspectableBridgeServiceDefinition({
port,
name,
port: options?.port ?? DefaultPort,
get name() {
return options?.name ?? (typeof document !== "undefined" ? document.title : "Babylon.js Scene");
},
}),
EntityQueryServiceDefinition,
ScreenshotCommandServiceDefinition,
Expand All @@ -118,6 +120,9 @@ export function _StartInspectable(scene: Scene, options?: Partial<InspectableOpt

state = {
refCount: 0,
get fullyDisposed() {
return fullyDisposed;
},
serviceContainer,
sceneDisposeObserver: { remove: () => {} },
fullyDispose,
Expand Down Expand Up @@ -175,7 +180,7 @@ export function _StartInspectable(scene: Scene, options?: Partial<InspectableOpt
let disposed = false;
const token: InternalInspectableToken = {
get isDisposed() {
return disposed;
return disposed || owningState.fullyDisposed;
},
get serviceContainer() {
return serviceContainer;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface IInspectableBridgeServiceOptions {

/**
* The session display name sent to the bridge.
* Can be a getter to provide a dynamic value that is re-read
* each time the bridge queries session information.
*/
name: string;
}
Expand Down Expand Up @@ -112,6 +114,14 @@ export function MakeInspectableBridgeServiceDefinition(options: IInspectableBrid
});
break;
}
case "getInfo": {
sendToBridge({
type: "infoResponse",
requestId: message.requestId,
name: options.name,
});
break;
}
case "execCommand": {
const command = commands.get(message.commandId);
if (!command) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { type IDisposable } from "core/index";
import { type IService } from "../../modularity/serviceDefinition";

/**
* The type of an inspectable command argument, which determines how
* the CLI processes the value before sending it to the browser.
*/
export type InspectableCommandArgType = "string" | "file";

/**
* Describes an argument for an inspectable command.
*/
Expand All @@ -19,6 +25,13 @@ export type InspectableCommandArg = {
* Whether the argument is required.
*/
required?: boolean;

/**
* The type of the argument. Defaults to "string".
* When set to "file", the CLI reads the file at the given path
* and passes its contents as the argument value.
*/
type?: InspectableCommandArgType;
};

/**
Expand Down
53 changes: 53 additions & 0 deletions packages/dev/inspector-v2/test/unit/cli/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type CommandResponse,
type CommandsResponse,
type ExecResponse,
type InfoResponse,
type SessionsResponse,
type StopResponse,
} from "../../../src/cli/protocol";
Expand Down Expand Up @@ -55,6 +56,24 @@ function tick(ms = 50): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

function autoRespondGetInfo(ws: WebSocket, name: string | (() => string)): void {
ws.on("message", (data) => {
try {
const msg = JSON.parse(data.toString());
if (msg.type === "getInfo") {
const response: InfoResponse = {
type: "infoResponse",
requestId: msg.requestId,
name: typeof name === "function" ? name() : name,
};
ws.send(JSON.stringify(response));
}
} catch {
// ignore
}
});
}

describe("Inspector Bridge", () => {
let bridge: IBridgeHandle;

Expand All @@ -76,6 +95,7 @@ describe("Inspector Bridge", () => {

it("registers a browser session and lists it via CLI", async () => {
const browser = await connect(bridge.browserPort);
autoRespondGetInfo(browser, "Test Scene");
browser.send(JSON.stringify({ type: "register", name: "Test Scene" }));
await tick();

Expand Down Expand Up @@ -110,6 +130,7 @@ describe("Inspector Bridge", () => {

it("removes session when browser disconnects", async () => {
const browser = await connect(bridge.browserPort);
autoRespondGetInfo(browser, "Temporary Scene");
browser.send(JSON.stringify({ type: "register", name: "Temporary Scene" }));
await tick();

Expand All @@ -132,6 +153,7 @@ describe("Inspector Bridge", () => {

it("forwards command listing from browser to CLI", async () => {
const browser = await connect(bridge.browserPort);
autoRespondGetInfo(browser, "Scene");
browser.send(JSON.stringify({ type: "register", name: "Scene" }));
await tick();

Expand Down Expand Up @@ -166,6 +188,7 @@ describe("Inspector Bridge", () => {

it("forwards command execution from CLI to browser", async () => {
const browser = await connect(bridge.browserPort);
autoRespondGetInfo(browser, "Scene");
browser.send(JSON.stringify({ type: "register", name: "Scene" }));
await tick();

Expand Down Expand Up @@ -237,10 +260,12 @@ describe("Inspector Bridge", () => {

it("supports multiple browser sessions", async () => {
const browser1 = await connect(bridge.browserPort);
autoRespondGetInfo(browser1, "Scene A");
browser1.send(JSON.stringify({ type: "register", name: "Scene A" }));
await tick();

const browser2 = await connect(bridge.browserPort);
autoRespondGetInfo(browser2, "Scene B");
browser2.send(JSON.stringify({ type: "register", name: "Scene B" }));
await tick();

Expand All @@ -258,6 +283,7 @@ describe("Inspector Bridge", () => {

it("ignores malformed JSON on browser port", async () => {
const browser = await connect(bridge.browserPort);
autoRespondGetInfo(browser, "After Bad JSON");
browser.send("not valid json{{{");
await tick();

Expand All @@ -281,6 +307,7 @@ describe("Inspector Bridge", () => {

// Bridge should still be functional — send a valid request.
const browser = await connect(bridge.browserPort);
autoRespondGetInfo(browser, "Scene");
browser.send(JSON.stringify({ type: "register", name: "Scene" }));
await tick();

Expand All @@ -290,4 +317,30 @@ describe("Inspector Bridge", () => {
await close(browser);
await close(cli);
});

it("returns updated session name via getInfo on each listing", async () => {
let currentName = "Initial Name";
const browser = await connect(bridge.browserPort);
autoRespondGetInfo(browser, () => currentName);
browser.send(JSON.stringify({ type: "register", name: "Initial Name" }));
await tick();

const cli = await connect(bridge.cliPort);

// First listing returns the initial name.
const first = await sendAndReceive<SessionsResponse>(cli, { type: "sessions" });
expect(first.sessions).toHaveLength(1);
expect(first.sessions[0].name).toBe("Initial Name");

// Update the name on the browser side.
currentName = "Updated Name";

// Second listing reflects the updated name.
const second = await sendAndReceive<SessionsResponse>(cli, { type: "sessions" });
expect(second.sessions).toHaveLength(1);
expect(second.sessions[0].name).toBe("Updated Name");

await close(browser);
await close(cli);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export class MonacoComponent extends React.Component<IMonacoComponentProps, ICom
if (gs.activeEditorPath !== p) {
gs.activeEditorPath = p;
gs.onActiveEditorChangedObservable?.notifyObservers();
this._monacoManager.switchActiveFile(p);
}
this.setState((s) => ({ ...s }));
})
Expand Down
Loading
Loading