@sokel-dev/plugin-sdk 0.2.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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * 事件源:插件主动把外部事件推给平台起 workflow(协议 §7)。
3
+ *
4
+ * 与操作的区别:操作是 request/reply(平台调插件),事件是 fire-and-forget(插件推平台)。
5
+ *
6
+ * 多 bot 单实例(协议 v1.3):平台每次注册/心跳下发「分配给本副本的凭证子集」,
7
+ * supervisor 按它 reconcile —— 每个凭证一套源实例,凭证被移除就取消,字段变了就重启。
8
+ */
9
+ import { toVars } from "./runtime.js";
10
+ export const TRIGGER_SUBJECT = "sokel.trigger";
11
+ export const CREDENTIAL_UPDATE_SUBJECT = "sokel.credential.update";
12
+ /** 注册回包 credentials 列表项 —— 分配给本副本的一个 bot 身份。 */
13
+ export class CredEntry {
14
+ id;
15
+ fields;
16
+ constructor(id = "", fields = {}) {
17
+ this.id = id;
18
+ this.fields = fields;
19
+ }
20
+ /** 字段的稳定签名:reconcile 据此判定「字段变更 → 重启该源实例」。 */
21
+ sig() {
22
+ return Object.keys(this.fields)
23
+ .sort()
24
+ .map((k) => `${k}=${this.fields[k]}`)
25
+ .join("\n");
26
+ }
27
+ }
28
+ /** 源实例运行态(源 × 凭证)。随注册/心跳上报,面板据此展示每个 bot。 */
29
+ export class StateBoard {
30
+ now;
31
+ m = new Map();
32
+ constructor(now = () => new Date().toISOString()) {
33
+ this.now = now;
34
+ }
35
+ set(sourceId, credId, status, error = "") {
36
+ const st = { source_id: sourceId, status, since: this.now() };
37
+ if (credId)
38
+ st.credential_id = credId;
39
+ if (error)
40
+ st.error = error;
41
+ this.m.set(`${sourceId}|${credId}`, st);
42
+ }
43
+ /**
44
+ * 只在该实例仍是 running 时改写。
45
+ *
46
+ * 源自报过状态(如 auth_required)之后正常返回,收尾时不该把那句话盖掉——
47
+ * 盖掉之后面板上只剩一个「已退出」,而「为什么退出」正是要看的那一半。
48
+ */
49
+ setIfRunning(sourceId, credId, status, error = "") {
50
+ const cur = this.m.get(`${sourceId}|${credId}`);
51
+ if (!cur || cur.status === "running")
52
+ this.set(sourceId, credId, status, error);
53
+ }
54
+ removeCred(credId) {
55
+ for (const [k, v] of this.m) {
56
+ if ((v.credential_id ?? "") === credId)
57
+ this.m.delete(k);
58
+ }
59
+ }
60
+ snapshot() {
61
+ return [...this.m.values()].sort((a, b) => a.source_id === b.source_id
62
+ ? (a.credential_id ?? "").localeCompare(b.credential_id ?? "")
63
+ : a.source_id.localeCompare(b.source_id));
64
+ }
65
+ }
66
+ /** 常驻事件源 / webhook 的上下文:推事件、读凭证、回写凭证、上传附件、自报状态。 */
67
+ export class SourceCtx {
68
+ credential;
69
+ credentialId;
70
+ sourceId;
71
+ /** stopped:该源实例被 reconcile 停止时置真。长轮询循环 while (!ctx.stopped)。 */
72
+ stopped = false;
73
+ token;
74
+ publish;
75
+ validEvents;
76
+ board;
77
+ files;
78
+ constructor(opts) {
79
+ this.token = opts.token;
80
+ this.publish = opts.publish;
81
+ this.validEvents = new Set(opts.validEvents ?? []);
82
+ this.credential = opts.credential ?? {};
83
+ this.credentialId = opts.credentialId ?? "";
84
+ this.sourceId = opts.sourceId ?? "";
85
+ this.board = opts.board;
86
+ this.files = opts.files;
87
+ }
88
+ /** 凭证按类型化形状读出(与操作侧 Ctx.credentialAs 同义)。 */
89
+ credentialAs() {
90
+ return this.credential;
91
+ }
92
+ /**
93
+ * 推一条事件(fire-and-forget)。
94
+ *
95
+ * event 必须是已声明的事件 id —— 拼错在这里当场报错,而不是变成一条平台侧无人认领的
96
+ * 消息(那种失败没有任何症状:插件日志正常,工作流就是不起)。
97
+ * eventId 是幂等键,平台按 (plugin, event, eventId) 去重。
98
+ */
99
+ async trigger(event, eventId, payload) {
100
+ if (this.validEvents.size > 0 && !this.validEvents.has(event)) {
101
+ throw new Error(`未声明的事件 "${event}"(先在 sokel.yaml 的 events 里声明)`);
102
+ }
103
+ const msg = {
104
+ token: this.token,
105
+ event,
106
+ payload: toVars(payload),
107
+ };
108
+ if (eventId)
109
+ msg.event_id = eventId;
110
+ if (this.credentialId)
111
+ msg.credential_id = this.credentialId;
112
+ await this.publish(TRIGGER_SUBJECT, encode(msg));
113
+ }
114
+ /**
115
+ * 把 patch 回写到本实例绑定的平台凭证(会话型凭证运行中刷新用)。
116
+ * 平台是唯一凭证存储方,插件本地从不落地凭证。
117
+ */
118
+ async updateCredential(patch) {
119
+ if (!this.credentialId)
120
+ throw new Error("本源实例未绑定凭证,无可回写目标");
121
+ if (Object.keys(patch).length === 0)
122
+ return;
123
+ Object.assign(this.credential, patch);
124
+ await this.publish(CREDENTIAL_UPDATE_SUBJECT, encode({ token: this.token, credential_id: this.credentialId, patch }));
125
+ }
126
+ /** 自报运行态(如 session 失效 → auth_required),随心跳上报,面板亮「待登录」。 */
127
+ reportStatus(status, msg = "") {
128
+ this.board?.set(this.sourceId, this.credentialId, status, msg);
129
+ }
130
+ async upload(name, mime, data) {
131
+ if (!this.files)
132
+ return { name, mime, size: data.length, data };
133
+ return this.files.store(name, mime, data);
134
+ }
135
+ async fetch(f) {
136
+ if (f.data)
137
+ return f.data;
138
+ if (!this.files)
139
+ throw new Error("文件运行时未就绪");
140
+ return this.files.fetch(f);
141
+ }
142
+ }
143
+ function encode(v) {
144
+ return new TextEncoder().encode(JSON.stringify(v));
145
+ }
146
+ /** per-credential 源实例监督器:按平台下发的凭证集合起停/重启。 */
147
+ export class SourceSupervisor {
148
+ start;
149
+ running = new Map();
150
+ constructor(start) {
151
+ this.start = start;
152
+ }
153
+ reconcile(desired) {
154
+ const want = new Map(desired.map((c) => [c.id, c]));
155
+ // 停:不在期望集合,或字段变更(先停后起 = 重启)
156
+ for (const [id, r] of [...this.running]) {
157
+ const c = want.get(id);
158
+ if (c && c.sig() === r.sig)
159
+ continue;
160
+ r.stop();
161
+ this.running.delete(id);
162
+ }
163
+ // 起:期望但未运行
164
+ for (const [id, c] of want) {
165
+ if (this.running.has(id))
166
+ continue;
167
+ this.running.set(id, { stop: this.start(c), sig: c.sig() });
168
+ }
169
+ }
170
+ stopAll() {
171
+ for (const r of this.running.values())
172
+ r.stop();
173
+ this.running.clear();
174
+ }
175
+ }
176
+ /** 空(无凭证插件)→ 一个空凭证裸实例,与有凭证时同一条代码路径。 */
177
+ export function desiredSourceCreds(creds) {
178
+ return creds.length > 0 ? creds : [new CredEntry()];
179
+ }
180
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../../src/events.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Sokel plugin SDK for Node.js / TypeScript.
3
+ *
4
+ * 契约在 sokel.yaml 里声明(语言中立),`sokel-gen generate` 生成类型化的接口与注册口;
5
+ * 本包提供运行时:注册握手、心跳、调用分发、文件分块、事件触发、webhook、协作式认证。
6
+ *
7
+ * ```ts
8
+ * import { Plugin } from "@sokel-dev/plugin-sdk";
9
+ * import { CONTRACT, onIssuesList } from "./sokel.gen.js";
10
+ *
11
+ * const p = new Plugin({ contract: CONTRACT, name: "gitlab" });
12
+ * onIssuesList(p, async (ctx, in_) => ({ issues: [], count: 0 }));
13
+ * await p.run();
14
+ * ```
15
+ */
16
+ export { Plugin } from "./plugin.js";
17
+ export type { Call, Config, Invoke, WebhookHandler } from "./plugin.js";
18
+ export { Contract, CAP_WEBHOOK, OP_WEBHOOK } from "./contract.js";
19
+ export type { ContractData, EventSpec, Field, OperationSpec } from "./contract.js";
20
+ export { BufferSink, Ctx, Emitter } from "./runtime.js";
21
+ export type { FileRuntime, Frame, Sink, SokelFile } from "./runtime.js";
22
+ export { CredEntry, SourceCtx, SourceSupervisor, StateBoard, desiredSourceCreds } from "./events.js";
23
+ export type { Source } from "./events.js";
24
+ export { WebhookRequest, ok, text } from "./webhook.js";
25
+ export type { WebhookFrame, WebhookResponse } from "./webhook.js";
26
+ export { CONFIRMED, EXPIRED, PENDING, SCANNED } from "./auth.js";
27
+ export type { AuthChallenge, AuthHandlers, AuthState } from "./auth.js";
28
+ export { env, envOr } from "./env.js";
29
+ export { NatsTransport, QUEUE_GROUP, discover, stableInstanceId } from "./nats.js";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Sokel plugin SDK for Node.js / TypeScript.
3
+ *
4
+ * 契约在 sokel.yaml 里声明(语言中立),`sokel-gen generate` 生成类型化的接口与注册口;
5
+ * 本包提供运行时:注册握手、心跳、调用分发、文件分块、事件触发、webhook、协作式认证。
6
+ *
7
+ * ```ts
8
+ * import { Plugin } from "@sokel-dev/plugin-sdk";
9
+ * import { CONTRACT, onIssuesList } from "./sokel.gen.js";
10
+ *
11
+ * const p = new Plugin({ contract: CONTRACT, name: "gitlab" });
12
+ * onIssuesList(p, async (ctx, in_) => ({ issues: [], count: 0 }));
13
+ * await p.run();
14
+ * ```
15
+ */
16
+ export { Plugin } from "./plugin.js";
17
+ export { Contract, CAP_WEBHOOK, OP_WEBHOOK } from "./contract.js";
18
+ export { BufferSink, Ctx, Emitter } from "./runtime.js";
19
+ export { CredEntry, SourceCtx, SourceSupervisor, StateBoard, desiredSourceCreds } from "./events.js";
20
+ export { WebhookRequest, ok, text } from "./webhook.js";
21
+ export { CONFIRMED, EXPIRED, PENDING, SCANNED } from "./auth.js";
22
+ export { env, envOr } from "./env.js";
23
+ export { NatsTransport, QUEUE_GROUP, discover, stableInstanceId } from "./nats.js";
24
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAErC,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAElE,OAAO,EAAE,UAAU,EAAE,GAAG,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AAExD,OAAO,EAAE,SAAS,EAAE,SAAS,EAAE,gBAAgB,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAErG,OAAO,EAAE,cAAc,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAExD,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAC"}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * NATS 承载:插件**出站**连 broker(无入站端口、无公网 IP、无防火墙洞)。
3
+ *
4
+ * 流程与 Go / Python SDK 逐条对齐(协议 §1-§7):发现 → 连接 → 注册(上报契约)→
5
+ * 订阅(队列组)→ 心跳续约 → 分发调用 → 优雅下线。事件源插件另有 per-credential supervisor。
6
+ */
7
+ import type { NatsConnection } from "nats";
8
+ import type { Plugin } from "./plugin.js";
9
+ import type { FileRuntime, SokelFile } from "./runtime.js";
10
+ /** 同组多副本共享队列组 → 每次调用只投一个副本(真·负载均衡)。 */
11
+ export declare const QUEUE_GROUP = "sokel-workers";
12
+ /** 回包/流帧自报副本身份。 */
13
+ export declare const INSTANCE_HEADER = "Sokel-Instance";
14
+ /** 经同一条 NATS 连接与平台交换文件字节。不要求插件可达平台 HTTP —— 内网插件同样可用。 */
15
+ export declare class NatsFiles implements FileRuntime {
16
+ private readonly nc;
17
+ private readonly token;
18
+ constructor(nc: NatsConnection, token: string);
19
+ fetch(f: SokelFile): Promise<Uint8Array>;
20
+ store(name: string, mime: string, data: Uint8Array): Promise<SokelFile>;
21
+ }
22
+ export declare class NatsTransport {
23
+ run(p: Plugin): Promise<void>;
24
+ private dispatch;
25
+ }
26
+ /**
27
+ * 统一 https 端点 → 经平台 /connect-info 发现真实承载地址。
28
+ * 直填 nats:// / tls:// 时跳过发现(本地开发 / 离线场景)。
29
+ */
30
+ export declare function discover(endpoint: string, token: string): Promise<string>;
31
+ /**
32
+ * 副本的稳定身份:重启复用,否则平台侧每次重启都多出一行永远 offline 的幽灵实例。
33
+ * 1) SOKEL_INSTANCE_ID;2) 工作目录里按 token 指纹命名的落盘文件;3) 落盘失败 → host-pid。
34
+ */
35
+ export declare function stableInstanceId(token: string): string;
@@ -0,0 +1,363 @@
1
+ /**
2
+ * NATS 承载:插件**出站**连 broker(无入站端口、无公网 IP、无防火墙洞)。
3
+ *
4
+ * 流程与 Go / Python SDK 逐条对齐(协议 §1-§7):发现 → 连接 → 注册(上报契约)→
5
+ * 订阅(队列组)→ 心跳续约 → 分发调用 → 优雅下线。事件源插件另有 per-credential supervisor。
6
+ */
7
+ import { connect, headers as natsHeaders } from "nats";
8
+ import { hostname } from "node:os";
9
+ import { readFileSync, writeFileSync } from "node:fs";
10
+ import { createHash, randomBytes } from "node:crypto";
11
+ import { OP_WEBHOOK } from "./contract.js";
12
+ import { env } from "./env.js";
13
+ import { CredEntry, SourceCtx, SourceSupervisor, desiredSourceCreds } from "./events.js";
14
+ /** 同组多副本共享队列组 → 每次调用只投一个副本(真·负载均衡)。 */
15
+ export const QUEUE_GROUP = "sokel-workers";
16
+ /** 回包/流帧自报副本身份。 */
17
+ export const INSTANCE_HEADER = "Sokel-Instance";
18
+ /** 1 MiB:字节不走操作 reply(受 max_payload 约束),走专用分块通道。 */
19
+ const FILE_CHUNK = 1 << 20;
20
+ const HEARTBEAT_MS = 20_000;
21
+ const RETRY_MS = 8_000;
22
+ const REQUEST_TIMEOUT_MS = 8_000;
23
+ const FILE_TIMEOUT_MS = 30_000;
24
+ const enc = new TextEncoder();
25
+ const dec = new TextDecoder();
26
+ /** 经同一条 NATS 连接与平台交换文件字节。不要求插件可达平台 HTTP —— 内网插件同样可用。 */
27
+ export class NatsFiles {
28
+ nc;
29
+ token;
30
+ constructor(nc, token) {
31
+ this.nc = nc;
32
+ this.token = token;
33
+ }
34
+ async fetch(f) {
35
+ const id = f.id || (f.url ? f.url.split("/").pop() : "");
36
+ if (!id)
37
+ throw new Error("文件引用缺少 id/url");
38
+ const chunks = [];
39
+ for (let seq = 0;; seq++) {
40
+ const resp = await this.nc.request("sokel.file.get", enc.encode(JSON.stringify({ token: this.token, id, seq })), { timeout: FILE_TIMEOUT_MS });
41
+ const r = JSON.parse(dec.decode(resp.data));
42
+ if (r.error)
43
+ throw new Error(r.error);
44
+ chunks.push(Buffer.from(r.data ?? "", "base64"));
45
+ if (r.last)
46
+ return Buffer.concat(chunks);
47
+ }
48
+ }
49
+ async store(name, mime, data) {
50
+ const buf = Buffer.from(data);
51
+ let uploadId = "";
52
+ for (let seq = 0;; seq++) {
53
+ const chunk = buf.subarray(seq * FILE_CHUNK, (seq + 1) * FILE_CHUNK);
54
+ const last = (seq + 1) * FILE_CHUNK >= buf.length;
55
+ const resp = await this.nc.request("sokel.file.put", enc.encode(JSON.stringify({
56
+ token: this.token,
57
+ upload_id: uploadId,
58
+ name,
59
+ mime,
60
+ seq,
61
+ last,
62
+ data: chunk.toString("base64"),
63
+ })), { timeout: FILE_TIMEOUT_MS });
64
+ const r = JSON.parse(dec.decode(resp.data));
65
+ if (r.error)
66
+ throw new Error(r.error);
67
+ if (r.upload_id)
68
+ uploadId = r.upload_id;
69
+ if (last) {
70
+ if (!r.file)
71
+ throw new Error("平台未返回文件引用");
72
+ return r.file;
73
+ }
74
+ }
75
+ }
76
+ }
77
+ export class NatsTransport {
78
+ async run(p) {
79
+ const target = await discover(p.endpoint, p.token);
80
+ // broker 的传输层鉴权优先用 SOKEL_NATS_TOKEN;缺省回退接入 token(无鉴权 broker 会忽略它)
81
+ const token = env("NATS_TOKEN") || p.token;
82
+ const ca = env("NATS_CA");
83
+ const nc = await connectForever({
84
+ servers: [target],
85
+ name: p.name,
86
+ token: token || undefined,
87
+ maxReconnectAttempts: -1, // 无限重连;订阅在重连后自动恢复
88
+ reconnectTimeWait: 2_000,
89
+ waitOnFirstConnect: true, // broker 未就绪时挂起等它,而不是启动失败退出
90
+ ...(ca ? { tls: { caFile: ca } } : {}),
91
+ });
92
+ const host = hostname();
93
+ const instanceId = stableInstanceId(p.token);
94
+ const startedAt = new Date().toISOString();
95
+ const files = new NatsFiles(nc, p.token);
96
+ let notifySubject = "";
97
+ const register = async () => {
98
+ const body = p.registerPayload(instanceId, host, startedAt);
99
+ const resp = await nc.request("sokel.register", enc.encode(JSON.stringify(body)), {
100
+ timeout: REQUEST_TIMEOUT_MS,
101
+ });
102
+ const reg = JSON.parse(dec.decode(resp.data));
103
+ if (!reg.ok || !reg.subject)
104
+ throw new Error(`注册被拒:${reg.error ?? "平台未给 subject"}`);
105
+ if (reg.notify_subject)
106
+ notifySubject = reg.notify_subject;
107
+ let creds = (reg.credentials ?? []).map((c) => new CredEntry(c.id ?? "", c.fields ?? {}));
108
+ // 旧平台只有单数形态 → 折算成单元素集合(行为与旧版一致)
109
+ if (creds.length === 0 && (reg.credential_id || reg.credential)) {
110
+ creds = [new CredEntry(reg.credential_id ?? "", reg.credential ?? {})];
111
+ }
112
+ return { subject: reg.subject, name: reg.name || p.name, creds };
113
+ };
114
+ // 启动注册失败不退出(broker / 平台可能尚未就绪),固定间隔重试直到成功
115
+ let first;
116
+ for (;;) {
117
+ try {
118
+ first = await register();
119
+ break;
120
+ }
121
+ catch (e) {
122
+ console.warn(`[sokel] 注册失败(${errText(e)}),${RETRY_MS / 1000}s 后重试…`);
123
+ await sleep(RETRY_MS);
124
+ }
125
+ }
126
+ const sub = nc.subscribe(first.subject, { queue: QUEUE_GROUP });
127
+ void (async () => {
128
+ for await (const msg of sub) {
129
+ void this.dispatch(p, nc, msg, files, instanceId);
130
+ }
131
+ })();
132
+ console.log(`[sokel] 已接入平台:插件「${first.name}」就绪,副本 ${instanceId} 监听 ${first.subject}`);
133
+ let supervisor;
134
+ if (p.sources.length > 0) {
135
+ supervisor = makeSupervisor(p, nc, files);
136
+ supervisor.reconcile(desiredSourceCreds(first.creds));
137
+ if (notifySubject) {
138
+ // 凭证变更即时通知:普通订阅(非队列组),组内每个副本都要收——各自的分配集合都可能变
139
+ const debounced = debounce(300, async () => {
140
+ try {
141
+ const { creds } = await register();
142
+ supervisor.reconcile(desiredSourceCreds(creds));
143
+ }
144
+ catch (e) {
145
+ console.warn(`[sokel] 凭证变更 re-register 失败: ${errText(e)}`);
146
+ }
147
+ });
148
+ const notifySub = nc.subscribe(notifySubject);
149
+ void (async () => {
150
+ for await (const _ of notifySub)
151
+ debounced();
152
+ })();
153
+ }
154
+ }
155
+ // 心跳续约保持副本在线;SIGINT/SIGTERM(docker stop / Ctrl-C)→ 优雅下线:
156
+ // 通知平台立即标记 offline(秒级感知),而非等心跳超时清扫(45s+)
157
+ await new Promise((resolve) => {
158
+ const timer = setInterval(async () => {
159
+ try {
160
+ const { creds } = await register();
161
+ // 每拍按最新分配集合 reconcile:分片迁移 / 凭证增删 / 字段刷新 → 起停或重启源实例
162
+ supervisor?.reconcile(desiredSourceCreds(creds));
163
+ }
164
+ catch (e) {
165
+ console.warn(`[sokel] 心跳续约失败: ${errText(e)}`);
166
+ }
167
+ }, HEARTBEAT_MS);
168
+ const bye = (sig) => {
169
+ clearInterval(timer);
170
+ supervisor?.stopAll();
171
+ nc.publish("sokel.unregister", enc.encode(JSON.stringify({ token: p.token, instance_id: instanceId })));
172
+ void nc
173
+ .flush() // 确保下线通知先于断连送达
174
+ .then(() => nc.drain())
175
+ .catch(() => undefined)
176
+ .then(() => {
177
+ console.log(`[sokel] 收到 ${sig},已通知平台下线,退出`);
178
+ resolve();
179
+ });
180
+ };
181
+ process.once("SIGINT", () => bye("SIGINT"));
182
+ process.once("SIGTERM", () => bye("SIGTERM"));
183
+ });
184
+ }
185
+ async dispatch(p, nc, msg, files, instanceId) {
186
+ if (!msg.reply)
187
+ return;
188
+ let call;
189
+ try {
190
+ call = JSON.parse(dec.decode(msg.data));
191
+ }
192
+ catch {
193
+ nc.publish(msg.reply, enc.encode(JSON.stringify({ error: "调用帧解不开" })));
194
+ return;
195
+ }
196
+ const op = call.operation ?? "";
197
+ const tag = traceTag(call.trace ?? {});
198
+ // 平台代收 webhook 的特殊帧:分发前拦截(老 SDK 没这段会走 unknown operation,
199
+ // 平台把它翻译成「插件未注册 webhook 处理器」)
200
+ if (op === OP_WEBHOOK) {
201
+ console.log(`[sokel] ← webhook 入站${tag}`);
202
+ const sctx = new SourceCtx({
203
+ token: p.token,
204
+ publish: (subject, data) => nc.publish(subject, data),
205
+ validEvents: p.contract.eventIds(),
206
+ credential: call.credential,
207
+ credentialId: call.credential_id ?? "",
208
+ sourceId: "webhook",
209
+ files,
210
+ });
211
+ const out = await p.handleWebhook(sctx, call.input ?? {});
212
+ nc.publish(msg.reply, enc.encode(JSON.stringify(out)));
213
+ return;
214
+ }
215
+ const h = natsHeaders();
216
+ h.set(INSTANCE_HEADER, instanceId);
217
+ const started = Date.now();
218
+ console.log(`[sokel] ← ${op} 开始${tag}`);
219
+ if (p.contract.isStream(op)) {
220
+ // 流式:逐帧发布到回复通道,末尾必发终止帧
221
+ const publishFrame = (f) => nc.publish(msg.reply, enc.encode(JSON.stringify(f)), { headers: h });
222
+ try {
223
+ await p.dispatch(call, publishFrame, files);
224
+ console.log(`[sokel] ✓ ${op} 完成(${Date.now() - started}ms)${tag}`);
225
+ }
226
+ catch (e) {
227
+ console.warn(`[sokel] ✗ ${op} 失败(${Date.now() - started}ms)${tag}: ${errText(e)}`);
228
+ publishFrame({ kind: "error", text: errText(e) });
229
+ }
230
+ publishFrame({ kind: "end" });
231
+ return;
232
+ }
233
+ try {
234
+ const vars = await p.dispatchBuffered(call, files);
235
+ console.log(`[sokel] ✓ ${op} 完成(${Date.now() - started}ms)${tag}`);
236
+ nc.publish(msg.reply, enc.encode(JSON.stringify(vars)), { headers: h });
237
+ }
238
+ catch (e) {
239
+ console.warn(`[sokel] ✗ ${op} 失败(${Date.now() - started}ms)${tag}: ${errText(e)}`);
240
+ nc.publish(msg.reply, enc.encode(JSON.stringify({ error: errText(e) })), { headers: h });
241
+ }
242
+ }
243
+ }
244
+ /** 每个凭证一套源实例:ctx 绑定该凭证,trigger 自动回带其 credential_id。 */
245
+ function makeSupervisor(p, nc, files) {
246
+ return new SourceSupervisor((cred) => {
247
+ const ctxs = [];
248
+ for (const src of p.sources) {
249
+ const sctx = new SourceCtx({
250
+ token: p.token,
251
+ publish: (subject, data) => nc.publish(subject, data),
252
+ validEvents: p.contract.eventIds(),
253
+ credential: cred.fields,
254
+ credentialId: cred.id,
255
+ sourceId: src.id,
256
+ board: p.board,
257
+ files,
258
+ });
259
+ ctxs.push(sctx);
260
+ console.log(`[sokel] 事件源「${src.id}」启动(credential=${cred.id || "(无凭证)"})`);
261
+ p.board.set(src.id, cred.id, "running");
262
+ void src
263
+ .fn(sctx)
264
+ .then(() => {
265
+ if (!sctx.stopped)
266
+ p.board.setIfRunning(src.id, cred.id, "exited");
267
+ })
268
+ .catch((e) => {
269
+ if (sctx.stopped)
270
+ return; // reconcile 停止:状态已由 stop() 整体移除
271
+ console.warn(`[sokel] 事件源「${src.id}」退出(credential=${cred.id || "(无凭证)"}): ${errText(e)}`);
272
+ p.board.set(src.id, cred.id, "error", errText(e));
273
+ });
274
+ }
275
+ return () => {
276
+ // JS 没有任务取消:约定由源循环自己看 ctx.stopped 退出(长轮询在下一拍即感知)
277
+ for (const c of ctxs)
278
+ c.stopped = true;
279
+ p.board.removeCred(cred.id);
280
+ };
281
+ });
282
+ }
283
+ /**
284
+ * 统一 https 端点 → 经平台 /connect-info 发现真实承载地址。
285
+ * 直填 nats:// / tls:// 时跳过发现(本地开发 / 离线场景)。
286
+ */
287
+ export async function discover(endpoint, token) {
288
+ const ep = (endpoint ?? "").trim();
289
+ if (ep.startsWith("nats://") || ep.startsWith("tls://"))
290
+ return ep;
291
+ if (!ep.startsWith("http://") && !ep.startsWith("https://")) {
292
+ throw new Error(`端点 "${endpoint}" 不合法:应为平台地址(https://…)或 nats://`);
293
+ }
294
+ const url = ep.replace(/\/+$/, "") + "/api/v1/connect-info";
295
+ const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
296
+ if (!resp.ok)
297
+ throw new Error(`平台发现失败 ${url}: HTTP ${resp.status}`);
298
+ const info = (await resp.json());
299
+ const target = info.transports?.nats;
300
+ if (!target)
301
+ throw new Error("平台未提供可用承载(connect-info.transports 为空)");
302
+ return target;
303
+ }
304
+ /**
305
+ * 副本的稳定身份:重启复用,否则平台侧每次重启都多出一行永远 offline 的幽灵实例。
306
+ * 1) SOKEL_INSTANCE_ID;2) 工作目录里按 token 指纹命名的落盘文件;3) 落盘失败 → host-pid。
307
+ */
308
+ export function stableInstanceId(token) {
309
+ const explicit = env("INSTANCE_ID");
310
+ if (explicit)
311
+ return explicit;
312
+ let file = ".sokel-instance-id";
313
+ if (token)
314
+ file += "." + createHash("sha256").update(token).digest("hex").slice(0, 8);
315
+ try {
316
+ const existing = readFileSync(file, "utf8").trim();
317
+ if (existing)
318
+ return existing;
319
+ }
320
+ catch {
321
+ /* 首次运行:往下走,生成并落盘 */
322
+ }
323
+ const id = `${hostname()}-${randomBytes(4).toString("hex")}`;
324
+ try {
325
+ writeFileSync(file, id + "\n");
326
+ }
327
+ catch {
328
+ return `${hostname()}-${process.pid}`;
329
+ }
330
+ return id;
331
+ }
332
+ async function connectForever(opts) {
333
+ for (;;) {
334
+ try {
335
+ return await connect(opts);
336
+ }
337
+ catch (e) {
338
+ console.warn(`[sokel] 连接平台失败(${errText(e)}),${RETRY_MS / 1000}s 后重试…`);
339
+ await sleep(RETRY_MS);
340
+ }
341
+ }
342
+ }
343
+ function debounce(ms, fn) {
344
+ let timer;
345
+ return () => {
346
+ if (timer)
347
+ clearTimeout(timer);
348
+ timer = setTimeout(fn, ms);
349
+ };
350
+ }
351
+ function traceTag(trace) {
352
+ const parts = [["run_id", "run"], ["workflow_id", "wf"], ["node_id", "node"]]
353
+ .filter(([key]) => trace[key])
354
+ .map(([key, label]) => `${label}=${trace[key]}`);
355
+ return parts.length > 0 ? ` [${parts.join(" ")}]` : "";
356
+ }
357
+ function errText(e) {
358
+ return e instanceof Error ? e.message : String(e);
359
+ }
360
+ function sleep(ms) {
361
+ return new Promise((r) => setTimeout(r, ms));
362
+ }
363
+ //# sourceMappingURL=nats.js.map