@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.
@@ -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
+ }
@@ -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 EndpointInstance.send(). This port intentionally owns
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.#unregisterAgent = registerXxxAgentEndpoint(name, this); // agent 注册仍在适配器侧
26
- * try {
27
- * await this.#lifecycle.start(async (handle) => {
28
- * this.#handle = handle; // ws 'close' 回调引用
29
- * await new Promise<void>((resolve, reject) => {
30
- * const ws = createWebSocket(...); this.#ws = ws;
31
- * ws.on('open', () => { this.#lifecycle.startHeartbeat(() => beat(), interval); resolve(); });
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
- * } catch (err) {
37
- * this.#unregisterAgent?.(); this.#unregisterAgent = undefined; // 反注册对称
38
- * throw err;
39
- * }
33
+ * });
40
34
  * ```
41
- * start 失败复位由基座保证;agent 注册/反注册是适配器专有依赖,刻意不收入基座。
35
+ * start 失败复位由基座保证;Client 由 Endpoint 直接持有,不建立旁路 registry。
42
36
  * 3. `stop()` 改为:先 `await this.#lifecycle.stop()`(清定时器 + 强关 ws + 竞态 settle),
43
- * 再做适配器专有清理(rejectAllPending、deduper.clear、agent 反注册)。
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
- /** 注册当前连接的强制关闭函数(心跳看门狗与 stop 使用);每次 connect 覆盖。 */
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; // stop-during-connect 竞态:静默 settle
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; // stop 竞态先于 open
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 === this.#generation) this.#forceClose = close;
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(() => connect(handle));
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 }));
@@ -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';