@appthen/sdk-web 0.0.2 → 0.0.4

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/index.d.ts CHANGED
@@ -47,12 +47,31 @@ export interface ConversationSocketEvent {
47
47
  export interface SocketIoConversationRealtimeAdapterOptions {
48
48
  url: string;
49
49
  account?: () => string | null | Promise<string | null>;
50
+ /**
51
+ * JWT 提供者(2026-09-21 修复):socket 的身份**只在握手时**由 query.token 建立,
52
+ * 服务端 join 是 fail-closed 的——没带 token 的连接会被拒绝加入任何房间(收不到
53
+ * 实时事件)。此前 token 只能经 connect(token) 显式传入,而宿主普遍不会调它,
54
+ * 首个 joinRoom/subscribeConversation 会以**无 token** 连接固化 ⇒ 审批卡原地
55
+ * 收敛(message.updated)永远到不了已打开的页面,卡片卡死在"Applying..."。
56
+ * 提供 getter 后每次 ensureSocket 都取当前 token;socket 已存在但 token 变化
57
+ * (含从无到有)时重建连接,activeRooms 会在 connect 后自动重新加入。
58
+ */
59
+ token?: () => string | null | Promise<string | null>;
50
60
  roomPrefix?: string;
51
61
  eventName?: string;
62
+ /**
63
+ * 持久房间(P1.5 投递模型):每次连接建立后自动 join 的房间列表。
64
+ * 用于「个人房间」`hub:subject:<subjectId>` —— 投递目标是「人」不是「房间」,
65
+ * 收件箱增量/通知请求由服务端扇出到该房间,客户端必须始终在线才能收到;
66
+ * 与 subscribeConversation 的引用计数房间不同,这里每次 connect 重放 join
67
+ * (服务端 socket.join 幂等),不参与 activeRooms 计数。
68
+ */
69
+ persistentRooms?: () => string[];
52
70
  }
53
71
  export declare class SocketIoConversationRealtimeAdapter implements RealtimeAdapter {
54
72
  private readonly options;
55
73
  private socket;
74
+ private socketToken;
56
75
  private connectPromise;
57
76
  private activeRooms;
58
77
  private subscriptions;
@@ -62,10 +81,17 @@ export declare class SocketIoConversationRealtimeAdapter implements RealtimeAdap
62
81
  private get messageEventName();
63
82
  private getConversationRoom;
64
83
  private getAccount;
84
+ private resolveSocketToken;
65
85
  private ensureSocket;
66
86
  private emitJoin;
67
87
  private emitLeave;
68
88
  private joinActiveRooms;
89
+ /**
90
+ * 持久房间(P1.5):每次连接后重放 join。个人房间 `hub:subject:<subjectId>`
91
+ * 承担「投递给人」的职责(收件箱增量/通知请求/未来投递路由),必须随连接常驻;
92
+ * 服务端 socket.join 幂等,重复 join 无副作用。
93
+ */
94
+ private joinPersistentRooms;
69
95
  connect(token?: string | null): Promise<void>;
70
96
  disconnect(): Promise<void>;
71
97
  /**
@@ -81,6 +107,28 @@ export declare class SocketIoConversationRealtimeAdapter implements RealtimeAdap
81
107
  onGlobalEvent(handler: (event: Record<string, unknown>) => void): () => void;
82
108
  subscribeConversation(conversationId: string): Promise<RealtimeSubscription<ConversationSocketEvent>>;
83
109
  publishConversationEvent(conversationId: string, event: Record<string, unknown>): Promise<void>;
110
+ /**
111
+ * 客户端活性上报(P0 状态层,SC-69 ClientPresence):前台/后台 + 当前聚焦会话。
112
+ * 服务端据此决定投递出口(在线免打扰等,SC-70 通知决策器)。
113
+ * 连接未就绪时先 ensureSocket;仍不可用则静默跳过(下次活性变化再上报)。
114
+ */
115
+ reportPresence(activity: {
116
+ appState?: 'foreground' | 'background';
117
+ focusConversation?: string | null;
118
+ }): Promise<void>;
119
+ private autoVisibilityBound;
120
+ /**
121
+ * 客户端设备 id 提供器(SC-74):设置后每次活性上报都附带 `deviceId`,
122
+ * 服务端据此刷新**设备粒度**在线态(SC-69 扩展 / SC-75 客户端管理 / 逐端推送)。
123
+ * 用 provider 而非固定值:宿主解析设备身份是异步的,后置写入也要生效。
124
+ */
125
+ clientDeviceIdProvider?: () => string | undefined;
126
+ /**
127
+ * 自动按页面可见性上报前台/后台(visibilitychange → foreground/background)。
128
+ * 幂等;浏览器环境才生效(无 document 时静默跳过)。聚焦会话由宿主
129
+ * 在会话切换时调 reportPresence({ focusConversation }) 补充。
130
+ */
131
+ enableAutoPageVisibility(): void;
84
132
  }
85
133
  export interface CreateWebChatHubClientOptions {
86
134
  baseUrl: string;
package/dist/index.js CHANGED
@@ -172,10 +172,12 @@ export class SocketIoConversationRealtimeAdapter {
172
172
  constructor(options) {
173
173
  this.options = options;
174
174
  this.socket = null;
175
+ this.socketToken = null;
175
176
  this.connectPromise = null;
176
177
  this.activeRooms = new Map();
177
178
  this.subscriptions = new Map();
178
179
  this.globalListeners = new Set();
180
+ this.autoVisibilityBound = false;
179
181
  }
180
182
  get roomPrefix() {
181
183
  return this.options.roomPrefix || 'hub:conversation:';
@@ -189,7 +191,32 @@ export class SocketIoConversationRealtimeAdapter {
189
191
  async getAccount() {
190
192
  return (await this.options.account?.()) || 'anonymous';
191
193
  }
194
+ async resolveSocketToken(explicit) {
195
+ if (explicit !== undefined)
196
+ return explicit;
197
+ try {
198
+ return (await this.options.token?.()) ?? null;
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ }
192
204
  async ensureSocket(token) {
205
+ const resolvedToken = await this.resolveSocketToken(token);
206
+ // token 变化(尤其是"无 → 有")必须重建连接:身份只在握手时建立,
207
+ // 复用一条无 token 的旧连接会让所有 join 继续 fail-closed。
208
+ if (this.socket && this.socketToken !== (resolvedToken || null)) {
209
+ const stale = this.socket;
210
+ this.socket = null;
211
+ this.connectPromise = null;
212
+ try {
213
+ stale.removeAllListeners();
214
+ stale.disconnect();
215
+ }
216
+ catch {
217
+ // 忽略销毁失败——下面的新连接会自愈
218
+ }
219
+ }
193
220
  if (this.socket?.connected) {
194
221
  return this.socket;
195
222
  }
@@ -197,11 +224,14 @@ export class SocketIoConversationRealtimeAdapter {
197
224
  await this.connectPromise;
198
225
  return this.socket;
199
226
  }
227
+ this.socketToken = resolvedToken || null;
200
228
  this.connectPromise = new Promise((resolve, reject) => {
201
229
  const socket = io(this.options.url, {
202
230
  autoConnect: false,
231
+ // 纯 WebSocket:禁止 polling/HTTP 轮询绕路。桌面端 local-server 对
232
+ // /socket.io upgrade 请求做 TCP 隧道转发,握手失败应直接暴露而非静默降级。
203
233
  transports: ['websocket'],
204
- query: token ? { token } : {},
234
+ query: resolvedToken ? { token: resolvedToken } : {},
205
235
  });
206
236
  const cleanup = () => {
207
237
  socket.off('connect', handleConnect);
@@ -210,6 +240,13 @@ export class SocketIoConversationRealtimeAdapter {
210
240
  const handleConnect = async () => {
211
241
  cleanup();
212
242
  this.socket = socket;
243
+ if (typeof console !== 'undefined') {
244
+ const transportName = socket.io?.engine?.transport?.name || 'unknown';
245
+ console.info(`[chat-hub-realtime] connected via transport: ${transportName}`);
246
+ socket.io.engine?.on?.('upgrade', (transport) => {
247
+ console.info(`[chat-hub-realtime] transport upgraded to: ${transport.name}`);
248
+ });
249
+ }
213
250
  socket.on(this.messageEventName, (payload) => {
214
251
  const evtName = payload && typeof payload === 'object'
215
252
  ? payload.eventName
@@ -232,8 +269,10 @@ export class SocketIoConversationRealtimeAdapter {
232
269
  });
233
270
  socket.on('connect', async () => {
234
271
  await this.joinActiveRooms();
272
+ await this.joinPersistentRooms();
235
273
  });
236
274
  await this.joinActiveRooms();
275
+ await this.joinPersistentRooms();
237
276
  resolve();
238
277
  };
239
278
  const handleError = (error) => {
@@ -300,12 +339,26 @@ export class SocketIoConversationRealtimeAdapter {
300
339
  async joinActiveRooms() {
301
340
  await Promise.all(Array.from(this.activeRooms.keys()).map((room) => this.emitJoin(room)));
302
341
  }
342
+ /**
343
+ * 持久房间(P1.5):每次连接后重放 join。个人房间 `hub:subject:<subjectId>`
344
+ * 承担「投递给人」的职责(收件箱增量/通知请求/未来投递路由),必须随连接常驻;
345
+ * 服务端 socket.join 幂等,重复 join 无副作用。
346
+ */
347
+ async joinPersistentRooms() {
348
+ const rooms = this.options.persistentRooms?.() || [];
349
+ for (const room of rooms) {
350
+ if (!room)
351
+ continue;
352
+ await this.emitJoin(room);
353
+ }
354
+ }
303
355
  async connect(token) {
304
356
  await this.ensureSocket(token);
305
357
  }
306
358
  async disconnect() {
307
359
  const socket = this.socket;
308
360
  this.socket = null;
361
+ this.socketToken = null;
309
362
  this.connectPromise = null;
310
363
  this.globalListeners.clear();
311
364
  if (socket) {
@@ -385,6 +438,61 @@ export class SocketIoConversationRealtimeAdapter {
385
438
  ...event,
386
439
  });
387
440
  }
441
+ /**
442
+ * 客户端活性上报(P0 状态层,SC-69 ClientPresence):前台/后台 + 当前聚焦会话。
443
+ * 服务端据此决定投递出口(在线免打扰等,SC-70 通知决策器)。
444
+ * 连接未就绪时先 ensureSocket;仍不可用则静默跳过(下次活性变化再上报)。
445
+ */
446
+ async reportPresence(activity) {
447
+ const socket = this.socket;
448
+ if (!socket?.connected) {
449
+ try {
450
+ await this.ensureSocket();
451
+ }
452
+ catch {
453
+ return;
454
+ }
455
+ }
456
+ if (!this.socket?.connected)
457
+ return;
458
+ const payload = {};
459
+ if (activity.appState === 'foreground' || activity.appState === 'background') {
460
+ payload.appState = activity.appState;
461
+ }
462
+ if (activity.focusConversation !== undefined) {
463
+ payload.focusConversation = activity.focusConversation;
464
+ }
465
+ // 设备粒度在线态(SC-69 扩展):每次上报都读一次 provider,
466
+ // 宿主后置写入 deviceId(如桌面壳 nodeId 解析完成)后照样生效。
467
+ const deviceId = this.clientDeviceIdProvider?.();
468
+ if (deviceId) {
469
+ payload.deviceId = deviceId;
470
+ }
471
+ this.socket.emit('presence.update', payload);
472
+ }
473
+ /**
474
+ * 自动按页面可见性上报前台/后台(visibilitychange → foreground/background)。
475
+ * 幂等;浏览器环境才生效(无 document 时静默跳过)。聚焦会话由宿主
476
+ * 在会话切换时调 reportPresence({ focusConversation }) 补充。
477
+ */
478
+ enableAutoPageVisibility() {
479
+ if (this.autoVisibilityBound)
480
+ return;
481
+ if (typeof document === 'undefined')
482
+ return;
483
+ const handleVisibility = () => {
484
+ const visible = document.visibilityState === 'visible';
485
+ void this.reportPresence({
486
+ appState: visible ? 'foreground' : 'background',
487
+ });
488
+ };
489
+ document.addEventListener('visibilitychange', handleVisibility);
490
+ // 立即上报一次当前状态(连接可能已就绪)
491
+ void this.reportPresence({
492
+ appState: document.visibilityState === 'visible' ? 'foreground' : 'background',
493
+ });
494
+ this.autoVisibilityBound = true;
495
+ }
388
496
  }
389
497
  export function createChatHubClient(options) {
390
498
  const clientOptions = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appthen/sdk-web",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "AppThen Conversation Hub SDK - Web platform adapter (fetch + socket.io-client)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  "README.md"
18
18
  ],
19
19
  "dependencies": {
20
- "@appthen/sdk-core": "0.0.2"
20
+ "@appthen/sdk-core": "0.0.4"
21
21
  },
22
22
  "peerDependencies": {
23
23
  "socket.io-client": "^4.7.0"