@4fu/arbor-plugin-http 0.0.0-stage → 2026.1106.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) 2026 4fuu
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 CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # @4fu/arbor-plugin-http
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The HTTP test channel: the first plugin that runs the whole host end to end
4
+ ([docs/architecture.md#plugins](../../docs/architecture.md#plugins)).
5
+
6
+ A webhook in, a window's sends out, and a small page a person drives it from.
7
+ Nothing here is specific to the plugin's own domain: a deployment that plugs a
8
+ different channel in composes the same stack the same way.
9
+
10
+ ## Run it
11
+
12
+ ```sh
13
+ DEEPSEEK_API_KEY=… JEV_API_KEY=… pnpm --filter @4fu/arbor-plugin-http start
14
+ ```
15
+
16
+ Then open the address it prints (127.0.0.1:8787 by default), pick or create a
17
+ conversation, and send a message. Replies arrive on the page as the window sends
18
+ them. The board beside the conversation is read-only: which windows are live and
19
+ what they are doing.
20
+
21
+ | Name | What it names | Default |
22
+ | --- | --- | --- |
23
+ | `ARBOR_DATA_DIR` | the journal, notes, `/env` and the workspaces | a directory under the system temp dir |
24
+ | `ARBOR_HOST` | the host the page binds | `127.0.0.1` |
25
+ | `ARBOR_PORT` | the port the page binds | `8787` |
26
+ | `ARBOR_MODEL` | the model the `chat` tier maps to | `deepseek-flash` |
27
+ | `DEEPSEEK_API_KEY` | pi's key for DeepSeek | - |
28
+ | `JEV_API_KEY` | Jev's key | - |
29
+
30
+ A configuration names a secret and never holds its value: the two keys are
31
+ environment variables pi and Jev read themselves, and the settings name them, so
32
+ the host's scrub covers every string that leaves it
33
+ ([docs/architecture.md#data-placement](../../docs/architecture.md#data-placement)).
34
+
35
+ The page is a local test tool, so it binds localhost by default. Visibility,
36
+ permissions and auth are deferred.
37
+
38
+ ## The channel
39
+
40
+ | Route | What it does |
41
+ | --- | --- |
42
+ | `GET /` | the page |
43
+ | `GET /app.js` | the page's script |
44
+ | `GET /api/state` | the connection, the conversations and the board |
45
+ | `GET /api/messages?conversation=<name>` | one conversation, both directions |
46
+ | `POST /webhook` | one inbound message |
47
+
48
+ `POST /webhook` takes `{ "conversation": "demo", "id": "42", "from": "alice",
49
+ "text": "部署流程是什么", "replyTo": "41" }`. The `id` is the sender's own id for
50
+ the message, which the channel keeps as its native id: `http:42`. The journal's
51
+ unique index on the native id is what stores a redelivered message once, so the
52
+ channel forwards every delivery it is given and keeps one line per native id on
53
+ the page. The answer says which happened: `redelivered`.
54
+
55
+ A window answers with `send({ to: "http:demo", content: "…" })`. The host records
56
+ the effect, the queue executes it exactly once, and this plugin's executor carries
57
+ it out through the channel, which hands back the native id the outside world would
58
+ have given it
59
+ ([docs/coordination.md#effects](../../docs/coordination.md#effects)).
60
+
61
+ ## What the plugin registers
62
+
63
+ | Part | What it gives |
64
+ | --- | --- |
65
+ | `adapters.channels` | the `Channel`: `connections`, `get`, `send` and the push source |
66
+ | `adapters.effectExecutors` | the executor that owns the sends to this channel's conversations |
67
+ | `promptSection` | one block kind and its two fixed lines, so the model reads what the channel is and how to answer on it |
68
+ | `judgeCriteria` | one extra criterion for the `route` decision |
69
+
70
+ It adds no direct tool and no code mode module of its own: capabilities are MCP
71
+ servers and skills in `/env`, which the model reads, changes and reinstalls with
72
+ `reload` like any item.
73
+
74
+ ## Test it
75
+
76
+ ```sh
77
+ pnpm --filter @4fu/arbor-plugin-http test
78
+ ```
79
+
80
+ The test composes the whole stack - the SQLite journal and notes, the directory
81
+ environment, the directory workspace, pi over its scripted faux provider and the
82
+ default judge over a fake transport - so it needs no key and makes no network
83
+ call. It proves the channel end to end: a webhook message reaches dispatch and
84
+ opens a window; a second message in the same conversation is steered into that
85
+ window; a reply comes back out through the channel; and a redelivered native id is
86
+ stored once.
package/channel.d.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The HTTP test channel: an outside source and sink over plain HTTP
3
+ * (packages/interfaces/src/channel.ts, docs/architecture.md#interfaces).
4
+ *
5
+ * It is the first real plugin's adapter, and it is a test tool: it holds a
6
+ * conversation per id, turns an inbound message into one `OutsideEvent` that
7
+ * carries its native id, records what a window sent, and serves the page a
8
+ * person drives it from. Nothing here knows the host; a deployment plugs the
9
+ * channel in and the host takes its push source
10
+ * (docs/architecture.md#plugins).
11
+ *
12
+ * An inbound message keeps the id the sender gave it, with the channel prefix:
13
+ * `http:42`. The journal's unique index on `external` is what makes a
14
+ * redelivered message stored once (docs/journal.md#external-ids), so this
15
+ * channel forwards every delivery it is given and keeps one line per native id
16
+ * on the page: the exactly-once guarantee is the host's, not the transport's.
17
+ *
18
+ * The page it serves is a local test tool, so the server binds `127.0.0.1` by
19
+ * default and a deployment that names another host says so explicitly.
20
+ * Visibility, permissions and auth are not part of the framework.
21
+ */
22
+ import type { BoardLine, Channel, ChannelConnection, ExternalRef, OutsideEvent, OutsideMessage, SendRequest, SendResult, Target, Unsubscribe } from "@4fu/arbor-interfaces";
23
+ /** Which way one message went. */
24
+ export type HttpDirection = "in" | "out";
25
+ /** One message this channel carries, in either direction. */
26
+ export interface HttpMessage {
27
+ /** Its native id, with the channel prefix: `http:42`. */
28
+ external: ExternalRef;
29
+ /** The conversation it belongs to: `http:demo`. */
30
+ conversation: Target;
31
+ direction: HttpDirection;
32
+ /** Who sent it: the webhook's `from`, or `arbor` for a window's send. */
33
+ from: string;
34
+ text: string;
35
+ time: number;
36
+ /** The native id it answers, when it is a reply. */
37
+ replyTo?: ExternalRef;
38
+ }
39
+ /** What a deployment configures for the channel. */
40
+ export interface HttpChannelOptions {
41
+ /** The channel prefix. `http`. */
42
+ id?: string;
43
+ /** The live board, for the page's read-only view. Absent when it has none. */
44
+ board?: () => readonly BoardLine[];
45
+ /** The host to bind. `127.0.0.1`, because the page is a local test tool. */
46
+ host?: string;
47
+ /** The port to bind. 0 picks a free one. */
48
+ port?: number;
49
+ /** The clock. */
50
+ now?: () => number;
51
+ }
52
+ /** One webhook message, as the page or an outside system posts it. */
53
+ export interface WebhookBody {
54
+ /** The conversation it belongs to, without the channel prefix. */
55
+ conversation: string;
56
+ /** The sender's own id for it. Absent when the channel assigns one. */
57
+ id?: string;
58
+ text: string;
59
+ from?: string;
60
+ /** The native id it answers, when it is a reply. */
61
+ replyTo?: string;
62
+ }
63
+ /** What the webhook answers with: where the message landed, and whether it had. */
64
+ export interface WebhookResult {
65
+ external: ExternalRef;
66
+ target: Target;
67
+ /** True when this native id had already arrived, so the host stored it once. */
68
+ redelivered: boolean;
69
+ }
70
+ /** One subscription to what arrives. */
71
+ type Handler = (events: readonly OutsideEvent[]) => void | Promise<void>;
72
+ export declare class HttpChannel implements Channel {
73
+ readonly id: string;
74
+ private readonly board;
75
+ private readonly host;
76
+ private readonly port;
77
+ private readonly clock;
78
+ private readonly messages;
79
+ private readonly handlers;
80
+ private server;
81
+ private address;
82
+ /** Every native id this channel has used, so an assigned one never collides. */
83
+ private readonly used;
84
+ private next;
85
+ constructor(options?: HttpChannelOptions);
86
+ /** What is connected: the one connection this channel has, and its state. */
87
+ connections(): Promise<readonly ChannelConnection[]>;
88
+ /**
89
+ * Look up a message by its native id. This channel is its own store, so the
90
+ * copy is the one it kept: `stored` says it came from the store rather than
91
+ * from a live source, which for this channel means the server is not up
92
+ * (packages/interfaces/src/channel.ts).
93
+ */
94
+ get(nativeId: ExternalRef): Promise<OutsideMessage | null>;
95
+ /**
96
+ * The sink. A window's `send` reaches here as an effect
97
+ * (docs/coordination.md#effects); the channel records it and hands back the
98
+ * native id the outside world would have given it, which the host records on
99
+ * the effect's result.
100
+ */
101
+ send(request: SendRequest): Promise<SendResult>;
102
+ /**
103
+ * Push source. The host takes it and ingests what arrives
104
+ * (docs/dispatch.md#tasks); a host that prefers polling ignores it.
105
+ */
106
+ subscribe(handler: Handler): Unsubscribe;
107
+ /** True once the server is listening. */
108
+ get online(): boolean;
109
+ /** Where the page is served, once it is up. */
110
+ get url(): string | undefined;
111
+ /** Every message this channel carries, oldest first. */
112
+ list(): readonly HttpMessage[];
113
+ /** The conversations this channel carries, most recent first. */
114
+ conversations(): readonly Target[];
115
+ /** Start serving the page and the webhook. Resolves with where it listens. */
116
+ listen(): Promise<string>;
117
+ /** Stop serving. What the channel recorded stays, for `get` and for the page. */
118
+ close(): Promise<void>;
119
+ /**
120
+ * One inbound message. The channel builds the outside event - its native id,
121
+ * the conversation as its target, the blocks a window reads - and hands it to
122
+ * every subscriber, which is the host (docs/dispatch.md#tasks). A native id
123
+ * that already arrived is forwarded anyway: the host's journal is what keeps
124
+ * it stored once, and the answer says so.
125
+ */
126
+ receive(body: WebhookBody): Promise<WebhookResult>;
127
+ /** One native id, with the channel prefix, and whether it had arrived before. */
128
+ private assign;
129
+ /** The page, its script, and the JSON reads the page polls. */
130
+ private route;
131
+ /** One file of the page, read on each request so a change needs no restart. */
132
+ private file;
133
+ private answer;
134
+ }
135
+ export {};
package/channel.js ADDED
@@ -0,0 +1,395 @@
1
+ /**
2
+ * The HTTP test channel: an outside source and sink over plain HTTP
3
+ * (packages/interfaces/src/channel.ts, docs/architecture.md#interfaces).
4
+ *
5
+ * It is the first real plugin's adapter, and it is a test tool: it holds a
6
+ * conversation per id, turns an inbound message into one `OutsideEvent` that
7
+ * carries its native id, records what a window sent, and serves the page a
8
+ * person drives it from. Nothing here knows the host; a deployment plugs the
9
+ * channel in and the host takes its push source
10
+ * (docs/architecture.md#plugins).
11
+ *
12
+ * An inbound message keeps the id the sender gave it, with the channel prefix:
13
+ * `http:42`. The journal's unique index on `external` is what makes a
14
+ * redelivered message stored once (docs/journal.md#external-ids), so this
15
+ * channel forwards every delivery it is given and keeps one line per native id
16
+ * on the page: the exactly-once guarantee is the host's, not the transport's.
17
+ *
18
+ * The page it serves is a local test tool, so the server binds `127.0.0.1` by
19
+ * default and a deployment that names another host says so explicitly.
20
+ * Visibility, permissions and auth are not part of the framework.
21
+ */
22
+ import { createServer } from "node:http";
23
+ import { readFile } from "node:fs/promises";
24
+ import { fileURLToPath } from "node:url";
25
+ import { dirname, join } from "node:path";
26
+ import { arborError } from "@4fu/arbor-errors";
27
+ import { dataBlock } from "@4fu/arbor-prompt";
28
+ /** How large a request body may be. 1 MiB is far more than a test message. */
29
+ const MAX_BODY_BYTES = 1024 * 1024;
30
+ /** Where the page's own files are, beside this module. */
31
+ const WEB_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "web");
32
+ /** The channel prefix every native id and every conversation target carries. */
33
+ const DEFAULT_CHANNEL_ID = "http";
34
+ export class HttpChannel {
35
+ id;
36
+ board;
37
+ host;
38
+ port;
39
+ clock;
40
+ messages = [];
41
+ handlers = new Set();
42
+ server;
43
+ address;
44
+ /** Every native id this channel has used, so an assigned one never collides. */
45
+ used = new Set();
46
+ next = 1;
47
+ constructor(options = {}) {
48
+ this.id = options.id ?? DEFAULT_CHANNEL_ID;
49
+ this.board = options.board;
50
+ this.host = options.host ?? "127.0.0.1";
51
+ this.port = options.port ?? 0;
52
+ this.clock = options.now ?? (() => Date.now());
53
+ }
54
+ // ---- the interface ------------------------------------------------------
55
+ /** What is connected: the one connection this channel has, and its state. */
56
+ async connections() {
57
+ return [
58
+ {
59
+ id: this.id,
60
+ online: this.online,
61
+ ...(this.address === undefined ? {} : { detail: this.address }),
62
+ },
63
+ ];
64
+ }
65
+ /**
66
+ * Look up a message by its native id. This channel is its own store, so the
67
+ * copy is the one it kept: `stored` says it came from the store rather than
68
+ * from a live source, which for this channel means the server is not up
69
+ * (packages/interfaces/src/channel.ts).
70
+ */
71
+ async get(nativeId) {
72
+ const message = this.messages.find((one) => one.external === nativeId);
73
+ if (message === undefined)
74
+ return null;
75
+ return {
76
+ external: message.external,
77
+ source: this.id,
78
+ target: message.conversation,
79
+ time: message.time,
80
+ content: [dataBlock(message.text)],
81
+ ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
82
+ stored: !this.online,
83
+ };
84
+ }
85
+ /**
86
+ * The sink. A window's `send` reaches here as an effect
87
+ * (docs/coordination.md#effects); the channel records it and hands back the
88
+ * native id the outside world would have given it, which the host records on
89
+ * the effect's result.
90
+ */
91
+ async send(request) {
92
+ const external = this.assign().external;
93
+ const message = {
94
+ external,
95
+ conversation: request.target,
96
+ direction: "out",
97
+ from: "arbor",
98
+ text: textOfBlocks(request.content),
99
+ time: this.clock(),
100
+ ...(request.replyTo === undefined ? {} : { replyTo: request.replyTo }),
101
+ };
102
+ this.messages.push(message);
103
+ return { external: message.external, time: message.time };
104
+ }
105
+ /**
106
+ * Push source. The host takes it and ingests what arrives
107
+ * (docs/dispatch.md#tasks); a host that prefers polling ignores it.
108
+ */
109
+ subscribe(handler) {
110
+ this.handlers.add(handler);
111
+ return () => {
112
+ this.handlers.delete(handler);
113
+ };
114
+ }
115
+ // ---- what the page reads ------------------------------------------------
116
+ /** True once the server is listening. */
117
+ get online() {
118
+ return this.server !== undefined && this.address !== undefined;
119
+ }
120
+ /** Where the page is served, once it is up. */
121
+ get url() {
122
+ return this.address;
123
+ }
124
+ /** Every message this channel carries, oldest first. */
125
+ list() {
126
+ return [...this.messages];
127
+ }
128
+ /** The conversations this channel carries, most recent first. */
129
+ conversations() {
130
+ const seen = new Map();
131
+ for (const message of this.messages)
132
+ seen.set(message.conversation, message.time);
133
+ return [...seen.entries()].sort((left, right) => right[1] - left[1]).map(([target]) => target);
134
+ }
135
+ /** Start serving the page and the webhook. Resolves with where it listens. */
136
+ async listen() {
137
+ if (this.server !== undefined)
138
+ return this.address;
139
+ const server = createServer((request, response) => {
140
+ void this.route(request, response).catch((error) => {
141
+ void this.answer(response, 500, { ok: false, error: infoOf(error) });
142
+ });
143
+ });
144
+ this.server = server;
145
+ try {
146
+ await new Promise((resolve, reject) => {
147
+ server.once("error", reject);
148
+ server.listen(this.port, this.host, () => resolve());
149
+ });
150
+ }
151
+ catch (error) {
152
+ this.server = undefined;
153
+ throw error;
154
+ }
155
+ const bound = server.address();
156
+ const port = typeof bound === "object" && bound !== null ? bound.port : this.port;
157
+ this.address = `http://${this.host}:${port}`;
158
+ return this.address;
159
+ }
160
+ /** Stop serving. What the channel recorded stays, for `get` and for the page. */
161
+ async close() {
162
+ const server = this.server;
163
+ this.server = undefined;
164
+ this.address = undefined;
165
+ if (server === undefined)
166
+ return;
167
+ await new Promise((resolve) => {
168
+ server.close(() => resolve());
169
+ server.closeAllConnections();
170
+ });
171
+ }
172
+ // ---- the webhook ---------------------------------------------------------
173
+ /**
174
+ * One inbound message. The channel builds the outside event - its native id,
175
+ * the conversation as its target, the blocks a window reads - and hands it to
176
+ * every subscriber, which is the host (docs/dispatch.md#tasks). A native id
177
+ * that already arrived is forwarded anyway: the host's journal is what keeps
178
+ * it stored once, and the answer says so.
179
+ */
180
+ async receive(body) {
181
+ const conversation = typeof body.conversation === "string" ? body.conversation.trim() : "";
182
+ if (conversation === "") {
183
+ throw arborError({
184
+ code: "invalid_input",
185
+ rule: "http.webhook.conversation",
186
+ message: "webhook: 没有给出来源名",
187
+ nextAction: "在 body 里给 conversation,例如 {\"conversation\":\"demo\",\"text\":\"你好\"}。",
188
+ });
189
+ }
190
+ const text = typeof body.text === "string" ? body.text : "";
191
+ if (text.trim() === "") {
192
+ throw arborError({
193
+ code: "invalid_input",
194
+ rule: "http.webhook.text",
195
+ message: "webhook: 没有给出消息内容",
196
+ nextAction: "在 body 里给 text。",
197
+ });
198
+ }
199
+ const replyTo = typeof body.replyTo === "string" && body.replyTo.trim() !== ""
200
+ ? nativeId(this.id, body.replyTo.trim())
201
+ : undefined;
202
+ const target = `${this.id}:${conversation}`;
203
+ const assigned = this.assign(typeof body.id === "string" && body.id.trim() !== "" ? body.id.trim() : undefined);
204
+ const redelivered = assigned.seen;
205
+ // One line per native id on the page, even when a sender retries.
206
+ if (!redelivered) {
207
+ this.messages.push({
208
+ external: assigned.external,
209
+ conversation: target,
210
+ direction: "in",
211
+ from: typeof body.from === "string" && body.from.trim() !== "" ? body.from.trim() : "web",
212
+ text,
213
+ time: this.clock(),
214
+ ...(replyTo === undefined ? {} : { replyTo }),
215
+ });
216
+ }
217
+ const event = {
218
+ source: this.id,
219
+ external: assigned.external,
220
+ target,
221
+ time: this.clock(),
222
+ content: [dataBlock(text)],
223
+ ...(replyTo === undefined ? {} : { replyTo }),
224
+ };
225
+ for (const handler of [...this.handlers])
226
+ await handler([event]);
227
+ return { external: assigned.external, target, redelivered };
228
+ }
229
+ /** One native id, with the channel prefix, and whether it had arrived before. */
230
+ assign(wanted) {
231
+ if (wanted !== undefined) {
232
+ const external = nativeId(this.id, wanted);
233
+ const seen = this.used.has(external);
234
+ this.used.add(external);
235
+ return { external, seen };
236
+ }
237
+ for (;;) {
238
+ const external = nativeId(this.id, String(this.next));
239
+ this.next += 1;
240
+ if (this.used.has(external))
241
+ continue;
242
+ this.used.add(external);
243
+ return { external, seen: false };
244
+ }
245
+ }
246
+ // ---- the HTTP surface ----------------------------------------------------
247
+ /** The page, its script, and the JSON reads the page polls. */
248
+ async route(request, response) {
249
+ const url = new URL(request.url ?? "/", `http://${this.host}`);
250
+ if (request.method === "POST" && url.pathname === "/webhook") {
251
+ const received = await this.receive((await bodyOf(request)));
252
+ await this.answer(response, 200, { ok: true, ...received });
253
+ return;
254
+ }
255
+ if (request.method === "GET" && url.pathname === "/api/state") {
256
+ await this.answer(response, 200, {
257
+ ok: true,
258
+ channel: this.id,
259
+ online: this.online,
260
+ address: this.address ?? "",
261
+ conversations: this.conversations().map((target) => ({
262
+ target,
263
+ messages: this.messages.filter((message) => message.conversation === target).length,
264
+ })),
265
+ board: (this.board?.() ?? []).map((line) => ({
266
+ window: line.window,
267
+ state: line.state,
268
+ doing: line.doing,
269
+ targets: [...line.targets],
270
+ expects: line.expects.map((one) => one.description ?? one.item ?? one.ref),
271
+ usage: { used: line.usage.used, limit: line.usage.limit },
272
+ })),
273
+ });
274
+ return;
275
+ }
276
+ if (request.method === "GET" && url.pathname === "/api/messages") {
277
+ const wanted = url.searchParams.get("conversation") ?? "";
278
+ const target = wanted === "" ? undefined : wanted.includes(":") ? wanted : `${this.id}:${wanted}`;
279
+ await this.answer(response, 200, {
280
+ ok: true,
281
+ items: this.messages
282
+ .filter((message) => target === undefined || message.conversation === target)
283
+ .map((message) => ({
284
+ external: message.external,
285
+ conversation: message.conversation,
286
+ direction: message.direction,
287
+ from: message.from,
288
+ text: message.text,
289
+ time: message.time,
290
+ ...(message.replyTo === undefined ? {} : { replyTo: message.replyTo }),
291
+ })),
292
+ });
293
+ return;
294
+ }
295
+ if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
296
+ await this.file(response, join(WEB_DIRECTORY, "index.html"), "text/html; charset=utf-8");
297
+ return;
298
+ }
299
+ if (request.method === "GET" && url.pathname === "/app.js") {
300
+ await this.file(response, join(WEB_DIRECTORY, "app.js"), "text/javascript; charset=utf-8");
301
+ return;
302
+ }
303
+ await this.answer(response, 404, {
304
+ ok: false,
305
+ error: {
306
+ code: "not_found",
307
+ rule: "http.route.unknown",
308
+ message: `没有 ${request.method ?? "GET"} ${url.pathname} 这个端点`,
309
+ nextAction: "用 GET / 打开页面,POST /webhook 收消息,GET /api/state 看现场板。",
310
+ },
311
+ });
312
+ }
313
+ /** One file of the page, read on each request so a change needs no restart. */
314
+ async file(response, path, type) {
315
+ let text;
316
+ try {
317
+ text = await readFile(path, "utf8");
318
+ }
319
+ catch {
320
+ await this.answer(response, 404, {
321
+ ok: false,
322
+ error: {
323
+ code: "not_found",
324
+ rule: "http.file.absent",
325
+ message: `页面文件 ${path} 不存在`,
326
+ nextAction: "检查这个插件的 src/web 目录。",
327
+ },
328
+ });
329
+ return;
330
+ }
331
+ response.writeHead(200, { "content-type": type, "cache-control": "no-store" });
332
+ response.end(text);
333
+ }
334
+ async answer(response, status, body) {
335
+ response.writeHead(status, {
336
+ "content-type": "application/json; charset=utf-8",
337
+ "cache-control": "no-store",
338
+ });
339
+ response.end(JSON.stringify(body));
340
+ }
341
+ }
342
+ /** The text of a send's blocks: what a window said, as one string. */
343
+ function textOfBlocks(blocks) {
344
+ const lines = [];
345
+ for (const block of blocks) {
346
+ const content = block.fields.content;
347
+ if (typeof content === "string")
348
+ lines.push(content);
349
+ }
350
+ return lines.join("\n");
351
+ }
352
+ /** One native id: the channel prefix, then the sender's own id for the message. */
353
+ function nativeId(channel, id) {
354
+ return `${channel}:${id}`;
355
+ }
356
+ /** One request body, read whole and parsed. */
357
+ async function bodyOf(request) {
358
+ const chunks = [];
359
+ let size = 0;
360
+ for await (const chunk of request) {
361
+ size += chunk.length;
362
+ if (size > MAX_BODY_BYTES) {
363
+ throw arborError({
364
+ code: "limit_reached",
365
+ rule: "http.body.too_large",
366
+ message: `请求体超过 ${MAX_BODY_BYTES} 字节`,
367
+ nextAction: "分条发送,或把这个插件的上限调大。",
368
+ });
369
+ }
370
+ chunks.push(chunk);
371
+ }
372
+ if (chunks.length === 0)
373
+ return {};
374
+ try {
375
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
376
+ }
377
+ catch {
378
+ throw arborError({
379
+ code: "invalid_input",
380
+ rule: "http.body.json",
381
+ message: "请求体不是 JSON",
382
+ nextAction: "发送一个 JSON 对象:{\"conversation\":\"demo\",\"text\":\"你好\"}。",
383
+ });
384
+ }
385
+ }
386
+ /** Anything a route threw, in the core's error shape. */
387
+ function infoOf(error) {
388
+ const info = error;
389
+ return {
390
+ code: typeof info.code === "string" ? info.code : "internal",
391
+ rule: typeof info.rule === "string" ? info.rule : "http.failed",
392
+ message: typeof info.message === "string" ? info.message : String(error),
393
+ ...(typeof info.nextAction === "string" ? { nextAction: info.nextAction } : {}),
394
+ };
395
+ }
package/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @4fu/arbor-plugin-http - the HTTP test channel, and the first plugin the host
3
+ * runs end to end (docs/architecture.md#plugins).
4
+ *
5
+ * The channel is the adapter: a webhook in, a window's sends out, and a page a
6
+ * person drives it from. `httpPlugin` wraps it as a `Plugin`, and `src/server.ts`
7
+ * is the entry point that composes the default stack around it.
8
+ */
9
+ export { HttpChannel, type HttpChannelOptions, type HttpDirection, type HttpMessage, type WebhookBody, type WebhookResult, } from "./channel.ts";
10
+ export { HttpSendExecutor, httpPlugin, type HttpPluginOptions } from "./plugin.ts";
package/index.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @4fu/arbor-plugin-http - the HTTP test channel, and the first plugin the host
3
+ * runs end to end (docs/architecture.md#plugins).
4
+ *
5
+ * The channel is the adapter: a webhook in, a window's sends out, and a page a
6
+ * person drives it from. `httpPlugin` wraps it as a `Plugin`, and `src/server.ts`
7
+ * is the entry point that composes the default stack around it.
8
+ */
9
+ export { HttpChannel, } from "./channel.js";
10
+ export { HttpSendExecutor, httpPlugin } from "./plugin.js";