@nolag/agents 0.4.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -17
- package/dist/AgentRoom.d.ts +32 -12
- package/dist/NoLagAgents.d.ts +92 -21
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/constants.d.ts +2 -0
- package/dist/index.cjs +512 -228
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +512 -228
- package/dist/index.mjs.map +1 -1
- package/dist/types.d.ts +13 -4
- package/dist/utils.d.ts +4 -0
- package/package.json +3 -3
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { NoLag } from '@nolag/js-sdk';
|
|
2
|
-
|
|
3
1
|
/**
|
|
4
2
|
* Tiny typed event emitter — framework-agnostic base for NoLagAgents and AgentRoom.
|
|
5
3
|
*
|
|
@@ -66,23 +64,31 @@ const TOPIC_TOOLS = "tools";
|
|
|
66
64
|
const TOPIC_APPROVAL = "approval";
|
|
67
65
|
/** Default room for agent coordination */
|
|
68
66
|
const DEFAULT_ROOM = "default-workflow";
|
|
67
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
68
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
69
69
|
/** Agents-protocol version: 2 = directed replies (filter-routed results),
|
|
70
70
|
* NO_HANDLER NACKs, presence protocol advertisement. Absent/1 = legacy
|
|
71
71
|
* broadcast replies (pre-0.2.0 SDKs). */
|
|
72
72
|
const AGENTS_PROTOCOL_VERSION = 2;
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
* AgentRoom —
|
|
75
|
+
* AgentRoom — a single agent-coordination room (scoped unit).
|
|
76
|
+
*
|
|
77
|
+
* Wraps a RoomContext from @nolag/js-sdk with typed pub/sub for agent
|
|
78
|
+
* coordination topics, presence-based service discovery, and capability
|
|
79
|
+
* routing.
|
|
76
80
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
81
|
+
* Created via `NoLagAgents.room(name)`. Do not instantiate directly. Presence
|
|
82
|
+
* events are routed in by the parent NoLagAgents (which owns the shared
|
|
83
|
+
* client's connection-level presence handlers); the room only wires its own
|
|
84
|
+
* topic handlers on its RoomContext, and cleanup removes exactly those.
|
|
79
85
|
*
|
|
80
86
|
* @example
|
|
81
87
|
* ```typescript
|
|
82
88
|
* const room = agents.room('default-workflow');
|
|
83
89
|
*
|
|
84
90
|
* // Service discovery - see who's connected
|
|
85
|
-
* const
|
|
91
|
+
* const connected = room.getConnectedAgents();
|
|
86
92
|
* const summarizers = room.findAgents('summarize');
|
|
87
93
|
*
|
|
88
94
|
* // Capability-filtered task handler
|
|
@@ -90,28 +96,32 @@ const AGENTS_PROTOCOL_VERSION = 2;
|
|
|
90
96
|
* ```
|
|
91
97
|
*/
|
|
92
98
|
class AgentRoom extends EventEmitter {
|
|
93
|
-
|
|
99
|
+
/** @internal */
|
|
100
|
+
constructor(name, roomContext, log, agentId, appName, isConnected, presence) {
|
|
94
101
|
super();
|
|
95
102
|
/** Registry of connected agents discovered via presence */
|
|
96
103
|
this._agents = new Map();
|
|
104
|
+
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
105
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
106
|
+
this._topicHandlers = [];
|
|
97
107
|
this.name = name;
|
|
98
108
|
this.agentId = agentId;
|
|
99
109
|
this._roomContext = roomContext;
|
|
100
|
-
this._client = client;
|
|
101
110
|
this._log = log;
|
|
111
|
+
this._appName = appName;
|
|
112
|
+
this._isConnected = isConnected;
|
|
102
113
|
this._presence = presence;
|
|
103
114
|
this._wireTopicListeners();
|
|
104
|
-
this._wirePresenceListeners();
|
|
105
115
|
// Set presence if provided (with the SDK's protocol version advertised
|
|
106
|
-
// so counterparts can detect incompatible reply semantics
|
|
116
|
+
// so counterparts can detect incompatible reply semantics, and a __scope
|
|
117
|
+
// tag so co-attached wrappers on other apps filter our presence out).
|
|
107
118
|
if (presence) {
|
|
108
|
-
|
|
109
|
-
this.
|
|
110
|
-
this.
|
|
111
|
-
this._roomContext.setPresence(withProtocol);
|
|
119
|
+
this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...presence };
|
|
120
|
+
this._log(`setting presence in room ${name}:`, this._presence);
|
|
121
|
+
this._roomContext.setPresence({ ...this._presence, __scope: appName });
|
|
112
122
|
}
|
|
113
123
|
// Fetch initial presence snapshot
|
|
114
|
-
this._fetchInitialPresence();
|
|
124
|
+
void this._fetchInitialPresence();
|
|
115
125
|
}
|
|
116
126
|
// ============================================================
|
|
117
127
|
// SERVICE DISCOVERY
|
|
@@ -143,10 +153,9 @@ class AgentRoom extends EventEmitter {
|
|
|
143
153
|
// ============================================================
|
|
144
154
|
/** Update this agent's presence data (protocol version auto-injected) */
|
|
145
155
|
setPresence(data) {
|
|
146
|
-
|
|
147
|
-
this._presence = withProtocol;
|
|
156
|
+
this._presence = { protocol: AGENTS_PROTOCOL_VERSION, ...data };
|
|
148
157
|
this._log(`updating presence in room ${this.name}`);
|
|
149
|
-
this._roomContext.setPresence(
|
|
158
|
+
this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
|
|
150
159
|
}
|
|
151
160
|
/** Fetch current presence snapshot for this room */
|
|
152
161
|
async fetchPresence() {
|
|
@@ -158,24 +167,13 @@ class AgentRoom extends EventEmitter {
|
|
|
158
167
|
return [];
|
|
159
168
|
}
|
|
160
169
|
}
|
|
161
|
-
/**
|
|
162
|
-
* @internal Emit a presence event (used by NoLagAgents for lobby forwarding)
|
|
163
|
-
*/
|
|
164
|
-
_emitPresence(event, actorId, data) {
|
|
165
|
-
if (event === 'presenceLeave') {
|
|
166
|
-
this.emit('presenceLeave', actorId);
|
|
167
|
-
}
|
|
168
|
-
else {
|
|
169
|
-
this.emit(event, actorId, data || {});
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
// ============================================================
|
|
173
|
-
// PUBLISH (with automatic agentId injection)
|
|
174
|
-
// ============================================================
|
|
175
170
|
/** Get the underlying RoomContext for advanced usage */
|
|
176
171
|
get context() {
|
|
177
172
|
return this._roomContext;
|
|
178
173
|
}
|
|
174
|
+
// ============================================================
|
|
175
|
+
// PUBLISH (with automatic agentId injection)
|
|
176
|
+
// ============================================================
|
|
179
177
|
/** Publish to the tasks topic */
|
|
180
178
|
publishTask(envelope) {
|
|
181
179
|
// Auto-set createdBy if not set
|
|
@@ -237,8 +235,82 @@ class AgentRoom extends EventEmitter {
|
|
|
237
235
|
this._publish(TOPIC_APPROVAL, data, { retain: true });
|
|
238
236
|
}
|
|
239
237
|
// ============================================================
|
|
238
|
+
// INTERNAL (called by NoLagAgents)
|
|
239
|
+
// ============================================================
|
|
240
|
+
/** @internal Re-apply local presence after a reconnect (core does not restore it) */
|
|
241
|
+
_updateLocalPresence() {
|
|
242
|
+
if (this._presence) {
|
|
243
|
+
this._roomContext.setPresence({ ...this._presence, __scope: this._appName });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/** @internal Route a presence:join event in from the parent */
|
|
247
|
+
_handlePresenceJoin(actorId, data) {
|
|
248
|
+
const d = data || {};
|
|
249
|
+
const agent = {
|
|
250
|
+
actorId,
|
|
251
|
+
name: d.name || actorId,
|
|
252
|
+
role: d.role || "agent",
|
|
253
|
+
capabilities: d.capabilities || [],
|
|
254
|
+
metadata: d.metadata,
|
|
255
|
+
connectedAt: Date.now(),
|
|
256
|
+
protocol: typeof d.protocol === "number" ? d.protocol : 1,
|
|
257
|
+
};
|
|
258
|
+
this._agents.set(actorId, agent);
|
|
259
|
+
this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
|
|
260
|
+
this.emit("presenceJoin", actorId, d);
|
|
261
|
+
}
|
|
262
|
+
/** @internal Route a presence:leave event in from the parent */
|
|
263
|
+
_handlePresenceLeave(actorId) {
|
|
264
|
+
const agent = this._agents.get(actorId);
|
|
265
|
+
this._agents.delete(actorId);
|
|
266
|
+
this._log(`agent left room ${this.name}:`, agent?.name || actorId);
|
|
267
|
+
this.emit("presenceLeave", actorId);
|
|
268
|
+
}
|
|
269
|
+
/** @internal Route a presence:update event in from the parent */
|
|
270
|
+
_handlePresenceUpdate(actorId, data) {
|
|
271
|
+
const d = data || {};
|
|
272
|
+
const existing = this._agents.get(actorId);
|
|
273
|
+
const agent = {
|
|
274
|
+
actorId,
|
|
275
|
+
name: d.name || existing?.name || actorId,
|
|
276
|
+
role: d.role || existing?.role || "agent",
|
|
277
|
+
capabilities: d.capabilities || existing?.capabilities || [],
|
|
278
|
+
metadata: d.metadata || existing?.metadata,
|
|
279
|
+
connectedAt: existing?.connectedAt || Date.now(),
|
|
280
|
+
protocol: typeof d.protocol === "number" ? d.protocol : (existing?.protocol ?? 1),
|
|
281
|
+
};
|
|
282
|
+
this._agents.set(actorId, agent);
|
|
283
|
+
this.emit("presenceUpdate", actorId, d);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* @internal Unsubscribe topics (when connected) and remove exactly this
|
|
287
|
+
* room's handler refs. Handler-specific removal only: the client may be
|
|
288
|
+
* shared, and a bare off(topic) would strip other consumers' handlers too.
|
|
289
|
+
*/
|
|
290
|
+
_cleanup() {
|
|
291
|
+
this._log(`room cleanup: ${this.name}`);
|
|
292
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
293
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
294
|
+
if (this._isConnected()) {
|
|
295
|
+
const topics = new Set(this._topicHandlers.map((t) => t.topic));
|
|
296
|
+
for (const topic of topics) {
|
|
297
|
+
this._roomContext.unsubscribe(topic);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
for (const { topic, handler } of this._topicHandlers) {
|
|
301
|
+
this._roomContext.off(topic, handler);
|
|
302
|
+
}
|
|
303
|
+
this._topicHandlers = [];
|
|
304
|
+
this._agents.clear();
|
|
305
|
+
this.removeAllListeners();
|
|
306
|
+
}
|
|
307
|
+
// ============================================================
|
|
240
308
|
// INTERNALS
|
|
241
309
|
// ============================================================
|
|
310
|
+
_on(topic, handler) {
|
|
311
|
+
this._topicHandlers.push({ topic, handler });
|
|
312
|
+
this._roomContext.on(topic, handler);
|
|
313
|
+
}
|
|
242
314
|
_publish(topic, data, options) {
|
|
243
315
|
this._log(`publish to ${topic} in room ${this.name}`);
|
|
244
316
|
if (options) {
|
|
@@ -249,15 +321,15 @@ class AgentRoom extends EventEmitter {
|
|
|
249
321
|
}
|
|
250
322
|
}
|
|
251
323
|
_toConnectedAgent(actor) {
|
|
252
|
-
const presence = actor.presence || actor.data || {};
|
|
324
|
+
const presence = (actor.presence || actor.data || {});
|
|
253
325
|
return {
|
|
254
|
-
actorId: actor.actorTokenId || actor.actorId ||
|
|
255
|
-
name: presence.name || actor.actorTokenId ||
|
|
256
|
-
role: presence.role ||
|
|
326
|
+
actorId: actor.actorTokenId || actor.actorId || "",
|
|
327
|
+
name: presence.name || actor.actorTokenId || "",
|
|
328
|
+
role: presence.role || "agent",
|
|
257
329
|
capabilities: presence.capabilities || [],
|
|
258
330
|
metadata: presence.metadata,
|
|
259
331
|
connectedAt: actor.joinedAt || Date.now(),
|
|
260
|
-
protocol: typeof presence.protocol ===
|
|
332
|
+
protocol: typeof presence.protocol === "number" ? presence.protocol : 1,
|
|
261
333
|
status: actor.status,
|
|
262
334
|
};
|
|
263
335
|
}
|
|
@@ -278,62 +350,6 @@ class AgentRoom extends EventEmitter {
|
|
|
278
350
|
// fetchPresence may not be available yet
|
|
279
351
|
}
|
|
280
352
|
}
|
|
281
|
-
_wirePresenceListeners() {
|
|
282
|
-
if (!this._client)
|
|
283
|
-
return;
|
|
284
|
-
const client = this._client;
|
|
285
|
-
client.on?.('presence:join', (evt) => {
|
|
286
|
-
if (evt?.roomId === this.name || !evt?.roomId) {
|
|
287
|
-
const id = evt?.actorId || evt?.actorTokenId;
|
|
288
|
-
const data = evt?.data || evt?.presence || {};
|
|
289
|
-
if (id) {
|
|
290
|
-
const agent = {
|
|
291
|
-
actorId: id,
|
|
292
|
-
name: data.name || id,
|
|
293
|
-
role: data.role || 'agent',
|
|
294
|
-
capabilities: data.capabilities || [],
|
|
295
|
-
metadata: data.metadata,
|
|
296
|
-
connectedAt: Date.now(),
|
|
297
|
-
protocol: typeof data.protocol === 'number' ? data.protocol : 1,
|
|
298
|
-
};
|
|
299
|
-
this._agents.set(id, agent);
|
|
300
|
-
this._log(`agent joined room ${this.name}:`, agent.name, agent.capabilities);
|
|
301
|
-
this.emit('presenceJoin', id, data);
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
});
|
|
305
|
-
client.on?.('presence:leave', (evt) => {
|
|
306
|
-
if (evt?.roomId === this.name || !evt?.roomId) {
|
|
307
|
-
const id = evt?.actorId || evt?.actorTokenId;
|
|
308
|
-
if (id) {
|
|
309
|
-
const agent = this._agents.get(id);
|
|
310
|
-
this._agents.delete(id);
|
|
311
|
-
this._log(`agent left room ${this.name}:`, agent?.name || id);
|
|
312
|
-
this.emit('presenceLeave', id);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
client.on?.('presence:update', (evt) => {
|
|
317
|
-
if (evt?.roomId === this.name || !evt?.roomId) {
|
|
318
|
-
const id = evt?.actorId || evt?.actorTokenId;
|
|
319
|
-
const data = evt?.data || evt?.presence || {};
|
|
320
|
-
if (id) {
|
|
321
|
-
const existing = this._agents.get(id);
|
|
322
|
-
const agent = {
|
|
323
|
-
actorId: id,
|
|
324
|
-
name: data.name || existing?.name || id,
|
|
325
|
-
role: data.role || existing?.role || 'agent',
|
|
326
|
-
capabilities: data.capabilities || existing?.capabilities || [],
|
|
327
|
-
metadata: data.metadata || existing?.metadata,
|
|
328
|
-
connectedAt: existing?.connectedAt || Date.now(),
|
|
329
|
-
protocol: typeof data.protocol === 'number' ? data.protocol : (existing?.protocol ?? 1),
|
|
330
|
-
};
|
|
331
|
-
this._agents.set(id, agent);
|
|
332
|
-
this.emit('presenceUpdate', id, data);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
353
|
_wireTopicListeners() {
|
|
338
354
|
// Work distribution topics honour the connection-level loadBalance
|
|
339
355
|
// setting, so a pool shares each message one-of-N (no double handling):
|
|
@@ -367,14 +383,14 @@ class AgentRoom extends EventEmitter {
|
|
|
367
383
|
{ topic: TOPIC_INBOX, event: "inbox" },
|
|
368
384
|
];
|
|
369
385
|
for (const { topic, event } of simpleMap) {
|
|
370
|
-
this.
|
|
386
|
+
this._on(topic, (data) => {
|
|
371
387
|
this._log(`received ${topic} in room ${this.name}`);
|
|
372
388
|
this.emit(event, data);
|
|
373
389
|
});
|
|
374
390
|
}
|
|
375
391
|
// Multiplexed: results topic carries task results AND tool responses,
|
|
376
392
|
// both filter-directed to this agent.
|
|
377
|
-
this.
|
|
393
|
+
this._on(TOPIC_RESULTS, (data) => {
|
|
378
394
|
this._log(`received ${TOPIC_RESULTS} in room ${this.name}`);
|
|
379
395
|
if (data?.type === "tool_response") {
|
|
380
396
|
this.emit("toolResponse", data);
|
|
@@ -384,7 +400,7 @@ class AgentRoom extends EventEmitter {
|
|
|
384
400
|
}
|
|
385
401
|
});
|
|
386
402
|
// Multiplexed: approval topic carries requests + responses
|
|
387
|
-
this.
|
|
403
|
+
this._on(TOPIC_APPROVAL, (data) => {
|
|
388
404
|
this._log(`received ${TOPIC_APPROVAL} in room ${this.name}`);
|
|
389
405
|
if (data?.type === "approval_response") {
|
|
390
406
|
this.emit("approvalResponse", data);
|
|
@@ -396,7 +412,7 @@ class AgentRoom extends EventEmitter {
|
|
|
396
412
|
// Tools topic carries requests; tool_response is still accepted here for
|
|
397
413
|
// backward compatibility with responders on older SDK versions (their
|
|
398
414
|
// responses are only reliable when the requester is not load-balanced).
|
|
399
|
-
this.
|
|
415
|
+
this._on(TOPIC_TOOLS, (data) => {
|
|
400
416
|
this._log(`received ${TOPIC_TOOLS} in room ${this.name}`);
|
|
401
417
|
if (data?.type === "tool_response") {
|
|
402
418
|
this.emit("toolResponse", data);
|
|
@@ -436,6 +452,29 @@ function createLogger(prefix, enabled) {
|
|
|
436
452
|
function createTimestamp() {
|
|
437
453
|
return Date.now();
|
|
438
454
|
}
|
|
455
|
+
// ============ Wrapper registry ============
|
|
456
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
457
|
+
// one connection would collide on topics, presence and the lobby.
|
|
458
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
459
|
+
const wrapperRegistry = new WeakMap();
|
|
460
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
461
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
462
|
+
let apps = wrapperRegistry.get(client);
|
|
463
|
+
if (!apps) {
|
|
464
|
+
apps = new Map();
|
|
465
|
+
wrapperRegistry.set(client, apps);
|
|
466
|
+
}
|
|
467
|
+
const existing = apps.get(appName);
|
|
468
|
+
if (existing) {
|
|
469
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
470
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
471
|
+
}
|
|
472
|
+
apps.set(appName, wrapperName);
|
|
473
|
+
}
|
|
474
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
475
|
+
function releaseWrapper(client, appName) {
|
|
476
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
477
|
+
}
|
|
439
478
|
|
|
440
479
|
/**
|
|
441
480
|
* NoLagAgents — high-level agent coordination SDK built on @nolag/js-sdk.
|
|
@@ -443,193 +482,438 @@ function createTimestamp() {
|
|
|
443
482
|
* Provides typed rooms for multi-agent patterns: Handoff, Blackboard,
|
|
444
483
|
* Inbox, Tools, Approval, and Observe.
|
|
445
484
|
*
|
|
485
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
486
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
487
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
488
|
+
*
|
|
446
489
|
* @example
|
|
447
490
|
* ```typescript
|
|
491
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
448
492
|
* import { NoLagAgents } from '@nolag/agents';
|
|
449
493
|
*
|
|
450
|
-
* const
|
|
494
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
495
|
+
* const agents = new NoLagAgents({
|
|
496
|
+
* client,
|
|
451
497
|
* appName: 'my-workflow',
|
|
452
498
|
* agentId: 'worker-1',
|
|
453
499
|
* presence: { name: 'worker-1', role: 'agent', capabilities: ['summarize'] },
|
|
454
500
|
* });
|
|
455
|
-
*
|
|
501
|
+
*
|
|
502
|
+
* await client.connect(); // the app owns the connection
|
|
503
|
+
* await agents.ready(); // wrapper setup done (identity, rooms, lobby)
|
|
456
504
|
*
|
|
457
505
|
* const room = agents.room('default-workflow');
|
|
458
|
-
* room.
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
506
|
+
* room.on('task', (task) => console.log('New task:', task));
|
|
507
|
+
*
|
|
508
|
+
* agents.detach(); // wrapper releases its handlers and topics
|
|
509
|
+
* client.disconnect(); // the app closes the socket
|
|
462
510
|
* ```
|
|
463
511
|
*/
|
|
464
512
|
class NoLagAgents extends EventEmitter {
|
|
465
|
-
constructor(
|
|
513
|
+
constructor(options) {
|
|
466
514
|
super();
|
|
467
|
-
this._client = null;
|
|
468
|
-
this._appContext = null;
|
|
469
515
|
this._rooms = new Map();
|
|
470
|
-
this.
|
|
471
|
-
|
|
516
|
+
this._lobby = null;
|
|
517
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
518
|
+
this._epoch = 0;
|
|
519
|
+
this._detached = false;
|
|
520
|
+
this._isReady = false;
|
|
521
|
+
this._lobbyRefreshTimer = null;
|
|
522
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
523
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
524
|
+
// closures on the client.
|
|
525
|
+
this._onConnectRef = () => this._onConnect();
|
|
526
|
+
this._onDisconnectRef = (reason) => {
|
|
527
|
+
this._log("disconnected:", reason);
|
|
528
|
+
this.emit("disconnected", reason);
|
|
529
|
+
};
|
|
530
|
+
this._onReconnectRef = () => {
|
|
531
|
+
this._log("reconnecting...");
|
|
532
|
+
this.emit("reconnecting");
|
|
533
|
+
};
|
|
534
|
+
this._onErrorRef = (error) => {
|
|
535
|
+
this._log("error:", error?.message ?? error);
|
|
536
|
+
this.emit("error", error);
|
|
537
|
+
};
|
|
538
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
539
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
540
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
541
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
542
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
543
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
544
|
+
if (!options?.client) {
|
|
545
|
+
throw new TypeError("NoLagAgents requires an injected NoLag client: new NoLagAgents({ client, ... })");
|
|
546
|
+
}
|
|
547
|
+
this._client = options.client;
|
|
472
548
|
this._options = {
|
|
473
549
|
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
474
550
|
agentId: options.agentId ?? generateId(),
|
|
551
|
+
name: options.name,
|
|
552
|
+
role: options.role,
|
|
475
553
|
debug: options.debug ?? false,
|
|
476
554
|
rooms: options.rooms ?? [DEFAULT_ROOM],
|
|
477
555
|
lobby: options.lobby,
|
|
478
|
-
presence: options.presence,
|
|
479
|
-
clientOptions: options.clientOptions,
|
|
556
|
+
presence: options.presence ?? this._presenceFromIdentity(options),
|
|
480
557
|
};
|
|
481
558
|
this._log = createLogger("NoLagAgents", this._options.debug);
|
|
559
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
560
|
+
this._readyResolve = resolve;
|
|
561
|
+
this._readyReject = reject;
|
|
562
|
+
});
|
|
563
|
+
// ready() rejection is only meaningful to callers that await it
|
|
564
|
+
this._readyPromise.catch(() => { });
|
|
565
|
+
registerWrapper(this._client, this._options.appName, "NoLagAgents");
|
|
566
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
567
|
+
this._client.on("connect", this._onConnectRef);
|
|
568
|
+
this._client.on("disconnect", this._onDisconnectRef);
|
|
569
|
+
this._client.on("reconnect", this._onReconnectRef);
|
|
570
|
+
this._client.on("error", this._onErrorRef);
|
|
571
|
+
this._client.on("presence:join", this._onPresenceJoinRef);
|
|
572
|
+
this._client.on("presence:leave", this._onPresenceLeaveRef);
|
|
573
|
+
this._client.on("presence:update", this._onPresenceUpdateRef);
|
|
574
|
+
this._client.on("lobbyPresence:join", this._onLobbyJoinRef);
|
|
575
|
+
this._client.on("lobbyPresence:leave", this._onLobbyLeaveRef);
|
|
576
|
+
this._client.on("lobbyPresence:update", this._onLobbyUpdateRef);
|
|
577
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
578
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
579
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
580
|
+
queueMicrotask(() => {
|
|
581
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
582
|
+
this._onConnect();
|
|
583
|
+
}
|
|
584
|
+
});
|
|
482
585
|
}
|
|
586
|
+
// ============ Public Properties ============
|
|
483
587
|
/** The agent's unique ID */
|
|
484
588
|
get agentId() {
|
|
485
589
|
return this._options.agentId;
|
|
486
590
|
}
|
|
487
|
-
/** Whether the
|
|
591
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
488
592
|
get connected() {
|
|
489
|
-
return this.
|
|
593
|
+
return !this._detached && this._client.connected;
|
|
594
|
+
}
|
|
595
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
596
|
+
get client() {
|
|
597
|
+
return this._client;
|
|
490
598
|
}
|
|
491
599
|
/** Map of joined rooms */
|
|
492
600
|
get rooms() {
|
|
493
601
|
return this._rooms;
|
|
494
602
|
}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
603
|
+
// ============ Lifecycle ============
|
|
604
|
+
/**
|
|
605
|
+
* Resolves once the wrapper's first setup completed (identity, configured
|
|
606
|
+
* rooms and — when configured — the lobby ready; equivalently, once
|
|
607
|
+
* 'connected' has fired). Rejects only if detach() is called before that.
|
|
608
|
+
* Client auth failures surface via the app's own `await client.connect()`.
|
|
609
|
+
*/
|
|
610
|
+
ready() {
|
|
611
|
+
return this._readyPromise;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
615
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
616
|
+
* Terminal and idempotent; never touches the socket. To use agents again,
|
|
617
|
+
* construct a new instance.
|
|
618
|
+
*/
|
|
619
|
+
detach() {
|
|
620
|
+
if (this._detached)
|
|
621
|
+
return;
|
|
622
|
+
this._log("detaching...");
|
|
623
|
+
this._detached = true;
|
|
624
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
625
|
+
if (this._lobbyRefreshTimer) {
|
|
626
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
627
|
+
this._lobbyRefreshTimer = null;
|
|
628
|
+
}
|
|
629
|
+
// Remove all client handlers by stored ref
|
|
630
|
+
this._client.off("connect", this._onConnectRef);
|
|
631
|
+
this._client.off("disconnect", this._onDisconnectRef);
|
|
632
|
+
this._client.off("reconnect", this._onReconnectRef);
|
|
633
|
+
this._client.off("error", this._onErrorRef);
|
|
634
|
+
this._client.off("presence:join", this._onPresenceJoinRef);
|
|
635
|
+
this._client.off("presence:leave", this._onPresenceLeaveRef);
|
|
636
|
+
this._client.off("presence:update", this._onPresenceUpdateRef);
|
|
637
|
+
this._client.off("lobbyPresence:join", this._onLobbyJoinRef);
|
|
638
|
+
this._client.off("lobbyPresence:leave", this._onLobbyLeaveRef);
|
|
639
|
+
this._client.off("lobbyPresence:update", this._onLobbyUpdateRef);
|
|
640
|
+
// Rooms: handler-specific off + connected-gated server unsubscribe
|
|
641
|
+
for (const name of [...this._rooms.keys()]) {
|
|
642
|
+
this._rooms.get(name)._cleanup();
|
|
643
|
+
this._rooms.delete(name);
|
|
644
|
+
}
|
|
645
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
646
|
+
if (this._lobby && this._client.connected) {
|
|
647
|
+
try {
|
|
648
|
+
this._lobby.unsubscribe();
|
|
649
|
+
}
|
|
650
|
+
catch {
|
|
651
|
+
/* best-effort */
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
this._lobby = null;
|
|
655
|
+
releaseWrapper(this._client, this._options.appName);
|
|
656
|
+
if (!this._isReady) {
|
|
657
|
+
this._readyReject(new Error("NoLagAgents detached before ready"));
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
// ============ Private: Epoch Setup ============
|
|
661
|
+
_onConnect() {
|
|
662
|
+
this._epoch++;
|
|
663
|
+
void this._runSetup(this._epoch);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
667
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
668
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
669
|
+
*/
|
|
670
|
+
async _runSetup(epoch) {
|
|
671
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
672
|
+
this._log(this._isReady ? "restoring after reconnect..." : "setting up...");
|
|
673
|
+
this._log("agentId:", this._options.agentId, "→ actorId:", this._client.actorId);
|
|
674
|
+
if (!this._isReady) {
|
|
675
|
+
// First successful setup: auto-join configured rooms.
|
|
676
|
+
for (const roomName of this._options.rooms) {
|
|
677
|
+
this._joinRoomInternal(roomName);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
// Reconnect: the core auto-restores topic subscriptions, but not
|
|
682
|
+
// room-scoped presence — re-apply each room's local presence.
|
|
683
|
+
for (const room of this._rooms.values()) {
|
|
684
|
+
room._updateLocalPresence();
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
// Lobby is OPTIONAL: only when configured. Subscribe every epoch
|
|
688
|
+
// (idempotent server-side) and diff-hydrate from the returned snapshot —
|
|
689
|
+
// one path for setup and reconnect restore.
|
|
690
|
+
if (this._options.lobby) {
|
|
691
|
+
if (!this._lobby) {
|
|
692
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(this._options.lobby);
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
const state = await this._lobby.subscribe();
|
|
696
|
+
if (stale())
|
|
697
|
+
return;
|
|
698
|
+
this._diffHydrateLobby(state);
|
|
699
|
+
this._log("lobby subscribed:", this._options.lobby);
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
if (stale())
|
|
703
|
+
return;
|
|
704
|
+
this._log("lobby subscription failed:", err);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
if (stale())
|
|
708
|
+
return;
|
|
709
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
710
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
711
|
+
if (!this._isReady) {
|
|
712
|
+
this._isReady = true;
|
|
713
|
+
this._readyResolve();
|
|
505
714
|
this.emit("connected");
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
this._connected = false;
|
|
509
|
-
this._log("disconnected:", reason);
|
|
510
|
-
this.emit("disconnected", reason);
|
|
511
|
-
});
|
|
512
|
-
this._client.on("reconnected", () => {
|
|
513
|
-
this._connected = true;
|
|
514
|
-
this._log("reconnected");
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
515
717
|
this.emit("reconnected");
|
|
516
|
-
});
|
|
517
|
-
this._client.on("error", (err) => {
|
|
518
|
-
this._log("error:", err.message);
|
|
519
|
-
this.emit("error", err);
|
|
520
|
-
});
|
|
521
|
-
await this._client.connect();
|
|
522
|
-
// Auto-join configured rooms
|
|
523
|
-
for (const roomName of this._options.rooms) {
|
|
524
|
-
this.room(roomName);
|
|
525
718
|
}
|
|
526
|
-
//
|
|
719
|
+
// Deferred lobby refetch: catches agents who joined during the setup
|
|
720
|
+
// window (only when the lobby is configured).
|
|
527
721
|
if (this._options.lobby) {
|
|
528
|
-
|
|
722
|
+
this._scheduleLobbyRefresh(epoch);
|
|
529
723
|
}
|
|
530
724
|
}
|
|
725
|
+
_scheduleLobbyRefresh(epoch) {
|
|
726
|
+
if (this._lobbyRefreshTimer)
|
|
727
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
728
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
729
|
+
this._lobbyRefreshTimer = null;
|
|
730
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
this._lobby
|
|
734
|
+
.fetchPresence()
|
|
735
|
+
.then((state) => {
|
|
736
|
+
if (epoch !== this._epoch || this._detached)
|
|
737
|
+
return;
|
|
738
|
+
this._diffHydrateLobby(state);
|
|
739
|
+
})
|
|
740
|
+
.catch(() => {
|
|
741
|
+
/* best-effort */
|
|
742
|
+
});
|
|
743
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
744
|
+
}
|
|
745
|
+
// ============ Room Management ============
|
|
531
746
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
747
|
+
* Get or create an AgentRoom wrapper.
|
|
748
|
+
* If the room hasn't been joined yet, it will be joined automatically.
|
|
749
|
+
*/
|
|
750
|
+
room(name) {
|
|
751
|
+
this._assertUsable();
|
|
752
|
+
const existing = this._rooms.get(name);
|
|
753
|
+
if (existing)
|
|
754
|
+
return existing;
|
|
755
|
+
return this._joinRoomInternal(name);
|
|
756
|
+
}
|
|
757
|
+
// ============ Lobby (cross-room presence observation) ============
|
|
758
|
+
/**
|
|
759
|
+
* Subscribe to a lobby for cross-room presence observation. Lobby presence
|
|
760
|
+
* events are forwarded into all AgentRooms. Prefer the `lobby` constructor
|
|
761
|
+
* option — this method is for on-demand subscription after ready.
|
|
534
762
|
*
|
|
535
763
|
* Returns the initial presence snapshot.
|
|
536
764
|
*/
|
|
537
765
|
async subscribeLobby(lobbySlug) {
|
|
538
|
-
|
|
539
|
-
throw new Error("Not connected. Call connect() before subscribing to lobbies.");
|
|
540
|
-
}
|
|
766
|
+
this._assertUsable();
|
|
541
767
|
this._log(`subscribing to lobby: ${lobbySlug}`);
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
const id = evt?.actorId;
|
|
547
|
-
const data = evt?.data || {};
|
|
548
|
-
if (id) {
|
|
549
|
-
this._log(`lobby presence:join — ${data.name || id}`);
|
|
550
|
-
for (const room of this._rooms.values()) {
|
|
551
|
-
const agents = room._agents;
|
|
552
|
-
if (!agents.has(id)) {
|
|
553
|
-
agents.set(id, {
|
|
554
|
-
actorId: id,
|
|
555
|
-
name: data.name || id,
|
|
556
|
-
role: data.role || 'agent',
|
|
557
|
-
capabilities: data.capabilities || [],
|
|
558
|
-
metadata: data.metadata,
|
|
559
|
-
connectedAt: Date.now(),
|
|
560
|
-
});
|
|
561
|
-
}
|
|
562
|
-
room._emitPresence('presenceJoin', id, data);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
});
|
|
566
|
-
this._client.on('lobbyPresence:leave', (evt) => {
|
|
567
|
-
const id = evt?.actorId;
|
|
568
|
-
if (id) {
|
|
569
|
-
this._log(`lobby presence:leave — ${id}`);
|
|
570
|
-
for (const room of this._rooms.values()) {
|
|
571
|
-
const agents = room._agents;
|
|
572
|
-
agents.delete(id);
|
|
573
|
-
room._emitPresence('presenceLeave', id);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
});
|
|
577
|
-
this._client.on('lobbyPresence:update', (evt) => {
|
|
578
|
-
const id = evt?.actorId;
|
|
579
|
-
const data = evt?.data || {};
|
|
580
|
-
if (id) {
|
|
581
|
-
for (const room of this._rooms.values()) {
|
|
582
|
-
const agents = room._agents;
|
|
583
|
-
const existing = agents.get(id);
|
|
584
|
-
if (existing) {
|
|
585
|
-
if (data.name)
|
|
586
|
-
existing.name = data.name;
|
|
587
|
-
if (data.role)
|
|
588
|
-
existing.role = data.role;
|
|
589
|
-
if (data.capabilities)
|
|
590
|
-
existing.capabilities = data.capabilities;
|
|
591
|
-
if (data.metadata)
|
|
592
|
-
existing.metadata = data.metadata;
|
|
593
|
-
}
|
|
594
|
-
room._emitPresence('presenceUpdate', id, data);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
});
|
|
768
|
+
this._options.lobby = lobbySlug;
|
|
769
|
+
if (!this._lobby) {
|
|
770
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(lobbySlug);
|
|
771
|
+
}
|
|
598
772
|
try {
|
|
599
|
-
const
|
|
600
|
-
this.
|
|
601
|
-
|
|
773
|
+
const state = await this._lobby.subscribe();
|
|
774
|
+
if (!this._detached)
|
|
775
|
+
this._diffHydrateLobby(state);
|
|
776
|
+
return state || {};
|
|
602
777
|
}
|
|
603
778
|
catch (err) {
|
|
604
|
-
this._log(
|
|
779
|
+
this._log("lobby subscription failed:", err);
|
|
605
780
|
return {};
|
|
606
781
|
}
|
|
607
782
|
}
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
this.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
this.
|
|
614
|
-
|
|
615
|
-
|
|
783
|
+
// ============ Private: Guards ============
|
|
784
|
+
_assertUsable() {
|
|
785
|
+
if (this._detached) {
|
|
786
|
+
throw new Error("NoLagAgents has been detached — construct a new instance");
|
|
787
|
+
}
|
|
788
|
+
if (!this._isReady) {
|
|
789
|
+
throw new Error('NoLagAgents not ready — await ready() or the "connected" event');
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// ============ Private: Room Setup ============
|
|
793
|
+
_joinRoomInternal(name) {
|
|
794
|
+
this._log(`joining room: ${name}`);
|
|
795
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
796
|
+
const room = new AgentRoom(name, roomContext, createLogger(`AgentRoom:${name}`, this._options.debug), this._options.agentId, this._options.appName, () => this._client.connected, this._options.presence);
|
|
797
|
+
this._rooms.set(name, room);
|
|
798
|
+
return room;
|
|
616
799
|
}
|
|
800
|
+
// ============ Private: Scope Filtering ============
|
|
617
801
|
/**
|
|
618
|
-
*
|
|
619
|
-
*
|
|
802
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
803
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
804
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
805
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
620
806
|
*/
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
807
|
+
_foreignScope(data) {
|
|
808
|
+
const scope = data?.__scope;
|
|
809
|
+
return typeof scope === "string" && scope !== this._options.appName;
|
|
810
|
+
}
|
|
811
|
+
// ============ Private: Room Presence → Rooms ============
|
|
812
|
+
_handleRoomPresenceJoin(data) {
|
|
813
|
+
if (data.actorTokenId === this._client.actorId)
|
|
814
|
+
return;
|
|
815
|
+
const presence = (data.presence || {});
|
|
816
|
+
if (this._foreignScope(presence))
|
|
817
|
+
return;
|
|
818
|
+
const roomId = data.roomId;
|
|
819
|
+
for (const room of this._targetRooms(roomId)) {
|
|
820
|
+
room._handlePresenceJoin(data.actorTokenId, presence);
|
|
627
821
|
}
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
822
|
+
}
|
|
823
|
+
_handleRoomPresenceLeave(data) {
|
|
824
|
+
if (data.actorTokenId === this._client.actorId)
|
|
825
|
+
return;
|
|
826
|
+
const roomId = data.roomId;
|
|
827
|
+
for (const room of this._targetRooms(roomId)) {
|
|
828
|
+
room._handlePresenceLeave(data.actorTokenId);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
_handleRoomPresenceUpdate(data) {
|
|
832
|
+
if (data.actorTokenId === this._client.actorId)
|
|
833
|
+
return;
|
|
834
|
+
const presence = (data.presence || {});
|
|
835
|
+
if (this._foreignScope(presence))
|
|
836
|
+
return;
|
|
837
|
+
const roomId = data.roomId;
|
|
838
|
+
for (const room of this._targetRooms(roomId)) {
|
|
839
|
+
room._handlePresenceUpdate(data.actorTokenId, presence);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/** Rooms a presence event targets: the named room, or all when unscoped. */
|
|
843
|
+
_targetRooms(roomId) {
|
|
844
|
+
if (roomId && this._rooms.has(roomId))
|
|
845
|
+
return [this._rooms.get(roomId)];
|
|
846
|
+
if (roomId)
|
|
847
|
+
return [];
|
|
848
|
+
return [...this._rooms.values()];
|
|
849
|
+
}
|
|
850
|
+
// ============ Private: Lobby → Rooms ============
|
|
851
|
+
_handleLobbyJoin(event) {
|
|
852
|
+
const { actorId, data } = event;
|
|
853
|
+
if (actorId === this._client.actorId)
|
|
854
|
+
return;
|
|
855
|
+
const presence = (data || {});
|
|
856
|
+
if (this._foreignScope(presence))
|
|
857
|
+
return;
|
|
858
|
+
this._log(`lobby presence:join — ${presence.name || actorId}`);
|
|
859
|
+
for (const room of this._rooms.values()) {
|
|
860
|
+
room._handlePresenceJoin(actorId, presence);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
_handleLobbyLeave(event) {
|
|
864
|
+
const { actorId, data } = event;
|
|
865
|
+
if (actorId === this._client.actorId)
|
|
866
|
+
return;
|
|
867
|
+
const presence = (data || {});
|
|
868
|
+
if (this._foreignScope(presence))
|
|
869
|
+
return;
|
|
870
|
+
this._log(`lobby presence:leave — ${actorId}`);
|
|
871
|
+
for (const room of this._rooms.values()) {
|
|
872
|
+
room._handlePresenceLeave(actorId);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
_handleLobbyUpdate(event) {
|
|
876
|
+
const { actorId, data } = event;
|
|
877
|
+
if (actorId === this._client.actorId)
|
|
878
|
+
return;
|
|
879
|
+
const presence = (data || {});
|
|
880
|
+
if (this._foreignScope(presence))
|
|
881
|
+
return;
|
|
882
|
+
for (const room of this._rooms.values()) {
|
|
883
|
+
room._handlePresenceUpdate(actorId, presence);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* Reconcile the rooms' agent registries against a fresh lobby snapshot,
|
|
888
|
+
* routing each present actor in as a join. One path for initial hydration,
|
|
889
|
+
* reconnect restore, and the deferred refetch.
|
|
890
|
+
*/
|
|
891
|
+
_diffHydrateLobby(state) {
|
|
892
|
+
for (const roomId of Object.keys(state)) {
|
|
893
|
+
const roomPresence = state[roomId];
|
|
894
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
895
|
+
if (actorId === this._client.actorId)
|
|
896
|
+
continue;
|
|
897
|
+
const raw = roomPresence[actorId];
|
|
898
|
+
// Server returns full actor records with presence nested under .presence
|
|
899
|
+
const presence = (raw?.presence ?? raw);
|
|
900
|
+
if (this._foreignScope(presence))
|
|
901
|
+
continue;
|
|
902
|
+
for (const room of this._rooms.values()) {
|
|
903
|
+
room._handlePresenceJoin(actorId, presence);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
// ============ Private: Helpers ============
|
|
909
|
+
/** Derive presence identity from name/role options when no presence given. */
|
|
910
|
+
_presenceFromIdentity(options) {
|
|
911
|
+
if (!options.name && !options.role)
|
|
912
|
+
return undefined;
|
|
913
|
+
return {
|
|
914
|
+
name: options.name ?? (options.agentId ?? "agent"),
|
|
915
|
+
role: options.role ?? "agent",
|
|
916
|
+
};
|
|
633
917
|
}
|
|
634
918
|
}
|
|
635
919
|
|