@zhin.js/adapter 1.1.11 → 1.2.1
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 +56 -4
- package/lib/adapter-index.d.ts +19 -6
- package/lib/adapter-index.js +74 -14
- package/lib/definition.d.ts +46 -27
- package/lib/definition.js +48 -5
- package/lib/endpoint-client.d.ts +62 -0
- package/lib/endpoint-client.js +163 -0
- package/lib/endpoint-control.d.ts +5 -1
- package/lib/endpoint-control.js +37 -0
- package/lib/endpoint-lifecycle.js +10 -16
- 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 +100 -31
- package/src/definition.ts +79 -29
- package/src/endpoint-client.ts +250 -0
- package/src/endpoint-control.ts +45 -1
- package/src/endpoint-lifecycle.ts +10 -16
- package/src/endpoint.ts +181 -0
- package/src/index.ts +6 -0
package/src/adapter-index.ts
CHANGED
|
@@ -10,20 +10,31 @@ import {
|
|
|
10
10
|
type RuntimeSnapshot,
|
|
11
11
|
} from '@zhin.js/plugin-runtime';
|
|
12
12
|
import { createCapabilityContext } from '@zhin.js/feature-kit';
|
|
13
|
-
import
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
import {
|
|
14
|
+
endpointCapabilitiesOf,
|
|
15
|
+
resolveAdapterOperations,
|
|
16
|
+
type AdapterCapability,
|
|
17
|
+
type AdapterDefinition,
|
|
18
|
+
type AdapterOperation,
|
|
19
|
+
type AdapterSegmentPolicy,
|
|
20
|
+
type EndpointSendRequest,
|
|
19
21
|
} from './definition.js';
|
|
22
|
+
import { bindEndpoint, isEndpoint, type Endpoint } from './endpoint.js';
|
|
20
23
|
import {
|
|
21
24
|
listEndpointManagementCapabilities,
|
|
22
25
|
type EndpointManagementCapability,
|
|
23
26
|
} from './endpoint-management.js';
|
|
24
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
assertDeclaredEndpointOperations,
|
|
29
|
+
endpointControlOf,
|
|
30
|
+
type EndpointControl,
|
|
31
|
+
} from './endpoint-control.js';
|
|
25
32
|
import { endpointContentOf, type EndpointContentResolveContext } from './endpoint-content.js';
|
|
26
|
-
import type {
|
|
33
|
+
import type {
|
|
34
|
+
ConversationReference,
|
|
35
|
+
ConversationResolution,
|
|
36
|
+
EndpointCapabilities,
|
|
37
|
+
} from '@zhin.js/im-contract';
|
|
27
38
|
|
|
28
39
|
export interface AdapterDescriptor {
|
|
29
40
|
readonly id: CapabilityId;
|
|
@@ -31,6 +42,7 @@ export interface AdapterDescriptor {
|
|
|
31
42
|
readonly name: string;
|
|
32
43
|
readonly source: string;
|
|
33
44
|
readonly capabilities: readonly AdapterCapability[];
|
|
45
|
+
readonly operations: readonly AdapterOperation[];
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
/** Console / Host-facing endpoint row (connected = admission open). */
|
|
@@ -45,7 +57,7 @@ export type AdapterEndpointPhase =
|
|
|
45
57
|
'pending' | 'starting' | 'online';
|
|
46
58
|
|
|
47
59
|
interface AdapterRecord extends AdapterDescriptor {
|
|
48
|
-
readonly endpoint:
|
|
60
|
+
readonly endpoint: Endpoint;
|
|
49
61
|
readonly segments?: AdapterSegmentPolicy;
|
|
50
62
|
started: boolean;
|
|
51
63
|
open: boolean;
|
|
@@ -80,7 +92,7 @@ export class AdapterIndex {
|
|
|
80
92
|
for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
|
|
81
93
|
signal.throwIfAborted();
|
|
82
94
|
for (const expansion of expandEndpointConfigs(slot, snapshot)) {
|
|
83
|
-
const
|
|
95
|
+
const created = await createEndpoint(slot, snapshot, admission, signal, expansion);
|
|
84
96
|
signal.throwIfAborted();
|
|
85
97
|
records.push({
|
|
86
98
|
id: expansion.id,
|
|
@@ -90,7 +102,8 @@ export class AdapterIndex {
|
|
|
90
102
|
name: expansion.endpointId,
|
|
91
103
|
source: slot.source,
|
|
92
104
|
capabilities: slot.definition.capabilities,
|
|
93
|
-
|
|
105
|
+
operations: created.operations,
|
|
106
|
+
endpoint: created.endpoint,
|
|
94
107
|
...(slot.definition.segments ? { segments: slot.definition.segments } : {}),
|
|
95
108
|
started: false,
|
|
96
109
|
open: false,
|
|
@@ -122,6 +135,7 @@ export class AdapterIndex {
|
|
|
122
135
|
name: endpointLiveName(record.endpoint) ?? record.name,
|
|
123
136
|
source: record.source,
|
|
124
137
|
capabilities: record.capabilities,
|
|
138
|
+
operations: record.operations,
|
|
125
139
|
connected: record.open && !record.stopped,
|
|
126
140
|
status: record.open && !record.stopped ? 'online' as const : 'offline' as const,
|
|
127
141
|
phase: endpointPhase(record),
|
|
@@ -143,21 +157,70 @@ export class AdapterIndex {
|
|
|
143
157
|
return exact?.id ?? matches[0]?.id;
|
|
144
158
|
}
|
|
145
159
|
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
*/
|
|
149
|
-
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 {
|
|
150
162
|
const id = this.resolve(adapter, endpointKey);
|
|
151
163
|
if (!id) return undefined;
|
|
152
164
|
return this.#records.get(id)?.endpoint;
|
|
153
165
|
}
|
|
154
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
|
+
|
|
155
203
|
owner(id: CapabilityId): PluginId {
|
|
156
204
|
const record = this.#records.get(id);
|
|
157
205
|
if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
|
|
158
206
|
return record.owner;
|
|
159
207
|
}
|
|
160
208
|
|
|
209
|
+
/** Exact, serializable capabilities for one concrete Endpoint. */
|
|
210
|
+
capabilities(id: CapabilityId): EndpointCapabilities {
|
|
211
|
+
const record = this.#records.get(id);
|
|
212
|
+
if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
|
|
213
|
+
return endpointCapabilitiesOf(record, record.operations);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Returns the control port only when the concrete Endpoint declared the operation and is active. */
|
|
217
|
+
control(id: CapabilityId, operation: AdapterOperation): EndpointControl | undefined {
|
|
218
|
+
const record = this.#records.get(id);
|
|
219
|
+
if (!record || !record.started || record.stopped) return undefined;
|
|
220
|
+
if (!record.operations.includes(operation)) return undefined;
|
|
221
|
+
return endpointControlOf(record.endpoint);
|
|
222
|
+
}
|
|
223
|
+
|
|
161
224
|
/**
|
|
162
225
|
* Endpoint 的消息段能力声明(出站协商降级依据);
|
|
163
226
|
* 未声明或未知 id 返回 undefined(调用方按历史行为处理)。
|
|
@@ -199,6 +262,11 @@ export class AdapterIndex {
|
|
|
199
262
|
}
|
|
200
263
|
signal.throwIfAborted();
|
|
201
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
|
+
}
|
|
202
270
|
record.started = true;
|
|
203
271
|
}
|
|
204
272
|
} catch (error) {
|
|
@@ -306,7 +374,7 @@ function matchesEndpoint(
|
|
|
306
374
|
|| record.id.endsWith(`/${adapter}`)
|
|
307
375
|
|| record.owner === adapter
|
|
308
376
|
|| record.owner.endsWith(`/${adapter}`);
|
|
309
|
-
//
|
|
377
|
+
// The live Endpoint identity is the bot runtime id (e.g. ICQQ uin). Host /
|
|
310
378
|
// activity-feedback resolve with that id; slot.localName alone is not enough
|
|
311
379
|
// when multiple plugin instances share localName "icqq".
|
|
312
380
|
const liveName = endpointLiveName(record.endpoint);
|
|
@@ -317,7 +385,7 @@ function matchesEndpoint(
|
|
|
317
385
|
return adapterOk && endpointOk;
|
|
318
386
|
}
|
|
319
387
|
|
|
320
|
-
function endpointLiveName(endpoint:
|
|
388
|
+
function endpointLiveName(endpoint: Endpoint): string | undefined {
|
|
321
389
|
const name = (endpoint as { readonly name?: unknown }).name;
|
|
322
390
|
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
323
391
|
}
|
|
@@ -328,9 +396,9 @@ function endpointPhase(record: AdapterRecord): AdapterEndpointPhase {
|
|
|
328
396
|
return 'pending';
|
|
329
397
|
}
|
|
330
398
|
|
|
331
|
-
function assertEndpoint(value: unknown, id: CapabilityId): asserts value is
|
|
332
|
-
if (!value
|
|
333
|
-
throw new TypeError(`Adapter ${id} create() must return an Endpoint
|
|
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`);
|
|
334
402
|
}
|
|
335
403
|
}
|
|
336
404
|
|
|
@@ -396,16 +464,17 @@ async function createEndpoint(
|
|
|
396
464
|
admission: GenerationAdmissionGate,
|
|
397
465
|
signal: AbortSignal,
|
|
398
466
|
expansion?: EndpointExpansion,
|
|
399
|
-
): Promise<
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
);
|
|
467
|
+
): Promise<Readonly<{ endpoint: Endpoint; operations: readonly AdapterOperation[] }>> {
|
|
468
|
+
const context = Object.freeze({
|
|
469
|
+
...createCapabilityContext(snapshot, slot.owner, admission, signal),
|
|
470
|
+
...(expansion?.config ? { config: expansion.config } : {}),
|
|
471
|
+
id: expansion?.id ?? slot.id,
|
|
472
|
+
name: slot.localName,
|
|
473
|
+
});
|
|
474
|
+
const operations = resolveAdapterOperations(slot.definition, context);
|
|
475
|
+
const endpoint = await slot.definition.create(context);
|
|
408
476
|
assertEndpoint(endpoint, expansion?.id ?? slot.id);
|
|
477
|
+
bindEndpoint(endpoint, context, admission);
|
|
409
478
|
if (slot.definition.capabilities.includes('outbound') && typeof endpoint.send !== 'function') {
|
|
410
479
|
throw new TypeError(
|
|
411
480
|
`Adapter Endpoint ${String(expansion?.id ?? slot.id)} declares outbound but send() is missing`,
|
|
@@ -413,10 +482,10 @@ async function createEndpoint(
|
|
|
413
482
|
}
|
|
414
483
|
assertDeclaredEndpointOperations(
|
|
415
484
|
endpoint,
|
|
416
|
-
|
|
485
|
+
operations,
|
|
417
486
|
String(expansion?.id ?? slot.id),
|
|
418
487
|
);
|
|
419
|
-
return endpoint;
|
|
488
|
+
return Object.freeze({ endpoint, operations });
|
|
420
489
|
}
|
|
421
490
|
|
|
422
491
|
async function stopRecords(
|
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 {
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
|
@@ -16,6 +30,11 @@ export type AdapterCapability = 'inbound' | 'outbound';
|
|
|
16
30
|
/** Operations beyond sending, declared by an Adapter definition. */
|
|
17
31
|
export type AdapterOperation = Exclude<EndpointOperation, 'send'>;
|
|
18
32
|
|
|
33
|
+
/** Resolve operations for one concrete Endpoint configuration. */
|
|
34
|
+
export type AdapterOperationDeclaration<TConfig = unknown> =
|
|
35
|
+
| readonly AdapterOperation[]
|
|
36
|
+
| ((context: AdapterContext<TConfig>) => readonly AdapterOperation[]);
|
|
37
|
+
|
|
19
38
|
/** 端点可消费的出站媒体来源形式。 */
|
|
20
39
|
export type AdapterOutboundMedia = 'url' | 'path' | 'base64' | 'upload';
|
|
21
40
|
|
|
@@ -30,25 +49,9 @@ export interface EndpointSendRequest {
|
|
|
30
49
|
readonly payload: unknown;
|
|
31
50
|
}
|
|
32
51
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
/** Optional platform-neutral control surface for existing messages. */
|
|
37
|
-
readonly control?: EndpointControl;
|
|
38
|
-
/** Optional canonical resolver for message, merged-forward and media references. */
|
|
39
|
-
readonly content?: EndpointContentPort;
|
|
40
|
-
/** Required readiness; must observe abort and settle before rollback returns. */
|
|
41
|
-
start?(signal: AbortSignal): void | Promise<void>;
|
|
42
|
-
/** Opens Endpoint-local flow behind the candidate generation admission gate. */
|
|
43
|
-
open?(): void;
|
|
44
|
-
/** Stops new inbound events while preserving in-flight work. */
|
|
45
|
-
close?(): void | Promise<void>;
|
|
46
|
-
/** Releases transport resources. Calls must be idempotent. */
|
|
47
|
-
stop?(): void | Promise<void>;
|
|
48
|
-
/** Platform message id. Core wraps it in the canonical MessageRef/DeliveryReceipt. */
|
|
49
|
-
send?(request: EndpointSendRequest): string | Promise<string>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Generation-owned construction context for one runtime Endpoint.
|
|
54
|
+
*/
|
|
52
55
|
export interface AdapterContext<TConfig = unknown> extends CapabilityContext<TConfig> {
|
|
53
56
|
readonly id: CapabilityId;
|
|
54
57
|
readonly name: string;
|
|
@@ -95,7 +98,8 @@ const OUTBOUND_MEDIA_FORMS: readonly AdapterOutboundMedia[] = [
|
|
|
95
98
|
|
|
96
99
|
const ADAPTER_OPERATIONS: readonly AdapterOperation[] = ['recall', 'edit', 'reaction', 'typing'];
|
|
97
100
|
|
|
98
|
-
export interface AdapterDefinition<TConfig = unknown> {
|
|
101
|
+
export interface AdapterDefinition<TConfig = unknown, TClient = unknown> {
|
|
102
|
+
/** @internal Runtime feature brand. */
|
|
99
103
|
readonly $feature: typeof adapterBrand;
|
|
100
104
|
readonly capabilities: readonly AdapterCapability[];
|
|
101
105
|
/**
|
|
@@ -103,12 +107,12 @@ export interface AdapterDefinition<TConfig = unknown> {
|
|
|
103
107
|
* `capabilities: ['outbound']`; a method existing on an endpoint is not a
|
|
104
108
|
* capability declaration.
|
|
105
109
|
*/
|
|
106
|
-
readonly operations?:
|
|
110
|
+
readonly operations?: AdapterOperationDeclaration<TConfig>;
|
|
107
111
|
/** 可选:端点消息段能力声明(出站协商降级挂载点)。 */
|
|
108
112
|
readonly segments?: AdapterSegmentPolicy;
|
|
109
113
|
create(
|
|
110
114
|
context: AdapterContext<TConfig>,
|
|
111
|
-
):
|
|
115
|
+
): Endpoint<TClient> | Promise<Endpoint<TClient>>;
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
declare module '@zhin.js/plugin-runtime' {
|
|
@@ -120,6 +124,23 @@ declare module '@zhin.js/plugin-runtime' {
|
|
|
120
124
|
}
|
|
121
125
|
}
|
|
122
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
|
+
*/
|
|
123
144
|
export function defineAdapter<TConfig = unknown>(
|
|
124
145
|
definition: Omit<AdapterDefinition<TConfig>, '$feature'>,
|
|
125
146
|
): Readonly<AdapterDefinition<TConfig>> {
|
|
@@ -134,7 +155,9 @@ export function defineAdapter<TConfig = unknown>(
|
|
|
134
155
|
throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
|
|
135
156
|
}
|
|
136
157
|
const segments = normalizeSegmentPolicy(definition.segments);
|
|
137
|
-
const operations =
|
|
158
|
+
const operations = typeof definition.operations === 'function'
|
|
159
|
+
? definition.operations
|
|
160
|
+
: normalizeOperations(definition.operations);
|
|
138
161
|
return Object.freeze({
|
|
139
162
|
...definition,
|
|
140
163
|
$feature: adapterBrand,
|
|
@@ -144,11 +167,20 @@ export function defineAdapter<TConfig = unknown>(
|
|
|
144
167
|
});
|
|
145
168
|
}
|
|
146
169
|
|
|
147
|
-
/**
|
|
170
|
+
/**
|
|
171
|
+
* Converts the definition's compact authoring form into the Runtime contract.
|
|
172
|
+
* @internal Adapter projection helper.
|
|
173
|
+
*/
|
|
148
174
|
export function endpointCapabilitiesOf(
|
|
149
175
|
definition: Pick<AdapterDefinition, 'capabilities' | 'operations'>,
|
|
176
|
+
resolvedOperations?: readonly AdapterOperation[],
|
|
150
177
|
): EndpointCapabilities {
|
|
151
|
-
|
|
178
|
+
if (typeof definition.operations === 'function' && resolvedOperations === undefined) {
|
|
179
|
+
throw new TypeError('Dynamic Adapter operations must be resolved for one Endpoint');
|
|
180
|
+
}
|
|
181
|
+
const declared = resolvedOperations
|
|
182
|
+
?? (Array.isArray(definition.operations) ? definition.operations : undefined);
|
|
183
|
+
const operations = declared?.reduce<Partial<Record<AdapterOperation, true>>>(
|
|
152
184
|
(result, operation) => ({ ...result, [operation]: true }),
|
|
153
185
|
{},
|
|
154
186
|
);
|
|
@@ -159,6 +191,21 @@ export function endpointCapabilitiesOf(
|
|
|
159
191
|
});
|
|
160
192
|
}
|
|
161
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Resolve and validate the operation declaration for one concrete Endpoint.
|
|
196
|
+
* @internal Adapter projection helper.
|
|
197
|
+
*/
|
|
198
|
+
export function resolveAdapterOperations<TConfig>(
|
|
199
|
+
definition: Pick<AdapterDefinition<TConfig>, 'operations'>,
|
|
200
|
+
context: AdapterContext<TConfig>,
|
|
201
|
+
): readonly AdapterOperation[] {
|
|
202
|
+
const declaration = definition.operations;
|
|
203
|
+
const operations = typeof declaration === 'function'
|
|
204
|
+
? declaration(context)
|
|
205
|
+
: declaration;
|
|
206
|
+
return normalizeOperations(operations) ?? Object.freeze([]);
|
|
207
|
+
}
|
|
208
|
+
|
|
162
209
|
function normalizeOperations(
|
|
163
210
|
operations: readonly AdapterOperation[] | undefined,
|
|
164
211
|
): readonly AdapterOperation[] | undefined {
|
|
@@ -226,6 +273,7 @@ function normalizeSegmentPolicy(
|
|
|
226
273
|
});
|
|
227
274
|
}
|
|
228
275
|
|
|
276
|
+
/** @internal Runtime validation for convention-discovered modules. */
|
|
229
277
|
export function parseAdapterDefinition(value: unknown): AdapterDefinition {
|
|
230
278
|
if (!value || typeof value !== 'object') throw invalidAdapter();
|
|
231
279
|
const definition = value as Partial<AdapterDefinition>;
|
|
@@ -240,7 +288,9 @@ export function parseAdapterDefinition(value: unknown): AdapterDefinition {
|
|
|
240
288
|
) throw invalidAdapter();
|
|
241
289
|
// defineAdapter 已校验过形状;外部手工构造的 definition 也在此兜底
|
|
242
290
|
normalizeSegmentPolicy(definition.segments);
|
|
243
|
-
|
|
291
|
+
if (typeof definition.operations !== 'function') {
|
|
292
|
+
normalizeOperations(definition.operations);
|
|
293
|
+
}
|
|
244
294
|
return definition as AdapterDefinition;
|
|
245
295
|
}
|
|
246
296
|
|
|
@@ -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
|
+
}
|