@appthen/sdk-web 0.0.2 → 0.0.3
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 +48 -0
- package/dist/index.js +109 -1
- package/package.json +2 -2
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:
|
|
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.
|
|
3
|
+
"version": "0.0.3",
|
|
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.
|
|
20
|
+
"@appthen/sdk-core": "0.0.3"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"socket.io-client": "^4.7.0"
|