@adep/client 0.1.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 ADDED
@@ -0,0 +1,38 @@
1
+ # @adep/client — AgentDeploy 实时客户端
2
+
3
+ > **实时通道订阅库**(任务单 RT-005):`换票 → 建连 → 订阅 → 断线自动重连 → 对称心跳`。独立于页面框架,可被非 Page 场景(如 widget 卡片渲染)复用。
4
+
5
+ ```bash
6
+ npm install @adep/client
7
+ # 或
8
+ pnpm add @adep/client
9
+ ```
10
+
11
+ ## 能力总览
12
+
13
+ - **票据换连**:向平台换取实时票据(ticket),以票据建立 WebSocket 连接
14
+ - **订阅 / 取消订阅**:按通道订阅实时事件
15
+ - **自动重连**:断线后自动重连并恢复订阅状态
16
+ - **对称心跳**:客户端与服务端对称心跳保活
17
+
18
+ ## 用法
19
+
20
+ ```ts
21
+ import { RealtimeClient } from '@adep/client'
22
+
23
+ const client = new RealtimeClient({ server: 'wss://api.example.com' })
24
+ await client.connect()
25
+
26
+ const off = client.subscribe('room:1', (msg) => {
27
+ console.log('收到实时消息', msg)
28
+ })
29
+
30
+ // 不需要时取消订阅并关闭
31
+ off()
32
+ await client.disconnect()
33
+ ```
34
+
35
+ ## 说明
36
+
37
+ - 仅依赖 `@adep/types` 提供消息类型契约。
38
+ - 协议细节(票据端点 / 心跳间隔 / 重连退避)见 `packages/docs/features/realtime.md`。
@@ -0,0 +1,18 @@
1
+ /**
2
+ * `@adep/client` —— AgentDeploy 实时客户端订阅库(任务单 RT-005)。
3
+ *
4
+ * 独立 npm 包(跨边界类型契约在 `@adep/types`,运行时实现归本包),可被非 Page 场景复用:
5
+ * - Web / Nuxt 页面(换票带 better-auth 会话 cookie);
6
+ * - FE-003 Agent 卡片渲染抽象(浏览器宿主接通 realtime 通道);
7
+ * - 服务端组件 / MCP 侧(换票带 API Key,见 `httpTicketProvider` 注入 header)。
8
+ *
9
+ * 导出面 = 主类 `RealtimeClient` + 便捷工厂 `createRealtimeClient` + 换票 HTTP 提供者
10
+ * `httpTicketProvider` + socket 适配(`SocketFactory` / 默认工厂)+ 复用的平台实时类型。
11
+ */
12
+ export { RealtimeClient, createRealtimeClient, REALTIME_SUBPROTOCOL, CLOSE_INVALID_TICKET, CLOSE_UNAUTHORIZED_SUBSCRIPTION, } from './realtime/client';
13
+ export type { RealtimeClientOptions, RealtimeClientStatus, TicketContext, TicketProvider, TicketResult, } from './realtime/client';
14
+ export { httpTicketProvider, TicketExchangeError } from './realtime/http-ticket';
15
+ export type { HttpTicketProviderOptions } from './realtime/http-ticket';
16
+ export { defaultSocketFactory } from './realtime/socket';
17
+ export type { RealtimeSocket, SocketFactory, SocketCloseInfo } from './realtime/socket';
18
+ export type { RealtimeChannelMessage, RealtimeClientEvent, RealtimeServerEvent } from '@adep/types';
package/dist/index.js ADDED
@@ -0,0 +1,332 @@
1
+ // packages/client/src/realtime/socket.ts
2
+ function adaptNativeSocket(ws) {
3
+ const socket = {
4
+ get readyState() {
5
+ return ws.readyState;
6
+ },
7
+ send: (data) => {
8
+ ws.send(data);
9
+ },
10
+ close: (code, reason) => {
11
+ ws.close(code, reason);
12
+ },
13
+ onopen: null,
14
+ onmessage: null,
15
+ onclose: null,
16
+ onerror: null
17
+ };
18
+ ws.onopen = () => socket.onopen?.();
19
+ ws.onmessage = (event) => socket.onmessage?.(String(event.data));
20
+ ws.onclose = (event) => {
21
+ socket.onclose?.({
22
+ code: event.code,
23
+ reason: event.reason,
24
+ wasClean: event.wasClean
25
+ });
26
+ };
27
+ ws.onerror = () => socket.onerror?.();
28
+ return socket;
29
+ }
30
+ function defaultSocketFactory() {
31
+ return (url, protocols) => adaptNativeSocket(new WebSocket(url, protocols));
32
+ }
33
+
34
+ // packages/client/src/realtime/client.ts
35
+ var REALTIME_SUBPROTOCOL = "adep.v1";
36
+ var CLOSE_INVALID_TICKET = 4400;
37
+ var CLOSE_UNAUTHORIZED_SUBSCRIPTION = 4401;
38
+ var WS_OPEN = 1;
39
+ function toWsUrl(baseUrl, path) {
40
+ const rest = baseUrl.replace(/^https?:\/\//, "").replace(/\/+$/, "");
41
+ const scheme = baseUrl.startsWith("https://") ? "wss://" : "ws://";
42
+ return `${scheme}${rest}${path}`;
43
+ }
44
+ function isChannelMessage(value) {
45
+ if (typeof value !== "object" || value === null) return false;
46
+ const obj = value;
47
+ return typeof obj.channel === "string" && typeof obj.seq === "number" && "data" in obj && typeof obj.publishedAt === "string";
48
+ }
49
+ function isServerEvent(value) {
50
+ if (typeof value !== "object" || value === null) return false;
51
+ const type = value.type;
52
+ return type === "subscribed" || type === "unsubscribed" || type === "error";
53
+ }
54
+ var RealtimeClient = class {
55
+ baseUrl;
56
+ projectId;
57
+ channel;
58
+ exchangeTicket;
59
+ onMessage;
60
+ onStatus;
61
+ onReconnected;
62
+ reconnectInitialMs;
63
+ reconnectMaxMs;
64
+ heartbeatMs;
65
+ factory;
66
+ logFn;
67
+ socket = null;
68
+ channels;
69
+ started = false;
70
+ closedByUser = false;
71
+ rejected = false;
72
+ attempt = 0;
73
+ status = "idle";
74
+ reconnectTimer = null;
75
+ heartbeatTimer = null;
76
+ pendingOpen = null;
77
+ constructor(options) {
78
+ this.baseUrl = options.baseUrl;
79
+ this.projectId = options.projectId;
80
+ this.channel = options.channel;
81
+ this.exchangeTicket = options.exchangeTicket;
82
+ this.onMessage = options.onMessage;
83
+ this.onStatus = options.onStatus;
84
+ this.onReconnected = options.onReconnected;
85
+ this.reconnectInitialMs = options.reconnect?.initialMs ?? 1e3;
86
+ this.reconnectMaxMs = options.reconnect?.maxMs ?? 3e4;
87
+ this.heartbeatMs = options.heartbeatIntervalMs ?? 15e3;
88
+ this.factory = options.socketFactory ?? defaultSocketFactory();
89
+ this.logFn = options.log;
90
+ this.channels = /* @__PURE__ */ new Set([options.channel]);
91
+ }
92
+ get currentStatus() {
93
+ return this.status;
94
+ }
95
+ get socketReadyState() {
96
+ return this.socket?.readyState ?? 3;
97
+ }
98
+ /** 在下次进入 `open`(或已在 open)时 resolve;用于「建连 → 订阅」链路的接入等待。 */
99
+ whenOpen() {
100
+ if (this.status === "open") return Promise.resolve();
101
+ return new Promise((resolve) => {
102
+ this.pendingOpen = resolve;
103
+ });
104
+ }
105
+ /** 启动订阅库:换票 → 建连 → 订阅并保持。 */
106
+ start() {
107
+ this.started = true;
108
+ this.closedByUser = false;
109
+ this.rejected = false;
110
+ this.attempt = 0;
111
+ this.clearReconnectTimer();
112
+ this.connect(0);
113
+ }
114
+ /** 追加订阅一个 channel(已 OPEN 时即时下发订阅帧;否则随下次建连一并订阅)。 */
115
+ subscribe(channel) {
116
+ this.channels.add(channel);
117
+ const ws = this.socket;
118
+ if (ws !== null && ws.readyState === WS_OPEN) {
119
+ this.sendFrame(ws, { type: "subscribe", channel });
120
+ }
121
+ }
122
+ /** 退订某 channel。 */
123
+ unsubscribe(channel) {
124
+ this.channels.delete(channel);
125
+ const ws = this.socket;
126
+ if (ws !== null && ws.readyState === WS_OPEN) {
127
+ this.sendFrame(ws, { type: "unsubscribe", channel });
128
+ }
129
+ }
130
+ /** 主动断开:停止自动重连,正常关闭 socket。 */
131
+ disconnect() {
132
+ this.closedByUser = true;
133
+ this.started = false;
134
+ this.clearReconnectTimer();
135
+ this.stopHeartbeat();
136
+ const ws = this.socket;
137
+ this.socket = null;
138
+ if (ws !== null && ws.readyState === WS_OPEN) {
139
+ ws.close(1e3, "client-disconnect");
140
+ }
141
+ this.setStatus("closed");
142
+ }
143
+ // —— 建连 / 重连 ——
144
+ connect(attempt) {
145
+ this.attempt = attempt;
146
+ this.setStatus(attempt === 0 ? "connecting" : "reconnecting");
147
+ this.stopHeartbeat();
148
+ Promise.resolve(this.exchangeTicket({ baseUrl: this.baseUrl, projectId: this.projectId })).then(({ ticket }) => {
149
+ if (!this.started || this.closedByUser || this.rejected) return;
150
+ const url = toWsUrl(this.baseUrl, `/v1/realtime/ws/${encodeURIComponent(this.channel)}`);
151
+ const ws = this.factory(url, [ticket, REALTIME_SUBPROTOCOL]);
152
+ ws.onopen = () => this.onSocketOpen(ws);
153
+ ws.onmessage = (data) => this.onMessageFrame(data);
154
+ ws.onclose = (info) => this.onSocketClose(ws, info);
155
+ ws.onerror = () => {
156
+ };
157
+ this.socket = ws;
158
+ this.logFn?.("realtime \u5EFA\u8FDE", { attempt, channel: this.channel });
159
+ this.startHeartbeat();
160
+ return void 0;
161
+ }).catch((error) => {
162
+ this.logFn?.("realtime \u6362\u7968\u5931\u8D25", { error: String(error) });
163
+ this.rejected = true;
164
+ this.stopHeartbeat();
165
+ this.setStatus("error");
166
+ });
167
+ }
168
+ onSocketOpen(ws) {
169
+ if (ws !== this.socket) return;
170
+ for (const channel of this.channels) {
171
+ this.sendFrame(ws, { type: "subscribe", channel });
172
+ }
173
+ this.setStatus("open");
174
+ if (this.attempt > 0) {
175
+ this.onReconnected?.({ channel: this.channel, attempt: this.attempt });
176
+ }
177
+ }
178
+ onSocketClose(ws, info) {
179
+ if (ws !== this.socket) return;
180
+ this.socket = null;
181
+ this.stopHeartbeat();
182
+ if (this.closedByUser) {
183
+ this.setStatus("closed");
184
+ return;
185
+ }
186
+ const authReject = info.code === CLOSE_INVALID_TICKET || info.code === CLOSE_UNAUTHORIZED_SUBSCRIPTION;
187
+ if (authReject) {
188
+ this.rejected = true;
189
+ this.setStatus("error");
190
+ return;
191
+ }
192
+ this.setStatus("reconnecting");
193
+ this.scheduleReconnect(this.attempt + 1);
194
+ }
195
+ scheduleReconnect(nextAttempt) {
196
+ if (!this.started || this.closedByUser || this.rejected) return;
197
+ this.clearReconnectTimer();
198
+ const delay = Math.min(this.reconnectInitialMs * 2 ** nextAttempt, this.reconnectMaxMs);
199
+ this.reconnectTimer = setTimeout(() => {
200
+ this.reconnectTimer = null;
201
+ if (this.started && !this.closedByUser && !this.rejected) {
202
+ this.connect(nextAttempt);
203
+ }
204
+ }, delay);
205
+ }
206
+ // —— 心跳(对称 watchdog)——
207
+ startHeartbeat() {
208
+ this.stopHeartbeat();
209
+ if (this.heartbeatMs <= 0) return;
210
+ this.heartbeatTimer = setInterval(() => this.tickHeartbeat(), this.heartbeatMs);
211
+ }
212
+ stopHeartbeat() {
213
+ if (this.heartbeatTimer !== null) {
214
+ clearInterval(this.heartbeatTimer);
215
+ this.heartbeatTimer = null;
216
+ }
217
+ }
218
+ tickHeartbeat() {
219
+ if (!this.started || this.closedByUser || this.rejected) return;
220
+ const ws = this.socket;
221
+ if (ws === null) return;
222
+ if (ws.readyState !== WS_OPEN) {
223
+ this.logFn?.("realtime \u5FC3\u8DF3\u68C0\u6D4B\u5230\u5931\u6548\u8FDE\u63A5\uFF0C\u89E6\u53D1\u91CD\u8FDE", {
224
+ readyState: ws.readyState
225
+ });
226
+ this.socket = null;
227
+ this.stopHeartbeat();
228
+ this.setStatus("reconnecting");
229
+ this.scheduleReconnect(this.attempt + 1);
230
+ }
231
+ }
232
+ // —— 帧处理 ——
233
+ onMessageFrame(data) {
234
+ let frame;
235
+ try {
236
+ frame = JSON.parse(data);
237
+ } catch {
238
+ this.logFn?.("realtime \u6536\u5230\u975E JSON \u4E0B\u884C\u5E27\uFF0C\u5FFD\u7565", { data: String(data).slice(0, 64) });
239
+ return;
240
+ }
241
+ if (isChannelMessage(frame)) {
242
+ this.onMessage?.(frame);
243
+ return;
244
+ }
245
+ if (isServerEvent(frame)) {
246
+ if (frame.type === "error") {
247
+ this.logFn?.("realtime \u670D\u52A1\u7AEF\u9519\u8BEF\u5E27", { code: frame.code, message: frame.message });
248
+ }
249
+ return;
250
+ }
251
+ this.logFn?.("realtime \u6536\u5230\u672A\u77E5\u4E0B\u884C\u5E27\uFF0C\u5FFD\u7565", { frame });
252
+ }
253
+ sendFrame(ws, frame) {
254
+ if (ws.readyState === WS_OPEN) {
255
+ ws.send(JSON.stringify(frame));
256
+ }
257
+ }
258
+ clearReconnectTimer() {
259
+ if (this.reconnectTimer !== null) {
260
+ clearTimeout(this.reconnectTimer);
261
+ this.reconnectTimer = null;
262
+ }
263
+ }
264
+ setStatus(status) {
265
+ this.status = status;
266
+ if (status === "open") {
267
+ const resolve = this.pendingOpen;
268
+ this.pendingOpen = null;
269
+ resolve?.();
270
+ }
271
+ this.onStatus?.(status);
272
+ }
273
+ };
274
+ function createRealtimeClient(options) {
275
+ return new RealtimeClient(options);
276
+ }
277
+
278
+ // packages/client/src/realtime/http-ticket.ts
279
+ var TicketExchangeError = class extends Error {
280
+ constructor(status, code, message) {
281
+ super(message);
282
+ this.status = status;
283
+ this.code = code;
284
+ this.name = "TicketExchangeError";
285
+ }
286
+ };
287
+ function errorCodeOf(body) {
288
+ if (typeof body === "object" && body !== null) {
289
+ const err = body.error;
290
+ if (typeof err?.code === "string") return err.code;
291
+ }
292
+ return "RT_TICKET_HTTP";
293
+ }
294
+ var joinUrl = (baseUrl, path) => `${baseUrl.replace(/\/+$/, "")}${path}`;
295
+ function httpTicketProvider(options = {}) {
296
+ return async ({ baseUrl, projectId }) => {
297
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
298
+ const response = await fetchImpl(joinUrl(baseUrl, "/api/v1/realtime/ticket"), {
299
+ method: "POST",
300
+ headers: { "content-type": "application/json", ...options.headers },
301
+ body: JSON.stringify({ projectId })
302
+ });
303
+ if (!response.ok) {
304
+ let body;
305
+ try {
306
+ body = await response.json();
307
+ } catch {
308
+ body = void 0;
309
+ }
310
+ throw new TicketExchangeError(
311
+ response.status,
312
+ errorCodeOf(body),
313
+ `\u6362\u7968\u5931\u8D25\uFF08HTTP ${response.status}\uFF09`
314
+ );
315
+ }
316
+ const data = await response.json();
317
+ if (typeof data.ticket !== "string" || typeof data.expiresIn !== "number") {
318
+ throw new TicketExchangeError(response.status, "RT_TICKET_HTTP", "\u6362\u7968\u54CD\u5E94\u7F3A\u5C11 ticket \u5B57\u6BB5");
319
+ }
320
+ return { ticket: data.ticket, expiresIn: data.expiresIn };
321
+ };
322
+ }
323
+ export {
324
+ CLOSE_INVALID_TICKET,
325
+ CLOSE_UNAUTHORIZED_SUBSCRIPTION,
326
+ REALTIME_SUBPROTOCOL,
327
+ RealtimeClient,
328
+ TicketExchangeError,
329
+ createRealtimeClient,
330
+ defaultSocketFactory,
331
+ httpTicketProvider
332
+ };
@@ -0,0 +1,118 @@
1
+ /**
2
+ * 实时客户端订阅库核心(RT-005)——独立 npm 包 `@adep/client`。
3
+ *
4
+ * 职责(PRD §2.12.9 出口场景 / §3.2.9 客户端订阅库):
5
+ * 换票 → 建连 → 订阅/退订 → 断线自动重连并重新订阅 → 对称心跳。
6
+ *
7
+ * 与服务端(RT-002/003/004,`server/domains/realtime/`)的握手契约对齐:
8
+ * - 票据经 `Sec-WebSocket-Protocol` 携带(**首个子协议**),服务端 `handshake.ticketFromProtocol`
9
+ * 取第一个子协议做一次性票据兑换;本库在票据**之后**再带一个 `adep.v1` 版本协商子协议标记。
10
+ * - 服务端兑换失败回关闭码:4400 = 票据无效/过期/重放,4401 = 订阅未授权。
11
+ * - 重连后历史**不补发**(§2.12.4),由客户端经 `onReconnected` 钩子自行从约定入口
12
+ * (用户函数 / cloud.db)拉取,而非服务端 push。
13
+ *
14
+ * 断线分类:
15
+ * - 授权类关闭(4400/4401)→ 明确 `error` 态,**不自动重连、不重试这把票据**(验收「重放被拒不静默回退」);
16
+ * - 普通网络断开(1006 等)→ 指数退避自动重连 + 重新订阅 + `onReconnected` 触发历史拉取。
17
+ * 心跳为「对称」的 liveness watchdog:不发明协议帧(避免误触函数派发),只周期检查连接存活,
18
+ * 对悬空的 CONNECTING / 僵尸连接触达重连;平台侧的自动离房由 RT-004 `PresenceTracker` 兜底。
19
+ */
20
+ import type { RealtimeChannelMessage } from '@adep/types';
21
+ import type { SocketFactory } from './socket';
22
+ /** 与服务端协商的子协议版本标记(跟随在票据之后的第二个子协议;服务端读首个子协议为票据)。 */
23
+ export declare const REALTIME_SUBPROTOCOL = "adep.v1";
24
+ /** 与服务端握手关闭码对齐(`server/domains/realtime/handshake.ts`)。 */
25
+ export declare const CLOSE_INVALID_TICKET = 4400;
26
+ export declare const CLOSE_UNAUTHORIZED_SUBSCRIPTION = 4401;
27
+ /** 订阅库对外暴露的生命周期状态。 */
28
+ export type RealtimeClientStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed' | 'error';
29
+ export interface TicketContext {
30
+ baseUrl: string;
31
+ projectId: string;
32
+ }
33
+ export interface TicketResult {
34
+ ticket: string;
35
+ expiresIn: number;
36
+ }
37
+ /** 换票提供者:旧凭据 → 一次性握手票据。可同步或异步。 */
38
+ export type TicketProvider = (ctx: TicketContext) => Promise<TicketResult> | TicketResult;
39
+ export interface RealtimeClientOptions {
40
+ /** 平台 base URL(http(s)://host[:port]),用于换票与拼接 WS 地址。 */
41
+ baseUrl: string;
42
+ /** 项目 id,换票与订阅授权的求值域。 */
43
+ projectId: string;
44
+ /** 订阅的 channel 名(连接即订阅;可用 `subscribe()` 追加更多)。 */
45
+ channel: string;
46
+ /** 换票提供者。 */
47
+ exchangeTicket: TicketProvider;
48
+ /** 收到的 channel 下行消息回调。 */
49
+ onMessage?: (message: RealtimeChannelMessage) => void;
50
+ /** 生命周期状态变更回调。 */
51
+ onStatus?: (status: RealtimeClientStatus) => void;
52
+ /** 重连成功并重新订阅后触发:客户端据此自拉历史(服务端不补发)。 */
53
+ onReconnected?: (info: {
54
+ channel: string;
55
+ attempt: number;
56
+ }) => void;
57
+ /** 重连退避;`initialMs` 缺省 1000,`maxMs` 缺省 30000。 */
58
+ reconnect?: {
59
+ initialMs?: number;
60
+ maxMs?: number;
61
+ };
62
+ /** 对称心跳间隔 ms;缺省 15000,`0` = 关闭 watchdog。 */
63
+ heartbeatIntervalMs?: number;
64
+ /** 可注入 WebSocket 工厂(单测 mock);缺省用宿主全局 WebSocket。 */
65
+ socketFactory?: SocketFactory;
66
+ log?: (message: string, meta?: Record<string, unknown>) => void;
67
+ }
68
+ export declare class RealtimeClient {
69
+ private readonly baseUrl;
70
+ private readonly projectId;
71
+ private readonly channel;
72
+ private readonly exchangeTicket;
73
+ private readonly onMessage;
74
+ private readonly onStatus;
75
+ private readonly onReconnected;
76
+ private readonly reconnectInitialMs;
77
+ private readonly reconnectMaxMs;
78
+ private readonly heartbeatMs;
79
+ private readonly factory;
80
+ private readonly logFn;
81
+ private socket;
82
+ private readonly channels;
83
+ private started;
84
+ private closedByUser;
85
+ private rejected;
86
+ private attempt;
87
+ private status;
88
+ private reconnectTimer;
89
+ private heartbeatTimer;
90
+ private pendingOpen;
91
+ constructor(options: RealtimeClientOptions);
92
+ get currentStatus(): RealtimeClientStatus;
93
+ get socketReadyState(): number;
94
+ /** 在下次进入 `open`(或已在 open)时 resolve;用于「建连 → 订阅」链路的接入等待。 */
95
+ whenOpen(): Promise<void>;
96
+ /** 启动订阅库:换票 → 建连 → 订阅并保持。 */
97
+ start(): void;
98
+ /** 追加订阅一个 channel(已 OPEN 时即时下发订阅帧;否则随下次建连一并订阅)。 */
99
+ subscribe(channel: string): void;
100
+ /** 退订某 channel。 */
101
+ unsubscribe(channel: string): void;
102
+ /** 主动断开:停止自动重连,正常关闭 socket。 */
103
+ disconnect(): void;
104
+ private connect;
105
+ private onSocketOpen;
106
+ private onSocketClose;
107
+ private scheduleReconnect;
108
+ private startHeartbeat;
109
+ private stopHeartbeat;
110
+ private tickHeartbeat;
111
+ private onMessageFrame;
112
+ private sendFrame;
113
+ private clearReconnectTimer;
114
+ private setStatus;
115
+ }
116
+ /** 便捷工厂:`createRealtimeClient(options)` 等价于 `new RealtimeClient(options)`。 */
117
+ export declare function createRealtimeClient(options: RealtimeClientOptions): RealtimeClient;
118
+ export type { RealtimeChannelMessage, RealtimeClientEvent, RealtimeServerEvent } from '@adep/types';
@@ -0,0 +1,29 @@
1
+ /**
2
+ * 实时通道换票 HTTP 客户端(RT-005)。
3
+ *
4
+ * 订阅库的「换票」步骤:向平台 `POST /api/v1/realtime/ticket` 换一张一次性、60s 过期、HMAC 签名的
5
+ * 握手票据(服务端实现 RT-002 `server/domains/realtime/auth/`)。鉴权方式随宿主而变:
6
+ * - 浏览器 / CLI:携带 better-auth 会话 cookie;
7
+ * - 服务端组件 / MCP:携带 API Key(`Authorization: Bearer ...`)。
8
+ * 故不内置鉴权,把「带哪些请求头」交由调用方通过 `httpTicketProvider(opts)` 注入。
9
+ *
10
+ * 零依赖:仅用全局 `fetch`(可注入 `fetchImpl` 供单测 mock,规范 §8)。
11
+ */
12
+ import type { TicketProvider } from './client';
13
+ export interface HttpTicketProviderOptions {
14
+ /** 随请求附带的自定义头(如 cookie / Authorization)。 */
15
+ headers?: Record<string, string>;
16
+ /** 可注入的 fetch 实现(测试 / 平台代理),缺省用全局 fetch。 */
17
+ fetchImpl?: typeof fetch;
18
+ }
19
+ /** 换票失败信封的错误形状(供调用方按 code 分支)。 */
20
+ export declare class TicketExchangeError extends Error {
21
+ readonly status: number;
22
+ readonly code: string;
23
+ constructor(status: number, code: string, message: string);
24
+ }
25
+ /**
26
+ * 构造「换票」的 `TicketProvider`:POST `/api/v1/realtime/ticket`,body 只带 projectId。
27
+ * 成功返回服务端签发的票据;非 2xx 抛 `TicketExchangeError`(resp 信封 code,如 `RT_UNAUTHORIZED`)。
28
+ */
29
+ export declare function httpTicketProvider(options?: HttpTicketProviderOptions): TicketProvider;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * 实时通道客户端 WebSocket 适配层(RT-005)。
3
+ *
4
+ * 订阅库必须能在多种宿主里复用:浏览器(FE-003 卡片渲染)、Bun(服务端组件)、Node ≥22。
5
+ * 原生 `WebSocket` 的形态在这三者里基本一致,但回调签名与事件对象略有出入,且单测需要注入
6
+ * 假 socket(规范 §8:测试不依赖外部服务、网络全部 mock)。因此抽一层最小适配:
7
+ * - `RealtimeSocket`:本库只依赖的 socket 投影(onopen/onmessage/onclose/onerror + send/close)。
8
+ * - `SocketFactory`:创建 socket 的函数——测试注入 `MockSocket`,生产用 `defaultSocketFactory()`。
9
+ *
10
+ * 纯接口 + 一个默认工厂,不引任何第三方运行时依赖(对齐 packages/types 的零依赖约束)。
11
+ */
12
+ /** socket close 事件的服务端关闭信息投影(对齐浏览器的 CloseEvent 关键字段)。 */
13
+ export interface SocketCloseInfo {
14
+ code: number;
15
+ reason: string;
16
+ wasClean: boolean;
17
+ }
18
+ /** 本库依赖的 WebSocket 最小投影。readyState 对齐原生:0=CONNECTING / 1=OPEN / 2=CLOSING / 3=CLOSED。 */
19
+ export interface RealtimeSocket {
20
+ readonly readyState: number;
21
+ send(data: string): void;
22
+ close(code?: number, reason?: string): void;
23
+ onopen: (() => void) | null;
24
+ onmessage: ((data: string) => void) | null;
25
+ onclose: ((info: SocketCloseInfo) => void) | null;
26
+ onerror: (() => void) | null;
27
+ }
28
+ /** 创建 socket 的函数;测试可注入假实现对网络事件做完全控制。 */
29
+ export type SocketFactory = (url: string, protocols: string[]) => RealtimeSocket;
30
+ /** 默认工厂:使用宿主全局 `WebSocket`(浏览器 / Bun / Node ≥22 均可用)。 */
31
+ export declare function defaultSocketFactory(): SocketFactory;
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@adep/client",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "description": "AgentDeploy 实时客户端订阅库(RT-005):换票→建连→订阅→断线自动重连→对称心跳。独立 npm 包,可被 FE-003 卡片渲染等非 Page 场景复用(见任务单 RT-005 归属裁定)。",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "test": "vitest run",
15
+ "build": "bun run ../../scripts/build-package.ts client"
16
+ },
17
+ "dependencies": {
18
+ "@adep/types": "workspace:*"
19
+ },
20
+ "publishConfig": {
21
+ "main": "./dist/index.js",
22
+ "types": "./dist/index.d.ts",
23
+ "exports": {
24
+ ".": {
25
+ "types": "./dist/index.d.ts",
26
+ "default": "./dist/index.js"
27
+ },
28
+ "./*": {
29
+ "types": "./dist/*.d.ts",
30
+ "default": "./dist/*.js"
31
+ }
32
+ }
33
+ }
34
+ }