@vibevibes/runtime 0.2.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/dist/bundler.d.ts +36 -0
- package/dist/bundler.d.ts.map +1 -0
- package/dist/bundler.js +365 -0
- package/dist/bundler.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol.d.ts +92 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +241 -0
- package/dist/protocol.js.map +1 -0
- package/dist/server.d.ts +24 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +3339 -0
- package/dist/server.js.map +1 -0
- package/dist/tick-engine.d.ts +82 -0
- package/dist/tick-engine.d.ts.map +1 -0
- package/dist/tick-engine.js +153 -0
- package/dist/tick-engine.js.map +1 -0
- package/dist/viewer/index.html +1388 -0
- package/package.json +53 -0
- package/src/bundler.ts +429 -0
- package/src/index.ts +26 -0
- package/src/protocol.ts +324 -0
- package/src/server.ts +3738 -0
- package/src/tick-engine.ts +216 -0
- package/src/viewer/index.html +1388 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,3339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vibevibes/runtime — the server engine for vibevibes experiences.
|
|
3
|
+
*
|
|
4
|
+
* Single-experience architecture with tool gate, WebSocket broadcasts, and room spawning.
|
|
5
|
+
*
|
|
6
|
+
* The server loads one experience from the project root (manifest.json for protocol
|
|
7
|
+
* experiences, or src/index.tsx for TypeScript experiences). Multiple rooms can be
|
|
8
|
+
* spawned, all running the same experience.
|
|
9
|
+
*
|
|
10
|
+
* AI agents join rooms via MCP or HTTP.
|
|
11
|
+
*/
|
|
12
|
+
import express from "express";
|
|
13
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
14
|
+
import http from "http";
|
|
15
|
+
import path from "path";
|
|
16
|
+
import fs from "fs";
|
|
17
|
+
import { fileURLToPath } from "url";
|
|
18
|
+
import { z, ZodError } from "zod";
|
|
19
|
+
import { EventEmitter } from "events";
|
|
20
|
+
import { bundleForServer, bundleForClient, evalServerBundle, validateClientBundle } from "./bundler.js";
|
|
21
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
22
|
+
import { TickEngine } from "./tick-engine.js";
|
|
23
|
+
import { isProtocolExperience, loadProtocolManifest, createProtocolModule, SubprocessExecutor } from "./protocol.js";
|
|
24
|
+
import { createChatTools } from "@vibevibes/sdk";
|
|
25
|
+
function formatZodError(err, toolName, tool) {
|
|
26
|
+
const issues = err.issues.map((issue) => {
|
|
27
|
+
const path = issue.path.length > 0 ? `'${issue.path.join(".")}'` : "input";
|
|
28
|
+
const extra = [];
|
|
29
|
+
const detail = issue;
|
|
30
|
+
if (detail.expected)
|
|
31
|
+
extra.push(`expected ${detail.expected}`);
|
|
32
|
+
if (detail.received && detail.received !== "undefined")
|
|
33
|
+
extra.push(`got ${detail.received}`);
|
|
34
|
+
const suffix = extra.length > 0 ? ` (${extra.join(", ")})` : "";
|
|
35
|
+
return ` ${path}: ${issue.message}${suffix}`;
|
|
36
|
+
});
|
|
37
|
+
let msg = `Invalid input for '${toolName}':\n${issues.join("\n")}`;
|
|
38
|
+
if (tool?.input_schema) {
|
|
39
|
+
try {
|
|
40
|
+
const schema = (tool.input_schema._jsonSchema || zodToJsonSchema(tool.input_schema));
|
|
41
|
+
const props = schema.properties;
|
|
42
|
+
const req = schema.required || [];
|
|
43
|
+
if (props) {
|
|
44
|
+
const fields = Object.entries(props).map(([k, v]) => {
|
|
45
|
+
const optional = !req.includes(k);
|
|
46
|
+
return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
|
|
47
|
+
});
|
|
48
|
+
msg += `\n\nExpected schema: { ${fields.join(", ")} }`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
}
|
|
53
|
+
if (tool?.description)
|
|
54
|
+
msg += `\nTool description: ${tool.description}`;
|
|
55
|
+
msg += `\n\nHint: Provide all required fields with correct types.`;
|
|
56
|
+
return msg;
|
|
57
|
+
}
|
|
58
|
+
function formatHandlerError(err, toolName, tool, input) {
|
|
59
|
+
const message = err.message || String(err);
|
|
60
|
+
let msg = `Tool '${toolName}' failed: ${message}`;
|
|
61
|
+
if (input !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
msg += `\n\nInput provided: ${JSON.stringify(input)}`;
|
|
64
|
+
}
|
|
65
|
+
catch { }
|
|
66
|
+
}
|
|
67
|
+
if (tool?.input_schema) {
|
|
68
|
+
try {
|
|
69
|
+
const schema = (tool.input_schema._jsonSchema || zodToJsonSchema(tool.input_schema));
|
|
70
|
+
const props = schema.properties;
|
|
71
|
+
const req = schema.required || [];
|
|
72
|
+
if (props) {
|
|
73
|
+
const fields = Object.entries(props).map(([k, v]) => {
|
|
74
|
+
const optional = !req.includes(k);
|
|
75
|
+
return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
|
|
76
|
+
});
|
|
77
|
+
msg += `\nTool expects: { ${fields.join(", ")} }`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
}
|
|
82
|
+
// Pattern-match common errors for specific hints
|
|
83
|
+
if (message.includes("Cannot read properties of undefined") || message.includes("Cannot read property")) {
|
|
84
|
+
msg += `\n\nHint: The handler accessed a property that doesn't exist on the current state. Check that initialState includes all fields your tools read from.`;
|
|
85
|
+
}
|
|
86
|
+
else if (message.includes("is not a function")) {
|
|
87
|
+
msg += `\n\nHint: Something expected to be a function is not. Check for missing imports or incorrect variable types in the tool handler.`;
|
|
88
|
+
}
|
|
89
|
+
else if (message.includes("Maximum call stack")) {
|
|
90
|
+
msg += `\n\nHint: Infinite recursion detected. A tool handler or function is calling itself without a base case.`;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
msg += `\n\nHint: The tool handler threw an error. Check the handler logic and ensure the current state matches what the handler expects.`;
|
|
94
|
+
}
|
|
95
|
+
return msg;
|
|
96
|
+
}
|
|
97
|
+
/** Safely extract an error message from an unknown thrown value. */
|
|
98
|
+
function toErrorMessage(err) {
|
|
99
|
+
return err instanceof Error ? err.message : String(err);
|
|
100
|
+
}
|
|
101
|
+
/** Safely extract a string from an Express query parameter value. */
|
|
102
|
+
function queryString(val) {
|
|
103
|
+
return typeof val === "string" ? val : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** Safely parse an Express query parameter as an integer (returns 0 if absent/invalid). */
|
|
106
|
+
function queryInt(val, radix = 10) {
|
|
107
|
+
return typeof val === "string" ? (parseInt(val, radix) || 0) : 0;
|
|
108
|
+
}
|
|
109
|
+
const __runtimeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
110
|
+
// PROJECT_ROOT is set by startServer() from ServerConfig.projectRoot.
|
|
111
|
+
// It points to the experience project root (where src/, experiences/, registry live).
|
|
112
|
+
let PROJECT_ROOT = "";
|
|
113
|
+
// ── Custom error types ───────────────────────────────────────
|
|
114
|
+
class ToolNotFoundError extends Error {
|
|
115
|
+
constructor(message) { super(message); this.name = "ToolNotFoundError"; }
|
|
116
|
+
}
|
|
117
|
+
class ToolForbiddenError extends Error {
|
|
118
|
+
constructor(message) { super(message); this.name = "ToolForbiddenError"; }
|
|
119
|
+
}
|
|
120
|
+
class Room {
|
|
121
|
+
id;
|
|
122
|
+
experienceId;
|
|
123
|
+
sharedState = {};
|
|
124
|
+
participants = new Map();
|
|
125
|
+
events = [];
|
|
126
|
+
wsConnections = new Map(); // ws → actorId
|
|
127
|
+
kickedActors = new Set();
|
|
128
|
+
kickedOwners = new Set();
|
|
129
|
+
parentRoomId;
|
|
130
|
+
childRoomIds = [];
|
|
131
|
+
/** Immutable config set at spawn time. Defines this room's modality/parameters. */
|
|
132
|
+
config;
|
|
133
|
+
/** Pre-created rooms (from registry) are exempt from empty-room GC. */
|
|
134
|
+
preCreated = false;
|
|
135
|
+
/** Per-room promise chain to serialize tool execution and prevent state interleaving. */
|
|
136
|
+
_executionQueue = Promise.resolve();
|
|
137
|
+
/** Monotonically increasing version counter for delta broadcasting. */
|
|
138
|
+
stateVersion = 0;
|
|
139
|
+
/** Previous state snapshot for computing deltas. null = first broadcast sends full state. */
|
|
140
|
+
_prevState = null;
|
|
141
|
+
constructor(id, experienceId, initialState, config) {
|
|
142
|
+
this.id = id;
|
|
143
|
+
this.experienceId = experienceId;
|
|
144
|
+
this.config = Object.freeze(config || {});
|
|
145
|
+
if (initialState) {
|
|
146
|
+
this.sharedState = initialState;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
broadcastToAll(message) {
|
|
150
|
+
const data = JSON.stringify(message);
|
|
151
|
+
for (const ws of this.wsConnections.keys()) {
|
|
152
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
153
|
+
try {
|
|
154
|
+
ws.send(data);
|
|
155
|
+
}
|
|
156
|
+
catch { /* client disconnected mid-send */ }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Broadcast a state update using delta compression.
|
|
162
|
+
* Compares current sharedState to _prevState using top-level key reference equality.
|
|
163
|
+
* Sends only changed/deleted keys (delta mode), or full state if no previous snapshot.
|
|
164
|
+
* @param forceFullState - If true, always sends full state (used for reset).
|
|
165
|
+
*/
|
|
166
|
+
broadcastStateUpdate(extra, forceFullState = false) {
|
|
167
|
+
this.stateVersion++;
|
|
168
|
+
const prev = this._prevState;
|
|
169
|
+
this._prevState = this.sharedState;
|
|
170
|
+
// First broadcast or forced full state — send everything
|
|
171
|
+
if (!prev || forceFullState) {
|
|
172
|
+
this.broadcastToAll({
|
|
173
|
+
type: "shared_state_update",
|
|
174
|
+
roomId: this.id,
|
|
175
|
+
stateVersion: this.stateVersion,
|
|
176
|
+
state: this.sharedState,
|
|
177
|
+
...extra,
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
// Compute delta: compare top-level keys by reference equality (===)
|
|
182
|
+
const changed = {};
|
|
183
|
+
const deleted = [];
|
|
184
|
+
let changeCount = 0;
|
|
185
|
+
for (const key of Object.keys(this.sharedState)) {
|
|
186
|
+
if (this.sharedState[key] !== prev[key]) {
|
|
187
|
+
changed[key] = this.sharedState[key];
|
|
188
|
+
changeCount++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
for (const key of Object.keys(prev)) {
|
|
192
|
+
if (!(key in this.sharedState)) {
|
|
193
|
+
deleted.push(key);
|
|
194
|
+
changeCount++;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// No state changes — still broadcast if there's an event (tools that don't modify state)
|
|
198
|
+
if (changeCount === 0 && !extra.event)
|
|
199
|
+
return;
|
|
200
|
+
if (changeCount === 0) {
|
|
201
|
+
// Event-only broadcast (tool ran but didn't change state)
|
|
202
|
+
this.broadcastToAll({
|
|
203
|
+
type: "shared_state_update",
|
|
204
|
+
roomId: this.id,
|
|
205
|
+
stateVersion: this.stateVersion,
|
|
206
|
+
delta: {},
|
|
207
|
+
...extra,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
this.broadcastToAll({
|
|
212
|
+
type: "shared_state_update",
|
|
213
|
+
roomId: this.id,
|
|
214
|
+
stateVersion: this.stateVersion,
|
|
215
|
+
delta: changed,
|
|
216
|
+
...(deleted.length > 0 ? { deletedKeys: deleted } : {}),
|
|
217
|
+
...extra,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Reset delta tracking (call after room reset to force full state on next broadcast). */
|
|
222
|
+
resetDeltaTracking() {
|
|
223
|
+
this._prevState = null;
|
|
224
|
+
}
|
|
225
|
+
participantList() {
|
|
226
|
+
return Array.from(this.participants.keys());
|
|
227
|
+
}
|
|
228
|
+
/** Returns participant objects with actorId, type, owner, agentMode, and metadata (for WebSocket broadcasts and stop hook discovery). */
|
|
229
|
+
participantDetails() {
|
|
230
|
+
return Array.from(this.participants.entries()).map(([actorId, p]) => {
|
|
231
|
+
const detail = {
|
|
232
|
+
actorId,
|
|
233
|
+
type: p.type,
|
|
234
|
+
role: p.role,
|
|
235
|
+
owner: p.owner,
|
|
236
|
+
};
|
|
237
|
+
if (p.agentMode)
|
|
238
|
+
detail.agentMode = p.agentMode;
|
|
239
|
+
if (p.metadata && Object.keys(p.metadata).length > 0)
|
|
240
|
+
detail.metadata = p.metadata;
|
|
241
|
+
return detail;
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
appendEvent(event) {
|
|
245
|
+
this.events.push(event);
|
|
246
|
+
if (this.events.length > MAX_EVENTS_PER_ROOM) {
|
|
247
|
+
this.events.splice(0, this.events.length - MAX_EVENTS_PER_ROOM);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
/** Serialize an async operation against this room's tool execution queue.
|
|
251
|
+
* Prevents concurrent tool calls from interleaving on shared state. */
|
|
252
|
+
enqueueExecution(fn) {
|
|
253
|
+
const next = this._executionQueue.then(() => fn());
|
|
254
|
+
// Swallow rejections on the chain so one failed tool doesn't block the next
|
|
255
|
+
this._executionQueue = next.then(() => { }, () => { });
|
|
256
|
+
return next;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// ── Constants ─────────────────────────────────────────────
|
|
260
|
+
const DEFAULT_PORT = 4321;
|
|
261
|
+
const MAX_EVENTS_PER_ROOM = 200;
|
|
262
|
+
const JOIN_EVENT_HISTORY = 20;
|
|
263
|
+
const ROOM_STATE_EVENT_HISTORY = 50;
|
|
264
|
+
const HISTORY_DEFAULT_LIMIT = 50;
|
|
265
|
+
const HISTORY_MAX_LIMIT = 200;
|
|
266
|
+
const DEFAULT_STREAM_RATE_LIMIT = 60;
|
|
267
|
+
const STREAM_RATE_WINDOW_MS = 1000;
|
|
268
|
+
const EVENT_BATCH_DEBOUNCE_MS = 50;
|
|
269
|
+
const DEFAULT_TICK_RATE_MS = 50;
|
|
270
|
+
const MAX_BATCH_CALLS = 10;
|
|
271
|
+
const LONG_POLL_MAX_TIMEOUT_MS = 55000;
|
|
272
|
+
const AGENT_CONTEXT_MAX_TIMEOUT_MS = 10000;
|
|
273
|
+
const WS_MAX_PAYLOAD_BYTES = 1024 * 1024; // 1MB
|
|
274
|
+
const WS_EPHEMERAL_MAX_BYTES = 65536; // 64KB
|
|
275
|
+
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
276
|
+
const ROOM_GC_INTERVAL_MS = 60_000;
|
|
277
|
+
const IDEMPOTENCY_CLEANUP_INTERVAL_MS = 60000;
|
|
278
|
+
const SCREENSHOT_DEFAULT_TIMEOUT_MS = 10000;
|
|
279
|
+
const SCREENSHOT_MAX_TIMEOUT_MS = 30000;
|
|
280
|
+
const HOT_RELOAD_DEBOUNCE_MS = 300;
|
|
281
|
+
const WS_CLOSE_GRACE_MS = 3000; // Grace period before deleting participant on WS close (allows reconnect on refresh)
|
|
282
|
+
const JSON_BODY_LIMIT = "256kb";
|
|
283
|
+
const TOOL_HTTP_TIMEOUT_MS = 30_000;
|
|
284
|
+
const TOOL_REGEX_MAX_LENGTH = 100;
|
|
285
|
+
const ROOM_EVENTS_MAX_LISTENERS = 200;
|
|
286
|
+
const STREAM_RATE_LIMIT_STALE_MS = 5000;
|
|
287
|
+
const STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS = 10000;
|
|
288
|
+
const BLOB_CACHE_MAX_AGE_SECONDS = 300; // 5 minutes — blobs can be overwritten
|
|
289
|
+
// ── Default observe (used when experience has no observe function) ─────
|
|
290
|
+
function defaultObserve(state, _event, _actorId) {
|
|
291
|
+
const result = {};
|
|
292
|
+
for (const [k, v] of Object.entries(state)) {
|
|
293
|
+
if (!k.startsWith("_"))
|
|
294
|
+
result[k] = v;
|
|
295
|
+
}
|
|
296
|
+
const phase = typeof state.phase === "string" ? state.phase : null;
|
|
297
|
+
result.directive = phase ? `Current phase: ${phase}` : "Observe the current state and act accordingly.";
|
|
298
|
+
return result;
|
|
299
|
+
}
|
|
300
|
+
// ── Global state ──────────────────────────────────────────
|
|
301
|
+
const DEFAULT_ROOM_ID = "local";
|
|
302
|
+
let PORT = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
|
|
303
|
+
let publicUrl = null;
|
|
304
|
+
const rooms = new Map();
|
|
305
|
+
const tickEngines = new Map();
|
|
306
|
+
const roomLinks = [];
|
|
307
|
+
let _actorCounter = 0;
|
|
308
|
+
const agentMemory = new Map();
|
|
309
|
+
const roomEmptySince = new Map(); // roomId → timestamp when first noticed empty (for GC)
|
|
310
|
+
const roomEvents = new EventEmitter();
|
|
311
|
+
roomEvents.setMaxListeners(ROOM_EVENTS_MAX_LISTENERS);
|
|
312
|
+
/** Remove all parent-child room links involving the given roomId and update parent's childRoomIds. */
|
|
313
|
+
function cleanupRoomLinks(roomId, room) {
|
|
314
|
+
for (let i = roomLinks.length - 1; i >= 0; i--) {
|
|
315
|
+
if (roomLinks[i].childRoomId === roomId || roomLinks[i].parentRoomId === roomId) {
|
|
316
|
+
roomLinks.splice(i, 1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (room.parentRoomId) {
|
|
320
|
+
const parent = rooms.get(room.parentRoomId);
|
|
321
|
+
if (parent) {
|
|
322
|
+
const idx = parent.childRoomIds.indexOf(roomId);
|
|
323
|
+
if (idx !== -1)
|
|
324
|
+
parent.childRoomIds.splice(idx, 1);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
function roomNotFoundError(roomId) {
|
|
329
|
+
return `Room '${roomId}' not found. Use list_rooms to see available rooms.`;
|
|
330
|
+
}
|
|
331
|
+
/** Set no-cache response headers for dynamic content. */
|
|
332
|
+
function setNoCacheHeaders(res) {
|
|
333
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
334
|
+
res.setHeader("Pragma", "no-cache");
|
|
335
|
+
res.setHeader("Expires", "0");
|
|
336
|
+
}
|
|
337
|
+
/** Broadcast a presence update to all WebSocket clients in a room. */
|
|
338
|
+
function broadcastPresenceUpdate(room) {
|
|
339
|
+
room.broadcastToAll({
|
|
340
|
+
type: "presence_update",
|
|
341
|
+
participants: room.participantList(),
|
|
342
|
+
participantDetails: room.participantDetails(),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// ── Blob store ──────────────────────────────────────────
|
|
346
|
+
const blobStore = new Map();
|
|
347
|
+
const blobMeta = new Map();
|
|
348
|
+
const MAX_BLOB_SIZE = 10 * 1024 * 1024; // 10MB per blob
|
|
349
|
+
const MAX_TOTAL_BLOBS = 50 * 1024 * 1024; // 50MB total
|
|
350
|
+
// ── Experience cache (replaces single global `experience`) ──
|
|
351
|
+
const experienceCache = new Map();
|
|
352
|
+
const experienceErrors = new Map(); // Last build error per experience ID
|
|
353
|
+
let hostExperienceId = "";
|
|
354
|
+
// Hot-reload rebuild gate — bundle endpoints await this during in-progress rebuilds
|
|
355
|
+
let rebuildingResolve = null;
|
|
356
|
+
let rebuildingPromise = null;
|
|
357
|
+
// Spawn rate limiting: max 5 spawns per source room per 5 minutes
|
|
358
|
+
const spawnCounts = new Map();
|
|
359
|
+
const SPAWN_WINDOW_MS = 5 * 60 * 1000;
|
|
360
|
+
const MAX_SPAWNS_PER_WINDOW = 5;
|
|
361
|
+
const MAX_ROOMS = 100; // Hard cap on total rooms to prevent resource exhaustion
|
|
362
|
+
const pendingSpawns = new Set(); // Room IDs currently being spawned (TOCTOU guard)
|
|
363
|
+
const RESERVED_ROOM_IDS = new Set(["spawn", "config-schema", "local", "library", "rooms", "participants", "events", "memory", "agent-context", "screenshot", "blob", "__proto__", "constructor", "prototype"]);
|
|
364
|
+
// Stream rate limiting: per actor, per stream, per room
|
|
365
|
+
const streamRateLimits = new Map();
|
|
366
|
+
// Periodic cleanup for stream rate limits (every 10 seconds)
|
|
367
|
+
const _streamRateCleanupTimer = setInterval(() => {
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
for (const [key, entry] of streamRateLimits) {
|
|
370
|
+
if (now - entry.windowStart > STREAM_RATE_LIMIT_STALE_MS)
|
|
371
|
+
streamRateLimits.delete(key);
|
|
372
|
+
}
|
|
373
|
+
}, STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS);
|
|
374
|
+
// Periodic cleanup for spawn rate limits (every 5 minutes)
|
|
375
|
+
const _spawnRateCleanupTimer = setInterval(() => {
|
|
376
|
+
const now = Date.now();
|
|
377
|
+
for (const [key, entry] of spawnCounts) {
|
|
378
|
+
if (now - entry.windowStart > SPAWN_WINDOW_MS)
|
|
379
|
+
spawnCounts.delete(key);
|
|
380
|
+
}
|
|
381
|
+
}, SPAWN_WINDOW_MS);
|
|
382
|
+
/** Set the public tunnel URL (called from dev.ts when --share is active). */
|
|
383
|
+
export function setPublicUrl(url) {
|
|
384
|
+
publicUrl = url;
|
|
385
|
+
}
|
|
386
|
+
/** Get the base URL clients should use (tunnel URL if sharing, localhost otherwise). */
|
|
387
|
+
export function getBaseUrl() {
|
|
388
|
+
return publicUrl || `http://localhost:${PORT}`;
|
|
389
|
+
}
|
|
390
|
+
// ── Helpers ────────────────────────────────────────────────
|
|
391
|
+
const FORBIDDEN_MERGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
392
|
+
function deepMerge(target, source) {
|
|
393
|
+
const result = { ...target };
|
|
394
|
+
for (const key of Object.keys(source)) {
|
|
395
|
+
if (FORBIDDEN_MERGE_KEYS.has(key))
|
|
396
|
+
continue;
|
|
397
|
+
const sv = source[key];
|
|
398
|
+
const tv = target[key];
|
|
399
|
+
if (sv !== null && typeof sv === "object" && !Array.isArray(sv) &&
|
|
400
|
+
tv !== null && typeof tv === "object" && !Array.isArray(tv)) {
|
|
401
|
+
result[key] = deepMerge(tv, sv);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
result[key] = sv;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
function assignActorId(username, type, owner) {
|
|
410
|
+
// Use owner as prefix when available (agents pass unique owner like "agent-abc123")
|
|
411
|
+
// This ensures two agents get truly distinct actorIds instead of "agent-ai-1" / "agent-ai-2"
|
|
412
|
+
const base = owner || `${username}-${type}`;
|
|
413
|
+
_actorCounter++;
|
|
414
|
+
return `${base}-${_actorCounter}`;
|
|
415
|
+
}
|
|
416
|
+
function getToolList(mod, allowedTools) {
|
|
417
|
+
if (!mod?.tools)
|
|
418
|
+
return [];
|
|
419
|
+
let tools = mod.tools;
|
|
420
|
+
if (allowedTools)
|
|
421
|
+
tools = tools.filter((t) => allowedTools.includes(t.name));
|
|
422
|
+
return tools.map((t) => ({
|
|
423
|
+
name: t.name,
|
|
424
|
+
description: t.description,
|
|
425
|
+
risk: t.risk || "low",
|
|
426
|
+
input_schema: t.input_schema?._jsonSchema
|
|
427
|
+
? t.input_schema._jsonSchema
|
|
428
|
+
: t.input_schema ? zodToJsonSchema(t.input_schema) : {},
|
|
429
|
+
}));
|
|
430
|
+
}
|
|
431
|
+
function getRoom(roomId) {
|
|
432
|
+
return rooms.get(roomId);
|
|
433
|
+
}
|
|
434
|
+
function getDefaultRoom() {
|
|
435
|
+
const room = rooms.get(DEFAULT_ROOM_ID);
|
|
436
|
+
if (!room)
|
|
437
|
+
throw new Error(`Default room '${DEFAULT_ROOM_ID}' not found. Server may still be starting up.`);
|
|
438
|
+
return room;
|
|
439
|
+
}
|
|
440
|
+
/** Get the loaded experience for a room. Returns undefined if not loaded. */
|
|
441
|
+
function getExperienceForRoom(room) {
|
|
442
|
+
return experienceCache.get(room.experienceId);
|
|
443
|
+
}
|
|
444
|
+
/** Get the host experience module (convenience). */
|
|
445
|
+
function getHostExperience() {
|
|
446
|
+
return experienceCache.get(hostExperienceId)?.module;
|
|
447
|
+
}
|
|
448
|
+
function generateRoomId() {
|
|
449
|
+
return `room-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
450
|
+
}
|
|
451
|
+
function checkSpawnRate(sourceRoomId) {
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
const entry = spawnCounts.get(sourceRoomId);
|
|
454
|
+
if (!entry || now - entry.windowStart > SPAWN_WINDOW_MS) {
|
|
455
|
+
spawnCounts.set(sourceRoomId, { count: 1, windowStart: now });
|
|
456
|
+
return true;
|
|
457
|
+
}
|
|
458
|
+
if (entry.count >= MAX_SPAWNS_PER_WINDOW) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
entry.count++;
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Resolve room config against a specific experience's roomConfig definition.
|
|
466
|
+
* Handles: preset names, explicit config objects, defaults, and validation.
|
|
467
|
+
*/
|
|
468
|
+
function resolveRoomConfig(experienceModule, configInput) {
|
|
469
|
+
const roomConfigDef = experienceModule?.roomConfig;
|
|
470
|
+
if (!roomConfigDef) {
|
|
471
|
+
// No config schema defined — shallow-copy (don't hold caller's reference) and reject arrays/null
|
|
472
|
+
return (typeof configInput === "object" && configInput !== null && !Array.isArray(configInput))
|
|
473
|
+
? { ...configInput } : {};
|
|
474
|
+
}
|
|
475
|
+
let resolved;
|
|
476
|
+
if (typeof configInput === "string") {
|
|
477
|
+
// Preset name
|
|
478
|
+
const preset = roomConfigDef.presets?.[configInput];
|
|
479
|
+
if (!preset) {
|
|
480
|
+
const available = Object.keys(roomConfigDef.presets || {}).join(", ");
|
|
481
|
+
throw new Error(`Unknown config preset '${configInput}'. Available: ${available || "(none)"}`);
|
|
482
|
+
}
|
|
483
|
+
resolved = { ...roomConfigDef.defaults, ...preset };
|
|
484
|
+
}
|
|
485
|
+
else if (configInput && Object.keys(configInput).length > 0) {
|
|
486
|
+
// Explicit config values merged over defaults
|
|
487
|
+
resolved = { ...roomConfigDef.defaults, ...configInput };
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
// No config provided — use defaults
|
|
491
|
+
resolved = roomConfigDef.defaults ? { ...roomConfigDef.defaults } : {};
|
|
492
|
+
}
|
|
493
|
+
// Validate against schema if available
|
|
494
|
+
if (roomConfigDef.schema?.parse) {
|
|
495
|
+
try {
|
|
496
|
+
resolved = roomConfigDef.schema.parse(resolved);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
if (err instanceof ZodError) {
|
|
500
|
+
const issues = err.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n");
|
|
501
|
+
throw new Error(`Invalid room config:\n${issues}`);
|
|
502
|
+
}
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
return resolved;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Resolve the initial state for a room from its experience module.
|
|
510
|
+
* Checks for `initialState` (function or object) on the module.
|
|
511
|
+
* Falls back to empty object if not defined.
|
|
512
|
+
*/
|
|
513
|
+
function resolveInitialState(experienceModule, config) {
|
|
514
|
+
const init = experienceModule?.initialState;
|
|
515
|
+
if (typeof init === "function") {
|
|
516
|
+
try {
|
|
517
|
+
return init(config) || {};
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
console.warn(`[resolveInitialState] initialState(config) threw:`, err);
|
|
521
|
+
return {};
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (init && typeof init === "object") {
|
|
525
|
+
return { ...init };
|
|
526
|
+
}
|
|
527
|
+
// Auto-generate initial state from stateSchema defaults (Zod .default() values)
|
|
528
|
+
const schema = experienceModule?.stateSchema;
|
|
529
|
+
if (schema && typeof schema.parse === "function") {
|
|
530
|
+
try {
|
|
531
|
+
return schema.parse({});
|
|
532
|
+
}
|
|
533
|
+
catch { /* schema has required fields without defaults */ }
|
|
534
|
+
}
|
|
535
|
+
return {};
|
|
536
|
+
}
|
|
537
|
+
// ── Experience discovery ────────────────────────────────────
|
|
538
|
+
/** Discover the experience entry point in the project root. */
|
|
539
|
+
function discoverEntryPath() {
|
|
540
|
+
// Protocol experience: manifest.json in project root
|
|
541
|
+
const manifestPath = path.join(PROJECT_ROOT, "manifest.json");
|
|
542
|
+
if (fs.existsSync(manifestPath))
|
|
543
|
+
return manifestPath;
|
|
544
|
+
// TypeScript experience: src/index.tsx
|
|
545
|
+
const tsxPath = path.join(PROJECT_ROOT, "src", "index.tsx");
|
|
546
|
+
if (fs.existsSync(tsxPath))
|
|
547
|
+
return tsxPath;
|
|
548
|
+
// TypeScript experience: index.tsx in root
|
|
549
|
+
const rootTsx = path.join(PROJECT_ROOT, "index.tsx");
|
|
550
|
+
if (fs.existsSync(rootTsx))
|
|
551
|
+
return rootTsx;
|
|
552
|
+
throw new Error(`No experience found in ${PROJECT_ROOT}. ` +
|
|
553
|
+
`Create a manifest.json (protocol) or src/index.tsx (TypeScript).`);
|
|
554
|
+
}
|
|
555
|
+
// ── Experience loading ─────────────────────────────────────
|
|
556
|
+
/**
|
|
557
|
+
* Load an experience from an arbitrary entry path.
|
|
558
|
+
* Bundles server + client, evals server bundle, caches the result.
|
|
559
|
+
*/
|
|
560
|
+
async function loadExperienceFromPath(entryPath) {
|
|
561
|
+
// Protocol-based experience (manifest.json + subprocess)
|
|
562
|
+
if (isProtocolExperience(entryPath)) {
|
|
563
|
+
return loadProtocolExperienceFromPath(entryPath);
|
|
564
|
+
}
|
|
565
|
+
const [sCode, cCode] = await Promise.all([
|
|
566
|
+
bundleForServer(entryPath),
|
|
567
|
+
bundleForClient(entryPath),
|
|
568
|
+
]);
|
|
569
|
+
const mod = await evalServerBundle(sCode);
|
|
570
|
+
if (!mod?.manifest || !mod?.tools) {
|
|
571
|
+
throw new Error(`Experience at ${entryPath} missing manifest or tools`);
|
|
572
|
+
}
|
|
573
|
+
// Auto-inject chat tools (_chat.send, _chat.team, _chat.clear) if not already present
|
|
574
|
+
if (!mod.tools.some((t) => t.name === '_chat.send')) {
|
|
575
|
+
mod.tools.push(...createChatTools(z));
|
|
576
|
+
}
|
|
577
|
+
// Validate client bundle for syntax errors and unresolved references
|
|
578
|
+
const clientError = validateClientBundle(cCode);
|
|
579
|
+
if (clientError) {
|
|
580
|
+
throw new Error(`Client bundle validation failed for ${entryPath}: ${clientError}`);
|
|
581
|
+
}
|
|
582
|
+
const loaded = {
|
|
583
|
+
module: mod,
|
|
584
|
+
clientBundle: cCode,
|
|
585
|
+
serverCode: sCode,
|
|
586
|
+
loadedAt: Date.now(),
|
|
587
|
+
sourcePath: entryPath,
|
|
588
|
+
};
|
|
589
|
+
experienceCache.set(mod.manifest.id, loaded);
|
|
590
|
+
experienceErrors.delete(mod.manifest.id); // Clear any previous build error
|
|
591
|
+
return loaded;
|
|
592
|
+
}
|
|
593
|
+
// Track protocol executors for cleanup
|
|
594
|
+
const protocolExecutors = new Map();
|
|
595
|
+
async function loadProtocolExperienceFromPath(manifestPath) {
|
|
596
|
+
const manifestDir = path.dirname(manifestPath);
|
|
597
|
+
const manifest = loadProtocolManifest(manifestPath);
|
|
598
|
+
// Stop existing executor if reloading
|
|
599
|
+
const existing = protocolExecutors.get(manifest.id);
|
|
600
|
+
if (existing)
|
|
601
|
+
existing.stop();
|
|
602
|
+
// Spawn the tool process
|
|
603
|
+
const executor = new SubprocessExecutor(manifest.toolProcess.command, manifest.toolProcess.args || [], manifestDir);
|
|
604
|
+
executor.start();
|
|
605
|
+
protocolExecutors.set(manifest.id, executor);
|
|
606
|
+
// Send init
|
|
607
|
+
try {
|
|
608
|
+
await executor.send("init", { experienceId: manifest.id }, 5000);
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// init is optional — process may not implement it
|
|
612
|
+
}
|
|
613
|
+
const mod = createProtocolModule(manifest, executor, manifestDir);
|
|
614
|
+
// Auto-inject chat tools if not present
|
|
615
|
+
if (!mod.tools.some((t) => t.name === '_chat.send')) {
|
|
616
|
+
mod.tools.push(...createChatTools(z));
|
|
617
|
+
}
|
|
618
|
+
// For protocol experiences, clientBundle is empty (HTML canvas served separately)
|
|
619
|
+
// and serverCode is the manifest JSON (for reference)
|
|
620
|
+
const loaded = {
|
|
621
|
+
module: mod,
|
|
622
|
+
clientBundle: "", // No JS bundle — HTML canvas served directly
|
|
623
|
+
serverCode: JSON.stringify(manifest),
|
|
624
|
+
loadedAt: Date.now(),
|
|
625
|
+
sourcePath: manifestPath,
|
|
626
|
+
};
|
|
627
|
+
experienceCache.set(manifest.id, loaded);
|
|
628
|
+
experienceErrors.delete(manifest.id);
|
|
629
|
+
console.log(`[protocol] Loaded ${manifest.title} (${manifest.id}) — ${manifest.tools.length} tools, process: ${manifest.toolProcess.command}`);
|
|
630
|
+
return loaded;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Get the loaded experience. Returns cached if available.
|
|
634
|
+
*/
|
|
635
|
+
async function getLoadedExperience() {
|
|
636
|
+
const cached = experienceCache.get(hostExperienceId);
|
|
637
|
+
if (cached)
|
|
638
|
+
return cached;
|
|
639
|
+
return loadHost();
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Load the experience from the project root.
|
|
643
|
+
*/
|
|
644
|
+
async function loadHost() {
|
|
645
|
+
const entryPath = discoverEntryPath();
|
|
646
|
+
const loaded = await loadExperienceFromPath(entryPath);
|
|
647
|
+
hostExperienceId = loaded.module.manifest.id;
|
|
648
|
+
return loaded;
|
|
649
|
+
}
|
|
650
|
+
// ── Spawn room (async — can load external experiences) ─────
|
|
651
|
+
async function spawnRoom(sourceRoomId, opts) {
|
|
652
|
+
if (!opts.skipRateLimit && !checkSpawnRate(sourceRoomId)) {
|
|
653
|
+
throw new Error(`Rate limited: max ${MAX_SPAWNS_PER_WINDOW} spawns per ${SPAWN_WINDOW_MS / 60000} minutes`);
|
|
654
|
+
}
|
|
655
|
+
let roomId = opts.name || generateRoomId();
|
|
656
|
+
// Sanitize room name: strip path traversal and unsafe characters
|
|
657
|
+
if (opts.name) {
|
|
658
|
+
roomId = roomId.replace(/[^a-zA-Z0-9._\-]/g, "-");
|
|
659
|
+
if (!roomId || roomId.length > 128)
|
|
660
|
+
throw new Error("Invalid room name");
|
|
661
|
+
}
|
|
662
|
+
// Reject reserved room IDs that would shadow API route segments or cause prototype pollution
|
|
663
|
+
if (RESERVED_ROOM_IDS.has(roomId)) {
|
|
664
|
+
throw new Error(`Room name '${roomId}' is reserved and cannot be used`);
|
|
665
|
+
}
|
|
666
|
+
// TOCTOU guard: check both committed and pending rooms atomically (single-threaded JS)
|
|
667
|
+
if (rooms.has(roomId) || pendingSpawns.has(roomId)) {
|
|
668
|
+
throw new Error(`Room '${roomId}' already exists`);
|
|
669
|
+
}
|
|
670
|
+
if (rooms.size + pendingSpawns.size >= MAX_ROOMS) {
|
|
671
|
+
throw new Error(`Room limit reached (${MAX_ROOMS}). Delete unused rooms before spawning new ones.`);
|
|
672
|
+
}
|
|
673
|
+
// Reserve the room ID before async work to prevent concurrent spawns
|
|
674
|
+
pendingSpawns.add(roomId);
|
|
675
|
+
let room;
|
|
676
|
+
try {
|
|
677
|
+
// Always use the host experience (single-experience server)
|
|
678
|
+
const targetExperienceLoaded = await getLoadedExperience();
|
|
679
|
+
// Resolve and validate config against the TARGET experience's schema
|
|
680
|
+
const config = resolveRoomConfig(targetExperienceLoaded.module, opts.config);
|
|
681
|
+
// Resolve initial state: experience default, merged with explicit initialState, plus linkBack
|
|
682
|
+
const expInitialState = resolveInitialState(targetExperienceLoaded.module, config);
|
|
683
|
+
// Filter FORBIDDEN_MERGE_KEYS from initialState to prevent prototype pollution
|
|
684
|
+
const safeInitialState = {};
|
|
685
|
+
if (opts.initialState && typeof opts.initialState === "object" && !Array.isArray(opts.initialState)) {
|
|
686
|
+
for (const [k, v] of Object.entries(opts.initialState)) {
|
|
687
|
+
if (!FORBIDDEN_MERGE_KEYS.has(k))
|
|
688
|
+
safeInitialState[k] = v;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
const mergedState = { ...expInitialState, ...safeInitialState };
|
|
692
|
+
const initialState = opts.linkBack
|
|
693
|
+
? { ...mergedState, _parentRoom: sourceRoomId }
|
|
694
|
+
: mergedState;
|
|
695
|
+
room = new Room(roomId, opts.experienceId, initialState, config);
|
|
696
|
+
room.parentRoomId = sourceRoomId;
|
|
697
|
+
rooms.set(roomId, room);
|
|
698
|
+
pendingSpawns.delete(roomId);
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
pendingSpawns.delete(roomId);
|
|
702
|
+
throw err;
|
|
703
|
+
}
|
|
704
|
+
// Track parent-child link
|
|
705
|
+
const sourceRoom = rooms.get(sourceRoomId);
|
|
706
|
+
if (sourceRoom) {
|
|
707
|
+
sourceRoom.childRoomIds.push(roomId);
|
|
708
|
+
}
|
|
709
|
+
// Store RoomLink (include config in metadata)
|
|
710
|
+
// Cap roomLinks to prevent unbounded growth under rapid spawn/GC cycling
|
|
711
|
+
if (roomLinks.length >= 1000) {
|
|
712
|
+
roomLinks.splice(0, roomLinks.length - 999);
|
|
713
|
+
}
|
|
714
|
+
roomLinks.push({
|
|
715
|
+
parentRoomId: sourceRoomId,
|
|
716
|
+
childRoomId: roomId,
|
|
717
|
+
linkType: "spawned",
|
|
718
|
+
metadata: { experienceId: opts.experienceId, config: room.config },
|
|
719
|
+
createdAt: new Date().toISOString(),
|
|
720
|
+
});
|
|
721
|
+
// Start tick engine if experience uses tick netcode
|
|
722
|
+
const loadedExp = experienceCache.get(opts.experienceId);
|
|
723
|
+
if (loadedExp)
|
|
724
|
+
maybeStartTickEngine(room, loadedExp);
|
|
725
|
+
const url = `${getBaseUrl()}/room/${roomId}`;
|
|
726
|
+
return { roomId, url, config: room.config };
|
|
727
|
+
}
|
|
728
|
+
// ── Tick Engine lifecycle ──────────────────────────────────
|
|
729
|
+
/** Start a tick engine for a room if its experience uses tick netcode. */
|
|
730
|
+
function maybeStartTickEngine(room, loaded) {
|
|
731
|
+
const manifest = loaded.module?.manifest;
|
|
732
|
+
if (manifest?.netcode !== "tick")
|
|
733
|
+
return;
|
|
734
|
+
// Stop existing engine first to prevent ghost intervals on hot-reload
|
|
735
|
+
stopTickEngine(room.id);
|
|
736
|
+
const tickRateMs = manifest.tickRateMs || DEFAULT_TICK_RATE_MS;
|
|
737
|
+
const engine = new TickEngine(room, loaded, roomEvents, tickRateMs);
|
|
738
|
+
engine.start();
|
|
739
|
+
tickEngines.set(room.id, engine);
|
|
740
|
+
}
|
|
741
|
+
/** Stop and remove the tick engine for a room. */
|
|
742
|
+
function stopTickEngine(roomId) {
|
|
743
|
+
const engine = tickEngines.get(roomId);
|
|
744
|
+
if (engine) {
|
|
745
|
+
engine.stop();
|
|
746
|
+
tickEngines.delete(roomId);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// ── Express app ────────────────────────────────────────────
|
|
750
|
+
const app = express();
|
|
751
|
+
app.use(express.json({ limit: JSON_BODY_LIMIT }));
|
|
752
|
+
// CORS for local development
|
|
753
|
+
app.use((_req, res, next) => {
|
|
754
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
755
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
|
756
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Idempotency-Key");
|
|
757
|
+
if (_req.method === "OPTIONS") {
|
|
758
|
+
res.sendStatus(200);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
next();
|
|
762
|
+
});
|
|
763
|
+
// Serve viewer (no-cache so changes are picked up immediately)
|
|
764
|
+
app.get("/", (_req, res) => {
|
|
765
|
+
setNoCacheHeaders(res);
|
|
766
|
+
res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
|
|
767
|
+
});
|
|
768
|
+
app.use("/viewer", express.static(path.join(__runtimeDir, "viewer")));
|
|
769
|
+
// Serve SDK ESM bundle (for scene engine and other SDK features in browser)
|
|
770
|
+
app.get("/sdk.js", (_req, res) => {
|
|
771
|
+
const sdkPath = path.join(PROJECT_ROOT, "node_modules", "@vibevibes", "sdk", "dist", "index.js");
|
|
772
|
+
if (fs.existsSync(sdkPath)) {
|
|
773
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
774
|
+
res.sendFile(sdkPath);
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
res.status(404).send("// SDK not found");
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
// ── Room state endpoint (flat = default room) ──────────────
|
|
781
|
+
app.get("/state", (req, res) => {
|
|
782
|
+
const room = getDefaultRoom();
|
|
783
|
+
const exp = getExperienceForRoom(room);
|
|
784
|
+
let observation;
|
|
785
|
+
const observeFn = exp?.module?.observe ?? defaultObserve;
|
|
786
|
+
const observeActorId = typeof req.query.actorId === "string" ? req.query.actorId : "viewer";
|
|
787
|
+
try {
|
|
788
|
+
observation = observeFn(room.sharedState, null, observeActorId);
|
|
789
|
+
}
|
|
790
|
+
catch (err) {
|
|
791
|
+
console.warn(`[observe] Error: ${toErrorMessage(err)}`);
|
|
792
|
+
}
|
|
793
|
+
res.json({
|
|
794
|
+
roomId: room.id,
|
|
795
|
+
experienceId: exp?.module?.manifest?.id ?? room.experienceId,
|
|
796
|
+
sharedState: room.sharedState,
|
|
797
|
+
stateVersion: room.stateVersion,
|
|
798
|
+
participants: room.participantList(),
|
|
799
|
+
events: room.events.slice(-ROOM_STATE_EVENT_HISTORY),
|
|
800
|
+
config: room.config,
|
|
801
|
+
observation,
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
// ── Slots endpoint (default room) ───────────────────────────
|
|
805
|
+
app.get("/slots", (_req, res) => {
|
|
806
|
+
const room = getDefaultRoom();
|
|
807
|
+
const exp = getExperienceForRoom(room);
|
|
808
|
+
const slots = exp?.module?.manifest?.participantSlots || exp?.module?.participants || [];
|
|
809
|
+
res.json({ slots, participantDetails: room.participantDetails() });
|
|
810
|
+
});
|
|
811
|
+
// ── Participants endpoint ──────────────────────────────────
|
|
812
|
+
app.get("/participants", (_req, res) => res.json({ participants: getDefaultRoom().participantDetails() }));
|
|
813
|
+
app.get("/rooms/:roomId/participants", (req, res) => {
|
|
814
|
+
const room = getRoom(req.params.roomId);
|
|
815
|
+
if (!room)
|
|
816
|
+
return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
817
|
+
res.json({ participants: room.participantDetails() });
|
|
818
|
+
});
|
|
819
|
+
// ── Tools list endpoint (structured tool discovery for agents) ──
|
|
820
|
+
function handleToolsList(room, req, res) {
|
|
821
|
+
const exp = getExperienceForRoom(room);
|
|
822
|
+
if (!exp) {
|
|
823
|
+
res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
// If actorId is provided, filter tools by the participant's allowedTools
|
|
827
|
+
const actorId = queryString(req.query.actorId);
|
|
828
|
+
let allowedTools;
|
|
829
|
+
if (actorId) {
|
|
830
|
+
const participant = room.participants.get(actorId);
|
|
831
|
+
allowedTools = participant?.allowedTools;
|
|
832
|
+
}
|
|
833
|
+
const tools = getToolList(exp.module, allowedTools);
|
|
834
|
+
// Also return stream definitions if the experience defines them
|
|
835
|
+
const streams = exp.module.streams
|
|
836
|
+
? exp.module.streams.map((s) => ({
|
|
837
|
+
name: s.name,
|
|
838
|
+
description: s.description || "",
|
|
839
|
+
rateLimit: s.rateLimit || DEFAULT_STREAM_RATE_LIMIT,
|
|
840
|
+
input_schema: s.input_schema ? zodToJsonSchema(s.input_schema) : {},
|
|
841
|
+
}))
|
|
842
|
+
: [];
|
|
843
|
+
res.json({
|
|
844
|
+
roomId: room.id,
|
|
845
|
+
experienceId: exp.module.manifest?.id || room.experienceId,
|
|
846
|
+
tools,
|
|
847
|
+
streams,
|
|
848
|
+
toolCount: tools.length,
|
|
849
|
+
streamCount: streams.length,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
app.get("/tools-list", (req, res) => handleToolsList(getDefaultRoom(), req, res));
|
|
853
|
+
app.get("/rooms/:roomId/tools-list", (req, res) => {
|
|
854
|
+
const room = getRoom(req.params.roomId);
|
|
855
|
+
if (!room)
|
|
856
|
+
return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
857
|
+
handleToolsList(room, req, res);
|
|
858
|
+
});
|
|
859
|
+
// ── History endpoint (filtered event log) ────────────────────
|
|
860
|
+
function handleHistory(room, req, res) {
|
|
861
|
+
const limit = Math.min(queryInt(req.query.limit) || HISTORY_DEFAULT_LIMIT, HISTORY_MAX_LIMIT);
|
|
862
|
+
const since = queryInt(req.query.since);
|
|
863
|
+
const actor = queryString(req.query.actor);
|
|
864
|
+
const tool = queryString(req.query.tool);
|
|
865
|
+
const owner = queryString(req.query.owner);
|
|
866
|
+
let events = room.events;
|
|
867
|
+
// Filter by timestamp
|
|
868
|
+
if (since > 0) {
|
|
869
|
+
events = events.filter(e => e.ts > since);
|
|
870
|
+
}
|
|
871
|
+
// Filter by actor
|
|
872
|
+
if (actor) {
|
|
873
|
+
events = events.filter(e => e.actorId === actor);
|
|
874
|
+
}
|
|
875
|
+
// Filter by owner
|
|
876
|
+
if (owner) {
|
|
877
|
+
events = events.filter(e => e.owner === owner);
|
|
878
|
+
}
|
|
879
|
+
// Filter by tool name — only allow safe literal characters (no regex metacharacters)
|
|
880
|
+
// This prevents user-controlled ReDoS via crafted patterns
|
|
881
|
+
if (tool) {
|
|
882
|
+
const SAFE_TOOL_REGEX = /^[a-zA-Z0-9._:\-]+$/;
|
|
883
|
+
if (tool.length <= TOOL_REGEX_MAX_LENGTH && SAFE_TOOL_REGEX.test(tool)) {
|
|
884
|
+
// Safe literal pattern — escape dots (regex metachar) for exact matching
|
|
885
|
+
try {
|
|
886
|
+
const escaped = tool.replace(/\./g, '\\.');
|
|
887
|
+
const toolRegex = new RegExp('^' + escaped + '$');
|
|
888
|
+
events = events.filter(e => toolRegex.test(e.tool));
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
events = events.filter(e => e.tool === tool);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
else {
|
|
895
|
+
events = events.filter(e => e.tool === tool);
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
// Take last N events
|
|
899
|
+
const filteredTotal = events.length;
|
|
900
|
+
const result = events.slice(-limit);
|
|
901
|
+
res.json({
|
|
902
|
+
roomId: room.id,
|
|
903
|
+
events: result,
|
|
904
|
+
total: room.events.length,
|
|
905
|
+
filtered: filteredTotal,
|
|
906
|
+
returned: result.length,
|
|
907
|
+
hasMore: filteredTotal > limit,
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
app.get("/history", (req, res) => handleHistory(getDefaultRoom(), req, res));
|
|
911
|
+
app.get("/rooms/:roomId/history", (req, res) => {
|
|
912
|
+
const room = getRoom(req.params.roomId);
|
|
913
|
+
if (!room)
|
|
914
|
+
return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
915
|
+
handleHistory(room, req, res);
|
|
916
|
+
});
|
|
917
|
+
// ── Who endpoint (rich participant info) ─────────────────────
|
|
918
|
+
function handleWho(room, _req, res) {
|
|
919
|
+
const participants = Array.from(room.participants.entries()).map(([actorId, p]) => {
|
|
920
|
+
// Find this participant's last action
|
|
921
|
+
let lastAction;
|
|
922
|
+
for (let i = room.events.length - 1; i >= 0; i--) {
|
|
923
|
+
if (room.events[i].actorId === actorId) {
|
|
924
|
+
lastAction = { tool: room.events[i].tool, ts: room.events[i].ts };
|
|
925
|
+
break;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
return {
|
|
929
|
+
actorId,
|
|
930
|
+
owner: p.owner,
|
|
931
|
+
type: p.type,
|
|
932
|
+
role: p.role,
|
|
933
|
+
joinedAt: p.joinedAt,
|
|
934
|
+
lastAction,
|
|
935
|
+
allowedTools: p.allowedTools,
|
|
936
|
+
};
|
|
937
|
+
});
|
|
938
|
+
res.json({
|
|
939
|
+
roomId: room.id,
|
|
940
|
+
participants,
|
|
941
|
+
count: participants.length,
|
|
942
|
+
humans: participants.filter(p => p.type === "human").length,
|
|
943
|
+
agents: participants.filter(p => p.type === "ai").length,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
app.get("/who", (req, res) => handleWho(getDefaultRoom(), req, res));
|
|
947
|
+
app.get("/rooms/:roomId/who", (req, res) => {
|
|
948
|
+
const room = getRoom(req.params.roomId);
|
|
949
|
+
if (!room)
|
|
950
|
+
return res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
951
|
+
handleWho(room, req, res);
|
|
952
|
+
});
|
|
953
|
+
// ── Join (flat = default room) ─────────────────────────────
|
|
954
|
+
function experienceNotLoadedError(experienceId) {
|
|
955
|
+
const lastError = experienceErrors.get(experienceId);
|
|
956
|
+
const hint = lastError
|
|
957
|
+
? `\nLast build error: ${lastError}\nFix the source and save to hot-reload.`
|
|
958
|
+
: `\nCheck that src/index.tsx exists and exports a valid experience.`;
|
|
959
|
+
return `Experience '${experienceId}' not loaded.${hint}`;
|
|
960
|
+
}
|
|
961
|
+
function handleJoin(room, req, res) {
|
|
962
|
+
const exp = getExperienceForRoom(room);
|
|
963
|
+
if (!exp) {
|
|
964
|
+
res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const { username = "user", actorType: rawActorType = "human", owner, role: requestedRole, agentMode: rawAgentMode, metadata: rawMetadata } = req.body;
|
|
968
|
+
const actorType = rawActorType === "ai" ? "ai" : "human";
|
|
969
|
+
const resolvedOwner = owner || username;
|
|
970
|
+
// Validate agentMode (only for AI participants)
|
|
971
|
+
const VALID_AGENT_MODES = ["behavior", "manual", "hybrid"];
|
|
972
|
+
const agentMode = actorType === "ai" && typeof rawAgentMode === "string" && VALID_AGENT_MODES.includes(rawAgentMode) ? rawAgentMode : undefined;
|
|
973
|
+
// Validate metadata (string→string record, max 20 keys, max 200 chars per value)
|
|
974
|
+
let metadata;
|
|
975
|
+
if (rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) {
|
|
976
|
+
metadata = {};
|
|
977
|
+
let keyCount = 0;
|
|
978
|
+
for (const [k, v] of Object.entries(rawMetadata)) {
|
|
979
|
+
if (keyCount >= 20)
|
|
980
|
+
break;
|
|
981
|
+
if (typeof k === "string" && typeof v === "string" && k.length <= 50) {
|
|
982
|
+
metadata[k] = String(v).slice(0, 200);
|
|
983
|
+
keyCount++;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (Object.keys(metadata).length === 0)
|
|
987
|
+
metadata = undefined;
|
|
988
|
+
}
|
|
989
|
+
// Reject empty owner — would bypass kick enforcement
|
|
990
|
+
if (!resolvedOwner) {
|
|
991
|
+
res.status(400).json({ error: "owner or username required" });
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
// Dedup: if same owner already has a participant in this room, reuse the existing entry
|
|
995
|
+
if (resolvedOwner) {
|
|
996
|
+
// Check kick status before allowing reconnect
|
|
997
|
+
if (room.kickedOwners.has(resolvedOwner)) {
|
|
998
|
+
res.status(403).json({ error: "You have been kicked from this room." });
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
for (const [existingId, existingP] of room.participants.entries()) {
|
|
1002
|
+
if (existingP.owner === resolvedOwner) {
|
|
1003
|
+
// Check if this specific actorId was kicked
|
|
1004
|
+
if (room.kickedActors.has(existingId)) {
|
|
1005
|
+
res.status(403).json({ error: "You have been kicked from this room." });
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
// Same owner reconnecting — return existing identity (preserves actorId continuity)
|
|
1009
|
+
existingP.joinedAt = Date.now();
|
|
1010
|
+
// Ensure _agentRoles stays current on reconnect
|
|
1011
|
+
if (existingP.type === "ai" && existingP.role) {
|
|
1012
|
+
room.sharedState = {
|
|
1013
|
+
...room.sharedState,
|
|
1014
|
+
_agentRoles: { ...(room.sharedState._agentRoles ?? {}), [existingId]: existingP.role },
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
// Clean up stale WS mapping and close the old WebSocket if still alive
|
|
1018
|
+
for (const [ws, wsActor] of room.wsConnections.entries()) {
|
|
1019
|
+
if (wsActor === existingId) {
|
|
1020
|
+
room.wsConnections.delete(ws);
|
|
1021
|
+
try {
|
|
1022
|
+
ws.close(1000, "Replaced by reconnect");
|
|
1023
|
+
}
|
|
1024
|
+
catch { }
|
|
1025
|
+
break;
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
// Broadcast presence update
|
|
1029
|
+
broadcastPresenceUpdate(room);
|
|
1030
|
+
// Compute observation for the reconnected agent
|
|
1031
|
+
let observation;
|
|
1032
|
+
let observeError;
|
|
1033
|
+
const reconnectObserve = exp.module.observe ?? defaultObserve;
|
|
1034
|
+
try {
|
|
1035
|
+
observation = reconnectObserve(room.sharedState, null, existingId);
|
|
1036
|
+
}
|
|
1037
|
+
catch (e) {
|
|
1038
|
+
console.error(`[observe] Error in ${exp.module.manifest.id}:`, toErrorMessage(e));
|
|
1039
|
+
observeError = toErrorMessage(e);
|
|
1040
|
+
}
|
|
1041
|
+
res.json({
|
|
1042
|
+
roomId: room.id,
|
|
1043
|
+
actorId: existingId,
|
|
1044
|
+
owner: resolvedOwner,
|
|
1045
|
+
role: existingP.role,
|
|
1046
|
+
systemPrompt: existingP.systemPrompt,
|
|
1047
|
+
reconnected: true,
|
|
1048
|
+
observation,
|
|
1049
|
+
observeError,
|
|
1050
|
+
tools: getToolList(exp.module, existingP.allowedTools),
|
|
1051
|
+
});
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Unified participant slot matching — works for BOTH humans and AI.
|
|
1057
|
+
// Resolution: (1) manifest.participantSlots, (2) module.participants, (3) legacy agentSlots.
|
|
1058
|
+
const participantSlots = exp.module.manifest?.participantSlots || exp.module.participants;
|
|
1059
|
+
const agentSlots = exp.module.agents || exp.module.manifest?.agentSlots;
|
|
1060
|
+
let slotRole;
|
|
1061
|
+
let slotAllowedTools;
|
|
1062
|
+
let actorIdBase;
|
|
1063
|
+
let slotSystemPrompt;
|
|
1064
|
+
if (participantSlots?.length) {
|
|
1065
|
+
// Count occupants per role (respecting maxInstances)
|
|
1066
|
+
const roleOccupancy = new Map();
|
|
1067
|
+
for (const [, p] of room.participants) {
|
|
1068
|
+
if (p.role)
|
|
1069
|
+
roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
|
|
1070
|
+
}
|
|
1071
|
+
// Match: (1) requested role → (2) first open slot matching type → (3) "any" slots
|
|
1072
|
+
const typeMatches = (slotType, joinType) => {
|
|
1073
|
+
if (!slotType || slotType === "any")
|
|
1074
|
+
return true;
|
|
1075
|
+
return slotType === joinType;
|
|
1076
|
+
};
|
|
1077
|
+
const hasCapacity = (slot) => {
|
|
1078
|
+
const max = slot.maxInstances ?? 1;
|
|
1079
|
+
const current = roleOccupancy.get(slot.role) || 0;
|
|
1080
|
+
return current < max;
|
|
1081
|
+
};
|
|
1082
|
+
let matched;
|
|
1083
|
+
if (requestedRole) {
|
|
1084
|
+
matched = participantSlots.find((s) => s.role === requestedRole && typeMatches(s.type, actorType) && hasCapacity(s));
|
|
1085
|
+
}
|
|
1086
|
+
if (!matched) {
|
|
1087
|
+
matched = participantSlots.find((s) => s.type === actorType && hasCapacity(s));
|
|
1088
|
+
}
|
|
1089
|
+
if (!matched) {
|
|
1090
|
+
matched = participantSlots.find((s) => typeMatches(s.type, actorType) && hasCapacity(s));
|
|
1091
|
+
}
|
|
1092
|
+
// Fallback: if all matching slots are full, use the first type-compatible slot anyway
|
|
1093
|
+
// (matches old behavior where agents overflow to agentSlots[0])
|
|
1094
|
+
if (!matched) {
|
|
1095
|
+
matched = participantSlots.find((s) => typeMatches(s.type, actorType));
|
|
1096
|
+
}
|
|
1097
|
+
if (matched) {
|
|
1098
|
+
slotRole = matched.role;
|
|
1099
|
+
slotAllowedTools = matched.allowedTools;
|
|
1100
|
+
slotSystemPrompt = matched.systemPrompt;
|
|
1101
|
+
// actorId uses the owner name, not the role — role lives in participant record
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
else if (actorType === "ai" && agentSlots && agentSlots.length > 0) {
|
|
1105
|
+
// Legacy fallback: agentSlots only match AI joins
|
|
1106
|
+
const occupiedRoles = new Set();
|
|
1107
|
+
for (const [, p] of room.participants) {
|
|
1108
|
+
if (p.type === "ai" && p.role)
|
|
1109
|
+
occupiedRoles.add(p.role);
|
|
1110
|
+
}
|
|
1111
|
+
const slot = agentSlots.find((s) => !occupiedRoles.has(s.role)) || agentSlots[0];
|
|
1112
|
+
slotRole = slot.role;
|
|
1113
|
+
slotAllowedTools = slot.allowedTools;
|
|
1114
|
+
slotSystemPrompt = slot.systemPrompt;
|
|
1115
|
+
}
|
|
1116
|
+
const actorId = assignActorId(username, actorType, actorIdBase || resolvedOwner);
|
|
1117
|
+
const participant = {
|
|
1118
|
+
type: actorType, joinedAt: Date.now(), owner: resolvedOwner,
|
|
1119
|
+
};
|
|
1120
|
+
if (slotRole) {
|
|
1121
|
+
participant.role = slotRole;
|
|
1122
|
+
}
|
|
1123
|
+
if (slotAllowedTools) {
|
|
1124
|
+
participant.allowedTools = slotAllowedTools;
|
|
1125
|
+
}
|
|
1126
|
+
if (slotSystemPrompt) {
|
|
1127
|
+
participant.systemPrompt = slotSystemPrompt;
|
|
1128
|
+
}
|
|
1129
|
+
if (!slotRole && requestedRole) {
|
|
1130
|
+
participant.role = requestedRole;
|
|
1131
|
+
}
|
|
1132
|
+
if (agentMode) {
|
|
1133
|
+
participant.agentMode = agentMode;
|
|
1134
|
+
}
|
|
1135
|
+
if (metadata) {
|
|
1136
|
+
participant.metadata = metadata;
|
|
1137
|
+
}
|
|
1138
|
+
room.participants.set(actorId, participant);
|
|
1139
|
+
// Auto-populate _agentRoles when an AI participant joins with a role
|
|
1140
|
+
if (actorType === "ai" && participant.role) {
|
|
1141
|
+
room.sharedState = {
|
|
1142
|
+
...room.sharedState,
|
|
1143
|
+
_agentRoles: { ...(room.sharedState._agentRoles ?? {}), [actorId]: participant.role },
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
// Broadcast presence update
|
|
1147
|
+
broadcastPresenceUpdate(room);
|
|
1148
|
+
// Compute observation so agents get a curated view from the start
|
|
1149
|
+
let observation;
|
|
1150
|
+
let observeError;
|
|
1151
|
+
const joinObserve = exp.module.observe ?? defaultObserve;
|
|
1152
|
+
try {
|
|
1153
|
+
observation = joinObserve(room.sharedState, null, actorId);
|
|
1154
|
+
}
|
|
1155
|
+
catch (e) {
|
|
1156
|
+
console.error(`[observe] Error in ${exp.module.manifest.id}:`, toErrorMessage(e));
|
|
1157
|
+
observeError = toErrorMessage(e);
|
|
1158
|
+
}
|
|
1159
|
+
res.json({
|
|
1160
|
+
roomId: room.id,
|
|
1161
|
+
actorId,
|
|
1162
|
+
owner: resolvedOwner,
|
|
1163
|
+
experienceId: exp.module.manifest.id,
|
|
1164
|
+
sharedState: room.sharedState,
|
|
1165
|
+
participants: room.participantList(),
|
|
1166
|
+
events: room.events.slice(-JOIN_EVENT_HISTORY),
|
|
1167
|
+
tools: getToolList(exp.module, participant.allowedTools),
|
|
1168
|
+
browserUrl: getBaseUrl(),
|
|
1169
|
+
config: room.config,
|
|
1170
|
+
hasRoomConfig: !!exp.module.roomConfig,
|
|
1171
|
+
observation,
|
|
1172
|
+
role: participant.role,
|
|
1173
|
+
allowedTools: participant.allowedTools,
|
|
1174
|
+
systemPrompt: slotSystemPrompt,
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
app.post("/join", (req, res) => handleJoin(getDefaultRoom(), req, res));
|
|
1178
|
+
// ── Leave (flat = default room) ────────────────────────────
|
|
1179
|
+
function handleLeave(room, req, res) {
|
|
1180
|
+
const { actorId } = req.body;
|
|
1181
|
+
if (!actorId || typeof actorId !== "string") {
|
|
1182
|
+
res.status(400).json({ error: "actorId required" });
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (!room.participants.has(actorId)) {
|
|
1186
|
+
res.status(404).json({ error: `Participant '${actorId}' not found in room '${room.id}'` });
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
room.participants.delete(actorId);
|
|
1190
|
+
// Clean up agent memory for this actor (key must match executeTool's memoryKey format)
|
|
1191
|
+
const exp = getExperienceForRoom(room);
|
|
1192
|
+
const memKey = exp ? `${exp.module.manifest.id}:${actorId}` : `${room.id}:${actorId}`;
|
|
1193
|
+
agentMemory.delete(memKey);
|
|
1194
|
+
// Clean up any associated WebSocket connections
|
|
1195
|
+
for (const [ws, wsActorId] of room.wsConnections.entries()) {
|
|
1196
|
+
if (wsActorId === actorId) {
|
|
1197
|
+
room.wsConnections.delete(ws);
|
|
1198
|
+
try {
|
|
1199
|
+
ws.close();
|
|
1200
|
+
}
|
|
1201
|
+
catch { }
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
broadcastPresenceUpdate(room);
|
|
1205
|
+
res.json({ left: true, actorId });
|
|
1206
|
+
}
|
|
1207
|
+
app.post("/leave", (req, res) => handleLeave(getDefaultRoom(), req, res));
|
|
1208
|
+
function handleKick(room, kickerActorId, targetActorId) {
|
|
1209
|
+
if (kickerActorId === targetActorId)
|
|
1210
|
+
return { error: "Cannot kick yourself" };
|
|
1211
|
+
const kicker = room.participants.get(kickerActorId);
|
|
1212
|
+
if (!kicker || kicker.type !== "human")
|
|
1213
|
+
return { error: "Only human participants can kick" };
|
|
1214
|
+
if (!room.participants.has(targetActorId))
|
|
1215
|
+
return { error: "Participant not found" };
|
|
1216
|
+
// Remove participant and mark as kicked (track both actorId and owner)
|
|
1217
|
+
const targetParticipant = room.participants.get(targetActorId);
|
|
1218
|
+
room.participants.delete(targetActorId);
|
|
1219
|
+
// Cap kicked sets to prevent unbounded growth on long-running rooms
|
|
1220
|
+
if (room.kickedActors.size >= 500) {
|
|
1221
|
+
const oldest = room.kickedActors.values().next().value;
|
|
1222
|
+
if (oldest)
|
|
1223
|
+
room.kickedActors.delete(oldest);
|
|
1224
|
+
}
|
|
1225
|
+
room.kickedActors.add(targetActorId);
|
|
1226
|
+
if (targetParticipant?.owner) {
|
|
1227
|
+
if (room.kickedOwners.size >= 500) {
|
|
1228
|
+
const oldest = room.kickedOwners.values().next().value;
|
|
1229
|
+
if (oldest)
|
|
1230
|
+
room.kickedOwners.delete(oldest);
|
|
1231
|
+
}
|
|
1232
|
+
room.kickedOwners.add(targetParticipant.owner);
|
|
1233
|
+
}
|
|
1234
|
+
// Close their WS if connected
|
|
1235
|
+
for (const [targetWs, wsActorId] of room.wsConnections.entries()) {
|
|
1236
|
+
if (wsActorId === targetActorId) {
|
|
1237
|
+
try {
|
|
1238
|
+
targetWs.send(JSON.stringify({ type: "kicked", by: kickerActorId }));
|
|
1239
|
+
targetWs.close();
|
|
1240
|
+
}
|
|
1241
|
+
catch { }
|
|
1242
|
+
room.wsConnections.delete(targetWs);
|
|
1243
|
+
break;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
// Broadcast updated presence
|
|
1247
|
+
broadcastPresenceUpdate(room);
|
|
1248
|
+
// Wake up any long-polling agent-context for the kicked agent
|
|
1249
|
+
roomEvents.emit(`room:${room.id}`);
|
|
1250
|
+
return { kicked: true, actorId: targetActorId };
|
|
1251
|
+
}
|
|
1252
|
+
app.post("/kick", (req, res) => {
|
|
1253
|
+
const result = handleKick(getDefaultRoom(), req.body.kickerActorId, req.body.targetActorId);
|
|
1254
|
+
res.status(result.error ? 400 : 200).json(result);
|
|
1255
|
+
});
|
|
1256
|
+
// ── Idempotency cache ────────────────────────────────────────
|
|
1257
|
+
const idempotencyCache = new Map();
|
|
1258
|
+
const IDEMPOTENCY_TTL = 30000; // 30 seconds
|
|
1259
|
+
// Cleanup expired entries every 60 seconds
|
|
1260
|
+
const _idempotencyCleanupTimer = setInterval(() => {
|
|
1261
|
+
const now = Date.now();
|
|
1262
|
+
for (const [key, entry] of idempotencyCache) {
|
|
1263
|
+
if (now - entry.ts > IDEMPOTENCY_TTL)
|
|
1264
|
+
idempotencyCache.delete(key);
|
|
1265
|
+
}
|
|
1266
|
+
}, IDEMPOTENCY_CLEANUP_INTERVAL_MS);
|
|
1267
|
+
async function executeTool(room, toolName, actorId, input = {}, owner, expiredFlag) {
|
|
1268
|
+
const exp = getExperienceForRoom(room);
|
|
1269
|
+
if (!exp)
|
|
1270
|
+
throw new Error(experienceNotLoadedError(room.experienceId));
|
|
1271
|
+
// Handle scoped tool calls from embedded experiences
|
|
1272
|
+
let scopeKey;
|
|
1273
|
+
let resolvedToolName = toolName;
|
|
1274
|
+
if (toolName.includes(':')) {
|
|
1275
|
+
const colonIdx = toolName.indexOf(':');
|
|
1276
|
+
scopeKey = toolName.slice(0, colonIdx);
|
|
1277
|
+
resolvedToolName = toolName.slice(colonIdx + 1);
|
|
1278
|
+
// Validate scopeKey against safe identifier pattern and forbidden keys
|
|
1279
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(scopeKey) || FORBIDDEN_MERGE_KEYS.has(scopeKey)) {
|
|
1280
|
+
throw new Error(`Invalid scope key in tool name: '${scopeKey}'`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
// Find tool from the room's experience
|
|
1284
|
+
const tool = exp.module.tools.find((t) => t.name === resolvedToolName);
|
|
1285
|
+
if (!tool) {
|
|
1286
|
+
const available = exp.module.tools.map((t) => t.name).join(", ");
|
|
1287
|
+
throw new ToolNotFoundError(`Tool '${resolvedToolName}' not found. Available tools: ${available}`);
|
|
1288
|
+
}
|
|
1289
|
+
// Check role-based tool restrictions for AI actors
|
|
1290
|
+
// Check both bare name and fully-scoped name (e.g. "hero.move" and "scope:hero.move")
|
|
1291
|
+
const callingParticipant = room.participants.get(actorId);
|
|
1292
|
+
if (callingParticipant?.allowedTools &&
|
|
1293
|
+
!callingParticipant.allowedTools.includes(resolvedToolName) &&
|
|
1294
|
+
!callingParticipant.allowedTools.includes(toolName)) {
|
|
1295
|
+
const role = callingParticipant.role || "ai";
|
|
1296
|
+
const allowed = callingParticipant.allowedTools.join(", ");
|
|
1297
|
+
throw new ToolForbiddenError(`Tool '${resolvedToolName}' is not allowed for role '${role}'. Allowed tools: ${allowed}`);
|
|
1298
|
+
}
|
|
1299
|
+
// Validate input
|
|
1300
|
+
let validatedInput = input;
|
|
1301
|
+
if (tool.input_schema?.parse) {
|
|
1302
|
+
validatedInput = tool.input_schema.parse(input);
|
|
1303
|
+
}
|
|
1304
|
+
// Build ToolCtx
|
|
1305
|
+
const participant = room.participants.get(actorId);
|
|
1306
|
+
const resolvedOwner = participant?.owner || owner || actorId;
|
|
1307
|
+
const memoryKey = `${exp.module.manifest.id}:${actorId}`;
|
|
1308
|
+
const ctx = {
|
|
1309
|
+
roomId: room.id,
|
|
1310
|
+
actorId,
|
|
1311
|
+
owner: resolvedOwner,
|
|
1312
|
+
get state() { return room.sharedState; },
|
|
1313
|
+
setState: (newState) => {
|
|
1314
|
+
if (expiredFlag?.value)
|
|
1315
|
+
return; // Tool timed out — discard late writes
|
|
1316
|
+
room.sharedState = newState;
|
|
1317
|
+
// Warn if state is getting large (>1MB serialized)
|
|
1318
|
+
if (process.env.NODE_ENV !== "test") {
|
|
1319
|
+
try {
|
|
1320
|
+
const size = JSON.stringify(newState).length;
|
|
1321
|
+
if (size > 1_000_000) {
|
|
1322
|
+
console.warn(`[room:${room.id}] State size warning: ${(size / 1_000_000).toFixed(1)}MB. Consider pruning old data.`);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
catch { }
|
|
1326
|
+
}
|
|
1327
|
+
},
|
|
1328
|
+
timestamp: Date.now(),
|
|
1329
|
+
memory: agentMemory.get(memoryKey) || {},
|
|
1330
|
+
setMemory: (updates) => {
|
|
1331
|
+
const current = agentMemory.get(memoryKey) || {};
|
|
1332
|
+
const merged = deepMerge(current, updates);
|
|
1333
|
+
// Cap per-actor memory at 100 keys to prevent unbounded growth
|
|
1334
|
+
const keys = Object.keys(merged);
|
|
1335
|
+
if (keys.length > 100) {
|
|
1336
|
+
for (const k of keys.slice(0, keys.length - 100))
|
|
1337
|
+
delete merged[k];
|
|
1338
|
+
}
|
|
1339
|
+
agentMemory.set(memoryKey, merged);
|
|
1340
|
+
},
|
|
1341
|
+
roomConfig: room.config,
|
|
1342
|
+
};
|
|
1343
|
+
// Scope state for embedded experience tool calls
|
|
1344
|
+
if (scopeKey) {
|
|
1345
|
+
Object.defineProperty(ctx, 'state', {
|
|
1346
|
+
get() { return room.sharedState[scopeKey] || {}; },
|
|
1347
|
+
configurable: true,
|
|
1348
|
+
});
|
|
1349
|
+
ctx.setState = (newState) => {
|
|
1350
|
+
if (expiredFlag?.value)
|
|
1351
|
+
return; // Tool timed out — discard late writes
|
|
1352
|
+
room.sharedState = { ...room.sharedState, [scopeKey]: newState };
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
// Wire spawnRoom if experience requests the capability
|
|
1356
|
+
const capabilities = exp.module.manifest.requested_capabilities || [];
|
|
1357
|
+
if (capabilities.includes("room.spawn")) {
|
|
1358
|
+
ctx.spawnRoom = async (opts) => {
|
|
1359
|
+
return spawnRoom(room.id, opts);
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
// Wire blob operations
|
|
1363
|
+
ctx.setBlob = (key, data) => {
|
|
1364
|
+
const buf = Buffer.from(data);
|
|
1365
|
+
if (buf.length > MAX_BLOB_SIZE)
|
|
1366
|
+
throw new Error(`Blob too large (${buf.length} bytes)`);
|
|
1367
|
+
// Enforce total blob size cap (same logic as REST endpoint)
|
|
1368
|
+
let totalSize = 0;
|
|
1369
|
+
for (const [, meta] of blobMeta)
|
|
1370
|
+
totalSize += meta.size;
|
|
1371
|
+
if (totalSize + buf.length > MAX_TOTAL_BLOBS) {
|
|
1372
|
+
const sorted = [...blobMeta.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
1373
|
+
while (totalSize + buf.length > MAX_TOTAL_BLOBS && sorted.length > 0) {
|
|
1374
|
+
const entry = sorted.shift();
|
|
1375
|
+
if (!entry)
|
|
1376
|
+
break;
|
|
1377
|
+
const [oldKey, oldMeta] = entry;
|
|
1378
|
+
blobStore.delete(oldKey);
|
|
1379
|
+
blobMeta.delete(oldKey);
|
|
1380
|
+
totalSize -= oldMeta.size;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
blobStore.set(key, buf);
|
|
1384
|
+
blobMeta.set(key, { size: buf.length, createdAt: Date.now(), roomId: room.id });
|
|
1385
|
+
return key;
|
|
1386
|
+
};
|
|
1387
|
+
ctx.getBlob = (key) => {
|
|
1388
|
+
const buf = blobStore.get(key);
|
|
1389
|
+
return buf ? buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) : undefined;
|
|
1390
|
+
};
|
|
1391
|
+
// Execute handler
|
|
1392
|
+
const output = await tool.handler(ctx, validatedInput);
|
|
1393
|
+
// Create event
|
|
1394
|
+
const callerRole = callingParticipant?.role;
|
|
1395
|
+
const event = {
|
|
1396
|
+
id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1397
|
+
ts: Date.now(),
|
|
1398
|
+
actorId,
|
|
1399
|
+
owner: ctx.owner,
|
|
1400
|
+
role: callerRole,
|
|
1401
|
+
tool: toolName,
|
|
1402
|
+
input: validatedInput,
|
|
1403
|
+
output,
|
|
1404
|
+
};
|
|
1405
|
+
// Compute observation (falls back to defaultObserve when experience has none)
|
|
1406
|
+
let observation;
|
|
1407
|
+
const toolObserve = exp.module.observe ?? defaultObserve;
|
|
1408
|
+
try {
|
|
1409
|
+
observation = toolObserve(room.sharedState, event, actorId);
|
|
1410
|
+
}
|
|
1411
|
+
catch (e) {
|
|
1412
|
+
console.error(`[observe] Error in ${exp.module.manifest.id}:`, toErrorMessage(e));
|
|
1413
|
+
}
|
|
1414
|
+
if (observation) {
|
|
1415
|
+
event.observation = observation;
|
|
1416
|
+
}
|
|
1417
|
+
// Append event
|
|
1418
|
+
room.appendEvent(event);
|
|
1419
|
+
// Broadcast state update (delta-compressed)
|
|
1420
|
+
room.broadcastStateUpdate({
|
|
1421
|
+
event,
|
|
1422
|
+
changedBy: actorId,
|
|
1423
|
+
tool: toolName,
|
|
1424
|
+
observation,
|
|
1425
|
+
});
|
|
1426
|
+
// Emit for long-poll listeners
|
|
1427
|
+
roomEvents.emit(`room:${room.id}`);
|
|
1428
|
+
// Notify tick engine if behavior tools were modified (handle scoped tools like "scope:_behavior.set")
|
|
1429
|
+
const baseTool = toolName.includes(':') ? toolName.split(':').pop() : toolName;
|
|
1430
|
+
if (baseTool.startsWith("_behavior.")) {
|
|
1431
|
+
const engine = tickEngines.get(room.id);
|
|
1432
|
+
if (engine)
|
|
1433
|
+
engine.markDirty();
|
|
1434
|
+
}
|
|
1435
|
+
return { tool: toolName, output, observation };
|
|
1436
|
+
}
|
|
1437
|
+
// ── Single tool HTTP endpoint ───────────────────────────────
|
|
1438
|
+
async function handleTool(room, req, res) {
|
|
1439
|
+
const exp = getExperienceForRoom(room);
|
|
1440
|
+
if (!exp) {
|
|
1441
|
+
res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
const toolName = req.params.toolName;
|
|
1445
|
+
const { actorId, input: rawInput = {}, owner } = req.body;
|
|
1446
|
+
// Ensure input is a plain object (not array or primitive)
|
|
1447
|
+
const input = rawInput !== null && typeof rawInput === "object" && !Array.isArray(rawInput) ? rawInput : {};
|
|
1448
|
+
// Reject requests without actorId (prevents bypassing participant/allowedTools checks)
|
|
1449
|
+
if (!actorId) {
|
|
1450
|
+
res.status(400).json({ error: "actorId is required" });
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
// Verify the caller is a participant in this room
|
|
1454
|
+
if (!room.participants.has(actorId)) {
|
|
1455
|
+
res.status(403).json({ error: `Actor '${actorId}' is not a participant in room '${room.id}'. Call /join first.` });
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
// Idempotency: return cached result if same key seen recently (room-scoped)
|
|
1459
|
+
// Placed AFTER participant check to prevent kicked/non-participant actors from replaying cached results
|
|
1460
|
+
const rawIdempotencyKey = req.headers["x-idempotency-key"];
|
|
1461
|
+
// Skip idempotency for excessively long keys (memory amplification prevention)
|
|
1462
|
+
const idempotencyKey = (rawIdempotencyKey && rawIdempotencyKey.length <= 128) ? `${room.id}:${rawIdempotencyKey}` : undefined;
|
|
1463
|
+
if (idempotencyKey) {
|
|
1464
|
+
const cached = idempotencyCache.get(idempotencyKey);
|
|
1465
|
+
if (cached && Date.now() - cached.ts < IDEMPOTENCY_TTL) {
|
|
1466
|
+
res.json({ output: cached.output, cached: true });
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
try {
|
|
1471
|
+
// Serialize tool execution through per-room queue to prevent state interleaving
|
|
1472
|
+
// Wrap in timeout to prevent a hung handler from permanently blocking the room queue
|
|
1473
|
+
// expiredFlag prevents timed-out handlers from mutating state after the queue moves on
|
|
1474
|
+
const expiredFlag = { value: false };
|
|
1475
|
+
let timeoutHandle;
|
|
1476
|
+
const result = await room.enqueueExecution(() => Promise.race([
|
|
1477
|
+
executeTool(room, toolName, actorId, input, owner, expiredFlag),
|
|
1478
|
+
new Promise((_, reject) => {
|
|
1479
|
+
timeoutHandle = setTimeout(() => {
|
|
1480
|
+
expiredFlag.value = true;
|
|
1481
|
+
reject(new Error(`Tool '${toolName}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
|
|
1482
|
+
}, TOOL_HTTP_TIMEOUT_MS);
|
|
1483
|
+
}),
|
|
1484
|
+
]));
|
|
1485
|
+
if (timeoutHandle !== undefined)
|
|
1486
|
+
clearTimeout(timeoutHandle);
|
|
1487
|
+
// Cache for idempotency
|
|
1488
|
+
if (idempotencyKey) {
|
|
1489
|
+
idempotencyCache.set(idempotencyKey, { output: result.output, ts: Date.now() });
|
|
1490
|
+
}
|
|
1491
|
+
res.json({ output: result.output, observation: result.observation });
|
|
1492
|
+
}
|
|
1493
|
+
catch (err) {
|
|
1494
|
+
// Determine HTTP status code based on error type
|
|
1495
|
+
let statusCode = 400;
|
|
1496
|
+
if (err instanceof ToolNotFoundError)
|
|
1497
|
+
statusCode = 404;
|
|
1498
|
+
else if (err instanceof ToolForbiddenError)
|
|
1499
|
+
statusCode = 403;
|
|
1500
|
+
// Look up tool for better error formatting (may be undefined if tool not found)
|
|
1501
|
+
const expForError = getExperienceForRoom(room);
|
|
1502
|
+
const toolForError = expForError?.module?.tools?.find((t) => t.name === toolName);
|
|
1503
|
+
const errorMsg = err instanceof ZodError
|
|
1504
|
+
? formatZodError(err, toolName, toolForError)
|
|
1505
|
+
: (err instanceof Error ? formatHandlerError(err, toolName, toolForError, input) : String(err));
|
|
1506
|
+
const resolvedOwner = owner || room.participants.get(actorId)?.owner;
|
|
1507
|
+
const event = {
|
|
1508
|
+
id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1509
|
+
ts: Date.now(),
|
|
1510
|
+
actorId,
|
|
1511
|
+
...(resolvedOwner ? { owner: resolvedOwner } : {}),
|
|
1512
|
+
tool: toolName,
|
|
1513
|
+
input,
|
|
1514
|
+
error: errorMsg,
|
|
1515
|
+
};
|
|
1516
|
+
room.appendEvent(event);
|
|
1517
|
+
roomEvents.emit(`room:${room.id}`);
|
|
1518
|
+
res.status(statusCode).json({ error: errorMsg });
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
app.post("/tools/:toolName", (req, res) => handleTool(getDefaultRoom(), req, res));
|
|
1522
|
+
// ── Batch tool endpoint ─────────────────────────────────────
|
|
1523
|
+
// Execute multiple tools sequentially in a single HTTP request.
|
|
1524
|
+
// Each tool sees the state left by the previous call, so order matters.
|
|
1525
|
+
// On error, previously successful calls are NOT rolled back.
|
|
1526
|
+
// Returns results for all calls, including errors.
|
|
1527
|
+
app.post("/tools-batch", (req, res) => handleBatch(getDefaultRoom(), req, res));
|
|
1528
|
+
async function handleBatch(room, req, res) {
|
|
1529
|
+
const exp = getExperienceForRoom(room);
|
|
1530
|
+
if (!exp) {
|
|
1531
|
+
res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1534
|
+
const { actorId, owner, calls } = req.body;
|
|
1535
|
+
if (!actorId) {
|
|
1536
|
+
res.status(400).json({ error: "actorId is required" });
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
// Verify the caller is a participant in this room
|
|
1540
|
+
if (actorId && !room.participants.has(actorId)) {
|
|
1541
|
+
res.status(403).json({ error: `Actor '${actorId}' is not a participant in room '${room.id}'. Call /join first.` });
|
|
1542
|
+
return;
|
|
1543
|
+
}
|
|
1544
|
+
if (!Array.isArray(calls) || calls.length === 0) {
|
|
1545
|
+
res.status(400).json({ error: "Missing or empty 'calls' array. Expected: [{ tool, input? }, ...]" });
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
if (calls.length > MAX_BATCH_CALLS) {
|
|
1549
|
+
res.status(400).json({ error: `Too many calls in batch (${calls.length}). Maximum is ${MAX_BATCH_CALLS}.` });
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
// Serialize entire batch through per-room queue so all calls in the batch
|
|
1553
|
+
// are atomic with respect to other concurrent tool calls.
|
|
1554
|
+
// Per-call timeout prevents a hung handler from permanently blocking the room queue.
|
|
1555
|
+
// Aggregate batch timeout prevents total queue hold > 60s.
|
|
1556
|
+
const BATCH_TOTAL_TIMEOUT_MS = 60_000;
|
|
1557
|
+
const batchStart = Date.now();
|
|
1558
|
+
const { results, lastObservation, hasError } = await room.enqueueExecution(async () => {
|
|
1559
|
+
const results = [];
|
|
1560
|
+
let lastObservation;
|
|
1561
|
+
let hasError = false;
|
|
1562
|
+
for (const call of calls) {
|
|
1563
|
+
// Check aggregate batch timeout
|
|
1564
|
+
if (Date.now() - batchStart > BATCH_TOTAL_TIMEOUT_MS) {
|
|
1565
|
+
results.push({ tool: call.tool || "?", error: `Batch total timeout exceeded (${BATCH_TOTAL_TIMEOUT_MS}ms)` });
|
|
1566
|
+
hasError = true;
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
if (!call.tool) {
|
|
1570
|
+
results.push({ tool: "?", error: "Missing 'tool' field in call" });
|
|
1571
|
+
hasError = true;
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
try {
|
|
1575
|
+
const batchExpiredFlag = { value: false };
|
|
1576
|
+
let batchTimeoutHandle;
|
|
1577
|
+
const result = await Promise.race([
|
|
1578
|
+
executeTool(room, call.tool, actorId, (call.input !== null && typeof call.input === 'object' && !Array.isArray(call.input)) ? call.input : {}, owner, batchExpiredFlag),
|
|
1579
|
+
new Promise((_, reject) => {
|
|
1580
|
+
batchTimeoutHandle = setTimeout(() => {
|
|
1581
|
+
batchExpiredFlag.value = true;
|
|
1582
|
+
reject(new Error(`Tool '${call.tool}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
|
|
1583
|
+
}, TOOL_HTTP_TIMEOUT_MS);
|
|
1584
|
+
}),
|
|
1585
|
+
]);
|
|
1586
|
+
if (batchTimeoutHandle !== undefined)
|
|
1587
|
+
clearTimeout(batchTimeoutHandle);
|
|
1588
|
+
results.push(result);
|
|
1589
|
+
if (result.observation)
|
|
1590
|
+
lastObservation = result.observation;
|
|
1591
|
+
}
|
|
1592
|
+
catch (err) {
|
|
1593
|
+
const errorMsg = err instanceof ZodError
|
|
1594
|
+
? formatZodError(err, call.tool)
|
|
1595
|
+
: (err instanceof Error ? err.message : String(err));
|
|
1596
|
+
// Log error event
|
|
1597
|
+
const resolvedBatchOwner = owner || room.participants.get(actorId)?.owner;
|
|
1598
|
+
const event = {
|
|
1599
|
+
id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1600
|
+
ts: Date.now(),
|
|
1601
|
+
actorId,
|
|
1602
|
+
...(resolvedBatchOwner ? { owner: resolvedBatchOwner } : {}),
|
|
1603
|
+
tool: call.tool,
|
|
1604
|
+
input: call.input || {},
|
|
1605
|
+
error: errorMsg,
|
|
1606
|
+
};
|
|
1607
|
+
room.appendEvent(event);
|
|
1608
|
+
roomEvents.emit(`room:${room.id}`);
|
|
1609
|
+
results.push({ tool: call.tool, error: errorMsg });
|
|
1610
|
+
hasError = true;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
return { results, lastObservation, hasError };
|
|
1614
|
+
});
|
|
1615
|
+
// Return 207 (Multi-Status) if there were mixed results, 200 if all succeeded
|
|
1616
|
+
const statusCode = hasError ? 207 : 200;
|
|
1617
|
+
res.status(statusCode).json({ results, observation: lastObservation });
|
|
1618
|
+
}
|
|
1619
|
+
// ── Get events (supports long-poll via ?timeout=N) ──────────
|
|
1620
|
+
function handleEvents(room, req, res) {
|
|
1621
|
+
const since = queryInt(req.query.since);
|
|
1622
|
+
const timeout = Math.min(queryInt(req.query.timeout), LONG_POLL_MAX_TIMEOUT_MS);
|
|
1623
|
+
const requestingActorId = queryString(req.query.actorId);
|
|
1624
|
+
// Helper: compute observation for current state
|
|
1625
|
+
const computeObservation = (events) => {
|
|
1626
|
+
const exp = getExperienceForRoom(room);
|
|
1627
|
+
if (!requestingActorId)
|
|
1628
|
+
return undefined;
|
|
1629
|
+
const agentObserve = exp?.module?.observe ?? defaultObserve;
|
|
1630
|
+
try {
|
|
1631
|
+
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
|
|
1632
|
+
return agentObserve(room.sharedState, lastEvent, requestingActorId);
|
|
1633
|
+
}
|
|
1634
|
+
catch (e) {
|
|
1635
|
+
console.error(`[observe] Error in ${exp?.module?.manifest?.id}:`, toErrorMessage(e));
|
|
1636
|
+
return undefined;
|
|
1637
|
+
}
|
|
1638
|
+
};
|
|
1639
|
+
const getNewEvents = () => room.events.filter((e) => e.ts > since && e.actorId !== requestingActorId);
|
|
1640
|
+
let newEvents = getNewEvents();
|
|
1641
|
+
if (newEvents.length > 0 || timeout === 0) {
|
|
1642
|
+
const observation = computeObservation(newEvents);
|
|
1643
|
+
res.json({
|
|
1644
|
+
events: newEvents,
|
|
1645
|
+
sharedState: room.sharedState,
|
|
1646
|
+
participants: room.participantList(),
|
|
1647
|
+
observation,
|
|
1648
|
+
});
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
// Long-poll: wait for event emission or timeout
|
|
1652
|
+
let responded = false;
|
|
1653
|
+
let batchTimer = null;
|
|
1654
|
+
const respond = () => {
|
|
1655
|
+
if (responded)
|
|
1656
|
+
return;
|
|
1657
|
+
responded = true;
|
|
1658
|
+
clearTimeout(timer);
|
|
1659
|
+
if (batchTimer) {
|
|
1660
|
+
clearTimeout(batchTimer);
|
|
1661
|
+
batchTimer = null;
|
|
1662
|
+
}
|
|
1663
|
+
roomEvents.removeListener(`room:${room.id}`, onEvent);
|
|
1664
|
+
newEvents = getNewEvents();
|
|
1665
|
+
const observation = computeObservation(newEvents);
|
|
1666
|
+
res.json({
|
|
1667
|
+
events: newEvents,
|
|
1668
|
+
sharedState: room.sharedState,
|
|
1669
|
+
participants: room.participantList(),
|
|
1670
|
+
observation,
|
|
1671
|
+
});
|
|
1672
|
+
};
|
|
1673
|
+
const timer = setTimeout(respond, timeout);
|
|
1674
|
+
const onEvent = () => {
|
|
1675
|
+
if (responded)
|
|
1676
|
+
return;
|
|
1677
|
+
if (batchTimer)
|
|
1678
|
+
return; // Deduplicate rapid events
|
|
1679
|
+
// Small delay to batch rapid events
|
|
1680
|
+
batchTimer = setTimeout(() => {
|
|
1681
|
+
batchTimer = null;
|
|
1682
|
+
if (responded)
|
|
1683
|
+
return;
|
|
1684
|
+
// Only respond if there are actual new tool events.
|
|
1685
|
+
// Streams modify state and emit roomEvents but don't create
|
|
1686
|
+
// event log entries — ignore those wake-ups so the long-poll
|
|
1687
|
+
// keeps waiting for real tool events (like _chat.send).
|
|
1688
|
+
const pending = getNewEvents();
|
|
1689
|
+
if (pending.length > 0) {
|
|
1690
|
+
respond();
|
|
1691
|
+
}
|
|
1692
|
+
}, EVENT_BATCH_DEBOUNCE_MS);
|
|
1693
|
+
};
|
|
1694
|
+
roomEvents.on(`room:${room.id}`, onEvent);
|
|
1695
|
+
// Cleanup on client disconnect
|
|
1696
|
+
req.on("close", () => {
|
|
1697
|
+
responded = true;
|
|
1698
|
+
clearTimeout(timer);
|
|
1699
|
+
if (batchTimer)
|
|
1700
|
+
clearTimeout(batchTimer);
|
|
1701
|
+
roomEvents.removeListener(`room:${room.id}`, onEvent);
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
app.get("/events", (req, res) => handleEvents(getDefaultRoom(), req, res));
|
|
1705
|
+
// ── Cross-room events (watch all rooms) ────────────────────
|
|
1706
|
+
app.get("/events/all", (req, res) => {
|
|
1707
|
+
const since = queryInt(req.query.since);
|
|
1708
|
+
const timeout = Math.min(queryInt(req.query.timeout), LONG_POLL_MAX_TIMEOUT_MS);
|
|
1709
|
+
const requestingActorId = queryString(req.query.actorId);
|
|
1710
|
+
const getAllEvents = () => {
|
|
1711
|
+
const allEvents = [];
|
|
1712
|
+
for (const room of rooms.values()) {
|
|
1713
|
+
for (const e of room.events) {
|
|
1714
|
+
if (e.ts > since) {
|
|
1715
|
+
// Self-filter: skip own events (consistent with /events and /agent-context)
|
|
1716
|
+
if (requestingActorId && e.actorId === requestingActorId)
|
|
1717
|
+
continue;
|
|
1718
|
+
allEvents.push({ ...e, roomId: room.id });
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
allEvents.sort((a, b) => a.ts - b.ts);
|
|
1723
|
+
return allEvents;
|
|
1724
|
+
};
|
|
1725
|
+
const getRoomSummaries = () => Array.from(rooms.values()).map((room) => ({
|
|
1726
|
+
roomId: room.id,
|
|
1727
|
+
experienceId: room.experienceId,
|
|
1728
|
+
participants: room.participantList(),
|
|
1729
|
+
eventCount: room.events.length,
|
|
1730
|
+
}));
|
|
1731
|
+
let newEvents = getAllEvents();
|
|
1732
|
+
if (newEvents.length > 0 || timeout === 0) {
|
|
1733
|
+
res.json({ events: newEvents, rooms: getRoomSummaries() });
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
// Long-poll: wait for any room event
|
|
1737
|
+
let responded = false;
|
|
1738
|
+
let batchTimer = null;
|
|
1739
|
+
const respond = () => {
|
|
1740
|
+
if (responded)
|
|
1741
|
+
return;
|
|
1742
|
+
responded = true;
|
|
1743
|
+
clearTimeout(timer);
|
|
1744
|
+
if (batchTimer) {
|
|
1745
|
+
clearTimeout(batchTimer);
|
|
1746
|
+
batchTimer = null;
|
|
1747
|
+
}
|
|
1748
|
+
clearInterval(newRoomCheck);
|
|
1749
|
+
for (const rid of subscribedRoomIds) {
|
|
1750
|
+
roomEvents.removeListener(`room:${rid}`, onEvent);
|
|
1751
|
+
}
|
|
1752
|
+
res.json({ events: getAllEvents(), rooms: getRoomSummaries() });
|
|
1753
|
+
};
|
|
1754
|
+
const timer = setTimeout(respond, timeout);
|
|
1755
|
+
const onEvent = () => {
|
|
1756
|
+
if (responded)
|
|
1757
|
+
return;
|
|
1758
|
+
if (batchTimer)
|
|
1759
|
+
return;
|
|
1760
|
+
batchTimer = setTimeout(() => {
|
|
1761
|
+
batchTimer = null;
|
|
1762
|
+
if (responded)
|
|
1763
|
+
return;
|
|
1764
|
+
// Only respond if there are actual new tool events — ignore
|
|
1765
|
+
// stream-only wake-ups (streams don't create event log entries).
|
|
1766
|
+
const pending = getAllEvents();
|
|
1767
|
+
if (pending.length > 0)
|
|
1768
|
+
respond();
|
|
1769
|
+
}, EVENT_BATCH_DEBOUNCE_MS);
|
|
1770
|
+
};
|
|
1771
|
+
// Listen on all existing rooms + track subscribed IDs for cleanup
|
|
1772
|
+
const subscribedRoomIds = new Set();
|
|
1773
|
+
for (const room of rooms.values()) {
|
|
1774
|
+
roomEvents.on(`room:${room.id}`, onEvent);
|
|
1775
|
+
subscribedRoomIds.add(room.id);
|
|
1776
|
+
}
|
|
1777
|
+
// Also check periodically for newly spawned rooms during long-poll
|
|
1778
|
+
const newRoomCheck = setInterval(() => {
|
|
1779
|
+
if (responded) {
|
|
1780
|
+
clearInterval(newRoomCheck);
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
for (const room of rooms.values()) {
|
|
1784
|
+
if (!subscribedRoomIds.has(room.id)) {
|
|
1785
|
+
roomEvents.on(`room:${room.id}`, onEvent);
|
|
1786
|
+
subscribedRoomIds.add(room.id);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}, 2000);
|
|
1790
|
+
req.on("close", () => {
|
|
1791
|
+
responded = true;
|
|
1792
|
+
clearTimeout(timer);
|
|
1793
|
+
if (batchTimer)
|
|
1794
|
+
clearTimeout(batchTimer);
|
|
1795
|
+
clearInterval(newRoomCheck);
|
|
1796
|
+
for (const rid of subscribedRoomIds) {
|
|
1797
|
+
roomEvents.removeListener(`room:${rid}`, onEvent);
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
});
|
|
1801
|
+
// ── Browser error capture ──────────────────────────────────────
|
|
1802
|
+
// The viewer POSTs browser errors here so the agent can see them
|
|
1803
|
+
// via /agent-context and fix them automatically.
|
|
1804
|
+
const roomBrowserErrors = new Map();
|
|
1805
|
+
const MAX_BROWSER_ERRORS = 20;
|
|
1806
|
+
const lastBrowserErrorAt = new Map(); // Rate limit per room
|
|
1807
|
+
const BROWSER_ERROR_COOLDOWN_MS = 200; // Min interval between error-triggered wake-ups
|
|
1808
|
+
const MAX_BROWSER_ERROR_MSG_LEN = 500; // Truncate overlong error messages
|
|
1809
|
+
app.post("/browser-error", (req, res) => {
|
|
1810
|
+
const { message, roomId: errorRoomId } = req.body || {};
|
|
1811
|
+
if (typeof message === "string" && message.trim()) {
|
|
1812
|
+
// Truncate message to prevent memory bloat
|
|
1813
|
+
const trimmed = message.trim().slice(0, MAX_BROWSER_ERROR_MSG_LEN);
|
|
1814
|
+
const now = Date.now();
|
|
1815
|
+
// Scope errors to the room they came from
|
|
1816
|
+
const targetRoom = (typeof errorRoomId === "string" && rooms.has(errorRoomId))
|
|
1817
|
+
? errorRoomId
|
|
1818
|
+
: getDefaultRoom().id;
|
|
1819
|
+
let errors = roomBrowserErrors.get(targetRoom);
|
|
1820
|
+
if (!errors) {
|
|
1821
|
+
errors = [];
|
|
1822
|
+
roomBrowserErrors.set(targetRoom, errors);
|
|
1823
|
+
}
|
|
1824
|
+
errors.push({ message: trimmed, ts: now });
|
|
1825
|
+
if (errors.length > MAX_BROWSER_ERRORS) {
|
|
1826
|
+
errors.splice(0, errors.length - MAX_BROWSER_ERRORS);
|
|
1827
|
+
}
|
|
1828
|
+
// Rate-limit wake-ups per room
|
|
1829
|
+
const lastErrorAt = lastBrowserErrorAt.get(targetRoom) || 0;
|
|
1830
|
+
if (now - lastErrorAt >= BROWSER_ERROR_COOLDOWN_MS) {
|
|
1831
|
+
lastBrowserErrorAt.set(targetRoom, now);
|
|
1832
|
+
roomEvents.emit(`room:${targetRoom}`);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
res.json({ ok: true });
|
|
1836
|
+
});
|
|
1837
|
+
// ── Agent context (combined events + observe for Stop hook) ──
|
|
1838
|
+
app.get("/agent-context", (req, res) => {
|
|
1839
|
+
const rawSince = queryInt(req.query.since);
|
|
1840
|
+
const timeout = Math.min(queryInt(req.query.timeout), AGENT_CONTEXT_MAX_TIMEOUT_MS);
|
|
1841
|
+
const actorId = queryString(req.query.actorId) || "unknown";
|
|
1842
|
+
const requestedRoomId = queryString(req.query.roomId);
|
|
1843
|
+
// Find the agent's actual room:
|
|
1844
|
+
// 1. Explicit roomId query param (preferred)
|
|
1845
|
+
// 2. Search all rooms for the actorId
|
|
1846
|
+
// 3. Fall back to default room
|
|
1847
|
+
let agentRoom = getDefaultRoom();
|
|
1848
|
+
if (requestedRoomId) {
|
|
1849
|
+
const r = getRoom(requestedRoomId);
|
|
1850
|
+
if (r)
|
|
1851
|
+
agentRoom = r;
|
|
1852
|
+
}
|
|
1853
|
+
else {
|
|
1854
|
+
for (const room of rooms.values()) {
|
|
1855
|
+
if (room.participants.has(actorId)) {
|
|
1856
|
+
agentRoom = room;
|
|
1857
|
+
break;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
// Resolve owner: explicit query param > participant record from agent's room
|
|
1862
|
+
// Never fall back to actorId.split — that caused multi-agent collisions
|
|
1863
|
+
// where two agents both resolved to the same prefix and filtered each other out
|
|
1864
|
+
const participantEntry = agentRoom.participants.get(actorId);
|
|
1865
|
+
const requestingOwner = queryString(req.query.owner) || participantEntry?.owner;
|
|
1866
|
+
// Use server-tracked cursor when client sends since=0 (e.g. stop hook runs
|
|
1867
|
+
// as a fresh process each time and can't persist cursors between invocations).
|
|
1868
|
+
// This makes the server the true source of truth for cursor state.
|
|
1869
|
+
const since = (rawSince === 0 && participantEntry?.eventCursor)
|
|
1870
|
+
? participantEntry.eventCursor
|
|
1871
|
+
: rawSince;
|
|
1872
|
+
// Update lastPollAt for AI heartbeat tracking
|
|
1873
|
+
if (participantEntry) {
|
|
1874
|
+
participantEntry.lastPollAt = Date.now();
|
|
1875
|
+
}
|
|
1876
|
+
// Gather events from ALL rooms (not just default) so the agent sees sub-room activity
|
|
1877
|
+
const getAllNewEvents = () => {
|
|
1878
|
+
const allEvents = [];
|
|
1879
|
+
for (const room of rooms.values()) {
|
|
1880
|
+
for (const e of room.events) {
|
|
1881
|
+
// Self-filter: skip events from the same owner
|
|
1882
|
+
// If either side has no owner, don't filter (safe default: show the event)
|
|
1883
|
+
if (requestingOwner && e.owner === requestingOwner)
|
|
1884
|
+
continue;
|
|
1885
|
+
// Skip tick engine events — agents react to observations, not individual ticks
|
|
1886
|
+
if (e.actorId === "_tick-engine" || e.owner === "_system")
|
|
1887
|
+
continue;
|
|
1888
|
+
if (e.ts > since) {
|
|
1889
|
+
allEvents.push({ ...e, roomId: room.id });
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
return allEvents.sort((a, b) => a.ts - b.ts);
|
|
1894
|
+
};
|
|
1895
|
+
// Collect all participants and available tools across rooms
|
|
1896
|
+
const getAllParticipants = () => {
|
|
1897
|
+
const all = new Set();
|
|
1898
|
+
for (const room of rooms.values()) {
|
|
1899
|
+
for (const p of room.participantList())
|
|
1900
|
+
all.add(p);
|
|
1901
|
+
}
|
|
1902
|
+
return [...all];
|
|
1903
|
+
};
|
|
1904
|
+
// Resolve the requesting agent's allowed tools from their participant record in their actual room
|
|
1905
|
+
const requestingParticipant = agentRoom.participants.get(actorId);
|
|
1906
|
+
const agentAllowedTools = requestingParticipant?.allowedTools;
|
|
1907
|
+
const getAllRoomInfo = () => {
|
|
1908
|
+
const info = {};
|
|
1909
|
+
for (const room of rooms.values()) {
|
|
1910
|
+
const exp = getExperienceForRoom(room);
|
|
1911
|
+
let toolNames = exp?.module?.tools?.map((t) => t.name) || [];
|
|
1912
|
+
if (agentAllowedTools)
|
|
1913
|
+
toolNames = toolNames.filter((n) => agentAllowedTools.includes(n));
|
|
1914
|
+
info[room.id] = {
|
|
1915
|
+
experience: exp?.module?.manifest?.id || room.experienceId || "unknown",
|
|
1916
|
+
tools: toolNames,
|
|
1917
|
+
participants: room.participantList(),
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
return info;
|
|
1921
|
+
};
|
|
1922
|
+
const buildResponse = () => {
|
|
1923
|
+
// Check if agentRoom was deleted during long-poll
|
|
1924
|
+
if (!rooms.has(agentRoom.id)) {
|
|
1925
|
+
return {
|
|
1926
|
+
events: [],
|
|
1927
|
+
observation: { done: true, reason: "room_deleted" },
|
|
1928
|
+
participants: getAllParticipants(),
|
|
1929
|
+
rooms: getAllRoomInfo(),
|
|
1930
|
+
};
|
|
1931
|
+
}
|
|
1932
|
+
const events = getAllNewEvents();
|
|
1933
|
+
// Check if this agent was kicked — return done:true so the stop hook archives the state file
|
|
1934
|
+
if (agentRoom.kickedActors.has(actorId)) {
|
|
1935
|
+
agentRoom.kickedActors.delete(actorId); // One-shot: clear after notifying
|
|
1936
|
+
return {
|
|
1937
|
+
events: [],
|
|
1938
|
+
observation: { done: true, reason: "kicked" },
|
|
1939
|
+
participants: getAllParticipants(),
|
|
1940
|
+
rooms: getAllRoomInfo(),
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
// Compute observation from the agent's actual room (not always default)
|
|
1944
|
+
const observeExp = getExperienceForRoom(agentRoom);
|
|
1945
|
+
let observation;
|
|
1946
|
+
let observeError;
|
|
1947
|
+
if (observeExp?.module?.observe) {
|
|
1948
|
+
try {
|
|
1949
|
+
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
|
|
1950
|
+
observation = observeExp.module.observe(agentRoom.sharedState, lastEvent, actorId);
|
|
1951
|
+
}
|
|
1952
|
+
catch (e) {
|
|
1953
|
+
console.error(`[observe] Error in ${observeExp.module.manifest?.id}:`, toErrorMessage(e));
|
|
1954
|
+
observeError = toErrorMessage(e);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
// Find the agent's most recent error (own events are filtered from `events`,
|
|
1958
|
+
// so scan raw room events for this actor's errors since the last poll)
|
|
1959
|
+
let lastError;
|
|
1960
|
+
for (const room of rooms.values()) {
|
|
1961
|
+
for (const e of room.events) {
|
|
1962
|
+
if (e.ts > since && e.error && e.actorId === actorId) {
|
|
1963
|
+
lastError = { tool: e.tool, error: e.error };
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
// Collect tick engine status across all rooms
|
|
1968
|
+
const tickStatus = {};
|
|
1969
|
+
for (const [roomId, engine] of tickEngines) {
|
|
1970
|
+
const status = engine.getStatus();
|
|
1971
|
+
tickStatus[roomId] = {
|
|
1972
|
+
enabled: status.enabled,
|
|
1973
|
+
tickCount: status.tickCount,
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
// Collect browser errors for this agent's room since last poll
|
|
1977
|
+
const roomErrors = roomBrowserErrors.get(agentRoom.id) || [];
|
|
1978
|
+
const recentBrowserErrors = roomErrors.filter(e => e.ts > since);
|
|
1979
|
+
// Clear reported errors for this room so they don't repeat
|
|
1980
|
+
if (recentBrowserErrors.length > 0) {
|
|
1981
|
+
roomBrowserErrors.set(agentRoom.id, roomErrors.filter(e => e.ts <= since));
|
|
1982
|
+
}
|
|
1983
|
+
// Track eventCursor server-side so agents don't need local state files
|
|
1984
|
+
let eventCursor;
|
|
1985
|
+
if (events.length > 0 && participantEntry) {
|
|
1986
|
+
eventCursor = Math.max(participantEntry.eventCursor || 0, ...events.map(e => e.ts));
|
|
1987
|
+
participantEntry.eventCursor = eventCursor;
|
|
1988
|
+
}
|
|
1989
|
+
else if (participantEntry?.eventCursor) {
|
|
1990
|
+
eventCursor = participantEntry.eventCursor;
|
|
1991
|
+
}
|
|
1992
|
+
return {
|
|
1993
|
+
events,
|
|
1994
|
+
observation: observation || {},
|
|
1995
|
+
observeError,
|
|
1996
|
+
lastError,
|
|
1997
|
+
browserErrors: recentBrowserErrors.length > 0 ? recentBrowserErrors : undefined,
|
|
1998
|
+
participants: getAllParticipants(),
|
|
1999
|
+
rooms: getAllRoomInfo(),
|
|
2000
|
+
tickEngines: Object.keys(tickStatus).length > 0 ? tickStatus : undefined,
|
|
2001
|
+
eventCursor,
|
|
2002
|
+
};
|
|
2003
|
+
};
|
|
2004
|
+
// If events, browser errors, or kicked status are already available, respond immediately
|
|
2005
|
+
let newEvents = getAllNewEvents();
|
|
2006
|
+
const pendingBrowserErrors = (roomBrowserErrors.get(agentRoom.id) || []).filter(e => e.ts > since);
|
|
2007
|
+
const isKicked = agentRoom.kickedActors.has(actorId);
|
|
2008
|
+
if (newEvents.length > 0 || pendingBrowserErrors.length > 0 || isKicked || timeout === 0) {
|
|
2009
|
+
res.json(buildResponse());
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
// Long-poll: wait for events or timeout
|
|
2013
|
+
let responded = false;
|
|
2014
|
+
let batchTimer = null;
|
|
2015
|
+
const respond = () => {
|
|
2016
|
+
if (responded)
|
|
2017
|
+
return;
|
|
2018
|
+
responded = true;
|
|
2019
|
+
clearTimeout(timer);
|
|
2020
|
+
if (batchTimer) {
|
|
2021
|
+
clearTimeout(batchTimer);
|
|
2022
|
+
batchTimer = null;
|
|
2023
|
+
}
|
|
2024
|
+
clearInterval(newRoomCheck);
|
|
2025
|
+
// Remove listeners from all subscribed rooms (tracked set handles deleted rooms)
|
|
2026
|
+
for (const rid of subscribedRoomIds) {
|
|
2027
|
+
roomEvents.removeListener(`room:${rid}`, onEvent);
|
|
2028
|
+
}
|
|
2029
|
+
res.json(buildResponse());
|
|
2030
|
+
};
|
|
2031
|
+
const timer = setTimeout(respond, timeout);
|
|
2032
|
+
const onEvent = () => {
|
|
2033
|
+
if (responded)
|
|
2034
|
+
return;
|
|
2035
|
+
if (batchTimer)
|
|
2036
|
+
return;
|
|
2037
|
+
batchTimer = setTimeout(() => {
|
|
2038
|
+
batchTimer = null;
|
|
2039
|
+
if (responded)
|
|
2040
|
+
return;
|
|
2041
|
+
const pending = getAllNewEvents();
|
|
2042
|
+
if (pending.length > 0 || agentRoom.kickedActors.has(actorId))
|
|
2043
|
+
respond();
|
|
2044
|
+
}, EVENT_BATCH_DEBOUNCE_MS);
|
|
2045
|
+
};
|
|
2046
|
+
// Listen on ALL rooms + track subscribed IDs for cleanup
|
|
2047
|
+
const subscribedRoomIds = new Set();
|
|
2048
|
+
for (const room of rooms.values()) {
|
|
2049
|
+
roomEvents.on(`room:${room.id}`, onEvent);
|
|
2050
|
+
subscribedRoomIds.add(room.id);
|
|
2051
|
+
}
|
|
2052
|
+
// Check periodically for newly spawned rooms during long-poll
|
|
2053
|
+
const newRoomCheck = setInterval(() => {
|
|
2054
|
+
if (responded) {
|
|
2055
|
+
clearInterval(newRoomCheck);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
for (const room of rooms.values()) {
|
|
2059
|
+
if (!subscribedRoomIds.has(room.id)) {
|
|
2060
|
+
roomEvents.on(`room:${room.id}`, onEvent);
|
|
2061
|
+
subscribedRoomIds.add(room.id);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
}, 2000);
|
|
2065
|
+
req.on("close", () => {
|
|
2066
|
+
responded = true;
|
|
2067
|
+
clearTimeout(timer);
|
|
2068
|
+
if (batchTimer)
|
|
2069
|
+
clearTimeout(batchTimer);
|
|
2070
|
+
clearInterval(newRoomCheck);
|
|
2071
|
+
for (const rid of subscribedRoomIds) {
|
|
2072
|
+
roomEvents.removeListener(`room:${rid}`, onEvent);
|
|
2073
|
+
}
|
|
2074
|
+
});
|
|
2075
|
+
});
|
|
2076
|
+
// ── Serve client bundle ────────────────────────────────────
|
|
2077
|
+
app.get("/bundle", async (_req, res) => {
|
|
2078
|
+
// Wait for any in-progress hot-reload to complete before serving
|
|
2079
|
+
if (rebuildingPromise)
|
|
2080
|
+
await rebuildingPromise;
|
|
2081
|
+
const host = experienceCache.get(hostExperienceId);
|
|
2082
|
+
res.setHeader("Content-Type", "text/javascript");
|
|
2083
|
+
setNoCacheHeaders(res);
|
|
2084
|
+
res.send(host?.clientBundle || "");
|
|
2085
|
+
});
|
|
2086
|
+
// ── Memory endpoints ───────────────────────────────────────
|
|
2087
|
+
app.get("/memory", (req, res) => {
|
|
2088
|
+
const key = queryString(req.query.key);
|
|
2089
|
+
if (!key) {
|
|
2090
|
+
res.json({});
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
res.json(agentMemory.get(key) || {});
|
|
2094
|
+
});
|
|
2095
|
+
app.post("/memory", (req, res) => {
|
|
2096
|
+
const { key, updates } = req.body;
|
|
2097
|
+
if (!key) {
|
|
2098
|
+
res.status(400).json({ error: "key required" });
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
if (!updates || typeof updates !== "object" || Array.isArray(updates)) {
|
|
2102
|
+
res.status(400).json({ error: "updates must be a plain object" });
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
const current = agentMemory.get(key) || {};
|
|
2106
|
+
const merged = deepMerge(current, updates);
|
|
2107
|
+
// Cap per-key object to 100 keys (same limit as tool-path setMemory)
|
|
2108
|
+
const keys = Object.keys(merged);
|
|
2109
|
+
if (keys.length > 100) {
|
|
2110
|
+
res.status(400).json({ error: `Memory key limit exceeded (${keys.length}/100)` });
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
agentMemory.set(key, merged);
|
|
2114
|
+
res.json({ saved: true });
|
|
2115
|
+
});
|
|
2116
|
+
// ── Blob store endpoints ──────────────────────────────────
|
|
2117
|
+
app.get("/blobs/:key", (req, res) => {
|
|
2118
|
+
const blob = blobStore.get(req.params.key);
|
|
2119
|
+
if (!blob) {
|
|
2120
|
+
res.status(404).json({ error: "Blob not found" });
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
res.setHeader("Content-Type", "application/octet-stream");
|
|
2124
|
+
res.setHeader("Content-Length", String(blob.length));
|
|
2125
|
+
res.setHeader("Cache-Control", `public, max-age=${BLOB_CACHE_MAX_AGE_SECONDS}`);
|
|
2126
|
+
res.send(blob);
|
|
2127
|
+
});
|
|
2128
|
+
app.post("/blobs/:key", express.raw({ limit: "10mb", type: "*/*" }), (req, res) => {
|
|
2129
|
+
const key = req.params.key;
|
|
2130
|
+
const { roomId, actorId } = req.query;
|
|
2131
|
+
// Validate blob key: safe characters only, no prototype-pollution keys
|
|
2132
|
+
if (!/^[a-zA-Z0-9._\-]+$/.test(key) || key.length > 256) {
|
|
2133
|
+
res.status(400).json({ error: "Invalid blob key format" });
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (key === "__proto__" || key === "constructor" || key === "prototype") {
|
|
2137
|
+
res.status(400).json({ error: "Reserved blob key name" });
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
2141
|
+
res.status(400).json({ error: "Empty or invalid blob data" });
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
if (req.body.length > MAX_BLOB_SIZE) {
|
|
2145
|
+
res.status(413).json({ error: `Blob too large (${req.body.length} bytes, max ${MAX_BLOB_SIZE})` });
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
// Check total size
|
|
2149
|
+
let totalSize = 0;
|
|
2150
|
+
for (const [, meta] of blobMeta)
|
|
2151
|
+
totalSize += meta.size;
|
|
2152
|
+
if (totalSize + req.body.length > MAX_TOTAL_BLOBS) {
|
|
2153
|
+
// Garbage collect: remove oldest blobs until we have space
|
|
2154
|
+
const sorted = [...blobMeta.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
2155
|
+
while (totalSize + req.body.length > MAX_TOTAL_BLOBS && sorted.length > 0) {
|
|
2156
|
+
const [oldKey, oldMeta] = sorted.shift();
|
|
2157
|
+
blobStore.delete(oldKey);
|
|
2158
|
+
blobMeta.delete(oldKey);
|
|
2159
|
+
totalSize -= oldMeta.size;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
blobStore.set(key, req.body);
|
|
2163
|
+
blobMeta.set(key, { size: req.body.length, createdAt: Date.now(), roomId: roomId || "local" });
|
|
2164
|
+
res.json({ key, size: req.body.length });
|
|
2165
|
+
});
|
|
2166
|
+
app.delete("/blobs/:key", (req, res) => {
|
|
2167
|
+
const key = req.params.key;
|
|
2168
|
+
// Validate key format (same safe-char check as POST)
|
|
2169
|
+
if (!/^[a-zA-Z0-9_\-.:]+$/.test(key) || key.length > 200) {
|
|
2170
|
+
res.status(400).json({ error: "Invalid blob key" });
|
|
2171
|
+
return;
|
|
2172
|
+
}
|
|
2173
|
+
if (!blobStore.has(key)) {
|
|
2174
|
+
res.status(404).json({ error: "Blob not found" });
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
blobStore.delete(key);
|
|
2178
|
+
blobMeta.delete(key);
|
|
2179
|
+
res.json({ deleted: true });
|
|
2180
|
+
});
|
|
2181
|
+
app.get("/blobs", (_req, res) => {
|
|
2182
|
+
const list = [...blobMeta.entries()].map(([key, meta]) => ({ key, ...meta }));
|
|
2183
|
+
res.json(list);
|
|
2184
|
+
});
|
|
2185
|
+
// ── Screenshot capture ────────────────────────────────────
|
|
2186
|
+
const pendingScreenshots = new Map();
|
|
2187
|
+
app.get("/screenshot", (req, res) => {
|
|
2188
|
+
// Find an open viewer WebSocket connection (check default room first, then all rooms)
|
|
2189
|
+
let viewerWs = null;
|
|
2190
|
+
for (const room of rooms.values()) {
|
|
2191
|
+
for (const [ws] of room.wsConnections) {
|
|
2192
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2193
|
+
viewerWs = ws;
|
|
2194
|
+
break;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
if (viewerWs)
|
|
2198
|
+
break;
|
|
2199
|
+
}
|
|
2200
|
+
if (!viewerWs) {
|
|
2201
|
+
res.status(503).json({ error: `No browser viewer connected. Open ${getBaseUrl()} first.` });
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
const requestId = `ss-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
2205
|
+
const timeoutMs = Math.min(queryInt(req.query.timeout) || SCREENSHOT_DEFAULT_TIMEOUT_MS, SCREENSHOT_MAX_TIMEOUT_MS);
|
|
2206
|
+
const promise = new Promise((resolve, reject) => {
|
|
2207
|
+
const timer = setTimeout(() => {
|
|
2208
|
+
pendingScreenshots.delete(requestId);
|
|
2209
|
+
reject(new Error("Screenshot timeout"));
|
|
2210
|
+
}, timeoutMs);
|
|
2211
|
+
pendingScreenshots.set(requestId, { resolve, reject, timer, viewerWs: viewerWs });
|
|
2212
|
+
});
|
|
2213
|
+
// Clean up if HTTP client disconnects before screenshot arrives
|
|
2214
|
+
req.on("close", () => {
|
|
2215
|
+
const p = pendingScreenshots.get(requestId);
|
|
2216
|
+
if (p) {
|
|
2217
|
+
clearTimeout(p.timer);
|
|
2218
|
+
pendingScreenshots.delete(requestId);
|
|
2219
|
+
p.reject(new Error("Client disconnected"));
|
|
2220
|
+
}
|
|
2221
|
+
});
|
|
2222
|
+
// Ask the viewer to capture
|
|
2223
|
+
try {
|
|
2224
|
+
viewerWs.send(JSON.stringify({ type: "screenshot_request", id: requestId }));
|
|
2225
|
+
}
|
|
2226
|
+
catch (err) {
|
|
2227
|
+
const p = pendingScreenshots.get(requestId);
|
|
2228
|
+
if (p) {
|
|
2229
|
+
clearTimeout(p.timer);
|
|
2230
|
+
pendingScreenshots.delete(requestId);
|
|
2231
|
+
p.reject(new Error("WebSocket send failed"));
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
promise
|
|
2235
|
+
.then((pngBuffer) => {
|
|
2236
|
+
res.setHeader("Content-Type", "image/png");
|
|
2237
|
+
res.setHeader("Content-Length", String(pngBuffer.length));
|
|
2238
|
+
res.send(pngBuffer);
|
|
2239
|
+
})
|
|
2240
|
+
.catch((err) => {
|
|
2241
|
+
res.status(500).json({ error: toErrorMessage(err) });
|
|
2242
|
+
});
|
|
2243
|
+
});
|
|
2244
|
+
// ── Sync (re-bundle) ──────────────────────────────────────
|
|
2245
|
+
app.post("/sync", async (_req, res) => {
|
|
2246
|
+
try {
|
|
2247
|
+
// Stop tick engines for host rooms before reloading (prevents ghost tick loops)
|
|
2248
|
+
for (const room of rooms.values()) {
|
|
2249
|
+
if (room.experienceId === hostExperienceId) {
|
|
2250
|
+
stopTickEngine(room.id);
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
await loadHost();
|
|
2254
|
+
const hostLoaded = experienceCache.get(hostExperienceId);
|
|
2255
|
+
// Broadcast and restart tick engines for rooms running the host experience
|
|
2256
|
+
for (const room of rooms.values()) {
|
|
2257
|
+
if (room.experienceId === hostExperienceId) {
|
|
2258
|
+
room.broadcastToAll({ type: "experience_updated" });
|
|
2259
|
+
if (hostLoaded)
|
|
2260
|
+
maybeStartTickEngine(room, hostLoaded);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
const host = getHostExperience();
|
|
2264
|
+
res.json({ synced: true, title: host?.manifest?.title });
|
|
2265
|
+
}
|
|
2266
|
+
catch (err) {
|
|
2267
|
+
res.status(500).json({ error: toErrorMessage(err) });
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
// ── Room management routes ─────────────────────────────────
|
|
2271
|
+
app.get("/rooms", (_req, res) => {
|
|
2272
|
+
const roomList = Array.from(rooms.values()).map((room) => {
|
|
2273
|
+
const exp = experienceCache.get(room.experienceId);
|
|
2274
|
+
const st = room.sharedState;
|
|
2275
|
+
const flags = {};
|
|
2276
|
+
if (st._recruitment)
|
|
2277
|
+
flags._recruitment = st._recruitment;
|
|
2278
|
+
if (st._announcement)
|
|
2279
|
+
flags._announcement = st._announcement;
|
|
2280
|
+
return {
|
|
2281
|
+
roomId: room.id,
|
|
2282
|
+
experienceId: room.experienceId,
|
|
2283
|
+
experienceTitle: exp?.module?.manifest?.title || room.experienceId,
|
|
2284
|
+
participants: room.participantDetails(),
|
|
2285
|
+
participantCount: room.participants.size,
|
|
2286
|
+
eventCount: room.events.length,
|
|
2287
|
+
parentRoomId: room.parentRoomId,
|
|
2288
|
+
childRoomIds: room.childRoomIds,
|
|
2289
|
+
config: room.config,
|
|
2290
|
+
flags,
|
|
2291
|
+
};
|
|
2292
|
+
});
|
|
2293
|
+
res.json(roomList);
|
|
2294
|
+
});
|
|
2295
|
+
app.post("/rooms/spawn", async (req, res) => {
|
|
2296
|
+
try {
|
|
2297
|
+
const { experienceId, name, initialState, linkBack, sourceRoomId, config } = req.body;
|
|
2298
|
+
const source = sourceRoomId || DEFAULT_ROOM_ID;
|
|
2299
|
+
// Validate sourceRoomId exists (prevents rate limit bypass via crafted sourceRoomId)
|
|
2300
|
+
if (sourceRoomId && !rooms.has(source)) {
|
|
2301
|
+
res.status(400).json({ error: `Source room '${source}' not found` });
|
|
2302
|
+
return;
|
|
2303
|
+
}
|
|
2304
|
+
// Only the actual library room bypasses rate limiting
|
|
2305
|
+
const skipRateLimit = source === "library" && rooms.has("library");
|
|
2306
|
+
const result = await spawnRoom(source, {
|
|
2307
|
+
experienceId: experienceId || hostExperienceId,
|
|
2308
|
+
name,
|
|
2309
|
+
initialState,
|
|
2310
|
+
linkBack,
|
|
2311
|
+
config,
|
|
2312
|
+
skipRateLimit,
|
|
2313
|
+
});
|
|
2314
|
+
res.json(result);
|
|
2315
|
+
}
|
|
2316
|
+
catch (err) {
|
|
2317
|
+
res.status(400).json({ error: toErrorMessage(err) });
|
|
2318
|
+
}
|
|
2319
|
+
});
|
|
2320
|
+
// ── Room config schema endpoint ────────────────────────────
|
|
2321
|
+
app.get("/rooms/config-schema", async (req, res) => {
|
|
2322
|
+
try {
|
|
2323
|
+
const loaded = await getLoadedExperience();
|
|
2324
|
+
const roomConfigDef = loaded.module?.roomConfig;
|
|
2325
|
+
if (!roomConfigDef) {
|
|
2326
|
+
res.json({ hasConfig: false, experienceId: hostExperienceId });
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
const schema = roomConfigDef.schema
|
|
2330
|
+
? zodToJsonSchema(roomConfigDef.schema)
|
|
2331
|
+
: {};
|
|
2332
|
+
res.json({
|
|
2333
|
+
hasConfig: true,
|
|
2334
|
+
experienceId: hostExperienceId,
|
|
2335
|
+
schema,
|
|
2336
|
+
defaults: roomConfigDef.defaults || {},
|
|
2337
|
+
presets: Object.keys(roomConfigDef.presets || {}),
|
|
2338
|
+
description: roomConfigDef.description || "",
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
catch (err) {
|
|
2342
|
+
res.status(400).json({ error: toErrorMessage(err) });
|
|
2343
|
+
}
|
|
2344
|
+
});
|
|
2345
|
+
// ── Tick status endpoint ───────────────────────────────────
|
|
2346
|
+
app.get("/rooms/:roomId/tick-status", (req, res) => {
|
|
2347
|
+
const room = getRoom(req.params.roomId);
|
|
2348
|
+
if (!room) {
|
|
2349
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2350
|
+
return;
|
|
2351
|
+
}
|
|
2352
|
+
const engine = tickEngines.get(room.id);
|
|
2353
|
+
if (!engine) {
|
|
2354
|
+
res.json({ enabled: false, tickRateMs: 0, tickCount: 0, behaviorsTotal: 0, behaviorsEnabled: 0, lastTick: null });
|
|
2355
|
+
return;
|
|
2356
|
+
}
|
|
2357
|
+
res.json(engine.getStatus());
|
|
2358
|
+
});
|
|
2359
|
+
// ── Experiences endpoint (discovery for agents) ────────────
|
|
2360
|
+
app.get("/experiences", async (_req, res) => {
|
|
2361
|
+
try {
|
|
2362
|
+
const loaded = await getLoadedExperience();
|
|
2363
|
+
res.json([{
|
|
2364
|
+
id: loaded.module.manifest.id,
|
|
2365
|
+
title: loaded.module.manifest.title,
|
|
2366
|
+
description: loaded.module.manifest.description,
|
|
2367
|
+
version: loaded.module.manifest.version,
|
|
2368
|
+
loaded: true,
|
|
2369
|
+
hasRoomConfig: !!loaded.module.roomConfig,
|
|
2370
|
+
category: loaded.module.manifest.category,
|
|
2371
|
+
tags: loaded.module.manifest.tags,
|
|
2372
|
+
}]);
|
|
2373
|
+
}
|
|
2374
|
+
catch (err) {
|
|
2375
|
+
res.json([]);
|
|
2376
|
+
}
|
|
2377
|
+
});
|
|
2378
|
+
app.get("/rooms/:roomId", (req, res) => {
|
|
2379
|
+
const room = getRoom(req.params.roomId);
|
|
2380
|
+
if (!room) {
|
|
2381
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
const exp = getExperienceForRoom(room);
|
|
2385
|
+
let observation;
|
|
2386
|
+
const roomObserve = exp?.module?.observe ?? defaultObserve;
|
|
2387
|
+
const observeActorId = typeof req.query.actorId === "string" ? req.query.actorId : "viewer";
|
|
2388
|
+
try {
|
|
2389
|
+
observation = roomObserve(room.sharedState, null, observeActorId);
|
|
2390
|
+
}
|
|
2391
|
+
catch (err) {
|
|
2392
|
+
console.warn(`[observe] Error: ${toErrorMessage(err)}`);
|
|
2393
|
+
}
|
|
2394
|
+
res.json({
|
|
2395
|
+
roomId: room.id,
|
|
2396
|
+
experienceId: room.experienceId,
|
|
2397
|
+
sharedState: room.sharedState,
|
|
2398
|
+
stateVersion: room.stateVersion,
|
|
2399
|
+
participants: room.participantList(),
|
|
2400
|
+
events: room.events.slice(-ROOM_STATE_EVENT_HISTORY),
|
|
2401
|
+
parentRoomId: room.parentRoomId,
|
|
2402
|
+
childRoomIds: room.childRoomIds,
|
|
2403
|
+
config: room.config,
|
|
2404
|
+
observation,
|
|
2405
|
+
});
|
|
2406
|
+
});
|
|
2407
|
+
app.get("/rooms/:roomId/links", (req, res) => {
|
|
2408
|
+
const roomId = req.params.roomId;
|
|
2409
|
+
const links = roomLinks.filter((l) => l.parentRoomId === roomId || l.childRoomId === roomId);
|
|
2410
|
+
res.json(links);
|
|
2411
|
+
});
|
|
2412
|
+
// ── Slots (query participant slot definitions + occupancy) ────
|
|
2413
|
+
app.get("/rooms/:roomId/slots", (req, res) => {
|
|
2414
|
+
const room = getRoom(req.params.roomId);
|
|
2415
|
+
if (!room) {
|
|
2416
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
const exp = getExperienceForRoom(room);
|
|
2420
|
+
const slots = exp?.module?.manifest?.participantSlots || exp?.module?.participants || [];
|
|
2421
|
+
res.json({
|
|
2422
|
+
slots,
|
|
2423
|
+
participantDetails: room.participantDetails(),
|
|
2424
|
+
});
|
|
2425
|
+
});
|
|
2426
|
+
app.post("/rooms/:roomId/join", (req, res) => {
|
|
2427
|
+
const room = getRoom(req.params.roomId);
|
|
2428
|
+
if (!room) {
|
|
2429
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
handleJoin(room, req, res);
|
|
2433
|
+
});
|
|
2434
|
+
app.post("/rooms/:roomId/leave", (req, res) => {
|
|
2435
|
+
const room = getRoom(req.params.roomId);
|
|
2436
|
+
if (!room) {
|
|
2437
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
handleLeave(room, req, res);
|
|
2441
|
+
});
|
|
2442
|
+
app.post("/rooms/:roomId/kick", (req, res) => {
|
|
2443
|
+
const room = getRoom(req.params.roomId);
|
|
2444
|
+
if (!room) {
|
|
2445
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2446
|
+
return;
|
|
2447
|
+
}
|
|
2448
|
+
const result = handleKick(room, req.body.kickerActorId, req.body.targetActorId);
|
|
2449
|
+
res.status(result.error ? 400 : 200).json(result);
|
|
2450
|
+
});
|
|
2451
|
+
// ── Reset room to initial state ──────────────────────────────
|
|
2452
|
+
app.post("/rooms/:roomId/reset", (req, res) => {
|
|
2453
|
+
const room = getRoom(req.params.roomId);
|
|
2454
|
+
if (!room) {
|
|
2455
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
const loaded = getExperienceForRoom(room);
|
|
2459
|
+
if (!loaded) {
|
|
2460
|
+
res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
|
|
2461
|
+
return;
|
|
2462
|
+
}
|
|
2463
|
+
const config = resolveRoomConfig(loaded.module, room.config);
|
|
2464
|
+
const initialState = resolveInitialState(loaded.module, config);
|
|
2465
|
+
room.sharedState = initialState;
|
|
2466
|
+
room.events.length = 0;
|
|
2467
|
+
// Reset all participant event cursors so agents see the reset event
|
|
2468
|
+
for (const [, p] of room.participants) {
|
|
2469
|
+
p.eventCursor = 0;
|
|
2470
|
+
}
|
|
2471
|
+
// Append a reset event so stop hooks see it
|
|
2472
|
+
const resetEvent = {
|
|
2473
|
+
id: `${Date.now()}-system-${Math.random().toString(36).slice(2, 6)}`,
|
|
2474
|
+
ts: Date.now(),
|
|
2475
|
+
actorId: "system",
|
|
2476
|
+
tool: "_reset",
|
|
2477
|
+
input: {},
|
|
2478
|
+
output: { reset: true },
|
|
2479
|
+
};
|
|
2480
|
+
room.appendEvent(resetEvent);
|
|
2481
|
+
roomEvents.emit(`room:${room.id}`);
|
|
2482
|
+
// Reset sends full state (not delta) since the entire state changed
|
|
2483
|
+
room.resetDeltaTracking();
|
|
2484
|
+
room.broadcastStateUpdate({
|
|
2485
|
+
changedBy: "system",
|
|
2486
|
+
tool: "_reset",
|
|
2487
|
+
}, true);
|
|
2488
|
+
res.json({ ok: true });
|
|
2489
|
+
});
|
|
2490
|
+
// ── Delete room (cleanup spawned rooms) ─────────────────────
|
|
2491
|
+
app.delete("/rooms/:roomId", (req, res) => {
|
|
2492
|
+
const roomId = req.params.roomId;
|
|
2493
|
+
if (roomId === DEFAULT_ROOM_ID) {
|
|
2494
|
+
res.status(400).json({ error: "Cannot delete the default room." });
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const room = getRoom(roomId);
|
|
2498
|
+
if (!room) {
|
|
2499
|
+
res.status(404).json({ error: roomNotFoundError(roomId) });
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
// Stop tick engine
|
|
2503
|
+
stopTickEngine(roomId);
|
|
2504
|
+
cleanupRoomLinks(roomId, room);
|
|
2505
|
+
// Clean up agent memory entries before clearing participants
|
|
2506
|
+
const expForDelete = getExperienceForRoom(room);
|
|
2507
|
+
const expIdForDelete = expForDelete?.module?.manifest?.id || room.experienceId;
|
|
2508
|
+
for (const [actorId] of room.participants) {
|
|
2509
|
+
agentMemory.delete(`${expIdForDelete}:${actorId}`);
|
|
2510
|
+
}
|
|
2511
|
+
// Close all WebSocket connections for this room
|
|
2512
|
+
for (const [ws] of room.wsConnections) {
|
|
2513
|
+
try {
|
|
2514
|
+
ws.close(1001, "Room deleted");
|
|
2515
|
+
}
|
|
2516
|
+
catch { }
|
|
2517
|
+
}
|
|
2518
|
+
room.wsConnections.clear();
|
|
2519
|
+
room.participants.clear();
|
|
2520
|
+
// Clean up blobs associated with this room
|
|
2521
|
+
for (const [key, meta] of blobMeta) {
|
|
2522
|
+
if (meta.roomId === roomId) {
|
|
2523
|
+
blobStore.delete(key);
|
|
2524
|
+
blobMeta.delete(key);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
// Wake up any long-poll listeners waiting on this room
|
|
2528
|
+
roomEvents.emit(`room:${roomId}`);
|
|
2529
|
+
// Clean up event listeners, GC tracking, and room data
|
|
2530
|
+
roomEvents.removeAllListeners(`room:${roomId}`);
|
|
2531
|
+
rooms.delete(roomId);
|
|
2532
|
+
roomBrowserErrors.delete(roomId);
|
|
2533
|
+
lastBrowserErrorAt.delete(roomId);
|
|
2534
|
+
spawnCounts.delete(roomId);
|
|
2535
|
+
roomEmptySince.delete(roomId);
|
|
2536
|
+
res.json({ deleted: true, roomId });
|
|
2537
|
+
});
|
|
2538
|
+
app.post("/rooms/:roomId/tools/:toolName", (req, res) => {
|
|
2539
|
+
const room = getRoom(req.params.roomId);
|
|
2540
|
+
if (!room) {
|
|
2541
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
handleTool(room, req, res);
|
|
2545
|
+
});
|
|
2546
|
+
app.post("/rooms/:roomId/tools-batch", (req, res) => {
|
|
2547
|
+
const room = getRoom(req.params.roomId);
|
|
2548
|
+
if (!room) {
|
|
2549
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
handleBatch(room, req, res);
|
|
2553
|
+
});
|
|
2554
|
+
// ── Stream endpoints (REST API for MCP agents) ──────────────
|
|
2555
|
+
function handleStreamRequest(room, streamName, req, res) {
|
|
2556
|
+
const exp = getExperienceForRoom(room);
|
|
2557
|
+
if (!exp?.module?.streams) {
|
|
2558
|
+
res.status(404).json({ error: "No streams defined" });
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
const streamDef = exp.module.streams.find((s) => s.name === streamName);
|
|
2562
|
+
if (!streamDef) {
|
|
2563
|
+
res.status(404).json({ error: `Stream '${streamName}' not found` });
|
|
2564
|
+
return;
|
|
2565
|
+
}
|
|
2566
|
+
const { actorId, input } = req.body;
|
|
2567
|
+
// Require actorId — anonymous stream writes are not allowed
|
|
2568
|
+
if (!actorId) {
|
|
2569
|
+
res.status(400).json({ error: "actorId is required for stream updates" });
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
// Verify the caller is a participant
|
|
2573
|
+
if (!room.participants.has(actorId)) {
|
|
2574
|
+
res.status(403).json({ error: `Actor '${actorId}' is not a participant in room '${room.id}'. Call /join first.` });
|
|
2575
|
+
return;
|
|
2576
|
+
}
|
|
2577
|
+
// Rate limiting
|
|
2578
|
+
const rateLimitKey = `${room.id}:${actorId}:${streamName}`;
|
|
2579
|
+
const now = Date.now();
|
|
2580
|
+
const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
|
|
2581
|
+
const windowMs = STREAM_RATE_WINDOW_MS;
|
|
2582
|
+
if (!streamRateLimits.has(rateLimitKey)) {
|
|
2583
|
+
streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
|
|
2584
|
+
}
|
|
2585
|
+
const rl = streamRateLimits.get(rateLimitKey);
|
|
2586
|
+
if (now - rl.windowStart > windowMs) {
|
|
2587
|
+
rl.count = 0;
|
|
2588
|
+
rl.windowStart = now;
|
|
2589
|
+
}
|
|
2590
|
+
if (rl.count >= rateLimit) {
|
|
2591
|
+
res.status(429).json({ error: `Rate limited: max ${rateLimit} updates/sec for stream '${streamName}'. Try again shortly.` });
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
rl.count++;
|
|
2595
|
+
// Validate input
|
|
2596
|
+
let validatedInput = input;
|
|
2597
|
+
if (streamDef.input_schema?.parse) {
|
|
2598
|
+
try {
|
|
2599
|
+
validatedInput = streamDef.input_schema.parse(input);
|
|
2600
|
+
}
|
|
2601
|
+
catch (err) {
|
|
2602
|
+
res.status(400).json({ error: toErrorMessage(err) });
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
// Execute merge
|
|
2607
|
+
try {
|
|
2608
|
+
room.sharedState = streamDef.merge(room.sharedState, validatedInput, actorId);
|
|
2609
|
+
}
|
|
2610
|
+
catch (err) {
|
|
2611
|
+
res.status(400).json({ error: toErrorMessage(err) });
|
|
2612
|
+
return;
|
|
2613
|
+
}
|
|
2614
|
+
// Broadcast state update (no event log entry for streams, delta-compressed)
|
|
2615
|
+
room.broadcastStateUpdate({
|
|
2616
|
+
changedBy: actorId,
|
|
2617
|
+
stream: streamName,
|
|
2618
|
+
});
|
|
2619
|
+
roomEvents.emit(`room:${room.id}`);
|
|
2620
|
+
res.json({ ok: true });
|
|
2621
|
+
}
|
|
2622
|
+
app.post("/streams/:streamName", (req, res) => {
|
|
2623
|
+
handleStreamRequest(getDefaultRoom(), req.params.streamName, req, res);
|
|
2624
|
+
});
|
|
2625
|
+
app.post("/rooms/:roomId/streams/:streamName", (req, res) => {
|
|
2626
|
+
const room = getRoom(req.params.roomId);
|
|
2627
|
+
if (!room) {
|
|
2628
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
handleStreamRequest(room, req.params.streamName, req, res);
|
|
2632
|
+
});
|
|
2633
|
+
app.get("/rooms/:roomId/events", (req, res) => {
|
|
2634
|
+
const room = getRoom(req.params.roomId);
|
|
2635
|
+
if (!room) {
|
|
2636
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
handleEvents(room, req, res);
|
|
2640
|
+
});
|
|
2641
|
+
// ── Room-specific bundle (serves the correct experience's client bundle) ──
|
|
2642
|
+
app.get("/rooms/:roomId/bundle", async (req, res) => {
|
|
2643
|
+
// Wait for any in-progress hot-reload to complete before serving
|
|
2644
|
+
if (rebuildingPromise)
|
|
2645
|
+
await rebuildingPromise;
|
|
2646
|
+
const room = getRoom(req.params.roomId);
|
|
2647
|
+
if (!room) {
|
|
2648
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2649
|
+
return;
|
|
2650
|
+
}
|
|
2651
|
+
const loaded = getExperienceForRoom(room);
|
|
2652
|
+
if (!loaded) {
|
|
2653
|
+
res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
res.setHeader("Content-Type", "text/javascript");
|
|
2657
|
+
setNoCacheHeaders(res);
|
|
2658
|
+
res.send(loaded.clientBundle);
|
|
2659
|
+
});
|
|
2660
|
+
// ── Protocol experience HTML canvas ───────────────────────
|
|
2661
|
+
app.get("/rooms/:roomId/canvas", async (req, res) => {
|
|
2662
|
+
const room = getRoom(req.params.roomId);
|
|
2663
|
+
if (!room) {
|
|
2664
|
+
res.status(404).json({ error: roomNotFoundError(req.params.roomId) });
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const loaded = getExperienceForRoom(room);
|
|
2668
|
+
if (!loaded) {
|
|
2669
|
+
res.status(500).json({ error: experienceNotLoadedError(room.experienceId) });
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
const canvasPath = loaded.module?._canvasPath;
|
|
2673
|
+
if (!canvasPath) {
|
|
2674
|
+
res.status(404).json({ error: "This experience has no HTML canvas" });
|
|
2675
|
+
return;
|
|
2676
|
+
}
|
|
2677
|
+
res.setHeader("Content-Type", "text/html");
|
|
2678
|
+
setNoCacheHeaders(res);
|
|
2679
|
+
res.sendFile(canvasPath);
|
|
2680
|
+
});
|
|
2681
|
+
// ── MCP config (for remote joiners) ───────────────────────
|
|
2682
|
+
app.get("/mcp-config", (_req, res) => {
|
|
2683
|
+
const serverUrl = getBaseUrl();
|
|
2684
|
+
res.json({
|
|
2685
|
+
mcpServers: {
|
|
2686
|
+
"vibevibes-remote": {
|
|
2687
|
+
command: "npx",
|
|
2688
|
+
args: ["-y", "@vibevibes/mcp@latest"],
|
|
2689
|
+
env: {
|
|
2690
|
+
VIBEVIBES_SERVER_URL: serverUrl,
|
|
2691
|
+
},
|
|
2692
|
+
},
|
|
2693
|
+
},
|
|
2694
|
+
instructions: [
|
|
2695
|
+
`Add the above to your .mcp.json to join this room.`,
|
|
2696
|
+
`Or run: npx @vibevibes/mcp@latest ${serverUrl}`,
|
|
2697
|
+
],
|
|
2698
|
+
});
|
|
2699
|
+
});
|
|
2700
|
+
// ── Catch-all: serve viewer for path-based room routing (e.g. /room/room-abc123) ──
|
|
2701
|
+
// Must be AFTER all API routes so it doesn't shadow them.
|
|
2702
|
+
app.get("*", (req, res, next) => {
|
|
2703
|
+
// Skip API-like paths and static assets
|
|
2704
|
+
if (req.path.startsWith("/rooms/") || req.path.startsWith("/tools/") ||
|
|
2705
|
+
req.path.startsWith("/viewer/") || req.path.startsWith("/blobs/") ||
|
|
2706
|
+
req.path.startsWith("/streams/") || req.path.endsWith(".js") ||
|
|
2707
|
+
req.path.endsWith(".css") || req.path.endsWith(".map")) {
|
|
2708
|
+
next();
|
|
2709
|
+
return;
|
|
2710
|
+
}
|
|
2711
|
+
// Serve the viewer — client-side JS will extract room ID from the path
|
|
2712
|
+
setNoCacheHeaders(res);
|
|
2713
|
+
res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
|
|
2714
|
+
});
|
|
2715
|
+
// ── Client bundle smoke test ──────────────────────────────────
|
|
2716
|
+
// Fetches the client bundle from the running server and tries to parse it.
|
|
2717
|
+
// Catches SyntaxErrors (like duplicate declarations) before the user sees them.
|
|
2718
|
+
async function smokeTestClientBundle(port) {
|
|
2719
|
+
try {
|
|
2720
|
+
const res = await fetch(`http://localhost:${port}/bundle`);
|
|
2721
|
+
const bundleCode = await res.text();
|
|
2722
|
+
if (bundleCode) {
|
|
2723
|
+
const error = validateClientBundle(bundleCode);
|
|
2724
|
+
if (error) {
|
|
2725
|
+
console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
|
|
2726
|
+
console.error(` ${error}`);
|
|
2727
|
+
console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
|
|
2728
|
+
}
|
|
2729
|
+
else {
|
|
2730
|
+
console.log(` Smoke test: client bundle OK`);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
}
|
|
2734
|
+
catch (err) {
|
|
2735
|
+
console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
|
|
2736
|
+
console.error(` ${toErrorMessage(err)}`);
|
|
2737
|
+
console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
// ── Start server ───────────────────────────────────────────
|
|
2741
|
+
export async function startServer(config) {
|
|
2742
|
+
// Apply config
|
|
2743
|
+
if (config?.projectRoot) {
|
|
2744
|
+
PROJECT_ROOT = config.projectRoot;
|
|
2745
|
+
}
|
|
2746
|
+
if (config?.port) {
|
|
2747
|
+
PORT = config.port;
|
|
2748
|
+
}
|
|
2749
|
+
if (!PROJECT_ROOT) {
|
|
2750
|
+
throw new Error("@vibevibes/runtime: projectRoot is required. Pass it via ServerConfig.");
|
|
2751
|
+
}
|
|
2752
|
+
await loadHost();
|
|
2753
|
+
// Create default room (with default config + initial state from experience)
|
|
2754
|
+
const hostExp = getHostExperience();
|
|
2755
|
+
const defaultConfig = resolveRoomConfig(hostExp, undefined);
|
|
2756
|
+
const hostInitialState = resolveInitialState(hostExp, defaultConfig);
|
|
2757
|
+
const defaultRoom = new Room(DEFAULT_ROOM_ID, hostExperienceId, hostInitialState, defaultConfig);
|
|
2758
|
+
rooms.set(DEFAULT_ROOM_ID, defaultRoom);
|
|
2759
|
+
// Start tick engine for default room if host experience uses tick netcode
|
|
2760
|
+
const hostLoaded = experienceCache.get(hostExperienceId);
|
|
2761
|
+
if (hostLoaded) {
|
|
2762
|
+
maybeStartTickEngine(defaultRoom, hostLoaded);
|
|
2763
|
+
}
|
|
2764
|
+
console.log(` Experience: ${hostExperienceId}`);
|
|
2765
|
+
const server = http.createServer(app);
|
|
2766
|
+
const wss = new WebSocketServer({ server, maxPayload: WS_MAX_PAYLOAD_BYTES });
|
|
2767
|
+
wss.on("error", (err) => { console.error("[WSS] server error:", err.message); });
|
|
2768
|
+
// Grace period timers for WS close → participant deletion (allows page-refresh reconnects)
|
|
2769
|
+
const wsCloseTimers = new Map();
|
|
2770
|
+
wss.on("connection", (ws) => {
|
|
2771
|
+
// Heartbeat: mark alive on connection and on pong
|
|
2772
|
+
const hbWs = ws;
|
|
2773
|
+
hbWs.isAlive = true;
|
|
2774
|
+
ws.on("pong", () => { hbWs.isAlive = true; });
|
|
2775
|
+
ws.on("error", (err) => { console.error("[WS] connection error:", err.message); });
|
|
2776
|
+
ws.on("message", (data) => {
|
|
2777
|
+
try {
|
|
2778
|
+
const msg = JSON.parse(data.toString());
|
|
2779
|
+
if (msg.type === "join") {
|
|
2780
|
+
const username = (msg.username || "viewer").slice(0, 100);
|
|
2781
|
+
const wsOwner = msg.owner || username;
|
|
2782
|
+
const roomId = msg.roomId || DEFAULT_ROOM_ID;
|
|
2783
|
+
const room = getRoom(roomId);
|
|
2784
|
+
if (!room) {
|
|
2785
|
+
ws.send(JSON.stringify({ type: "error", error: roomNotFoundError(roomId) }));
|
|
2786
|
+
return;
|
|
2787
|
+
}
|
|
2788
|
+
// Reuse actorId if the viewer sends one back (e.g. on refresh)
|
|
2789
|
+
let actorId;
|
|
2790
|
+
if (msg.actorId) {
|
|
2791
|
+
// Check kick status BEFORE evicting stale WS or reusing actorId
|
|
2792
|
+
if (room.kickedActors.has(msg.actorId)) {
|
|
2793
|
+
ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (room.kickedOwners.has(wsOwner)) {
|
|
2797
|
+
ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
|
|
2798
|
+
return;
|
|
2799
|
+
}
|
|
2800
|
+
// Check if the old actorId is held by a stale WS (refresh scenario)
|
|
2801
|
+
let staleWs = null;
|
|
2802
|
+
for (const [existingWs, existingId] of room.wsConnections.entries()) {
|
|
2803
|
+
if (existingId === msg.actorId && existingWs !== ws) {
|
|
2804
|
+
staleWs = existingWs;
|
|
2805
|
+
break;
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
if (staleWs) {
|
|
2809
|
+
// Evict the stale connection
|
|
2810
|
+
room.wsConnections.delete(staleWs);
|
|
2811
|
+
try {
|
|
2812
|
+
staleWs.close();
|
|
2813
|
+
}
|
|
2814
|
+
catch { }
|
|
2815
|
+
}
|
|
2816
|
+
// Cancel any pending close grace timer for this actorId
|
|
2817
|
+
const closeTimer = wsCloseTimers.get(msg.actorId);
|
|
2818
|
+
if (closeTimer) {
|
|
2819
|
+
clearTimeout(closeTimer);
|
|
2820
|
+
wsCloseTimers.delete(msg.actorId);
|
|
2821
|
+
}
|
|
2822
|
+
// Re-add participant if it was deleted during grace window
|
|
2823
|
+
if (!room.participants.has(msg.actorId)) {
|
|
2824
|
+
room.participants.set(msg.actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner });
|
|
2825
|
+
}
|
|
2826
|
+
// Reuse the old actorId
|
|
2827
|
+
actorId = msg.actorId;
|
|
2828
|
+
}
|
|
2829
|
+
else {
|
|
2830
|
+
// Block kicked owners before burning an actorId counter slot
|
|
2831
|
+
if (room.kickedOwners.has(wsOwner)) {
|
|
2832
|
+
ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
|
|
2833
|
+
return;
|
|
2834
|
+
}
|
|
2835
|
+
// Dedup by owner: reuse existing participant if same owner already joined (matches HTTP join logic)
|
|
2836
|
+
let existingActorId;
|
|
2837
|
+
for (const [aid, p] of room.participants) {
|
|
2838
|
+
if (p.owner === wsOwner) {
|
|
2839
|
+
existingActorId = aid;
|
|
2840
|
+
break;
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
if (existingActorId) {
|
|
2844
|
+
actorId = existingActorId;
|
|
2845
|
+
// Evict any stale WS for this actorId
|
|
2846
|
+
for (const [existingWs, existingId] of room.wsConnections.entries()) {
|
|
2847
|
+
if (existingId === existingActorId && existingWs !== ws) {
|
|
2848
|
+
room.wsConnections.delete(existingWs);
|
|
2849
|
+
try {
|
|
2850
|
+
existingWs.close();
|
|
2851
|
+
}
|
|
2852
|
+
catch { }
|
|
2853
|
+
break;
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
else {
|
|
2858
|
+
// Match human join against participant slots (if defined)
|
|
2859
|
+
const exp = getExperienceForRoom(room);
|
|
2860
|
+
const pSlots = exp?.module?.manifest?.participantSlots || exp?.module?.participants;
|
|
2861
|
+
let wsSlotRole;
|
|
2862
|
+
if (pSlots?.length) {
|
|
2863
|
+
const roleOccupancy = new Map();
|
|
2864
|
+
for (const [, p] of room.participants) {
|
|
2865
|
+
if (p.role)
|
|
2866
|
+
roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
|
|
2867
|
+
}
|
|
2868
|
+
const hasCapacity = (slot) => {
|
|
2869
|
+
const max = slot.maxInstances ?? 1;
|
|
2870
|
+
const current = roleOccupancy.get(slot.role) || 0;
|
|
2871
|
+
return current < max;
|
|
2872
|
+
};
|
|
2873
|
+
// Match: requested role → type-specific → any
|
|
2874
|
+
let matched;
|
|
2875
|
+
if (msg.role) {
|
|
2876
|
+
matched = pSlots.find((s) => s.role === msg.role && (!s.type || s.type === "human" || s.type === "any") && hasCapacity(s));
|
|
2877
|
+
}
|
|
2878
|
+
if (!matched) {
|
|
2879
|
+
matched = pSlots.find((s) => s.type === "human" && hasCapacity(s));
|
|
2880
|
+
}
|
|
2881
|
+
if (!matched) {
|
|
2882
|
+
matched = pSlots.find((s) => (!s.type || s.type === "any") && hasCapacity(s));
|
|
2883
|
+
}
|
|
2884
|
+
// Fallback: if all human-compatible slots are full, use first compatible
|
|
2885
|
+
if (!matched) {
|
|
2886
|
+
matched = pSlots.find((s) => !s.type || s.type === "human" || s.type === "any");
|
|
2887
|
+
}
|
|
2888
|
+
if (matched) {
|
|
2889
|
+
wsSlotRole = matched.role;
|
|
2890
|
+
const base = matched.role.toLowerCase().replace(/\s+/g, "-");
|
|
2891
|
+
actorId = assignActorId(username, "human", base);
|
|
2892
|
+
}
|
|
2893
|
+
else {
|
|
2894
|
+
actorId = assignActorId(username, "human");
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
else {
|
|
2898
|
+
actorId = assignActorId(username, "human");
|
|
2899
|
+
}
|
|
2900
|
+
const wsRole = wsSlotRole || msg.role || undefined;
|
|
2901
|
+
room.participants.set(actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner, role: wsRole });
|
|
2902
|
+
} // end: new actorId assignment block
|
|
2903
|
+
}
|
|
2904
|
+
// For reconnect case: if msg.actorId no longer has a participant entry
|
|
2905
|
+
// (e.g., after room reset), reject the stale reconnect. Viewer must do a fresh join.
|
|
2906
|
+
if (msg.actorId && !room.participants.has(msg.actorId)) {
|
|
2907
|
+
ws.send(JSON.stringify({ type: "error", error: "Session expired. Please rejoin the room." }));
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2910
|
+
// Track this WS → actorId in the room
|
|
2911
|
+
room.wsConnections.set(ws, actorId);
|
|
2912
|
+
// Resolve role from participant entry
|
|
2913
|
+
const resolvedWsRole = room.participants.get(actorId)?.role;
|
|
2914
|
+
// Send initial state (includes stateVersion for delta tracking)
|
|
2915
|
+
ws.send(JSON.stringify({
|
|
2916
|
+
type: "joined",
|
|
2917
|
+
roomId: room.id,
|
|
2918
|
+
actorId,
|
|
2919
|
+
role: resolvedWsRole,
|
|
2920
|
+
sharedState: room.sharedState,
|
|
2921
|
+
stateVersion: room.stateVersion,
|
|
2922
|
+
participants: room.participantList(),
|
|
2923
|
+
participantDetails: room.participantDetails(),
|
|
2924
|
+
events: room.events.slice(-JOIN_EVENT_HISTORY),
|
|
2925
|
+
config: room.config,
|
|
2926
|
+
}));
|
|
2927
|
+
// Broadcast presence update to others in this room
|
|
2928
|
+
broadcastPresenceUpdate(room);
|
|
2929
|
+
}
|
|
2930
|
+
if (msg.type === "ephemeral") {
|
|
2931
|
+
// Relay ephemeral data to all OTHER clients in the same room.
|
|
2932
|
+
// No validation, no persistence — this is the fast path for cursors,
|
|
2933
|
+
// typing indicators, follow mode, and other high-frequency cosmetic data.
|
|
2934
|
+
// Size guard: reject oversized payloads to prevent DoS amplification
|
|
2935
|
+
const ephPayload = JSON.stringify(msg.data);
|
|
2936
|
+
if (ephPayload.length > WS_EPHEMERAL_MAX_BYTES) {
|
|
2937
|
+
ws.send(JSON.stringify({ type: "error", error: "Ephemeral payload too large (max 64KB)" }));
|
|
2938
|
+
return;
|
|
2939
|
+
}
|
|
2940
|
+
for (const room of rooms.values()) {
|
|
2941
|
+
const senderActorId = room.wsConnections.get(ws);
|
|
2942
|
+
if (senderActorId) {
|
|
2943
|
+
const payload = JSON.stringify({
|
|
2944
|
+
type: "ephemeral",
|
|
2945
|
+
actorId: senderActorId,
|
|
2946
|
+
data: msg.data,
|
|
2947
|
+
});
|
|
2948
|
+
for (const [otherWs, otherId] of room.wsConnections.entries()) {
|
|
2949
|
+
if (otherWs !== ws && otherWs.readyState === WebSocket.OPEN) {
|
|
2950
|
+
otherWs.send(payload);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
break;
|
|
2954
|
+
}
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
if (msg.type === "stream") {
|
|
2958
|
+
// Validate stream name
|
|
2959
|
+
if (typeof msg.name !== "string" || !msg.name) {
|
|
2960
|
+
ws.send(JSON.stringify({ type: "stream_error", error: "stream.name must be a non-empty string" }));
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
// High-frequency continuous state channel
|
|
2964
|
+
// Find which room this WS belongs to
|
|
2965
|
+
for (const room of rooms.values()) {
|
|
2966
|
+
const senderActorId = room.wsConnections.get(ws);
|
|
2967
|
+
if (!senderActorId)
|
|
2968
|
+
continue;
|
|
2969
|
+
const exp = getExperienceForRoom(room);
|
|
2970
|
+
if (!exp?.module?.streams)
|
|
2971
|
+
break;
|
|
2972
|
+
const streamDef = exp.module.streams.find((s) => s.name === msg.name);
|
|
2973
|
+
if (!streamDef) {
|
|
2974
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' not found` }));
|
|
2975
|
+
break;
|
|
2976
|
+
}
|
|
2977
|
+
// Rate limiting
|
|
2978
|
+
const rateLimitKey = `${room.id}:${senderActorId}:${msg.name}`;
|
|
2979
|
+
const now = Date.now();
|
|
2980
|
+
const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
|
|
2981
|
+
const windowMs = STREAM_RATE_WINDOW_MS;
|
|
2982
|
+
if (!streamRateLimits.has(rateLimitKey)) {
|
|
2983
|
+
streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
|
|
2984
|
+
}
|
|
2985
|
+
const rl = streamRateLimits.get(rateLimitKey);
|
|
2986
|
+
if (now - rl.windowStart > windowMs) {
|
|
2987
|
+
rl.count = 0;
|
|
2988
|
+
rl.windowStart = now;
|
|
2989
|
+
}
|
|
2990
|
+
if (rl.count >= rateLimit) {
|
|
2991
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Rate limited: max ${rateLimit}/sec for '${msg.name}'` }));
|
|
2992
|
+
break;
|
|
2993
|
+
}
|
|
2994
|
+
rl.count++;
|
|
2995
|
+
// Validate input
|
|
2996
|
+
let validatedInput = msg.input;
|
|
2997
|
+
if (streamDef.input_schema?.parse) {
|
|
2998
|
+
try {
|
|
2999
|
+
validatedInput = streamDef.input_schema.parse(msg.input);
|
|
3000
|
+
}
|
|
3001
|
+
catch (err) {
|
|
3002
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Invalid input for stream '${msg.name}': ${toErrorMessage(err)}` }));
|
|
3003
|
+
break;
|
|
3004
|
+
}
|
|
3005
|
+
}
|
|
3006
|
+
// Execute merge
|
|
3007
|
+
try {
|
|
3008
|
+
room.sharedState = streamDef.merge(room.sharedState, validatedInput, senderActorId);
|
|
3009
|
+
}
|
|
3010
|
+
catch (err) {
|
|
3011
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' merge failed: ${toErrorMessage(err)}` }));
|
|
3012
|
+
break;
|
|
3013
|
+
}
|
|
3014
|
+
// Broadcast state update (no event log entry for streams, delta-compressed)
|
|
3015
|
+
room.broadcastStateUpdate({
|
|
3016
|
+
changedBy: senderActorId,
|
|
3017
|
+
stream: msg.name,
|
|
3018
|
+
});
|
|
3019
|
+
roomEvents.emit(`room:${room.id}`);
|
|
3020
|
+
break;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
3023
|
+
if (msg.type === "kick") {
|
|
3024
|
+
let found = false;
|
|
3025
|
+
for (const room of rooms.values()) {
|
|
3026
|
+
const kickerActorId = room.wsConnections.get(ws);
|
|
3027
|
+
if (!kickerActorId)
|
|
3028
|
+
continue;
|
|
3029
|
+
found = true;
|
|
3030
|
+
const result = handleKick(room, kickerActorId, msg.targetActorId);
|
|
3031
|
+
if (result.error) {
|
|
3032
|
+
console.warn(`[kick] ${kickerActorId} failed to kick ${msg.targetActorId}: ${result.error}`);
|
|
3033
|
+
ws.send(JSON.stringify({ type: "kick_error", error: result.error }));
|
|
3034
|
+
}
|
|
3035
|
+
else {
|
|
3036
|
+
ws.send(JSON.stringify({ type: "kick_success", actorId: msg.targetActorId }));
|
|
3037
|
+
}
|
|
3038
|
+
break;
|
|
3039
|
+
}
|
|
3040
|
+
if (!found) {
|
|
3041
|
+
ws.send(JSON.stringify({ type: "kick_error", error: "You are not in any room" }));
|
|
3042
|
+
}
|
|
3043
|
+
}
|
|
3044
|
+
if (msg.type === "screenshot_response") {
|
|
3045
|
+
const pending = pendingScreenshots.get(msg.id);
|
|
3046
|
+
if (pending && pending.viewerWs === ws) {
|
|
3047
|
+
clearTimeout(pending.timer);
|
|
3048
|
+
pendingScreenshots.delete(msg.id);
|
|
3049
|
+
if (msg.error) {
|
|
3050
|
+
pending.reject(new Error(msg.error));
|
|
3051
|
+
}
|
|
3052
|
+
else if (msg.dataUrl) {
|
|
3053
|
+
// Validate data URL format before decoding
|
|
3054
|
+
if (typeof msg.dataUrl !== "string" || !msg.dataUrl.startsWith("data:image/png;base64,")) {
|
|
3055
|
+
pending.reject(new Error("Invalid screenshot data URL format"));
|
|
3056
|
+
}
|
|
3057
|
+
else {
|
|
3058
|
+
const base64Data = msg.dataUrl.slice("data:image/png;base64,".length);
|
|
3059
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
3060
|
+
pending.resolve(buffer);
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
else {
|
|
3064
|
+
pending.reject(new Error("Empty screenshot response"));
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
catch (err) {
|
|
3070
|
+
// Log unexpected errors (not JSON parse failures) for debugging
|
|
3071
|
+
if (!(err instanceof SyntaxError)) {
|
|
3072
|
+
console.error("[WS] Unexpected handler error:", err instanceof Error ? err.message : String(err));
|
|
3073
|
+
}
|
|
3074
|
+
}
|
|
3075
|
+
});
|
|
3076
|
+
ws.on("close", () => {
|
|
3077
|
+
// Clean up participant from whichever room this WS belongs to.
|
|
3078
|
+
// Only delete human participants — AI agents join via HTTP and don't rely on WS for presence.
|
|
3079
|
+
for (const room of rooms.values()) {
|
|
3080
|
+
const actorId = room.wsConnections.get(ws);
|
|
3081
|
+
if (actorId) {
|
|
3082
|
+
room.wsConnections.delete(ws);
|
|
3083
|
+
const participant = room.participants.get(actorId);
|
|
3084
|
+
if (!participant || participant.type === "human") {
|
|
3085
|
+
// Grace period: delay participant deletion to allow page refresh reconnects.
|
|
3086
|
+
// If the same actorId reconnects within the grace period, the deletion is cancelled.
|
|
3087
|
+
const timer = setTimeout(() => {
|
|
3088
|
+
wsCloseTimers.delete(actorId);
|
|
3089
|
+
// Only delete if no new WS reconnected for this actorId
|
|
3090
|
+
let reconnected = false;
|
|
3091
|
+
for (const [, wsActorId] of room.wsConnections) {
|
|
3092
|
+
if (wsActorId === actorId) {
|
|
3093
|
+
reconnected = true;
|
|
3094
|
+
break;
|
|
3095
|
+
}
|
|
3096
|
+
}
|
|
3097
|
+
if (!reconnected) {
|
|
3098
|
+
room.participants.delete(actorId);
|
|
3099
|
+
broadcastPresenceUpdate(room);
|
|
3100
|
+
}
|
|
3101
|
+
}, WS_CLOSE_GRACE_MS);
|
|
3102
|
+
// Cancel any previous timer for this actorId and store the new one
|
|
3103
|
+
const prev = wsCloseTimers.get(actorId);
|
|
3104
|
+
if (prev)
|
|
3105
|
+
clearTimeout(prev);
|
|
3106
|
+
wsCloseTimers.set(actorId, timer);
|
|
3107
|
+
}
|
|
3108
|
+
break;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
});
|
|
3112
|
+
});
|
|
3113
|
+
// ── WebSocket heartbeat interval ──────────────────────────
|
|
3114
|
+
const heartbeatInterval = setInterval(() => {
|
|
3115
|
+
for (const ws of wss.clients) {
|
|
3116
|
+
if (ws.isAlive === false) {
|
|
3117
|
+
// Dead connection — clean up from rooms and terminate
|
|
3118
|
+
for (const room of rooms.values()) {
|
|
3119
|
+
const actorId = room.wsConnections.get(ws);
|
|
3120
|
+
if (actorId) {
|
|
3121
|
+
room.participants.delete(actorId);
|
|
3122
|
+
room.wsConnections.delete(ws);
|
|
3123
|
+
broadcastPresenceUpdate(room);
|
|
3124
|
+
break;
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
ws.terminate();
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
ws.isAlive = false;
|
|
3131
|
+
ws.ping();
|
|
3132
|
+
}
|
|
3133
|
+
}, WS_HEARTBEAT_INTERVAL_MS);
|
|
3134
|
+
// ── AI agent heartbeat sweep ──────────────────────────────
|
|
3135
|
+
// Evict AI participants that haven't polled /agent-context within the timeout.
|
|
3136
|
+
// This cleans up zombie agents after crashes, SIGKILLs, etc.
|
|
3137
|
+
// 5 minutes — generous enough for Claude Code's turn model.
|
|
3138
|
+
const AI_HEARTBEAT_TIMEOUT_MS = 300_000;
|
|
3139
|
+
const aiHeartbeatInterval = setInterval(() => {
|
|
3140
|
+
const now = Date.now();
|
|
3141
|
+
for (const room of rooms.values()) {
|
|
3142
|
+
const toEvict = [];
|
|
3143
|
+
for (const [actorId, p] of room.participants) {
|
|
3144
|
+
if (p.type !== "ai")
|
|
3145
|
+
continue;
|
|
3146
|
+
// "behavior" agents run autonomously via tick engine — they never poll, so skip heartbeat eviction
|
|
3147
|
+
if (p.agentMode === "behavior")
|
|
3148
|
+
continue;
|
|
3149
|
+
const lastSeen = p.lastPollAt || p.joinedAt;
|
|
3150
|
+
if (now - lastSeen > AI_HEARTBEAT_TIMEOUT_MS)
|
|
3151
|
+
toEvict.push(actorId);
|
|
3152
|
+
}
|
|
3153
|
+
for (const actorId of toEvict) {
|
|
3154
|
+
room.participants.delete(actorId);
|
|
3155
|
+
// Clean up memory entries keyed by this actorId
|
|
3156
|
+
const exp = getExperienceForRoom(room);
|
|
3157
|
+
const expId = exp?.module?.manifest?.id || room.experienceId;
|
|
3158
|
+
agentMemory.delete(`${expId}:${actorId}`);
|
|
3159
|
+
}
|
|
3160
|
+
if (toEvict.length > 0) {
|
|
3161
|
+
broadcastPresenceUpdate(room);
|
|
3162
|
+
// Notify long-pollers that participants changed
|
|
3163
|
+
roomEvents.emit(`room:${room.id}`);
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
}, WS_HEARTBEAT_INTERVAL_MS);
|
|
3167
|
+
// ── Room garbage collection sweep ──────────────────────────
|
|
3168
|
+
// Auto-delete spawned rooms that have been empty (zero participants) for over 5 minutes.
|
|
3169
|
+
// The default "local" room is never deleted. Rooms with active WebSocket connections
|
|
3170
|
+
// or recent participants are kept alive. This prevents zombie rooms from accumulating.
|
|
3171
|
+
const ROOM_EMPTY_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
3172
|
+
const roomGcInterval = setInterval(() => {
|
|
3173
|
+
const now = Date.now();
|
|
3174
|
+
for (const [roomId, room] of rooms) {
|
|
3175
|
+
if (roomId === DEFAULT_ROOM_ID || room.preCreated)
|
|
3176
|
+
continue; // Never GC the default or pre-created rooms
|
|
3177
|
+
if (room.participants.size === 0 && room.wsConnections.size === 0) {
|
|
3178
|
+
// Room is empty — track when we first noticed
|
|
3179
|
+
if (!roomEmptySince.has(roomId)) {
|
|
3180
|
+
roomEmptySince.set(roomId, now);
|
|
3181
|
+
}
|
|
3182
|
+
const emptySince = roomEmptySince.get(roomId);
|
|
3183
|
+
if (now - emptySince >= ROOM_EMPTY_TTL_MS) {
|
|
3184
|
+
// TTL expired — garbage collect this room
|
|
3185
|
+
console.log(`[GC] Deleting empty room '${roomId}' (empty for ${Math.round((now - emptySince) / 1000)}s)`);
|
|
3186
|
+
stopTickEngine(roomId);
|
|
3187
|
+
cleanupRoomLinks(roomId, room);
|
|
3188
|
+
// Clean up memory entries only for actors that were in this room.
|
|
3189
|
+
// Per-actor cleanup already happens during heartbeat eviction (line ~2846).
|
|
3190
|
+
// Don't wildcard-delete all memory for the experience — other rooms
|
|
3191
|
+
// running the same experience would lose their agents' memory.
|
|
3192
|
+
const exp = getExperienceForRoom(room);
|
|
3193
|
+
const expId = exp?.module?.manifest?.id || room.experienceId;
|
|
3194
|
+
for (const [actorId] of room.participants) {
|
|
3195
|
+
agentMemory.delete(`${expId}:${actorId}`);
|
|
3196
|
+
}
|
|
3197
|
+
// Clean up blobs associated with this room
|
|
3198
|
+
for (const [key, meta] of blobMeta) {
|
|
3199
|
+
if (meta.roomId === roomId) {
|
|
3200
|
+
blobStore.delete(key);
|
|
3201
|
+
blobMeta.delete(key);
|
|
3202
|
+
}
|
|
3203
|
+
}
|
|
3204
|
+
// Wake any long-poll listeners before deletion, then clean up event emitter
|
|
3205
|
+
roomEvents.emit(`room:${roomId}`);
|
|
3206
|
+
roomEvents.removeAllListeners(`room:${roomId}`);
|
|
3207
|
+
rooms.delete(roomId);
|
|
3208
|
+
roomEmptySince.delete(roomId);
|
|
3209
|
+
roomBrowserErrors.delete(roomId);
|
|
3210
|
+
lastBrowserErrorAt.delete(roomId);
|
|
3211
|
+
spawnCounts.delete(roomId);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
else {
|
|
3215
|
+
// Room has participants — reset the empty timer
|
|
3216
|
+
roomEmptySince.delete(roomId);
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
}, ROOM_GC_INTERVAL_MS);
|
|
3220
|
+
// Watch src/ and experiences/ directories for changes (host experience only)
|
|
3221
|
+
// Check both PROJECT_ROOT/experiences/ and PROJECT_ROOT/../experiences/ (top-level monorepo)
|
|
3222
|
+
const watchDirs = [
|
|
3223
|
+
path.join(PROJECT_ROOT, "src"),
|
|
3224
|
+
path.join(PROJECT_ROOT, "experiences"),
|
|
3225
|
+
path.resolve(PROJECT_ROOT, "..", "experiences"),
|
|
3226
|
+
].filter((d) => fs.existsSync(d));
|
|
3227
|
+
let debounceTimer = null;
|
|
3228
|
+
function onSrcChange(filename) {
|
|
3229
|
+
if (debounceTimer)
|
|
3230
|
+
clearTimeout(debounceTimer);
|
|
3231
|
+
// Signal that a rebuild is starting (so bundle endpoints can wait)
|
|
3232
|
+
if (!rebuildingPromise) {
|
|
3233
|
+
rebuildingPromise = new Promise((resolve) => { rebuildingResolve = resolve; });
|
|
3234
|
+
}
|
|
3235
|
+
debounceTimer = setTimeout(async () => {
|
|
3236
|
+
console.log(`\nFile changed${filename ? ` (${filename})` : ""}, rebuilding...`);
|
|
3237
|
+
// Determine which experiences are affected by this file change
|
|
3238
|
+
// Stop tick engines before reloading
|
|
3239
|
+
for (const room of rooms.values()) {
|
|
3240
|
+
stopTickEngine(room.id);
|
|
3241
|
+
}
|
|
3242
|
+
try {
|
|
3243
|
+
// Reload the experience
|
|
3244
|
+
await loadHost();
|
|
3245
|
+
const reloaded = experienceCache.get(hostExperienceId);
|
|
3246
|
+
for (const room of rooms.values()) {
|
|
3247
|
+
room.broadcastToAll({ type: "experience_updated" });
|
|
3248
|
+
if (reloaded)
|
|
3249
|
+
maybeStartTickEngine(room, reloaded);
|
|
3250
|
+
}
|
|
3251
|
+
smokeTestClientBundle(PORT);
|
|
3252
|
+
console.log("Hot reload complete.");
|
|
3253
|
+
}
|
|
3254
|
+
catch (err) {
|
|
3255
|
+
experienceErrors.set(hostExperienceId, toErrorMessage(err));
|
|
3256
|
+
console.error("Hot reload failed:", toErrorMessage(err));
|
|
3257
|
+
// Restart tick engines with last-known-good experience so rooms don't stay frozen
|
|
3258
|
+
const lastGood = experienceCache.get(hostExperienceId);
|
|
3259
|
+
for (const room of rooms.values()) {
|
|
3260
|
+
if (room.experienceId === hostExperienceId) {
|
|
3261
|
+
room.broadcastToAll({ type: "build_error", error: toErrorMessage(err) });
|
|
3262
|
+
if (lastGood)
|
|
3263
|
+
maybeStartTickEngine(room, lastGood);
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
finally {
|
|
3268
|
+
// Always resolve the rebuilding promise so bundle endpoints unblock
|
|
3269
|
+
if (rebuildingResolve) {
|
|
3270
|
+
rebuildingResolve();
|
|
3271
|
+
rebuildingResolve = null;
|
|
3272
|
+
rebuildingPromise = null;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}, HOT_RELOAD_DEBOUNCE_MS);
|
|
3276
|
+
}
|
|
3277
|
+
for (const watchDir of watchDirs) {
|
|
3278
|
+
try {
|
|
3279
|
+
// recursive: true works on Windows and macOS
|
|
3280
|
+
fs.watch(watchDir, { recursive: true }, (_event, filename) => {
|
|
3281
|
+
if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
|
|
3282
|
+
// Resolve filename against watchDir (fs.watch gives paths relative to watched dir)
|
|
3283
|
+
onSrcChange(path.join(path.relative(PROJECT_ROOT, watchDir), filename));
|
|
3284
|
+
}
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3287
|
+
catch {
|
|
3288
|
+
// Fallback for Linux: watch individual directories
|
|
3289
|
+
function watchDirRecursive(dir) {
|
|
3290
|
+
fs.watch(dir, (_event, filename) => {
|
|
3291
|
+
if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
|
|
3292
|
+
onSrcChange(path.join(path.relative(PROJECT_ROOT, dir), filename));
|
|
3293
|
+
}
|
|
3294
|
+
});
|
|
3295
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
3296
|
+
if (entry.isDirectory())
|
|
3297
|
+
watchDirRecursive(path.join(dir, entry.name));
|
|
3298
|
+
}
|
|
3299
|
+
}
|
|
3300
|
+
watchDirRecursive(watchDir);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
server.listen(PORT, async () => {
|
|
3304
|
+
console.log(`\n vibe-vibe local runtime`);
|
|
3305
|
+
console.log(` ───────────────────────`);
|
|
3306
|
+
console.log(` Viewer: http://localhost:${PORT}`);
|
|
3307
|
+
// Verify the client bundle can be parsed without errors
|
|
3308
|
+
smokeTestClientBundle(PORT);
|
|
3309
|
+
if (publicUrl) {
|
|
3310
|
+
const shareUrl = getBaseUrl();
|
|
3311
|
+
console.log(``);
|
|
3312
|
+
console.log(` ┌─────────────────────────────────────────────────┐`);
|
|
3313
|
+
console.log(` │ SHARE WITH FRIENDS: │`);
|
|
3314
|
+
console.log(` │ │`);
|
|
3315
|
+
console.log(` │ ${shareUrl.padEnd(47)} │`);
|
|
3316
|
+
console.log(` │ │`);
|
|
3317
|
+
console.log(` │ Open in browser to join the room. │`);
|
|
3318
|
+
console.log(` │ AI: npx @vibevibes/mcp ${(shareUrl).padEnd(23)} │`);
|
|
3319
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
3320
|
+
}
|
|
3321
|
+
console.log(`\n Watching src/ and experiences/ for changes\n`);
|
|
3322
|
+
});
|
|
3323
|
+
// Cleanup all intervals on server close
|
|
3324
|
+
server.on("close", () => {
|
|
3325
|
+
clearInterval(heartbeatInterval);
|
|
3326
|
+
clearInterval(aiHeartbeatInterval);
|
|
3327
|
+
clearInterval(roomGcInterval);
|
|
3328
|
+
clearInterval(_streamRateCleanupTimer);
|
|
3329
|
+
clearInterval(_spawnRateCleanupTimer);
|
|
3330
|
+
clearInterval(_idempotencyCleanupTimer);
|
|
3331
|
+
});
|
|
3332
|
+
return server;
|
|
3333
|
+
}
|
|
3334
|
+
// Convenience: allow setting PROJECT_ROOT before startServer() is called
|
|
3335
|
+
// (e.g., for registry loading during setup).
|
|
3336
|
+
export function setProjectRoot(root) {
|
|
3337
|
+
PROJECT_ROOT = root;
|
|
3338
|
+
}
|
|
3339
|
+
//# sourceMappingURL=server.js.map
|