@kittymi/openclaw-generic-http 0.1.3
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 +21 -0
- package/README.md +132 -0
- package/dist/channel/account.d.ts +7 -0
- package/dist/channel/account.js +18 -0
- package/dist/channel/capabilities.d.ts +10 -0
- package/dist/channel/capabilities.js +11 -0
- package/dist/channel/host-adapter.d.ts +28 -0
- package/dist/channel/host-adapter.js +36 -0
- package/dist/channel/lifecycle.d.ts +18 -0
- package/dist/channel/lifecycle.js +28 -0
- package/dist/channel/plugin.d.ts +46 -0
- package/dist/channel/plugin.js +120 -0
- package/dist/channel/probe.d.ts +19 -0
- package/dist/channel/probe.js +149 -0
- package/dist/channel/resolve.d.ts +30 -0
- package/dist/channel/resolve.js +98 -0
- package/dist/channel/stream.d.ts +35 -0
- package/dist/channel/stream.js +127 -0
- package/dist/config/host-config-schema.d.ts +21 -0
- package/dist/config/host-config-schema.js +80 -0
- package/dist/config/loader.d.ts +7 -0
- package/dist/config/loader.js +38 -0
- package/dist/config/schema.d.ts +48 -0
- package/dist/config/schema.js +1 -0
- package/dist/errors/codes.d.ts +11 -0
- package/dist/errors/codes.js +10 -0
- package/dist/errors/exceptions.d.ts +7 -0
- package/dist/errors/exceptions.js +12 -0
- package/dist/inbound/mapper.d.ts +21 -0
- package/dist/inbound/mapper.js +22 -0
- package/dist/inbound/validator.d.ts +4 -0
- package/dist/inbound/validator.js +114 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +31 -0
- package/dist/mapping/conversation-mapper.d.ts +1 -0
- package/dist/mapping/conversation-mapper.js +3 -0
- package/dist/mapping/sender-mapper.d.ts +1 -0
- package/dist/mapping/sender-mapper.js +3 -0
- package/dist/mapping/thread-mapper.d.ts +1 -0
- package/dist/mapping/thread-mapper.js +7 -0
- package/dist/openclaw-entry.d.ts +276 -0
- package/dist/openclaw-entry.js +728 -0
- package/dist/outbound/client.d.ts +6 -0
- package/dist/outbound/client.js +1 -0
- package/dist/outbound/controller.d.ts +15 -0
- package/dist/outbound/controller.js +28 -0
- package/dist/outbound/http-client.d.ts +23 -0
- package/dist/outbound/http-client.js +150 -0
- package/dist/outbound/mapper.d.ts +29 -0
- package/dist/outbound/mapper.js +19 -0
- package/dist/outbound/mock-client.d.ts +18 -0
- package/dist/outbound/mock-client.js +26 -0
- package/dist/outbound/sender.d.ts +3 -0
- package/dist/outbound/sender.js +5 -0
- package/dist/protocol/attachments.d.ts +10 -0
- package/dist/protocol/attachments.js +56 -0
- package/dist/protocol/dto.d.ts +46 -0
- package/dist/protocol/dto.js +1 -0
- package/dist/protocol/serializer.d.ts +1 -0
- package/dist/protocol/serializer.js +3 -0
- package/dist/security/nonce-store.d.ts +30 -0
- package/dist/security/nonce-store.js +32 -0
- package/dist/security/signer.d.ts +10 -0
- package/dist/security/signer.js +20 -0
- package/dist/security/verifier.d.ts +2 -0
- package/dist/security/verifier.js +20 -0
- package/dist/setup-entry.d.ts +351 -0
- package/dist/setup-entry.js +73 -0
- package/dist/utils/json.d.ts +1 -0
- package/dist/utils/json.js +3 -0
- package/dist/utils/log.d.ts +1 -0
- package/dist/utils/log.js +3 -0
- package/dist/utils/time.d.ts +1 -0
- package/dist/utils/time.js +3 -0
- package/openclaw.config.schema.json +80 -0
- package/openclaw.plugin.json +175 -0
- package/package.json +72 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 KittyMi
|
|
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,132 @@
|
|
|
1
|
+
# openclaw-generic-http
|
|
2
|
+
|
|
3
|
+
`openclaw-generic-http` 是 OpenClaw 的 `generic-http` channel 插件。
|
|
4
|
+
|
|
5
|
+
它负责把 OpenClaw 接到一个遵循 `generic-http protocol v1` 的 bridge / relay / platform 上:
|
|
6
|
+
|
|
7
|
+
- 通过 `GET /stream/inbound` 消费入站事件
|
|
8
|
+
- 通过 `POST /stream/acks` 确认已处理事件
|
|
9
|
+
- 通过 `POST /outbound/messages` 发送 OpenClaw 出站消息
|
|
10
|
+
- 处理配置、安全签名、会话路由和宿主生命周期适配
|
|
11
|
+
|
|
12
|
+
## 当前完整度
|
|
13
|
+
|
|
14
|
+
当前版本已经具备 `0.1.x` 首次独立发布所需的最小闭环:
|
|
15
|
+
|
|
16
|
+
- `webhook + stream` ingress 运行时
|
|
17
|
+
- OpenClaw 宿主注册入口与 host adapter
|
|
18
|
+
- `health / probe / resolve / capabilities`
|
|
19
|
+
- 文本、图片、文件附件规范化
|
|
20
|
+
- 构建、测试与 npm 打包检查
|
|
21
|
+
|
|
22
|
+
当前仍未完成的平台级能力:
|
|
23
|
+
|
|
24
|
+
- 还没有覆盖多个 OpenClaw 版本的兼容矩阵
|
|
25
|
+
- 只有基础 CI 和手动发布流程,尚未形成完整发布自动化
|
|
26
|
+
- 只有最小真实 bridge 回归脚本,尚未形成更完整的端到端样例集
|
|
27
|
+
- 多账号、复杂附件和更细粒度错误映射仍是后续增强项
|
|
28
|
+
|
|
29
|
+
结论:
|
|
30
|
+
|
|
31
|
+
- 适合作为 `0.1.x` 预览版公开发布
|
|
32
|
+
- 还不应宣称为“生产级已完全收口”
|
|
33
|
+
|
|
34
|
+
## 兼容性
|
|
35
|
+
|
|
36
|
+
当前声明的支持范围:
|
|
37
|
+
|
|
38
|
+
- OpenClaw Desktop `2026.5.x`
|
|
39
|
+
- Node.js `>=22.16.0`
|
|
40
|
+
|
|
41
|
+
当前已验证环境:
|
|
42
|
+
|
|
43
|
+
- OpenClaw `2026.5.12 (f066dd2)`
|
|
44
|
+
|
|
45
|
+
| Item | Status |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| OpenClaw Desktop `2026.5.12` | Verified locally |
|
|
48
|
+
| OpenClaw Desktop `2026.5.x` | Supported release line |
|
|
49
|
+
| Node.js `22.x` | Verified in local/dev and CI |
|
|
50
|
+
| Node.js `24.x` | Verified in local/dev and CI |
|
|
51
|
+
|
|
52
|
+
说明:
|
|
53
|
+
|
|
54
|
+
- `2026.5.x` 是当前 `0.1.2` 发布线声明支持的 OpenClaw 版本范围
|
|
55
|
+
- 当前只在 `2026.5.12` 做过实际本机验证
|
|
56
|
+
- 对 `2026.4.x` 及更早版本、以及 `2026.6.x` 及更高版本,当前不承诺兼容
|
|
57
|
+
|
|
58
|
+
## 安装
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
openclaw plugins install @kittymi/openclaw-generic-http
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
或:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install -g @kittymi/openclaw-generic-http
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
当前更推荐通过 OpenClaw 插件机制安装,而不是只做全局 npm 安装。
|
|
71
|
+
|
|
72
|
+
## 配置示例
|
|
73
|
+
|
|
74
|
+
最小单账号配置:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"channels": {
|
|
79
|
+
"generic-http": {
|
|
80
|
+
"enabled": true,
|
|
81
|
+
"defaultAccount": "default",
|
|
82
|
+
"accounts": {
|
|
83
|
+
"default": {
|
|
84
|
+
"baseUrl": "https://bridge.example.com",
|
|
85
|
+
"apiKey": "replace-me",
|
|
86
|
+
"signingSecret": "replace-me"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
本地联调样例见 [dev-config/README.md](./dev-config/README.md) 和 [dev-config/openclaw-generic-http.local.json](./dev-config/openclaw-generic-http.local.json)。
|
|
95
|
+
|
|
96
|
+
## 适用场景
|
|
97
|
+
|
|
98
|
+
适合:
|
|
99
|
+
|
|
100
|
+
- 本地或内网运行的 OpenClaw
|
|
101
|
+
- 第三方系统通过 webhook 写入 bridge
|
|
102
|
+
- OpenClaw 通过 stream 主动拉取入站事件
|
|
103
|
+
|
|
104
|
+
不适合:
|
|
105
|
+
|
|
106
|
+
- 需要插件直接暴露公网入站地址的场景
|
|
107
|
+
- 一开始就要覆盖复杂卡片、多租户工作台、重型消息总线的场景
|
|
108
|
+
|
|
109
|
+
## 本地开发
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm install
|
|
113
|
+
npm run build
|
|
114
|
+
npm test
|
|
115
|
+
npm run pack:check
|
|
116
|
+
npm run test:e2e
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
当前 OpenClaw 桌面版本下,更可靠的启用方式仍然是:
|
|
120
|
+
|
|
121
|
+
1. 安装插件
|
|
122
|
+
2. 手工写入 `channels.generic-http`
|
|
123
|
+
3. 执行 `openclaw channels list --all`
|
|
124
|
+
4. 执行 `openclaw channels status --channel generic-http`
|
|
125
|
+
|
|
126
|
+
`openclaw channels add --channel ...` 仍主要依赖内置静态 catalog,不一定能直接枚举第三方 channel。
|
|
127
|
+
|
|
128
|
+
## 文档
|
|
129
|
+
|
|
130
|
+
- 安装与配置:[docs/01-installation-guide.md](./docs/01-installation-guide.md)
|
|
131
|
+
- 常见问题:[docs/02-faq.md](./docs/02-faq.md)
|
|
132
|
+
- 本地联调:[docs/03-local-dev.md](./docs/03-local-dev.md)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { GenericHttpAccountConfig, GenericHttpPluginConfig } from "../config/schema.js";
|
|
2
|
+
export interface ResolvedGenericHttpAccount {
|
|
3
|
+
accountId: string;
|
|
4
|
+
config: GenericHttpAccountConfig;
|
|
5
|
+
}
|
|
6
|
+
export declare function listConfiguredAccountIds(config: GenericHttpPluginConfig): string[];
|
|
7
|
+
export declare function resolveConfiguredAccount(config: GenericHttpPluginConfig, accountId?: string | null): ResolvedGenericHttpAccount;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ERROR_CODES } from "../errors/codes.js";
|
|
2
|
+
import { GenericHttpPluginError } from "../errors/exceptions.js";
|
|
3
|
+
export function listConfiguredAccountIds(config) {
|
|
4
|
+
return Object.keys(config.accounts);
|
|
5
|
+
}
|
|
6
|
+
export function resolveConfiguredAccount(config, accountId) {
|
|
7
|
+
const normalizedAccountId = typeof accountId === "string" && accountId.trim() !== ""
|
|
8
|
+
? accountId.trim()
|
|
9
|
+
: config.defaultAccount;
|
|
10
|
+
const resolvedConfig = config.accounts[normalizedAccountId];
|
|
11
|
+
if (resolvedConfig === undefined) {
|
|
12
|
+
throw new GenericHttpPluginError(ERROR_CODES.INVALID_REQUEST, "Unknown accountId for generic-http channel runtime.", { accountId: normalizedAccountId });
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
accountId: normalizedAccountId,
|
|
16
|
+
config: resolvedConfig
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface GenericHttpCapabilities {
|
|
2
|
+
textInbound: boolean;
|
|
3
|
+
textOutbound: boolean;
|
|
4
|
+
attachments: boolean;
|
|
5
|
+
threading: boolean;
|
|
6
|
+
replies: boolean;
|
|
7
|
+
deliveryReceipt: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_GENERIC_HTTP_CAPABILITIES: GenericHttpCapabilities;
|
|
10
|
+
export declare function getGenericHttpCapabilities(): GenericHttpCapabilities;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const DEFAULT_GENERIC_HTTP_CAPABILITIES = {
|
|
2
|
+
textInbound: true,
|
|
3
|
+
textOutbound: true,
|
|
4
|
+
attachments: true,
|
|
5
|
+
threading: true,
|
|
6
|
+
replies: true,
|
|
7
|
+
deliveryReceipt: false
|
|
8
|
+
};
|
|
9
|
+
export function getGenericHttpCapabilities() {
|
|
10
|
+
return { ...DEFAULT_GENERIC_HTTP_CAPABILITIES };
|
|
11
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { GenericHttpPluginConfig } from "../config/schema.js";
|
|
2
|
+
import type { NormalizedInboundMessageEvent } from "../inbound/mapper.js";
|
|
3
|
+
import { type GenericHttpChannelLifecycle, type GenericHttpChannelLifecycleHandlers } from "./lifecycle.js";
|
|
4
|
+
import type { GenericHttpCapabilities } from "./capabilities.js";
|
|
5
|
+
import type { GenericHttpChannelPlugin, GenericHttpChannelPluginRuntimeOptions, GenericHttpChannelPluginStatus } from "./plugin.js";
|
|
6
|
+
import type { InternalOutboundMessage, OutboundMessageResult } from "../outbound/mapper.js";
|
|
7
|
+
import type { ProbeResult } from "./probe.js";
|
|
8
|
+
import type { ResolveRequest, ResolveResponse } from "./resolve.js";
|
|
9
|
+
export interface GenericHttpHostAdapterHandlers extends GenericHttpChannelLifecycleHandlers {
|
|
10
|
+
}
|
|
11
|
+
export interface GenericHttpHostAdapter {
|
|
12
|
+
readonly plugin: GenericHttpChannelPlugin;
|
|
13
|
+
readonly lifecycle: GenericHttpChannelLifecycle;
|
|
14
|
+
activate(accountId?: string | null): Promise<void>;
|
|
15
|
+
deactivate(): void;
|
|
16
|
+
dispose(): void;
|
|
17
|
+
status(): GenericHttpChannelPluginStatus;
|
|
18
|
+
capabilities(): GenericHttpCapabilities;
|
|
19
|
+
probe(accountId?: string | null): Promise<ProbeResult>;
|
|
20
|
+
resolve(request: ResolveRequest): Promise<ResolveResponse>;
|
|
21
|
+
sendOutboundMessage(message: InternalOutboundMessage): Promise<OutboundMessageResult>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Adapt the generic HTTP runtime to a host-friendly contract with explicit
|
|
25
|
+
* activate/deactivate lifecycle methods and delegated inbound event dispatch.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createGenericHttpHostAdapter(rawConfig: Partial<GenericHttpPluginConfig>, handlers: GenericHttpHostAdapterHandlers, options?: Omit<GenericHttpChannelPluginRuntimeOptions, "onInboundStreamMessage" | "onInboundStreamError">): GenericHttpHostAdapter;
|
|
28
|
+
export type { NormalizedInboundMessageEvent };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createGenericHttpChannelLifecycle } from "./lifecycle.js";
|
|
2
|
+
/**
|
|
3
|
+
* Adapt the generic HTTP runtime to a host-friendly contract with explicit
|
|
4
|
+
* activate/deactivate lifecycle methods and delegated inbound event dispatch.
|
|
5
|
+
*/
|
|
6
|
+
export function createGenericHttpHostAdapter(rawConfig, handlers, options = {}) {
|
|
7
|
+
const lifecycle = createGenericHttpChannelLifecycle(rawConfig, handlers, options);
|
|
8
|
+
return {
|
|
9
|
+
plugin: lifecycle.plugin,
|
|
10
|
+
lifecycle,
|
|
11
|
+
activate(accountId) {
|
|
12
|
+
return lifecycle.start(accountId);
|
|
13
|
+
},
|
|
14
|
+
deactivate() {
|
|
15
|
+
lifecycle.stop();
|
|
16
|
+
},
|
|
17
|
+
dispose() {
|
|
18
|
+
lifecycle.close();
|
|
19
|
+
},
|
|
20
|
+
status() {
|
|
21
|
+
return lifecycle.plugin.status();
|
|
22
|
+
},
|
|
23
|
+
capabilities() {
|
|
24
|
+
return lifecycle.plugin.capabilities();
|
|
25
|
+
},
|
|
26
|
+
probe(accountId) {
|
|
27
|
+
return lifecycle.plugin.probe(accountId);
|
|
28
|
+
},
|
|
29
|
+
resolve(request) {
|
|
30
|
+
return lifecycle.plugin.resolve(request);
|
|
31
|
+
},
|
|
32
|
+
sendOutboundMessage(message) {
|
|
33
|
+
return lifecycle.plugin.sendOutboundMessage(message);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { GenericHttpPluginConfig } from "../config/schema.js";
|
|
2
|
+
import type { NormalizedInboundMessageEvent } from "../inbound/mapper.js";
|
|
3
|
+
import { type GenericHttpChannelPlugin, type GenericHttpChannelPluginRuntimeOptions } from "./plugin.js";
|
|
4
|
+
export interface GenericHttpChannelLifecycleHandlers {
|
|
5
|
+
dispatchInboundEvent(event: NormalizedInboundMessageEvent): Promise<void> | void;
|
|
6
|
+
onStreamError?(error: unknown): Promise<void> | void;
|
|
7
|
+
}
|
|
8
|
+
export interface GenericHttpChannelLifecycle {
|
|
9
|
+
plugin: GenericHttpChannelPlugin;
|
|
10
|
+
start(accountId?: string | null): Promise<void>;
|
|
11
|
+
stop(): void;
|
|
12
|
+
close(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Bridge the protocol runtime into a host lifecycle shape. The host provides
|
|
16
|
+
* the event dispatcher and decides when the stream loop should start or stop.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createGenericHttpChannelLifecycle(rawConfig: Partial<GenericHttpPluginConfig>, handlers: GenericHttpChannelLifecycleHandlers, options?: Omit<GenericHttpChannelPluginRuntimeOptions, "onInboundStreamMessage" | "onInboundStreamError">): GenericHttpChannelLifecycle;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createGenericHttpChannelPlugin } from "./plugin.js";
|
|
2
|
+
/**
|
|
3
|
+
* Bridge the protocol runtime into a host lifecycle shape. The host provides
|
|
4
|
+
* the event dispatcher and decides when the stream loop should start or stop.
|
|
5
|
+
*/
|
|
6
|
+
export function createGenericHttpChannelLifecycle(rawConfig, handlers, options = {}) {
|
|
7
|
+
const plugin = createGenericHttpChannelPlugin(rawConfig, {
|
|
8
|
+
...options,
|
|
9
|
+
async onInboundStreamMessage(item) {
|
|
10
|
+
await handlers.dispatchInboundEvent(item.normalizedEvent);
|
|
11
|
+
},
|
|
12
|
+
async onInboundStreamError(error) {
|
|
13
|
+
await handlers.onStreamError?.(error);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
return {
|
|
17
|
+
plugin,
|
|
18
|
+
start(accountId) {
|
|
19
|
+
return plugin.startStreamIngress(accountId);
|
|
20
|
+
},
|
|
21
|
+
stop() {
|
|
22
|
+
plugin.stopStreamIngress();
|
|
23
|
+
},
|
|
24
|
+
close() {
|
|
25
|
+
plugin.close();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { GenericHttpPluginConfig } from "../config/schema.js";
|
|
2
|
+
import { type HttpOutboundClientOptions, type OutboundClient } from "../outbound/client.js";
|
|
3
|
+
import type { InternalOutboundMessage, OutboundMessageResult } from "../outbound/mapper.js";
|
|
4
|
+
import { type GenericHttpCapabilities } from "./capabilities.js";
|
|
5
|
+
import { type ProbeAccountOptions, type ProbeResult } from "./probe.js";
|
|
6
|
+
import { type ResolveAccountOptions, type ResolveRequest, type ResolveResponse } from "./resolve.js";
|
|
7
|
+
import { type AckInboundMessagesResult, type PullInboundMessagesResult, type StreamAckOptions, type StreamPullOptions } from "./stream.js";
|
|
8
|
+
type OutboundClientFactory = (config: GenericHttpPluginConfig, accountId: string) => OutboundClient;
|
|
9
|
+
export interface GenericHttpChannelPluginStatus {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
defaultAccount: string;
|
|
12
|
+
accounts: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface GenericHttpChannelPluginRuntimeOptions {
|
|
15
|
+
outboundClientFactory?: OutboundClientFactory;
|
|
16
|
+
outboundClientOptions?: HttpOutboundClientOptions;
|
|
17
|
+
probeOptions?: ProbeAccountOptions;
|
|
18
|
+
resolveOptions?: ResolveAccountOptions;
|
|
19
|
+
streamPullOptions?: StreamPullOptions;
|
|
20
|
+
streamAckOptions?: StreamAckOptions;
|
|
21
|
+
streamIngressPollIntervalMillis?: number;
|
|
22
|
+
onInboundStreamMessage?: (message: PullInboundMessagesResult["items"][number]) => Promise<void> | void;
|
|
23
|
+
onInboundStreamError?: (error: unknown) => Promise<void> | void;
|
|
24
|
+
}
|
|
25
|
+
export interface GenericHttpStreamIngressStatus {
|
|
26
|
+
running: boolean;
|
|
27
|
+
accountId: string | null;
|
|
28
|
+
}
|
|
29
|
+
export interface GenericHttpChannelPlugin {
|
|
30
|
+
name: string;
|
|
31
|
+
config: GenericHttpPluginConfig;
|
|
32
|
+
status(): GenericHttpChannelPluginStatus;
|
|
33
|
+
capabilities(): GenericHttpCapabilities;
|
|
34
|
+
probe(accountId?: string | null): Promise<ProbeResult>;
|
|
35
|
+
resolve(request: ResolveRequest): Promise<ResolveResponse>;
|
|
36
|
+
pullInboundMessages(accountId?: string | null): Promise<PullInboundMessagesResult>;
|
|
37
|
+
ackInboundMessages(accountId: string, eventIds: string[]): Promise<AckInboundMessagesResult>;
|
|
38
|
+
startStreamIngress(accountId?: string | null): Promise<void>;
|
|
39
|
+
stopStreamIngress(): void;
|
|
40
|
+
streamIngressStatus(): GenericHttpStreamIngressStatus;
|
|
41
|
+
sendOutboundMessage(message: InternalOutboundMessage): Promise<OutboundMessageResult>;
|
|
42
|
+
close(): void;
|
|
43
|
+
}
|
|
44
|
+
export declare function createGenericHttpChannelPlugin(rawConfig?: Partial<GenericHttpPluginConfig>, options?: GenericHttpChannelPluginRuntimeOptions): GenericHttpChannelPlugin;
|
|
45
|
+
export declare const genericHttpChannelPlugin: GenericHttpChannelPlugin;
|
|
46
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { loadConfig } from "../config/loader.js";
|
|
2
|
+
import { HttpOutboundClient } from "../outbound/client.js";
|
|
3
|
+
import { handleOutboundMessage } from "../outbound/controller.js";
|
|
4
|
+
import { getGenericHttpCapabilities } from "./capabilities.js";
|
|
5
|
+
import { listConfiguredAccountIds, resolveConfiguredAccount } from "./account.js";
|
|
6
|
+
import { probeAccountConfig } from "./probe.js";
|
|
7
|
+
import { resolveRemotely, resolveLocally } from "./resolve.js";
|
|
8
|
+
import { ackInboundMessages, pullInboundMessages } from "./stream.js";
|
|
9
|
+
function createDefaultOutboundClientFactory(options = {}) {
|
|
10
|
+
return (config, accountId) => {
|
|
11
|
+
const account = resolveConfiguredAccount(config, accountId);
|
|
12
|
+
return new HttpOutboundClient(account.config, options);
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function createGenericHttpChannelPlugin(rawConfig = {}, options = {}) {
|
|
16
|
+
const config = loadConfig(rawConfig);
|
|
17
|
+
const outboundClientFactory = options.outboundClientFactory ??
|
|
18
|
+
createDefaultOutboundClientFactory(options.outboundClientOptions);
|
|
19
|
+
const streamIngressPollIntervalMillis = options.streamIngressPollIntervalMillis ?? 1000;
|
|
20
|
+
let streamIngressRunning = false;
|
|
21
|
+
let streamIngressAccountId = null;
|
|
22
|
+
let streamIngressTimer;
|
|
23
|
+
function clearStreamIngressTimer() {
|
|
24
|
+
if (streamIngressTimer !== undefined) {
|
|
25
|
+
clearTimeout(streamIngressTimer);
|
|
26
|
+
streamIngressTimer = undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function runStreamIngressCycle(accountId) {
|
|
30
|
+
if (!streamIngressRunning || streamIngressAccountId !== accountId) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const pulled = await pullInboundMessages(accountId, resolveConfiguredAccount(config, accountId).config, options.streamPullOptions);
|
|
35
|
+
const ackedEventIds = [];
|
|
36
|
+
for (const item of pulled.items) {
|
|
37
|
+
await options.onInboundStreamMessage?.(item);
|
|
38
|
+
ackedEventIds.push(item.eventId);
|
|
39
|
+
}
|
|
40
|
+
if (ackedEventIds.length > 0) {
|
|
41
|
+
await ackInboundMessages(accountId, ackedEventIds, resolveConfiguredAccount(config, accountId).config, options.streamAckOptions);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await options.onInboundStreamError?.(error);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
if (streamIngressRunning && streamIngressAccountId === accountId) {
|
|
49
|
+
streamIngressTimer = setTimeout(() => {
|
|
50
|
+
void runStreamIngressCycle(accountId);
|
|
51
|
+
}, streamIngressPollIntervalMillis);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
name: "generic-http",
|
|
57
|
+
config,
|
|
58
|
+
status() {
|
|
59
|
+
return {
|
|
60
|
+
enabled: config.enabled,
|
|
61
|
+
defaultAccount: config.defaultAccount,
|
|
62
|
+
accounts: listConfiguredAccountIds(config)
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
capabilities() {
|
|
66
|
+
return getGenericHttpCapabilities();
|
|
67
|
+
},
|
|
68
|
+
probe(accountId) {
|
|
69
|
+
const account = resolveConfiguredAccount(config, accountId);
|
|
70
|
+
return probeAccountConfig(account.accountId, account.config, options.probeOptions);
|
|
71
|
+
},
|
|
72
|
+
async resolve(request) {
|
|
73
|
+
const account = resolveConfiguredAccount(config, request.accountId);
|
|
74
|
+
try {
|
|
75
|
+
return await resolveRemotely(account.config, request, options.resolveOptions);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return resolveLocally(request);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
async pullInboundMessages(accountId) {
|
|
82
|
+
const account = resolveConfiguredAccount(config, accountId);
|
|
83
|
+
return await pullInboundMessages(account.accountId, account.config, options.streamPullOptions);
|
|
84
|
+
},
|
|
85
|
+
async ackInboundMessages(accountId, eventIds) {
|
|
86
|
+
const account = resolveConfiguredAccount(config, accountId);
|
|
87
|
+
return await ackInboundMessages(account.accountId, eventIds, account.config, options.streamAckOptions);
|
|
88
|
+
},
|
|
89
|
+
async startStreamIngress(accountId) {
|
|
90
|
+
const account = resolveConfiguredAccount(config, accountId);
|
|
91
|
+
streamIngressRunning = true;
|
|
92
|
+
streamIngressAccountId = account.accountId;
|
|
93
|
+
clearStreamIngressTimer();
|
|
94
|
+
await runStreamIngressCycle(account.accountId);
|
|
95
|
+
},
|
|
96
|
+
stopStreamIngress() {
|
|
97
|
+
streamIngressRunning = false;
|
|
98
|
+
streamIngressAccountId = null;
|
|
99
|
+
clearStreamIngressTimer();
|
|
100
|
+
},
|
|
101
|
+
streamIngressStatus() {
|
|
102
|
+
return {
|
|
103
|
+
running: streamIngressRunning,
|
|
104
|
+
accountId: streamIngressRunning ? streamIngressAccountId : null
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
async sendOutboundMessage(message) {
|
|
108
|
+
const account = resolveConfiguredAccount(config, message.accountId);
|
|
109
|
+
const client = outboundClientFactory(config, account.accountId);
|
|
110
|
+
const response = await handleOutboundMessage(client, message);
|
|
111
|
+
return response.result;
|
|
112
|
+
},
|
|
113
|
+
close() {
|
|
114
|
+
streamIngressRunning = false;
|
|
115
|
+
streamIngressAccountId = null;
|
|
116
|
+
clearStreamIngressTimer();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export const genericHttpChannelPlugin = createGenericHttpChannelPlugin();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { GenericHttpAccountConfig } from "../config/schema.js";
|
|
2
|
+
export interface ProbeCheckResult {
|
|
3
|
+
name: string;
|
|
4
|
+
status: "OK" | "ERROR";
|
|
5
|
+
detail?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ProbeResult {
|
|
8
|
+
success: true;
|
|
9
|
+
status: "OK" | "ERROR";
|
|
10
|
+
accountId: string;
|
|
11
|
+
checks: ProbeCheckResult[];
|
|
12
|
+
}
|
|
13
|
+
export interface ProbeAccountOptions {
|
|
14
|
+
fetchImpl?: typeof fetch;
|
|
15
|
+
nowEpochSeconds?: () => number;
|
|
16
|
+
nonceFactory?: () => string;
|
|
17
|
+
requestIdFactory?: () => string;
|
|
18
|
+
}
|
|
19
|
+
export declare function probeAccountConfig(accountId: string, accountConfig: GenericHttpAccountConfig, options?: ProbeAccountOptions): Promise<ProbeResult>;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { serializeProtocolObject } from "../protocol/serializer.js";
|
|
3
|
+
import { signPayload } from "../security/signer.js";
|
|
4
|
+
function hasValue(value) {
|
|
5
|
+
return typeof value === "string" && value.trim() !== "";
|
|
6
|
+
}
|
|
7
|
+
function buildLocalChecks(accountConfig) {
|
|
8
|
+
return [
|
|
9
|
+
{
|
|
10
|
+
name: "base-url",
|
|
11
|
+
status: hasValue(accountConfig.baseUrl) ? "OK" : "ERROR",
|
|
12
|
+
detail: hasValue(accountConfig.baseUrl)
|
|
13
|
+
? accountConfig.baseUrl
|
|
14
|
+
: "baseUrl is not configured"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
name: "signing-secret",
|
|
18
|
+
status: hasValue(accountConfig.outboundSecret) || hasValue(accountConfig.signingSecret)
|
|
19
|
+
? "OK"
|
|
20
|
+
: "ERROR",
|
|
21
|
+
detail: hasValue(accountConfig.outboundSecret) || hasValue(accountConfig.signingSecret)
|
|
22
|
+
? "available"
|
|
23
|
+
: "signingSecret or outboundSecret is required"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
name: "inbound-secret",
|
|
27
|
+
status: hasValue(accountConfig.inboundSecret) || hasValue(accountConfig.signingSecret)
|
|
28
|
+
? "OK"
|
|
29
|
+
: "ERROR",
|
|
30
|
+
detail: hasValue(accountConfig.inboundSecret) || hasValue(accountConfig.signingSecret)
|
|
31
|
+
? "available"
|
|
32
|
+
: "signingSecret or inboundSecret is required"
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
function buildSignedHeaders(accountConfig, method, path, rawBody, timestamp, nonce, requestId) {
|
|
37
|
+
const signingSecret = accountConfig.outboundSecret ?? accountConfig.signingSecret ?? "";
|
|
38
|
+
const signature = signPayload(signingSecret, {
|
|
39
|
+
method,
|
|
40
|
+
path,
|
|
41
|
+
timestamp,
|
|
42
|
+
nonce,
|
|
43
|
+
rawBody
|
|
44
|
+
});
|
|
45
|
+
return {
|
|
46
|
+
accept: "application/json",
|
|
47
|
+
"content-type": "application/json",
|
|
48
|
+
"x-request-id": requestId,
|
|
49
|
+
"x-generic-http-version": "1",
|
|
50
|
+
"x-timestamp": timestamp,
|
|
51
|
+
"x-nonce": nonce,
|
|
52
|
+
"x-signature": signature,
|
|
53
|
+
"x-api-key": accountConfig.apiKey ?? ""
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function buildErrorCheck(name, detail) {
|
|
57
|
+
return {
|
|
58
|
+
name,
|
|
59
|
+
status: "ERROR",
|
|
60
|
+
detail
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function runRemoteHealthCheck(accountConfig, options) {
|
|
64
|
+
const endpoint = new URL("/health", accountConfig.baseUrl);
|
|
65
|
+
const timestamp = String(options.nowEpochSeconds());
|
|
66
|
+
const nonce = options.nonceFactory();
|
|
67
|
+
const requestId = options.requestIdFactory();
|
|
68
|
+
const headers = buildSignedHeaders(accountConfig, "GET", endpoint.pathname, "", timestamp, nonce, requestId);
|
|
69
|
+
try {
|
|
70
|
+
const response = await options.fetchImpl(endpoint.toString(), {
|
|
71
|
+
method: "GET",
|
|
72
|
+
headers
|
|
73
|
+
});
|
|
74
|
+
if (!response.ok) {
|
|
75
|
+
return buildErrorCheck("health", `GET /health failed with ${response.status} ${response.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
const payload = (await response.json());
|
|
78
|
+
if (payload.success !== true || payload.status !== "UP") {
|
|
79
|
+
return buildErrorCheck("health", "GET /health returned an invalid success/status payload");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
name: "health",
|
|
83
|
+
status: "OK",
|
|
84
|
+
detail: payload.service ?? "remote health check passed"
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
return buildErrorCheck("health", error instanceof Error ? error.message : String(error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async function runRemoteProbeCheck(accountId, accountConfig, options) {
|
|
92
|
+
const endpoint = new URL("/probe", accountConfig.baseUrl);
|
|
93
|
+
const rawBody = serializeProtocolObject({ accountId });
|
|
94
|
+
const timestamp = String(options.nowEpochSeconds());
|
|
95
|
+
const nonce = options.nonceFactory();
|
|
96
|
+
const requestId = options.requestIdFactory();
|
|
97
|
+
const headers = buildSignedHeaders(accountConfig, "POST", endpoint.pathname, rawBody, timestamp, nonce, requestId);
|
|
98
|
+
try {
|
|
99
|
+
const response = await options.fetchImpl(endpoint.toString(), {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers,
|
|
102
|
+
body: rawBody
|
|
103
|
+
});
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
return buildErrorCheck("probe-api", `POST /probe failed with ${response.status} ${response.statusText}`);
|
|
106
|
+
}
|
|
107
|
+
const payload = (await response.json());
|
|
108
|
+
if (payload.success !== true || typeof payload.status !== "string") {
|
|
109
|
+
return buildErrorCheck("probe-api", "POST /probe returned an invalid success/status payload");
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
name: "probe-api",
|
|
113
|
+
status: payload.status === "OK" ? "OK" : "ERROR",
|
|
114
|
+
detail: Array.isArray(payload.checks) && payload.checks.length > 0
|
|
115
|
+
? payload.checks
|
|
116
|
+
.map((check) => `${check.name ?? "unknown"}=${check.status ?? "unknown"}`)
|
|
117
|
+
.join(", ")
|
|
118
|
+
: "remote probe completed"
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return buildErrorCheck("probe-api", error instanceof Error ? error.message : String(error));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
export async function probeAccountConfig(accountId, accountConfig, options = {}) {
|
|
126
|
+
const checks = buildLocalChecks(accountConfig);
|
|
127
|
+
if (checks.some((check) => check.status === "ERROR")) {
|
|
128
|
+
return {
|
|
129
|
+
success: true,
|
|
130
|
+
status: "ERROR",
|
|
131
|
+
accountId,
|
|
132
|
+
checks
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const resolvedOptions = {
|
|
136
|
+
fetchImpl: options.fetchImpl ?? fetch,
|
|
137
|
+
nowEpochSeconds: options.nowEpochSeconds ?? (() => Math.floor(Date.now() / 1000)),
|
|
138
|
+
nonceFactory: options.nonceFactory ?? (() => randomUUID()),
|
|
139
|
+
requestIdFactory: options.requestIdFactory ?? (() => randomUUID())
|
|
140
|
+
};
|
|
141
|
+
checks.push(await runRemoteHealthCheck(accountConfig, resolvedOptions));
|
|
142
|
+
checks.push(await runRemoteProbeCheck(accountId, accountConfig, resolvedOptions));
|
|
143
|
+
return {
|
|
144
|
+
success: true,
|
|
145
|
+
status: checks.every((check) => check.status === "OK") ? "OK" : "ERROR",
|
|
146
|
+
accountId,
|
|
147
|
+
checks
|
|
148
|
+
};
|
|
149
|
+
}
|