@npgamedev/godot-mcp-server 0.0.1 → 1.0.0
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/ATTRIBUTIONS.md +141 -0
- package/LICENSE +28 -0
- package/README.md +386 -4
- package/dist/extensions/extensionChanges.js +131 -0
- package/dist/extensions/extensionCommand.js +25 -0
- package/dist/extensions/extensionDiscovery.js +141 -0
- package/dist/extensions/extensionRegistrar.js +82 -0
- package/dist/extensions/extensions.js +33 -0
- package/dist/groups/builtinGroups.js +60 -0
- package/dist/groups/defs/3dTools.js +17 -0
- package/dist/groups/defs/animationAuthoring.js +16 -0
- package/dist/groups/defs/assetOps.js +6 -0
- package/dist/groups/defs/audio.js +6 -0
- package/dist/groups/defs/classdb.js +6 -0
- package/dist/groups/defs/cleanup.js +6 -0
- package/dist/groups/defs/debugger.js +6 -0
- package/dist/groups/defs/editorAdvanced.js +16 -0
- package/dist/groups/defs/inputMap.js +6 -0
- package/dist/groups/defs/layerNaming.js +6 -0
- package/dist/groups/defs/lspCodeAnalysis.js +23 -0
- package/dist/groups/defs/lspCodeNavigation.js +15 -0
- package/dist/groups/defs/navigation.js +17 -0
- package/dist/groups/defs/particles.js +21 -0
- package/dist/groups/defs/pathEditing.js +22 -0
- package/dist/groups/defs/placeholders.js +28 -0
- package/dist/groups/defs/procedural.js +6 -0
- package/dist/groups/defs/resourceIo.js +6 -0
- package/dist/groups/defs/runtimeAdvanced.js +17 -0
- package/dist/groups/defs/sceneAdvanced.js +6 -0
- package/dist/groups/defs/sceneInheritance.js +6 -0
- package/dist/groups/defs/signals.js +6 -0
- package/dist/groups/defs/spriteframes.js +16 -0
- package/dist/groups/defs/theme.js +6 -0
- package/dist/groups/defs/tilemap.js +6 -0
- package/dist/groups/defs/tileset.js +22 -0
- package/dist/groups/defs/tilesetEdit.js +21 -0
- package/dist/groups/defs/userData.js +6 -0
- package/dist/groups/extensionGroups.js +182 -0
- package/dist/groups/groupActivation.js +210 -0
- package/dist/groups/groupCatalogue.js +52 -0
- package/dist/groups/groupMatch.js +182 -0
- package/dist/groups/groupResult.js +16 -0
- package/dist/groups/groupState.js +15 -0
- package/dist/groups/groupToolHandlers.js +103 -0
- package/dist/groups/groupTypes.js +10 -0
- package/dist/groups/groups.js +205 -0
- package/dist/index.js +144 -0
- package/dist/lsp/lspClient.js +509 -0
- package/dist/lsp/lspLabels.js +105 -0
- package/dist/lsp/lspProjectScan.js +156 -0
- package/dist/lsp/lspSession.js +139 -0
- package/dist/lsp/lspStatusReporter.js +76 -0
- package/dist/lsp/lspUri.js +68 -0
- package/dist/mcp/prompts.js +59 -0
- package/dist/mcp/resources.js +113 -0
- package/dist/mcp/roots.js +30 -0
- package/dist/registration/catalogue.js +123 -0
- package/dist/registration/extensionCollision.js +38 -0
- package/dist/registration/operations.js +67 -0
- package/dist/registration/screenshotResponse.js +71 -0
- package/dist/registration/toolDispatch.js +76 -0
- package/dist/registration/toolMeta.js +104 -0
- package/dist/registration/toolRefs.js +48 -0
- package/dist/registration/toolRegistry.js +211 -0
- package/dist/registry.js +291 -0
- package/dist/registryLiveness.js +113 -0
- package/dist/security/pathGuard.js +97 -0
- package/dist/security/profiles.js +104 -0
- package/dist/security/untrusted.js +23 -0
- package/dist/shared/errorContract.js +154 -0
- package/dist/shared/errors.js +21 -0
- package/dist/shared/pagination.js +135 -0
- package/dist/shared/schemaCoercion.js +173 -0
- package/dist/shared/stableJson.js +27 -0
- package/dist/shared/types.js +1 -0
- package/dist/shared/version.js +82 -0
- package/dist/startup/cliArgs.js +103 -0
- package/dist/startup/configReload.js +57 -0
- package/dist/startup/hooks.js +89 -0
- package/dist/startup/lifecycle.js +22 -0
- package/dist/startup/portConfig.js +127 -0
- package/dist/startup/reconcile.js +81 -0
- package/dist/startup/registrars.js +59 -0
- package/dist/startup/serverMode.js +19 -0
- package/dist/startup/startupEnv.js +142 -0
- package/dist/tools/animation.js +88 -0
- package/dist/tools/asset.js +58 -0
- package/dist/tools/assetWrite.js +16 -0
- package/dist/tools/audio.js +40 -0
- package/dist/tools/classdb.js +43 -0
- package/dist/tools/collision.js +23 -0
- package/dist/tools/debug.js +46 -0
- package/dist/tools/diff.js +18 -0
- package/dist/tools/editor.js +222 -0
- package/dist/tools/file.js +38 -0
- package/dist/tools/folder.js +27 -0
- package/dist/tools/inputMap.js +54 -0
- package/dist/tools/layerNames.js +29 -0
- package/dist/tools/lsp.js +524 -0
- package/dist/tools/navigation.js +24 -0
- package/dist/tools/node.js +145 -0
- package/dist/tools/nodeManagement.js +82 -0
- package/dist/tools/particles.js +83 -0
- package/dist/tools/path.js +30 -0
- package/dist/tools/playtest.js +99 -0
- package/dist/tools/procedural.js +101 -0
- package/dist/tools/resource.js +47 -0
- package/dist/tools/runtime.js +338 -0
- package/dist/tools/save.js +64 -0
- package/dist/tools/scene.js +102 -0
- package/dist/tools/sceneInheritance.js +19 -0
- package/dist/tools/sceneQuery.js +38 -0
- package/dist/tools/script.js +75 -0
- package/dist/tools/signals.js +53 -0
- package/dist/tools/sound.js +31 -0
- package/dist/tools/spatial.js +41 -0
- package/dist/tools/spriteframes.js +94 -0
- package/dist/tools/texture.js +35 -0
- package/dist/tools/theme.js +35 -0
- package/dist/tools/threeD.js +116 -0
- package/dist/tools/tilemap.js +51 -0
- package/dist/tools/tileset.js +226 -0
- package/dist/transport/authHandshake.js +43 -0
- package/dist/transport/bridge.js +225 -0
- package/dist/transport/channel.js +352 -0
- package/dist/transport/heartbeat.js +54 -0
- package/dist/transport/runtimeConnection.js +239 -0
- package/dist/transport/tokenPath.js +98 -0
- package/package.json +97 -4
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnecting, authenticating WebSocket with JSON-RPC request/response
|
|
3
|
+
* correlation — the transport Channel primitive.
|
|
4
|
+
*
|
|
5
|
+
* One responsibility: run ONE reliable request/response conversation over a
|
|
6
|
+
* flaky socket — connect, authenticate (re-reading the token every connect),
|
|
7
|
+
* correlate responses to their pending promise by id, reset a pending call's
|
|
8
|
+
* timeout on _queued/_executing progress notifications, and ride out transient
|
|
9
|
+
* drops with exponential backoff (reset on a successful round-trip, not on
|
|
10
|
+
* open). Kept whole: the correlation layer and the connection/reconnect
|
|
11
|
+
* lifecycle co-vary — the backoff reset lives inside the response-correlation
|
|
12
|
+
* path — so they do not separate without a mutual-callback cycle.
|
|
13
|
+
*/
|
|
14
|
+
import { WebSocket } from "ws";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { BridgeError } from "../shared/errors.js";
|
|
17
|
+
import { readToken } from "./tokenPath.js";
|
|
18
|
+
import { authenticate } from "./authHandshake.js";
|
|
19
|
+
import { getServerVersion, compareVersions } from "../shared/version.js";
|
|
20
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
21
|
+
const JSONRPC_VERSION = "2.0";
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
23
|
+
// Reconnect tuning. 2^6 = 64s clamped to 60s ceiling, so the attempt
|
|
24
|
+
// progression is 1, 2, 4, 8, 16, 32, 60, 60, ... seconds. The per-call
|
|
25
|
+
// await ceiling (CALL_AWAIT_RECONNECT_MS) bounds how long a fresh call()
|
|
26
|
+
// will wait for the bridge to come back before rejecting DISCONNECTED —
|
|
27
|
+
// covers the first two backoff rungs (1s + 2s) plus editor-restart
|
|
28
|
+
// settle margin.
|
|
29
|
+
const RECONNECT_BASE_MS = 1000;
|
|
30
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
31
|
+
const RECONNECT_MAX_ATTEMPT = 6;
|
|
32
|
+
const CALL_AWAIT_RECONNECT_MS = 10_000;
|
|
33
|
+
// ── Channel (WebSocket wrapper with reconnect) ───────────────────────
|
|
34
|
+
export function createChannel(url, projectPath, onAuthResolved, onNotification, opts) {
|
|
35
|
+
const noReconnect = opts?.noReconnect ?? false;
|
|
36
|
+
const connectTimeout = opts?.connectTimeoutMs ?? 30_000;
|
|
37
|
+
// Skip the server/toolkit version-compat check on the runtime channel — the
|
|
38
|
+
// editor connection runs the authoritative check, and the runtime auth ack
|
|
39
|
+
// carries no version, so re-checking only false-warns about the same plugin.
|
|
40
|
+
const skipVersionCheck = opts?.skipVersionCheck ?? false;
|
|
41
|
+
const pending = new Map();
|
|
42
|
+
const openWaiters = new Set();
|
|
43
|
+
let ws = undefined;
|
|
44
|
+
let connectPromise = undefined;
|
|
45
|
+
let reconnectTimer = undefined;
|
|
46
|
+
let attempt = 0;
|
|
47
|
+
let closed = false;
|
|
48
|
+
// Cold vs hot disconnect distinction. Until the first successful open we
|
|
49
|
+
// treat connect failures as terminal (CONNECT_FAILED) so callers like
|
|
50
|
+
// `callRuntime` can map "game never started" to GAME_NOT_RUNNING in ms,
|
|
51
|
+
// not after a 10s reconnect-await ceiling. Once we've connected once we
|
|
52
|
+
// assume the peer exists and ride out transient drops with backoff.
|
|
53
|
+
// When noReconnect is set (runtime channels), disconnect is always terminal.
|
|
54
|
+
let hasConnectedOnce = false;
|
|
55
|
+
/** Send a fire-and-forget JSON-RPC notification to the toolkit (no id, no response). */
|
|
56
|
+
function sendNotification(method, params) {
|
|
57
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
58
|
+
ws.send(JSON.stringify({ jsonrpc: JSONRPC_VERSION, method, params }));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Cancel an in-flight bridge call: clear its timeout, reject the promise, and
|
|
62
|
+
* notify the toolkit so cooperative cancellation can bail out early. */
|
|
63
|
+
function cancelPending(id) {
|
|
64
|
+
const pendingRequest = pending.get(id);
|
|
65
|
+
if (pendingRequest) {
|
|
66
|
+
clearTimeout(pendingRequest.timer);
|
|
67
|
+
pendingRequest.reject(new BridgeError("CANCELLED", "Request cancelled by client"));
|
|
68
|
+
pending.delete(id);
|
|
69
|
+
}
|
|
70
|
+
// Always notify toolkit — fire-and-forget even if pending already resolved.
|
|
71
|
+
sendNotification("_cancel", { request_id: id });
|
|
72
|
+
}
|
|
73
|
+
function rejectAllPending(code, message) {
|
|
74
|
+
for (const [id, pendingRequest] of pending) {
|
|
75
|
+
clearTimeout(pendingRequest.timer);
|
|
76
|
+
pendingRequest.reject(new BridgeError(code, message));
|
|
77
|
+
pending.delete(id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function resolveAllWaiters(socket) {
|
|
81
|
+
for (const waiter of openWaiters) {
|
|
82
|
+
clearTimeout(waiter.timer);
|
|
83
|
+
waiter.resolve(socket);
|
|
84
|
+
}
|
|
85
|
+
openWaiters.clear();
|
|
86
|
+
}
|
|
87
|
+
function rejectAllWaiters(code, message) {
|
|
88
|
+
for (const waiter of openWaiters) {
|
|
89
|
+
clearTimeout(waiter.timer);
|
|
90
|
+
waiter.reject(new BridgeError(code, message));
|
|
91
|
+
}
|
|
92
|
+
openWaiters.clear();
|
|
93
|
+
}
|
|
94
|
+
function scheduleReconnect() {
|
|
95
|
+
if (closed)
|
|
96
|
+
return;
|
|
97
|
+
// Runtime channels (noReconnect): game is dead, don't try again.
|
|
98
|
+
// Reject all waiters immediately so callers get DISCONNECTED fast.
|
|
99
|
+
if (noReconnect) {
|
|
100
|
+
rejectAllWaiters("DISCONNECTED", `${url} disconnected (no reconnect — runtime channel)`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (reconnectTimer)
|
|
104
|
+
return;
|
|
105
|
+
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** attempt);
|
|
106
|
+
attempt = Math.min(attempt + 1, RECONNECT_MAX_ATTEMPT);
|
|
107
|
+
process.stderr.write(`[bridge] ${url} disconnected; reconnect in ${delay}ms (attempt ${attempt})\n`);
|
|
108
|
+
reconnectTimer = setTimeout(() => {
|
|
109
|
+
reconnectTimer = undefined;
|
|
110
|
+
if (closed)
|
|
111
|
+
return;
|
|
112
|
+
// Failure path schedules the next attempt itself via socket.error.
|
|
113
|
+
void connect().catch(() => { });
|
|
114
|
+
}, delay);
|
|
115
|
+
reconnectTimer.unref?.();
|
|
116
|
+
}
|
|
117
|
+
/** Authenticate after the socket opens, then resolve the connect promise. */
|
|
118
|
+
async function performAuth(socket, resolve, reject) {
|
|
119
|
+
const wasReconnect = hasConnectedOnce;
|
|
120
|
+
process.stderr.write(`[bridge] ${url} ${wasReconnect ? "reconnected" : "connected"}, authenticating…\n`);
|
|
121
|
+
try {
|
|
122
|
+
// Re-read token from disk on every connect (including reconnects)
|
|
123
|
+
// so rotated tokens after a plugin restart are picked up.
|
|
124
|
+
const token = await readToken(projectPath);
|
|
125
|
+
const authResp = await authenticate(socket, token);
|
|
126
|
+
hasConnectedOnce = true;
|
|
127
|
+
if (authResp.godotVersion && onAuthResolved)
|
|
128
|
+
onAuthResolved({ version: authResp.godotVersion, headless: authResp.headless });
|
|
129
|
+
const verNote = authResp.godotVersion ? ` (Godot ${authResp.godotVersion})` : "";
|
|
130
|
+
process.stderr.write(`[bridge] ${url} authenticated${verNote}\n`);
|
|
131
|
+
// Version mismatch check — human-only (stderr), nothing on MCP wire.
|
|
132
|
+
if (!skipVersionCheck) {
|
|
133
|
+
const serverVer = getServerVersion();
|
|
134
|
+
const severity = compareVersions(serverVer, authResp.toolkitVersion);
|
|
135
|
+
if (severity === "major") {
|
|
136
|
+
process.stderr.write(`[bridge] ERROR: major version mismatch — server ${serverVer}, toolkit ${authResp.toolkitVersion}. Update both to the same major version.\n`);
|
|
137
|
+
}
|
|
138
|
+
else if (severity === "minor") {
|
|
139
|
+
process.stderr.write(`[bridge] WARNING: version mismatch — server ${serverVer}, toolkit ${authResp.toolkitVersion}. Consider updating.\n`);
|
|
140
|
+
}
|
|
141
|
+
else if (severity === "unknown") {
|
|
142
|
+
process.stderr.write(`[bridge] WARNING: toolkit did not report version (pre-handshake build?). Consider updating the toolkit plugin.\n`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Notify on reconnect so the server can re-read config.
|
|
146
|
+
if (wasReconnect) {
|
|
147
|
+
onNotification?.()?.("config_reloaded", { reconnect: true });
|
|
148
|
+
}
|
|
149
|
+
resolveAllWaiters(socket);
|
|
150
|
+
resolve(socket);
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
ws = undefined;
|
|
154
|
+
socket.close();
|
|
155
|
+
const error = err instanceof BridgeError ? err : new BridgeError("AUTH_FAILED", err.message);
|
|
156
|
+
rejectAllWaiters(error.code, error.message);
|
|
157
|
+
reject(error);
|
|
158
|
+
if (!closed)
|
|
159
|
+
scheduleReconnect();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function connect() {
|
|
163
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
164
|
+
return Promise.resolve(ws);
|
|
165
|
+
if (connectPromise)
|
|
166
|
+
return connectPromise;
|
|
167
|
+
connectPromise = new Promise((resolve, reject) => {
|
|
168
|
+
const socket = new WebSocket(url);
|
|
169
|
+
ws = socket;
|
|
170
|
+
// Connection timeout: if neither `open` nor `error` fires within the
|
|
171
|
+
// deadline, tear down the attempt. Without this, connect() hangs
|
|
172
|
+
// indefinitely. Editor channels use 30s (slow startups); runtime
|
|
173
|
+
// channels use 10s (game is either there or dead).
|
|
174
|
+
const connectTimer = setTimeout(() => {
|
|
175
|
+
connectPromise = undefined;
|
|
176
|
+
ws = undefined;
|
|
177
|
+
socket.removeAllListeners();
|
|
178
|
+
socket.close();
|
|
179
|
+
const error = new BridgeError("CONNECT_FAILED", `WebSocket connection to ${url} timed out (${connectTimeout / 1000}s)`);
|
|
180
|
+
scheduleReconnect();
|
|
181
|
+
reject(error);
|
|
182
|
+
}, connectTimeout);
|
|
183
|
+
connectTimer.unref?.();
|
|
184
|
+
socket.once("open", () => {
|
|
185
|
+
clearTimeout(connectTimer);
|
|
186
|
+
connectPromise = undefined;
|
|
187
|
+
if (reconnectTimer) {
|
|
188
|
+
clearTimeout(reconnectTimer);
|
|
189
|
+
reconnectTimer = undefined;
|
|
190
|
+
}
|
|
191
|
+
// Note: attempt is reset on successful message round-trip (below),
|
|
192
|
+
// not on open — open alone isn't proof the peer is healthy. A
|
|
193
|
+
// half-broken peer that accepts then immediately closes the socket
|
|
194
|
+
// would otherwise reset backoff every cycle.
|
|
195
|
+
void performAuth(socket, resolve, reject);
|
|
196
|
+
});
|
|
197
|
+
socket.once("error", (err) => {
|
|
198
|
+
clearTimeout(connectTimer);
|
|
199
|
+
connectPromise = undefined;
|
|
200
|
+
ws = undefined;
|
|
201
|
+
const error = new BridgeError("CONNECT_FAILED", `WebSocket error: ${err.message}`);
|
|
202
|
+
// Hot path: keep waiters alive across this failure — they'll either
|
|
203
|
+
// be picked up by a later successful reconnect or hit their per-call
|
|
204
|
+
// 10s timer. Cold path: the connect()'s rejection here propagates
|
|
205
|
+
// through awaitOpenSocket → call() so callers see CONNECT_FAILED.
|
|
206
|
+
scheduleReconnect();
|
|
207
|
+
reject(error);
|
|
208
|
+
});
|
|
209
|
+
socket.on("message", (data) => {
|
|
210
|
+
let message;
|
|
211
|
+
try {
|
|
212
|
+
message = JSON.parse(data.toString());
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Plugin notification (no JSON-RPC id, has notification field).
|
|
218
|
+
if (message.notification && message.id == null) {
|
|
219
|
+
process.stderr.write(`[godot-mcp] plugin notification: ${message.notification}\n`);
|
|
220
|
+
onNotification?.()?.(message.notification, message.params);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
// Mutation-queue notifications from the toolkit: reset the pending
|
|
224
|
+
// request's timeout so queued commands don't time out on the sidecar.
|
|
225
|
+
const method = message.method;
|
|
226
|
+
if ((method === "_queued" || method === "_executing") && message.id == null) {
|
|
227
|
+
const reqId = String(message.params?.request_id ?? "");
|
|
228
|
+
const queuedPending = pending.get(reqId);
|
|
229
|
+
if (queuedPending) {
|
|
230
|
+
clearTimeout(queuedPending.timer);
|
|
231
|
+
queuedPending.timer = setTimeout(() => {
|
|
232
|
+
pending.delete(reqId);
|
|
233
|
+
if (method === "_queued") {
|
|
234
|
+
// The call was accepted and queued behind other mutations (fair
|
|
235
|
+
// FIFO), then ran out its window before its turn — not a stall.
|
|
236
|
+
// Mark it so the error mapper serves the serialization-specific
|
|
237
|
+
// hint, not the generic "editor is busy" one. Code stays TIMEOUT.
|
|
238
|
+
const queuedTimeoutError = new BridgeError("TIMEOUT", `call accepted and serialized behind other mutations; exceeded its ${queuedPending.timeoutMs}ms window while still queued`);
|
|
239
|
+
queuedTimeoutError.serializedQueueTimeout = true;
|
|
240
|
+
queuedPending.reject(queuedTimeoutError);
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
// Post-_executing: the call was RUNNING when its window closed —
|
|
244
|
+
// genuinely slow, not queue wait. Generic timeout shape, no marker.
|
|
245
|
+
queuedPending.reject(new BridgeError("TIMEOUT", `call timed out after ${queuedPending.timeoutMs}ms (post-${method})`));
|
|
246
|
+
}
|
|
247
|
+
}, queuedPending.timeoutMs);
|
|
248
|
+
}
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const id = message.id;
|
|
252
|
+
if (id == null)
|
|
253
|
+
return;
|
|
254
|
+
const key = String(id);
|
|
255
|
+
const pendingRequest = pending.get(key);
|
|
256
|
+
if (!pendingRequest)
|
|
257
|
+
return;
|
|
258
|
+
clearTimeout(pendingRequest.timer);
|
|
259
|
+
pending.delete(key);
|
|
260
|
+
// Reset backoff on successful round-trip.
|
|
261
|
+
attempt = 0;
|
|
262
|
+
if (message.error) {
|
|
263
|
+
pendingRequest.reject(new BridgeError("RPC_ERROR", `${message.error.code}: ${message.error.message}`));
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
pendingRequest.resolve(message.result);
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
socket.on("close", () => {
|
|
270
|
+
ws = undefined;
|
|
271
|
+
rejectAllPending("DISCONNECTED", "WebSocket closed before response");
|
|
272
|
+
// Auto-reconnect unless the user explicitly closed us.
|
|
273
|
+
if (!closed)
|
|
274
|
+
scheduleReconnect();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
return connectPromise;
|
|
278
|
+
}
|
|
279
|
+
function awaitOpenSocket(maxMs) {
|
|
280
|
+
if (ws && ws.readyState === WebSocket.OPEN)
|
|
281
|
+
return Promise.resolve(ws);
|
|
282
|
+
// Cold path: never been connected. Surface failure immediately so
|
|
283
|
+
// GAME_NOT_RUNNING / first-call-against-dead-editor stay snappy.
|
|
284
|
+
if (!hasConnectedOnce)
|
|
285
|
+
return connect();
|
|
286
|
+
// Hot path: was connected, dropped. Wait up to maxMs for either an
|
|
287
|
+
// in-flight reconnect to succeed or our timer to fire DISCONNECTED.
|
|
288
|
+
return new Promise((resolve, reject) => {
|
|
289
|
+
const timer = setTimeout(() => {
|
|
290
|
+
openWaiters.delete(waiter);
|
|
291
|
+
reject(new BridgeError("DISCONNECTED", `no connection to ${url} after ${maxMs}ms`));
|
|
292
|
+
}, maxMs);
|
|
293
|
+
const waiter = { resolve, reject, timer };
|
|
294
|
+
openWaiters.add(waiter);
|
|
295
|
+
// If neither a connect attempt nor a backoff timer is in flight,
|
|
296
|
+
// kick off a connect now. Otherwise the existing one will eventually
|
|
297
|
+
// call resolveAllWaiters or scheduleReconnect.
|
|
298
|
+
if (!connectPromise && !reconnectTimer) {
|
|
299
|
+
void connect().catch(() => { });
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
async call(method, params = null, timeoutMs = DEFAULT_TIMEOUT_MS, signal) {
|
|
305
|
+
if (closed)
|
|
306
|
+
throw new BridgeError("CLOSED", "channel is closed");
|
|
307
|
+
// Pre-aborted guard: if the signal was already aborted before we start,
|
|
308
|
+
// throw immediately to avoid half-initialized pending state.
|
|
309
|
+
if (signal?.aborted)
|
|
310
|
+
throw new BridgeError("CANCELLED", "Request already cancelled");
|
|
311
|
+
const socket = ws && ws.readyState === WebSocket.OPEN ? ws : await awaitOpenSocket(CALL_AWAIT_RECONNECT_MS);
|
|
312
|
+
const id = randomUUID();
|
|
313
|
+
const payload = JSON.stringify({ jsonrpc: JSONRPC_VERSION, id, method, params });
|
|
314
|
+
return new Promise((resolve, reject) => {
|
|
315
|
+
const timer = setTimeout(() => {
|
|
316
|
+
pending.delete(id);
|
|
317
|
+
reject(new BridgeError("TIMEOUT", `call to ${method} timed out after ${timeoutMs}ms`));
|
|
318
|
+
}, timeoutMs);
|
|
319
|
+
pending.set(id, { resolve, reject, timer, timeoutMs });
|
|
320
|
+
// Wire up cooperative cancellation: when the MCP client cancels the
|
|
321
|
+
// request, the SDK aborts this signal, which triggers cancelPending.
|
|
322
|
+
signal?.addEventListener("abort", () => cancelPending(id), { once: true });
|
|
323
|
+
socket.send(payload, (err) => {
|
|
324
|
+
if (err) {
|
|
325
|
+
const pendingRequest = pending.get(id);
|
|
326
|
+
if (pendingRequest) {
|
|
327
|
+
clearTimeout(pendingRequest.timer);
|
|
328
|
+
pending.delete(id);
|
|
329
|
+
reject(new BridgeError("SEND_FAILED", err.message));
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
});
|
|
334
|
+
},
|
|
335
|
+
async close() {
|
|
336
|
+
closed = true;
|
|
337
|
+
if (reconnectTimer) {
|
|
338
|
+
clearTimeout(reconnectTimer);
|
|
339
|
+
reconnectTimer = undefined;
|
|
340
|
+
}
|
|
341
|
+
rejectAllPending("CLOSED", "channel closed by caller");
|
|
342
|
+
rejectAllWaiters("CLOSED", "channel closed by caller");
|
|
343
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
344
|
+
await new Promise((resolve) => {
|
|
345
|
+
ws.once("close", () => resolve());
|
|
346
|
+
ws.close();
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
ws = undefined;
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A generic, bridge-agnostic liveness primitive: a self-stopping interval that
|
|
3
|
+
* probes a resource, counts consecutive failures, and runs the owner's teardown
|
|
4
|
+
* after a threshold. It knows nothing of channels, ports, or the runtime — the
|
|
5
|
+
* resource-specific bits (the probe, the liveness check, the teardown) are
|
|
6
|
+
* injected through `ping`/`isAlive`/`onDead`, so the timer policy is
|
|
7
|
+
* unit-testable in isolation and reusable. The bridge composes it as the
|
|
8
|
+
* frozen-game detector for the playtest runtime channel.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Build a liveness heartbeat. On each tick — before probing — it checks
|
|
12
|
+
* `isAlive`: a false result stops the timer and skips the probe with NO failure
|
|
13
|
+
* increment (the load-bearing self-stop guard for a resource that vanished
|
|
14
|
+
* out-of-band). Otherwise it `await`s `ping`; a resolve resets the
|
|
15
|
+
* consecutive-failure counter, a reject increments it, and reaching
|
|
16
|
+
* `maxFailures` fires `onDead` once (the owner's teardown) then stops.
|
|
17
|
+
*/
|
|
18
|
+
export function createHeartbeat(opts) {
|
|
19
|
+
const { ping, isAlive, onDead, intervalMs, maxFailures } = opts;
|
|
20
|
+
// Closure state, constructed per primitive (no module-level mutable).
|
|
21
|
+
let interval = undefined;
|
|
22
|
+
let failures = 0;
|
|
23
|
+
function start() {
|
|
24
|
+
if (interval)
|
|
25
|
+
return;
|
|
26
|
+
failures = 0;
|
|
27
|
+
interval = setInterval(async () => {
|
|
28
|
+
if (!isAlive()) {
|
|
29
|
+
stop();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
await ping();
|
|
34
|
+
failures = 0;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
failures++;
|
|
38
|
+
if (failures >= maxFailures) {
|
|
39
|
+
onDead();
|
|
40
|
+
stop();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}, intervalMs);
|
|
44
|
+
interval.unref?.();
|
|
45
|
+
}
|
|
46
|
+
function stop() {
|
|
47
|
+
if (interval) {
|
|
48
|
+
clearInterval(interval);
|
|
49
|
+
interval = undefined;
|
|
50
|
+
}
|
|
51
|
+
failures = 0;
|
|
52
|
+
}
|
|
53
|
+
return { start, stop };
|
|
54
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The playtest runtime-connection aggregate.
|
|
3
|
+
*
|
|
4
|
+
* Owns the ephemeral playtest channel and everything that keeps it consistent —
|
|
5
|
+
* the (runtimeChannel, cachedRuntimePort) pair, the registry watcher that
|
|
6
|
+
* auto-connects/tears-down on playtest start/stop, the port-waiters that
|
|
7
|
+
* waitForRuntimeConnection parks on, and a composed heartbeat that proactively
|
|
8
|
+
* clears a frozen game. One reason to change: the runtime-process connection
|
|
9
|
+
* lifecycle (discover → connect → watch-for-freeze → tear down a playtest
|
|
10
|
+
* channel). It touches neither version state nor notification routing — that is
|
|
11
|
+
* why it carves cleanly out of the composition root.
|
|
12
|
+
*
|
|
13
|
+
* The channel primitive, the heartbeat primitive, and the six registry reader
|
|
14
|
+
* fns are injected through an optional `deps` seam (production default = the
|
|
15
|
+
* real imports) so the aggregate is unit-testable in isolation with fakes. The
|
|
16
|
+
* seam destructures `deps ?? REAL` at the top into the same bare names the
|
|
17
|
+
* bodies reference.
|
|
18
|
+
*
|
|
19
|
+
* Deps: channel (createChannel, Channel), heartbeat (createHeartbeat), registry
|
|
20
|
+
* (six reader fns), errors (BridgeError). The composition root (bridge.ts)
|
|
21
|
+
* constructs one createRuntimeConnection and delegates its callRuntime /
|
|
22
|
+
* waitForRuntimeConnection / clearRuntime facade methods to it.
|
|
23
|
+
*/
|
|
24
|
+
import { createChannel as realCreateChannel } from "./channel.js";
|
|
25
|
+
import { createHeartbeat as realCreateHeartbeat } from "./heartbeat.js";
|
|
26
|
+
import * as realRegistry from "../registry.js";
|
|
27
|
+
import { BridgeError } from "../shared/errors.js";
|
|
28
|
+
export function createRuntimeConnection(opts, deps) {
|
|
29
|
+
// DI seam: destructure the injected (or real) collaborators into the same
|
|
30
|
+
// bare names the bodies reference. The real imports are aliased (real*) so
|
|
31
|
+
// this default object can name them without shadowing the destructured locals
|
|
32
|
+
// (a temporal-dead-zone hazard otherwise).
|
|
33
|
+
const { createChannel, createHeartbeat, registry } = deps ?? {
|
|
34
|
+
createChannel: realCreateChannel,
|
|
35
|
+
createHeartbeat: realCreateHeartbeat,
|
|
36
|
+
registry: realRegistry,
|
|
37
|
+
};
|
|
38
|
+
const { discoverRuntime, normalizePath, watchRegistry, unwatchRegistry, isWatcherActive, getCachedRuntimePort } = registry;
|
|
39
|
+
const projectPath = opts.projectPath;
|
|
40
|
+
// ── Runtime channel management ───────────────────────────────────
|
|
41
|
+
// When an explicit port is set, create a static channel. Otherwise,
|
|
42
|
+
// callRuntime re-reads the registry on each invocation to pick up
|
|
43
|
+
// newly-started playtests. The channel is cached and recreated only
|
|
44
|
+
// when the port changes.
|
|
45
|
+
// Single construction point for all runtime channels (no reconnect, 10s connect timeout).
|
|
46
|
+
const createRuntimeChannel = (port) => createChannel(`ws://127.0.0.1:${port}`, projectPath, undefined, undefined, {
|
|
47
|
+
noReconnect: true,
|
|
48
|
+
connectTimeoutMs: 10_000,
|
|
49
|
+
// The editor connection runs the authoritative server/toolkit version
|
|
50
|
+
// check; the runtime ack carries no version, so skip the redundant
|
|
51
|
+
// (false-positive) check on the runtime channel.
|
|
52
|
+
skipVersionCheck: true,
|
|
53
|
+
});
|
|
54
|
+
let runtimeChannel = opts?.explicitRuntimePort
|
|
55
|
+
? createRuntimeChannel(opts.explicitRuntimePort)
|
|
56
|
+
: undefined;
|
|
57
|
+
let cachedRuntimePort = opts?.explicitRuntimePort ? Number(opts.explicitRuntimePort) : undefined;
|
|
58
|
+
let runtimePortResolvers = [];
|
|
59
|
+
// ── Runtime heartbeat (frozen-game detection) ───────────────────
|
|
60
|
+
// Pings the runtime every 15s with a 10s timeout. Four consecutive
|
|
61
|
+
// failures (~60s unresponsive) → proactive teardown. The generous
|
|
62
|
+
// threshold avoids false positives on poorly-optimized games running
|
|
63
|
+
// at very low FPS. True freezes (infinite loop) will never respond.
|
|
64
|
+
// The generic timer/threshold policy lives in heartbeat.ts; the
|
|
65
|
+
// runtime-state probe + teardown are injected here.
|
|
66
|
+
const heartbeat = createHeartbeat({
|
|
67
|
+
// The probe bakes its own 10s timeout (the channel call self-rejects at
|
|
68
|
+
// 10s), so createHeartbeat runs no timeout race of its own.
|
|
69
|
+
ping: () => runtimeChannel.call("ping", null, 10_000),
|
|
70
|
+
// Load-bearing self-stop guard: callRuntime can clear runtimeChannel
|
|
71
|
+
// WITHOUT stopping us (it relies on the next tick seeing this and
|
|
72
|
+
// self-stopping — no failure counted).
|
|
73
|
+
isAlive: () => runtimeChannel !== undefined,
|
|
74
|
+
onDead: () => {
|
|
75
|
+
process.stderr.write("[bridge] heartbeat failed 4x (~60s) — runtime dead/frozen, clearing\n");
|
|
76
|
+
if (runtimeChannel) {
|
|
77
|
+
void runtimeChannel.close();
|
|
78
|
+
runtimeChannel = undefined;
|
|
79
|
+
cachedRuntimePort = undefined;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
intervalMs: 15_000,
|
|
83
|
+
maxFailures: 4,
|
|
84
|
+
});
|
|
85
|
+
// ── Registry watcher for instant runtime discovery ─────────────
|
|
86
|
+
// fs.watch on projects.json auto-connects to new runtime ports and
|
|
87
|
+
// tears down stale channels. Replaces per-RPC file reads in
|
|
88
|
+
// callRuntime with in-memory lookups (Path A). Falls back to
|
|
89
|
+
// per-RPC reads when fs.watch is unavailable (Path B).
|
|
90
|
+
if (projectPath && !opts?.explicitRuntimePort) {
|
|
91
|
+
const normalizedProject = normalizePath(projectPath);
|
|
92
|
+
watchRegistry({
|
|
93
|
+
onDiscovered: (discoveredPath, port) => {
|
|
94
|
+
if (discoveredPath !== normalizedProject)
|
|
95
|
+
return;
|
|
96
|
+
process.stderr.write(`[bridge] runtime discovered on port ${port}\n`);
|
|
97
|
+
if (runtimeChannel)
|
|
98
|
+
void runtimeChannel.close();
|
|
99
|
+
runtimeChannel = createRuntimeChannel(port);
|
|
100
|
+
cachedRuntimePort = port;
|
|
101
|
+
heartbeat.start();
|
|
102
|
+
// Notify any pending waitForRuntimeConnection callers.
|
|
103
|
+
const resolvers = runtimePortResolvers;
|
|
104
|
+
runtimePortResolvers = [];
|
|
105
|
+
for (const resolve of resolvers)
|
|
106
|
+
resolve(port);
|
|
107
|
+
},
|
|
108
|
+
onRemoved: (removedPath) => {
|
|
109
|
+
if (removedPath !== normalizedProject)
|
|
110
|
+
return;
|
|
111
|
+
process.stderr.write(`[bridge] runtime removed\n`);
|
|
112
|
+
heartbeat.stop();
|
|
113
|
+
if (runtimeChannel) {
|
|
114
|
+
void runtimeChannel.close();
|
|
115
|
+
runtimeChannel = undefined;
|
|
116
|
+
cachedRuntimePort = undefined;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
async callRuntime(method, params, timeoutMs, signal) {
|
|
123
|
+
// Static port override — same as explicit-port behaviour.
|
|
124
|
+
if (opts?.explicitRuntimePort) {
|
|
125
|
+
// The pinned channel is built once at construct, but clearRuntime()
|
|
126
|
+
// (game_stopped notification on game stop/crash) nulls it. Unlike the
|
|
127
|
+
// discovery path there is no watcher to rebuild it, so lazily re-create
|
|
128
|
+
// it against the pinned port here — otherwise the next call dereferences
|
|
129
|
+
// an undefined channel and every runtime tool is dead until restart.
|
|
130
|
+
if (!runtimeChannel) {
|
|
131
|
+
runtimeChannel = createRuntimeChannel(opts.explicitRuntimePort);
|
|
132
|
+
cachedRuntimePort = Number(opts.explicitRuntimePort);
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
return await runtimeChannel.call(method, params, timeoutMs, signal);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
if (err instanceof BridgeError && (err.code === "CONNECT_FAILED" || err.code === "DISCONNECTED")) {
|
|
139
|
+
throw new BridgeError("GAME_NOT_RUNNING", `no runtime server on 127.0.0.1:${opts.explicitRuntimePort} — start the game in the editor (F5) with a debug build`);
|
|
140
|
+
}
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Registry-based discovery.
|
|
145
|
+
if (!projectPath) {
|
|
146
|
+
throw new BridgeError("GAME_NOT_RUNNING", "no runtime port configured and no project path for registry lookup");
|
|
147
|
+
}
|
|
148
|
+
// Fast path: if clearRuntime() was called (game_stopped notification),
|
|
149
|
+
// trust it over the potentially-stale registry cache. The registry
|
|
150
|
+
// watcher has a 100ms debounce — during that window getCachedRuntimePort
|
|
151
|
+
// still returns the old port. Don't create a doomed channel to it.
|
|
152
|
+
if (!runtimeChannel && cachedRuntimePort === undefined) {
|
|
153
|
+
const freshPort = isWatcherActive() ? getCachedRuntimePort(projectPath) : discoverRuntime(projectPath);
|
|
154
|
+
if (freshPort === null) {
|
|
155
|
+
throw new BridgeError("GAME_NOT_RUNNING", "no runtime_port in registry — start the game in the editor (F5) with a debug build");
|
|
156
|
+
}
|
|
157
|
+
// Registry still has a port — either watcher is stale (race) or a new
|
|
158
|
+
// game started before we ran. Re-read the file to break the debounce.
|
|
159
|
+
const diskPort = discoverRuntime(projectPath);
|
|
160
|
+
if (diskPort === null) {
|
|
161
|
+
throw new BridgeError("GAME_NOT_RUNNING", "game stopped (runtime cleared by notification, registry not yet updated)");
|
|
162
|
+
}
|
|
163
|
+
// Disk confirms a port exists — new game started. Create channel.
|
|
164
|
+
runtimeChannel = createRuntimeChannel(diskPort);
|
|
165
|
+
cachedRuntimePort = diskPort;
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
// Normal path: consult registry cache.
|
|
169
|
+
const currentPort = isWatcherActive() ? getCachedRuntimePort(projectPath) : discoverRuntime(projectPath);
|
|
170
|
+
if (currentPort === null) {
|
|
171
|
+
// No playtest running — close stale channel and reject immediately.
|
|
172
|
+
if (runtimeChannel) {
|
|
173
|
+
await runtimeChannel.close();
|
|
174
|
+
runtimeChannel = undefined;
|
|
175
|
+
cachedRuntimePort = undefined;
|
|
176
|
+
}
|
|
177
|
+
throw new BridgeError("GAME_NOT_RUNNING", "no runtime_port in registry — start the game in the editor (F5) with a debug build");
|
|
178
|
+
}
|
|
179
|
+
// Port changed (new playtest or different runtime instance).
|
|
180
|
+
if (currentPort !== cachedRuntimePort) {
|
|
181
|
+
if (runtimeChannel)
|
|
182
|
+
await runtimeChannel.close();
|
|
183
|
+
runtimeChannel = createRuntimeChannel(currentPort);
|
|
184
|
+
cachedRuntimePort = currentPort;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
return await runtimeChannel.call(method, params, timeoutMs, signal);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
if (err instanceof BridgeError && (err.code === "CONNECT_FAILED" || err.code === "DISCONNECTED")) {
|
|
192
|
+
const failedPort = cachedRuntimePort;
|
|
193
|
+
await runtimeChannel.close();
|
|
194
|
+
runtimeChannel = undefined;
|
|
195
|
+
cachedRuntimePort = undefined;
|
|
196
|
+
throw new BridgeError("GAME_NOT_RUNNING", `runtime server on port ${failedPort} is not responding — playtest may have ended`);
|
|
197
|
+
}
|
|
198
|
+
throw err;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
waitForRuntimeConnection(timeoutMs) {
|
|
202
|
+
if (!projectPath)
|
|
203
|
+
return Promise.resolve(undefined);
|
|
204
|
+
return new Promise((resolve) => {
|
|
205
|
+
const timer = setTimeout(() => {
|
|
206
|
+
runtimePortResolvers = runtimePortResolvers.filter((r) => r !== handler);
|
|
207
|
+
resolve(undefined);
|
|
208
|
+
}, timeoutMs);
|
|
209
|
+
timer.unref?.();
|
|
210
|
+
const handler = (port) => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
runtimePortResolvers = runtimePortResolvers.filter((r) => r !== handler);
|
|
213
|
+
resolve(port != null ? { port } : undefined);
|
|
214
|
+
};
|
|
215
|
+
runtimePortResolvers.push(handler);
|
|
216
|
+
});
|
|
217
|
+
},
|
|
218
|
+
clearRuntime() {
|
|
219
|
+
heartbeat.stop();
|
|
220
|
+
if (runtimeChannel) {
|
|
221
|
+
void runtimeChannel.close();
|
|
222
|
+
runtimeChannel = undefined;
|
|
223
|
+
cachedRuntimePort = undefined;
|
|
224
|
+
process.stderr.write("[bridge] runtime cleared (game_stopped notification)\n");
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
async close() {
|
|
228
|
+
heartbeat.stop();
|
|
229
|
+
// Resolve outstanding runtime-port waiters as undefined (bridge closing).
|
|
230
|
+
const resolvers = runtimePortResolvers;
|
|
231
|
+
runtimePortResolvers = [];
|
|
232
|
+
for (const resolve of resolvers)
|
|
233
|
+
resolve(undefined);
|
|
234
|
+
unwatchRegistry();
|
|
235
|
+
if (runtimeChannel)
|
|
236
|
+
await runtimeChannel.close();
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|