@player-devtools/client-flipper 0.13.1--canary.17.955
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/index.cjs +301 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/index.legacy-esm.js +274 -0
- package/dist/index.mjs +274 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +31 -0
- package/src/index.ts +1 -0
- package/src/transport.ts +381 -0
- package/types/index.d.ts +2 -0
- package/types/transport.d.ts +24 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// ../../../../../../../../../../../execroot/_main/bazel-out/k8-fastbuild/bin/devtools/client/flipper/src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
FlipperServerTransport: () => FlipperServerTransport
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(src_exports);
|
|
36
|
+
|
|
37
|
+
// ../../../../../../../../../../../execroot/_main/bazel-out/k8-fastbuild/bin/devtools/client/flipper/src/transport.ts
|
|
38
|
+
var import_flipper_server_client = require("flipper-server-client");
|
|
39
|
+
var import_child_process = require("child_process");
|
|
40
|
+
var net = __toESM(require("net"));
|
|
41
|
+
var fs = __toESM(require("fs"));
|
|
42
|
+
var os = __toESM(require("os"));
|
|
43
|
+
var path = __toESM(require("path"));
|
|
44
|
+
var import_ws = require("ws");
|
|
45
|
+
if (typeof globalThis.WebSocket === "undefined") {
|
|
46
|
+
globalThis.WebSocket = import_ws.WebSocket;
|
|
47
|
+
}
|
|
48
|
+
var PLUGIN_API = "player-ui-devtools";
|
|
49
|
+
function waitForPort(host, port, timeoutMs = 3e4) {
|
|
50
|
+
return new Promise((resolve, reject) => {
|
|
51
|
+
const deadline = Date.now() + timeoutMs;
|
|
52
|
+
const attempt = () => {
|
|
53
|
+
const socket = net.connect(port, host);
|
|
54
|
+
socket.once("connect", () => {
|
|
55
|
+
socket.destroy();
|
|
56
|
+
resolve();
|
|
57
|
+
});
|
|
58
|
+
socket.once("error", () => {
|
|
59
|
+
socket.destroy();
|
|
60
|
+
if (Date.now() >= deadline) {
|
|
61
|
+
reject(new Error(`Timed out waiting for ${host}:${port}`));
|
|
62
|
+
} else {
|
|
63
|
+
setTimeout(attempt, 500);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
attempt();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
var FlipperRefcount = class {
|
|
71
|
+
constructor() {
|
|
72
|
+
this.dir = path.join(os.tmpdir(), "player-devtools-mcp");
|
|
73
|
+
this.file = path.join(this.dir, "flipper-server.refcount");
|
|
74
|
+
this.lock = path.join(this.dir, "flipper-server.lock");
|
|
75
|
+
}
|
|
76
|
+
/** Acquire the cross-process lock, run `fn`, then release — even on throw. */
|
|
77
|
+
withLock(fn) {
|
|
78
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
79
|
+
const deadline = Date.now() + 5e3;
|
|
80
|
+
for (; ; ) {
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(this.lock);
|
|
83
|
+
break;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err.code !== "EEXIST") throw err;
|
|
86
|
+
if (Date.now() >= deadline) {
|
|
87
|
+
try {
|
|
88
|
+
fs.rmdirSync(this.lock);
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return fn();
|
|
96
|
+
} finally {
|
|
97
|
+
try {
|
|
98
|
+
fs.rmdirSync(this.lock);
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
read() {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
write(state) {
|
|
111
|
+
fs.writeFileSync(this.file, JSON.stringify(state));
|
|
112
|
+
}
|
|
113
|
+
clear() {
|
|
114
|
+
try {
|
|
115
|
+
fs.unlinkSync(this.file);
|
|
116
|
+
} catch {
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Is the recorded daemon PID still alive? */
|
|
120
|
+
isAlive(pid) {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Register interest in the daemon. Returns whether this caller is the one
|
|
130
|
+
* responsible for starting it (because no live daemon was recorded).
|
|
131
|
+
* `recordPid` is called with the started PID once the daemon is up.
|
|
132
|
+
*/
|
|
133
|
+
acquire() {
|
|
134
|
+
return this.withLock(() => {
|
|
135
|
+
const state = this.read();
|
|
136
|
+
if (state && this.isAlive(state.pid)) {
|
|
137
|
+
this.write({ pid: state.pid, refs: state.refs + 1 });
|
|
138
|
+
return { shouldStart: false, commit: () => {
|
|
139
|
+
} };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
shouldStart: true,
|
|
143
|
+
commit: (pid) => this.withLock(() => this.write({ pid, refs: 1 }))
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Drop this caller's interest. Returns the PID to kill if this was the last
|
|
149
|
+
* reference, otherwise null.
|
|
150
|
+
*/
|
|
151
|
+
release() {
|
|
152
|
+
return this.withLock(() => {
|
|
153
|
+
const state = this.read();
|
|
154
|
+
if (!state) return null;
|
|
155
|
+
if (state.refs <= 1) {
|
|
156
|
+
this.clear();
|
|
157
|
+
return state.pid;
|
|
158
|
+
}
|
|
159
|
+
this.write({ pid: state.pid, refs: state.refs - 1 });
|
|
160
|
+
return null;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
var FlipperServerTransport = class {
|
|
165
|
+
constructor(options = {}) {
|
|
166
|
+
this.options = options;
|
|
167
|
+
this.server = null;
|
|
168
|
+
this.refcount = new FlipperRefcount();
|
|
169
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
170
|
+
/**
|
|
171
|
+
* Client IDs that have sent at least one message through the devtools
|
|
172
|
+
* plugin — these are the clients we send outbound messages to.
|
|
173
|
+
*/
|
|
174
|
+
this.activeClientIds = /* @__PURE__ */ new Set();
|
|
175
|
+
this.sendMessage = async (message) => {
|
|
176
|
+
if (!this.server) return;
|
|
177
|
+
const payload = {
|
|
178
|
+
method: "execute",
|
|
179
|
+
params: {
|
|
180
|
+
api: PLUGIN_API,
|
|
181
|
+
method: "message::flipper",
|
|
182
|
+
params: message
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
await Promise.all(
|
|
186
|
+
[...this.activeClientIds].map(
|
|
187
|
+
(clientId) => this.server.exec("client-request-response", clientId, payload).catch(
|
|
188
|
+
(err) => {
|
|
189
|
+
console.warn(
|
|
190
|
+
`[FlipperServerTransport] Failed to send to client ${clientId}:`,
|
|
191
|
+
err
|
|
192
|
+
);
|
|
193
|
+
this.activeClientIds.delete(clientId);
|
|
194
|
+
}
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
);
|
|
198
|
+
};
|
|
199
|
+
this.addListener = (callback) => {
|
|
200
|
+
this.listeners.add(callback);
|
|
201
|
+
};
|
|
202
|
+
this.removeListener = (callback) => {
|
|
203
|
+
this.listeners.delete(callback);
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async connect() {
|
|
207
|
+
const host = this.options.host ?? "localhost";
|
|
208
|
+
const port = this.options.port ?? 52342;
|
|
209
|
+
const { shouldStart, commit } = this.refcount.acquire();
|
|
210
|
+
if (shouldStart) {
|
|
211
|
+
console.log("[FlipperServerTransport] Starting flipper-server...");
|
|
212
|
+
const serverScript = require.resolve("flipper-server/server.js");
|
|
213
|
+
const child = (0, import_child_process.spawn)(process.execPath, [serverScript, "--open=true"], {
|
|
214
|
+
stdio: "inherit",
|
|
215
|
+
detached: true
|
|
216
|
+
});
|
|
217
|
+
child.on("error", (err) => {
|
|
218
|
+
console.error(
|
|
219
|
+
"[FlipperServerTransport] flipper-server process error:",
|
|
220
|
+
err
|
|
221
|
+
);
|
|
222
|
+
});
|
|
223
|
+
child.unref();
|
|
224
|
+
await waitForPort(host, port);
|
|
225
|
+
commit(child.pid);
|
|
226
|
+
console.log("[FlipperServerTransport] flipper-server ready.");
|
|
227
|
+
} else {
|
|
228
|
+
await waitForPort(host, port);
|
|
229
|
+
console.log("[FlipperServerTransport] Attached to flipper-server.");
|
|
230
|
+
}
|
|
231
|
+
const { getAuthToken } = (
|
|
232
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
233
|
+
require("flipper-server/lib/app-connectivity/certificate-exchange/certificate-utils")
|
|
234
|
+
);
|
|
235
|
+
let cachedToken = null;
|
|
236
|
+
try {
|
|
237
|
+
cachedToken = await getAuthToken();
|
|
238
|
+
} catch (err) {
|
|
239
|
+
console.warn("[FlipperServerTransport] Could not read auth token:", err);
|
|
240
|
+
}
|
|
241
|
+
this.server = await (0, import_flipper_server_client.createFlipperServer)(
|
|
242
|
+
host,
|
|
243
|
+
port,
|
|
244
|
+
() => cachedToken,
|
|
245
|
+
(state) => {
|
|
246
|
+
if (state === import_flipper_server_client.FlipperServerState.DISCONNECTED) {
|
|
247
|
+
console.warn("[FlipperServerTransport] Disconnected from server");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
await this.server.connect();
|
|
252
|
+
this.server.on("client-connected", (info) => {
|
|
253
|
+
console.log(
|
|
254
|
+
"[FlipperServerTransport] client-connected:",
|
|
255
|
+
JSON.stringify(info)
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
this.server.on("client-disconnected", ({ id }) => {
|
|
259
|
+
console.log("[FlipperServerTransport] client-disconnected:", id);
|
|
260
|
+
this.activeClientIds.delete(id);
|
|
261
|
+
});
|
|
262
|
+
this.server.on("client-message", ({ id, message }) => {
|
|
263
|
+
let parsed;
|
|
264
|
+
try {
|
|
265
|
+
parsed = JSON.parse(message);
|
|
266
|
+
} catch {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
console.debug(
|
|
270
|
+
`[FlipperServerTransport] client-message from ${id}: method=${parsed.method} api=${parsed.params?.api} pluginMethod=${parsed.params?.method}`
|
|
271
|
+
);
|
|
272
|
+
if (parsed.method !== "execute" || parsed.params?.api !== PLUGIN_API || parsed.params?.method !== "message::plugin") {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.activeClientIds.add(id);
|
|
276
|
+
const payload = parsed.params.params;
|
|
277
|
+
for (const listener of this.listeners) {
|
|
278
|
+
listener(payload);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
async close() {
|
|
283
|
+
this.listeners.clear();
|
|
284
|
+
this.activeClientIds.clear();
|
|
285
|
+
this.server?.close();
|
|
286
|
+
this.server = null;
|
|
287
|
+
const pidToKill = this.refcount.release();
|
|
288
|
+
if (pidToKill !== null) {
|
|
289
|
+
try {
|
|
290
|
+
process.kill(pidToKill);
|
|
291
|
+
console.log("[FlipperServerTransport] Shut down flipper-server.");
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
298
|
+
0 && (module.exports = {
|
|
299
|
+
FlipperServerTransport
|
|
300
|
+
});
|
|
301
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../../../../../execroot/_main/bazel-out/k8-fastbuild/bin/devtools/client/flipper/src/index.ts","../../../../../../../../../../../../../execroot/_main/bazel-out/k8-fastbuild/bin/devtools/client/flipper/src/transport.ts"],"sourcesContent":["export { FlipperServerTransport } from \"./transport\";\n","import {\n createFlipperServer,\n FlipperServerState,\n type FlipperServer,\n} from \"flipper-server-client\";\nimport { spawn } from \"child_process\";\nimport * as net from \"net\";\nimport * as fs from \"fs\";\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport { WebSocket as WsWebSocket } from \"ws\";\nimport type {\n CommunicationLayerMethods,\n ExtensionSupportedEvents,\n MessengerEvent,\n TransactionMetadata,\n Transport,\n} from \"@player-devtools/types\";\n\n// polyfill WebSocket for Node < 22\nif (typeof (globalThis as { WebSocket?: unknown }).WebSocket === \"undefined\") {\n (globalThis as { WebSocket?: unknown }).WebSocket =\n WsWebSocket as unknown as typeof WebSocket;\n}\n\ntype MessageCallback = (\n message: TransactionMetadata & MessengerEvent<ExtensionSupportedEvents>,\n) => void;\n\nconst PLUGIN_API = \"player-ui-devtools\";\n\n/** Shape of a Flipper `client-message` payload after JSON.parse */\ntype FlipperExecuteMessage = {\n method: \"execute\";\n params: {\n api: string;\n method: string;\n params?: unknown;\n };\n};\n\n/**\n * Flipper headless transport\n *\n * Connects to a running `flipper-server` process and routes messages for the\n * \"flipper-plugin-player-ui-devtools\" plugin through to the Messenger layer.\n */\n/** Wait until a TCP port is accepting connections, polling every 500ms */\nfunction waitForPort(\n host: string,\n port: number,\n timeoutMs = 30_000,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n const deadline = Date.now() + timeoutMs;\n const attempt = () => {\n const socket = net.connect(port, host);\n socket.once(\"connect\", () => {\n socket.destroy();\n resolve();\n });\n socket.once(\"error\", () => {\n socket.destroy();\n if (Date.now() >= deadline) {\n reject(new Error(`Timed out waiting for ${host}:${port}`));\n } else {\n setTimeout(attempt, 500);\n }\n });\n };\n attempt();\n });\n}\n\n/**\n * Cross-process refcount for the shared `flipper-server` daemon.\n *\n * Many MCP server processes (one per project/user registration) attach to a\n * single `flipper-server` on a fixed port. No in-process variable can\n * coordinate their lifecycles, so we track liveness in a small file guarded by\n * an atomic lock directory:\n *\n * - The first attach starts the daemon and records its PID with `refs: 1`.\n * - Each subsequent attach increments `refs`.\n * - Each detach decrements `refs`; the last one out shuts the daemon down.\n *\n * The lock directory (`mkdir` is atomic across processes) serializes the\n * read-modify-write so concurrently-starting instances don't race.\n */\ntype RefcountFile = { pid: number; refs: number };\n\nclass FlipperRefcount {\n private readonly dir = path.join(os.tmpdir(), \"player-devtools-mcp\");\n private readonly file = path.join(this.dir, \"flipper-server.refcount\");\n private readonly lock = path.join(this.dir, \"flipper-server.lock\");\n\n /** Acquire the cross-process lock, run `fn`, then release — even on throw. */\n private withLock<T>(fn: () => T): T {\n fs.mkdirSync(this.dir, { recursive: true });\n const deadline = Date.now() + 5_000;\n // Spin on an atomic mkdir until we own the lock or time out.\n for (;;) {\n try {\n fs.mkdirSync(this.lock);\n break;\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (Date.now() >= deadline) {\n // Stale lock from a crashed process — reclaim it.\n try {\n fs.rmdirSync(this.lock);\n } catch {\n /* another instance won the reclaim; retry */\n }\n }\n }\n }\n try {\n return fn();\n } finally {\n try {\n fs.rmdirSync(this.lock);\n } catch {\n /* already gone */\n }\n }\n }\n\n private read(): RefcountFile | null {\n try {\n return JSON.parse(fs.readFileSync(this.file, \"utf8\")) as RefcountFile;\n } catch {\n return null;\n }\n }\n\n private write(state: RefcountFile): void {\n fs.writeFileSync(this.file, JSON.stringify(state));\n }\n\n private clear(): void {\n try {\n fs.unlinkSync(this.file);\n } catch {\n /* already gone */\n }\n }\n\n /** Is the recorded daemon PID still alive? */\n private isAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Register interest in the daemon. Returns whether this caller is the one\n * responsible for starting it (because no live daemon was recorded).\n * `recordPid` is called with the started PID once the daemon is up.\n */\n acquire(): { shouldStart: boolean; commit: (pid: number) => void } {\n return this.withLock(() => {\n const state = this.read();\n if (state && this.isAlive(state.pid)) {\n this.write({ pid: state.pid, refs: state.refs + 1 });\n return { shouldStart: false, commit: () => {} };\n }\n // No live daemon — this caller will start one and record its PID.\n return {\n shouldStart: true,\n commit: (pid: number) =>\n this.withLock(() => this.write({ pid, refs: 1 })),\n };\n });\n }\n\n /**\n * Drop this caller's interest. Returns the PID to kill if this was the last\n * reference, otherwise null.\n */\n release(): number | null {\n return this.withLock(() => {\n const state = this.read();\n if (!state) return null;\n if (state.refs <= 1) {\n this.clear();\n return state.pid;\n }\n this.write({ pid: state.pid, refs: state.refs - 1 });\n return null;\n });\n }\n}\n\nexport class FlipperServerTransport implements Transport {\n private server: FlipperServer | null = null;\n private refcount = new FlipperRefcount();\n private listeners = new Set<MessageCallback>();\n\n /**\n * Client IDs that have sent at least one message through the devtools\n * plugin — these are the clients we send outbound messages to.\n */\n private activeClientIds = new Set<string>();\n\n constructor(\n private options: {\n /** Flipper server host; defaults to \"localhost\" */\n host?: string;\n /** Flipper server WebSocket port; defaults to 52342 */\n port?: number;\n } = {},\n ) {}\n\n async connect(): Promise<void> {\n const host = this.options.host ?? \"localhost\";\n const port = this.options.port ?? 52342;\n\n // Register interest in the shared daemon. The first instance to do so is\n // told to start it; the rest just attach. The daemon outlives any single\n // MCP process and is only torn down when the last instance detaches.\n const { shouldStart, commit } = this.refcount.acquire();\n\n if (shouldStart) {\n console.log(\"[FlipperServerTransport] Starting flipper-server...\");\n const serverScript = require.resolve(\"flipper-server/server.js\");\n // Detached + unref'd: the daemon must survive this process exiting so\n // other instances keep their connections. We never kill it directly —\n // shutdown is driven by the refcount in close().\n const child = spawn(process.execPath, [serverScript, \"--open=true\"], {\n stdio: \"inherit\",\n detached: true,\n });\n child.on(\"error\", (err: Error) => {\n console.error(\n \"[FlipperServerTransport] flipper-server process error:\",\n err,\n );\n });\n child.unref();\n await waitForPort(host, port);\n commit(child.pid!);\n console.log(\"[FlipperServerTransport] flipper-server ready.\");\n } else {\n // Daemon already running (started by another instance) — wait for it to\n // accept connections in case it's still coming up, then attach.\n await waitForPort(host, port);\n console.log(\"[FlipperServerTransport] Attached to flipper-server.\");\n }\n\n // Read the auth token the flipper-server wrote during startup\n const { getAuthToken } =\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n require(\"flipper-server/lib/app-connectivity/certificate-exchange/certificate-utils\") as {\n getAuthToken: () => Promise<string>;\n };\n\n let cachedToken: string | null = null;\n try {\n cachedToken = await getAuthToken();\n } catch (err) {\n console.warn(\"[FlipperServerTransport] Could not read auth token:\", err);\n }\n\n this.server = await createFlipperServer(\n host,\n port,\n () => cachedToken,\n (state) => {\n if (state === FlipperServerState.DISCONNECTED) {\n console.warn(\"[FlipperServerTransport] Disconnected from server\");\n }\n },\n );\n\n await this.server.connect();\n\n // Track client connects/disconnects\n this.server.on(\"client-connected\", (info) => {\n console.log(\n \"[FlipperServerTransport] client-connected:\",\n JSON.stringify(info),\n );\n });\n this.server.on(\"client-disconnected\", ({ id }) => {\n console.log(\"[FlipperServerTransport] client-disconnected:\", id);\n this.activeClientIds.delete(id);\n });\n\n // Route inbound device messages to our Messenger listeners\n this.server.on(\"client-message\", ({ id, message }) => {\n let parsed: FlipperExecuteMessage;\n try {\n parsed = JSON.parse(message) as FlipperExecuteMessage;\n } catch {\n return;\n }\n\n console.debug(\n `[FlipperServerTransport] client-message from ${id}: method=${parsed.method} api=${(parsed.params as { api?: string })?.api} pluginMethod=${(parsed.params as { method?: string })?.method}`,\n );\n\n if (\n parsed.method !== \"execute\" ||\n parsed.params?.api !== PLUGIN_API ||\n parsed.params?.method !== \"message::plugin\"\n ) {\n return;\n }\n\n // This client is talking through the devtools plugin — remember it\n this.activeClientIds.add(id);\n\n const payload = parsed.params.params as TransactionMetadata &\n MessengerEvent<ExtensionSupportedEvents>;\n\n for (const listener of this.listeners) {\n listener(payload);\n }\n });\n }\n\n sendMessage: CommunicationLayerMethods[\"sendMessage\"] = async (message) => {\n if (!this.server) return;\n\n const payload: FlipperExecuteMessage = {\n method: \"execute\",\n params: {\n api: PLUGIN_API,\n method: \"message::flipper\",\n params: message,\n },\n };\n\n await Promise.all(\n [...this.activeClientIds].map((clientId) =>\n this.server!.exec(\"client-request-response\", clientId, payload).catch(\n (err) => {\n console.warn(\n `[FlipperServerTransport] Failed to send to client ${clientId}:`,\n err,\n );\n // Remove dead client so we stop trying\n this.activeClientIds.delete(clientId);\n },\n ),\n ),\n );\n };\n\n addListener: CommunicationLayerMethods[\"addListener\"] = (callback) => {\n this.listeners.add(callback);\n };\n\n removeListener: CommunicationLayerMethods[\"removeListener\"] = (callback) => {\n this.listeners.delete(callback);\n };\n\n async close(): Promise<void> {\n this.listeners.clear();\n this.activeClientIds.clear();\n this.server?.close();\n this.server = null;\n\n // Drop our reference to the shared daemon. If we were the last user, the\n // refcount hands back its PID and we shut it down; otherwise it keeps\n // running for the remaining instances.\n const pidToKill = this.refcount.release();\n if (pidToKill !== null) {\n try {\n process.kill(pidToKill);\n console.log(\"[FlipperServerTransport] Shut down flipper-server.\");\n } catch {\n /* already gone */\n }\n }\n }\n}\n"],"mappings":"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","names":["WsWebSocket"]}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// ../../../../../../../../../../../execroot/_main/bazel-out/k8-fastbuild/bin/devtools/client/flipper/src/transport.ts
|
|
9
|
+
import {
|
|
10
|
+
createFlipperServer,
|
|
11
|
+
FlipperServerState
|
|
12
|
+
} from "flipper-server-client";
|
|
13
|
+
import { spawn } from "child_process";
|
|
14
|
+
import * as net from "net";
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as os from "os";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { WebSocket as WsWebSocket } from "ws";
|
|
19
|
+
if (typeof globalThis.WebSocket === "undefined") {
|
|
20
|
+
globalThis.WebSocket = WsWebSocket;
|
|
21
|
+
}
|
|
22
|
+
var PLUGIN_API = "player-ui-devtools";
|
|
23
|
+
function waitForPort(host, port, timeoutMs = 3e4) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const deadline = Date.now() + timeoutMs;
|
|
26
|
+
const attempt = () => {
|
|
27
|
+
const socket = net.connect(port, host);
|
|
28
|
+
socket.once("connect", () => {
|
|
29
|
+
socket.destroy();
|
|
30
|
+
resolve();
|
|
31
|
+
});
|
|
32
|
+
socket.once("error", () => {
|
|
33
|
+
socket.destroy();
|
|
34
|
+
if (Date.now() >= deadline) {
|
|
35
|
+
reject(new Error(`Timed out waiting for ${host}:${port}`));
|
|
36
|
+
} else {
|
|
37
|
+
setTimeout(attempt, 500);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
attempt();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
var FlipperRefcount = class {
|
|
45
|
+
constructor() {
|
|
46
|
+
this.dir = path.join(os.tmpdir(), "player-devtools-mcp");
|
|
47
|
+
this.file = path.join(this.dir, "flipper-server.refcount");
|
|
48
|
+
this.lock = path.join(this.dir, "flipper-server.lock");
|
|
49
|
+
}
|
|
50
|
+
/** Acquire the cross-process lock, run `fn`, then release — even on throw. */
|
|
51
|
+
withLock(fn) {
|
|
52
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
53
|
+
const deadline = Date.now() + 5e3;
|
|
54
|
+
for (; ; ) {
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(this.lock);
|
|
57
|
+
break;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code !== "EEXIST") throw err;
|
|
60
|
+
if (Date.now() >= deadline) {
|
|
61
|
+
try {
|
|
62
|
+
fs.rmdirSync(this.lock);
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return fn();
|
|
70
|
+
} finally {
|
|
71
|
+
try {
|
|
72
|
+
fs.rmdirSync(this.lock);
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
read() {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
write(state) {
|
|
85
|
+
fs.writeFileSync(this.file, JSON.stringify(state));
|
|
86
|
+
}
|
|
87
|
+
clear() {
|
|
88
|
+
try {
|
|
89
|
+
fs.unlinkSync(this.file);
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Is the recorded daemon PID still alive? */
|
|
94
|
+
isAlive(pid) {
|
|
95
|
+
try {
|
|
96
|
+
process.kill(pid, 0);
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Register interest in the daemon. Returns whether this caller is the one
|
|
104
|
+
* responsible for starting it (because no live daemon was recorded).
|
|
105
|
+
* `recordPid` is called with the started PID once the daemon is up.
|
|
106
|
+
*/
|
|
107
|
+
acquire() {
|
|
108
|
+
return this.withLock(() => {
|
|
109
|
+
const state = this.read();
|
|
110
|
+
if (state && this.isAlive(state.pid)) {
|
|
111
|
+
this.write({ pid: state.pid, refs: state.refs + 1 });
|
|
112
|
+
return { shouldStart: false, commit: () => {
|
|
113
|
+
} };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
shouldStart: true,
|
|
117
|
+
commit: (pid) => this.withLock(() => this.write({ pid, refs: 1 }))
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Drop this caller's interest. Returns the PID to kill if this was the last
|
|
123
|
+
* reference, otherwise null.
|
|
124
|
+
*/
|
|
125
|
+
release() {
|
|
126
|
+
return this.withLock(() => {
|
|
127
|
+
const state = this.read();
|
|
128
|
+
if (!state) return null;
|
|
129
|
+
if (state.refs <= 1) {
|
|
130
|
+
this.clear();
|
|
131
|
+
return state.pid;
|
|
132
|
+
}
|
|
133
|
+
this.write({ pid: state.pid, refs: state.refs - 1 });
|
|
134
|
+
return null;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
var FlipperServerTransport = class {
|
|
139
|
+
constructor(options = {}) {
|
|
140
|
+
this.options = options;
|
|
141
|
+
this.server = null;
|
|
142
|
+
this.refcount = new FlipperRefcount();
|
|
143
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
144
|
+
/**
|
|
145
|
+
* Client IDs that have sent at least one message through the devtools
|
|
146
|
+
* plugin — these are the clients we send outbound messages to.
|
|
147
|
+
*/
|
|
148
|
+
this.activeClientIds = /* @__PURE__ */ new Set();
|
|
149
|
+
this.sendMessage = async (message) => {
|
|
150
|
+
if (!this.server) return;
|
|
151
|
+
const payload = {
|
|
152
|
+
method: "execute",
|
|
153
|
+
params: {
|
|
154
|
+
api: PLUGIN_API,
|
|
155
|
+
method: "message::flipper",
|
|
156
|
+
params: message
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
await Promise.all(
|
|
160
|
+
[...this.activeClientIds].map(
|
|
161
|
+
(clientId) => this.server.exec("client-request-response", clientId, payload).catch(
|
|
162
|
+
(err) => {
|
|
163
|
+
console.warn(
|
|
164
|
+
`[FlipperServerTransport] Failed to send to client ${clientId}:`,
|
|
165
|
+
err
|
|
166
|
+
);
|
|
167
|
+
this.activeClientIds.delete(clientId);
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
this.addListener = (callback) => {
|
|
174
|
+
this.listeners.add(callback);
|
|
175
|
+
};
|
|
176
|
+
this.removeListener = (callback) => {
|
|
177
|
+
this.listeners.delete(callback);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async connect() {
|
|
181
|
+
const host = this.options.host ?? "localhost";
|
|
182
|
+
const port = this.options.port ?? 52342;
|
|
183
|
+
const { shouldStart, commit } = this.refcount.acquire();
|
|
184
|
+
if (shouldStart) {
|
|
185
|
+
console.log("[FlipperServerTransport] Starting flipper-server...");
|
|
186
|
+
const serverScript = __require.resolve("flipper-server/server.js");
|
|
187
|
+
const child = spawn(process.execPath, [serverScript, "--open=true"], {
|
|
188
|
+
stdio: "inherit",
|
|
189
|
+
detached: true
|
|
190
|
+
});
|
|
191
|
+
child.on("error", (err) => {
|
|
192
|
+
console.error(
|
|
193
|
+
"[FlipperServerTransport] flipper-server process error:",
|
|
194
|
+
err
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
child.unref();
|
|
198
|
+
await waitForPort(host, port);
|
|
199
|
+
commit(child.pid);
|
|
200
|
+
console.log("[FlipperServerTransport] flipper-server ready.");
|
|
201
|
+
} else {
|
|
202
|
+
await waitForPort(host, port);
|
|
203
|
+
console.log("[FlipperServerTransport] Attached to flipper-server.");
|
|
204
|
+
}
|
|
205
|
+
const { getAuthToken } = (
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
207
|
+
__require("flipper-server/lib/app-connectivity/certificate-exchange/certificate-utils")
|
|
208
|
+
);
|
|
209
|
+
let cachedToken = null;
|
|
210
|
+
try {
|
|
211
|
+
cachedToken = await getAuthToken();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.warn("[FlipperServerTransport] Could not read auth token:", err);
|
|
214
|
+
}
|
|
215
|
+
this.server = await createFlipperServer(
|
|
216
|
+
host,
|
|
217
|
+
port,
|
|
218
|
+
() => cachedToken,
|
|
219
|
+
(state) => {
|
|
220
|
+
if (state === FlipperServerState.DISCONNECTED) {
|
|
221
|
+
console.warn("[FlipperServerTransport] Disconnected from server");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
await this.server.connect();
|
|
226
|
+
this.server.on("client-connected", (info) => {
|
|
227
|
+
console.log(
|
|
228
|
+
"[FlipperServerTransport] client-connected:",
|
|
229
|
+
JSON.stringify(info)
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
this.server.on("client-disconnected", ({ id }) => {
|
|
233
|
+
console.log("[FlipperServerTransport] client-disconnected:", id);
|
|
234
|
+
this.activeClientIds.delete(id);
|
|
235
|
+
});
|
|
236
|
+
this.server.on("client-message", ({ id, message }) => {
|
|
237
|
+
let parsed;
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(message);
|
|
240
|
+
} catch {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
console.debug(
|
|
244
|
+
`[FlipperServerTransport] client-message from ${id}: method=${parsed.method} api=${parsed.params?.api} pluginMethod=${parsed.params?.method}`
|
|
245
|
+
);
|
|
246
|
+
if (parsed.method !== "execute" || parsed.params?.api !== PLUGIN_API || parsed.params?.method !== "message::plugin") {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.activeClientIds.add(id);
|
|
250
|
+
const payload = parsed.params.params;
|
|
251
|
+
for (const listener of this.listeners) {
|
|
252
|
+
listener(payload);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async close() {
|
|
257
|
+
this.listeners.clear();
|
|
258
|
+
this.activeClientIds.clear();
|
|
259
|
+
this.server?.close();
|
|
260
|
+
this.server = null;
|
|
261
|
+
const pidToKill = this.refcount.release();
|
|
262
|
+
if (pidToKill !== null) {
|
|
263
|
+
try {
|
|
264
|
+
process.kill(pidToKill);
|
|
265
|
+
console.log("[FlipperServerTransport] Shut down flipper-server.");
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
export {
|
|
272
|
+
FlipperServerTransport
|
|
273
|
+
};
|
|
274
|
+
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// ../../../../../../../../../../../execroot/_main/bazel-out/k8-fastbuild/bin/devtools/client/flipper/src/transport.ts
|
|
9
|
+
import {
|
|
10
|
+
createFlipperServer,
|
|
11
|
+
FlipperServerState
|
|
12
|
+
} from "flipper-server-client";
|
|
13
|
+
import { spawn } from "child_process";
|
|
14
|
+
import * as net from "net";
|
|
15
|
+
import * as fs from "fs";
|
|
16
|
+
import * as os from "os";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { WebSocket as WsWebSocket } from "ws";
|
|
19
|
+
if (typeof globalThis.WebSocket === "undefined") {
|
|
20
|
+
globalThis.WebSocket = WsWebSocket;
|
|
21
|
+
}
|
|
22
|
+
var PLUGIN_API = "player-ui-devtools";
|
|
23
|
+
function waitForPort(host, port, timeoutMs = 3e4) {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const deadline = Date.now() + timeoutMs;
|
|
26
|
+
const attempt = () => {
|
|
27
|
+
const socket = net.connect(port, host);
|
|
28
|
+
socket.once("connect", () => {
|
|
29
|
+
socket.destroy();
|
|
30
|
+
resolve();
|
|
31
|
+
});
|
|
32
|
+
socket.once("error", () => {
|
|
33
|
+
socket.destroy();
|
|
34
|
+
if (Date.now() >= deadline) {
|
|
35
|
+
reject(new Error(`Timed out waiting for ${host}:${port}`));
|
|
36
|
+
} else {
|
|
37
|
+
setTimeout(attempt, 500);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
attempt();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
var FlipperRefcount = class {
|
|
45
|
+
constructor() {
|
|
46
|
+
this.dir = path.join(os.tmpdir(), "player-devtools-mcp");
|
|
47
|
+
this.file = path.join(this.dir, "flipper-server.refcount");
|
|
48
|
+
this.lock = path.join(this.dir, "flipper-server.lock");
|
|
49
|
+
}
|
|
50
|
+
/** Acquire the cross-process lock, run `fn`, then release — even on throw. */
|
|
51
|
+
withLock(fn) {
|
|
52
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
53
|
+
const deadline = Date.now() + 5e3;
|
|
54
|
+
for (; ; ) {
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(this.lock);
|
|
57
|
+
break;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
if (err.code !== "EEXIST") throw err;
|
|
60
|
+
if (Date.now() >= deadline) {
|
|
61
|
+
try {
|
|
62
|
+
fs.rmdirSync(this.lock);
|
|
63
|
+
} catch {
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
return fn();
|
|
70
|
+
} finally {
|
|
71
|
+
try {
|
|
72
|
+
fs.rmdirSync(this.lock);
|
|
73
|
+
} catch {
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
read() {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(fs.readFileSync(this.file, "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
write(state) {
|
|
85
|
+
fs.writeFileSync(this.file, JSON.stringify(state));
|
|
86
|
+
}
|
|
87
|
+
clear() {
|
|
88
|
+
try {
|
|
89
|
+
fs.unlinkSync(this.file);
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/** Is the recorded daemon PID still alive? */
|
|
94
|
+
isAlive(pid) {
|
|
95
|
+
try {
|
|
96
|
+
process.kill(pid, 0);
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Register interest in the daemon. Returns whether this caller is the one
|
|
104
|
+
* responsible for starting it (because no live daemon was recorded).
|
|
105
|
+
* `recordPid` is called with the started PID once the daemon is up.
|
|
106
|
+
*/
|
|
107
|
+
acquire() {
|
|
108
|
+
return this.withLock(() => {
|
|
109
|
+
const state = this.read();
|
|
110
|
+
if (state && this.isAlive(state.pid)) {
|
|
111
|
+
this.write({ pid: state.pid, refs: state.refs + 1 });
|
|
112
|
+
return { shouldStart: false, commit: () => {
|
|
113
|
+
} };
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
shouldStart: true,
|
|
117
|
+
commit: (pid) => this.withLock(() => this.write({ pid, refs: 1 }))
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Drop this caller's interest. Returns the PID to kill if this was the last
|
|
123
|
+
* reference, otherwise null.
|
|
124
|
+
*/
|
|
125
|
+
release() {
|
|
126
|
+
return this.withLock(() => {
|
|
127
|
+
const state = this.read();
|
|
128
|
+
if (!state) return null;
|
|
129
|
+
if (state.refs <= 1) {
|
|
130
|
+
this.clear();
|
|
131
|
+
return state.pid;
|
|
132
|
+
}
|
|
133
|
+
this.write({ pid: state.pid, refs: state.refs - 1 });
|
|
134
|
+
return null;
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
var FlipperServerTransport = class {
|
|
139
|
+
constructor(options = {}) {
|
|
140
|
+
this.options = options;
|
|
141
|
+
this.server = null;
|
|
142
|
+
this.refcount = new FlipperRefcount();
|
|
143
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
144
|
+
/**
|
|
145
|
+
* Client IDs that have sent at least one message through the devtools
|
|
146
|
+
* plugin — these are the clients we send outbound messages to.
|
|
147
|
+
*/
|
|
148
|
+
this.activeClientIds = /* @__PURE__ */ new Set();
|
|
149
|
+
this.sendMessage = async (message) => {
|
|
150
|
+
if (!this.server) return;
|
|
151
|
+
const payload = {
|
|
152
|
+
method: "execute",
|
|
153
|
+
params: {
|
|
154
|
+
api: PLUGIN_API,
|
|
155
|
+
method: "message::flipper",
|
|
156
|
+
params: message
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
await Promise.all(
|
|
160
|
+
[...this.activeClientIds].map(
|
|
161
|
+
(clientId) => this.server.exec("client-request-response", clientId, payload).catch(
|
|
162
|
+
(err) => {
|
|
163
|
+
console.warn(
|
|
164
|
+
`[FlipperServerTransport] Failed to send to client ${clientId}:`,
|
|
165
|
+
err
|
|
166
|
+
);
|
|
167
|
+
this.activeClientIds.delete(clientId);
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
this.addListener = (callback) => {
|
|
174
|
+
this.listeners.add(callback);
|
|
175
|
+
};
|
|
176
|
+
this.removeListener = (callback) => {
|
|
177
|
+
this.listeners.delete(callback);
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
async connect() {
|
|
181
|
+
const host = this.options.host ?? "localhost";
|
|
182
|
+
const port = this.options.port ?? 52342;
|
|
183
|
+
const { shouldStart, commit } = this.refcount.acquire();
|
|
184
|
+
if (shouldStart) {
|
|
185
|
+
console.log("[FlipperServerTransport] Starting flipper-server...");
|
|
186
|
+
const serverScript = __require.resolve("flipper-server/server.js");
|
|
187
|
+
const child = spawn(process.execPath, [serverScript, "--open=true"], {
|
|
188
|
+
stdio: "inherit",
|
|
189
|
+
detached: true
|
|
190
|
+
});
|
|
191
|
+
child.on("error", (err) => {
|
|
192
|
+
console.error(
|
|
193
|
+
"[FlipperServerTransport] flipper-server process error:",
|
|
194
|
+
err
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
child.unref();
|
|
198
|
+
await waitForPort(host, port);
|
|
199
|
+
commit(child.pid);
|
|
200
|
+
console.log("[FlipperServerTransport] flipper-server ready.");
|
|
201
|
+
} else {
|
|
202
|
+
await waitForPort(host, port);
|
|
203
|
+
console.log("[FlipperServerTransport] Attached to flipper-server.");
|
|
204
|
+
}
|
|
205
|
+
const { getAuthToken } = (
|
|
206
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
207
|
+
__require("flipper-server/lib/app-connectivity/certificate-exchange/certificate-utils")
|
|
208
|
+
);
|
|
209
|
+
let cachedToken = null;
|
|
210
|
+
try {
|
|
211
|
+
cachedToken = await getAuthToken();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.warn("[FlipperServerTransport] Could not read auth token:", err);
|
|
214
|
+
}
|
|
215
|
+
this.server = await createFlipperServer(
|
|
216
|
+
host,
|
|
217
|
+
port,
|
|
218
|
+
() => cachedToken,
|
|
219
|
+
(state) => {
|
|
220
|
+
if (state === FlipperServerState.DISCONNECTED) {
|
|
221
|
+
console.warn("[FlipperServerTransport] Disconnected from server");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
await this.server.connect();
|
|
226
|
+
this.server.on("client-connected", (info) => {
|
|
227
|
+
console.log(
|
|
228
|
+
"[FlipperServerTransport] client-connected:",
|
|
229
|
+
JSON.stringify(info)
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
this.server.on("client-disconnected", ({ id }) => {
|
|
233
|
+
console.log("[FlipperServerTransport] client-disconnected:", id);
|
|
234
|
+
this.activeClientIds.delete(id);
|
|
235
|
+
});
|
|
236
|
+
this.server.on("client-message", ({ id, message }) => {
|
|
237
|
+
let parsed;
|
|
238
|
+
try {
|
|
239
|
+
parsed = JSON.parse(message);
|
|
240
|
+
} catch {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
console.debug(
|
|
244
|
+
`[FlipperServerTransport] client-message from ${id}: method=${parsed.method} api=${parsed.params?.api} pluginMethod=${parsed.params?.method}`
|
|
245
|
+
);
|
|
246
|
+
if (parsed.method !== "execute" || parsed.params?.api !== PLUGIN_API || parsed.params?.method !== "message::plugin") {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.activeClientIds.add(id);
|
|
250
|
+
const payload = parsed.params.params;
|
|
251
|
+
for (const listener of this.listeners) {
|
|
252
|
+
listener(payload);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async close() {
|
|
257
|
+
this.listeners.clear();
|
|
258
|
+
this.activeClientIds.clear();
|
|
259
|
+
this.server?.close();
|
|
260
|
+
this.server = null;
|
|
261
|
+
const pidToKill = this.refcount.release();
|
|
262
|
+
if (pidToKill !== null) {
|
|
263
|
+
try {
|
|
264
|
+
process.kill(pidToKill);
|
|
265
|
+
console.log("[FlipperServerTransport] Shut down flipper-server.");
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
export {
|
|
272
|
+
FlipperServerTransport
|
|
273
|
+
};
|
|
274
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../../../../../execroot/_main/bazel-out/k8-fastbuild/bin/devtools/client/flipper/src/transport.ts"],"sourcesContent":["import {\n createFlipperServer,\n FlipperServerState,\n type FlipperServer,\n} from \"flipper-server-client\";\nimport { spawn } from \"child_process\";\nimport * as net from \"net\";\nimport * as fs from \"fs\";\nimport * as os from \"os\";\nimport * as path from \"path\";\nimport { WebSocket as WsWebSocket } from \"ws\";\nimport type {\n CommunicationLayerMethods,\n ExtensionSupportedEvents,\n MessengerEvent,\n TransactionMetadata,\n Transport,\n} from \"@player-devtools/types\";\n\n// polyfill WebSocket for Node < 22\nif (typeof (globalThis as { WebSocket?: unknown }).WebSocket === \"undefined\") {\n (globalThis as { WebSocket?: unknown }).WebSocket =\n WsWebSocket as unknown as typeof WebSocket;\n}\n\ntype MessageCallback = (\n message: TransactionMetadata & MessengerEvent<ExtensionSupportedEvents>,\n) => void;\n\nconst PLUGIN_API = \"player-ui-devtools\";\n\n/** Shape of a Flipper `client-message` payload after JSON.parse */\ntype FlipperExecuteMessage = {\n method: \"execute\";\n params: {\n api: string;\n method: string;\n params?: unknown;\n };\n};\n\n/**\n * Flipper headless transport\n *\n * Connects to a running `flipper-server` process and routes messages for the\n * \"flipper-plugin-player-ui-devtools\" plugin through to the Messenger layer.\n */\n/** Wait until a TCP port is accepting connections, polling every 500ms */\nfunction waitForPort(\n host: string,\n port: number,\n timeoutMs = 30_000,\n): Promise<void> {\n return new Promise((resolve, reject) => {\n const deadline = Date.now() + timeoutMs;\n const attempt = () => {\n const socket = net.connect(port, host);\n socket.once(\"connect\", () => {\n socket.destroy();\n resolve();\n });\n socket.once(\"error\", () => {\n socket.destroy();\n if (Date.now() >= deadline) {\n reject(new Error(`Timed out waiting for ${host}:${port}`));\n } else {\n setTimeout(attempt, 500);\n }\n });\n };\n attempt();\n });\n}\n\n/**\n * Cross-process refcount for the shared `flipper-server` daemon.\n *\n * Many MCP server processes (one per project/user registration) attach to a\n * single `flipper-server` on a fixed port. No in-process variable can\n * coordinate their lifecycles, so we track liveness in a small file guarded by\n * an atomic lock directory:\n *\n * - The first attach starts the daemon and records its PID with `refs: 1`.\n * - Each subsequent attach increments `refs`.\n * - Each detach decrements `refs`; the last one out shuts the daemon down.\n *\n * The lock directory (`mkdir` is atomic across processes) serializes the\n * read-modify-write so concurrently-starting instances don't race.\n */\ntype RefcountFile = { pid: number; refs: number };\n\nclass FlipperRefcount {\n private readonly dir = path.join(os.tmpdir(), \"player-devtools-mcp\");\n private readonly file = path.join(this.dir, \"flipper-server.refcount\");\n private readonly lock = path.join(this.dir, \"flipper-server.lock\");\n\n /** Acquire the cross-process lock, run `fn`, then release — even on throw. */\n private withLock<T>(fn: () => T): T {\n fs.mkdirSync(this.dir, { recursive: true });\n const deadline = Date.now() + 5_000;\n // Spin on an atomic mkdir until we own the lock or time out.\n for (;;) {\n try {\n fs.mkdirSync(this.lock);\n break;\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== \"EEXIST\") throw err;\n if (Date.now() >= deadline) {\n // Stale lock from a crashed process — reclaim it.\n try {\n fs.rmdirSync(this.lock);\n } catch {\n /* another instance won the reclaim; retry */\n }\n }\n }\n }\n try {\n return fn();\n } finally {\n try {\n fs.rmdirSync(this.lock);\n } catch {\n /* already gone */\n }\n }\n }\n\n private read(): RefcountFile | null {\n try {\n return JSON.parse(fs.readFileSync(this.file, \"utf8\")) as RefcountFile;\n } catch {\n return null;\n }\n }\n\n private write(state: RefcountFile): void {\n fs.writeFileSync(this.file, JSON.stringify(state));\n }\n\n private clear(): void {\n try {\n fs.unlinkSync(this.file);\n } catch {\n /* already gone */\n }\n }\n\n /** Is the recorded daemon PID still alive? */\n private isAlive(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Register interest in the daemon. Returns whether this caller is the one\n * responsible for starting it (because no live daemon was recorded).\n * `recordPid` is called with the started PID once the daemon is up.\n */\n acquire(): { shouldStart: boolean; commit: (pid: number) => void } {\n return this.withLock(() => {\n const state = this.read();\n if (state && this.isAlive(state.pid)) {\n this.write({ pid: state.pid, refs: state.refs + 1 });\n return { shouldStart: false, commit: () => {} };\n }\n // No live daemon — this caller will start one and record its PID.\n return {\n shouldStart: true,\n commit: (pid: number) =>\n this.withLock(() => this.write({ pid, refs: 1 })),\n };\n });\n }\n\n /**\n * Drop this caller's interest. Returns the PID to kill if this was the last\n * reference, otherwise null.\n */\n release(): number | null {\n return this.withLock(() => {\n const state = this.read();\n if (!state) return null;\n if (state.refs <= 1) {\n this.clear();\n return state.pid;\n }\n this.write({ pid: state.pid, refs: state.refs - 1 });\n return null;\n });\n }\n}\n\nexport class FlipperServerTransport implements Transport {\n private server: FlipperServer | null = null;\n private refcount = new FlipperRefcount();\n private listeners = new Set<MessageCallback>();\n\n /**\n * Client IDs that have sent at least one message through the devtools\n * plugin — these are the clients we send outbound messages to.\n */\n private activeClientIds = new Set<string>();\n\n constructor(\n private options: {\n /** Flipper server host; defaults to \"localhost\" */\n host?: string;\n /** Flipper server WebSocket port; defaults to 52342 */\n port?: number;\n } = {},\n ) {}\n\n async connect(): Promise<void> {\n const host = this.options.host ?? \"localhost\";\n const port = this.options.port ?? 52342;\n\n // Register interest in the shared daemon. The first instance to do so is\n // told to start it; the rest just attach. The daemon outlives any single\n // MCP process and is only torn down when the last instance detaches.\n const { shouldStart, commit } = this.refcount.acquire();\n\n if (shouldStart) {\n console.log(\"[FlipperServerTransport] Starting flipper-server...\");\n const serverScript = require.resolve(\"flipper-server/server.js\");\n // Detached + unref'd: the daemon must survive this process exiting so\n // other instances keep their connections. We never kill it directly —\n // shutdown is driven by the refcount in close().\n const child = spawn(process.execPath, [serverScript, \"--open=true\"], {\n stdio: \"inherit\",\n detached: true,\n });\n child.on(\"error\", (err: Error) => {\n console.error(\n \"[FlipperServerTransport] flipper-server process error:\",\n err,\n );\n });\n child.unref();\n await waitForPort(host, port);\n commit(child.pid!);\n console.log(\"[FlipperServerTransport] flipper-server ready.\");\n } else {\n // Daemon already running (started by another instance) — wait for it to\n // accept connections in case it's still coming up, then attach.\n await waitForPort(host, port);\n console.log(\"[FlipperServerTransport] Attached to flipper-server.\");\n }\n\n // Read the auth token the flipper-server wrote during startup\n const { getAuthToken } =\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n require(\"flipper-server/lib/app-connectivity/certificate-exchange/certificate-utils\") as {\n getAuthToken: () => Promise<string>;\n };\n\n let cachedToken: string | null = null;\n try {\n cachedToken = await getAuthToken();\n } catch (err) {\n console.warn(\"[FlipperServerTransport] Could not read auth token:\", err);\n }\n\n this.server = await createFlipperServer(\n host,\n port,\n () => cachedToken,\n (state) => {\n if (state === FlipperServerState.DISCONNECTED) {\n console.warn(\"[FlipperServerTransport] Disconnected from server\");\n }\n },\n );\n\n await this.server.connect();\n\n // Track client connects/disconnects\n this.server.on(\"client-connected\", (info) => {\n console.log(\n \"[FlipperServerTransport] client-connected:\",\n JSON.stringify(info),\n );\n });\n this.server.on(\"client-disconnected\", ({ id }) => {\n console.log(\"[FlipperServerTransport] client-disconnected:\", id);\n this.activeClientIds.delete(id);\n });\n\n // Route inbound device messages to our Messenger listeners\n this.server.on(\"client-message\", ({ id, message }) => {\n let parsed: FlipperExecuteMessage;\n try {\n parsed = JSON.parse(message) as FlipperExecuteMessage;\n } catch {\n return;\n }\n\n console.debug(\n `[FlipperServerTransport] client-message from ${id}: method=${parsed.method} api=${(parsed.params as { api?: string })?.api} pluginMethod=${(parsed.params as { method?: string })?.method}`,\n );\n\n if (\n parsed.method !== \"execute\" ||\n parsed.params?.api !== PLUGIN_API ||\n parsed.params?.method !== \"message::plugin\"\n ) {\n return;\n }\n\n // This client is talking through the devtools plugin — remember it\n this.activeClientIds.add(id);\n\n const payload = parsed.params.params as TransactionMetadata &\n MessengerEvent<ExtensionSupportedEvents>;\n\n for (const listener of this.listeners) {\n listener(payload);\n }\n });\n }\n\n sendMessage: CommunicationLayerMethods[\"sendMessage\"] = async (message) => {\n if (!this.server) return;\n\n const payload: FlipperExecuteMessage = {\n method: \"execute\",\n params: {\n api: PLUGIN_API,\n method: \"message::flipper\",\n params: message,\n },\n };\n\n await Promise.all(\n [...this.activeClientIds].map((clientId) =>\n this.server!.exec(\"client-request-response\", clientId, payload).catch(\n (err) => {\n console.warn(\n `[FlipperServerTransport] Failed to send to client ${clientId}:`,\n err,\n );\n // Remove dead client so we stop trying\n this.activeClientIds.delete(clientId);\n },\n ),\n ),\n );\n };\n\n addListener: CommunicationLayerMethods[\"addListener\"] = (callback) => {\n this.listeners.add(callback);\n };\n\n removeListener: CommunicationLayerMethods[\"removeListener\"] = (callback) => {\n this.listeners.delete(callback);\n };\n\n async close(): Promise<void> {\n this.listeners.clear();\n this.activeClientIds.clear();\n this.server?.close();\n this.server = null;\n\n // Drop our reference to the shared daemon. If we were the last user, the\n // refcount hands back its PID and we shut it down; otherwise it keeps\n // running for the remaining instances.\n const pidToKill = this.refcount.release();\n if (pidToKill !== null) {\n try {\n process.kill(pidToKill);\n console.log(\"[FlipperServerTransport] Shut down flipper-server.\");\n } catch {\n /* already gone */\n }\n }\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"sideEffects": false,
|
|
3
|
+
"files": [
|
|
4
|
+
"dist",
|
|
5
|
+
"src",
|
|
6
|
+
"types"
|
|
7
|
+
],
|
|
8
|
+
"name": "@player-devtools/client-flipper",
|
|
9
|
+
"version": "0.13.1--canary.17.955",
|
|
10
|
+
"main": "dist/cjs/index.cjs",
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@player-devtools/types": "0.13.1--canary.17.955",
|
|
13
|
+
"@types/ws": "^8.0.0",
|
|
14
|
+
"flipper-server": "^0.273.0",
|
|
15
|
+
"flipper-server-client": "^0.273.0",
|
|
16
|
+
"ws": "^8.0.0",
|
|
17
|
+
"tslib": "^2.6.2"
|
|
18
|
+
},
|
|
19
|
+
"module": "dist/index.legacy-esm.js",
|
|
20
|
+
"types": "types/index.d.ts",
|
|
21
|
+
"exports": {
|
|
22
|
+
"./package.json": "./package.json",
|
|
23
|
+
"./dist/index.css": "./dist/index.css",
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./types/index.d.ts",
|
|
26
|
+
"import": "./dist/index.mjs",
|
|
27
|
+
"default": "./dist/cjs/index.cjs"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {}
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FlipperServerTransport } from "./transport";
|
package/src/transport.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFlipperServer,
|
|
3
|
+
FlipperServerState,
|
|
4
|
+
type FlipperServer,
|
|
5
|
+
} from "flipper-server-client";
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import * as net from "net";
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as os from "os";
|
|
10
|
+
import * as path from "path";
|
|
11
|
+
import { WebSocket as WsWebSocket } from "ws";
|
|
12
|
+
import type {
|
|
13
|
+
CommunicationLayerMethods,
|
|
14
|
+
ExtensionSupportedEvents,
|
|
15
|
+
MessengerEvent,
|
|
16
|
+
TransactionMetadata,
|
|
17
|
+
Transport,
|
|
18
|
+
} from "@player-devtools/types";
|
|
19
|
+
|
|
20
|
+
// polyfill WebSocket for Node < 22
|
|
21
|
+
if (typeof (globalThis as { WebSocket?: unknown }).WebSocket === "undefined") {
|
|
22
|
+
(globalThis as { WebSocket?: unknown }).WebSocket =
|
|
23
|
+
WsWebSocket as unknown as typeof WebSocket;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type MessageCallback = (
|
|
27
|
+
message: TransactionMetadata & MessengerEvent<ExtensionSupportedEvents>,
|
|
28
|
+
) => void;
|
|
29
|
+
|
|
30
|
+
const PLUGIN_API = "player-ui-devtools";
|
|
31
|
+
|
|
32
|
+
/** Shape of a Flipper `client-message` payload after JSON.parse */
|
|
33
|
+
type FlipperExecuteMessage = {
|
|
34
|
+
method: "execute";
|
|
35
|
+
params: {
|
|
36
|
+
api: string;
|
|
37
|
+
method: string;
|
|
38
|
+
params?: unknown;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Flipper headless transport
|
|
44
|
+
*
|
|
45
|
+
* Connects to a running `flipper-server` process and routes messages for the
|
|
46
|
+
* "flipper-plugin-player-ui-devtools" plugin through to the Messenger layer.
|
|
47
|
+
*/
|
|
48
|
+
/** Wait until a TCP port is accepting connections, polling every 500ms */
|
|
49
|
+
function waitForPort(
|
|
50
|
+
host: string,
|
|
51
|
+
port: number,
|
|
52
|
+
timeoutMs = 30_000,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
const attempt = () => {
|
|
57
|
+
const socket = net.connect(port, host);
|
|
58
|
+
socket.once("connect", () => {
|
|
59
|
+
socket.destroy();
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
socket.once("error", () => {
|
|
63
|
+
socket.destroy();
|
|
64
|
+
if (Date.now() >= deadline) {
|
|
65
|
+
reject(new Error(`Timed out waiting for ${host}:${port}`));
|
|
66
|
+
} else {
|
|
67
|
+
setTimeout(attempt, 500);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
attempt();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Cross-process refcount for the shared `flipper-server` daemon.
|
|
77
|
+
*
|
|
78
|
+
* Many MCP server processes (one per project/user registration) attach to a
|
|
79
|
+
* single `flipper-server` on a fixed port. No in-process variable can
|
|
80
|
+
* coordinate their lifecycles, so we track liveness in a small file guarded by
|
|
81
|
+
* an atomic lock directory:
|
|
82
|
+
*
|
|
83
|
+
* - The first attach starts the daemon and records its PID with `refs: 1`.
|
|
84
|
+
* - Each subsequent attach increments `refs`.
|
|
85
|
+
* - Each detach decrements `refs`; the last one out shuts the daemon down.
|
|
86
|
+
*
|
|
87
|
+
* The lock directory (`mkdir` is atomic across processes) serializes the
|
|
88
|
+
* read-modify-write so concurrently-starting instances don't race.
|
|
89
|
+
*/
|
|
90
|
+
type RefcountFile = { pid: number; refs: number };
|
|
91
|
+
|
|
92
|
+
class FlipperRefcount {
|
|
93
|
+
private readonly dir = path.join(os.tmpdir(), "player-devtools-mcp");
|
|
94
|
+
private readonly file = path.join(this.dir, "flipper-server.refcount");
|
|
95
|
+
private readonly lock = path.join(this.dir, "flipper-server.lock");
|
|
96
|
+
|
|
97
|
+
/** Acquire the cross-process lock, run `fn`, then release — even on throw. */
|
|
98
|
+
private withLock<T>(fn: () => T): T {
|
|
99
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
100
|
+
const deadline = Date.now() + 5_000;
|
|
101
|
+
// Spin on an atomic mkdir until we own the lock or time out.
|
|
102
|
+
for (;;) {
|
|
103
|
+
try {
|
|
104
|
+
fs.mkdirSync(this.lock);
|
|
105
|
+
break;
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
108
|
+
if (Date.now() >= deadline) {
|
|
109
|
+
// Stale lock from a crashed process — reclaim it.
|
|
110
|
+
try {
|
|
111
|
+
fs.rmdirSync(this.lock);
|
|
112
|
+
} catch {
|
|
113
|
+
/* another instance won the reclaim; retry */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
return fn();
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
fs.rmdirSync(this.lock);
|
|
123
|
+
} catch {
|
|
124
|
+
/* already gone */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private read(): RefcountFile | null {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(fs.readFileSync(this.file, "utf8")) as RefcountFile;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private write(state: RefcountFile): void {
|
|
138
|
+
fs.writeFileSync(this.file, JSON.stringify(state));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private clear(): void {
|
|
142
|
+
try {
|
|
143
|
+
fs.unlinkSync(this.file);
|
|
144
|
+
} catch {
|
|
145
|
+
/* already gone */
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Is the recorded daemon PID still alive? */
|
|
150
|
+
private isAlive(pid: number): boolean {
|
|
151
|
+
try {
|
|
152
|
+
process.kill(pid, 0);
|
|
153
|
+
return true;
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Register interest in the daemon. Returns whether this caller is the one
|
|
161
|
+
* responsible for starting it (because no live daemon was recorded).
|
|
162
|
+
* `recordPid` is called with the started PID once the daemon is up.
|
|
163
|
+
*/
|
|
164
|
+
acquire(): { shouldStart: boolean; commit: (pid: number) => void } {
|
|
165
|
+
return this.withLock(() => {
|
|
166
|
+
const state = this.read();
|
|
167
|
+
if (state && this.isAlive(state.pid)) {
|
|
168
|
+
this.write({ pid: state.pid, refs: state.refs + 1 });
|
|
169
|
+
return { shouldStart: false, commit: () => {} };
|
|
170
|
+
}
|
|
171
|
+
// No live daemon — this caller will start one and record its PID.
|
|
172
|
+
return {
|
|
173
|
+
shouldStart: true,
|
|
174
|
+
commit: (pid: number) =>
|
|
175
|
+
this.withLock(() => this.write({ pid, refs: 1 })),
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Drop this caller's interest. Returns the PID to kill if this was the last
|
|
182
|
+
* reference, otherwise null.
|
|
183
|
+
*/
|
|
184
|
+
release(): number | null {
|
|
185
|
+
return this.withLock(() => {
|
|
186
|
+
const state = this.read();
|
|
187
|
+
if (!state) return null;
|
|
188
|
+
if (state.refs <= 1) {
|
|
189
|
+
this.clear();
|
|
190
|
+
return state.pid;
|
|
191
|
+
}
|
|
192
|
+
this.write({ pid: state.pid, refs: state.refs - 1 });
|
|
193
|
+
return null;
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export class FlipperServerTransport implements Transport {
|
|
199
|
+
private server: FlipperServer | null = null;
|
|
200
|
+
private refcount = new FlipperRefcount();
|
|
201
|
+
private listeners = new Set<MessageCallback>();
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Client IDs that have sent at least one message through the devtools
|
|
205
|
+
* plugin — these are the clients we send outbound messages to.
|
|
206
|
+
*/
|
|
207
|
+
private activeClientIds = new Set<string>();
|
|
208
|
+
|
|
209
|
+
constructor(
|
|
210
|
+
private options: {
|
|
211
|
+
/** Flipper server host; defaults to "localhost" */
|
|
212
|
+
host?: string;
|
|
213
|
+
/** Flipper server WebSocket port; defaults to 52342 */
|
|
214
|
+
port?: number;
|
|
215
|
+
} = {},
|
|
216
|
+
) {}
|
|
217
|
+
|
|
218
|
+
async connect(): Promise<void> {
|
|
219
|
+
const host = this.options.host ?? "localhost";
|
|
220
|
+
const port = this.options.port ?? 52342;
|
|
221
|
+
|
|
222
|
+
// Register interest in the shared daemon. The first instance to do so is
|
|
223
|
+
// told to start it; the rest just attach. The daemon outlives any single
|
|
224
|
+
// MCP process and is only torn down when the last instance detaches.
|
|
225
|
+
const { shouldStart, commit } = this.refcount.acquire();
|
|
226
|
+
|
|
227
|
+
if (shouldStart) {
|
|
228
|
+
console.log("[FlipperServerTransport] Starting flipper-server...");
|
|
229
|
+
const serverScript = require.resolve("flipper-server/server.js");
|
|
230
|
+
// Detached + unref'd: the daemon must survive this process exiting so
|
|
231
|
+
// other instances keep their connections. We never kill it directly —
|
|
232
|
+
// shutdown is driven by the refcount in close().
|
|
233
|
+
const child = spawn(process.execPath, [serverScript, "--open=true"], {
|
|
234
|
+
stdio: "inherit",
|
|
235
|
+
detached: true,
|
|
236
|
+
});
|
|
237
|
+
child.on("error", (err: Error) => {
|
|
238
|
+
console.error(
|
|
239
|
+
"[FlipperServerTransport] flipper-server process error:",
|
|
240
|
+
err,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
child.unref();
|
|
244
|
+
await waitForPort(host, port);
|
|
245
|
+
commit(child.pid!);
|
|
246
|
+
console.log("[FlipperServerTransport] flipper-server ready.");
|
|
247
|
+
} else {
|
|
248
|
+
// Daemon already running (started by another instance) — wait for it to
|
|
249
|
+
// accept connections in case it's still coming up, then attach.
|
|
250
|
+
await waitForPort(host, port);
|
|
251
|
+
console.log("[FlipperServerTransport] Attached to flipper-server.");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Read the auth token the flipper-server wrote during startup
|
|
255
|
+
const { getAuthToken } =
|
|
256
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
257
|
+
require("flipper-server/lib/app-connectivity/certificate-exchange/certificate-utils") as {
|
|
258
|
+
getAuthToken: () => Promise<string>;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
let cachedToken: string | null = null;
|
|
262
|
+
try {
|
|
263
|
+
cachedToken = await getAuthToken();
|
|
264
|
+
} catch (err) {
|
|
265
|
+
console.warn("[FlipperServerTransport] Could not read auth token:", err);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
this.server = await createFlipperServer(
|
|
269
|
+
host,
|
|
270
|
+
port,
|
|
271
|
+
() => cachedToken,
|
|
272
|
+
(state) => {
|
|
273
|
+
if (state === FlipperServerState.DISCONNECTED) {
|
|
274
|
+
console.warn("[FlipperServerTransport] Disconnected from server");
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
await this.server.connect();
|
|
280
|
+
|
|
281
|
+
// Track client connects/disconnects
|
|
282
|
+
this.server.on("client-connected", (info) => {
|
|
283
|
+
console.log(
|
|
284
|
+
"[FlipperServerTransport] client-connected:",
|
|
285
|
+
JSON.stringify(info),
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
this.server.on("client-disconnected", ({ id }) => {
|
|
289
|
+
console.log("[FlipperServerTransport] client-disconnected:", id);
|
|
290
|
+
this.activeClientIds.delete(id);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Route inbound device messages to our Messenger listeners
|
|
294
|
+
this.server.on("client-message", ({ id, message }) => {
|
|
295
|
+
let parsed: FlipperExecuteMessage;
|
|
296
|
+
try {
|
|
297
|
+
parsed = JSON.parse(message) as FlipperExecuteMessage;
|
|
298
|
+
} catch {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
console.debug(
|
|
303
|
+
`[FlipperServerTransport] client-message from ${id}: method=${parsed.method} api=${(parsed.params as { api?: string })?.api} pluginMethod=${(parsed.params as { method?: string })?.method}`,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
if (
|
|
307
|
+
parsed.method !== "execute" ||
|
|
308
|
+
parsed.params?.api !== PLUGIN_API ||
|
|
309
|
+
parsed.params?.method !== "message::plugin"
|
|
310
|
+
) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// This client is talking through the devtools plugin — remember it
|
|
315
|
+
this.activeClientIds.add(id);
|
|
316
|
+
|
|
317
|
+
const payload = parsed.params.params as TransactionMetadata &
|
|
318
|
+
MessengerEvent<ExtensionSupportedEvents>;
|
|
319
|
+
|
|
320
|
+
for (const listener of this.listeners) {
|
|
321
|
+
listener(payload);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
sendMessage: CommunicationLayerMethods["sendMessage"] = async (message) => {
|
|
327
|
+
if (!this.server) return;
|
|
328
|
+
|
|
329
|
+
const payload: FlipperExecuteMessage = {
|
|
330
|
+
method: "execute",
|
|
331
|
+
params: {
|
|
332
|
+
api: PLUGIN_API,
|
|
333
|
+
method: "message::flipper",
|
|
334
|
+
params: message,
|
|
335
|
+
},
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
await Promise.all(
|
|
339
|
+
[...this.activeClientIds].map((clientId) =>
|
|
340
|
+
this.server!.exec("client-request-response", clientId, payload).catch(
|
|
341
|
+
(err) => {
|
|
342
|
+
console.warn(
|
|
343
|
+
`[FlipperServerTransport] Failed to send to client ${clientId}:`,
|
|
344
|
+
err,
|
|
345
|
+
);
|
|
346
|
+
// Remove dead client so we stop trying
|
|
347
|
+
this.activeClientIds.delete(clientId);
|
|
348
|
+
},
|
|
349
|
+
),
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
addListener: CommunicationLayerMethods["addListener"] = (callback) => {
|
|
355
|
+
this.listeners.add(callback);
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
removeListener: CommunicationLayerMethods["removeListener"] = (callback) => {
|
|
359
|
+
this.listeners.delete(callback);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
async close(): Promise<void> {
|
|
363
|
+
this.listeners.clear();
|
|
364
|
+
this.activeClientIds.clear();
|
|
365
|
+
this.server?.close();
|
|
366
|
+
this.server = null;
|
|
367
|
+
|
|
368
|
+
// Drop our reference to the shared daemon. If we were the last user, the
|
|
369
|
+
// refcount hands back its PID and we shut it down; otherwise it keeps
|
|
370
|
+
// running for the remaining instances.
|
|
371
|
+
const pidToKill = this.refcount.release();
|
|
372
|
+
if (pidToKill !== null) {
|
|
373
|
+
try {
|
|
374
|
+
process.kill(pidToKill);
|
|
375
|
+
console.log("[FlipperServerTransport] Shut down flipper-server.");
|
|
376
|
+
} catch {
|
|
377
|
+
/* already gone */
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CommunicationLayerMethods, Transport } from "@player-devtools/types";
|
|
2
|
+
export declare class FlipperServerTransport implements Transport {
|
|
3
|
+
private options;
|
|
4
|
+
private server;
|
|
5
|
+
private refcount;
|
|
6
|
+
private listeners;
|
|
7
|
+
/**
|
|
8
|
+
* Client IDs that have sent at least one message through the devtools
|
|
9
|
+
* plugin — these are the clients we send outbound messages to.
|
|
10
|
+
*/
|
|
11
|
+
private activeClientIds;
|
|
12
|
+
constructor(options?: {
|
|
13
|
+
/** Flipper server host; defaults to "localhost" */
|
|
14
|
+
host?: string;
|
|
15
|
+
/** Flipper server WebSocket port; defaults to 52342 */
|
|
16
|
+
port?: number;
|
|
17
|
+
});
|
|
18
|
+
connect(): Promise<void>;
|
|
19
|
+
sendMessage: CommunicationLayerMethods["sendMessage"];
|
|
20
|
+
addListener: CommunicationLayerMethods["addListener"];
|
|
21
|
+
removeListener: CommunicationLayerMethods["removeListener"];
|
|
22
|
+
close(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
//# sourceMappingURL=transport.d.ts.map
|