@zhin.js/adapter 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 凉菜
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @zhin.js/adapter
2
+
3
+ Zhin Plugin Runtime 的 Adapter Feature。它从插件或项目的 `adapters/**/*.ts` 发现
4
+ `defineAdapter()` 定义,按 Plugin owner 投影 Endpoint,并把 start/open/close/stop 纳入同一
5
+ generation handoff。
6
+
7
+ ```ts
8
+ import { defineAdapter } from '@zhin.js/adapter';
9
+
10
+ export default defineAdapter({
11
+ capabilities: ['inbound', 'outbound'],
12
+ create: (context) => ({ name: context.name }),
13
+ });
14
+ ```
15
+
16
+ 本包只依赖 Kernel 与 Feature Kit,不包含具体平台 SDK。生产 manifest 指向
17
+ `lib/provider.js`;开发时可通过 conditional export 读取源码。
18
+
19
+ 验证:`pnpm --filter @zhin.js/adapter test && pnpm --filter @zhin.js/adapter build`。
20
+
21
+ 架构说明见 [Plugin Monorepo 与 Feature Provider](../../../docs/architecture/target-implementation/plugin-monorepo-and-features.md)。
@@ -0,0 +1,44 @@
1
+ import { type CapabilityId, type CapabilitySlot, type PluginId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import type { AdapterCapability, AdapterDefinition, EndpointInstance, EndpointSendRequest } from './definition.js';
3
+ export interface AdapterDescriptor {
4
+ readonly id: CapabilityId;
5
+ readonly owner: PluginId;
6
+ readonly name: string;
7
+ readonly source: string;
8
+ readonly capabilities: readonly AdapterCapability[];
9
+ }
10
+ /** Console / Host-facing endpoint row (connected = admission open). */
11
+ export interface AdapterEndpointSummary extends AdapterDescriptor {
12
+ readonly connected: boolean;
13
+ readonly status: 'online' | 'offline';
14
+ readonly phase: AdapterEndpointPhase;
15
+ }
16
+ export type AdapterEndpointPhase = 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
17
+ export declare class AdapterIndex {
18
+ #private;
19
+ readonly $projection: "zhin.adapter-index/1";
20
+ private constructor();
21
+ static create(slots: readonly Readonly<CapabilitySlot<AdapterDefinition>>[], snapshot: RuntimeSnapshot, options?: {
22
+ readonly startTimeoutMs?: number;
23
+ readonly deferredGiveUpMs?: number;
24
+ }): Promise<AdapterIndex>;
25
+ list(): readonly AdapterDescriptor[];
26
+ /** Endpoint rows for Console `endpoint.list` / `endpoint.info`. */
27
+ describe(): readonly AdapterEndpointSummary[];
28
+ /**
29
+ * Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
30
+ * Matches local name, capability id, or owner path segments.
31
+ */
32
+ resolve(adapter: string, endpointId: string): CapabilityId | undefined;
33
+ /**
34
+ * Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
35
+ */
36
+ instance(adapter: string, endpointId: string): EndpointInstance | undefined;
37
+ owner(id: CapabilityId): PluginId;
38
+ start(): Promise<void>;
39
+ open(): void;
40
+ close(): Promise<void>;
41
+ stop(): Promise<void>;
42
+ send(id: CapabilityId, request: EndpointSendRequest): Promise<unknown>;
43
+ }
44
+ export declare function isAdapterIndex(value: unknown): value is AdapterIndex;
@@ -0,0 +1,378 @@
1
+ import { DisposeStack, } from '@zhin.js/plugin-runtime';
2
+ import { createCapabilityContext } from '@zhin.js/feature-kit';
3
+ import { formatCompact, getLogger } from '@zhin.js/logger';
4
+ const logger = getLogger('Adapter');
5
+ export class AdapterIndex {
6
+ $projection = 'zhin.adapter-index/1';
7
+ #records = new Map();
8
+ #order;
9
+ /** True after `open()` until `close()` / `stop()` — late starts may open themselves. */
10
+ #admissionOpen = false;
11
+ #startTimeoutMs;
12
+ /** Final give-up budget for deferred starts (never-settling start promises). */
13
+ #deferredGiveUpMs;
14
+ constructor(records, startTimeoutMs, deferredGiveUpMs) {
15
+ this.#order = Object.freeze([...records]);
16
+ this.#startTimeoutMs = startTimeoutMs;
17
+ this.#deferredGiveUpMs = deferredGiveUpMs;
18
+ for (const record of records)
19
+ this.#records.set(record.id, record);
20
+ }
21
+ static async create(slots, snapshot, options = {}) {
22
+ const records = [];
23
+ const unconfigured = [];
24
+ try {
25
+ for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
26
+ const endpoint = await createEndpointSoft(slot, snapshot);
27
+ if (endpoint.unconfigured)
28
+ unconfigured.push(slot.localName);
29
+ records.push({
30
+ id: slot.id,
31
+ owner: slot.owner,
32
+ name: slot.localName,
33
+ source: slot.source,
34
+ capabilities: slot.definition.capabilities,
35
+ endpoint: endpoint.instance,
36
+ unconfigured: endpoint.unconfigured,
37
+ started: false,
38
+ open: false,
39
+ failed: false,
40
+ startAttempted: false,
41
+ // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
42
+ stopped: endpoint.unconfigured,
43
+ });
44
+ }
45
+ if (unconfigured.length > 0) {
46
+ logger.info(formatCompact({
47
+ op: 'adapters_unconfigured',
48
+ count: unconfigured.length,
49
+ names: unconfigured.join(','),
50
+ }));
51
+ }
52
+ return new AdapterIndex(records, options.startTimeoutMs ?? 3_000, options.deferredGiveUpMs ?? 60_000);
53
+ }
54
+ catch (error) {
55
+ await stopRecords(records, error);
56
+ throw error;
57
+ }
58
+ }
59
+ list() {
60
+ return this.#order.map(({ endpoint: _endpoint, unconfigured: _unconfigured, started: _started, open: _open, stopped: _stopped, failed: _failed, startAttempted: _startAttempted, ...descriptor }) => Object.freeze(descriptor));
61
+ }
62
+ /** Endpoint rows for Console `endpoint.list` / `endpoint.info`. */
63
+ describe() {
64
+ return Object.freeze(this.#order.map((record) => Object.freeze({
65
+ id: record.id,
66
+ owner: record.owner,
67
+ // Console 展示用 live name(如 ICQQ uin、sandbox bot 名),缺省回退 slot localName
68
+ name: endpointLiveName(record.endpoint) ?? record.name,
69
+ source: record.source,
70
+ capabilities: record.capabilities,
71
+ connected: record.open && !record.stopped,
72
+ status: record.open && !record.stopped ? 'online' : 'offline',
73
+ phase: endpointPhase(record),
74
+ })));
75
+ }
76
+ /**
77
+ * Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
78
+ * Matches local name, capability id, or owner path segments.
79
+ */
80
+ resolve(adapter, endpointId) {
81
+ const matches = this.#order.filter((record) => matchesEndpoint(record, adapter, endpointId));
82
+ if (matches.length === 1)
83
+ return matches[0]?.id;
84
+ if (matches.length === 0)
85
+ return undefined;
86
+ // Prefer exact localName === endpointId when ambiguous.
87
+ const exact = matches.find((record) => record.name === endpointId);
88
+ return exact?.id ?? matches[0]?.id;
89
+ }
90
+ /**
91
+ * Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
92
+ */
93
+ instance(adapter, endpointId) {
94
+ const id = this.resolve(adapter, endpointId);
95
+ if (!id)
96
+ return undefined;
97
+ return this.#records.get(id)?.endpoint;
98
+ }
99
+ owner(id) {
100
+ const record = this.#records.get(id);
101
+ if (!record)
102
+ throw new Error(`Unknown Adapter Endpoint: ${id}`);
103
+ return record.owner;
104
+ }
105
+ async start() {
106
+ // Soft-start in parallel with a short wait so kitchen-sink Roots do not
107
+ // stall generation. Configured platforms that need longer (QQ auth, Slack
108
+ // socket, GitHub verify) stay in-flight instead of being stop()'d mid-connect.
109
+ const startTimeoutMs = this.#startTimeoutMs;
110
+ await Promise.all(this.#order.map(async (record) => {
111
+ if (record.started || record.stopped)
112
+ return;
113
+ record.startAttempted = true;
114
+ const startPromise = (async () => record.endpoint.start?.())();
115
+ try {
116
+ await withTimeout(startPromise, startTimeoutMs, `Adapter start timed out after ${startTimeoutMs}ms`);
117
+ if (record.stopped)
118
+ return;
119
+ record.started = true;
120
+ }
121
+ catch (error) {
122
+ const message = error instanceof Error ? error.message : String(error);
123
+ if (message.includes('timed out after')) {
124
+ logger.info(formatCompact({
125
+ op: 'adapter_start_deferred',
126
+ id: record.id,
127
+ name: record.name,
128
+ waitMs: startTimeoutMs,
129
+ }));
130
+ // Final backstop: a deferred start promise that never settles must
131
+ // not keep the Endpoint in limbo forever.
132
+ const giveUp = setTimeout(() => {
133
+ if (record.stopped || record.started)
134
+ return;
135
+ record.stopped = true;
136
+ record.failed = true;
137
+ // Swallow a late rejection so it does not become unhandled.
138
+ void startPromise.catch(() => undefined);
139
+ logger.warn(formatCompact({
140
+ op: 'adapter_start_give_up',
141
+ id: record.id,
142
+ name: record.name,
143
+ waitMs: this.#deferredGiveUpMs,
144
+ }));
145
+ }, this.#deferredGiveUpMs);
146
+ giveUp.unref?.();
147
+ void startPromise.then(() => {
148
+ clearTimeout(giveUp);
149
+ if (record.stopped || record.started)
150
+ return;
151
+ record.started = true;
152
+ if (this.#admissionOpen && !record.open) {
153
+ try {
154
+ record.endpoint.open?.();
155
+ record.open = true;
156
+ }
157
+ catch (openError) {
158
+ logger.warn(formatCompact({
159
+ op: 'adapter_open_after_deferred_fail',
160
+ id: record.id,
161
+ name: record.name,
162
+ error: openError instanceof Error ? openError.message : String(openError),
163
+ }));
164
+ }
165
+ }
166
+ }, (startError) => {
167
+ clearTimeout(giveUp);
168
+ if (record.stopped)
169
+ return;
170
+ record.stopped = true;
171
+ record.failed = true;
172
+ logger.warn(formatCompact({
173
+ op: 'adapter_start_soft_fail',
174
+ id: record.id,
175
+ name: record.name,
176
+ error: startError instanceof Error ? startError.message : String(startError),
177
+ stack: startError instanceof Error ? startError.stack : undefined,
178
+ }));
179
+ });
180
+ return;
181
+ }
182
+ record.stopped = true;
183
+ record.failed = true;
184
+ void startPromise.catch(() => undefined);
185
+ // Startup connect failures are logged once here (with stack); Endpoint
186
+ // implementations must NOT re-log them at error level.
187
+ logger.warn(formatCompact({
188
+ op: 'adapter_start_soft_fail',
189
+ id: record.id,
190
+ name: record.name,
191
+ error: message,
192
+ stack: error instanceof Error ? error.stack : undefined,
193
+ }));
194
+ // No endpoint.stop() here: adapter Endpoints self-stop in their start()
195
+ // catch by convention (verified across icqq/qq/slack/… endpoints).
196
+ }
197
+ }));
198
+ }
199
+ open() {
200
+ this.#admissionOpen = true;
201
+ const errors = [];
202
+ for (const record of this.#order) {
203
+ if (!record.started || record.open || record.stopped)
204
+ continue;
205
+ try {
206
+ record.endpoint.open?.();
207
+ record.open = true;
208
+ }
209
+ catch (error) {
210
+ errors.push(error);
211
+ }
212
+ }
213
+ if (errors.length > 0)
214
+ throw new AggregateError(errors, 'Adapter Endpoint open failed');
215
+ }
216
+ async close() {
217
+ this.#admissionOpen = false;
218
+ const stack = new DisposeStack();
219
+ for (const record of this.#order) {
220
+ if (!record.open || record.stopped)
221
+ continue;
222
+ stack.add(async () => {
223
+ await record.endpoint.close?.();
224
+ record.open = false;
225
+ });
226
+ }
227
+ await stack.dispose();
228
+ }
229
+ async stop() {
230
+ const stack = new DisposeStack();
231
+ // DisposeStack unwinds in reverse: admission closes before transports stop,
232
+ // and a close failure cannot skip transport cleanup.
233
+ stack.add(() => stopRecords(this.#order));
234
+ stack.add(() => this.close());
235
+ await stack.dispose();
236
+ }
237
+ async send(id, request) {
238
+ const record = this.#records.get(id);
239
+ if (!record)
240
+ throw new Error(`Unknown Adapter Endpoint: ${id}`);
241
+ if (!record.capabilities.includes('outbound') || !record.endpoint.send) {
242
+ throw new Error(`Adapter Endpoint does not support outbound: ${id}`);
243
+ }
244
+ if (!record.started || record.stopped) {
245
+ throw new Error(`Adapter Endpoint is not active: ${id}`);
246
+ }
247
+ return record.endpoint.send(request);
248
+ }
249
+ }
250
+ export function isAdapterIndex(value) {
251
+ return !!value && typeof value === 'object'
252
+ && value.$projection === 'zhin.adapter-index/1';
253
+ }
254
+ function matchesEndpoint(record, adapter, endpointId) {
255
+ const adapterOk = record.name === adapter
256
+ || record.id === adapter
257
+ || record.id.endsWith(`/${adapter}`)
258
+ || record.owner === adapter
259
+ || record.owner.endsWith(`/${adapter}`);
260
+ // Live EndpointInstance.name is the bot runtime id (e.g. ICQQ uin). Host /
261
+ // activity-feedback resolve with that id; slot.localName alone is not enough
262
+ // when multiple plugin instances share localName "icqq".
263
+ const liveName = endpointLiveName(record.endpoint);
264
+ const endpointOk = record.name === endpointId
265
+ || record.id === endpointId
266
+ || record.id.endsWith(`/${endpointId}`)
267
+ || (liveName !== undefined && liveName === endpointId);
268
+ return adapterOk && endpointOk;
269
+ }
270
+ function endpointLiveName(endpoint) {
271
+ const name = endpoint.name;
272
+ return typeof name === 'string' && name.length > 0 ? name : undefined;
273
+ }
274
+ function endpointPhase(record) {
275
+ if (record.unconfigured)
276
+ return 'unconfigured';
277
+ if (record.failed)
278
+ return 'failed';
279
+ if (record.open && !record.stopped)
280
+ return 'online';
281
+ if (record.startAttempted)
282
+ return 'starting';
283
+ return 'pending';
284
+ }
285
+ function assertEndpoint(value, id) {
286
+ if (!value || typeof value !== 'object') {
287
+ throw new TypeError(`Adapter ${id} create() must return an Endpoint instance`);
288
+ }
289
+ }
290
+ /**
291
+ * Adapter `resolveXxxConfig` helpers report missing config/credentials as
292
+ * TypeError("… requires …") by convention; only those are expected failures.
293
+ */
294
+ function isUnconfiguredError(error) {
295
+ return (error instanceof TypeError
296
+ && /requires|not configured|missing|未配置|缺少/i.test(error.message));
297
+ }
298
+ async function createEndpointSoft(slot, snapshot) {
299
+ let endpoint;
300
+ try {
301
+ endpoint = await slot.definition.create(Object.freeze({
302
+ ...createCapabilityContext(snapshot, slot.owner),
303
+ id: slot.id,
304
+ name: slot.localName,
305
+ }));
306
+ }
307
+ catch (error) {
308
+ // Missing config / credentials: degrade to an inert stub so the rest of
309
+ // the generation still boots. Anything else (network failures, bugs in
310
+ // create()) is unexpected — keep the stub but surface a warning instead
311
+ // of silently swallowing it at debug level.
312
+ const message = error instanceof Error ? error.message : String(error);
313
+ const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
314
+ log(formatCompact({
315
+ op: 'adapter_create_soft_fail',
316
+ id: slot.id,
317
+ name: slot.localName,
318
+ error: message,
319
+ }));
320
+ return {
321
+ instance: createUnconfiguredEndpoint(message),
322
+ unconfigured: true,
323
+ };
324
+ }
325
+ // Programming errors (create() did not return an Endpoint) must surface:
326
+ // they propagate to AdapterIndex.create's catch, which disposes the records
327
+ // created so far instead of hiding the bug behind an unconfigured stub.
328
+ assertEndpoint(endpoint, slot.id);
329
+ return { instance: endpoint, unconfigured: false };
330
+ }
331
+ function createUnconfiguredEndpoint(reason) {
332
+ return Object.freeze({
333
+ start() {
334
+ throw new Error(`Adapter unconfigured: ${reason}`);
335
+ },
336
+ open() { },
337
+ close() { },
338
+ stop() { },
339
+ send() {
340
+ throw new Error(`Adapter unconfigured: ${reason}`);
341
+ },
342
+ });
343
+ }
344
+ function withTimeout(promise, ms, message) {
345
+ if (promise === undefined)
346
+ return Promise.resolve(undefined);
347
+ return new Promise((resolve, reject) => {
348
+ const timer = setTimeout(() => reject(new Error(message)), ms);
349
+ Promise.resolve(promise).then((value) => {
350
+ clearTimeout(timer);
351
+ resolve(value);
352
+ }, (error) => {
353
+ clearTimeout(timer);
354
+ reject(error);
355
+ });
356
+ });
357
+ }
358
+ async function stopRecords(records, primaryError) {
359
+ const stack = new DisposeStack();
360
+ for (const record of records) {
361
+ if (record.stopped)
362
+ continue;
363
+ stack.add(async () => {
364
+ record.stopped = true;
365
+ record.open = false;
366
+ await record.endpoint.stop?.();
367
+ });
368
+ }
369
+ try {
370
+ await stack.dispose();
371
+ }
372
+ catch (stopError) {
373
+ if (primaryError !== undefined) {
374
+ throw new AggregateError([primaryError, stopError], 'Adapter prepare and Endpoint cleanup both failed', { cause: stopError });
375
+ }
376
+ throw stopError;
377
+ }
378
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Pick an explicit credential string.
3
+ * The first `typeof === 'string'` wins — including `""` — so an empty config
4
+ * field disables `process.env` fallbacks (kitchen-sink quiet boot).
5
+ */
6
+ export declare function pickCredential(...candidates: unknown[]): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Pick an explicit credential string.
3
+ * The first `typeof === 'string'` wins — including `""` — so an empty config
4
+ * field disables `process.env` fallbacks (kitchen-sink quiet boot).
5
+ */
6
+ export function pickCredential(...candidates) {
7
+ for (const value of candidates) {
8
+ if (typeof value === 'string')
9
+ return value;
10
+ }
11
+ return '';
12
+ }
@@ -0,0 +1,36 @@
1
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
+ import type { CapabilityContext } from '@zhin.js/feature-kit';
3
+ declare const adapterBrand: "zhin.adapter/1";
4
+ export type AdapterCapability = 'inbound' | 'outbound';
5
+ export interface EndpointSendRequest {
6
+ readonly target: string;
7
+ readonly payload: unknown;
8
+ readonly parent?: {
9
+ readonly type?: string;
10
+ readonly id?: string;
11
+ readonly name?: string;
12
+ };
13
+ }
14
+ export interface EndpointInstance<TResult = unknown> {
15
+ /** Allocates transport resources but must not admit inbound events yet. */
16
+ start?(): void | Promise<void>;
17
+ /** Opens admission after the candidate generation has committed. */
18
+ open?(): void;
19
+ /** Stops new inbound events while preserving in-flight work. */
20
+ close?(): void | Promise<void>;
21
+ /** Releases transport resources. Calls must be idempotent. */
22
+ stop?(): void | Promise<void>;
23
+ send?(request: EndpointSendRequest): TResult | Promise<TResult>;
24
+ }
25
+ export interface AdapterContext<TConfig = unknown> extends CapabilityContext<TConfig> {
26
+ readonly id: CapabilityId;
27
+ readonly name: string;
28
+ }
29
+ export interface AdapterDefinition<TConfig = unknown, TResult = unknown> {
30
+ readonly $feature: typeof adapterBrand;
31
+ readonly capabilities: readonly AdapterCapability[];
32
+ create(context: AdapterContext<TConfig>): EndpointInstance<TResult> | Promise<EndpointInstance<TResult>>;
33
+ }
34
+ export declare function defineAdapter<TConfig = unknown, TResult = unknown>(definition: Omit<AdapterDefinition<TConfig, TResult>, '$feature'>): Readonly<AdapterDefinition<TConfig, TResult>>;
35
+ export declare function parseAdapterDefinition(value: unknown): AdapterDefinition;
36
+ export {};
@@ -0,0 +1,31 @@
1
+ const adapterBrand = 'zhin.adapter/1';
2
+ export function defineAdapter(definition) {
3
+ if (typeof definition.create !== 'function') {
4
+ throw new TypeError('Adapter create must be a function');
5
+ }
6
+ const capabilities = [...new Set(definition.capabilities)];
7
+ if (capabilities.length === 0
8
+ || capabilities.some((value) => value !== 'inbound' && value !== 'outbound')) {
9
+ throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
10
+ }
11
+ return Object.freeze({
12
+ ...definition,
13
+ $feature: adapterBrand,
14
+ capabilities: Object.freeze(capabilities),
15
+ });
16
+ }
17
+ export function parseAdapterDefinition(value) {
18
+ if (!value || typeof value !== 'object')
19
+ throw invalidAdapter();
20
+ const definition = value;
21
+ if (definition.$feature !== adapterBrand
22
+ || typeof definition.create !== 'function'
23
+ || !Array.isArray(definition.capabilities)
24
+ || definition.capabilities.length === 0
25
+ || definition.capabilities.some((capability) => capability !== 'inbound' && capability !== 'outbound'))
26
+ throw invalidAdapter();
27
+ return definition;
28
+ }
29
+ function invalidAdapter() {
30
+ return new TypeError('Adapter module must default-export defineAdapter(...)');
31
+ }
package/lib/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './adapter-index.js';
2
+ export * from './credentials.js';
3
+ export * from './definition.js';
4
+ export * from './provider.js';
5
+ export { default } from './provider.js';
package/lib/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './adapter-index.js';
2
+ export * from './credentials.js';
3
+ export * from './definition.js';
4
+ export * from './provider.js';
5
+ export { default } from './provider.js';
@@ -0,0 +1,5 @@
1
+ import { AdapterIndex } from './adapter-index.js';
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, unknown>, AdapterIndex>>;
4
+ export { adapterFeature };
5
+ export default adapterFeature;
@@ -0,0 +1,43 @@
1
+ import { featureId } from '@zhin.js/plugin-runtime';
2
+ import { defineFeatureProvider, typeScriptModules } from '@zhin.js/feature-kit';
3
+ import { AdapterIndex } from './adapter-index.js';
4
+ import { parseAdapterDefinition } from './definition.js';
5
+ export const adapterFeatureId = featureId('zhin.adapter');
6
+ const adapterFeature = defineFeatureProvider({
7
+ protocol: 1,
8
+ id: adapterFeatureId,
9
+ authoring: {
10
+ conventions: [typeScriptModules({
11
+ id: 'adapters-ts',
12
+ directory: 'adapters',
13
+ })],
14
+ validate: parseAdapterDefinition,
15
+ },
16
+ runtime: {
17
+ async project(slots, context) {
18
+ const index = await AdapterIndex.create(slots, context.snapshot);
19
+ let previousIndex;
20
+ return {
21
+ value: index,
22
+ dispose: () => index.stop(),
23
+ handoff: {
24
+ quiescePrevious(previous) {
25
+ previousIndex = previousAdapterIndex(previous);
26
+ return previousIndex?.close();
27
+ },
28
+ activateNext: () => index.start(),
29
+ deactivateNext: () => index.stop(),
30
+ resumePrevious() {
31
+ previousIndex?.open();
32
+ },
33
+ openNext: () => index.open(),
34
+ },
35
+ };
36
+ },
37
+ },
38
+ });
39
+ function previousAdapterIndex(snapshot) {
40
+ return snapshot.projections.get(adapterFeatureId);
41
+ }
42
+ export { adapterFeature };
43
+ export default adapterFeature;
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@zhin.js/adapter",
3
+ "version": "1.0.0",
4
+ "description": "Convention-based Adapter and Endpoint Feature for Zhin Plugin Runtime",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "development": "./src/index.ts",
12
+ "import": "./lib/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "lib",
17
+ "src"
18
+ ],
19
+ "dependencies": {
20
+ "@zhin.js/logger": "1.0.74",
21
+ "@zhin.js/plugin-runtime": "1.0.0",
22
+ "@zhin.js/feature-kit": "1.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^26.1.0",
26
+ "typescript": "^6.0.3"
27
+ },
28
+ "zhin": {
29
+ "protocol": 1,
30
+ "type": "feature",
31
+ "entry": "./lib/provider.js",
32
+ "engine": "^1.0.0",
33
+ "featureApi": "1.0.0"
34
+ },
35
+ "engines": {
36
+ "node": "^20.19.0 || >=22.12.0"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/zhinjs/zhin.git",
41
+ "directory": "packages/im/adapter"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "registry": "https://registry.npmjs.org"
46
+ },
47
+ "license": "MIT",
48
+ "private": false,
49
+ "scripts": {
50
+ "build": "tsc",
51
+ "clean": "rimraf lib",
52
+ "test": "vitest run --root ../../.. packages/im/adapter/tests"
53
+ }
54
+ }
@@ -0,0 +1,467 @@
1
+ import {
2
+ DisposeStack,
3
+ type CapabilityId,
4
+ type CapabilitySlot,
5
+ type PluginId,
6
+ type RuntimeSnapshot,
7
+ } from '@zhin.js/plugin-runtime';
8
+ import { createCapabilityContext } from '@zhin.js/feature-kit';
9
+ import { formatCompact, getLogger } from '@zhin.js/logger';
10
+ import type {
11
+ AdapterCapability,
12
+ AdapterDefinition,
13
+ EndpointInstance,
14
+ EndpointSendRequest,
15
+ } from './definition.js';
16
+
17
+ const logger = getLogger('Adapter');
18
+
19
+ export interface AdapterDescriptor {
20
+ readonly id: CapabilityId;
21
+ readonly owner: PluginId;
22
+ readonly name: string;
23
+ readonly source: string;
24
+ readonly capabilities: readonly AdapterCapability[];
25
+ }
26
+
27
+ /** Console / Host-facing endpoint row (connected = admission open). */
28
+ export interface AdapterEndpointSummary extends AdapterDescriptor {
29
+ readonly connected: boolean;
30
+ readonly status: 'online' | 'offline';
31
+ readonly phase: AdapterEndpointPhase;
32
+ }
33
+
34
+ export type AdapterEndpointPhase =
35
+ 'pending' | 'starting' | 'online' | 'failed' | 'unconfigured';
36
+
37
+ interface AdapterRecord extends AdapterDescriptor {
38
+ readonly endpoint: EndpointInstance;
39
+ readonly unconfigured: boolean;
40
+ started: boolean;
41
+ open: boolean;
42
+ stopped: boolean;
43
+ /** Start rejected or was given up on — distinguishes 'failed' from 'unconfigured'. */
44
+ failed: boolean;
45
+ /** start() was invoked at least once (may still be in flight). */
46
+ startAttempted: boolean;
47
+ }
48
+
49
+ export class AdapterIndex {
50
+ readonly $projection = 'zhin.adapter-index/1' as const;
51
+ readonly #records = new Map<CapabilityId, AdapterRecord>();
52
+ readonly #order: readonly AdapterRecord[];
53
+ /** True after `open()` until `close()` / `stop()` — late starts may open themselves. */
54
+ #admissionOpen = false;
55
+ readonly #startTimeoutMs: number;
56
+ /** Final give-up budget for deferred starts (never-settling start promises). */
57
+ readonly #deferredGiveUpMs: number;
58
+
59
+ private constructor(
60
+ records: readonly AdapterRecord[],
61
+ startTimeoutMs: number,
62
+ deferredGiveUpMs: number,
63
+ ) {
64
+ this.#order = Object.freeze([...records]);
65
+ this.#startTimeoutMs = startTimeoutMs;
66
+ this.#deferredGiveUpMs = deferredGiveUpMs;
67
+ for (const record of records) this.#records.set(record.id, record);
68
+ }
69
+
70
+ static async create(
71
+ slots: readonly Readonly<CapabilitySlot<AdapterDefinition>>[],
72
+ snapshot: RuntimeSnapshot,
73
+ options: {
74
+ readonly startTimeoutMs?: number;
75
+ readonly deferredGiveUpMs?: number;
76
+ } = {},
77
+ ): Promise<AdapterIndex> {
78
+ const records: AdapterRecord[] = [];
79
+ const unconfigured: string[] = [];
80
+ try {
81
+ for (const slot of [...slots].sort((left, right) => left.id.localeCompare(right.id))) {
82
+ const endpoint = await createEndpointSoft(slot, snapshot);
83
+ if (endpoint.unconfigured) unconfigured.push(slot.localName);
84
+ records.push({
85
+ id: slot.id,
86
+ owner: slot.owner,
87
+ name: slot.localName,
88
+ source: slot.source,
89
+ capabilities: slot.definition.capabilities,
90
+ endpoint: endpoint.instance,
91
+ unconfigured: endpoint.unconfigured,
92
+ started: false,
93
+ open: false,
94
+ failed: false,
95
+ startAttempted: false,
96
+ // Unconfigured stubs skip start/open so kitchen-sink Roots stay quiet.
97
+ stopped: endpoint.unconfigured,
98
+ });
99
+ }
100
+ if (unconfigured.length > 0) {
101
+ logger.info(formatCompact({
102
+ op: 'adapters_unconfigured',
103
+ count: unconfigured.length,
104
+ names: unconfigured.join(','),
105
+ }));
106
+ }
107
+ return new AdapterIndex(
108
+ records,
109
+ options.startTimeoutMs ?? 3_000,
110
+ options.deferredGiveUpMs ?? 60_000,
111
+ );
112
+ } catch (error) {
113
+ await stopRecords(records, error);
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ list(): readonly AdapterDescriptor[] {
119
+ return this.#order.map(({ endpoint: _endpoint, unconfigured: _unconfigured,
120
+ started: _started, open: _open, stopped: _stopped, failed: _failed,
121
+ startAttempted: _startAttempted, ...descriptor }) => Object.freeze(descriptor));
122
+ }
123
+
124
+ /** Endpoint rows for Console `endpoint.list` / `endpoint.info`. */
125
+ describe(): readonly AdapterEndpointSummary[] {
126
+ return Object.freeze(this.#order.map((record) => Object.freeze({
127
+ id: record.id,
128
+ owner: record.owner,
129
+ // Console 展示用 live name(如 ICQQ uin、sandbox bot 名),缺省回退 slot localName
130
+ name: endpointLiveName(record.endpoint) ?? record.name,
131
+ source: record.source,
132
+ capabilities: record.capabilities,
133
+ connected: record.open && !record.stopped,
134
+ status: record.open && !record.stopped ? 'online' as const : 'offline' as const,
135
+ phase: endpointPhase(record),
136
+ })));
137
+ }
138
+
139
+ /**
140
+ * Resolve a Console `$adapter` + `$endpoint` pair to a capability id.
141
+ * Matches local name, capability id, or owner path segments.
142
+ */
143
+ resolve(adapter: string, endpointId: string): CapabilityId | undefined {
144
+ const matches = this.#order.filter((record) =>
145
+ matchesEndpoint(record, adapter, endpointId));
146
+ if (matches.length === 1) return matches[0]?.id;
147
+ if (matches.length === 0) return undefined;
148
+ // Prefer exact localName === endpointId when ambiguous.
149
+ const exact = matches.find((record) => record.name === endpointId);
150
+ return exact?.id ?? matches[0]?.id;
151
+ }
152
+
153
+ /**
154
+ * Resolve a live EndpointInstance for Host-side side channels (reactions, etc.).
155
+ */
156
+ instance(adapter: string, endpointId: string): EndpointInstance | undefined {
157
+ const id = this.resolve(adapter, endpointId);
158
+ if (!id) return undefined;
159
+ return this.#records.get(id)?.endpoint;
160
+ }
161
+
162
+ owner(id: CapabilityId): PluginId {
163
+ const record = this.#records.get(id);
164
+ if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
165
+ return record.owner;
166
+ }
167
+
168
+ async start(): Promise<void> {
169
+ // Soft-start in parallel with a short wait so kitchen-sink Roots do not
170
+ // stall generation. Configured platforms that need longer (QQ auth, Slack
171
+ // socket, GitHub verify) stay in-flight instead of being stop()'d mid-connect.
172
+ const startTimeoutMs = this.#startTimeoutMs;
173
+ await Promise.all(this.#order.map(async (record) => {
174
+ if (record.started || record.stopped) return;
175
+ record.startAttempted = true;
176
+ const startPromise = (async () => record.endpoint.start?.())();
177
+ try {
178
+ await withTimeout(
179
+ startPromise,
180
+ startTimeoutMs,
181
+ `Adapter start timed out after ${startTimeoutMs}ms`,
182
+ );
183
+ if (record.stopped) return;
184
+ record.started = true;
185
+ } catch (error) {
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ if (message.includes('timed out after')) {
188
+ logger.info(formatCompact({
189
+ op: 'adapter_start_deferred',
190
+ id: record.id,
191
+ name: record.name,
192
+ waitMs: startTimeoutMs,
193
+ }));
194
+ // Final backstop: a deferred start promise that never settles must
195
+ // not keep the Endpoint in limbo forever.
196
+ const giveUp = setTimeout(() => {
197
+ if (record.stopped || record.started) return;
198
+ record.stopped = true;
199
+ record.failed = true;
200
+ // Swallow a late rejection so it does not become unhandled.
201
+ void startPromise.catch(() => undefined);
202
+ logger.warn(formatCompact({
203
+ op: 'adapter_start_give_up',
204
+ id: record.id,
205
+ name: record.name,
206
+ waitMs: this.#deferredGiveUpMs,
207
+ }));
208
+ }, this.#deferredGiveUpMs);
209
+ giveUp.unref?.();
210
+ void startPromise.then(
211
+ () => {
212
+ clearTimeout(giveUp);
213
+ if (record.stopped || record.started) return;
214
+ record.started = true;
215
+ if (this.#admissionOpen && !record.open) {
216
+ try {
217
+ record.endpoint.open?.();
218
+ record.open = true;
219
+ } catch (openError) {
220
+ logger.warn(formatCompact({
221
+ op: 'adapter_open_after_deferred_fail',
222
+ id: record.id,
223
+ name: record.name,
224
+ error: openError instanceof Error ? openError.message : String(openError),
225
+ }));
226
+ }
227
+ }
228
+ },
229
+ (startError) => {
230
+ clearTimeout(giveUp);
231
+ if (record.stopped) return;
232
+ record.stopped = true;
233
+ record.failed = true;
234
+ logger.warn(formatCompact({
235
+ op: 'adapter_start_soft_fail',
236
+ id: record.id,
237
+ name: record.name,
238
+ error: startError instanceof Error ? startError.message : String(startError),
239
+ stack: startError instanceof Error ? startError.stack : undefined,
240
+ }));
241
+ },
242
+ );
243
+ return;
244
+ }
245
+ record.stopped = true;
246
+ record.failed = true;
247
+ void startPromise.catch(() => undefined);
248
+ // Startup connect failures are logged once here (with stack); Endpoint
249
+ // implementations must NOT re-log them at error level.
250
+ logger.warn(formatCompact({
251
+ op: 'adapter_start_soft_fail',
252
+ id: record.id,
253
+ name: record.name,
254
+ error: message,
255
+ stack: error instanceof Error ? error.stack : undefined,
256
+ }));
257
+ // No endpoint.stop() here: adapter Endpoints self-stop in their start()
258
+ // catch by convention (verified across icqq/qq/slack/… endpoints).
259
+ }
260
+ }));
261
+ }
262
+
263
+ open(): void {
264
+ this.#admissionOpen = true;
265
+ const errors: unknown[] = [];
266
+ for (const record of this.#order) {
267
+ if (!record.started || record.open || record.stopped) continue;
268
+ try {
269
+ record.endpoint.open?.();
270
+ record.open = true;
271
+ } catch (error) {
272
+ errors.push(error);
273
+ }
274
+ }
275
+ if (errors.length > 0) throw new AggregateError(errors, 'Adapter Endpoint open failed');
276
+ }
277
+
278
+ async close(): Promise<void> {
279
+ this.#admissionOpen = false;
280
+ const stack = new DisposeStack();
281
+ for (const record of this.#order) {
282
+ if (!record.open || record.stopped) continue;
283
+ stack.add(async () => {
284
+ await record.endpoint.close?.();
285
+ record.open = false;
286
+ });
287
+ }
288
+ await stack.dispose();
289
+ }
290
+
291
+ async stop(): Promise<void> {
292
+ const stack = new DisposeStack();
293
+ // DisposeStack unwinds in reverse: admission closes before transports stop,
294
+ // and a close failure cannot skip transport cleanup.
295
+ stack.add(() => stopRecords(this.#order));
296
+ stack.add(() => this.close());
297
+ await stack.dispose();
298
+ }
299
+
300
+ async send(id: CapabilityId, request: EndpointSendRequest): Promise<unknown> {
301
+ const record = this.#records.get(id);
302
+ if (!record) throw new Error(`Unknown Adapter Endpoint: ${id}`);
303
+ if (!record.capabilities.includes('outbound') || !record.endpoint.send) {
304
+ throw new Error(`Adapter Endpoint does not support outbound: ${id}`);
305
+ }
306
+ if (!record.started || record.stopped) {
307
+ throw new Error(`Adapter Endpoint is not active: ${id}`);
308
+ }
309
+ return record.endpoint.send(request);
310
+ }
311
+ }
312
+
313
+ export function isAdapterIndex(value: unknown): value is AdapterIndex {
314
+ return !!value && typeof value === 'object'
315
+ && (value as { readonly $projection?: unknown }).$projection === 'zhin.adapter-index/1';
316
+ }
317
+
318
+ function matchesEndpoint(
319
+ record: AdapterRecord,
320
+ adapter: string,
321
+ endpointId: string,
322
+ ): boolean {
323
+ const adapterOk = record.name === adapter
324
+ || record.id === adapter
325
+ || record.id.endsWith(`/${adapter}`)
326
+ || record.owner === adapter
327
+ || record.owner.endsWith(`/${adapter}`);
328
+ // Live EndpointInstance.name is the bot runtime id (e.g. ICQQ uin). Host /
329
+ // activity-feedback resolve with that id; slot.localName alone is not enough
330
+ // when multiple plugin instances share localName "icqq".
331
+ const liveName = endpointLiveName(record.endpoint);
332
+ const endpointOk = record.name === endpointId
333
+ || record.id === endpointId
334
+ || record.id.endsWith(`/${endpointId}`)
335
+ || (liveName !== undefined && liveName === endpointId);
336
+ return adapterOk && endpointOk;
337
+ }
338
+
339
+ function endpointLiveName(endpoint: EndpointInstance): string | undefined {
340
+ const name = (endpoint as { readonly name?: unknown }).name;
341
+ return typeof name === 'string' && name.length > 0 ? name : undefined;
342
+ }
343
+
344
+ function endpointPhase(record: AdapterRecord): AdapterEndpointPhase {
345
+ if (record.unconfigured) return 'unconfigured';
346
+ if (record.failed) return 'failed';
347
+ if (record.open && !record.stopped) return 'online';
348
+ if (record.startAttempted) return 'starting';
349
+ return 'pending';
350
+ }
351
+
352
+ function assertEndpoint(value: unknown, id: CapabilityId): asserts value is EndpointInstance {
353
+ if (!value || typeof value !== 'object') {
354
+ throw new TypeError(`Adapter ${id} create() must return an Endpoint instance`);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Adapter `resolveXxxConfig` helpers report missing config/credentials as
360
+ * TypeError("… requires …") by convention; only those are expected failures.
361
+ */
362
+ function isUnconfiguredError(error: unknown): boolean {
363
+ return (
364
+ error instanceof TypeError
365
+ && /requires|not configured|missing|未配置|缺少/i.test(error.message)
366
+ );
367
+ }
368
+
369
+ async function createEndpointSoft(
370
+ slot: Readonly<CapabilitySlot<AdapterDefinition>>,
371
+ snapshot: RuntimeSnapshot,
372
+ ): Promise<{ readonly instance: EndpointInstance; readonly unconfigured: boolean }> {
373
+ let endpoint: unknown;
374
+ try {
375
+ endpoint = await slot.definition.create(
376
+ Object.freeze({
377
+ ...createCapabilityContext(snapshot, slot.owner),
378
+ id: slot.id,
379
+ name: slot.localName,
380
+ }),
381
+ );
382
+ } catch (error) {
383
+ // Missing config / credentials: degrade to an inert stub so the rest of
384
+ // the generation still boots. Anything else (network failures, bugs in
385
+ // create()) is unexpected — keep the stub but surface a warning instead
386
+ // of silently swallowing it at debug level.
387
+ const message = error instanceof Error ? error.message : String(error);
388
+ const log = isUnconfiguredError(error) ? logger.debug.bind(logger) : logger.warn.bind(logger);
389
+ log(formatCompact({
390
+ op: 'adapter_create_soft_fail',
391
+ id: slot.id,
392
+ name: slot.localName,
393
+ error: message,
394
+ }));
395
+ return {
396
+ instance: createUnconfiguredEndpoint(message),
397
+ unconfigured: true,
398
+ };
399
+ }
400
+ // Programming errors (create() did not return an Endpoint) must surface:
401
+ // they propagate to AdapterIndex.create's catch, which disposes the records
402
+ // created so far instead of hiding the bug behind an unconfigured stub.
403
+ assertEndpoint(endpoint, slot.id);
404
+ return { instance: endpoint, unconfigured: false };
405
+ }
406
+
407
+ function createUnconfiguredEndpoint(reason: string): EndpointInstance {
408
+ return Object.freeze({
409
+ start() {
410
+ throw new Error(`Adapter unconfigured: ${reason}`);
411
+ },
412
+ open() {},
413
+ close() {},
414
+ stop() {},
415
+ send() {
416
+ throw new Error(`Adapter unconfigured: ${reason}`);
417
+ },
418
+ });
419
+ }
420
+
421
+ function withTimeout<T>(
422
+ promise: Promise<T> | T | undefined,
423
+ ms: number,
424
+ message: string,
425
+ ): Promise<T | undefined> {
426
+ if (promise === undefined) return Promise.resolve(undefined);
427
+ return new Promise<T | undefined>((resolve, reject) => {
428
+ const timer = setTimeout(() => reject(new Error(message)), ms);
429
+ Promise.resolve(promise).then(
430
+ (value) => {
431
+ clearTimeout(timer);
432
+ resolve(value);
433
+ },
434
+ (error) => {
435
+ clearTimeout(timer);
436
+ reject(error);
437
+ },
438
+ );
439
+ });
440
+ }
441
+
442
+ async function stopRecords(
443
+ records: readonly AdapterRecord[],
444
+ primaryError?: unknown,
445
+ ): Promise<void> {
446
+ const stack = new DisposeStack();
447
+ for (const record of records) {
448
+ if (record.stopped) continue;
449
+ stack.add(async () => {
450
+ record.stopped = true;
451
+ record.open = false;
452
+ await record.endpoint.stop?.();
453
+ });
454
+ }
455
+ try {
456
+ await stack.dispose();
457
+ } catch (stopError) {
458
+ if (primaryError !== undefined) {
459
+ throw new AggregateError(
460
+ [primaryError, stopError],
461
+ 'Adapter prepare and Endpoint cleanup both failed',
462
+ { cause: stopError },
463
+ );
464
+ }
465
+ throw stopError;
466
+ }
467
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Pick an explicit credential string.
3
+ * The first `typeof === 'string'` wins — including `""` — so an empty config
4
+ * field disables `process.env` fallbacks (kitchen-sink quiet boot).
5
+ */
6
+ export function pickCredential(...candidates: unknown[]): string {
7
+ for (const value of candidates) {
8
+ if (typeof value === 'string') return value;
9
+ }
10
+ return '';
11
+ }
@@ -0,0 +1,76 @@
1
+ import type { CapabilityId } from '@zhin.js/plugin-runtime';
2
+ import type { CapabilityContext } from '@zhin.js/feature-kit';
3
+
4
+ const adapterBrand = 'zhin.adapter/1' as const;
5
+
6
+ export type AdapterCapability = 'inbound' | 'outbound';
7
+
8
+ export interface EndpointSendRequest {
9
+ readonly target: string;
10
+ readonly payload: unknown;
11
+ readonly parent?: { readonly type?: string; readonly id?: string; readonly name?: string };
12
+ }
13
+
14
+ export interface EndpointInstance<TResult = unknown> {
15
+ /** Allocates transport resources but must not admit inbound events yet. */
16
+ start?(): void | Promise<void>;
17
+ /** Opens admission after the candidate generation has committed. */
18
+ open?(): void;
19
+ /** Stops new inbound events while preserving in-flight work. */
20
+ close?(): void | Promise<void>;
21
+ /** Releases transport resources. Calls must be idempotent. */
22
+ stop?(): void | Promise<void>;
23
+ send?(request: EndpointSendRequest): TResult | Promise<TResult>;
24
+ }
25
+
26
+ export interface AdapterContext<TConfig = unknown> extends CapabilityContext<TConfig> {
27
+ readonly id: CapabilityId;
28
+ readonly name: string;
29
+ }
30
+
31
+ export interface AdapterDefinition<TConfig = unknown, TResult = unknown> {
32
+ readonly $feature: typeof adapterBrand;
33
+ readonly capabilities: readonly AdapterCapability[];
34
+ create(
35
+ context: AdapterContext<TConfig>,
36
+ ): EndpointInstance<TResult> | Promise<EndpointInstance<TResult>>;
37
+ }
38
+
39
+ export function defineAdapter<TConfig = unknown, TResult = unknown>(
40
+ definition: Omit<AdapterDefinition<TConfig, TResult>, '$feature'>,
41
+ ): Readonly<AdapterDefinition<TConfig, TResult>> {
42
+ if (typeof definition.create !== 'function') {
43
+ throw new TypeError('Adapter create must be a function');
44
+ }
45
+ const capabilities = [...new Set(definition.capabilities)];
46
+ if (
47
+ capabilities.length === 0
48
+ || capabilities.some((value) => value !== 'inbound' && value !== 'outbound')
49
+ ) {
50
+ throw new TypeError('Adapter capabilities must contain inbound and/or outbound');
51
+ }
52
+ return Object.freeze({
53
+ ...definition,
54
+ $feature: adapterBrand,
55
+ capabilities: Object.freeze(capabilities),
56
+ });
57
+ }
58
+
59
+ export function parseAdapterDefinition(value: unknown): AdapterDefinition {
60
+ if (!value || typeof value !== 'object') throw invalidAdapter();
61
+ const definition = value as Partial<AdapterDefinition>;
62
+ if (
63
+ definition.$feature !== adapterBrand
64
+ || typeof definition.create !== 'function'
65
+ || !Array.isArray(definition.capabilities)
66
+ || definition.capabilities.length === 0
67
+ || definition.capabilities.some(
68
+ (capability) => capability !== 'inbound' && capability !== 'outbound',
69
+ )
70
+ ) throw invalidAdapter();
71
+ return definition as AdapterDefinition;
72
+ }
73
+
74
+ function invalidAdapter(): TypeError {
75
+ return new TypeError('Adapter module must default-export defineAdapter(...)');
76
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './adapter-index.js';
2
+ export * from './credentials.js';
3
+ export * from './definition.js';
4
+ export * from './provider.js';
5
+ export { default } from './provider.js';
@@ -0,0 +1,47 @@
1
+ import { featureId, type RuntimeSnapshot } from '@zhin.js/plugin-runtime';
2
+ import { defineFeatureProvider, typeScriptModules } from '@zhin.js/feature-kit';
3
+ import { AdapterIndex } from './adapter-index.js';
4
+ import { parseAdapterDefinition } from './definition.js';
5
+
6
+ export const adapterFeatureId = featureId('zhin.adapter');
7
+
8
+ const adapterFeature = defineFeatureProvider({
9
+ protocol: 1,
10
+ id: adapterFeatureId,
11
+ authoring: {
12
+ conventions: [typeScriptModules({
13
+ id: 'adapters-ts',
14
+ directory: 'adapters',
15
+ })],
16
+ validate: parseAdapterDefinition,
17
+ },
18
+ runtime: {
19
+ async project(slots, context) {
20
+ const index = await AdapterIndex.create(slots, context.snapshot);
21
+ let previousIndex: AdapterIndex | undefined;
22
+ return {
23
+ value: index,
24
+ dispose: () => index.stop(),
25
+ handoff: {
26
+ quiescePrevious(previous) {
27
+ previousIndex = previousAdapterIndex(previous);
28
+ return previousIndex?.close();
29
+ },
30
+ activateNext: () => index.start(),
31
+ deactivateNext: () => index.stop(),
32
+ resumePrevious() {
33
+ previousIndex?.open();
34
+ },
35
+ openNext: () => index.open(),
36
+ },
37
+ };
38
+ },
39
+ },
40
+ });
41
+
42
+ function previousAdapterIndex(snapshot: RuntimeSnapshot): AdapterIndex | undefined {
43
+ return snapshot.projections.get(adapterFeatureId) as AdapterIndex | undefined;
44
+ }
45
+
46
+ export { adapterFeature };
47
+ export default adapterFeature;