From f5855529cc85d3520918216751d816f67541b805 Mon Sep 17 00:00:00 2001 From: Jeremiah Zucker Date: Wed, 9 Sep 2026 14:41:26 -0700 Subject: [PATCH] Support headless Flipper server config, plugin install, and activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give FlipperServerTransport control over three things that previously required a manual Flipper desktop app: a configurable browser-open URL (instead of always opening http://localhost:52342), installing the devtools plugin via Flipper's documented plugins-install-from-npm RPC (instead of requiring the repo's local justfile/Bazel tooling), and explicit enablePlugin/disablePlugin methods that send the init/deinit handshake Flipper's device SDK requires before it will relay plugin messages — something flipper-server never does on its own for a non-background plugin without a desktop UI attached. --- MODULE.bazel.lock | 2 +- devtools/client/flipper/BUILD | 2 + .../flipper/src/__tests__/transport.test.ts | 142 +++++++++++++++ devtools/client/flipper/src/transport.ts | 162 +++++++++++++++++- devtools/mcp/README.md | 45 +++++ devtools/mcp/bin/run | 40 ++++- devtools/mcp/src/server.ts | 2 +- package.json | 2 + pnpm-lock.yaml | 7 + 9 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 devtools/client/flipper/src/__tests__/transport.test.ts diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 255d441..d4c67d2 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -448,7 +448,7 @@ "bzlTransitiveDigest": "aVqwKoRPrSXO367SJABlye04kmpR/9VM2xiXB3nh3Ls=", "usagesDigest": "qH5h0y49b/BYrI5SRoLkSpQctbXmqG8KSHgoA8eopCE=", "recordedFileInputs": { - "@@//package.json": "b6aad65b889b5a2400249868ce69c780c98f4d2d7ede27b8fefff320a909bc42" + "@@//package.json": "dd20ac9a9ddd7e87592fda0ad350539f14af9f629de460de2c36c8f66d908dd7" }, "recordedDirentsInputs": {}, "envVariables": {}, diff --git a/devtools/client/flipper/BUILD b/devtools/client/flipper/BUILD index c71f7eb..d8da2a2 100644 --- a/devtools/client/flipper/BUILD +++ b/devtools/client/flipper/BUILD @@ -13,8 +13,10 @@ js_pipeline( deps = [ ":node_modules/@player-devtools/types", "//:node_modules/@types/ws", + "//:node_modules/flipper-common", "//:node_modules/flipper-server", "//:node_modules/flipper-server-client", + "//:node_modules/open", "//:node_modules/ws", ], ) diff --git a/devtools/client/flipper/src/__tests__/transport.test.ts b/devtools/client/flipper/src/__tests__/transport.test.ts new file mode 100644 index 0000000..c9ece40 --- /dev/null +++ b/devtools/client/flipper/src/__tests__/transport.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from "vitest"; +import type { FlipperServer } from "flipper-server-client"; +import { FlipperServerTransport } from "../transport"; + +/** A fake `FlipperServer` whose `exec` outcomes the test controls. */ +function fakeFlipperServer( + exec: (method: string, ...args: Array) => Promise, +): FlipperServer { + return { exec } as unknown as FlipperServer; +} + +/** Reaches into the transport's private fields to set up state without going through `connect()`. */ +function attach( + transport: FlipperServerTransport, + server: FlipperServer, + clientIds: Array = [], +): void { + const t = transport as unknown as { + server: FlipperServer; + connectedClientIds: Set; + }; + t.server = server; + t.connectedClientIds = new Set(clientIds); +} + +describe("FlipperServerTransport", () => { + describe("ensurePluginInstalled", () => { + it("returns the existing plugin without installing when already present", async () => { + const existing = { + name: "flipper-plugin-player-ui-devtools", + version: "1.2.3", + }; + const exec = vi.fn(async (method: string) => { + if (method === "plugins-get-installed-plugins") return [existing]; + throw new Error(`unexpected exec: ${method}`); + }); + const transport = new FlipperServerTransport(); + attach(transport, fakeFlipperServer(exec)); + + await expect(transport.ensurePluginInstalled()).resolves.toBe(existing); + expect(exec).not.toHaveBeenCalledWith( + "plugins-install-from-npm", + expect.anything(), + ); + }); + + it("installs from npm when the plugin is missing", async () => { + const installed = { + name: "flipper-plugin-player-ui-devtools", + version: "4.5.6", + }; + const exec = vi.fn(async (method: string) => { + if (method === "plugins-get-installed-plugins") return []; + if (method === "plugins-install-from-npm") return installed; + throw new Error(`unexpected exec: ${method}`); + }); + const transport = new FlipperServerTransport(); + attach(transport, fakeFlipperServer(exec)); + + await expect(transport.ensurePluginInstalled()).resolves.toBe(installed); + expect(exec).toHaveBeenCalledWith( + "plugins-install-from-npm", + "flipper-plugin-player-ui-devtools", + ); + }); + + it("rejects when not connected", async () => { + const transport = new FlipperServerTransport(); + await expect(transport.ensurePluginInstalled()).rejects.toThrow( + "not connected", + ); + }); + }); + + describe("enablePlugin / disablePlugin", () => { + it("sends init to a single client id when one is given", async () => { + const exec = vi.fn(async () => undefined); + const transport = new FlipperServerTransport(); + attach(transport, fakeFlipperServer(exec), ["a", "b"]); + + await transport.enablePlugin("a"); + + expect(exec).toHaveBeenCalledTimes(1); + expect(exec).toHaveBeenCalledWith("client-request-response", "a", { + method: "init", + params: { plugin: "player-ui-devtools" }, + }); + }); + + it("sends init to every connected client when no id is given", async () => { + const exec = vi.fn(async () => undefined); + const transport = new FlipperServerTransport(); + attach(transport, fakeFlipperServer(exec), ["a", "b"]); + + await transport.enablePlugin(); + + expect(exec).toHaveBeenCalledTimes(2); + expect(exec).toHaveBeenCalledWith( + "client-request-response", + "a", + expect.objectContaining({ method: "init" }), + ); + expect(exec).toHaveBeenCalledWith( + "client-request-response", + "b", + expect.objectContaining({ method: "init" }), + ); + }); + + it("sends deinit via disablePlugin", async () => { + const exec = vi.fn(async () => undefined); + const transport = new FlipperServerTransport(); + attach(transport, fakeFlipperServer(exec), ["a"]); + + await transport.disablePlugin("a"); + + expect(exec).toHaveBeenCalledWith( + "client-request-response", + "a", + expect.objectContaining({ method: "deinit" }), + ); + }); + + it("swallows a per-client failure instead of rejecting the whole call", async () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + const exec = vi.fn(async (_method: string, id: string) => { + if (id === "bad") throw new Error("boom"); + return undefined; + }); + const transport = new FlipperServerTransport(); + attach(transport, fakeFlipperServer(exec), ["good", "bad"]); + + await expect(transport.enablePlugin()).resolves.toBeUndefined(); + expect(exec).toHaveBeenCalledTimes(2); + }); + + it("rejects when not connected", async () => { + const transport = new FlipperServerTransport(); + await expect(transport.enablePlugin()).rejects.toThrow("not connected"); + }); + }); +}); diff --git a/devtools/client/flipper/src/transport.ts b/devtools/client/flipper/src/transport.ts index 6afaa37..7f9ef3e 100644 --- a/devtools/client/flipper/src/transport.ts +++ b/devtools/client/flipper/src/transport.ts @@ -3,7 +3,9 @@ import { FlipperServerState, type FlipperServer, } from "flipper-server-client"; +import type { InstalledPluginDetails } from "flipper-common"; import { spawn } from "child_process"; +import open from "open"; import * as net from "net"; import * as fs from "fs"; import * as os from "os"; @@ -28,6 +30,7 @@ type MessageCallback = ( ) => void; const PLUGIN_API = "player-ui-devtools"; +const PLUGIN_NPM_NAME = "flipper-plugin-player-ui-devtools"; /** * All diagnostics go to stderr. @@ -51,6 +54,12 @@ type FlipperExecuteMessage = { }; }; +/** Wire shape of the `init`/`deinit` plugin-activation handshake (`js-flipper`'s `FlipperClient.onMessageReceived`) */ +type FlipperPluginLifecycleMessage = { + method: "init" | "deinit"; + params: { plugin: string }; +}; + /** * Flipper headless transport * @@ -218,12 +227,41 @@ export class FlipperServerTransport implements Transport { */ private activeClientIds = new Set(); + /** + * Client IDs currently connected to flipper-server, regardless of whether + * the devtools plugin has been activated for them. Used by `enablePlugin`'s + * "all connected clients" convenience. + */ + private connectedClientIds = new Set(); + constructor( private options: { /** Flipper server host; defaults to "localhost" */ host?: string; /** Flipper server WebSocket port; defaults to 52342 */ port?: number; + /** + * Whether to open a browser/PWA UI once the server is up. Defaults to + * `false` — an agent driving this transport has no use for a browser + * tab, unlike a human running `flipper-server` directly. + */ + open?: boolean; + /** + * URL to open in the browser when `open` is true. Defaults to + * `http://localhost:{port}`. `flipper-server` itself always binds to + * localhost regardless of this value — this only controls what URL + * gets opened, so a custom host (e.g. a branded domain) must already + * resolve to this machine (e.g. via `/etc/hosts`) for it to work. + */ + url?: string; + /** + * Activate the devtools plugin automatically for every client that + * connects, instead of requiring an explicit `enablePlugin()` call per + * client. Off by default so a caller can choose when/whether to + * activate a given device; turn this on for a fully hands-off session + * where every connecting device should be watched immediately. + */ + autoEnablePlugin?: boolean; } = {}, ) {} @@ -239,6 +277,10 @@ export class FlipperServerTransport implements Transport { if (shouldStart) { log("[FlipperServerTransport] Starting flipper-server..."); const serverScript = require.resolve("flipper-server/server.js"); + // We always pass --open=false and drive any browser-open ourselves + // (below) so we control both whether it happens and what URL is used — + // flipper-server's own --open only ever opens http://localhost:{port}. + // Detached + unref'd: the daemon must survive this process exiting so // other instances keep their connections. We never kill it directly — // shutdown is driven by the refcount in close(). @@ -246,10 +288,14 @@ export class FlipperServerTransport implements Transport { // The daemon must never inherit our fd 1: it outlives this process and // would write into a later session's JSON-RPC stream. Its stderr stays // inherited so daemon diagnostics remain visible. - const child = spawn(process.execPath, [serverScript, "--open=true"], { - stdio: ["ignore", "ignore", "inherit"], - detached: true, - }); + const child = spawn( + process.execPath, + [serverScript, "--open=false", `--port=${port}`], + { + stdio: ["ignore", "ignore", "inherit"], + detached: true, + }, + ); child.on("error", (err: Error) => { console.error( "[FlipperServerTransport] flipper-server process error:", @@ -267,6 +313,16 @@ export class FlipperServerTransport implements Transport { log("[FlipperServerTransport] Attached to flipper-server."); } + if (this.options.open) { + const url = this.options.url ?? `http://localhost:${port}`; + log(`[FlipperServerTransport] Opening ${url}`); + try { + await open(url); + } catch (err) { + console.warn("[FlipperServerTransport] Failed to open UI:", err); + } + } + // Read the auth token the flipper-server wrote during startup const { getAuthToken } = // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -297,10 +353,20 @@ export class FlipperServerTransport implements Transport { // Track client connects/disconnects this.server.on("client-connected", (info) => { log("[FlipperServerTransport] client-connected:", JSON.stringify(info)); + this.connectedClientIds.add(info.id); + if (this.options.autoEnablePlugin) { + this.enablePlugin(info.id).catch((err) => { + console.warn( + `[FlipperServerTransport] Failed to auto-enable plugin for client ${info.id}:`, + err, + ); + }); + } }); this.server.on("client-disconnected", ({ id }) => { log("[FlipperServerTransport] client-disconnected:", id); this.activeClientIds.delete(id); + this.connectedClientIds.delete(id); }); // Route inbound device messages to our Messenger listeners @@ -364,6 +430,93 @@ export class FlipperServerTransport implements Transport { ); }; + /** + * Ensures `flipper-plugin-player-ui-devtools` is installed on the attached + * flipper-server, installing the published npm package if it's missing. + * + * This is Flipper's own documented plugin-install RPC (the same + * `exec(...)` commands the desktop UI's "Install Plugin" button calls) — + * not a filesystem workaround. It's opt-in: callers that manage plugin + * installation themselves (or run against a flipper-server that already + * has it) can skip calling this. + */ + async ensurePluginInstalled(): Promise { + if (!this.server) { + throw new Error("FlipperServerTransport is not connected"); + } + + const installed = await this.server.exec("plugins-get-installed-plugins"); + const existing = installed.find((p) => p.name === PLUGIN_NPM_NAME); + if (existing) { + log( + `[FlipperServerTransport] ${PLUGIN_NPM_NAME}@${existing.version} already installed.`, + ); + return existing; + } + + log(`[FlipperServerTransport] Installing ${PLUGIN_NPM_NAME} from npm...`); + const details = await this.server.exec( + "plugins-install-from-npm", + PLUGIN_NPM_NAME, + ); + log( + `[FlipperServerTransport] Installed ${PLUGIN_NPM_NAME}@${details.version}.`, + ); + return details; + } + + /** + * Activates the devtools plugin for a connected client by sending the + * `init` handshake `js-flipper`'s device SDK requires before it will open + * a live plugin connection (`onConnect`/`FlipperConnection`) and start + * relaying `client-message`s for our api. Nothing in flipper-server itself + * sends this automatically for a non-background plugin like ours unless a + * full Flipper desktop app is attached and its tab is selected — this + * method lets a caller trigger the same handshake directly, without + * needing a desktop UI at all. + * + * Pass a specific `clientId`, or omit it to enable for every client + * currently connected to flipper-server — including ones `autoEnablePlugin` + * already activated, so `init` must tolerate being sent more than once to + * the same client (Flipper's own device SDK treats it as idempotent). + */ + async enablePlugin(clientId?: string): Promise { + await this.sendLifecycleMessage("init", clientId); + } + + /** Symmetric counterpart to `enablePlugin` — releases the plugin connection without disconnecting the client from flipper-server. */ + async disablePlugin(clientId?: string): Promise { + await this.sendLifecycleMessage("deinit", clientId); + } + + private async sendLifecycleMessage( + method: "init" | "deinit", + clientId?: string, + ): Promise { + if (!this.server) { + throw new Error("FlipperServerTransport is not connected"); + } + + const targets = clientId ? [clientId] : [...this.connectedClientIds]; + const payload: FlipperPluginLifecycleMessage = { + method, + params: { plugin: PLUGIN_API }, + }; + + await Promise.all( + targets.map((id) => + this.server!.exec("client-request-response", id, payload).catch( + (err) => { + console.warn( + `[FlipperServerTransport] Failed to ${method} plugin for client ${id}:`, + err, + ); + }, + ), + ), + ); + } + addListener: CommunicationLayerMethods["addListener"] = (callback) => { this.listeners.add(callback); }; @@ -375,6 +528,7 @@ export class FlipperServerTransport implements Transport { async close(): Promise { this.listeners.clear(); this.activeClientIds.clear(); + this.connectedClientIds.clear(); this.server?.close(); this.server = null; diff --git a/devtools/mcp/README.md b/devtools/mcp/README.md index 6710eb3..97a2059 100644 --- a/devtools/mcp/README.md +++ b/devtools/mcp/README.md @@ -99,6 +99,51 @@ await server.start(); | --- | --- | --- | | `host` | `"localhost"` | Flipper server host. | | `port` | `52342` | Flipper server WebSocket port. | +| `open` | `false` | Open a browser/PWA UI once the server is up. Off by default — an agent has no use for a browser tab. | +| `url` | `http://localhost:{port}` | URL to open when `open` is true. `flipper-server` itself always binds to `localhost`, regardless of this value — it only controls what gets opened in the browser, so a custom host must already resolve to this machine (e.g. via `/etc/hosts`). | +| `autoEnablePlugin` | `false` | Automatically call `enablePlugin()` for every client that connects (see [Plugin activation](#plugin-activation) below). | + +The CLI (`player-devtools-mcp` / `bin/run`) reads `open`/`url` from +`PLAYER_DEVTOOLS_FLIPPER_OPEN` / `PLAYER_DEVTOOLS_FLIPPER_URL`, and always +installs the plugin and enables it for connected/connecting clients on +startup (see below) — no manual Flipper UI interaction is required. + +### Plugin installation + +`flipper-server` has no built-in way to auto-fetch a plugin — but it does +expose the same install RPCs its own desktop UI's "Install Plugin" button +uses, over the same `exec(...)` mechanism this transport already relies on. +`FlipperServerTransport.ensurePluginInstalled()` calls +`plugins-get-installed-plugins` to check whether +`flipper-plugin-player-ui-devtools` is already installed, and if not, installs +it from npm via `plugins-install-from-npm` — no filesystem access, no +dependency on this repo's Bazel/justfile tooling, just Flipper's documented +plugin-management API: + +```ts +const transport = new FlipperServerTransport(); +await transport.connect(); +await transport.ensurePluginInstalled(); +``` + +### Plugin activation + +Flipper only opens a live connection for a plugin (and starts relaying its +messages) after sending it an `init` handshake — normally something only the +Flipper *desktop app* does, either automatically for a small class of +"background" plugins, or when a human selects that plugin's tab. Neither +applies to `flipper-plugin-player-ui-devtools` or to a headless MCP session, +so `FlipperServerTransport` exposes the handshake directly: + +```ts +await transport.enablePlugin(); // activate for every connected client +await transport.enablePlugin(id); // or just one +await transport.disablePlugin(id); // release it again, without disconnecting +``` + +Pass `autoEnablePlugin: true` to the constructor to have this happen +automatically for every client as it connects, instead of calling +`enablePlugin()` yourself. ### Shared `flipper-server` daemon diff --git a/devtools/mcp/bin/run b/devtools/mcp/bin/run index c36e51a..7575b1a 100644 --- a/devtools/mcp/bin/run +++ b/devtools/mcp/bin/run @@ -1,17 +1,41 @@ #!/usr/bin/env node const { MCPServer } = require("@player-devtools/mcp"); -const { - FlipperServerTransport, -} = require("@player-devtools/client-flipper"); +const { FlipperServerTransport } = require("@player-devtools/client-flipper"); -const transport = new FlipperServerTransport(); +// PLAYER_DEVTOOLS_FLIPPER_OPEN=true opens a browser/PWA UI once flipper-server +// is up (off by default — an agent has no use for a browser tab). +// PLAYER_DEVTOOLS_FLIPPER_URL overrides the URL that gets opened (defaults to +// http://localhost:{port}); the host still needs to resolve to this machine. +const transport = new FlipperServerTransport({ + open: process.env.PLAYER_DEVTOOLS_FLIPPER_OPEN === "true", + url: process.env.PLAYER_DEVTOOLS_FLIPPER_URL, + // flipper-server never activates a non-background plugin like ours on its + // own unless a full Flipper desktop app is attached with our tab selected — + // we don't want to depend on that, so every connecting device gets the + // plugin activated for it automatically. + autoEnablePlugin: true, +}); const server = new MCPServer(transport); -server.start().catch((err) => { - console.error("Failed to start MCP server:", err); - process.exit(1); -}); +server + .start() + .then(async () => { + try { + // autoEnablePlugin (above) activates every client as it connects; no + // separate enablePlugin() call is needed here. + await transport.ensurePluginInstalled(); + } catch (err) { + console.warn( + "[@player-devtools/mcp] Failed to install the devtools plugin:", + err, + ); + } + }) + .catch((err) => { + console.error("Failed to start MCP server:", err); + process.exit(1); + }); process.on("SIGINT", () => { server.stop().then(() => process.exit(0)); diff --git a/devtools/mcp/src/server.ts b/devtools/mcp/src/server.ts index 18093f3..f2bc270 100644 --- a/devtools/mcp/src/server.ts +++ b/devtools/mcp/src/server.ts @@ -58,7 +58,7 @@ export class MCPServer { this.transportConnected = true; } catch (err) { console.warn( - "[MCPServer] Transport connect failed (will operate in disconnected mode):", + "[@player-devtools/mcp] Transport connect failed (will operate in disconnected mode):", err instanceof Error ? err.message : err, ); } diff --git a/package.json b/package.json index 363fcd0..ccc81f6 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "eslint-plugin-prettier": "^5.2.3", "eslint-plugin-react": "^7.37.4", "figures": "^3.0.0", + "flipper-common": "^0.273.0", "flipper-pkg": "^0.273.0", "flipper-server": "^0.273.0", "flipper-server-client": "^0.273.0", @@ -118,6 +119,7 @@ "log-update": "^4.0.0", "mkdirp": "^1.0.4", "oclif": "^4.4.2", + "open": "^8.4.2", "posthog-node": "^5.0.0", "prettier": "^3.5.3", "react": "^18.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a656ed..88b8692 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -274,6 +274,9 @@ importers: figures: specifier: ^3.0.0 version: 3.2.0 + flipper-common: + specifier: ^0.273.0 + version: 0.273.0 flipper-pkg: specifier: ^0.273.0 version: 0.273.0(@swc/core@1.3.74)(@types/node@24.5.2)(typescript@5.8.3) @@ -322,6 +325,9 @@ importers: oclif: specifier: ^4.4.2 version: 4.22.22(@types/node@24.5.2) + open: + specifier: ^8.4.2 + version: 8.4.2 posthog-node: specifier: ^5.0.0 version: 5.48.1(rxjs@7.8.2) @@ -7369,6 +7375,7 @@ packages: eslint@9.35.0: resolution: {integrity: sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==, tarball: https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*'