@zhin.js/adapter 1.2.0 → 1.2.2
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 +47 -1
- package/lib/adapter-index.d.ts +12 -5
- package/lib/adapter-index.js +46 -7
- package/lib/definition.d.ts +41 -26
- package/lib/definition.js +28 -2
- package/lib/endpoint-client.d.ts +62 -0
- package/lib/endpoint-client.js +163 -0
- package/lib/endpoint-control.d.ts +1 -1
- package/lib/endpoint-lifecycle.d.ts +1 -1
- package/lib/endpoint-lifecycle.js +35 -25
- package/lib/endpoint.d.ts +78 -0
- package/lib/endpoint.js +100 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.js +6 -0
- package/lib/provider.d.ts +1 -1
- package/package.json +5 -5
- package/src/adapter-index.ts +52 -12
- package/src/definition.ts +49 -26
- package/src/endpoint-client.ts +250 -0
- package/src/endpoint-control.ts +1 -1
- package/src/endpoint-lifecycle.ts +31 -23
- package/src/endpoint.ts +181 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { CapabilityContext } from '@zhin.js/feature-kit';
|
|
2
|
+
import { isAdapterIndex } from './adapter-index.js';
|
|
3
|
+
import { Endpoint } from './endpoint.js';
|
|
4
|
+
import { adapterFeatureId } from './provider.js';
|
|
5
|
+
|
|
6
|
+
const endpointClientBrand = 'zhin.endpoint-client/1' as const;
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
AdapterClient,
|
|
10
|
+
AdapterClientRegistry,
|
|
11
|
+
AdapterClientTypes,
|
|
12
|
+
AdapterEvents,
|
|
13
|
+
RegisteredAdapterName,
|
|
14
|
+
} from '@zhin.js/feature-kit';
|
|
15
|
+
|
|
16
|
+
/** Convert an EventEmitter-style tuple map into the payload map used by handlers. */
|
|
17
|
+
export type ClientEventPayloads<TEvents extends object> = {
|
|
18
|
+
readonly [K in keyof TEvents]: TEvents[K] extends readonly [infer TPayload, ...unknown[]]
|
|
19
|
+
? TPayload
|
|
20
|
+
: never;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
interface ClientEventSource {
|
|
24
|
+
on(name: string, listener: (payload: unknown) => void): unknown;
|
|
25
|
+
off(name: string, listener: (payload: unknown) => void): unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Forward one Client's public event surface through the Endpoint boundary.
|
|
30
|
+
* The returned disposer is useful when a Client can outlive its Endpoint.
|
|
31
|
+
*/
|
|
32
|
+
export function forwardEndpointClientEvents(
|
|
33
|
+
client: ClientEventSource,
|
|
34
|
+
names: readonly string[],
|
|
35
|
+
receive: (name: string, payload: unknown) => void,
|
|
36
|
+
): () => void {
|
|
37
|
+
const subscriptions = names.map((name) => {
|
|
38
|
+
const listener = (payload: unknown) => receive(name, payload);
|
|
39
|
+
client.on(name, listener);
|
|
40
|
+
return { name, listener };
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
for (const { name, listener } of subscriptions) client.off(name, listener);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ClientEventSubscription = (
|
|
48
|
+
receive: (name: string, payload: unknown) => void,
|
|
49
|
+
) => () => void;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deep Endpoint base for SDK/protocol Clients.
|
|
53
|
+
*
|
|
54
|
+
* It owns the open admission gate and the single Client-event → Endpoint-event
|
|
55
|
+
* bridge. Concrete Endpoints only own account transport and raw-event
|
|
56
|
+
* normalization; they do not repeat dispatch plumbing.
|
|
57
|
+
*/
|
|
58
|
+
export abstract class ClientEndpoint<TClient = unknown> extends Endpoint<TClient> {
|
|
59
|
+
#clientEventsOpen = false;
|
|
60
|
+
#clientEventsRelease?: () => void;
|
|
61
|
+
|
|
62
|
+
open(): void {
|
|
63
|
+
this.#clientEventsOpen = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
close(): void {
|
|
67
|
+
this.#clientEventsOpen = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
protected get clientEventsOpen(): boolean {
|
|
71
|
+
return this.#clientEventsOpen;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
protected bindClientEvents(
|
|
75
|
+
subscribe: ClientEventSubscription,
|
|
76
|
+
receive?: (name: string, payload: unknown) => void,
|
|
77
|
+
onError?: (name: string, error: unknown) => void,
|
|
78
|
+
): void {
|
|
79
|
+
this.#clientEventsRelease?.();
|
|
80
|
+
this.#clientEventsRelease = subscribe((name, payload) => {
|
|
81
|
+
if (!this.#clientEventsOpen) return;
|
|
82
|
+
void this.emitPlatform(name, payload).catch((error) => onError?.(name, error));
|
|
83
|
+
try {
|
|
84
|
+
receive?.(name, payload);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
onError?.(name, error);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
protected releaseClientEvents(): void {
|
|
92
|
+
this.#clientEventsRelease?.();
|
|
93
|
+
this.#clientEventsRelease = undefined;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Typed identity for one platform's native Client surface. */
|
|
98
|
+
export interface EndpointClientToken<TClient, TEvents extends object = Record<string, unknown>> {
|
|
99
|
+
readonly $client: typeof endpointClientBrand;
|
|
100
|
+
readonly adapter: string;
|
|
101
|
+
/** @internal Type-only covariance anchor. */
|
|
102
|
+
readonly _client?: TClient;
|
|
103
|
+
/** @internal Type-only covariance anchor for native platform events. */
|
|
104
|
+
readonly _events?: TEvents;
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the Client from an operation context. Current inbound operations
|
|
107
|
+
* infer their Endpoint; detached operations must provide `endpointKey`.
|
|
108
|
+
*/
|
|
109
|
+
get(context: EndpointClientContext, endpointKey?: string): TClient;
|
|
110
|
+
/** Resolve when this operation belongs to the platform; otherwise return undefined. */
|
|
111
|
+
find(context: EndpointClientContext, endpointKey?: string): TClient | undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Operation-scoped sources that can resolve an Endpoint Client. */
|
|
115
|
+
export interface EndpointClientContext {
|
|
116
|
+
readonly project?: CapabilityContext['project'];
|
|
117
|
+
readonly message?: unknown;
|
|
118
|
+
readonly input?: unknown;
|
|
119
|
+
readonly endpoint?: string;
|
|
120
|
+
readonly origin?: unknown;
|
|
121
|
+
readonly conversation?: unknown;
|
|
122
|
+
readonly $client?: unknown;
|
|
123
|
+
readonly clientAdapter?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Declare the native Client type exported by a platform adapter. */
|
|
127
|
+
export function defineEndpointClient<
|
|
128
|
+
TClient,
|
|
129
|
+
TEvents extends object = Record<string, unknown>,
|
|
130
|
+
>(adapter: string): EndpointClientToken<TClient, TEvents> {
|
|
131
|
+
const normalized = adapter.trim();
|
|
132
|
+
if (!normalized) throw new TypeError('Endpoint Client adapter cannot be empty');
|
|
133
|
+
const token: EndpointClientToken<TClient, TEvents> = {
|
|
134
|
+
$client: endpointClientBrand,
|
|
135
|
+
adapter: normalized,
|
|
136
|
+
get(context, endpointKey) {
|
|
137
|
+
return resolveEndpointClient(context, token, endpointKey);
|
|
138
|
+
},
|
|
139
|
+
find(context, endpointKey) {
|
|
140
|
+
return findEndpointClient(context, token, endpointKey);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
return Object.freeze(token);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve a platform Client from the current generation.
|
|
148
|
+
*
|
|
149
|
+
* The returned object is valid only for the lifetime of `context`; callers
|
|
150
|
+
* must not retain it beyond the current command, handler, tool, task, or
|
|
151
|
+
* schedule operation.
|
|
152
|
+
*/
|
|
153
|
+
function resolveEndpointClient<TClient, TEvents extends object>(
|
|
154
|
+
context: EndpointClientContext,
|
|
155
|
+
token: EndpointClientToken<TClient, TEvents>,
|
|
156
|
+
endpointKey?: string,
|
|
157
|
+
): TClient {
|
|
158
|
+
if (token.$client !== endpointClientBrand) {
|
|
159
|
+
throw new TypeError('Invalid Endpoint Client token');
|
|
160
|
+
}
|
|
161
|
+
const current = currentClientSource(context);
|
|
162
|
+
if (current && '$client' in current) {
|
|
163
|
+
if (endpointKey && !matchesCurrentEndpoint(current, endpointKey)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Endpoint Client ${endpointKey} does not match the current operation Endpoint`,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (current.clientAdapter && current.clientAdapter !== token.adapter) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`Endpoint Client ${token.adapter} cannot access ${current.clientAdapter}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return current.$client as TClient;
|
|
174
|
+
}
|
|
175
|
+
const resolvedEndpointKey = endpointKey ?? endpointKeyFromContext(context);
|
|
176
|
+
if (!resolvedEndpointKey) {
|
|
177
|
+
throw new Error('Detached Endpoint Client access requires an explicit endpoint key');
|
|
178
|
+
}
|
|
179
|
+
if (!context.project) {
|
|
180
|
+
throw new Error('Endpoint Client access requires a generation operation context');
|
|
181
|
+
}
|
|
182
|
+
const projection = context.project<unknown>(adapterFeatureId);
|
|
183
|
+
if (!isAdapterIndex(projection)) {
|
|
184
|
+
throw new Error('Adapter Feature projection is not installed');
|
|
185
|
+
}
|
|
186
|
+
return projection.client<TClient>(token.adapter, resolvedEndpointKey);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface CurrentClientSource {
|
|
190
|
+
readonly endpointId?: string;
|
|
191
|
+
readonly clientAdapter?: string;
|
|
192
|
+
readonly conversation?: unknown;
|
|
193
|
+
readonly $client?: unknown;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function findEndpointClient<TClient, TEvents extends object>(
|
|
197
|
+
context: EndpointClientContext,
|
|
198
|
+
token: EndpointClientToken<TClient, TEvents>,
|
|
199
|
+
endpointKey?: string,
|
|
200
|
+
): TClient | undefined {
|
|
201
|
+
const current = currentClientSource(context);
|
|
202
|
+
if (current && '$client' in current) {
|
|
203
|
+
if (current.clientAdapter && current.clientAdapter !== token.adapter) return undefined;
|
|
204
|
+
if (endpointKey && !matchesCurrentEndpoint(current, endpointKey)) return undefined;
|
|
205
|
+
try {
|
|
206
|
+
return current.$client as TClient;
|
|
207
|
+
} catch {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const resolvedEndpointKey = endpointKey ?? endpointKeyFromContext(context);
|
|
212
|
+
if (!resolvedEndpointKey || !context.project) return undefined;
|
|
213
|
+
const projection = context.project<unknown>(adapterFeatureId);
|
|
214
|
+
if (!isAdapterIndex(projection)) return undefined;
|
|
215
|
+
return projection.findClient<TClient>(token.adapter, resolvedEndpointKey);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function currentClientSource(context: EndpointClientContext): CurrentClientSource | undefined {
|
|
219
|
+
for (const candidate of [context, context.message, context.input]) {
|
|
220
|
+
if (candidate && typeof candidate === 'object'
|
|
221
|
+
&& ('$client' in candidate || 'clientAdapter' in candidate)) {
|
|
222
|
+
return candidate as CurrentClientSource;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function matchesCurrentEndpoint(source: CurrentClientSource, endpointKey: string): boolean {
|
|
229
|
+
const candidates = [source.endpointId, conversationEndpointId(source.conversation)]
|
|
230
|
+
.filter((value): value is string => typeof value === 'string' && value.length > 0);
|
|
231
|
+
return candidates.length === 0 || candidates.includes(endpointKey);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function endpointKeyFromContext(context: EndpointClientContext): string | undefined {
|
|
235
|
+
if (typeof context.endpoint === 'string' && context.endpoint.length > 0) return context.endpoint;
|
|
236
|
+
const origin = context.origin as { readonly kind?: unknown; readonly endpoint?: unknown } | undefined;
|
|
237
|
+
if (origin?.kind === 'im' && typeof origin.endpoint === 'string' && origin.endpoint.length > 0) {
|
|
238
|
+
return origin.endpoint;
|
|
239
|
+
}
|
|
240
|
+
return conversationEndpointId(context.conversation)
|
|
241
|
+
?? conversationEndpointId((context.input as { readonly conversation?: unknown } | undefined)?.conversation);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function conversationEndpointId(value: unknown): string | undefined {
|
|
245
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
246
|
+
const endpoint = (value as { readonly endpoint?: unknown }).endpoint;
|
|
247
|
+
if (!endpoint || typeof endpoint !== 'object') return undefined;
|
|
248
|
+
const id = (endpoint as { readonly id?: unknown }).id;
|
|
249
|
+
return typeof id === 'string' && id.length > 0 ? id : undefined;
|
|
250
|
+
}
|
package/src/endpoint-control.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { ConversationRef, MessageRef } from '@zhin.js/im-contract';
|
|
|
3
3
|
/**
|
|
4
4
|
* Transport-neutral control plane for a live endpoint.
|
|
5
5
|
*
|
|
6
|
-
* Sending belongs to
|
|
6
|
+
* Sending belongs to Endpoint.send(). This port intentionally owns
|
|
7
7
|
* operations that address an existing platform message, so Core never needs
|
|
8
8
|
* to know a protocol's method names or identifier layout.
|
|
9
9
|
*/
|
|
@@ -22,25 +22,19 @@
|
|
|
22
22
|
* 构造器里 `this.#lifecycle = createEndpointLifecycle({ name: config.id, reconnect, heartbeat })`。
|
|
23
23
|
* 2. `start()` 改为:
|
|
24
24
|
* ```ts
|
|
25
|
-
* this.#
|
|
26
|
-
*
|
|
27
|
-
* await
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* ws.on('close', (code, reason) => { handle.notifyClosed(...); rejectIfNotSettled(...); });
|
|
33
|
-
* ws.on('error', (err) => rejectIfNotSettled(err));
|
|
34
|
-
* });
|
|
25
|
+
* await this.#lifecycle.start(async (handle) => {
|
|
26
|
+
* this.#handle = handle; // 供 ws 'close' 回调引用
|
|
27
|
+
* await new Promise<void>((resolve, reject) => {
|
|
28
|
+
* const ws = createWebSocket(...); this.#ws = ws;
|
|
29
|
+
* ws.on('open', () => { this.#lifecycle.startHeartbeat(() => beat(), interval); resolve(); });
|
|
30
|
+
* ws.on('close', (code, reason) => { handle.notifyClosed(...); rejectIfNotSettled(...); });
|
|
31
|
+
* ws.on('error', (err) => rejectIfNotSettled(err));
|
|
35
32
|
* });
|
|
36
|
-
* }
|
|
37
|
-
* this.#unregisterAgent?.(); this.#unregisterAgent = undefined; // 反注册对称
|
|
38
|
-
* throw err;
|
|
39
|
-
* }
|
|
33
|
+
* });
|
|
40
34
|
* ```
|
|
41
|
-
* start 失败复位由基座保证;
|
|
35
|
+
* start 失败复位由基座保证;Client 由 Endpoint 直接持有,不建立旁路 registry。
|
|
42
36
|
* 3. `stop()` 改为:先 `await this.#lifecycle.stop()`(清定时器 + 强关 ws + 竞态 settle),
|
|
43
|
-
* 再做适配器专有清理(rejectAllPending、deduper.clear
|
|
37
|
+
* 再做适配器专有清理(rejectAllPending、deduper.clear)。
|
|
44
38
|
* 4. `handle.onForceClose(() => this.#ws?.close())` 在每次拿到新 socket 后注册,
|
|
45
39
|
* 供心跳看门狗主动断开;ws 'message'/'pong' 回调里调 `notifyHeartbeatAck()` 喂狗。
|
|
46
40
|
* 5. 退避参数由配置映射:reconnect_interval → initialIntervalMs,可按需覆盖
|
|
@@ -105,7 +99,7 @@ export interface EndpointConnectHandle {
|
|
|
105
99
|
* 初始连接失败由 start() 的拒绝路径复位,不武装重连。
|
|
106
100
|
*/
|
|
107
101
|
notifyClosed(reason?: unknown): void;
|
|
108
|
-
/**
|
|
102
|
+
/** 注册当前连接的强制关闭函数;同代已 stop 时立即关闭,旧代句柄忽略。 */
|
|
109
103
|
onForceClose(close: () => void): void;
|
|
110
104
|
}
|
|
111
105
|
|
|
@@ -203,16 +197,17 @@ class EndpointLifecycleImpl implements EndpointLifecycle {
|
|
|
203
197
|
this.#connect = connect;
|
|
204
198
|
this.#state = 'connecting';
|
|
205
199
|
this.#attempt = 0;
|
|
200
|
+
const generation = this.#generation + 1;
|
|
206
201
|
try {
|
|
207
202
|
await this.#runConnect(connect);
|
|
208
203
|
} catch (err) {
|
|
209
204
|
// 注意:stop() 可能在 await 期间并发改写 #state,必须经 getter 读取避免 TS 窄化误判
|
|
210
|
-
if (this.#currentState() === 'stopped') return;
|
|
205
|
+
if (generation !== this.#generation || this.#currentState() === 'stopped') return;
|
|
211
206
|
// start 失败复位:回 idle、不武装重连,允许调用方重试
|
|
212
207
|
this.#state = 'idle';
|
|
213
208
|
throw err;
|
|
214
209
|
}
|
|
215
|
-
if (this.#currentState() === 'stopped') return;
|
|
210
|
+
if (generation !== this.#generation || this.#currentState() === 'stopped') return;
|
|
216
211
|
this.#state = 'open';
|
|
217
212
|
}
|
|
218
213
|
|
|
@@ -316,7 +311,16 @@ class EndpointLifecycleImpl implements EndpointLifecycle {
|
|
|
316
311
|
this.#scheduleReconnect();
|
|
317
312
|
},
|
|
318
313
|
onForceClose: (close) => {
|
|
319
|
-
if (generation
|
|
314
|
+
if (generation !== this.#generation) return;
|
|
315
|
+
if (this.#state === 'stopped') {
|
|
316
|
+
try {
|
|
317
|
+
close();
|
|
318
|
+
} catch {
|
|
319
|
+
/* same best-effort cleanup as stop() */
|
|
320
|
+
}
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
this.#forceClose = close;
|
|
320
324
|
},
|
|
321
325
|
};
|
|
322
326
|
}
|
|
@@ -327,7 +331,10 @@ class EndpointLifecycleImpl implements EndpointLifecycle {
|
|
|
327
331
|
this.#forceClose = undefined;
|
|
328
332
|
const handle = this.#createHandle(generation);
|
|
329
333
|
// Promise.resolve().then 兜底同步抛错;额外 catch 防止 stop 竞态后迟到拒绝变 unhandled
|
|
330
|
-
const connecting = Promise.resolve().then(() =>
|
|
334
|
+
const connecting = Promise.resolve().then(() => {
|
|
335
|
+
if (generation !== this.#generation || this.#state === 'stopped') return;
|
|
336
|
+
return connect(handle);
|
|
337
|
+
});
|
|
331
338
|
connecting.catch(() => { /* settled via race; late rejection ignored */ });
|
|
332
339
|
let wake!: () => void;
|
|
333
340
|
const stopped = new Promise<void>((resolve) => {
|
|
@@ -381,10 +388,11 @@ class EndpointLifecycleImpl implements EndpointLifecycle {
|
|
|
381
388
|
}));
|
|
382
389
|
const elapsed = await this.#sleep(delay);
|
|
383
390
|
if (!elapsed || this.#currentState() !== 'reconnecting') return;
|
|
391
|
+
const generation = this.#generation + 1;
|
|
384
392
|
try {
|
|
385
393
|
await this.#runConnect(connect);
|
|
386
394
|
} catch (err) {
|
|
387
|
-
if (this.#currentState() === 'stopped') return;
|
|
395
|
+
if (generation !== this.#generation || this.#currentState() === 'stopped') return;
|
|
388
396
|
this.#attempt += 1;
|
|
389
397
|
logger.debug(formatCompact({
|
|
390
398
|
op: 'reconnect',
|
|
@@ -395,7 +403,7 @@ class EndpointLifecycleImpl implements EndpointLifecycle {
|
|
|
395
403
|
}));
|
|
396
404
|
continue;
|
|
397
405
|
}
|
|
398
|
-
if (this.#currentState() === 'stopped') return;
|
|
406
|
+
if (generation !== this.#generation || this.#currentState() === 'stopped') return;
|
|
399
407
|
this.#state = 'open';
|
|
400
408
|
this.#attempt = 0;
|
|
401
409
|
logger.info(formatCompact({ op: 'reconnect', endpoint: this.#name, ok: true }));
|
package/src/endpoint.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createToken,
|
|
3
|
+
type CapabilityId,
|
|
4
|
+
type GenerationAdmissionGate,
|
|
5
|
+
} from '@zhin.js/plugin-runtime';
|
|
6
|
+
import type { AdapterContext, EndpointSendRequest } from './definition.js';
|
|
7
|
+
import type { EndpointManagement } from './endpoint-management.js';
|
|
8
|
+
import type { EndpointControl } from './endpoint-control.js';
|
|
9
|
+
import type { EndpointContentPort } from './endpoint-content.js';
|
|
10
|
+
|
|
11
|
+
/** The one inbound event boundary shared by every platform Endpoint. */
|
|
12
|
+
export interface EndpointEventGateway {
|
|
13
|
+
receive(event: EndpointEvent): Promise<unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Callback shape used by protocol normalizers which feed Endpoint.emit(). */
|
|
17
|
+
export type EndpointEventEmitter = <TPayload>(name: string, payload: TPayload) => Promise<unknown>;
|
|
18
|
+
|
|
19
|
+
/** Generation-bound gateway injected into Endpoint by AdapterIndex. */
|
|
20
|
+
export const endpointEventGatewayToken = createToken<EndpointEventGateway>(
|
|
21
|
+
'zhin.adapter.endpoint-events',
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
/** Stable identity of the Endpoint which produced an event. */
|
|
25
|
+
export interface EndpointIdentity {
|
|
26
|
+
readonly id: CapabilityId;
|
|
27
|
+
readonly adapter: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The single event context delivered from an Endpoint into Core and plugins.
|
|
32
|
+
* `client` is the actual platform SDK/protocol client owned by the Endpoint.
|
|
33
|
+
*/
|
|
34
|
+
export interface EndpointEvent<
|
|
35
|
+
TPayload = unknown,
|
|
36
|
+
TClient = unknown,
|
|
37
|
+
TName extends string = string,
|
|
38
|
+
> {
|
|
39
|
+
readonly name: TName;
|
|
40
|
+
readonly payload: TPayload;
|
|
41
|
+
readonly endpoint: EndpointIdentity;
|
|
42
|
+
readonly client: TClient;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Lossless native event delivered before any optional canonical projection. */
|
|
46
|
+
export interface PlatformEvent<
|
|
47
|
+
TEvent = unknown,
|
|
48
|
+
TName extends string = string,
|
|
49
|
+
> {
|
|
50
|
+
/** Native SDK/protocol event name, for example `guild_member_add`. */
|
|
51
|
+
readonly name: TName;
|
|
52
|
+
/** Native SDK/protocol payload without canonicalization. */
|
|
53
|
+
readonly event: TEvent;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const endpointBrand = Symbol.for('zhin.adapter.endpoint/1');
|
|
57
|
+
const endpointBind = Symbol.for('zhin.adapter.endpoint-bind/1');
|
|
58
|
+
const PRE_ADMISSION_EVENT_LIMIT = 256;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Deep platform boundary.
|
|
62
|
+
*
|
|
63
|
+
* Platform implementations inherit this class, expose the platform-native
|
|
64
|
+
* `client`, own account/transport lifecycle, and normalize every inbound SDK
|
|
65
|
+
* callback through `emit()`. Core owns dispatch, admission and plugin context.
|
|
66
|
+
*/
|
|
67
|
+
export abstract class Endpoint<TClient = unknown> {
|
|
68
|
+
readonly [endpointBrand] = true;
|
|
69
|
+
/**
|
|
70
|
+
* Platform SDK or protocol client exposed to plugin code.
|
|
71
|
+
* It must be a distinct object: Endpoint owns framework lifecycle, Client
|
|
72
|
+
* owns platform operations.
|
|
73
|
+
*/
|
|
74
|
+
abstract readonly client: TClient;
|
|
75
|
+
|
|
76
|
+
readonly management?: EndpointManagement;
|
|
77
|
+
readonly control?: EndpointControl;
|
|
78
|
+
readonly content?: EndpointContentPort;
|
|
79
|
+
|
|
80
|
+
#identity?: EndpointIdentity;
|
|
81
|
+
#events?: EndpointEventGateway;
|
|
82
|
+
#admissionState: 'unbound' | 'pending' | 'active' | 'retired' = 'unbound';
|
|
83
|
+
#pendingEvents: EndpointEvent[] = [];
|
|
84
|
+
|
|
85
|
+
/** @internal Bound exactly once by the generation-owned AdapterIndex. */
|
|
86
|
+
[endpointBind](context: AdapterContext, admission?: GenerationAdmissionGate): void {
|
|
87
|
+
if (this.#events) throw new Error(`Endpoint ${context.id} is already bound`);
|
|
88
|
+
this.#identity = Object.freeze({ id: context.id, adapter: context.name });
|
|
89
|
+
this.#events = context.use(endpointEventGatewayToken);
|
|
90
|
+
if (!admission) {
|
|
91
|
+
this.#admissionState = 'active';
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.#admissionState = 'pending';
|
|
95
|
+
admission.onActivate(() => {
|
|
96
|
+
if (this.#admissionState !== 'pending') return;
|
|
97
|
+
this.#admissionState = 'active';
|
|
98
|
+
admission.onDeactivate(() => {
|
|
99
|
+
this.#admissionState = 'retired';
|
|
100
|
+
this.#pendingEvents.length = 0;
|
|
101
|
+
});
|
|
102
|
+
const pending = this.#pendingEvents.splice(0);
|
|
103
|
+
for (const event of pending) {
|
|
104
|
+
void this.#events?.receive(event).catch(() => undefined);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The identity is available after AdapterDefinition.create returns. */
|
|
110
|
+
get identity(): EndpointIdentity {
|
|
111
|
+
if (!this.#identity) throw new Error('Endpoint is not bound to a runtime generation');
|
|
112
|
+
return this.#identity;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The only legal platform-to-framework event ingress. */
|
|
116
|
+
protected emit<TPayload, TName extends string>(
|
|
117
|
+
name: TName,
|
|
118
|
+
payload: TPayload,
|
|
119
|
+
): Promise<unknown> {
|
|
120
|
+
if (!this.#events || !this.#identity) {
|
|
121
|
+
throw new Error('Endpoint emitted before it was bound to a runtime generation');
|
|
122
|
+
}
|
|
123
|
+
const event = Object.freeze({
|
|
124
|
+
name,
|
|
125
|
+
payload,
|
|
126
|
+
endpoint: this.#identity,
|
|
127
|
+
client: this.client,
|
|
128
|
+
});
|
|
129
|
+
if (this.#admissionState === 'pending') {
|
|
130
|
+
if (this.#pendingEvents.length >= PRE_ADMISSION_EVENT_LIMIT) {
|
|
131
|
+
this.#pendingEvents.shift();
|
|
132
|
+
}
|
|
133
|
+
this.#pendingEvents.push(event);
|
|
134
|
+
return Promise.resolve(undefined);
|
|
135
|
+
}
|
|
136
|
+
if (this.#admissionState === 'retired') return Promise.resolve(undefined);
|
|
137
|
+
return this.#events.receive(event);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Lossless native-event projection. Adapters call this before deriving
|
|
142
|
+
* message/notice/request/system events, including for unknown event kinds.
|
|
143
|
+
*/
|
|
144
|
+
protected emitPlatform<TEvent, TName extends string>(
|
|
145
|
+
name: TName,
|
|
146
|
+
event: TEvent,
|
|
147
|
+
): Promise<unknown> {
|
|
148
|
+
return this.emit('platform.receive', Object.freeze({ name, event }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
abstract start(signal: AbortSignal): void | Promise<void>;
|
|
152
|
+
abstract open(): void;
|
|
153
|
+
abstract close(): void | Promise<void>;
|
|
154
|
+
abstract stop(): void | Promise<void>;
|
|
155
|
+
send?(_request: EndpointSendRequest): string | Promise<string>;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @internal AdapterIndex binding hook; deliberately not exported by name. */
|
|
159
|
+
export function bindEndpoint(
|
|
160
|
+
endpoint: Endpoint,
|
|
161
|
+
context: AdapterContext,
|
|
162
|
+
admission?: GenerationAdmissionGate,
|
|
163
|
+
): void {
|
|
164
|
+
endpoint[endpointBind](context, admission);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** @internal Cross-generation Endpoint check that survives ESM module re-evaluation. */
|
|
168
|
+
export function isEndpoint(value: unknown): value is Endpoint {
|
|
169
|
+
if (!value || typeof value !== 'object') return false;
|
|
170
|
+
const candidate = value as Partial<Endpoint> & {
|
|
171
|
+
readonly [endpointBrand]?: unknown;
|
|
172
|
+
readonly [endpointBind]?: unknown;
|
|
173
|
+
};
|
|
174
|
+
return candidate[endpointBrand] === true
|
|
175
|
+
&& typeof candidate[endpointBind] === 'function'
|
|
176
|
+
&& typeof candidate.start === 'function'
|
|
177
|
+
&& typeof candidate.open === 'function'
|
|
178
|
+
&& typeof candidate.close === 'function'
|
|
179
|
+
&& typeof candidate.stop === 'function'
|
|
180
|
+
&& 'client' in candidate;
|
|
181
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter authoring contracts and platform-neutral Endpoint capabilities.
|
|
3
|
+
* @module @zhin.js/adapter
|
|
4
|
+
*/
|
|
1
5
|
/** @internal 适配器 projection(AdapterIndex),框架内部机制,不承诺不 break。 */
|
|
2
6
|
export * from './adapter-index.js';
|
|
3
7
|
export * from './credentials.js';
|
|
4
8
|
/** @public 用户侧创作面:`defineAdapter`(`adapters/` 约定目录默认导出,承诺 semver)。 */
|
|
5
9
|
export * from './definition.js';
|
|
10
|
+
export * from './endpoint.js';
|
|
6
11
|
export * from './endpoint-commands.js';
|
|
7
12
|
export * from './endpoint-lifecycle.js';
|
|
8
13
|
export * from './endpoint-management.js';
|
|
9
14
|
export * from './endpoint-control.js';
|
|
10
15
|
export * from './endpoint-content.js';
|
|
16
|
+
export * from './endpoint-client.js';
|
|
11
17
|
export * from './provider.js';
|
|
12
18
|
export { default } from './provider.js';
|