@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.
@@ -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,可按需覆盖
@@ -108,19 +102,20 @@ class EndpointLifecycleImpl {
108
102
  this.#connect = connect;
109
103
  this.#state = 'connecting';
110
104
  this.#attempt = 0;
105
+ const generation = this.#generation + 1;
111
106
  try {
112
107
  await this.#runConnect(connect);
113
108
  }
114
109
  catch (err) {
115
110
  // 注意:stop() 可能在 await 期间并发改写 #state,必须经 getter 读取避免 TS 窄化误判
116
- if (this.#currentState() === 'stopped')
117
- return; // stop-during-connect 竞态:静默 settle
111
+ if (generation !== this.#generation || this.#currentState() === 'stopped')
112
+ return;
118
113
  // start 失败复位:回 idle、不武装重连,允许调用方重试
119
114
  this.#state = 'idle';
120
115
  throw err;
121
116
  }
122
- if (this.#currentState() === 'stopped')
123
- return; // stop 竞态先于 open
117
+ if (generation !== this.#generation || this.#currentState() === 'stopped')
118
+ return;
124
119
  this.#state = 'open';
125
120
  }
126
121
  async stop() {
@@ -226,8 +221,18 @@ class EndpointLifecycleImpl {
226
221
  this.#scheduleReconnect();
227
222
  },
228
223
  onForceClose: (close) => {
229
- if (generation === this.#generation)
230
- this.#forceClose = close;
224
+ if (generation !== this.#generation)
225
+ return;
226
+ if (this.#state === 'stopped') {
227
+ try {
228
+ close();
229
+ }
230
+ catch {
231
+ /* same best-effort cleanup as stop() */
232
+ }
233
+ return;
234
+ }
235
+ this.#forceClose = close;
231
236
  },
232
237
  };
233
238
  }
@@ -237,7 +242,11 @@ class EndpointLifecycleImpl {
237
242
  this.#forceClose = undefined;
238
243
  const handle = this.#createHandle(generation);
239
244
  // Promise.resolve().then 兜底同步抛错;额外 catch 防止 stop 竞态后迟到拒绝变 unhandled
240
- const connecting = Promise.resolve().then(() => connect(handle));
245
+ const connecting = Promise.resolve().then(() => {
246
+ if (generation !== this.#generation || this.#state === 'stopped')
247
+ return;
248
+ return connect(handle);
249
+ });
241
250
  connecting.catch(() => { });
242
251
  let wake;
243
252
  const stopped = new Promise((resolve) => {
@@ -291,11 +300,12 @@ class EndpointLifecycleImpl {
291
300
  const elapsed = await this.#sleep(delay);
292
301
  if (!elapsed || this.#currentState() !== 'reconnecting')
293
302
  return;
303
+ const generation = this.#generation + 1;
294
304
  try {
295
305
  await this.#runConnect(connect);
296
306
  }
297
307
  catch (err) {
298
- if (this.#currentState() === 'stopped')
308
+ if (generation !== this.#generation || this.#currentState() === 'stopped')
299
309
  return;
300
310
  this.#attempt += 1;
301
311
  logger.debug(formatCompact({
@@ -307,7 +317,7 @@ class EndpointLifecycleImpl {
307
317
  }));
308
318
  continue;
309
319
  }
310
- if (this.#currentState() === 'stopped')
320
+ if (generation !== this.#generation || this.#currentState() === 'stopped')
311
321
  return;
312
322
  this.#state = 'open';
313
323
  this.#attempt = 0;
@@ -0,0 +1,78 @@
1
+ import { type CapabilityId, type GenerationAdmissionGate } from '@zhin.js/plugin-runtime';
2
+ import type { AdapterContext, EndpointSendRequest } from './definition.js';
3
+ import type { EndpointManagement } from './endpoint-management.js';
4
+ import type { EndpointControl } from './endpoint-control.js';
5
+ import type { EndpointContentPort } from './endpoint-content.js';
6
+ /** The one inbound event boundary shared by every platform Endpoint. */
7
+ export interface EndpointEventGateway {
8
+ receive(event: EndpointEvent): Promise<unknown>;
9
+ }
10
+ /** Callback shape used by protocol normalizers which feed Endpoint.emit(). */
11
+ export type EndpointEventEmitter = <TPayload>(name: string, payload: TPayload) => Promise<unknown>;
12
+ /** Generation-bound gateway injected into Endpoint by AdapterIndex. */
13
+ export declare const endpointEventGatewayToken: import("@zhin.js/plugin-runtime").Token<EndpointEventGateway>;
14
+ /** Stable identity of the Endpoint which produced an event. */
15
+ export interface EndpointIdentity {
16
+ readonly id: CapabilityId;
17
+ readonly adapter: string;
18
+ }
19
+ /**
20
+ * The single event context delivered from an Endpoint into Core and plugins.
21
+ * `client` is the actual platform SDK/protocol client owned by the Endpoint.
22
+ */
23
+ export interface EndpointEvent<TPayload = unknown, TClient = unknown, TName extends string = string> {
24
+ readonly name: TName;
25
+ readonly payload: TPayload;
26
+ readonly endpoint: EndpointIdentity;
27
+ readonly client: TClient;
28
+ }
29
+ /** Lossless native event delivered before any optional canonical projection. */
30
+ export interface PlatformEvent<TEvent = unknown, TName extends string = string> {
31
+ /** Native SDK/protocol event name, for example `guild_member_add`. */
32
+ readonly name: TName;
33
+ /** Native SDK/protocol payload without canonicalization. */
34
+ readonly event: TEvent;
35
+ }
36
+ declare const endpointBrand: unique symbol;
37
+ declare const endpointBind: unique symbol;
38
+ /**
39
+ * Deep platform boundary.
40
+ *
41
+ * Platform implementations inherit this class, expose the platform-native
42
+ * `client`, own account/transport lifecycle, and normalize every inbound SDK
43
+ * callback through `emit()`. Core owns dispatch, admission and plugin context.
44
+ */
45
+ export declare abstract class Endpoint<TClient = unknown> {
46
+ #private;
47
+ readonly [endpointBrand] = true;
48
+ /**
49
+ * Platform SDK or protocol client exposed to plugin code.
50
+ * It must be a distinct object: Endpoint owns framework lifecycle, Client
51
+ * owns platform operations.
52
+ */
53
+ abstract readonly client: TClient;
54
+ readonly management?: EndpointManagement;
55
+ readonly control?: EndpointControl;
56
+ readonly content?: EndpointContentPort;
57
+ /** @internal Bound exactly once by the generation-owned AdapterIndex. */
58
+ [endpointBind](context: AdapterContext, admission?: GenerationAdmissionGate): void;
59
+ /** The identity is available after AdapterDefinition.create returns. */
60
+ get identity(): EndpointIdentity;
61
+ /** The only legal platform-to-framework event ingress. */
62
+ protected emit<TPayload, TName extends string>(name: TName, payload: TPayload): Promise<unknown>;
63
+ /**
64
+ * Lossless native-event projection. Adapters call this before deriving
65
+ * message/notice/request/system events, including for unknown event kinds.
66
+ */
67
+ protected emitPlatform<TEvent, TName extends string>(name: TName, event: TEvent): Promise<unknown>;
68
+ abstract start(signal: AbortSignal): void | Promise<void>;
69
+ abstract open(): void;
70
+ abstract close(): void | Promise<void>;
71
+ abstract stop(): void | Promise<void>;
72
+ send?(_request: EndpointSendRequest): string | Promise<string>;
73
+ }
74
+ /** @internal AdapterIndex binding hook; deliberately not exported by name. */
75
+ export declare function bindEndpoint(endpoint: Endpoint, context: AdapterContext, admission?: GenerationAdmissionGate): void;
76
+ /** @internal Cross-generation Endpoint check that survives ESM module re-evaluation. */
77
+ export declare function isEndpoint(value: unknown): value is Endpoint;
78
+ export {};
@@ -0,0 +1,100 @@
1
+ import { createToken, } from '@zhin.js/plugin-runtime';
2
+ /** Generation-bound gateway injected into Endpoint by AdapterIndex. */
3
+ export const endpointEventGatewayToken = createToken('zhin.adapter.endpoint-events');
4
+ const endpointBrand = Symbol.for('zhin.adapter.endpoint/1');
5
+ const endpointBind = Symbol.for('zhin.adapter.endpoint-bind/1');
6
+ const PRE_ADMISSION_EVENT_LIMIT = 256;
7
+ /**
8
+ * Deep platform boundary.
9
+ *
10
+ * Platform implementations inherit this class, expose the platform-native
11
+ * `client`, own account/transport lifecycle, and normalize every inbound SDK
12
+ * callback through `emit()`. Core owns dispatch, admission and plugin context.
13
+ */
14
+ export class Endpoint {
15
+ [endpointBrand] = true;
16
+ management;
17
+ control;
18
+ content;
19
+ #identity;
20
+ #events;
21
+ #admissionState = 'unbound';
22
+ #pendingEvents = [];
23
+ /** @internal Bound exactly once by the generation-owned AdapterIndex. */
24
+ [endpointBind](context, admission) {
25
+ if (this.#events)
26
+ throw new Error(`Endpoint ${context.id} is already bound`);
27
+ this.#identity = Object.freeze({ id: context.id, adapter: context.name });
28
+ this.#events = context.use(endpointEventGatewayToken);
29
+ if (!admission) {
30
+ this.#admissionState = 'active';
31
+ return;
32
+ }
33
+ this.#admissionState = 'pending';
34
+ admission.onActivate(() => {
35
+ if (this.#admissionState !== 'pending')
36
+ return;
37
+ this.#admissionState = 'active';
38
+ admission.onDeactivate(() => {
39
+ this.#admissionState = 'retired';
40
+ this.#pendingEvents.length = 0;
41
+ });
42
+ const pending = this.#pendingEvents.splice(0);
43
+ for (const event of pending) {
44
+ void this.#events?.receive(event).catch(() => undefined);
45
+ }
46
+ });
47
+ }
48
+ /** The identity is available after AdapterDefinition.create returns. */
49
+ get identity() {
50
+ if (!this.#identity)
51
+ throw new Error('Endpoint is not bound to a runtime generation');
52
+ return this.#identity;
53
+ }
54
+ /** The only legal platform-to-framework event ingress. */
55
+ emit(name, payload) {
56
+ if (!this.#events || !this.#identity) {
57
+ throw new Error('Endpoint emitted before it was bound to a runtime generation');
58
+ }
59
+ const event = Object.freeze({
60
+ name,
61
+ payload,
62
+ endpoint: this.#identity,
63
+ client: this.client,
64
+ });
65
+ if (this.#admissionState === 'pending') {
66
+ if (this.#pendingEvents.length >= PRE_ADMISSION_EVENT_LIMIT) {
67
+ this.#pendingEvents.shift();
68
+ }
69
+ this.#pendingEvents.push(event);
70
+ return Promise.resolve(undefined);
71
+ }
72
+ if (this.#admissionState === 'retired')
73
+ return Promise.resolve(undefined);
74
+ return this.#events.receive(event);
75
+ }
76
+ /**
77
+ * Lossless native-event projection. Adapters call this before deriving
78
+ * message/notice/request/system events, including for unknown event kinds.
79
+ */
80
+ emitPlatform(name, event) {
81
+ return this.emit('platform.receive', Object.freeze({ name, event }));
82
+ }
83
+ }
84
+ /** @internal AdapterIndex binding hook; deliberately not exported by name. */
85
+ export function bindEndpoint(endpoint, context, admission) {
86
+ endpoint[endpointBind](context, admission);
87
+ }
88
+ /** @internal Cross-generation Endpoint check that survives ESM module re-evaluation. */
89
+ export function isEndpoint(value) {
90
+ if (!value || typeof value !== 'object')
91
+ return false;
92
+ const candidate = value;
93
+ return candidate[endpointBrand] === true
94
+ && typeof candidate[endpointBind] === 'function'
95
+ && typeof candidate.start === 'function'
96
+ && typeof candidate.open === 'function'
97
+ && typeof candidate.close === 'function'
98
+ && typeof candidate.stop === 'function'
99
+ && 'client' in candidate;
100
+ }
package/lib/index.d.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';
package/lib/index.js 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';
package/lib/provider.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { AdapterIndex } from './adapter-index.js';
2
2
  export declare const adapterFeatureId: import("@zhin.js/plugin-runtime").FeatureId;
3
- declare const adapterFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<import("./definition.js").AdapterDefinition<unknown>, AdapterIndex>>;
3
+ declare const adapterFeature: Readonly<import("@zhin.js/feature-kit").FeatureProvider<import("./definition.js").AdapterDefinition<unknown, unknown>, AdapterIndex>>;
4
4
  export { adapterFeature };
5
5
  export default adapterFeature;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,15 +18,15 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "yaml": "^2.9.0",
21
- "@zhin.js/feature-kit": "1.0.12",
21
+ "@zhin.js/feature-kit": "1.0.13",
22
22
  "@zhin.js/im-contract": "1.0.4",
23
- "@zhin.js/logger": "1.0.76",
24
- "@zhin.js/plugin-runtime": "1.1.7"
23
+ "@zhin.js/logger": "1.0.77",
24
+ "@zhin.js/plugin-runtime": "1.1.8"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^26.1.2",
28
28
  "typescript": "^6.0.3",
29
- "@zhin.js/command": "1.0.15"
29
+ "@zhin.js/command": "1.0.16"
30
30
  },
31
31
  "zhin": {
32
32
  "protocol": 1,
@@ -17,9 +17,9 @@ import {
17
17
  type AdapterDefinition,
18
18
  type AdapterOperation,
19
19
  type AdapterSegmentPolicy,
20
- type EndpointInstance,
21
20
  type EndpointSendRequest,
22
21
  } from './definition.js';
22
+ import { bindEndpoint, isEndpoint, type Endpoint } from './endpoint.js';
23
23
  import {
24
24
  listEndpointManagementCapabilities,
25
25
  type EndpointManagementCapability,
@@ -57,7 +57,7 @@ export type AdapterEndpointPhase =
57
57
  'pending' | 'starting' | 'online';
58
58
 
59
59
  interface AdapterRecord extends AdapterDescriptor {
60
- readonly endpoint: EndpointInstance;
60
+ readonly endpoint: Endpoint;
61
61
  readonly segments?: AdapterSegmentPolicy;
62
62
  started: boolean;
63
63
  open: boolean;
@@ -157,15 +157,49 @@ export class AdapterIndex {
157
157
  return exact?.id ?? matches[0]?.id;
158
158
  }
159
159
 
160
- /**
161
- * Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
162
- */
163
- instance(adapter: string, endpointKey: string): EndpointInstance | undefined {
160
+ /** Resolve the framework-owned Endpoint for internal Host control ports. */
161
+ connection(adapter: string, endpointKey: string): Endpoint | undefined {
164
162
  const id = this.resolve(adapter, endpointKey);
165
163
  if (!id) return undefined;
166
164
  return this.#records.get(id)?.endpoint;
167
165
  }
168
166
 
167
+ /** Resolve the platform-native client owned by one active Endpoint. */
168
+ client<TClient>(adapter: string, endpointKey: string): TClient {
169
+ const id = this.resolve(adapter, endpointKey);
170
+ const record = id ? this.#records.get(id) : undefined;
171
+ if (!record) throw new Error(`Endpoint ${adapter}/${endpointKey} does not exist`);
172
+ if (!record.started || record.stopped) {
173
+ throw new Error(`Endpoint ${adapter}/${endpointKey} is not active`);
174
+ }
175
+ return record.endpoint.client as TClient;
176
+ }
177
+
178
+ /** Resolve the Client directly from a generation-stable CapabilityId. */
179
+ clientById<TClient>(id: CapabilityId): TClient {
180
+ const record = this.#records.get(id);
181
+ if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
182
+ if (!record.started || record.stopped) {
183
+ throw new Error(`Adapter Endpoint ${id} is not active`);
184
+ }
185
+ return record.endpoint.client as TClient;
186
+ }
187
+
188
+ /** Literal adapter name used by authoring-context type discrimination. */
189
+ clientAdapter(id: CapabilityId): string {
190
+ const record = this.#records.get(id);
191
+ if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
192
+ return record.endpoint.identity.adapter;
193
+ }
194
+
195
+ /** Optional Client lookup for cross-platform middleware and routing. */
196
+ findClient<TClient>(adapter: string, endpointKey: string): TClient | undefined {
197
+ const id = this.resolve(adapter, endpointKey);
198
+ const record = id ? this.#records.get(id) : undefined;
199
+ if (!record || !record.started || record.stopped) return undefined;
200
+ return record.endpoint.client as TClient;
201
+ }
202
+
169
203
  owner(id: CapabilityId): PluginId {
170
204
  const record = this.#records.get(id);
171
205
  if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
@@ -228,6 +262,11 @@ export class AdapterIndex {
228
262
  }
229
263
  signal.throwIfAborted();
230
264
  if (record.stopped) throw new Error(`Adapter Endpoint stopped during start: ${record.id}`);
265
+ if (record.endpoint.client === record.endpoint) {
266
+ throw new TypeError(
267
+ `Adapter Endpoint ${record.id} must expose a distinct platform client`,
268
+ );
269
+ }
231
270
  record.started = true;
232
271
  }
233
272
  } catch (error) {
@@ -335,7 +374,7 @@ function matchesEndpoint(
335
374
  || record.id.endsWith(`/${adapter}`)
336
375
  || record.owner === adapter
337
376
  || record.owner.endsWith(`/${adapter}`);
338
- // Live EndpointInstance.name is the bot runtime id (e.g. ICQQ uin). Host /
377
+ // The live Endpoint identity is the bot runtime id (e.g. ICQQ uin). Host /
339
378
  // activity-feedback resolve with that id; slot.localName alone is not enough
340
379
  // when multiple plugin instances share localName "icqq".
341
380
  const liveName = endpointLiveName(record.endpoint);
@@ -346,7 +385,7 @@ function matchesEndpoint(
346
385
  return adapterOk && endpointOk;
347
386
  }
348
387
 
349
- function endpointLiveName(endpoint: EndpointInstance): string | undefined {
388
+ function endpointLiveName(endpoint: Endpoint): string | undefined {
350
389
  const name = (endpoint as { readonly name?: unknown }).name;
351
390
  return typeof name === 'string' && name.length > 0 ? name : undefined;
352
391
  }
@@ -357,9 +396,9 @@ function endpointPhase(record: AdapterRecord): AdapterEndpointPhase {
357
396
  return 'pending';
358
397
  }
359
398
 
360
- function assertEndpoint(value: unknown, id: CapabilityId): asserts value is EndpointInstance {
361
- if (!value || typeof value !== 'object') {
362
- throw new TypeError(`Adapter ${id} create() must return an Endpoint instance`);
399
+ function assertEndpoint(value: unknown, id: CapabilityId): asserts value is Endpoint {
400
+ if (!isEndpoint(value)) {
401
+ throw new TypeError(`Adapter ${id} create() must return an Endpoint subclass`);
363
402
  }
364
403
  }
365
404
 
@@ -425,7 +464,7 @@ async function createEndpoint(
425
464
  admission: GenerationAdmissionGate,
426
465
  signal: AbortSignal,
427
466
  expansion?: EndpointExpansion,
428
- ): Promise<Readonly<{ endpoint: EndpointInstance; operations: readonly AdapterOperation[] }>> {
467
+ ): Promise<Readonly<{ endpoint: Endpoint; operations: readonly AdapterOperation[] }>> {
429
468
  const context = Object.freeze({
430
469
  ...createCapabilityContext(snapshot, slot.owner, admission, signal),
431
470
  ...(expansion?.config ? { config: expansion.config } : {}),
@@ -435,6 +474,7 @@ async function createEndpoint(
435
474
  const operations = resolveAdapterOperations(slot.definition, context);
436
475
  const endpoint = await slot.definition.create(context);
437
476
  assertEndpoint(endpoint, expansion?.id ?? slot.id);
477
+ bindEndpoint(endpoint, context, admission);
438
478
  if (slot.definition.capabilities.includes('outbound') && typeof endpoint.send !== 'function') {
439
479
  throw new TypeError(
440
480
  `Adapter Endpoint ${String(expansion?.id ?? slot.id)} declares outbound but send() is missing`,
package/src/definition.ts CHANGED
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Adapter authoring API consumed from `zhin.js/adapter`.
3
+ * @module zhin.js/adapter
4
+ */
1
5
  import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
6
  import type { CapabilityContext } from '@zhin.js/feature-kit';
3
7
  import type {
@@ -5,9 +9,19 @@ import type {
5
9
  EndpointCapabilities,
6
10
  EndpointOperation,
7
11
  } from '@zhin.js/im-contract';
8
- import type { EndpointManagement } from './endpoint-management.js';
9
- import type { EndpointControl } from './endpoint-control.js';
10
- import type { EndpointContentPort } from './endpoint-content.js';
12
+ import type { Endpoint } from './endpoint.js';
13
+
14
+ export { Endpoint } from './endpoint.js';
15
+ export type {
16
+ EndpointEvent,
17
+ EndpointIdentity,
18
+ PlatformEvent,
19
+ } from './endpoint.js';
20
+ export { defineEndpointClient } from './endpoint-client.js';
21
+ export type {
22
+ EndpointClientContext,
23
+ EndpointClientToken,
24
+ } from './endpoint-client.js';
11
25
 
12
26
  const adapterBrand = 'zhin.adapter/1' as const;
13
27
 
@@ -35,25 +49,9 @@ export interface EndpointSendRequest {
35
49
  readonly payload: unknown;
36
50
  }
37
51
 
38
- export interface EndpointInstance {
39
- /** Optional platform-neutral Console/Host management surface. */
40
- readonly management?: EndpointManagement;
41
- /** Optional platform-neutral control surface for existing messages. */
42
- readonly control?: EndpointControl;
43
- /** Optional canonical resolver for message, merged-forward and media references. */
44
- readonly content?: EndpointContentPort;
45
- /** Required readiness; must observe abort and settle before rollback returns. */
46
- start?(signal: AbortSignal): void | Promise<void>;
47
- /** Opens Endpoint-local flow behind the candidate generation admission gate. */
48
- open?(): void;
49
- /** Stops new inbound events while preserving in-flight work. */
50
- close?(): void | Promise<void>;
51
- /** Releases transport resources. Calls must be idempotent. */
52
- stop?(): void | Promise<void>;
53
- /** Platform message id. Core wraps it in the canonical MessageRef/DeliveryReceipt. */
54
- send?(request: EndpointSendRequest): string | Promise<string>;
55
- }
56
-
52
+ /**
53
+ * Generation-owned construction context for one runtime Endpoint.
54
+ */
57
55
  export interface AdapterContext<TConfig = unknown> extends CapabilityContext<TConfig> {
58
56
  readonly id: CapabilityId;
59
57
  readonly name: string;
@@ -100,7 +98,8 @@ const OUTBOUND_MEDIA_FORMS: readonly AdapterOutboundMedia[] = [
100
98
 
101
99
  const ADAPTER_OPERATIONS: readonly AdapterOperation[] = ['recall', 'edit', 'reaction', 'typing'];
102
100
 
103
- export interface AdapterDefinition<TConfig = unknown> {
101
+ export interface AdapterDefinition<TConfig = unknown, TClient = unknown> {
102
+ /** @internal Runtime feature brand. */
104
103
  readonly $feature: typeof adapterBrand;
105
104
  readonly capabilities: readonly AdapterCapability[];
106
105
  /**
@@ -113,7 +112,7 @@ export interface AdapterDefinition<TConfig = unknown> {
113
112
  readonly segments?: AdapterSegmentPolicy;
114
113
  create(
115
114
  context: AdapterContext<TConfig>,
116
- ): EndpointInstance | Promise<EndpointInstance>;
115
+ ): Endpoint<TClient> | Promise<Endpoint<TClient>>;
117
116
  }
118
117
 
119
118
  declare module '@zhin.js/plugin-runtime' {
@@ -125,6 +124,23 @@ declare module '@zhin.js/plugin-runtime' {
125
124
  }
126
125
  }
127
126
 
127
+ /**
128
+ * Define an Adapter module for the `adapters/` convention directory.
129
+ *
130
+ * The returned definition is immutable and declares capabilities before an
131
+ * Endpoint is created, so Runtime admission can fail closed.
132
+ *
133
+ * @public
134
+ * @example
135
+ * ```ts
136
+ * import { defineAdapter } from 'zhin.js/adapter';
137
+ *
138
+ * export default defineAdapter({
139
+ * capabilities: ['inbound', 'outbound'],
140
+ * create: () => new MyPlatformEndpoint(),
141
+ * });
142
+ * ```
143
+ */
128
144
  export function defineAdapter<TConfig = unknown>(
129
145
  definition: Omit<AdapterDefinition<TConfig>, '$feature'>,
130
146
  ): Readonly<AdapterDefinition<TConfig>> {
@@ -151,7 +167,10 @@ export function defineAdapter<TConfig = unknown>(
151
167
  });
152
168
  }
153
169
 
154
- /** Converts the definition's compact authoring form into the public contract. */
170
+ /**
171
+ * Converts the definition's compact authoring form into the Runtime contract.
172
+ * @internal Adapter projection helper.
173
+ */
155
174
  export function endpointCapabilitiesOf(
156
175
  definition: Pick<AdapterDefinition, 'capabilities' | 'operations'>,
157
176
  resolvedOperations?: readonly AdapterOperation[],
@@ -172,7 +191,10 @@ export function endpointCapabilitiesOf(
172
191
  });
173
192
  }
174
193
 
175
- /** Resolve and validate the operation declaration for one concrete Endpoint. */
194
+ /**
195
+ * Resolve and validate the operation declaration for one concrete Endpoint.
196
+ * @internal Adapter projection helper.
197
+ */
176
198
  export function resolveAdapterOperations<TConfig>(
177
199
  definition: Pick<AdapterDefinition<TConfig>, 'operations'>,
178
200
  context: AdapterContext<TConfig>,
@@ -251,6 +273,7 @@ function normalizeSegmentPolicy(
251
273
  });
252
274
  }
253
275
 
276
+ /** @internal Runtime validation for convention-discovered modules. */
254
277
  export function parseAdapterDefinition(value: unknown): AdapterDefinition {
255
278
  if (!value || typeof value !== 'object') throw invalidAdapter();
256
279
  const definition = value as Partial<AdapterDefinition>;