@vibevibes/mcp 0.1.0 → 0.3.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/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/cli.js +11 -11
- package/bin/postinstall.js +39 -0
- package/bin/serve.js +41 -0
- package/dist/bundler.d.ts +36 -0
- package/dist/bundler.js +254 -0
- package/dist/index.d.ts +4 -8
- package/dist/index.js +767 -221
- package/dist/protocol.d.ts +85 -0
- package/dist/protocol.js +240 -0
- package/dist/server.d.ts +17 -0
- package/dist/server.js +1947 -0
- package/dist/tick-engine.d.ts +81 -0
- package/dist/tick-engine.js +151 -0
- package/dist/viewer/index.html +689 -0
- package/hooks/logic.js +258 -0
- package/hooks/stop-hook.js +341 -0
- package/package.json +59 -33
package/dist/server.js
ADDED
|
@@ -0,0 +1,1947 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vibevibes/runtime — the server engine for vibevibes experiences.
|
|
3
|
+
*
|
|
4
|
+
* Single-room, single-experience architecture.
|
|
5
|
+
* AI agents join via MCP or HTTP. Humans join via browser.
|
|
6
|
+
* Tools are the only mutation path. State is server-authoritative.
|
|
7
|
+
*/
|
|
8
|
+
import express from "express";
|
|
9
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
10
|
+
import http from "http";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import fs from "fs";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { z, ZodError } from "zod";
|
|
15
|
+
import { EventEmitter } from "events";
|
|
16
|
+
import { bundleForServer, bundleForClient, evalServerBundle, validateClientBundle } from "./bundler.js";
|
|
17
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
18
|
+
import { TickEngine } from "./tick-engine.js";
|
|
19
|
+
import { isProtocolExperience, loadProtocolManifest, createProtocolModule, SubprocessExecutor } from "./protocol.js";
|
|
20
|
+
import { createChatTools } from "@vibevibes/sdk";
|
|
21
|
+
function formatZodError(err, toolName, tool) {
|
|
22
|
+
const issues = err.issues.map((issue) => {
|
|
23
|
+
const path = issue.path.length > 0 ? `'${issue.path.join(".")}'` : "input";
|
|
24
|
+
const extra = [];
|
|
25
|
+
const detail = issue;
|
|
26
|
+
if (detail.expected)
|
|
27
|
+
extra.push(`expected ${detail.expected}`);
|
|
28
|
+
if (detail.received && detail.received !== "undefined")
|
|
29
|
+
extra.push(`got ${detail.received}`);
|
|
30
|
+
const suffix = extra.length > 0 ? ` (${extra.join(", ")})` : "";
|
|
31
|
+
return ` ${path}: ${issue.message}${suffix}`;
|
|
32
|
+
});
|
|
33
|
+
let msg = `Invalid input for '${toolName}':\n${issues.join("\n")}`;
|
|
34
|
+
if (tool?.input_schema) {
|
|
35
|
+
try {
|
|
36
|
+
const schema = (tool.input_schema._jsonSchema || zodToJsonSchema(tool.input_schema));
|
|
37
|
+
const props = schema.properties;
|
|
38
|
+
const req = schema.required || [];
|
|
39
|
+
if (props) {
|
|
40
|
+
const fields = Object.entries(props).map(([k, v]) => {
|
|
41
|
+
const optional = !req.includes(k);
|
|
42
|
+
return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
|
|
43
|
+
});
|
|
44
|
+
msg += `\n\nExpected schema: { ${fields.join(", ")} }`;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
if (tool?.description)
|
|
50
|
+
msg += `\nTool description: ${tool.description}`;
|
|
51
|
+
msg += `\n\nHint: Provide all required fields with correct types.`;
|
|
52
|
+
return msg;
|
|
53
|
+
}
|
|
54
|
+
function formatHandlerError(err, toolName, tool, input) {
|
|
55
|
+
const message = err.message || String(err);
|
|
56
|
+
let msg = `Tool '${toolName}' failed: ${message}`;
|
|
57
|
+
if (input !== undefined) {
|
|
58
|
+
try {
|
|
59
|
+
msg += `\n\nInput provided: ${JSON.stringify(input)}`;
|
|
60
|
+
}
|
|
61
|
+
catch { }
|
|
62
|
+
}
|
|
63
|
+
if (tool?.input_schema) {
|
|
64
|
+
try {
|
|
65
|
+
const schema = (tool.input_schema._jsonSchema || zodToJsonSchema(tool.input_schema));
|
|
66
|
+
const props = schema.properties;
|
|
67
|
+
const req = schema.required || [];
|
|
68
|
+
if (props) {
|
|
69
|
+
const fields = Object.entries(props).map(([k, v]) => {
|
|
70
|
+
const optional = !req.includes(k);
|
|
71
|
+
return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
|
|
72
|
+
});
|
|
73
|
+
msg += `\nTool expects: { ${fields.join(", ")} }`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
}
|
|
78
|
+
if (message.includes("Cannot read properties of undefined") || message.includes("Cannot read property")) {
|
|
79
|
+
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.`;
|
|
80
|
+
}
|
|
81
|
+
else if (message.includes("is not a function")) {
|
|
82
|
+
msg += `\n\nHint: Something expected to be a function is not. Check for missing imports or incorrect variable types in the tool handler.`;
|
|
83
|
+
}
|
|
84
|
+
else if (message.includes("Maximum call stack")) {
|
|
85
|
+
msg += `\n\nHint: Infinite recursion detected. A tool handler or function is calling itself without a base case.`;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
msg += `\n\nHint: The tool handler threw an error. Check the handler logic and ensure the current state matches what the handler expects.`;
|
|
89
|
+
}
|
|
90
|
+
return msg;
|
|
91
|
+
}
|
|
92
|
+
function toErrorMessage(err) {
|
|
93
|
+
return err instanceof Error ? err.message : String(err);
|
|
94
|
+
}
|
|
95
|
+
function queryString(val) {
|
|
96
|
+
return typeof val === "string" ? val : undefined;
|
|
97
|
+
}
|
|
98
|
+
function queryInt(val, radix = 10) {
|
|
99
|
+
return typeof val === "string" ? (parseInt(val, radix) || 0) : 0;
|
|
100
|
+
}
|
|
101
|
+
const __runtimeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
102
|
+
let PROJECT_ROOT = "";
|
|
103
|
+
// ── Custom error types ───────────────────────────────────────
|
|
104
|
+
class ToolNotFoundError extends Error {
|
|
105
|
+
constructor(message) { super(message); this.name = "ToolNotFoundError"; }
|
|
106
|
+
}
|
|
107
|
+
class ToolForbiddenError extends Error {
|
|
108
|
+
constructor(message) { super(message); this.name = "ToolForbiddenError"; }
|
|
109
|
+
}
|
|
110
|
+
// ── Room ───────────────────────────────────────────────────
|
|
111
|
+
class Room {
|
|
112
|
+
id = "local";
|
|
113
|
+
experienceId;
|
|
114
|
+
config = {};
|
|
115
|
+
sharedState = {};
|
|
116
|
+
participants = new Map();
|
|
117
|
+
events = [];
|
|
118
|
+
wsConnections = new Map();
|
|
119
|
+
kickedActors = new Set();
|
|
120
|
+
kickedOwners = new Set();
|
|
121
|
+
_executionQueue = Promise.resolve();
|
|
122
|
+
stateVersion = 0;
|
|
123
|
+
_prevState = null;
|
|
124
|
+
constructor(experienceId, initialState) {
|
|
125
|
+
this.experienceId = experienceId;
|
|
126
|
+
if (initialState)
|
|
127
|
+
this.sharedState = initialState;
|
|
128
|
+
}
|
|
129
|
+
broadcastToAll(message) {
|
|
130
|
+
const data = JSON.stringify(message);
|
|
131
|
+
for (const ws of this.wsConnections.keys()) {
|
|
132
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
133
|
+
try {
|
|
134
|
+
ws.send(data);
|
|
135
|
+
}
|
|
136
|
+
catch { }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
broadcastStateUpdate(extra, forceFullState = false) {
|
|
141
|
+
this.stateVersion++;
|
|
142
|
+
const prev = this._prevState;
|
|
143
|
+
this._prevState = this.sharedState;
|
|
144
|
+
if (!prev || forceFullState) {
|
|
145
|
+
this.broadcastToAll({
|
|
146
|
+
type: "shared_state_update",
|
|
147
|
+
stateVersion: this.stateVersion,
|
|
148
|
+
state: this.sharedState,
|
|
149
|
+
...extra,
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const changed = {};
|
|
154
|
+
const deleted = [];
|
|
155
|
+
let changeCount = 0;
|
|
156
|
+
for (const key of Object.keys(this.sharedState)) {
|
|
157
|
+
if (this.sharedState[key] !== prev[key]) {
|
|
158
|
+
changed[key] = this.sharedState[key];
|
|
159
|
+
changeCount++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
for (const key of Object.keys(prev)) {
|
|
163
|
+
if (!(key in this.sharedState)) {
|
|
164
|
+
deleted.push(key);
|
|
165
|
+
changeCount++;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (changeCount === 0 && !extra.event)
|
|
169
|
+
return;
|
|
170
|
+
if (changeCount === 0) {
|
|
171
|
+
this.broadcastToAll({
|
|
172
|
+
type: "shared_state_update",
|
|
173
|
+
stateVersion: this.stateVersion,
|
|
174
|
+
delta: {},
|
|
175
|
+
...extra,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
this.broadcastToAll({
|
|
180
|
+
type: "shared_state_update",
|
|
181
|
+
stateVersion: this.stateVersion,
|
|
182
|
+
delta: changed,
|
|
183
|
+
...(deleted.length > 0 ? { deletedKeys: deleted } : {}),
|
|
184
|
+
...extra,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
resetDeltaTracking() {
|
|
189
|
+
this._prevState = null;
|
|
190
|
+
}
|
|
191
|
+
participantList() {
|
|
192
|
+
return Array.from(this.participants.keys());
|
|
193
|
+
}
|
|
194
|
+
participantDetails() {
|
|
195
|
+
return Array.from(this.participants.entries()).map(([actorId, p]) => {
|
|
196
|
+
const detail = {
|
|
197
|
+
actorId, type: p.type, role: p.role, owner: p.owner,
|
|
198
|
+
};
|
|
199
|
+
if (p.agentMode)
|
|
200
|
+
detail.agentMode = p.agentMode;
|
|
201
|
+
if (p.metadata && Object.keys(p.metadata).length > 0)
|
|
202
|
+
detail.metadata = p.metadata;
|
|
203
|
+
return detail;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
appendEvent(event) {
|
|
207
|
+
this.events.push(event);
|
|
208
|
+
if (this.events.length > MAX_EVENTS) {
|
|
209
|
+
this.events.splice(0, this.events.length - MAX_EVENTS);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
enqueueExecution(fn) {
|
|
213
|
+
const next = this._executionQueue.then(() => fn());
|
|
214
|
+
this._executionQueue = next.then(() => { }, () => { });
|
|
215
|
+
return next;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// ── Constants ─────────────────────────────────────────────
|
|
219
|
+
const DEFAULT_PORT = 4321;
|
|
220
|
+
const MAX_EVENTS = 200;
|
|
221
|
+
const JOIN_EVENT_HISTORY = 20;
|
|
222
|
+
const ROOM_STATE_EVENT_HISTORY = 50;
|
|
223
|
+
const HISTORY_DEFAULT_LIMIT = 50;
|
|
224
|
+
const HISTORY_MAX_LIMIT = 200;
|
|
225
|
+
const DEFAULT_STREAM_RATE_LIMIT = 60;
|
|
226
|
+
const STREAM_RATE_WINDOW_MS = 1000;
|
|
227
|
+
const EVENT_BATCH_DEBOUNCE_MS = 50;
|
|
228
|
+
const DEFAULT_TICK_RATE_MS = 50;
|
|
229
|
+
const MAX_BATCH_CALLS = 10;
|
|
230
|
+
const LONG_POLL_MAX_TIMEOUT_MS = 55000;
|
|
231
|
+
const AGENT_CONTEXT_MAX_TIMEOUT_MS = 10000;
|
|
232
|
+
const WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
|
|
233
|
+
const WS_EPHEMERAL_MAX_BYTES = 65536;
|
|
234
|
+
const WS_HEARTBEAT_INTERVAL_MS = 30000;
|
|
235
|
+
const HOT_RELOAD_DEBOUNCE_MS = 300;
|
|
236
|
+
const WS_CLOSE_GRACE_MS = 3000;
|
|
237
|
+
const JSON_BODY_LIMIT = "256kb";
|
|
238
|
+
const TOOL_HTTP_TIMEOUT_MS = 30_000;
|
|
239
|
+
const TOOL_REGEX_MAX_LENGTH = 100;
|
|
240
|
+
const ROOM_EVENTS_MAX_LISTENERS = 200;
|
|
241
|
+
const STREAM_RATE_LIMIT_STALE_MS = 5000;
|
|
242
|
+
const STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS = 10000;
|
|
243
|
+
const IDEMPOTENCY_CLEANUP_INTERVAL_MS = 60000;
|
|
244
|
+
// ── Default observe ────────────────────────────────────────
|
|
245
|
+
function defaultObserve(state, _event, _actorId) {
|
|
246
|
+
const result = {};
|
|
247
|
+
for (const [k, v] of Object.entries(state)) {
|
|
248
|
+
if (!k.startsWith("_"))
|
|
249
|
+
result[k] = v;
|
|
250
|
+
}
|
|
251
|
+
const phase = typeof state.phase === "string" ? state.phase : null;
|
|
252
|
+
result.directive = phase ? `Current phase: ${phase}` : "Observe the current state and act accordingly.";
|
|
253
|
+
return result;
|
|
254
|
+
}
|
|
255
|
+
// ── Global state ──────────────────────────────────────────
|
|
256
|
+
let PORT = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
|
|
257
|
+
let publicUrl = null;
|
|
258
|
+
let room;
|
|
259
|
+
let tickEngine = null;
|
|
260
|
+
let _actorCounter = 0;
|
|
261
|
+
const roomEvents = new EventEmitter();
|
|
262
|
+
roomEvents.setMaxListeners(ROOM_EVENTS_MAX_LISTENERS);
|
|
263
|
+
// Experience (single)
|
|
264
|
+
let loadedExperience = null;
|
|
265
|
+
let experienceError = null;
|
|
266
|
+
// Hot-reload rebuild gate
|
|
267
|
+
let rebuildingResolve = null;
|
|
268
|
+
let rebuildingPromise = null;
|
|
269
|
+
// Stream rate limiting
|
|
270
|
+
const streamRateLimits = new Map();
|
|
271
|
+
const _streamRateCleanupTimer = setInterval(() => {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
for (const [key, entry] of streamRateLimits) {
|
|
274
|
+
if (now - entry.windowStart > STREAM_RATE_LIMIT_STALE_MS)
|
|
275
|
+
streamRateLimits.delete(key);
|
|
276
|
+
}
|
|
277
|
+
}, STREAM_RATE_LIMIT_CLEANUP_INTERVAL_MS);
|
|
278
|
+
export function setPublicUrl(url) {
|
|
279
|
+
publicUrl = url;
|
|
280
|
+
}
|
|
281
|
+
export function getBaseUrl() {
|
|
282
|
+
return publicUrl || `http://localhost:${PORT}`;
|
|
283
|
+
}
|
|
284
|
+
// ── Helpers ────────────────────────────────────────────────
|
|
285
|
+
const FORBIDDEN_MERGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
286
|
+
function assignActorId(username, type, owner) {
|
|
287
|
+
const base = owner || `${username}-${type}`;
|
|
288
|
+
_actorCounter++;
|
|
289
|
+
return `${base}-${_actorCounter}`;
|
|
290
|
+
}
|
|
291
|
+
function setNoCacheHeaders(res) {
|
|
292
|
+
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
|
293
|
+
res.setHeader("Pragma", "no-cache");
|
|
294
|
+
res.setHeader("Expires", "0");
|
|
295
|
+
}
|
|
296
|
+
function getToolList(mod, allowedTools) {
|
|
297
|
+
if (!mod?.tools)
|
|
298
|
+
return [];
|
|
299
|
+
let tools = mod.tools;
|
|
300
|
+
if (allowedTools)
|
|
301
|
+
tools = tools.filter((t) => allowedTools.includes(t.name));
|
|
302
|
+
return tools.map((t) => ({
|
|
303
|
+
name: t.name,
|
|
304
|
+
description: t.description,
|
|
305
|
+
risk: t.risk || "low",
|
|
306
|
+
input_schema: t.input_schema?._jsonSchema
|
|
307
|
+
? t.input_schema._jsonSchema
|
|
308
|
+
: t.input_schema ? zodToJsonSchema(t.input_schema) : {},
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
function getModule() {
|
|
312
|
+
return loadedExperience?.module;
|
|
313
|
+
}
|
|
314
|
+
function resolveInitialState(mod) {
|
|
315
|
+
const init = mod?.initialState;
|
|
316
|
+
if (typeof init === "function") {
|
|
317
|
+
try {
|
|
318
|
+
return init({}) || {};
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
console.warn(`[resolveInitialState] initialState() threw:`, err);
|
|
322
|
+
return {};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
if (init && typeof init === "object")
|
|
326
|
+
return { ...init };
|
|
327
|
+
const schema = mod?.stateSchema;
|
|
328
|
+
if (schema && typeof schema.parse === "function") {
|
|
329
|
+
try {
|
|
330
|
+
return schema.parse({});
|
|
331
|
+
}
|
|
332
|
+
catch { }
|
|
333
|
+
}
|
|
334
|
+
return {};
|
|
335
|
+
}
|
|
336
|
+
function broadcastPresenceUpdate() {
|
|
337
|
+
room.broadcastToAll({
|
|
338
|
+
type: "presence_update",
|
|
339
|
+
participants: room.participantList(),
|
|
340
|
+
participantDetails: room.participantDetails(),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function experienceNotLoadedError() {
|
|
344
|
+
const hint = experienceError
|
|
345
|
+
? `\nLast build error: ${experienceError}\nFix the source and save to hot-reload.`
|
|
346
|
+
: `\nCheck that src/index.tsx exists and exports a valid experience.`;
|
|
347
|
+
return `Experience not loaded.${hint}`;
|
|
348
|
+
}
|
|
349
|
+
// ── Experience discovery & loading ──────────────────────────
|
|
350
|
+
function discoverEntryPath() {
|
|
351
|
+
const manifestPath = path.join(PROJECT_ROOT, "manifest.json");
|
|
352
|
+
if (fs.existsSync(manifestPath))
|
|
353
|
+
return manifestPath;
|
|
354
|
+
const tsxPath = path.join(PROJECT_ROOT, "src", "index.tsx");
|
|
355
|
+
if (fs.existsSync(tsxPath))
|
|
356
|
+
return tsxPath;
|
|
357
|
+
const rootTsx = path.join(PROJECT_ROOT, "index.tsx");
|
|
358
|
+
if (fs.existsSync(rootTsx))
|
|
359
|
+
return rootTsx;
|
|
360
|
+
throw new Error(`No experience found in ${PROJECT_ROOT}. ` +
|
|
361
|
+
`Create a manifest.json (protocol) or src/index.tsx (TypeScript).`);
|
|
362
|
+
}
|
|
363
|
+
const protocolExecutors = new Map();
|
|
364
|
+
async function loadExperience() {
|
|
365
|
+
const entryPath = discoverEntryPath();
|
|
366
|
+
if (isProtocolExperience(entryPath)) {
|
|
367
|
+
await loadProtocolExperience(entryPath);
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const [sCode, cCode] = await Promise.all([
|
|
371
|
+
bundleForServer(entryPath),
|
|
372
|
+
bundleForClient(entryPath),
|
|
373
|
+
]);
|
|
374
|
+
const mod = await evalServerBundle(sCode);
|
|
375
|
+
if (!mod?.manifest || !mod?.tools) {
|
|
376
|
+
throw new Error(`Experience at ${entryPath} missing manifest or tools`);
|
|
377
|
+
}
|
|
378
|
+
if (!mod.tools.some((t) => t.name === '_chat.send')) {
|
|
379
|
+
mod.tools.push(...createChatTools(z));
|
|
380
|
+
}
|
|
381
|
+
const clientError = validateClientBundle(cCode);
|
|
382
|
+
if (clientError) {
|
|
383
|
+
throw new Error(`Client bundle validation failed for ${entryPath}: ${clientError}`);
|
|
384
|
+
}
|
|
385
|
+
loadedExperience = {
|
|
386
|
+
module: mod,
|
|
387
|
+
clientBundle: cCode,
|
|
388
|
+
serverCode: sCode,
|
|
389
|
+
loadedAt: Date.now(),
|
|
390
|
+
sourcePath: entryPath,
|
|
391
|
+
};
|
|
392
|
+
experienceError = null;
|
|
393
|
+
}
|
|
394
|
+
async function loadProtocolExperience(manifestPath) {
|
|
395
|
+
const manifestDir = path.dirname(manifestPath);
|
|
396
|
+
const manifest = loadProtocolManifest(manifestPath);
|
|
397
|
+
const existing = protocolExecutors.get(manifest.id);
|
|
398
|
+
if (existing)
|
|
399
|
+
existing.stop();
|
|
400
|
+
const executor = new SubprocessExecutor(manifest.toolProcess.command, manifest.toolProcess.args || [], manifestDir);
|
|
401
|
+
executor.start();
|
|
402
|
+
protocolExecutors.set(manifest.id, executor);
|
|
403
|
+
try {
|
|
404
|
+
await executor.send("init", { experienceId: manifest.id }, 5000);
|
|
405
|
+
}
|
|
406
|
+
catch { }
|
|
407
|
+
const mod = createProtocolModule(manifest, executor, manifestDir);
|
|
408
|
+
if (!mod.tools.some((t) => t.name === '_chat.send')) {
|
|
409
|
+
mod.tools.push(...createChatTools(z));
|
|
410
|
+
}
|
|
411
|
+
loadedExperience = {
|
|
412
|
+
module: mod,
|
|
413
|
+
clientBundle: "",
|
|
414
|
+
serverCode: JSON.stringify(manifest),
|
|
415
|
+
loadedAt: Date.now(),
|
|
416
|
+
sourcePath: manifestPath,
|
|
417
|
+
};
|
|
418
|
+
experienceError = null;
|
|
419
|
+
console.log(`[protocol] Loaded ${manifest.title} (${manifest.id}) — ${manifest.tools.length} tools`);
|
|
420
|
+
}
|
|
421
|
+
// ── Tick Engine lifecycle ──────────────────────────────────
|
|
422
|
+
function maybeStartTickEngine() {
|
|
423
|
+
if (!loadedExperience)
|
|
424
|
+
return;
|
|
425
|
+
const manifest = loadedExperience.module?.manifest;
|
|
426
|
+
if (manifest?.netcode !== "tick")
|
|
427
|
+
return;
|
|
428
|
+
stopTickEngine();
|
|
429
|
+
const tickRateMs = manifest.tickRateMs || DEFAULT_TICK_RATE_MS;
|
|
430
|
+
tickEngine = new TickEngine(room, loadedExperience, roomEvents, tickRateMs);
|
|
431
|
+
tickEngine.start();
|
|
432
|
+
}
|
|
433
|
+
function stopTickEngine() {
|
|
434
|
+
if (tickEngine) {
|
|
435
|
+
tickEngine.stop();
|
|
436
|
+
tickEngine = null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
// ── Express app ────────────────────────────────────────────
|
|
440
|
+
const app = express();
|
|
441
|
+
app.use(express.json({ limit: JSON_BODY_LIMIT }));
|
|
442
|
+
app.use((_req, res, next) => {
|
|
443
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
444
|
+
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
|
|
445
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Idempotency-Key");
|
|
446
|
+
if (_req.method === "OPTIONS") {
|
|
447
|
+
res.sendStatus(200);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
next();
|
|
451
|
+
});
|
|
452
|
+
app.get("/", (_req, res) => {
|
|
453
|
+
setNoCacheHeaders(res);
|
|
454
|
+
res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
|
|
455
|
+
});
|
|
456
|
+
app.use("/viewer", express.static(path.join(__runtimeDir, "viewer")));
|
|
457
|
+
app.get("/sdk.js", (_req, res) => {
|
|
458
|
+
res.setHeader("Content-Type", "application/javascript");
|
|
459
|
+
res.send(`
|
|
460
|
+
export function defineExperience(c) { return c; }
|
|
461
|
+
export function defineTool(c) { return { risk: "low", capabilities_required: [], ...c }; }
|
|
462
|
+
export function defineTest(c) { return c; }
|
|
463
|
+
export function defineStream(c) { return c; }
|
|
464
|
+
export function createChatTools(z) {
|
|
465
|
+
return [
|
|
466
|
+
{ name: "_chat.send", description: "Send a chat message", risk: "low", capabilities_required: ["state.write"],
|
|
467
|
+
input_schema: z.object({ message: z.string() }),
|
|
468
|
+
handler: async (ctx, input) => {
|
|
469
|
+
const chat = Array.isArray(ctx.state._chat) ? ctx.state._chat : [];
|
|
470
|
+
ctx.setState({ ...ctx.state, _chat: [...chat.slice(-99), { actorId: ctx.actorId, message: input.message, ts: Date.now() }] });
|
|
471
|
+
return { sent: true };
|
|
472
|
+
}},
|
|
473
|
+
{ name: "_chat.clear", description: "Clear chat", risk: "low", capabilities_required: ["state.write"],
|
|
474
|
+
input_schema: z.object({}),
|
|
475
|
+
handler: async (ctx) => { ctx.setState({ ...ctx.state, _chat: [] }); return { cleared: true }; }},
|
|
476
|
+
];
|
|
477
|
+
}
|
|
478
|
+
`);
|
|
479
|
+
});
|
|
480
|
+
// ── State endpoint ─────────────────────────────────────────
|
|
481
|
+
app.get("/state", (req, res) => {
|
|
482
|
+
const mod = getModule();
|
|
483
|
+
let observation;
|
|
484
|
+
const observeFn = mod?.observe ?? defaultObserve;
|
|
485
|
+
const observeActorId = typeof req.query.actorId === "string" ? req.query.actorId : "viewer";
|
|
486
|
+
try {
|
|
487
|
+
observation = observeFn(room.sharedState, null, observeActorId);
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
console.warn(`[observe] Error: ${toErrorMessage(err)}`);
|
|
491
|
+
}
|
|
492
|
+
res.json({
|
|
493
|
+
experienceId: mod?.manifest?.id,
|
|
494
|
+
sharedState: room.sharedState,
|
|
495
|
+
stateVersion: room.stateVersion,
|
|
496
|
+
participants: room.participantList(),
|
|
497
|
+
events: room.events.slice(-ROOM_STATE_EVENT_HISTORY),
|
|
498
|
+
observation,
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
// ── Slots endpoint ──────────────────────────────────────────
|
|
502
|
+
app.get("/slots", (_req, res) => {
|
|
503
|
+
const mod = getModule();
|
|
504
|
+
const slots = mod?.manifest?.participantSlots || mod?.participants || [];
|
|
505
|
+
res.json({ slots, participantDetails: room.participantDetails() });
|
|
506
|
+
});
|
|
507
|
+
// ── Participants endpoint ──────────────────────────────────
|
|
508
|
+
app.get("/participants", (_req, res) => res.json({ participants: room.participantDetails() }));
|
|
509
|
+
// ── Tools list endpoint ────────────────────────────────────
|
|
510
|
+
app.get("/tools-list", (req, res) => {
|
|
511
|
+
const mod = getModule();
|
|
512
|
+
if (!mod) {
|
|
513
|
+
res.status(500).json({ error: experienceNotLoadedError() });
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const actorId = queryString(req.query.actorId);
|
|
517
|
+
let allowedTools;
|
|
518
|
+
if (actorId) {
|
|
519
|
+
const participant = room.participants.get(actorId);
|
|
520
|
+
allowedTools = participant?.allowedTools;
|
|
521
|
+
}
|
|
522
|
+
const tools = getToolList(mod, allowedTools);
|
|
523
|
+
const streams = mod.streams
|
|
524
|
+
? mod.streams.map((s) => ({
|
|
525
|
+
name: s.name,
|
|
526
|
+
description: s.description || "",
|
|
527
|
+
rateLimit: s.rateLimit || DEFAULT_STREAM_RATE_LIMIT,
|
|
528
|
+
input_schema: s.input_schema ? zodToJsonSchema(s.input_schema) : {},
|
|
529
|
+
}))
|
|
530
|
+
: [];
|
|
531
|
+
res.json({
|
|
532
|
+
experienceId: mod.manifest?.id,
|
|
533
|
+
tools,
|
|
534
|
+
streams,
|
|
535
|
+
toolCount: tools.length,
|
|
536
|
+
streamCount: streams.length,
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
// ── History endpoint ───────────────────────────────────────
|
|
540
|
+
app.get("/history", (req, res) => {
|
|
541
|
+
const limit = Math.min(queryInt(req.query.limit) || HISTORY_DEFAULT_LIMIT, HISTORY_MAX_LIMIT);
|
|
542
|
+
const since = queryInt(req.query.since);
|
|
543
|
+
const actor = queryString(req.query.actor);
|
|
544
|
+
const tool = queryString(req.query.tool);
|
|
545
|
+
const owner = queryString(req.query.owner);
|
|
546
|
+
let events = room.events;
|
|
547
|
+
if (since > 0)
|
|
548
|
+
events = events.filter(e => e.ts > since);
|
|
549
|
+
if (actor)
|
|
550
|
+
events = events.filter(e => e.actorId === actor);
|
|
551
|
+
if (owner)
|
|
552
|
+
events = events.filter(e => e.owner === owner);
|
|
553
|
+
if (tool) {
|
|
554
|
+
const SAFE_TOOL_REGEX = /^[a-zA-Z0-9._:\-]+$/;
|
|
555
|
+
if (tool.length <= TOOL_REGEX_MAX_LENGTH && SAFE_TOOL_REGEX.test(tool)) {
|
|
556
|
+
try {
|
|
557
|
+
const escaped = tool.replace(/\./g, '\\.');
|
|
558
|
+
const toolRegex = new RegExp('^' + escaped + '$');
|
|
559
|
+
events = events.filter(e => toolRegex.test(e.tool));
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
events = events.filter(e => e.tool === tool);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
events = events.filter(e => e.tool === tool);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const filteredTotal = events.length;
|
|
570
|
+
const result = events.slice(-limit);
|
|
571
|
+
res.json({
|
|
572
|
+
events: result,
|
|
573
|
+
total: room.events.length,
|
|
574
|
+
filtered: filteredTotal,
|
|
575
|
+
returned: result.length,
|
|
576
|
+
hasMore: filteredTotal > limit,
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
// ── Who endpoint ───────────────────────────────────────────
|
|
580
|
+
app.get("/who", (_req, res) => {
|
|
581
|
+
const participants = Array.from(room.participants.entries()).map(([actorId, p]) => {
|
|
582
|
+
let lastAction;
|
|
583
|
+
for (let i = room.events.length - 1; i >= 0; i--) {
|
|
584
|
+
if (room.events[i].actorId === actorId) {
|
|
585
|
+
lastAction = { tool: room.events[i].tool, ts: room.events[i].ts };
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return { actorId, owner: p.owner, type: p.type, role: p.role, joinedAt: p.joinedAt, lastAction, allowedTools: p.allowedTools };
|
|
590
|
+
});
|
|
591
|
+
res.json({
|
|
592
|
+
participants,
|
|
593
|
+
count: participants.length,
|
|
594
|
+
humans: participants.filter(p => p.type === "human").length,
|
|
595
|
+
agents: participants.filter(p => p.type === "ai").length,
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
// ── Join ───────────────────────────────────────────────────
|
|
599
|
+
app.post("/join", (req, res) => {
|
|
600
|
+
const mod = getModule();
|
|
601
|
+
if (!mod) {
|
|
602
|
+
res.status(500).json({ error: experienceNotLoadedError() });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const { username = "user", actorType: rawActorType = "human", owner, role: requestedRole, agentMode: rawAgentMode, metadata: rawMetadata } = req.body;
|
|
606
|
+
const actorType = rawActorType === "ai" ? "ai" : "human";
|
|
607
|
+
const resolvedOwner = owner || username;
|
|
608
|
+
const VALID_AGENT_MODES = ["behavior", "manual", "hybrid"];
|
|
609
|
+
const agentMode = actorType === "ai" && typeof rawAgentMode === "string" && VALID_AGENT_MODES.includes(rawAgentMode) ? rawAgentMode : undefined;
|
|
610
|
+
let metadata;
|
|
611
|
+
if (rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) {
|
|
612
|
+
metadata = {};
|
|
613
|
+
let keyCount = 0;
|
|
614
|
+
for (const [k, v] of Object.entries(rawMetadata)) {
|
|
615
|
+
if (keyCount >= 20)
|
|
616
|
+
break;
|
|
617
|
+
if (typeof k === "string" && typeof v === "string" && k.length <= 50) {
|
|
618
|
+
metadata[k] = String(v).slice(0, 200);
|
|
619
|
+
keyCount++;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (Object.keys(metadata).length === 0)
|
|
623
|
+
metadata = undefined;
|
|
624
|
+
}
|
|
625
|
+
if (!resolvedOwner) {
|
|
626
|
+
res.status(400).json({ error: "owner or username required" });
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
// Dedup: if same owner already has a participant, reuse
|
|
630
|
+
if (resolvedOwner) {
|
|
631
|
+
if (room.kickedOwners.has(resolvedOwner)) {
|
|
632
|
+
res.status(403).json({ error: "You have been kicked from this room." });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
for (const [existingId, existingP] of room.participants.entries()) {
|
|
636
|
+
if (existingP.owner === resolvedOwner) {
|
|
637
|
+
if (room.kickedActors.has(existingId)) {
|
|
638
|
+
res.status(403).json({ error: "You have been kicked from this room." });
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
existingP.joinedAt = Date.now();
|
|
642
|
+
if (existingP.type === "ai" && existingP.role) {
|
|
643
|
+
room.sharedState = {
|
|
644
|
+
...room.sharedState,
|
|
645
|
+
_agentRoles: { ...(room.sharedState._agentRoles ?? {}), [existingId]: existingP.role },
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
for (const [ws, wsActor] of room.wsConnections.entries()) {
|
|
649
|
+
if (wsActor === existingId) {
|
|
650
|
+
room.wsConnections.delete(ws);
|
|
651
|
+
try {
|
|
652
|
+
ws.close(1000, "Replaced by reconnect");
|
|
653
|
+
}
|
|
654
|
+
catch { }
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
broadcastPresenceUpdate();
|
|
659
|
+
let observation;
|
|
660
|
+
let observeError;
|
|
661
|
+
const reconnectObserve = mod.observe ?? defaultObserve;
|
|
662
|
+
try {
|
|
663
|
+
observation = reconnectObserve(room.sharedState, null, existingId);
|
|
664
|
+
}
|
|
665
|
+
catch (e) {
|
|
666
|
+
console.error(`[observe] Error:`, toErrorMessage(e));
|
|
667
|
+
observeError = toErrorMessage(e);
|
|
668
|
+
}
|
|
669
|
+
res.json({
|
|
670
|
+
actorId: existingId,
|
|
671
|
+
owner: resolvedOwner,
|
|
672
|
+
role: existingP.role,
|
|
673
|
+
systemPrompt: existingP.systemPrompt,
|
|
674
|
+
reconnected: true,
|
|
675
|
+
observation,
|
|
676
|
+
observeError,
|
|
677
|
+
tools: getToolList(mod, existingP.allowedTools),
|
|
678
|
+
});
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
// Participant slot matching
|
|
684
|
+
const participantSlots = mod.manifest?.participantSlots || mod.participants;
|
|
685
|
+
const agentSlots = mod.agents || mod.manifest?.agentSlots;
|
|
686
|
+
let slotRole;
|
|
687
|
+
let slotAllowedTools;
|
|
688
|
+
let actorIdBase;
|
|
689
|
+
let slotSystemPrompt;
|
|
690
|
+
if (participantSlots?.length) {
|
|
691
|
+
const roleOccupancy = new Map();
|
|
692
|
+
for (const [, p] of room.participants) {
|
|
693
|
+
if (p.role)
|
|
694
|
+
roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
|
|
695
|
+
}
|
|
696
|
+
const typeMatches = (slotType, joinType) => {
|
|
697
|
+
if (!slotType || slotType === "any")
|
|
698
|
+
return true;
|
|
699
|
+
return slotType === joinType;
|
|
700
|
+
};
|
|
701
|
+
const hasCapacity = (slot) => {
|
|
702
|
+
const max = slot.maxInstances ?? 1;
|
|
703
|
+
const current = roleOccupancy.get(slot.role) || 0;
|
|
704
|
+
return current < max;
|
|
705
|
+
};
|
|
706
|
+
let matched;
|
|
707
|
+
if (requestedRole) {
|
|
708
|
+
matched = participantSlots.find((s) => s.role === requestedRole && typeMatches(s.type, actorType) && hasCapacity(s));
|
|
709
|
+
}
|
|
710
|
+
if (!matched)
|
|
711
|
+
matched = participantSlots.find((s) => s.type === actorType && hasCapacity(s));
|
|
712
|
+
if (!matched)
|
|
713
|
+
matched = participantSlots.find((s) => typeMatches(s.type, actorType) && hasCapacity(s));
|
|
714
|
+
if (!matched)
|
|
715
|
+
matched = participantSlots.find((s) => typeMatches(s.type, actorType));
|
|
716
|
+
if (matched) {
|
|
717
|
+
slotRole = matched.role;
|
|
718
|
+
slotAllowedTools = matched.allowedTools;
|
|
719
|
+
slotSystemPrompt = matched.systemPrompt;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
else if (actorType === "ai" && agentSlots && agentSlots.length > 0) {
|
|
723
|
+
const occupiedRoles = new Set();
|
|
724
|
+
for (const [, p] of room.participants) {
|
|
725
|
+
if (p.type === "ai" && p.role)
|
|
726
|
+
occupiedRoles.add(p.role);
|
|
727
|
+
}
|
|
728
|
+
const slot = agentSlots.find((s) => !occupiedRoles.has(s.role)) || agentSlots[0];
|
|
729
|
+
slotRole = slot.role;
|
|
730
|
+
slotAllowedTools = slot.allowedTools;
|
|
731
|
+
slotSystemPrompt = slot.systemPrompt;
|
|
732
|
+
}
|
|
733
|
+
const actorId = assignActorId(username, actorType, actorIdBase || resolvedOwner);
|
|
734
|
+
const participant = { type: actorType, joinedAt: Date.now(), owner: resolvedOwner };
|
|
735
|
+
if (slotRole)
|
|
736
|
+
participant.role = slotRole;
|
|
737
|
+
if (slotAllowedTools)
|
|
738
|
+
participant.allowedTools = slotAllowedTools;
|
|
739
|
+
if (slotSystemPrompt)
|
|
740
|
+
participant.systemPrompt = slotSystemPrompt;
|
|
741
|
+
if (!slotRole && requestedRole)
|
|
742
|
+
participant.role = requestedRole;
|
|
743
|
+
if (agentMode)
|
|
744
|
+
participant.agentMode = agentMode;
|
|
745
|
+
if (metadata)
|
|
746
|
+
participant.metadata = metadata;
|
|
747
|
+
room.participants.set(actorId, participant);
|
|
748
|
+
if (actorType === "ai" && participant.role) {
|
|
749
|
+
room.sharedState = {
|
|
750
|
+
...room.sharedState,
|
|
751
|
+
_agentRoles: { ...(room.sharedState._agentRoles ?? {}), [actorId]: participant.role },
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
broadcastPresenceUpdate();
|
|
755
|
+
let observation;
|
|
756
|
+
let observeError;
|
|
757
|
+
const joinObserve = mod.observe ?? defaultObserve;
|
|
758
|
+
try {
|
|
759
|
+
observation = joinObserve(room.sharedState, null, actorId);
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
console.error(`[observe] Error:`, toErrorMessage(e));
|
|
763
|
+
observeError = toErrorMessage(e);
|
|
764
|
+
}
|
|
765
|
+
res.json({
|
|
766
|
+
actorId,
|
|
767
|
+
owner: resolvedOwner,
|
|
768
|
+
experienceId: mod.manifest.id,
|
|
769
|
+
sharedState: room.sharedState,
|
|
770
|
+
participants: room.participantList(),
|
|
771
|
+
events: room.events.slice(-JOIN_EVENT_HISTORY),
|
|
772
|
+
tools: getToolList(mod, participant.allowedTools),
|
|
773
|
+
browserUrl: getBaseUrl(),
|
|
774
|
+
observation,
|
|
775
|
+
role: participant.role,
|
|
776
|
+
allowedTools: participant.allowedTools,
|
|
777
|
+
systemPrompt: slotSystemPrompt,
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
// ── Leave ──────────────────────────────────────────────────
|
|
781
|
+
app.post("/leave", (req, res) => {
|
|
782
|
+
const { actorId } = req.body;
|
|
783
|
+
if (!actorId || typeof actorId !== "string") {
|
|
784
|
+
res.status(400).json({ error: "actorId required" });
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (!room.participants.has(actorId)) {
|
|
788
|
+
res.status(404).json({ error: `Participant '${actorId}' not found` });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
room.participants.delete(actorId);
|
|
792
|
+
for (const [ws, wsActorId] of room.wsConnections.entries()) {
|
|
793
|
+
if (wsActorId === actorId) {
|
|
794
|
+
room.wsConnections.delete(ws);
|
|
795
|
+
try {
|
|
796
|
+
ws.close();
|
|
797
|
+
}
|
|
798
|
+
catch { }
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
broadcastPresenceUpdate();
|
|
802
|
+
res.json({ left: true, actorId });
|
|
803
|
+
});
|
|
804
|
+
// ── Kick ───────────────────────────────────────────────────
|
|
805
|
+
app.post("/kick", (req, res) => {
|
|
806
|
+
const { kickerActorId, targetActorId } = req.body;
|
|
807
|
+
if (kickerActorId === targetActorId) {
|
|
808
|
+
res.status(400).json({ error: "Cannot kick yourself" });
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const kicker = room.participants.get(kickerActorId);
|
|
812
|
+
if (!kicker || kicker.type !== "human") {
|
|
813
|
+
res.status(400).json({ error: "Only human participants can kick" });
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (!room.participants.has(targetActorId)) {
|
|
817
|
+
res.status(400).json({ error: "Participant not found" });
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const targetParticipant = room.participants.get(targetActorId);
|
|
821
|
+
room.participants.delete(targetActorId);
|
|
822
|
+
if (room.kickedActors.size >= 500) {
|
|
823
|
+
const oldest = room.kickedActors.values().next().value;
|
|
824
|
+
if (oldest)
|
|
825
|
+
room.kickedActors.delete(oldest);
|
|
826
|
+
}
|
|
827
|
+
room.kickedActors.add(targetActorId);
|
|
828
|
+
if (targetParticipant?.owner) {
|
|
829
|
+
if (room.kickedOwners.size >= 500) {
|
|
830
|
+
const oldest = room.kickedOwners.values().next().value;
|
|
831
|
+
if (oldest)
|
|
832
|
+
room.kickedOwners.delete(oldest);
|
|
833
|
+
}
|
|
834
|
+
room.kickedOwners.add(targetParticipant.owner);
|
|
835
|
+
}
|
|
836
|
+
for (const [targetWs, wsActorId] of room.wsConnections.entries()) {
|
|
837
|
+
if (wsActorId === targetActorId) {
|
|
838
|
+
try {
|
|
839
|
+
targetWs.send(JSON.stringify({ type: "kicked", by: kickerActorId }));
|
|
840
|
+
targetWs.close();
|
|
841
|
+
}
|
|
842
|
+
catch { }
|
|
843
|
+
room.wsConnections.delete(targetWs);
|
|
844
|
+
break;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
broadcastPresenceUpdate();
|
|
848
|
+
roomEvents.emit("room");
|
|
849
|
+
res.json({ kicked: true, actorId: targetActorId });
|
|
850
|
+
});
|
|
851
|
+
// ── Idempotency cache ────────────────────────────────────────
|
|
852
|
+
const idempotencyCache = new Map();
|
|
853
|
+
const IDEMPOTENCY_TTL = 30000;
|
|
854
|
+
const _idempotencyCleanupTimer = setInterval(() => {
|
|
855
|
+
const now = Date.now();
|
|
856
|
+
for (const [key, entry] of idempotencyCache) {
|
|
857
|
+
if (now - entry.ts > IDEMPOTENCY_TTL)
|
|
858
|
+
idempotencyCache.delete(key);
|
|
859
|
+
}
|
|
860
|
+
}, IDEMPOTENCY_CLEANUP_INTERVAL_MS);
|
|
861
|
+
async function executeTool(toolName, actorId, input = {}, owner, expiredFlag) {
|
|
862
|
+
const mod = getModule();
|
|
863
|
+
if (!mod)
|
|
864
|
+
throw new Error(experienceNotLoadedError());
|
|
865
|
+
let scopeKey;
|
|
866
|
+
let resolvedToolName = toolName;
|
|
867
|
+
if (toolName.includes(':')) {
|
|
868
|
+
const colonIdx = toolName.indexOf(':');
|
|
869
|
+
scopeKey = toolName.slice(0, colonIdx);
|
|
870
|
+
resolvedToolName = toolName.slice(colonIdx + 1);
|
|
871
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(scopeKey) || FORBIDDEN_MERGE_KEYS.has(scopeKey)) {
|
|
872
|
+
throw new Error(`Invalid scope key in tool name: '${scopeKey}'`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
const tool = mod.tools.find((t) => t.name === resolvedToolName);
|
|
876
|
+
if (!tool) {
|
|
877
|
+
const available = mod.tools.map((t) => t.name).join(", ");
|
|
878
|
+
throw new ToolNotFoundError(`Tool '${resolvedToolName}' not found. Available tools: ${available}`);
|
|
879
|
+
}
|
|
880
|
+
const callingParticipant = room.participants.get(actorId);
|
|
881
|
+
if (callingParticipant?.allowedTools &&
|
|
882
|
+
!callingParticipant.allowedTools.includes(resolvedToolName) &&
|
|
883
|
+
!callingParticipant.allowedTools.includes(toolName)) {
|
|
884
|
+
const role = callingParticipant.role || "ai";
|
|
885
|
+
const allowed = callingParticipant.allowedTools.join(", ");
|
|
886
|
+
throw new ToolForbiddenError(`Tool '${resolvedToolName}' is not allowed for role '${role}'. Allowed tools: ${allowed}`);
|
|
887
|
+
}
|
|
888
|
+
let validatedInput = input;
|
|
889
|
+
if (tool.input_schema?.parse) {
|
|
890
|
+
validatedInput = tool.input_schema.parse(input);
|
|
891
|
+
}
|
|
892
|
+
const participant = room.participants.get(actorId);
|
|
893
|
+
const resolvedOwner = participant?.owner || owner || actorId;
|
|
894
|
+
const ctx = {
|
|
895
|
+
roomId: "local",
|
|
896
|
+
actorId,
|
|
897
|
+
owner: resolvedOwner,
|
|
898
|
+
get state() { return room.sharedState; },
|
|
899
|
+
setState: (newState) => {
|
|
900
|
+
if (expiredFlag?.value)
|
|
901
|
+
return;
|
|
902
|
+
room.sharedState = newState;
|
|
903
|
+
},
|
|
904
|
+
timestamp: Date.now(),
|
|
905
|
+
memory: {},
|
|
906
|
+
setMemory: () => { },
|
|
907
|
+
};
|
|
908
|
+
if (scopeKey) {
|
|
909
|
+
Object.defineProperty(ctx, 'state', {
|
|
910
|
+
get() { return room.sharedState[scopeKey] || {}; },
|
|
911
|
+
configurable: true,
|
|
912
|
+
});
|
|
913
|
+
ctx.setState = (newState) => {
|
|
914
|
+
if (expiredFlag?.value)
|
|
915
|
+
return;
|
|
916
|
+
room.sharedState = { ...room.sharedState, [scopeKey]: newState };
|
|
917
|
+
};
|
|
918
|
+
}
|
|
919
|
+
const output = await tool.handler(ctx, validatedInput);
|
|
920
|
+
const callerRole = callingParticipant?.role;
|
|
921
|
+
const event = {
|
|
922
|
+
id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
|
|
923
|
+
ts: Date.now(),
|
|
924
|
+
actorId,
|
|
925
|
+
owner: ctx.owner,
|
|
926
|
+
role: callerRole,
|
|
927
|
+
tool: toolName,
|
|
928
|
+
input: validatedInput,
|
|
929
|
+
output,
|
|
930
|
+
};
|
|
931
|
+
let observation;
|
|
932
|
+
const toolObserve = mod.observe ?? defaultObserve;
|
|
933
|
+
try {
|
|
934
|
+
observation = toolObserve(room.sharedState, event, actorId);
|
|
935
|
+
}
|
|
936
|
+
catch (e) {
|
|
937
|
+
console.error(`[observe] Error:`, toErrorMessage(e));
|
|
938
|
+
}
|
|
939
|
+
if (observation)
|
|
940
|
+
event.observation = observation;
|
|
941
|
+
room.appendEvent(event);
|
|
942
|
+
room.broadcastStateUpdate({
|
|
943
|
+
event,
|
|
944
|
+
changedBy: actorId,
|
|
945
|
+
tool: toolName,
|
|
946
|
+
observation,
|
|
947
|
+
});
|
|
948
|
+
roomEvents.emit("room");
|
|
949
|
+
const baseTool = toolName.includes(':') ? toolName.split(':').pop() : toolName;
|
|
950
|
+
if (baseTool.startsWith("_behavior.")) {
|
|
951
|
+
if (tickEngine)
|
|
952
|
+
tickEngine.markDirty();
|
|
953
|
+
}
|
|
954
|
+
return { tool: toolName, output, observation };
|
|
955
|
+
}
|
|
956
|
+
// ── Single tool HTTP endpoint ───────────────────────────────
|
|
957
|
+
app.post("/tools/:toolName", async (req, res) => {
|
|
958
|
+
const mod = getModule();
|
|
959
|
+
if (!mod) {
|
|
960
|
+
res.status(500).json({ error: experienceNotLoadedError() });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
const toolName = req.params.toolName;
|
|
964
|
+
const { actorId, input: rawInput = {}, owner } = req.body;
|
|
965
|
+
const input = rawInput !== null && typeof rawInput === "object" && !Array.isArray(rawInput) ? rawInput : {};
|
|
966
|
+
if (!actorId) {
|
|
967
|
+
res.status(400).json({ error: "actorId is required" });
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
if (!room.participants.has(actorId)) {
|
|
971
|
+
res.status(403).json({ error: `Actor '${actorId}' is not a participant. Call /join first.` });
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const rawIdempotencyKey = req.headers["x-idempotency-key"];
|
|
975
|
+
const idempotencyKey = (rawIdempotencyKey && rawIdempotencyKey.length <= 128) ? rawIdempotencyKey : undefined;
|
|
976
|
+
if (idempotencyKey) {
|
|
977
|
+
const cached = idempotencyCache.get(idempotencyKey);
|
|
978
|
+
if (cached && Date.now() - cached.ts < IDEMPOTENCY_TTL) {
|
|
979
|
+
res.json({ output: cached.output, cached: true });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
const expiredFlag = { value: false };
|
|
985
|
+
let timeoutHandle;
|
|
986
|
+
const result = await room.enqueueExecution(() => Promise.race([
|
|
987
|
+
executeTool(toolName, actorId, input, owner, expiredFlag),
|
|
988
|
+
new Promise((_, reject) => {
|
|
989
|
+
timeoutHandle = setTimeout(() => {
|
|
990
|
+
expiredFlag.value = true;
|
|
991
|
+
reject(new Error(`Tool '${toolName}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
|
|
992
|
+
}, TOOL_HTTP_TIMEOUT_MS);
|
|
993
|
+
}),
|
|
994
|
+
]));
|
|
995
|
+
if (timeoutHandle !== undefined)
|
|
996
|
+
clearTimeout(timeoutHandle);
|
|
997
|
+
if (idempotencyKey) {
|
|
998
|
+
idempotencyCache.set(idempotencyKey, { output: result.output, ts: Date.now() });
|
|
999
|
+
}
|
|
1000
|
+
res.json({ output: result.output, observation: result.observation });
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
let statusCode = 400;
|
|
1004
|
+
if (err instanceof ToolNotFoundError)
|
|
1005
|
+
statusCode = 404;
|
|
1006
|
+
else if (err instanceof ToolForbiddenError)
|
|
1007
|
+
statusCode = 403;
|
|
1008
|
+
const toolForError = mod.tools.find((t) => t.name === toolName);
|
|
1009
|
+
const errorMsg = err instanceof ZodError
|
|
1010
|
+
? formatZodError(err, toolName, toolForError)
|
|
1011
|
+
: (err instanceof Error ? formatHandlerError(err, toolName, toolForError, input) : String(err));
|
|
1012
|
+
const resolvedOwner = owner || room.participants.get(actorId)?.owner;
|
|
1013
|
+
const event = {
|
|
1014
|
+
id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1015
|
+
ts: Date.now(),
|
|
1016
|
+
actorId,
|
|
1017
|
+
...(resolvedOwner ? { owner: resolvedOwner } : {}),
|
|
1018
|
+
tool: toolName,
|
|
1019
|
+
input,
|
|
1020
|
+
error: errorMsg,
|
|
1021
|
+
};
|
|
1022
|
+
room.appendEvent(event);
|
|
1023
|
+
roomEvents.emit("room");
|
|
1024
|
+
res.status(statusCode).json({ error: errorMsg });
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
// ── Batch tool endpoint ─────────────────────────────────────
|
|
1028
|
+
app.post("/tools-batch", async (req, res) => {
|
|
1029
|
+
const mod = getModule();
|
|
1030
|
+
if (!mod) {
|
|
1031
|
+
res.status(500).json({ error: experienceNotLoadedError() });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const { actorId, owner, calls } = req.body;
|
|
1035
|
+
if (!actorId) {
|
|
1036
|
+
res.status(400).json({ error: "actorId is required" });
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (!room.participants.has(actorId)) {
|
|
1040
|
+
res.status(403).json({ error: `Actor '${actorId}' is not a participant. Call /join first.` });
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (!Array.isArray(calls) || calls.length === 0) {
|
|
1044
|
+
res.status(400).json({ error: "Missing or empty 'calls' array. Expected: [{ tool, input? }, ...]" });
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (calls.length > MAX_BATCH_CALLS) {
|
|
1048
|
+
res.status(400).json({ error: `Too many calls in batch (${calls.length}). Maximum is ${MAX_BATCH_CALLS}.` });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const BATCH_TOTAL_TIMEOUT_MS = 60_000;
|
|
1052
|
+
const batchStart = Date.now();
|
|
1053
|
+
const { results, lastObservation, hasError } = await room.enqueueExecution(async () => {
|
|
1054
|
+
const results = [];
|
|
1055
|
+
let lastObservation;
|
|
1056
|
+
let hasError = false;
|
|
1057
|
+
for (const call of calls) {
|
|
1058
|
+
if (Date.now() - batchStart > BATCH_TOTAL_TIMEOUT_MS) {
|
|
1059
|
+
results.push({ tool: call.tool || "?", error: `Batch total timeout exceeded (${BATCH_TOTAL_TIMEOUT_MS}ms)` });
|
|
1060
|
+
hasError = true;
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (!call.tool) {
|
|
1064
|
+
results.push({ tool: "?", error: "Missing 'tool' field in call" });
|
|
1065
|
+
hasError = true;
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
try {
|
|
1069
|
+
const batchExpiredFlag = { value: false };
|
|
1070
|
+
let batchTimeoutHandle;
|
|
1071
|
+
const result = await Promise.race([
|
|
1072
|
+
executeTool(call.tool, actorId, (call.input !== null && typeof call.input === 'object' && !Array.isArray(call.input)) ? call.input : {}, owner, batchExpiredFlag),
|
|
1073
|
+
new Promise((_, reject) => {
|
|
1074
|
+
batchTimeoutHandle = setTimeout(() => {
|
|
1075
|
+
batchExpiredFlag.value = true;
|
|
1076
|
+
reject(new Error(`Tool '${call.tool}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
|
|
1077
|
+
}, TOOL_HTTP_TIMEOUT_MS);
|
|
1078
|
+
}),
|
|
1079
|
+
]);
|
|
1080
|
+
if (batchTimeoutHandle !== undefined)
|
|
1081
|
+
clearTimeout(batchTimeoutHandle);
|
|
1082
|
+
results.push(result);
|
|
1083
|
+
if (result.observation)
|
|
1084
|
+
lastObservation = result.observation;
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
const errorMsg = err instanceof ZodError
|
|
1088
|
+
? formatZodError(err, call.tool)
|
|
1089
|
+
: (err instanceof Error ? err.message : String(err));
|
|
1090
|
+
const resolvedBatchOwner = owner || room.participants.get(actorId)?.owner;
|
|
1091
|
+
const event = {
|
|
1092
|
+
id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1093
|
+
ts: Date.now(),
|
|
1094
|
+
actorId,
|
|
1095
|
+
...(resolvedBatchOwner ? { owner: resolvedBatchOwner } : {}),
|
|
1096
|
+
tool: call.tool,
|
|
1097
|
+
input: call.input || {},
|
|
1098
|
+
error: errorMsg,
|
|
1099
|
+
};
|
|
1100
|
+
room.appendEvent(event);
|
|
1101
|
+
roomEvents.emit("room");
|
|
1102
|
+
results.push({ tool: call.tool, error: errorMsg });
|
|
1103
|
+
hasError = true;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
return { results, lastObservation, hasError };
|
|
1107
|
+
});
|
|
1108
|
+
res.status(hasError ? 207 : 200).json({ results, observation: lastObservation });
|
|
1109
|
+
});
|
|
1110
|
+
// ── Events (supports long-poll via ?timeout=N) ──────────────
|
|
1111
|
+
app.get("/events", (req, res) => {
|
|
1112
|
+
const since = queryInt(req.query.since);
|
|
1113
|
+
const timeout = Math.min(queryInt(req.query.timeout), LONG_POLL_MAX_TIMEOUT_MS);
|
|
1114
|
+
const requestingActorId = queryString(req.query.actorId);
|
|
1115
|
+
const computeObservation = (events) => {
|
|
1116
|
+
const mod = getModule();
|
|
1117
|
+
if (!requestingActorId)
|
|
1118
|
+
return undefined;
|
|
1119
|
+
const agentObserve = mod?.observe ?? defaultObserve;
|
|
1120
|
+
try {
|
|
1121
|
+
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
|
|
1122
|
+
return agentObserve(room.sharedState, lastEvent, requestingActorId);
|
|
1123
|
+
}
|
|
1124
|
+
catch (e) {
|
|
1125
|
+
console.error(`[observe] Error:`, toErrorMessage(e));
|
|
1126
|
+
return undefined;
|
|
1127
|
+
}
|
|
1128
|
+
};
|
|
1129
|
+
const getNewEvents = () => room.events.filter((e) => e.ts > since && e.actorId !== requestingActorId);
|
|
1130
|
+
let newEvents = getNewEvents();
|
|
1131
|
+
if (newEvents.length > 0 || timeout === 0) {
|
|
1132
|
+
res.json({
|
|
1133
|
+
events: newEvents,
|
|
1134
|
+
sharedState: room.sharedState,
|
|
1135
|
+
participants: room.participantList(),
|
|
1136
|
+
observation: computeObservation(newEvents),
|
|
1137
|
+
});
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
let responded = false;
|
|
1141
|
+
let batchTimer = null;
|
|
1142
|
+
const respond = () => {
|
|
1143
|
+
if (responded)
|
|
1144
|
+
return;
|
|
1145
|
+
responded = true;
|
|
1146
|
+
clearTimeout(timer);
|
|
1147
|
+
if (batchTimer) {
|
|
1148
|
+
clearTimeout(batchTimer);
|
|
1149
|
+
batchTimer = null;
|
|
1150
|
+
}
|
|
1151
|
+
roomEvents.removeListener("room", onEvent);
|
|
1152
|
+
newEvents = getNewEvents();
|
|
1153
|
+
res.json({
|
|
1154
|
+
events: newEvents,
|
|
1155
|
+
sharedState: room.sharedState,
|
|
1156
|
+
participants: room.participantList(),
|
|
1157
|
+
observation: computeObservation(newEvents),
|
|
1158
|
+
});
|
|
1159
|
+
};
|
|
1160
|
+
const timer = setTimeout(respond, timeout);
|
|
1161
|
+
const onEvent = () => {
|
|
1162
|
+
if (responded)
|
|
1163
|
+
return;
|
|
1164
|
+
if (batchTimer)
|
|
1165
|
+
return;
|
|
1166
|
+
batchTimer = setTimeout(() => {
|
|
1167
|
+
batchTimer = null;
|
|
1168
|
+
if (responded)
|
|
1169
|
+
return;
|
|
1170
|
+
const pending = getNewEvents();
|
|
1171
|
+
if (pending.length > 0)
|
|
1172
|
+
respond();
|
|
1173
|
+
}, EVENT_BATCH_DEBOUNCE_MS);
|
|
1174
|
+
};
|
|
1175
|
+
roomEvents.on("room", onEvent);
|
|
1176
|
+
req.on("close", () => {
|
|
1177
|
+
responded = true;
|
|
1178
|
+
clearTimeout(timer);
|
|
1179
|
+
if (batchTimer)
|
|
1180
|
+
clearTimeout(batchTimer);
|
|
1181
|
+
roomEvents.removeListener("room", onEvent);
|
|
1182
|
+
});
|
|
1183
|
+
});
|
|
1184
|
+
// ── Browser error capture ──────────────────────────────────
|
|
1185
|
+
const browserErrors = [];
|
|
1186
|
+
const MAX_BROWSER_ERRORS = 20;
|
|
1187
|
+
const BROWSER_ERROR_COOLDOWN_MS = 200;
|
|
1188
|
+
let lastBrowserErrorAt = 0;
|
|
1189
|
+
app.post("/browser-error", (req, res) => {
|
|
1190
|
+
const { message } = req.body || {};
|
|
1191
|
+
if (typeof message === "string" && message.trim()) {
|
|
1192
|
+
const trimmed = message.trim().slice(0, 500);
|
|
1193
|
+
const now = Date.now();
|
|
1194
|
+
browserErrors.push({ message: trimmed, ts: now });
|
|
1195
|
+
if (browserErrors.length > MAX_BROWSER_ERRORS) {
|
|
1196
|
+
browserErrors.splice(0, browserErrors.length - MAX_BROWSER_ERRORS);
|
|
1197
|
+
}
|
|
1198
|
+
if (now - lastBrowserErrorAt >= BROWSER_ERROR_COOLDOWN_MS) {
|
|
1199
|
+
lastBrowserErrorAt = now;
|
|
1200
|
+
roomEvents.emit("room");
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
res.json({ ok: true });
|
|
1204
|
+
});
|
|
1205
|
+
// ── Agent context ──────────────────────────────────────────
|
|
1206
|
+
app.get("/agent-context", (req, res) => {
|
|
1207
|
+
const rawSince = queryInt(req.query.since);
|
|
1208
|
+
const timeout = Math.min(queryInt(req.query.timeout), AGENT_CONTEXT_MAX_TIMEOUT_MS);
|
|
1209
|
+
const actorId = queryString(req.query.actorId) || "unknown";
|
|
1210
|
+
const participantEntry = room.participants.get(actorId);
|
|
1211
|
+
const requestingOwner = queryString(req.query.owner) || participantEntry?.owner;
|
|
1212
|
+
const since = (rawSince === 0 && participantEntry?.eventCursor)
|
|
1213
|
+
? participantEntry.eventCursor
|
|
1214
|
+
: rawSince;
|
|
1215
|
+
if (participantEntry)
|
|
1216
|
+
participantEntry.lastPollAt = Date.now();
|
|
1217
|
+
const getNewEvents = () => {
|
|
1218
|
+
return room.events.filter(e => {
|
|
1219
|
+
if (requestingOwner && e.owner === requestingOwner)
|
|
1220
|
+
return false;
|
|
1221
|
+
if (e.actorId === "_tick-engine" || e.owner === "_system")
|
|
1222
|
+
return false;
|
|
1223
|
+
return e.ts > since;
|
|
1224
|
+
}).sort((a, b) => a.ts - b.ts);
|
|
1225
|
+
};
|
|
1226
|
+
const mod = getModule();
|
|
1227
|
+
const buildResponse = () => {
|
|
1228
|
+
const events = getNewEvents();
|
|
1229
|
+
if (room.kickedActors.has(actorId)) {
|
|
1230
|
+
room.kickedActors.delete(actorId);
|
|
1231
|
+
return { events: [], observation: { done: true, reason: "kicked" }, participants: room.participantList() };
|
|
1232
|
+
}
|
|
1233
|
+
let observation;
|
|
1234
|
+
let observeError;
|
|
1235
|
+
if (mod?.observe) {
|
|
1236
|
+
try {
|
|
1237
|
+
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
|
|
1238
|
+
observation = mod.observe(room.sharedState, lastEvent, actorId);
|
|
1239
|
+
}
|
|
1240
|
+
catch (e) {
|
|
1241
|
+
console.error(`[observe] Error:`, toErrorMessage(e));
|
|
1242
|
+
observeError = toErrorMessage(e);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
let lastError;
|
|
1246
|
+
for (const e of room.events) {
|
|
1247
|
+
if (e.ts > since && e.error && e.actorId === actorId) {
|
|
1248
|
+
lastError = { tool: e.tool, error: e.error };
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const recentBrowserErrors = browserErrors.filter(e => e.ts > since);
|
|
1252
|
+
if (recentBrowserErrors.length > 0) {
|
|
1253
|
+
// Clear reported errors
|
|
1254
|
+
const cutoff = since;
|
|
1255
|
+
while (browserErrors.length > 0 && browserErrors[0].ts <= cutoff) {
|
|
1256
|
+
browserErrors.shift();
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
let eventCursor;
|
|
1260
|
+
if (events.length > 0 && participantEntry) {
|
|
1261
|
+
eventCursor = Math.max(participantEntry.eventCursor || 0, ...events.map(e => e.ts));
|
|
1262
|
+
participantEntry.eventCursor = eventCursor;
|
|
1263
|
+
}
|
|
1264
|
+
else if (participantEntry?.eventCursor) {
|
|
1265
|
+
eventCursor = participantEntry.eventCursor;
|
|
1266
|
+
}
|
|
1267
|
+
return {
|
|
1268
|
+
events,
|
|
1269
|
+
observation: observation || {},
|
|
1270
|
+
observeError,
|
|
1271
|
+
lastError,
|
|
1272
|
+
browserErrors: recentBrowserErrors.length > 0 ? recentBrowserErrors : undefined,
|
|
1273
|
+
participants: room.participantList(),
|
|
1274
|
+
tickEngine: tickEngine ? { enabled: true, tickCount: tickEngine.getStatus().tickCount } : undefined,
|
|
1275
|
+
eventCursor,
|
|
1276
|
+
};
|
|
1277
|
+
};
|
|
1278
|
+
let newEvents = getNewEvents();
|
|
1279
|
+
const pendingBrowserErrs = browserErrors.filter(e => e.ts > since);
|
|
1280
|
+
const isKicked = room.kickedActors.has(actorId);
|
|
1281
|
+
if (newEvents.length > 0 || pendingBrowserErrs.length > 0 || isKicked || timeout === 0) {
|
|
1282
|
+
res.json(buildResponse());
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
let responded = false;
|
|
1286
|
+
let batchTimer = null;
|
|
1287
|
+
const respond = () => {
|
|
1288
|
+
if (responded)
|
|
1289
|
+
return;
|
|
1290
|
+
responded = true;
|
|
1291
|
+
clearTimeout(timer);
|
|
1292
|
+
if (batchTimer) {
|
|
1293
|
+
clearTimeout(batchTimer);
|
|
1294
|
+
batchTimer = null;
|
|
1295
|
+
}
|
|
1296
|
+
roomEvents.removeListener("room", onEvent);
|
|
1297
|
+
res.json(buildResponse());
|
|
1298
|
+
};
|
|
1299
|
+
const timer = setTimeout(respond, timeout);
|
|
1300
|
+
const onEvent = () => {
|
|
1301
|
+
if (responded)
|
|
1302
|
+
return;
|
|
1303
|
+
if (batchTimer)
|
|
1304
|
+
return;
|
|
1305
|
+
batchTimer = setTimeout(() => {
|
|
1306
|
+
batchTimer = null;
|
|
1307
|
+
if (responded)
|
|
1308
|
+
return;
|
|
1309
|
+
const pending = getNewEvents();
|
|
1310
|
+
if (pending.length > 0 || room.kickedActors.has(actorId))
|
|
1311
|
+
respond();
|
|
1312
|
+
}, EVENT_BATCH_DEBOUNCE_MS);
|
|
1313
|
+
};
|
|
1314
|
+
roomEvents.on("room", onEvent);
|
|
1315
|
+
req.on("close", () => {
|
|
1316
|
+
responded = true;
|
|
1317
|
+
clearTimeout(timer);
|
|
1318
|
+
if (batchTimer)
|
|
1319
|
+
clearTimeout(batchTimer);
|
|
1320
|
+
roomEvents.removeListener("room", onEvent);
|
|
1321
|
+
});
|
|
1322
|
+
});
|
|
1323
|
+
// ── Serve client bundle ────────────────────────────────────
|
|
1324
|
+
app.get("/bundle", async (_req, res) => {
|
|
1325
|
+
if (rebuildingPromise)
|
|
1326
|
+
await rebuildingPromise;
|
|
1327
|
+
res.setHeader("Content-Type", "text/javascript");
|
|
1328
|
+
setNoCacheHeaders(res);
|
|
1329
|
+
res.send(loadedExperience?.clientBundle || "");
|
|
1330
|
+
});
|
|
1331
|
+
// ── Stream endpoints ───────────────────────────────────────
|
|
1332
|
+
app.post("/streams/:streamName", (req, res) => {
|
|
1333
|
+
const mod = getModule();
|
|
1334
|
+
if (!mod?.streams) {
|
|
1335
|
+
res.status(404).json({ error: "No streams defined" });
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
const streamDef = mod.streams.find((s) => s.name === req.params.streamName);
|
|
1339
|
+
if (!streamDef) {
|
|
1340
|
+
res.status(404).json({ error: `Stream '${req.params.streamName}' not found` });
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
const { actorId, input } = req.body;
|
|
1344
|
+
if (!actorId) {
|
|
1345
|
+
res.status(400).json({ error: "actorId is required for stream updates" });
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (!room.participants.has(actorId)) {
|
|
1349
|
+
res.status(403).json({ error: `Actor '${actorId}' is not a participant. Call /join first.` });
|
|
1350
|
+
return;
|
|
1351
|
+
}
|
|
1352
|
+
const rateLimitKey = `${actorId}:${req.params.streamName}`;
|
|
1353
|
+
const now = Date.now();
|
|
1354
|
+
const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
|
|
1355
|
+
if (!streamRateLimits.has(rateLimitKey)) {
|
|
1356
|
+
streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
|
|
1357
|
+
}
|
|
1358
|
+
const rl = streamRateLimits.get(rateLimitKey);
|
|
1359
|
+
if (now - rl.windowStart > STREAM_RATE_WINDOW_MS) {
|
|
1360
|
+
rl.count = 0;
|
|
1361
|
+
rl.windowStart = now;
|
|
1362
|
+
}
|
|
1363
|
+
if (rl.count >= rateLimit) {
|
|
1364
|
+
res.status(429).json({ error: `Rate limited: max ${rateLimit} updates/sec for stream '${req.params.streamName}'.` });
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
rl.count++;
|
|
1368
|
+
let validatedInput = input;
|
|
1369
|
+
if (streamDef.input_schema?.parse) {
|
|
1370
|
+
try {
|
|
1371
|
+
validatedInput = streamDef.input_schema.parse(input);
|
|
1372
|
+
}
|
|
1373
|
+
catch (err) {
|
|
1374
|
+
res.status(400).json({ error: toErrorMessage(err) });
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
try {
|
|
1379
|
+
room.sharedState = streamDef.merge(room.sharedState, validatedInput, actorId);
|
|
1380
|
+
}
|
|
1381
|
+
catch (err) {
|
|
1382
|
+
res.status(400).json({ error: toErrorMessage(err) });
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
room.broadcastStateUpdate({ changedBy: actorId, stream: req.params.streamName });
|
|
1386
|
+
roomEvents.emit("room");
|
|
1387
|
+
res.json({ ok: true });
|
|
1388
|
+
});
|
|
1389
|
+
// ── Reset ──────────────────────────────────────────────────
|
|
1390
|
+
app.post("/reset", (_req, res) => {
|
|
1391
|
+
const mod = getModule();
|
|
1392
|
+
if (!mod) {
|
|
1393
|
+
res.status(500).json({ error: experienceNotLoadedError() });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
room.sharedState = resolveInitialState(mod);
|
|
1397
|
+
room.events.length = 0;
|
|
1398
|
+
for (const [, p] of room.participants) {
|
|
1399
|
+
p.eventCursor = 0;
|
|
1400
|
+
}
|
|
1401
|
+
const resetEvent = {
|
|
1402
|
+
id: `${Date.now()}-system-${Math.random().toString(36).slice(2, 6)}`,
|
|
1403
|
+
ts: Date.now(),
|
|
1404
|
+
actorId: "system",
|
|
1405
|
+
tool: "_reset",
|
|
1406
|
+
input: {},
|
|
1407
|
+
output: { reset: true },
|
|
1408
|
+
};
|
|
1409
|
+
room.appendEvent(resetEvent);
|
|
1410
|
+
roomEvents.emit("room");
|
|
1411
|
+
room.resetDeltaTracking();
|
|
1412
|
+
room.broadcastStateUpdate({ changedBy: "system", tool: "_reset" }, true);
|
|
1413
|
+
res.json({ ok: true });
|
|
1414
|
+
});
|
|
1415
|
+
// ── Sync (re-bundle) ──────────────────────────────────────
|
|
1416
|
+
app.post("/sync", async (_req, res) => {
|
|
1417
|
+
try {
|
|
1418
|
+
stopTickEngine();
|
|
1419
|
+
await loadExperience();
|
|
1420
|
+
room.broadcastToAll({ type: "experience_updated" });
|
|
1421
|
+
maybeStartTickEngine();
|
|
1422
|
+
const mod = getModule();
|
|
1423
|
+
res.json({ synced: true, title: mod?.manifest?.title });
|
|
1424
|
+
}
|
|
1425
|
+
catch (err) {
|
|
1426
|
+
res.status(500).json({ error: toErrorMessage(err) });
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
// ── Experiences endpoint ───────────────────────────────────
|
|
1430
|
+
app.get("/experiences", async (_req, res) => {
|
|
1431
|
+
const mod = getModule();
|
|
1432
|
+
if (!mod) {
|
|
1433
|
+
res.json([]);
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1436
|
+
res.json([{
|
|
1437
|
+
id: mod.manifest.id,
|
|
1438
|
+
title: mod.manifest.title,
|
|
1439
|
+
description: mod.manifest.description,
|
|
1440
|
+
version: mod.manifest.version,
|
|
1441
|
+
loaded: true,
|
|
1442
|
+
category: mod.manifest.category,
|
|
1443
|
+
tags: mod.manifest.tags,
|
|
1444
|
+
}]);
|
|
1445
|
+
});
|
|
1446
|
+
// ── Tick status ────────────────────────────────────────────
|
|
1447
|
+
app.get("/tick-status", (_req, res) => {
|
|
1448
|
+
if (!tickEngine) {
|
|
1449
|
+
res.json({ enabled: false, tickRateMs: 0, tickCount: 0, behaviorsTotal: 0, behaviorsEnabled: 0, lastTick: null });
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
res.json(tickEngine.getStatus());
|
|
1453
|
+
});
|
|
1454
|
+
// ── MCP config ─────────────────────────────────────────────
|
|
1455
|
+
app.get("/mcp-config", (_req, res) => {
|
|
1456
|
+
const serverUrl = getBaseUrl();
|
|
1457
|
+
res.json({
|
|
1458
|
+
mcpServers: {
|
|
1459
|
+
"vibevibes-remote": {
|
|
1460
|
+
command: "npx",
|
|
1461
|
+
args: ["-y", "@vibevibes/mcp@latest"],
|
|
1462
|
+
env: { VIBEVIBES_SERVER_URL: serverUrl },
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
instructions: [
|
|
1466
|
+
`Add the above to your .mcp.json to join this room.`,
|
|
1467
|
+
`Or run: npx @vibevibes/mcp@latest ${serverUrl}`,
|
|
1468
|
+
],
|
|
1469
|
+
});
|
|
1470
|
+
});
|
|
1471
|
+
// ── Protocol experience HTML canvas ───────────────────────
|
|
1472
|
+
app.get("/canvas", async (_req, res) => {
|
|
1473
|
+
if (!loadedExperience) {
|
|
1474
|
+
res.status(500).json({ error: experienceNotLoadedError() });
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
const canvasPath = loadedExperience.module?._canvasPath;
|
|
1478
|
+
if (!canvasPath) {
|
|
1479
|
+
res.status(404).json({ error: "This experience has no HTML canvas" });
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
res.setHeader("Content-Type", "text/html");
|
|
1483
|
+
setNoCacheHeaders(res);
|
|
1484
|
+
res.sendFile(canvasPath);
|
|
1485
|
+
});
|
|
1486
|
+
// ── Catch-all: serve viewer ─────────────────────────────────
|
|
1487
|
+
app.get("*", (req, res, next) => {
|
|
1488
|
+
if (req.path.startsWith("/tools/") || req.path.startsWith("/viewer/") ||
|
|
1489
|
+
req.path.startsWith("/streams/") || req.path.endsWith(".js") ||
|
|
1490
|
+
req.path.endsWith(".css") || req.path.endsWith(".map")) {
|
|
1491
|
+
next();
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
setNoCacheHeaders(res);
|
|
1495
|
+
res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
|
|
1496
|
+
});
|
|
1497
|
+
// ── Client bundle smoke test ──────────────────────────────
|
|
1498
|
+
async function smokeTestClientBundle(port) {
|
|
1499
|
+
try {
|
|
1500
|
+
const res = await fetch(`http://localhost:${port}/bundle`);
|
|
1501
|
+
const bundleCode = await res.text();
|
|
1502
|
+
if (bundleCode) {
|
|
1503
|
+
const error = validateClientBundle(bundleCode);
|
|
1504
|
+
if (error) {
|
|
1505
|
+
console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
|
|
1506
|
+
console.error(` ${error}`);
|
|
1507
|
+
console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
|
|
1508
|
+
}
|
|
1509
|
+
else {
|
|
1510
|
+
console.log(` Smoke test: client bundle OK`);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
|
|
1516
|
+
console.error(` ${toErrorMessage(err)}`);
|
|
1517
|
+
console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
// ── Start server ───────────────────────────────────────────
|
|
1521
|
+
export async function startServer(config) {
|
|
1522
|
+
if (config?.projectRoot)
|
|
1523
|
+
PROJECT_ROOT = config.projectRoot;
|
|
1524
|
+
if (config?.port)
|
|
1525
|
+
PORT = config.port;
|
|
1526
|
+
if (!PROJECT_ROOT)
|
|
1527
|
+
throw new Error("@vibevibes/runtime: projectRoot is required.");
|
|
1528
|
+
await loadExperience();
|
|
1529
|
+
const mod = getModule();
|
|
1530
|
+
const initialState = resolveInitialState(mod);
|
|
1531
|
+
room = new Room(mod.manifest.id, initialState);
|
|
1532
|
+
maybeStartTickEngine();
|
|
1533
|
+
console.log(` Experience: ${mod.manifest.id}`);
|
|
1534
|
+
const server = http.createServer(app);
|
|
1535
|
+
const wss = new WebSocketServer({ server, maxPayload: WS_MAX_PAYLOAD_BYTES });
|
|
1536
|
+
wss.on("error", (err) => { console.error("[WSS] server error:", err.message); });
|
|
1537
|
+
const wsCloseTimers = new Map();
|
|
1538
|
+
wss.on("connection", (ws) => {
|
|
1539
|
+
const hbWs = ws;
|
|
1540
|
+
hbWs.isAlive = true;
|
|
1541
|
+
ws.on("pong", () => { hbWs.isAlive = true; });
|
|
1542
|
+
ws.on("error", (err) => { console.error("[WS] connection error:", err.message); });
|
|
1543
|
+
ws.on("message", (data) => {
|
|
1544
|
+
try {
|
|
1545
|
+
const msg = JSON.parse(data.toString());
|
|
1546
|
+
if (msg.type === "join") {
|
|
1547
|
+
const username = (msg.username || "viewer").slice(0, 100);
|
|
1548
|
+
const wsOwner = msg.owner || username;
|
|
1549
|
+
if (msg.actorId) {
|
|
1550
|
+
if (room.kickedActors.has(msg.actorId) || room.kickedOwners.has(wsOwner)) {
|
|
1551
|
+
ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
let staleWs = null;
|
|
1555
|
+
for (const [existingWs, existingId] of room.wsConnections.entries()) {
|
|
1556
|
+
if (existingId === msg.actorId && existingWs !== ws) {
|
|
1557
|
+
staleWs = existingWs;
|
|
1558
|
+
break;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
if (staleWs) {
|
|
1562
|
+
room.wsConnections.delete(staleWs);
|
|
1563
|
+
try {
|
|
1564
|
+
staleWs.close();
|
|
1565
|
+
}
|
|
1566
|
+
catch { }
|
|
1567
|
+
}
|
|
1568
|
+
const closeTimer = wsCloseTimers.get(msg.actorId);
|
|
1569
|
+
if (closeTimer) {
|
|
1570
|
+
clearTimeout(closeTimer);
|
|
1571
|
+
wsCloseTimers.delete(msg.actorId);
|
|
1572
|
+
}
|
|
1573
|
+
if (!room.participants.has(msg.actorId)) {
|
|
1574
|
+
room.participants.set(msg.actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner });
|
|
1575
|
+
}
|
|
1576
|
+
room.wsConnections.set(ws, msg.actorId);
|
|
1577
|
+
}
|
|
1578
|
+
else {
|
|
1579
|
+
if (room.kickedOwners.has(wsOwner)) {
|
|
1580
|
+
ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
let existingActorId;
|
|
1584
|
+
for (const [aid, p] of room.participants) {
|
|
1585
|
+
if (p.owner === wsOwner) {
|
|
1586
|
+
existingActorId = aid;
|
|
1587
|
+
break;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
let actorId;
|
|
1591
|
+
if (existingActorId) {
|
|
1592
|
+
actorId = existingActorId;
|
|
1593
|
+
for (const [existingWs, existingId] of room.wsConnections.entries()) {
|
|
1594
|
+
if (existingId === existingActorId && existingWs !== ws) {
|
|
1595
|
+
room.wsConnections.delete(existingWs);
|
|
1596
|
+
try {
|
|
1597
|
+
existingWs.close();
|
|
1598
|
+
}
|
|
1599
|
+
catch { }
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
else {
|
|
1605
|
+
const mod = getModule();
|
|
1606
|
+
const pSlots = mod?.manifest?.participantSlots || mod?.participants;
|
|
1607
|
+
let wsSlotRole;
|
|
1608
|
+
if (pSlots?.length) {
|
|
1609
|
+
const roleOccupancy = new Map();
|
|
1610
|
+
for (const [, p] of room.participants) {
|
|
1611
|
+
if (p.role)
|
|
1612
|
+
roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
|
|
1613
|
+
}
|
|
1614
|
+
const hasCapacity = (slot) => {
|
|
1615
|
+
const max = slot.maxInstances ?? 1;
|
|
1616
|
+
const current = roleOccupancy.get(slot.role) || 0;
|
|
1617
|
+
return current < max;
|
|
1618
|
+
};
|
|
1619
|
+
let matched;
|
|
1620
|
+
if (msg.role) {
|
|
1621
|
+
matched = pSlots.find((s) => s.role === msg.role && (!s.type || s.type === "human" || s.type === "any") && hasCapacity(s));
|
|
1622
|
+
}
|
|
1623
|
+
if (!matched)
|
|
1624
|
+
matched = pSlots.find((s) => s.type === "human" && hasCapacity(s));
|
|
1625
|
+
if (!matched)
|
|
1626
|
+
matched = pSlots.find((s) => (!s.type || s.type === "any") && hasCapacity(s));
|
|
1627
|
+
if (!matched)
|
|
1628
|
+
matched = pSlots.find((s) => !s.type || s.type === "human" || s.type === "any");
|
|
1629
|
+
if (matched) {
|
|
1630
|
+
wsSlotRole = matched.role;
|
|
1631
|
+
const base = matched.role.toLowerCase().replace(/\s+/g, "-");
|
|
1632
|
+
actorId = assignActorId(username, "human", base);
|
|
1633
|
+
}
|
|
1634
|
+
else {
|
|
1635
|
+
actorId = assignActorId(username, "human");
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
else {
|
|
1639
|
+
actorId = assignActorId(username, "human");
|
|
1640
|
+
}
|
|
1641
|
+
const wsRole = wsSlotRole || msg.role || undefined;
|
|
1642
|
+
room.participants.set(actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner, role: wsRole });
|
|
1643
|
+
}
|
|
1644
|
+
room.wsConnections.set(ws, actorId);
|
|
1645
|
+
}
|
|
1646
|
+
const actorId = room.wsConnections.get(ws);
|
|
1647
|
+
if (!room.participants.has(actorId)) {
|
|
1648
|
+
ws.send(JSON.stringify({ type: "error", error: "Session expired. Please rejoin the room." }));
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
const resolvedWsRole = room.participants.get(actorId)?.role;
|
|
1652
|
+
ws.send(JSON.stringify({
|
|
1653
|
+
type: "joined",
|
|
1654
|
+
actorId,
|
|
1655
|
+
role: resolvedWsRole,
|
|
1656
|
+
sharedState: room.sharedState,
|
|
1657
|
+
stateVersion: room.stateVersion,
|
|
1658
|
+
participants: room.participantList(),
|
|
1659
|
+
participantDetails: room.participantDetails(),
|
|
1660
|
+
events: room.events.slice(-JOIN_EVENT_HISTORY),
|
|
1661
|
+
}));
|
|
1662
|
+
broadcastPresenceUpdate();
|
|
1663
|
+
}
|
|
1664
|
+
if (msg.type === "ephemeral") {
|
|
1665
|
+
const ephPayload = JSON.stringify(msg.data);
|
|
1666
|
+
if (ephPayload.length > WS_EPHEMERAL_MAX_BYTES) {
|
|
1667
|
+
ws.send(JSON.stringify({ type: "error", error: "Ephemeral payload too large (max 64KB)" }));
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
const senderActorId = room.wsConnections.get(ws);
|
|
1671
|
+
if (senderActorId) {
|
|
1672
|
+
const payload = JSON.stringify({ type: "ephemeral", actorId: senderActorId, data: msg.data });
|
|
1673
|
+
for (const [otherWs] of room.wsConnections.entries()) {
|
|
1674
|
+
if (otherWs !== ws && otherWs.readyState === WebSocket.OPEN) {
|
|
1675
|
+
otherWs.send(payload);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
if (msg.type === "stream") {
|
|
1681
|
+
if (typeof msg.name !== "string" || !msg.name) {
|
|
1682
|
+
ws.send(JSON.stringify({ type: "stream_error", error: "stream.name must be a non-empty string" }));
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
const senderActorId = room.wsConnections.get(ws);
|
|
1686
|
+
if (!senderActorId)
|
|
1687
|
+
return;
|
|
1688
|
+
const mod = getModule();
|
|
1689
|
+
if (!mod?.streams)
|
|
1690
|
+
return;
|
|
1691
|
+
const streamDef = mod.streams.find((s) => s.name === msg.name);
|
|
1692
|
+
if (!streamDef) {
|
|
1693
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' not found` }));
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
const rateLimitKey = `${senderActorId}:${msg.name}`;
|
|
1697
|
+
const now = Date.now();
|
|
1698
|
+
const rateLimit = streamDef.rateLimit || DEFAULT_STREAM_RATE_LIMIT;
|
|
1699
|
+
if (!streamRateLimits.has(rateLimitKey)) {
|
|
1700
|
+
streamRateLimits.set(rateLimitKey, { count: 0, windowStart: now });
|
|
1701
|
+
}
|
|
1702
|
+
const rl = streamRateLimits.get(rateLimitKey);
|
|
1703
|
+
if (now - rl.windowStart > STREAM_RATE_WINDOW_MS) {
|
|
1704
|
+
rl.count = 0;
|
|
1705
|
+
rl.windowStart = now;
|
|
1706
|
+
}
|
|
1707
|
+
if (rl.count >= rateLimit) {
|
|
1708
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Rate limited: max ${rateLimit}/sec for '${msg.name}'` }));
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
rl.count++;
|
|
1712
|
+
let validatedInput = msg.input;
|
|
1713
|
+
if (streamDef.input_schema?.parse) {
|
|
1714
|
+
try {
|
|
1715
|
+
validatedInput = streamDef.input_schema.parse(msg.input);
|
|
1716
|
+
}
|
|
1717
|
+
catch (err) {
|
|
1718
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Invalid input for stream '${msg.name}': ${toErrorMessage(err)}` }));
|
|
1719
|
+
return;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
try {
|
|
1723
|
+
room.sharedState = streamDef.merge(room.sharedState, validatedInput, senderActorId);
|
|
1724
|
+
}
|
|
1725
|
+
catch (err) {
|
|
1726
|
+
ws.send(JSON.stringify({ type: "stream_error", error: `Stream '${msg.name}' merge failed: ${toErrorMessage(err)}` }));
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
room.broadcastStateUpdate({ changedBy: senderActorId, stream: msg.name });
|
|
1730
|
+
roomEvents.emit("room");
|
|
1731
|
+
}
|
|
1732
|
+
if (msg.type === "kick") {
|
|
1733
|
+
const kickerActorId = room.wsConnections.get(ws);
|
|
1734
|
+
if (!kickerActorId) {
|
|
1735
|
+
ws.send(JSON.stringify({ type: "kick_error", error: "You are not in the room" }));
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
// Reuse kick logic inline
|
|
1739
|
+
if (kickerActorId === msg.targetActorId) {
|
|
1740
|
+
ws.send(JSON.stringify({ type: "kick_error", error: "Cannot kick yourself" }));
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
const kicker = room.participants.get(kickerActorId);
|
|
1744
|
+
if (!kicker || kicker.type !== "human") {
|
|
1745
|
+
ws.send(JSON.stringify({ type: "kick_error", error: "Only human participants can kick" }));
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
if (!room.participants.has(msg.targetActorId)) {
|
|
1749
|
+
ws.send(JSON.stringify({ type: "kick_error", error: "Participant not found" }));
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
const targetP = room.participants.get(msg.targetActorId);
|
|
1753
|
+
room.participants.delete(msg.targetActorId);
|
|
1754
|
+
if (room.kickedActors.size >= 500) {
|
|
1755
|
+
const oldest = room.kickedActors.values().next().value;
|
|
1756
|
+
if (oldest)
|
|
1757
|
+
room.kickedActors.delete(oldest);
|
|
1758
|
+
}
|
|
1759
|
+
room.kickedActors.add(msg.targetActorId);
|
|
1760
|
+
if (targetP?.owner) {
|
|
1761
|
+
if (room.kickedOwners.size >= 500) {
|
|
1762
|
+
const oldest = room.kickedOwners.values().next().value;
|
|
1763
|
+
if (oldest)
|
|
1764
|
+
room.kickedOwners.delete(oldest);
|
|
1765
|
+
}
|
|
1766
|
+
room.kickedOwners.add(targetP.owner);
|
|
1767
|
+
}
|
|
1768
|
+
for (const [targetWs, wsActorId] of room.wsConnections.entries()) {
|
|
1769
|
+
if (wsActorId === msg.targetActorId) {
|
|
1770
|
+
try {
|
|
1771
|
+
targetWs.send(JSON.stringify({ type: "kicked", by: kickerActorId }));
|
|
1772
|
+
targetWs.close();
|
|
1773
|
+
}
|
|
1774
|
+
catch { }
|
|
1775
|
+
room.wsConnections.delete(targetWs);
|
|
1776
|
+
break;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
broadcastPresenceUpdate();
|
|
1780
|
+
roomEvents.emit("room");
|
|
1781
|
+
ws.send(JSON.stringify({ type: "kick_success", actorId: msg.targetActorId }));
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
catch (err) {
|
|
1785
|
+
if (!(err instanceof SyntaxError)) {
|
|
1786
|
+
console.error("[WS] Unexpected handler error:", err instanceof Error ? err.message : String(err));
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
ws.on("close", () => {
|
|
1791
|
+
const actorId = room.wsConnections.get(ws);
|
|
1792
|
+
if (actorId) {
|
|
1793
|
+
room.wsConnections.delete(ws);
|
|
1794
|
+
const participant = room.participants.get(actorId);
|
|
1795
|
+
if (!participant || participant.type === "human") {
|
|
1796
|
+
const timer = setTimeout(() => {
|
|
1797
|
+
wsCloseTimers.delete(actorId);
|
|
1798
|
+
let reconnected = false;
|
|
1799
|
+
for (const [, wsActorId] of room.wsConnections) {
|
|
1800
|
+
if (wsActorId === actorId) {
|
|
1801
|
+
reconnected = true;
|
|
1802
|
+
break;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
if (!reconnected) {
|
|
1806
|
+
room.participants.delete(actorId);
|
|
1807
|
+
broadcastPresenceUpdate();
|
|
1808
|
+
}
|
|
1809
|
+
}, WS_CLOSE_GRACE_MS);
|
|
1810
|
+
const prev = wsCloseTimers.get(actorId);
|
|
1811
|
+
if (prev)
|
|
1812
|
+
clearTimeout(prev);
|
|
1813
|
+
wsCloseTimers.set(actorId, timer);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
});
|
|
1817
|
+
});
|
|
1818
|
+
// ── WebSocket heartbeat interval ──────────────────────────
|
|
1819
|
+
const heartbeatInterval = setInterval(() => {
|
|
1820
|
+
for (const ws of wss.clients) {
|
|
1821
|
+
if (ws.isAlive === false) {
|
|
1822
|
+
const actorId = room.wsConnections.get(ws);
|
|
1823
|
+
if (actorId) {
|
|
1824
|
+
room.participants.delete(actorId);
|
|
1825
|
+
room.wsConnections.delete(ws);
|
|
1826
|
+
broadcastPresenceUpdate();
|
|
1827
|
+
}
|
|
1828
|
+
ws.terminate();
|
|
1829
|
+
continue;
|
|
1830
|
+
}
|
|
1831
|
+
ws.isAlive = false;
|
|
1832
|
+
ws.ping();
|
|
1833
|
+
}
|
|
1834
|
+
}, WS_HEARTBEAT_INTERVAL_MS);
|
|
1835
|
+
// ── AI agent heartbeat sweep ──────────────────────────────
|
|
1836
|
+
const AI_HEARTBEAT_TIMEOUT_MS = 300_000;
|
|
1837
|
+
const aiHeartbeatInterval = setInterval(() => {
|
|
1838
|
+
const now = Date.now();
|
|
1839
|
+
const toEvict = [];
|
|
1840
|
+
for (const [actorId, p] of room.participants) {
|
|
1841
|
+
if (p.type !== "ai")
|
|
1842
|
+
continue;
|
|
1843
|
+
if (p.agentMode === "behavior")
|
|
1844
|
+
continue;
|
|
1845
|
+
const lastSeen = p.lastPollAt || p.joinedAt;
|
|
1846
|
+
if (now - lastSeen > AI_HEARTBEAT_TIMEOUT_MS)
|
|
1847
|
+
toEvict.push(actorId);
|
|
1848
|
+
}
|
|
1849
|
+
for (const actorId of toEvict) {
|
|
1850
|
+
room.participants.delete(actorId);
|
|
1851
|
+
}
|
|
1852
|
+
if (toEvict.length > 0) {
|
|
1853
|
+
broadcastPresenceUpdate();
|
|
1854
|
+
roomEvents.emit("room");
|
|
1855
|
+
}
|
|
1856
|
+
}, WS_HEARTBEAT_INTERVAL_MS);
|
|
1857
|
+
// ── Watch src/ for changes ────────────────────────────────
|
|
1858
|
+
const watchDirs = [
|
|
1859
|
+
path.join(PROJECT_ROOT, "src"),
|
|
1860
|
+
path.join(PROJECT_ROOT, "experiences"),
|
|
1861
|
+
path.resolve(PROJECT_ROOT, "..", "experiences"),
|
|
1862
|
+
].filter((d) => fs.existsSync(d));
|
|
1863
|
+
let debounceTimer = null;
|
|
1864
|
+
function onSrcChange(filename) {
|
|
1865
|
+
if (debounceTimer)
|
|
1866
|
+
clearTimeout(debounceTimer);
|
|
1867
|
+
if (!rebuildingPromise) {
|
|
1868
|
+
rebuildingPromise = new Promise((resolve) => { rebuildingResolve = resolve; });
|
|
1869
|
+
}
|
|
1870
|
+
debounceTimer = setTimeout(async () => {
|
|
1871
|
+
console.log(`\nFile changed${filename ? ` (${filename})` : ""}, rebuilding...`);
|
|
1872
|
+
stopTickEngine();
|
|
1873
|
+
try {
|
|
1874
|
+
await loadExperience();
|
|
1875
|
+
room.broadcastToAll({ type: "experience_updated" });
|
|
1876
|
+
maybeStartTickEngine();
|
|
1877
|
+
smokeTestClientBundle(PORT);
|
|
1878
|
+
console.log("Hot reload complete.");
|
|
1879
|
+
}
|
|
1880
|
+
catch (err) {
|
|
1881
|
+
experienceError = toErrorMessage(err);
|
|
1882
|
+
console.error("Hot reload failed:", toErrorMessage(err));
|
|
1883
|
+
room.broadcastToAll({ type: "build_error", error: toErrorMessage(err) });
|
|
1884
|
+
maybeStartTickEngine();
|
|
1885
|
+
}
|
|
1886
|
+
finally {
|
|
1887
|
+
if (rebuildingResolve) {
|
|
1888
|
+
rebuildingResolve();
|
|
1889
|
+
rebuildingResolve = null;
|
|
1890
|
+
rebuildingPromise = null;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
}, HOT_RELOAD_DEBOUNCE_MS);
|
|
1894
|
+
}
|
|
1895
|
+
for (const watchDir of watchDirs) {
|
|
1896
|
+
try {
|
|
1897
|
+
fs.watch(watchDir, { recursive: true }, (_event, filename) => {
|
|
1898
|
+
if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
|
|
1899
|
+
onSrcChange(path.join(path.relative(PROJECT_ROOT, watchDir), filename));
|
|
1900
|
+
}
|
|
1901
|
+
});
|
|
1902
|
+
}
|
|
1903
|
+
catch {
|
|
1904
|
+
function watchDirRecursive(dir) {
|
|
1905
|
+
fs.watch(dir, (_event, filename) => {
|
|
1906
|
+
if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
|
|
1907
|
+
onSrcChange(path.join(path.relative(PROJECT_ROOT, dir), filename));
|
|
1908
|
+
}
|
|
1909
|
+
});
|
|
1910
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
1911
|
+
if (entry.isDirectory())
|
|
1912
|
+
watchDirRecursive(path.join(dir, entry.name));
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
watchDirRecursive(watchDir);
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
server.listen(PORT, async () => {
|
|
1919
|
+
console.log(`\n vibe-vibe local runtime`);
|
|
1920
|
+
console.log(` ───────────────────────`);
|
|
1921
|
+
console.log(` Viewer: http://localhost:${PORT}`);
|
|
1922
|
+
smokeTestClientBundle(PORT);
|
|
1923
|
+
if (publicUrl) {
|
|
1924
|
+
const shareUrl = getBaseUrl();
|
|
1925
|
+
console.log(``);
|
|
1926
|
+
console.log(` ┌─────────────────────────────────────────────────┐`);
|
|
1927
|
+
console.log(` │ SHARE WITH FRIENDS: │`);
|
|
1928
|
+
console.log(` │ │`);
|
|
1929
|
+
console.log(` │ ${shareUrl.padEnd(47)} │`);
|
|
1930
|
+
console.log(` │ │`);
|
|
1931
|
+
console.log(` │ Open in browser to join the room. │`);
|
|
1932
|
+
console.log(` │ AI: npx @vibevibes/mcp ${(shareUrl).padEnd(23)} │`);
|
|
1933
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
1934
|
+
}
|
|
1935
|
+
console.log(`\n Watching src/ for changes\n`);
|
|
1936
|
+
});
|
|
1937
|
+
server.on("close", () => {
|
|
1938
|
+
clearInterval(heartbeatInterval);
|
|
1939
|
+
clearInterval(aiHeartbeatInterval);
|
|
1940
|
+
clearInterval(_streamRateCleanupTimer);
|
|
1941
|
+
clearInterval(_idempotencyCleanupTimer);
|
|
1942
|
+
});
|
|
1943
|
+
return server;
|
|
1944
|
+
}
|
|
1945
|
+
export function setProjectRoot(root) {
|
|
1946
|
+
PROJECT_ROOT = root;
|
|
1947
|
+
}
|