@vibevibes/sdk 0.5.0 → 0.6.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/server.js ADDED
@@ -0,0 +1,1441 @@
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 { 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
+ function formatZodError(err, toolName, tool) {
19
+ const issues = err.issues.map((issue) => {
20
+ const path = issue.path.length > 0 ? `'${issue.path.join(".")}'` : "input";
21
+ const extra = [];
22
+ const detail = issue;
23
+ if (detail.expected)
24
+ extra.push(`expected ${detail.expected}`);
25
+ if (detail.received && detail.received !== "undefined")
26
+ extra.push(`got ${detail.received}`);
27
+ const suffix = extra.length > 0 ? ` (${extra.join(", ")})` : "";
28
+ return ` ${path}: ${issue.message}${suffix}`;
29
+ });
30
+ let msg = `Invalid input for '${toolName}':\n${issues.join("\n")}`;
31
+ if (tool?.input_schema) {
32
+ try {
33
+ const schema = (tool.input_schema._jsonSchema || zodToJsonSchema(tool.input_schema));
34
+ const props = schema.properties;
35
+ const req = schema.required || [];
36
+ if (props) {
37
+ const fields = Object.entries(props).map(([k, v]) => {
38
+ const optional = !req.includes(k);
39
+ return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
40
+ });
41
+ msg += `\n\nExpected schema: { ${fields.join(", ")} }`;
42
+ }
43
+ }
44
+ catch { }
45
+ }
46
+ if (tool?.description)
47
+ msg += `\nTool description: ${tool.description}`;
48
+ msg += `\n\nHint: Provide all required fields with correct types.`;
49
+ return msg;
50
+ }
51
+ function formatHandlerError(err, toolName, tool, input) {
52
+ const message = err.message || String(err);
53
+ let msg = `Tool '${toolName}' failed: ${message}`;
54
+ if (input !== undefined) {
55
+ try {
56
+ msg += `\n\nInput provided: ${JSON.stringify(input)}`;
57
+ }
58
+ catch { }
59
+ }
60
+ if (tool?.input_schema) {
61
+ try {
62
+ const schema = (tool.input_schema._jsonSchema || zodToJsonSchema(tool.input_schema));
63
+ const props = schema.properties;
64
+ const req = schema.required || [];
65
+ if (props) {
66
+ const fields = Object.entries(props).map(([k, v]) => {
67
+ const optional = !req.includes(k);
68
+ return `${k}${optional ? "?" : ""}: ${v.type || "any"}`;
69
+ });
70
+ msg += `\nTool expects: { ${fields.join(", ")} }`;
71
+ }
72
+ }
73
+ catch { }
74
+ }
75
+ if (message.includes("Cannot read properties of undefined") || message.includes("Cannot read property")) {
76
+ 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.`;
77
+ }
78
+ else if (message.includes("is not a function")) {
79
+ msg += `\n\nHint: Something expected to be a function is not. Check for missing imports or incorrect variable types in the tool handler.`;
80
+ }
81
+ else if (message.includes("Maximum call stack")) {
82
+ msg += `\n\nHint: Infinite recursion detected. A tool handler or function is calling itself without a base case.`;
83
+ }
84
+ else {
85
+ msg += `\n\nHint: The tool handler threw an error. Check the handler logic and ensure the current state matches what the handler expects.`;
86
+ }
87
+ return msg;
88
+ }
89
+ function toErrorMessage(err) {
90
+ return err instanceof Error ? err.message : String(err);
91
+ }
92
+ function queryString(val) {
93
+ return typeof val === "string" ? val : undefined;
94
+ }
95
+ function queryInt(val, radix = 10) {
96
+ return typeof val === "string" ? (parseInt(val, radix) || 0) : 0;
97
+ }
98
+ const __runtimeDir = path.dirname(fileURLToPath(import.meta.url));
99
+ let PROJECT_ROOT = "";
100
+ // ── Custom error types ───────────────────────────────────────
101
+ class ToolNotFoundError extends Error {
102
+ constructor(message) { super(message); this.name = "ToolNotFoundError"; }
103
+ }
104
+ class ToolForbiddenError extends Error {
105
+ constructor(message) { super(message); this.name = "ToolForbiddenError"; }
106
+ }
107
+ // ── Room ───────────────────────────────────────────────────
108
+ class Room {
109
+ id = "local";
110
+ experienceId;
111
+ config = {};
112
+ sharedState = {};
113
+ participants = new Map();
114
+ events = [];
115
+ wsConnections = new Map();
116
+ kickedActors = new Set();
117
+ kickedOwners = new Set();
118
+ _executionQueue = Promise.resolve();
119
+ stateVersion = 0;
120
+ _prevState = null;
121
+ constructor(experienceId, initialState) {
122
+ this.experienceId = experienceId;
123
+ if (initialState)
124
+ this.sharedState = initialState;
125
+ }
126
+ broadcastToAll(message) {
127
+ const data = JSON.stringify(message);
128
+ for (const ws of this.wsConnections.keys()) {
129
+ if (ws.readyState === WebSocket.OPEN) {
130
+ try {
131
+ ws.send(data);
132
+ }
133
+ catch { }
134
+ }
135
+ }
136
+ }
137
+ broadcastStateUpdate(extra, forceFullState = false) {
138
+ this.stateVersion++;
139
+ const prev = this._prevState;
140
+ this._prevState = this.sharedState;
141
+ if (!prev || forceFullState) {
142
+ this.broadcastToAll({
143
+ type: "shared_state_update",
144
+ stateVersion: this.stateVersion,
145
+ state: this.sharedState,
146
+ ...extra,
147
+ });
148
+ return;
149
+ }
150
+ const changed = {};
151
+ const deleted = [];
152
+ let changeCount = 0;
153
+ for (const key of Object.keys(this.sharedState)) {
154
+ if (this.sharedState[key] !== prev[key]) {
155
+ changed[key] = this.sharedState[key];
156
+ changeCount++;
157
+ }
158
+ }
159
+ for (const key of Object.keys(prev)) {
160
+ if (!(key in this.sharedState)) {
161
+ deleted.push(key);
162
+ changeCount++;
163
+ }
164
+ }
165
+ if (changeCount === 0 && !extra.event)
166
+ return;
167
+ if (changeCount === 0) {
168
+ this.broadcastToAll({
169
+ type: "shared_state_update",
170
+ stateVersion: this.stateVersion,
171
+ delta: {},
172
+ ...extra,
173
+ });
174
+ }
175
+ else {
176
+ this.broadcastToAll({
177
+ type: "shared_state_update",
178
+ stateVersion: this.stateVersion,
179
+ delta: changed,
180
+ ...(deleted.length > 0 ? { deletedKeys: deleted } : {}),
181
+ ...extra,
182
+ });
183
+ }
184
+ }
185
+ resetDeltaTracking() {
186
+ this._prevState = null;
187
+ }
188
+ participantList() {
189
+ return Array.from(this.participants.keys());
190
+ }
191
+ participantDetails() {
192
+ return Array.from(this.participants.entries()).map(([actorId, p]) => {
193
+ const detail = {
194
+ actorId, type: p.type, role: p.role, owner: p.owner,
195
+ };
196
+ if (p.metadata && Object.keys(p.metadata).length > 0)
197
+ detail.metadata = p.metadata;
198
+ return detail;
199
+ });
200
+ }
201
+ appendEvent(event) {
202
+ this.events.push(event);
203
+ if (this.events.length > MAX_EVENTS) {
204
+ this.events.splice(0, this.events.length - MAX_EVENTS);
205
+ }
206
+ }
207
+ enqueueExecution(fn) {
208
+ const next = this._executionQueue.then(() => fn());
209
+ this._executionQueue = next.then(() => { }, () => { });
210
+ return next;
211
+ }
212
+ }
213
+ // ── Constants ─────────────────────────────────────────────
214
+ const DEFAULT_PORT = 4321;
215
+ const MAX_EVENTS = 200;
216
+ const JOIN_EVENT_HISTORY = 20;
217
+ const ROOM_STATE_EVENT_HISTORY = 50;
218
+ const EVENT_BATCH_DEBOUNCE_MS = 50;
219
+ const MAX_BATCH_CALLS = 10;
220
+ const AGENT_CONTEXT_MAX_TIMEOUT_MS = 10000;
221
+ const WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
222
+ const WS_EPHEMERAL_MAX_BYTES = 65536;
223
+ const WS_HEARTBEAT_INTERVAL_MS = 30000;
224
+ const HOT_RELOAD_DEBOUNCE_MS = 300;
225
+ const WS_CLOSE_GRACE_MS = 3000;
226
+ const JSON_BODY_LIMIT = "256kb";
227
+ const TOOL_HTTP_TIMEOUT_MS = 30_000;
228
+ const ROOM_EVENTS_MAX_LISTENERS = 200;
229
+ const IDEMPOTENCY_CLEANUP_INTERVAL_MS = 60000;
230
+ // ── Default observe ────────────────────────────────────────
231
+ function defaultObserve(state, _event, _actorId) {
232
+ const result = {};
233
+ for (const [k, v] of Object.entries(state)) {
234
+ if (!k.startsWith("_"))
235
+ result[k] = v;
236
+ }
237
+ const phase = typeof state.phase === "string" ? state.phase : null;
238
+ result.directive = phase ? `Current phase: ${phase}` : "Observe the current state and act accordingly.";
239
+ return result;
240
+ }
241
+ // ── Global state ──────────────────────────────────────────
242
+ let PORT = parseInt(process.env.PORT || String(DEFAULT_PORT), 10);
243
+ let publicUrl = null;
244
+ let room;
245
+ let _actorCounter = 0;
246
+ const roomEvents = new EventEmitter();
247
+ roomEvents.setMaxListeners(ROOM_EVENTS_MAX_LISTENERS);
248
+ // Experience (single)
249
+ let loadedExperience = null;
250
+ let experienceError = null;
251
+ // Hot-reload rebuild gate
252
+ let rebuildingResolve = null;
253
+ let rebuildingPromise = null;
254
+ export function setPublicUrl(url) {
255
+ publicUrl = url;
256
+ }
257
+ export function getBaseUrl() {
258
+ return publicUrl || `http://localhost:${PORT}`;
259
+ }
260
+ // ── Helpers ────────────────────────────────────────────────
261
+ const FORBIDDEN_MERGE_KEYS = new Set(["__proto__", "constructor", "prototype"]);
262
+ function assignActorId(username, type, owner) {
263
+ const base = owner || `${username}-${type}`;
264
+ _actorCounter++;
265
+ return `${base}-${_actorCounter}`;
266
+ }
267
+ function setNoCacheHeaders(res) {
268
+ res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
269
+ res.setHeader("Pragma", "no-cache");
270
+ res.setHeader("Expires", "0");
271
+ }
272
+ function getToolList(mod, allowedTools) {
273
+ if (!mod?.tools)
274
+ return [];
275
+ let tools = mod.tools;
276
+ if (allowedTools)
277
+ tools = tools.filter((t) => allowedTools.includes(t.name));
278
+ return tools.map((t) => ({
279
+ name: t.name,
280
+ description: t.description,
281
+ risk: t.risk || "low",
282
+ input_schema: t.input_schema?._jsonSchema
283
+ ? t.input_schema._jsonSchema
284
+ : t.input_schema ? zodToJsonSchema(t.input_schema) : {},
285
+ }));
286
+ }
287
+ function getModule() {
288
+ return loadedExperience?.module;
289
+ }
290
+ function resolveInitialState(mod) {
291
+ const init = mod?.initialState;
292
+ if (typeof init === "function") {
293
+ try {
294
+ return init({}) || {};
295
+ }
296
+ catch (err) {
297
+ console.warn(`[resolveInitialState] initialState() threw:`, err);
298
+ return {};
299
+ }
300
+ }
301
+ if (init && typeof init === "object")
302
+ return { ...init };
303
+ const schema = mod?.stateSchema;
304
+ if (schema && typeof schema.parse === "function") {
305
+ try {
306
+ return schema.parse({});
307
+ }
308
+ catch { }
309
+ }
310
+ return {};
311
+ }
312
+ function broadcastPresenceUpdate() {
313
+ room.broadcastToAll({
314
+ type: "presence_update",
315
+ participants: room.participantList(),
316
+ participantDetails: room.participantDetails(),
317
+ });
318
+ }
319
+ function experienceNotLoadedError() {
320
+ const hint = experienceError
321
+ ? `\nLast build error: ${experienceError}\nFix the source and save to hot-reload.`
322
+ : `\nCheck that src/index.tsx exists and exports a valid experience.`;
323
+ return `Experience not loaded.${hint}`;
324
+ }
325
+ // ── Experience discovery & loading ──────────────────────────
326
+ function discoverEntryPath() {
327
+ const tsxPath = path.join(PROJECT_ROOT, "src", "index.tsx");
328
+ if (fs.existsSync(tsxPath))
329
+ return tsxPath;
330
+ const rootTsx = path.join(PROJECT_ROOT, "index.tsx");
331
+ if (fs.existsSync(rootTsx))
332
+ return rootTsx;
333
+ throw new Error(`No experience found in ${PROJECT_ROOT}. ` +
334
+ `Create src/index.tsx (TypeScript).`);
335
+ }
336
+ async function loadExperience() {
337
+ const entryPath = discoverEntryPath();
338
+ const [sCode, cCode] = await Promise.all([
339
+ bundleForServer(entryPath),
340
+ bundleForClient(entryPath),
341
+ ]);
342
+ const mod = await evalServerBundle(sCode);
343
+ if (!mod?.manifest || !mod?.tools) {
344
+ throw new Error(`Experience at ${entryPath} missing manifest or tools`);
345
+ }
346
+ const clientError = validateClientBundle(cCode);
347
+ if (clientError) {
348
+ throw new Error(`Client bundle validation failed for ${entryPath}: ${clientError}`);
349
+ }
350
+ loadedExperience = {
351
+ module: mod,
352
+ clientBundle: cCode,
353
+ serverCode: sCode,
354
+ loadedAt: Date.now(),
355
+ sourcePath: entryPath,
356
+ };
357
+ experienceError = null;
358
+ }
359
+ // ── Express app ────────────────────────────────────────────
360
+ const app = express();
361
+ app.use(express.json({ limit: JSON_BODY_LIMIT }));
362
+ app.use((_req, res, next) => {
363
+ res.setHeader("Access-Control-Allow-Origin", "*");
364
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,PATCH,DELETE,OPTIONS");
365
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Idempotency-Key");
366
+ if (_req.method === "OPTIONS") {
367
+ res.sendStatus(200);
368
+ return;
369
+ }
370
+ next();
371
+ });
372
+ app.get("/", (_req, res) => {
373
+ setNoCacheHeaders(res);
374
+ res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
375
+ });
376
+ app.use("/viewer", express.static(path.join(__runtimeDir, "viewer")));
377
+ app.get("/sdk.js", (_req, res) => {
378
+ res.setHeader("Content-Type", "application/javascript");
379
+ res.send(`
380
+ export function defineExperience(c) { return c; }
381
+ export function defineTool(c) { return { risk: "low", capabilities_required: [], ...c }; }
382
+ export function defineTest(c) { return c; }
383
+ export function defineStream(c) { return c; }
384
+ `);
385
+ });
386
+ // ── State endpoint ─────────────────────────────────────────
387
+ app.get("/state", (req, res) => {
388
+ const mod = getModule();
389
+ let observation;
390
+ const observeFn = mod?.observe ?? defaultObserve;
391
+ const observeActorId = typeof req.query.actorId === "string" ? req.query.actorId : "viewer";
392
+ try {
393
+ observation = observeFn(room.sharedState, null, observeActorId);
394
+ }
395
+ catch (err) {
396
+ console.warn(`[observe] Error: ${toErrorMessage(err)}`);
397
+ }
398
+ res.json({
399
+ experienceId: mod?.manifest?.id,
400
+ sharedState: room.sharedState,
401
+ stateVersion: room.stateVersion,
402
+ participants: room.participantList(),
403
+ events: room.events.slice(-ROOM_STATE_EVENT_HISTORY),
404
+ observation,
405
+ });
406
+ });
407
+ // ── Participants endpoint ──────────────────────────────────
408
+ app.get("/participants", (_req, res) => res.json({ participants: room.participantDetails() }));
409
+ // ── Tools list endpoint ────────────────────────────────────
410
+ app.get("/tools-list", (req, res) => {
411
+ const mod = getModule();
412
+ if (!mod) {
413
+ res.status(500).json({ error: experienceNotLoadedError() });
414
+ return;
415
+ }
416
+ const actorId = queryString(req.query.actorId);
417
+ let allowedTools;
418
+ if (actorId) {
419
+ const participant = room.participants.get(actorId);
420
+ allowedTools = participant?.allowedTools;
421
+ }
422
+ const tools = getToolList(mod, allowedTools);
423
+ res.json({
424
+ experienceId: mod.manifest?.id,
425
+ tools,
426
+ toolCount: tools.length,
427
+ });
428
+ });
429
+ // ── Join ───────────────────────────────────────────────────
430
+ app.post("/join", (req, res) => {
431
+ const mod = getModule();
432
+ if (!mod) {
433
+ res.status(500).json({ error: experienceNotLoadedError() });
434
+ return;
435
+ }
436
+ const { username = "user", actorType: rawActorType = "human", owner, role: requestedRole, metadata: rawMetadata } = req.body;
437
+ const actorType = rawActorType === "ai" ? "ai" : "human";
438
+ const resolvedOwner = owner || username;
439
+ let metadata;
440
+ if (rawMetadata && typeof rawMetadata === "object" && !Array.isArray(rawMetadata)) {
441
+ metadata = {};
442
+ let keyCount = 0;
443
+ for (const [k, v] of Object.entries(rawMetadata)) {
444
+ if (keyCount >= 20)
445
+ break;
446
+ if (typeof k === "string" && typeof v === "string" && k.length <= 50) {
447
+ metadata[k] = String(v).slice(0, 200);
448
+ keyCount++;
449
+ }
450
+ }
451
+ if (Object.keys(metadata).length === 0)
452
+ metadata = undefined;
453
+ }
454
+ if (!resolvedOwner) {
455
+ res.status(400).json({ error: "owner or username required" });
456
+ return;
457
+ }
458
+ // Dedup: if same owner already has a participant, reuse
459
+ if (resolvedOwner) {
460
+ if (room.kickedOwners.has(resolvedOwner)) {
461
+ res.status(403).json({ error: "You have been kicked from this room." });
462
+ return;
463
+ }
464
+ for (const [existingId, existingP] of room.participants.entries()) {
465
+ if (existingP.owner === resolvedOwner) {
466
+ if (room.kickedActors.has(existingId)) {
467
+ res.status(403).json({ error: "You have been kicked from this room." });
468
+ return;
469
+ }
470
+ existingP.joinedAt = Date.now();
471
+ if (existingP.type === "ai" && existingP.role) {
472
+ room.sharedState = {
473
+ ...room.sharedState,
474
+ _agentRoles: { ...(room.sharedState._agentRoles ?? {}), [existingId]: existingP.role },
475
+ };
476
+ }
477
+ for (const [ws, wsActor] of room.wsConnections.entries()) {
478
+ if (wsActor === existingId) {
479
+ room.wsConnections.delete(ws);
480
+ try {
481
+ ws.close(1000, "Replaced by reconnect");
482
+ }
483
+ catch { }
484
+ break;
485
+ }
486
+ }
487
+ broadcastPresenceUpdate();
488
+ let observation;
489
+ let observeError;
490
+ const reconnectObserve = mod.observe ?? defaultObserve;
491
+ try {
492
+ observation = reconnectObserve(room.sharedState, null, existingId);
493
+ }
494
+ catch (e) {
495
+ console.error(`[observe] Error:`, toErrorMessage(e));
496
+ observeError = toErrorMessage(e);
497
+ }
498
+ res.json({
499
+ actorId: existingId,
500
+ owner: resolvedOwner,
501
+ role: existingP.role,
502
+ systemPrompt: existingP.systemPrompt,
503
+ reconnected: true,
504
+ observation,
505
+ observeError,
506
+ tools: getToolList(mod, existingP.allowedTools),
507
+ });
508
+ return;
509
+ }
510
+ }
511
+ }
512
+ // Participant slot matching
513
+ const participantSlots = mod.manifest?.participantSlots || mod.participants;
514
+ const agentSlots = mod.agents || mod.manifest?.agentSlots;
515
+ let slotRole;
516
+ let slotAllowedTools;
517
+ let actorIdBase;
518
+ let slotSystemPrompt;
519
+ if (participantSlots?.length) {
520
+ const roleOccupancy = new Map();
521
+ for (const [, p] of room.participants) {
522
+ if (p.role)
523
+ roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
524
+ }
525
+ const typeMatches = (slotType, joinType) => {
526
+ if (!slotType || slotType === "any")
527
+ return true;
528
+ return slotType === joinType;
529
+ };
530
+ const hasCapacity = (slot) => {
531
+ const max = slot.maxInstances ?? 1;
532
+ const current = roleOccupancy.get(slot.role) || 0;
533
+ return current < max;
534
+ };
535
+ let matched;
536
+ if (requestedRole) {
537
+ matched = participantSlots.find((s) => s.role === requestedRole && typeMatches(s.type, actorType) && hasCapacity(s));
538
+ }
539
+ if (!matched)
540
+ matched = participantSlots.find((s) => s.type === actorType && hasCapacity(s));
541
+ if (!matched)
542
+ matched = participantSlots.find((s) => typeMatches(s.type, actorType) && hasCapacity(s));
543
+ if (!matched)
544
+ matched = participantSlots.find((s) => typeMatches(s.type, actorType));
545
+ if (matched) {
546
+ slotRole = matched.role;
547
+ slotAllowedTools = matched.allowedTools;
548
+ slotSystemPrompt = matched.systemPrompt;
549
+ }
550
+ }
551
+ else if (actorType === "ai" && agentSlots && agentSlots.length > 0) {
552
+ const occupiedRoles = new Set();
553
+ for (const [, p] of room.participants) {
554
+ if (p.type === "ai" && p.role)
555
+ occupiedRoles.add(p.role);
556
+ }
557
+ const slot = agentSlots.find((s) => !occupiedRoles.has(s.role)) || agentSlots[0];
558
+ slotRole = slot.role;
559
+ slotAllowedTools = slot.allowedTools;
560
+ slotSystemPrompt = slot.systemPrompt;
561
+ }
562
+ const actorId = assignActorId(username, actorType, actorIdBase || resolvedOwner);
563
+ const participant = { type: actorType, joinedAt: Date.now(), owner: resolvedOwner };
564
+ if (slotRole)
565
+ participant.role = slotRole;
566
+ if (slotAllowedTools)
567
+ participant.allowedTools = slotAllowedTools;
568
+ if (slotSystemPrompt)
569
+ participant.systemPrompt = slotSystemPrompt;
570
+ if (!slotRole && requestedRole)
571
+ participant.role = requestedRole;
572
+ if (metadata)
573
+ participant.metadata = metadata;
574
+ room.participants.set(actorId, participant);
575
+ if (actorType === "ai" && participant.role) {
576
+ room.sharedState = {
577
+ ...room.sharedState,
578
+ _agentRoles: { ...(room.sharedState._agentRoles ?? {}), [actorId]: participant.role },
579
+ };
580
+ }
581
+ broadcastPresenceUpdate();
582
+ let observation;
583
+ let observeError;
584
+ const joinObserve = mod.observe ?? defaultObserve;
585
+ try {
586
+ observation = joinObserve(room.sharedState, null, actorId);
587
+ }
588
+ catch (e) {
589
+ console.error(`[observe] Error:`, toErrorMessage(e));
590
+ observeError = toErrorMessage(e);
591
+ }
592
+ res.json({
593
+ actorId,
594
+ owner: resolvedOwner,
595
+ experienceId: mod.manifest.id,
596
+ sharedState: room.sharedState,
597
+ participants: room.participantList(),
598
+ events: room.events.slice(-JOIN_EVENT_HISTORY),
599
+ tools: getToolList(mod, participant.allowedTools),
600
+ browserUrl: getBaseUrl(),
601
+ observation,
602
+ role: participant.role,
603
+ allowedTools: participant.allowedTools,
604
+ systemPrompt: slotSystemPrompt,
605
+ });
606
+ });
607
+ // ── Leave ──────────────────────────────────────────────────
608
+ app.post("/leave", (req, res) => {
609
+ const { actorId } = req.body;
610
+ if (!actorId || typeof actorId !== "string") {
611
+ res.status(400).json({ error: "actorId required" });
612
+ return;
613
+ }
614
+ if (!room.participants.has(actorId)) {
615
+ res.status(404).json({ error: `Participant '${actorId}' not found` });
616
+ return;
617
+ }
618
+ room.participants.delete(actorId);
619
+ for (const [ws, wsActorId] of room.wsConnections.entries()) {
620
+ if (wsActorId === actorId) {
621
+ room.wsConnections.delete(ws);
622
+ try {
623
+ ws.close();
624
+ }
625
+ catch { }
626
+ }
627
+ }
628
+ broadcastPresenceUpdate();
629
+ res.json({ left: true, actorId });
630
+ });
631
+ // ── Idempotency cache ────────────────────────────────────────
632
+ const idempotencyCache = new Map();
633
+ const IDEMPOTENCY_TTL = 30000;
634
+ const _idempotencyCleanupTimer = setInterval(() => {
635
+ const now = Date.now();
636
+ for (const [key, entry] of idempotencyCache) {
637
+ if (now - entry.ts > IDEMPOTENCY_TTL)
638
+ idempotencyCache.delete(key);
639
+ }
640
+ }, IDEMPOTENCY_CLEANUP_INTERVAL_MS);
641
+ async function executeTool(toolName, actorId, input = {}, owner, expiredFlag) {
642
+ const mod = getModule();
643
+ if (!mod)
644
+ throw new Error(experienceNotLoadedError());
645
+ let scopeKey;
646
+ let resolvedToolName = toolName;
647
+ if (toolName.includes(':')) {
648
+ const colonIdx = toolName.indexOf(':');
649
+ scopeKey = toolName.slice(0, colonIdx);
650
+ resolvedToolName = toolName.slice(colonIdx + 1);
651
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(scopeKey) || FORBIDDEN_MERGE_KEYS.has(scopeKey)) {
652
+ throw new Error(`Invalid scope key in tool name: '${scopeKey}'`);
653
+ }
654
+ }
655
+ const tool = mod.tools.find((t) => t.name === resolvedToolName);
656
+ if (!tool) {
657
+ const available = mod.tools.map((t) => t.name).join(", ");
658
+ throw new ToolNotFoundError(`Tool '${resolvedToolName}' not found. Available tools: ${available}`);
659
+ }
660
+ const callingParticipant = room.participants.get(actorId);
661
+ if (callingParticipant?.allowedTools &&
662
+ !callingParticipant.allowedTools.includes(resolvedToolName) &&
663
+ !callingParticipant.allowedTools.includes(toolName)) {
664
+ const role = callingParticipant.role || "ai";
665
+ const allowed = callingParticipant.allowedTools.join(", ");
666
+ throw new ToolForbiddenError(`Tool '${resolvedToolName}' is not allowed for role '${role}'. Allowed tools: ${allowed}`);
667
+ }
668
+ let validatedInput = input;
669
+ if (tool.input_schema?.parse) {
670
+ validatedInput = tool.input_schema.parse(input);
671
+ }
672
+ const participant = room.participants.get(actorId);
673
+ const resolvedOwner = participant?.owner || owner || actorId;
674
+ const ctx = {
675
+ roomId: "local",
676
+ actorId,
677
+ owner: resolvedOwner,
678
+ get state() { return room.sharedState; },
679
+ setState: (newState) => {
680
+ if (expiredFlag?.value)
681
+ return;
682
+ room.sharedState = newState;
683
+ },
684
+ timestamp: Date.now(),
685
+ memory: {},
686
+ setMemory: () => { },
687
+ };
688
+ if (scopeKey) {
689
+ Object.defineProperty(ctx, 'state', {
690
+ get() { return room.sharedState[scopeKey] || {}; },
691
+ configurable: true,
692
+ });
693
+ ctx.setState = (newState) => {
694
+ if (expiredFlag?.value)
695
+ return;
696
+ room.sharedState = { ...room.sharedState, [scopeKey]: newState };
697
+ };
698
+ }
699
+ const output = await tool.handler(ctx, validatedInput);
700
+ const callerRole = callingParticipant?.role;
701
+ const event = {
702
+ id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
703
+ ts: Date.now(),
704
+ actorId,
705
+ owner: ctx.owner,
706
+ role: callerRole,
707
+ tool: toolName,
708
+ input: validatedInput,
709
+ output,
710
+ };
711
+ let observation;
712
+ const toolObserve = mod.observe ?? defaultObserve;
713
+ try {
714
+ observation = toolObserve(room.sharedState, event, actorId);
715
+ }
716
+ catch (e) {
717
+ console.error(`[observe] Error:`, toErrorMessage(e));
718
+ }
719
+ if (observation)
720
+ event.observation = observation;
721
+ room.appendEvent(event);
722
+ room.broadcastStateUpdate({
723
+ event,
724
+ changedBy: actorId,
725
+ tool: toolName,
726
+ observation,
727
+ });
728
+ roomEvents.emit("room");
729
+ return { tool: toolName, output, observation };
730
+ }
731
+ // ── Single tool HTTP endpoint ───────────────────────────────
732
+ app.post("/tools/:toolName", async (req, res) => {
733
+ const mod = getModule();
734
+ if (!mod) {
735
+ res.status(500).json({ error: experienceNotLoadedError() });
736
+ return;
737
+ }
738
+ const toolName = req.params.toolName;
739
+ const { actorId, input: rawInput = {}, owner } = req.body;
740
+ const input = rawInput !== null && typeof rawInput === "object" && !Array.isArray(rawInput) ? rawInput : {};
741
+ if (!actorId) {
742
+ res.status(400).json({ error: "actorId is required" });
743
+ return;
744
+ }
745
+ if (!room.participants.has(actorId)) {
746
+ res.status(403).json({ error: `Actor '${actorId}' is not a participant. Call /join first.` });
747
+ return;
748
+ }
749
+ const rawIdempotencyKey = req.headers["x-idempotency-key"];
750
+ const idempotencyKey = (rawIdempotencyKey && rawIdempotencyKey.length <= 128) ? rawIdempotencyKey : undefined;
751
+ if (idempotencyKey) {
752
+ const cached = idempotencyCache.get(idempotencyKey);
753
+ if (cached && Date.now() - cached.ts < IDEMPOTENCY_TTL) {
754
+ res.json({ output: cached.output, cached: true });
755
+ return;
756
+ }
757
+ }
758
+ try {
759
+ const expiredFlag = { value: false };
760
+ let timeoutHandle;
761
+ const result = await room.enqueueExecution(() => Promise.race([
762
+ executeTool(toolName, actorId, input, owner, expiredFlag),
763
+ new Promise((_, reject) => {
764
+ timeoutHandle = setTimeout(() => {
765
+ expiredFlag.value = true;
766
+ reject(new Error(`Tool '${toolName}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
767
+ }, TOOL_HTTP_TIMEOUT_MS);
768
+ }),
769
+ ]));
770
+ if (timeoutHandle !== undefined)
771
+ clearTimeout(timeoutHandle);
772
+ if (idempotencyKey) {
773
+ idempotencyCache.set(idempotencyKey, { output: result.output, ts: Date.now() });
774
+ }
775
+ res.json({ output: result.output, observation: result.observation });
776
+ }
777
+ catch (err) {
778
+ let statusCode = 400;
779
+ if (err instanceof ToolNotFoundError)
780
+ statusCode = 404;
781
+ else if (err instanceof ToolForbiddenError)
782
+ statusCode = 403;
783
+ const toolForError = mod.tools.find((t) => t.name === toolName);
784
+ const errorMsg = err instanceof ZodError
785
+ ? formatZodError(err, toolName, toolForError)
786
+ : (err instanceof Error ? formatHandlerError(err, toolName, toolForError, input) : String(err));
787
+ const resolvedOwner = owner || room.participants.get(actorId)?.owner;
788
+ const event = {
789
+ id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
790
+ ts: Date.now(),
791
+ actorId,
792
+ ...(resolvedOwner ? { owner: resolvedOwner } : {}),
793
+ tool: toolName,
794
+ input,
795
+ error: errorMsg,
796
+ };
797
+ room.appendEvent(event);
798
+ roomEvents.emit("room");
799
+ res.status(statusCode).json({ error: errorMsg });
800
+ }
801
+ });
802
+ // ── Batch tool endpoint ─────────────────────────────────────
803
+ app.post("/tools-batch", async (req, res) => {
804
+ const mod = getModule();
805
+ if (!mod) {
806
+ res.status(500).json({ error: experienceNotLoadedError() });
807
+ return;
808
+ }
809
+ const { actorId, owner, calls } = req.body;
810
+ if (!actorId) {
811
+ res.status(400).json({ error: "actorId is required" });
812
+ return;
813
+ }
814
+ if (!room.participants.has(actorId)) {
815
+ res.status(403).json({ error: `Actor '${actorId}' is not a participant. Call /join first.` });
816
+ return;
817
+ }
818
+ if (!Array.isArray(calls) || calls.length === 0) {
819
+ res.status(400).json({ error: "Missing or empty 'calls' array. Expected: [{ tool, input? }, ...]" });
820
+ return;
821
+ }
822
+ if (calls.length > MAX_BATCH_CALLS) {
823
+ res.status(400).json({ error: `Too many calls in batch (${calls.length}). Maximum is ${MAX_BATCH_CALLS}.` });
824
+ return;
825
+ }
826
+ const BATCH_TOTAL_TIMEOUT_MS = 60_000;
827
+ const batchStart = Date.now();
828
+ const { results, lastObservation, hasError } = await room.enqueueExecution(async () => {
829
+ const results = [];
830
+ let lastObservation;
831
+ let hasError = false;
832
+ for (const call of calls) {
833
+ if (Date.now() - batchStart > BATCH_TOTAL_TIMEOUT_MS) {
834
+ results.push({ tool: call.tool || "?", error: `Batch total timeout exceeded (${BATCH_TOTAL_TIMEOUT_MS}ms)` });
835
+ hasError = true;
836
+ continue;
837
+ }
838
+ if (!call.tool) {
839
+ results.push({ tool: "?", error: "Missing 'tool' field in call" });
840
+ hasError = true;
841
+ continue;
842
+ }
843
+ try {
844
+ const batchExpiredFlag = { value: false };
845
+ let batchTimeoutHandle;
846
+ const result = await Promise.race([
847
+ executeTool(call.tool, actorId, (call.input !== null && typeof call.input === 'object' && !Array.isArray(call.input)) ? call.input : {}, owner, batchExpiredFlag),
848
+ new Promise((_, reject) => {
849
+ batchTimeoutHandle = setTimeout(() => {
850
+ batchExpiredFlag.value = true;
851
+ reject(new Error(`Tool '${call.tool}' timed out after ${TOOL_HTTP_TIMEOUT_MS}ms`));
852
+ }, TOOL_HTTP_TIMEOUT_MS);
853
+ }),
854
+ ]);
855
+ if (batchTimeoutHandle !== undefined)
856
+ clearTimeout(batchTimeoutHandle);
857
+ results.push(result);
858
+ if (result.observation)
859
+ lastObservation = result.observation;
860
+ }
861
+ catch (err) {
862
+ const errorMsg = err instanceof ZodError
863
+ ? formatZodError(err, call.tool)
864
+ : (err instanceof Error ? err.message : String(err));
865
+ const resolvedBatchOwner = owner || room.participants.get(actorId)?.owner;
866
+ const event = {
867
+ id: `${Date.now()}-${actorId}-${Math.random().toString(36).slice(2, 6)}`,
868
+ ts: Date.now(),
869
+ actorId,
870
+ ...(resolvedBatchOwner ? { owner: resolvedBatchOwner } : {}),
871
+ tool: call.tool,
872
+ input: call.input || {},
873
+ error: errorMsg,
874
+ };
875
+ room.appendEvent(event);
876
+ roomEvents.emit("room");
877
+ results.push({ tool: call.tool, error: errorMsg });
878
+ hasError = true;
879
+ }
880
+ }
881
+ return { results, lastObservation, hasError };
882
+ });
883
+ res.status(hasError ? 207 : 200).json({ results, observation: lastObservation });
884
+ });
885
+ // ── Browser error capture ──────────────────────────────────
886
+ const browserErrors = [];
887
+ const MAX_BROWSER_ERRORS = 20;
888
+ const BROWSER_ERROR_COOLDOWN_MS = 200;
889
+ let lastBrowserErrorAt = 0;
890
+ app.post("/browser-error", (req, res) => {
891
+ const { message } = req.body || {};
892
+ if (typeof message === "string" && message.trim()) {
893
+ const trimmed = message.trim().slice(0, 500);
894
+ const now = Date.now();
895
+ browserErrors.push({ message: trimmed, ts: now });
896
+ if (browserErrors.length > MAX_BROWSER_ERRORS) {
897
+ browserErrors.splice(0, browserErrors.length - MAX_BROWSER_ERRORS);
898
+ }
899
+ if (now - lastBrowserErrorAt >= BROWSER_ERROR_COOLDOWN_MS) {
900
+ lastBrowserErrorAt = now;
901
+ roomEvents.emit("room");
902
+ }
903
+ }
904
+ res.json({ ok: true });
905
+ });
906
+ // ── Screenshot ─────────────────────────────────────────────
907
+ const screenshotCallbacks = new Map();
908
+ app.get("/screenshot", async (_req, res) => {
909
+ // Find a browser WebSocket connection to request a screenshot from
910
+ let browserWs = null;
911
+ for (const [ws, actorId] of room.wsConnections.entries()) {
912
+ if (ws.readyState === WebSocket.OPEN && actorId.startsWith("viewer-")) {
913
+ browserWs = ws;
914
+ break;
915
+ }
916
+ }
917
+ // Fallback: any open connection
918
+ if (!browserWs) {
919
+ for (const [ws] of room.wsConnections.entries()) {
920
+ if (ws.readyState === WebSocket.OPEN) {
921
+ browserWs = ws;
922
+ break;
923
+ }
924
+ }
925
+ }
926
+ if (!browserWs) {
927
+ return res.status(503).json({ error: "No browser connected to capture screenshot" });
928
+ }
929
+ const id = `ss-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
930
+ try {
931
+ const dataUrl = await new Promise((resolve, reject) => {
932
+ const timer = setTimeout(() => {
933
+ screenshotCallbacks.delete(id);
934
+ reject(new Error("Screenshot timeout"));
935
+ }, 10000);
936
+ screenshotCallbacks.set(id, {
937
+ resolve: (url) => { clearTimeout(timer); resolve(url); },
938
+ reject: (err) => { clearTimeout(timer); reject(err); },
939
+ });
940
+ browserWs.send(JSON.stringify({ type: "screenshot_request", id }));
941
+ });
942
+ res.json({ dataUrl });
943
+ }
944
+ catch (err) {
945
+ res.status(500).json({ error: toErrorMessage(err) });
946
+ }
947
+ });
948
+ // Called by WebSocket handler when browser sends screenshot_response
949
+ function handleScreenshotResponse(msg) {
950
+ const cb = screenshotCallbacks.get(msg.id);
951
+ if (!cb)
952
+ return;
953
+ screenshotCallbacks.delete(msg.id);
954
+ if (msg.error) {
955
+ cb.reject(new Error(msg.error));
956
+ }
957
+ else if (msg.dataUrl) {
958
+ cb.resolve(msg.dataUrl);
959
+ }
960
+ else {
961
+ cb.reject(new Error("Empty screenshot response"));
962
+ }
963
+ }
964
+ // ── Agent context ──────────────────────────────────────────
965
+ app.get("/agent-context", (req, res) => {
966
+ const rawSince = queryInt(req.query.since);
967
+ const timeout = Math.min(queryInt(req.query.timeout), AGENT_CONTEXT_MAX_TIMEOUT_MS);
968
+ const actorId = queryString(req.query.actorId) || "unknown";
969
+ const participantEntry = room.participants.get(actorId);
970
+ const requestingOwner = queryString(req.query.owner) || participantEntry?.owner;
971
+ const since = (rawSince === 0 && participantEntry?.eventCursor)
972
+ ? participantEntry.eventCursor
973
+ : rawSince;
974
+ if (participantEntry)
975
+ participantEntry.lastPollAt = Date.now();
976
+ const getNewEvents = () => {
977
+ return room.events.filter(e => {
978
+ if (requestingOwner && e.owner === requestingOwner)
979
+ return false;
980
+ return e.ts > since;
981
+ }).sort((a, b) => a.ts - b.ts);
982
+ };
983
+ const mod = getModule();
984
+ const buildResponse = () => {
985
+ const events = getNewEvents();
986
+ if (room.kickedActors.has(actorId)) {
987
+ room.kickedActors.delete(actorId);
988
+ return { events: [], observation: { done: true, reason: "kicked" }, participants: room.participantList() };
989
+ }
990
+ let observation;
991
+ let observeError;
992
+ if (mod?.observe) {
993
+ try {
994
+ const lastEvent = events.length > 0 ? events[events.length - 1] : null;
995
+ observation = mod.observe(room.sharedState, lastEvent, actorId);
996
+ }
997
+ catch (e) {
998
+ console.error(`[observe] Error:`, toErrorMessage(e));
999
+ observeError = toErrorMessage(e);
1000
+ }
1001
+ }
1002
+ let lastError;
1003
+ for (const e of room.events) {
1004
+ if (e.ts > since && e.error && e.actorId === actorId) {
1005
+ lastError = { tool: e.tool, error: e.error };
1006
+ }
1007
+ }
1008
+ const recentBrowserErrors = browserErrors.filter(e => e.ts > since);
1009
+ if (recentBrowserErrors.length > 0) {
1010
+ // Clear reported errors
1011
+ const cutoff = since;
1012
+ while (browserErrors.length > 0 && browserErrors[0].ts <= cutoff) {
1013
+ browserErrors.shift();
1014
+ }
1015
+ }
1016
+ let eventCursor;
1017
+ if (events.length > 0 && participantEntry) {
1018
+ eventCursor = Math.max(participantEntry.eventCursor || 0, ...events.map(e => e.ts));
1019
+ participantEntry.eventCursor = eventCursor;
1020
+ }
1021
+ else if (participantEntry?.eventCursor) {
1022
+ eventCursor = participantEntry.eventCursor;
1023
+ }
1024
+ return {
1025
+ events,
1026
+ observation: observation || {},
1027
+ observeError,
1028
+ lastError,
1029
+ browserErrors: recentBrowserErrors.length > 0 ? recentBrowserErrors : undefined,
1030
+ participants: room.participantList(),
1031
+ eventCursor,
1032
+ };
1033
+ };
1034
+ let newEvents = getNewEvents();
1035
+ const pendingBrowserErrs = browserErrors.filter(e => e.ts > since);
1036
+ const isKicked = room.kickedActors.has(actorId);
1037
+ if (newEvents.length > 0 || pendingBrowserErrs.length > 0 || isKicked || timeout === 0) {
1038
+ res.json(buildResponse());
1039
+ return;
1040
+ }
1041
+ let responded = false;
1042
+ let batchTimer = null;
1043
+ const respond = () => {
1044
+ if (responded)
1045
+ return;
1046
+ responded = true;
1047
+ clearTimeout(timer);
1048
+ if (batchTimer) {
1049
+ clearTimeout(batchTimer);
1050
+ batchTimer = null;
1051
+ }
1052
+ roomEvents.removeListener("room", onEvent);
1053
+ res.json(buildResponse());
1054
+ };
1055
+ const timer = setTimeout(respond, timeout);
1056
+ const onEvent = () => {
1057
+ if (responded)
1058
+ return;
1059
+ if (batchTimer)
1060
+ return;
1061
+ batchTimer = setTimeout(() => {
1062
+ batchTimer = null;
1063
+ if (responded)
1064
+ return;
1065
+ const pending = getNewEvents();
1066
+ if (pending.length > 0 || room.kickedActors.has(actorId))
1067
+ respond();
1068
+ }, EVENT_BATCH_DEBOUNCE_MS);
1069
+ };
1070
+ roomEvents.on("room", onEvent);
1071
+ req.on("close", () => {
1072
+ responded = true;
1073
+ clearTimeout(timer);
1074
+ if (batchTimer)
1075
+ clearTimeout(batchTimer);
1076
+ roomEvents.removeListener("room", onEvent);
1077
+ });
1078
+ });
1079
+ // ── Serve client bundle ────────────────────────────────────
1080
+ app.get("/bundle", async (_req, res) => {
1081
+ if (rebuildingPromise)
1082
+ await rebuildingPromise;
1083
+ res.setHeader("Content-Type", "text/javascript");
1084
+ setNoCacheHeaders(res);
1085
+ res.send(loadedExperience?.clientBundle || "");
1086
+ });
1087
+ // ── Catch-all: serve viewer ─────────────────────────────────
1088
+ app.get("*", (req, res, next) => {
1089
+ if (req.path.startsWith("/tools/") || req.path.startsWith("/viewer/") ||
1090
+ req.path.endsWith(".js") ||
1091
+ req.path.endsWith(".css") || req.path.endsWith(".map")) {
1092
+ next();
1093
+ return;
1094
+ }
1095
+ setNoCacheHeaders(res);
1096
+ res.sendFile(path.join(__runtimeDir, "viewer", "index.html"));
1097
+ });
1098
+ // ── Client bundle smoke test ──────────────────────────────
1099
+ async function smokeTestClientBundle(port) {
1100
+ try {
1101
+ const res = await fetch(`http://localhost:${port}/bundle`);
1102
+ const bundleCode = await res.text();
1103
+ if (bundleCode) {
1104
+ const error = validateClientBundle(bundleCode);
1105
+ if (error) {
1106
+ console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
1107
+ console.error(` ${error}`);
1108
+ console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
1109
+ }
1110
+ else {
1111
+ console.log(` Smoke test: client bundle OK`);
1112
+ }
1113
+ }
1114
+ }
1115
+ catch (err) {
1116
+ console.error(`\n ⚠ SMOKE TEST FAILED — client bundle has errors:`);
1117
+ console.error(` ${toErrorMessage(err)}`);
1118
+ console.error(` The viewer will fail to load. Fix the source and save to hot-reload.\n`);
1119
+ }
1120
+ }
1121
+ // ── Start server ───────────────────────────────────────────
1122
+ export async function startServer(config) {
1123
+ if (config?.projectRoot)
1124
+ PROJECT_ROOT = config.projectRoot;
1125
+ if (config?.port)
1126
+ PORT = config.port;
1127
+ if (!PROJECT_ROOT)
1128
+ throw new Error("@vibevibes/runtime: projectRoot is required.");
1129
+ await loadExperience();
1130
+ const mod = getModule();
1131
+ const initialState = resolveInitialState(mod);
1132
+ room = new Room(mod.manifest.id, initialState);
1133
+ console.log(` Experience: ${mod.manifest.id}`);
1134
+ const server = http.createServer(app);
1135
+ const wss = new WebSocketServer({ server, maxPayload: WS_MAX_PAYLOAD_BYTES });
1136
+ wss.on("error", (err) => { console.error("[WSS] server error:", err.message); });
1137
+ const wsCloseTimers = new Map();
1138
+ wss.on("connection", (ws) => {
1139
+ const hbWs = ws;
1140
+ hbWs.isAlive = true;
1141
+ ws.on("pong", () => { hbWs.isAlive = true; });
1142
+ ws.on("error", (err) => { console.error("[WS] connection error:", err.message); });
1143
+ ws.on("message", (data) => {
1144
+ try {
1145
+ const msg = JSON.parse(data.toString());
1146
+ if (msg.type === "join") {
1147
+ const username = (msg.username || "viewer").slice(0, 100);
1148
+ const wsOwner = msg.owner || username;
1149
+ if (msg.actorId) {
1150
+ if (room.kickedActors.has(msg.actorId) || room.kickedOwners.has(wsOwner)) {
1151
+ ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
1152
+ return;
1153
+ }
1154
+ let staleWs = null;
1155
+ for (const [existingWs, existingId] of room.wsConnections.entries()) {
1156
+ if (existingId === msg.actorId && existingWs !== ws) {
1157
+ staleWs = existingWs;
1158
+ break;
1159
+ }
1160
+ }
1161
+ if (staleWs) {
1162
+ room.wsConnections.delete(staleWs);
1163
+ try {
1164
+ staleWs.close();
1165
+ }
1166
+ catch { }
1167
+ }
1168
+ const closeTimer = wsCloseTimers.get(msg.actorId);
1169
+ if (closeTimer) {
1170
+ clearTimeout(closeTimer);
1171
+ wsCloseTimers.delete(msg.actorId);
1172
+ }
1173
+ if (!room.participants.has(msg.actorId)) {
1174
+ room.participants.set(msg.actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner });
1175
+ }
1176
+ room.wsConnections.set(ws, msg.actorId);
1177
+ }
1178
+ else {
1179
+ if (room.kickedOwners.has(wsOwner)) {
1180
+ ws.send(JSON.stringify({ type: "error", error: "You have been kicked from this room." }));
1181
+ return;
1182
+ }
1183
+ let existingActorId;
1184
+ for (const [aid, p] of room.participants) {
1185
+ if (p.owner === wsOwner) {
1186
+ existingActorId = aid;
1187
+ break;
1188
+ }
1189
+ }
1190
+ let actorId;
1191
+ if (existingActorId) {
1192
+ actorId = existingActorId;
1193
+ for (const [existingWs, existingId] of room.wsConnections.entries()) {
1194
+ if (existingId === existingActorId && existingWs !== ws) {
1195
+ room.wsConnections.delete(existingWs);
1196
+ try {
1197
+ existingWs.close();
1198
+ }
1199
+ catch { }
1200
+ break;
1201
+ }
1202
+ }
1203
+ }
1204
+ else {
1205
+ const mod = getModule();
1206
+ const pSlots = mod?.manifest?.participantSlots || mod?.participants;
1207
+ let wsSlotRole;
1208
+ if (pSlots?.length) {
1209
+ const roleOccupancy = new Map();
1210
+ for (const [, p] of room.participants) {
1211
+ if (p.role)
1212
+ roleOccupancy.set(p.role, (roleOccupancy.get(p.role) || 0) + 1);
1213
+ }
1214
+ const hasCapacity = (slot) => {
1215
+ const max = slot.maxInstances ?? 1;
1216
+ const current = roleOccupancy.get(slot.role) || 0;
1217
+ return current < max;
1218
+ };
1219
+ let matched;
1220
+ if (msg.role) {
1221
+ matched = pSlots.find((s) => s.role === msg.role && (!s.type || s.type === "human" || s.type === "any") && hasCapacity(s));
1222
+ }
1223
+ if (!matched)
1224
+ matched = pSlots.find((s) => s.type === "human" && hasCapacity(s));
1225
+ if (!matched)
1226
+ matched = pSlots.find((s) => (!s.type || s.type === "any") && hasCapacity(s));
1227
+ if (!matched)
1228
+ matched = pSlots.find((s) => !s.type || s.type === "human" || s.type === "any");
1229
+ if (matched) {
1230
+ wsSlotRole = matched.role;
1231
+ const base = matched.role.toLowerCase().replace(/\s+/g, "-");
1232
+ actorId = assignActorId(username, "human", base);
1233
+ }
1234
+ else {
1235
+ actorId = assignActorId(username, "human");
1236
+ }
1237
+ }
1238
+ else {
1239
+ actorId = assignActorId(username, "human");
1240
+ }
1241
+ const wsRole = wsSlotRole || msg.role || undefined;
1242
+ room.participants.set(actorId, { type: "human", joinedAt: Date.now(), owner: wsOwner, role: wsRole });
1243
+ }
1244
+ room.wsConnections.set(ws, actorId);
1245
+ }
1246
+ const actorId = room.wsConnections.get(ws);
1247
+ if (!room.participants.has(actorId)) {
1248
+ ws.send(JSON.stringify({ type: "error", error: "Session expired. Please rejoin the room." }));
1249
+ return;
1250
+ }
1251
+ const resolvedWsRole = room.participants.get(actorId)?.role;
1252
+ ws.send(JSON.stringify({
1253
+ type: "joined",
1254
+ actorId,
1255
+ role: resolvedWsRole,
1256
+ sharedState: room.sharedState,
1257
+ stateVersion: room.stateVersion,
1258
+ participants: room.participantList(),
1259
+ participantDetails: room.participantDetails(),
1260
+ events: room.events.slice(-JOIN_EVENT_HISTORY),
1261
+ }));
1262
+ broadcastPresenceUpdate();
1263
+ }
1264
+ if (msg.type === "ephemeral") {
1265
+ const ephPayload = JSON.stringify(msg.data);
1266
+ if (ephPayload.length > WS_EPHEMERAL_MAX_BYTES) {
1267
+ ws.send(JSON.stringify({ type: "error", error: "Ephemeral payload too large (max 64KB)" }));
1268
+ return;
1269
+ }
1270
+ const senderActorId = room.wsConnections.get(ws);
1271
+ if (senderActorId) {
1272
+ const payload = JSON.stringify({ type: "ephemeral", actorId: senderActorId, data: msg.data });
1273
+ for (const [otherWs] of room.wsConnections.entries()) {
1274
+ if (otherWs !== ws && otherWs.readyState === WebSocket.OPEN) {
1275
+ otherWs.send(payload);
1276
+ }
1277
+ }
1278
+ }
1279
+ }
1280
+ if (msg.type === "screenshot_response") {
1281
+ handleScreenshotResponse(msg);
1282
+ }
1283
+ }
1284
+ catch (err) {
1285
+ if (!(err instanceof SyntaxError)) {
1286
+ console.error("[WS] Unexpected handler error:", err instanceof Error ? err.message : String(err));
1287
+ }
1288
+ }
1289
+ });
1290
+ ws.on("close", () => {
1291
+ const actorId = room.wsConnections.get(ws);
1292
+ if (actorId) {
1293
+ room.wsConnections.delete(ws);
1294
+ const participant = room.participants.get(actorId);
1295
+ if (!participant || participant.type === "human") {
1296
+ const timer = setTimeout(() => {
1297
+ wsCloseTimers.delete(actorId);
1298
+ let reconnected = false;
1299
+ for (const [, wsActorId] of room.wsConnections) {
1300
+ if (wsActorId === actorId) {
1301
+ reconnected = true;
1302
+ break;
1303
+ }
1304
+ }
1305
+ if (!reconnected) {
1306
+ room.participants.delete(actorId);
1307
+ broadcastPresenceUpdate();
1308
+ }
1309
+ }, WS_CLOSE_GRACE_MS);
1310
+ const prev = wsCloseTimers.get(actorId);
1311
+ if (prev)
1312
+ clearTimeout(prev);
1313
+ wsCloseTimers.set(actorId, timer);
1314
+ }
1315
+ }
1316
+ });
1317
+ });
1318
+ // ── WebSocket heartbeat interval ──────────────────────────
1319
+ const heartbeatInterval = setInterval(() => {
1320
+ for (const ws of wss.clients) {
1321
+ if (ws.isAlive === false) {
1322
+ const actorId = room.wsConnections.get(ws);
1323
+ if (actorId) {
1324
+ room.participants.delete(actorId);
1325
+ room.wsConnections.delete(ws);
1326
+ broadcastPresenceUpdate();
1327
+ }
1328
+ ws.terminate();
1329
+ continue;
1330
+ }
1331
+ ws.isAlive = false;
1332
+ ws.ping();
1333
+ }
1334
+ }, WS_HEARTBEAT_INTERVAL_MS);
1335
+ // ── AI agent heartbeat sweep ──────────────────────────────
1336
+ const AI_HEARTBEAT_TIMEOUT_MS = 300_000;
1337
+ const aiHeartbeatInterval = setInterval(() => {
1338
+ const now = Date.now();
1339
+ const toEvict = [];
1340
+ for (const [actorId, p] of room.participants) {
1341
+ if (p.type !== "ai")
1342
+ continue;
1343
+ const lastSeen = p.lastPollAt || p.joinedAt;
1344
+ if (now - lastSeen > AI_HEARTBEAT_TIMEOUT_MS)
1345
+ toEvict.push(actorId);
1346
+ }
1347
+ for (const actorId of toEvict) {
1348
+ room.participants.delete(actorId);
1349
+ }
1350
+ if (toEvict.length > 0) {
1351
+ broadcastPresenceUpdate();
1352
+ roomEvents.emit("room");
1353
+ }
1354
+ }, WS_HEARTBEAT_INTERVAL_MS);
1355
+ // ── Watch src/ for changes ────────────────────────────────
1356
+ const watchDirs = [
1357
+ path.join(PROJECT_ROOT, "src"),
1358
+ path.join(PROJECT_ROOT, "experiences"),
1359
+ path.resolve(PROJECT_ROOT, "..", "experiences"),
1360
+ ].filter((d) => fs.existsSync(d));
1361
+ let debounceTimer = null;
1362
+ function onSrcChange(filename) {
1363
+ if (debounceTimer)
1364
+ clearTimeout(debounceTimer);
1365
+ if (!rebuildingPromise) {
1366
+ rebuildingPromise = new Promise((resolve) => { rebuildingResolve = resolve; });
1367
+ }
1368
+ debounceTimer = setTimeout(async () => {
1369
+ console.log(`\nFile changed${filename ? ` (${filename})` : ""}, rebuilding...`);
1370
+ try {
1371
+ await loadExperience();
1372
+ room.broadcastToAll({ type: "experience_updated" });
1373
+ smokeTestClientBundle(PORT);
1374
+ console.log("Hot reload complete.");
1375
+ }
1376
+ catch (err) {
1377
+ experienceError = toErrorMessage(err);
1378
+ console.error("Hot reload failed:", toErrorMessage(err));
1379
+ room.broadcastToAll({ type: "build_error", error: toErrorMessage(err) });
1380
+ }
1381
+ finally {
1382
+ if (rebuildingResolve) {
1383
+ rebuildingResolve();
1384
+ rebuildingResolve = null;
1385
+ rebuildingPromise = null;
1386
+ }
1387
+ }
1388
+ }, HOT_RELOAD_DEBOUNCE_MS);
1389
+ }
1390
+ for (const watchDir of watchDirs) {
1391
+ try {
1392
+ fs.watch(watchDir, { recursive: true }, (_event, filename) => {
1393
+ if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
1394
+ onSrcChange(path.join(path.relative(PROJECT_ROOT, watchDir), filename));
1395
+ }
1396
+ });
1397
+ }
1398
+ catch {
1399
+ function watchDirRecursive(dir) {
1400
+ fs.watch(dir, (_event, filename) => {
1401
+ if (filename && /\.(tsx?|jsx?|css|json)$/.test(filename)) {
1402
+ onSrcChange(path.join(path.relative(PROJECT_ROOT, dir), filename));
1403
+ }
1404
+ });
1405
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1406
+ if (entry.isDirectory())
1407
+ watchDirRecursive(path.join(dir, entry.name));
1408
+ }
1409
+ }
1410
+ watchDirRecursive(watchDir);
1411
+ }
1412
+ }
1413
+ server.listen(PORT, async () => {
1414
+ console.log(`\n vibe-vibe local runtime`);
1415
+ console.log(` ───────────────────────`);
1416
+ console.log(` Viewer: http://localhost:${PORT}`);
1417
+ smokeTestClientBundle(PORT);
1418
+ if (publicUrl) {
1419
+ const shareUrl = getBaseUrl();
1420
+ console.log(``);
1421
+ console.log(` ┌─────────────────────────────────────────────────┐`);
1422
+ console.log(` │ SHARE WITH FRIENDS: │`);
1423
+ console.log(` │ │`);
1424
+ console.log(` │ ${shareUrl.padEnd(47)} │`);
1425
+ console.log(` │ │`);
1426
+ console.log(` │ Open in browser to join the room. │`);
1427
+ console.log(` │ AI: npx @vibevibes/mcp ${(shareUrl).padEnd(23)} │`);
1428
+ console.log(` └─────────────────────────────────────────────────┘`);
1429
+ }
1430
+ console.log(`\n Watching src/ for changes\n`);
1431
+ });
1432
+ server.on("close", () => {
1433
+ clearInterval(heartbeatInterval);
1434
+ clearInterval(aiHeartbeatInterval);
1435
+ clearInterval(_idempotencyCleanupTimer);
1436
+ });
1437
+ return server;
1438
+ }
1439
+ export function setProjectRoot(root) {
1440
+ PROJECT_ROOT = root;
1441
+ }