replica-bridge 0.3.12 → 0.3.14
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/package.json +1 -1
- package/src/bridge/board.js +260 -48
- package/src/bridge/service.js +3 -1
- package/src/protocol.js +13 -4
package/package.json
CHANGED
package/src/bridge/board.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
// The optional 任务 board (PROTOCOL.md "看板"): reads AgensWork's agens-backend, which only listens on this computer, for
|
|
2
2
|
// a phone that asked. The bridge does not start the backend or care how it started: it knows a port, and a computer
|
|
3
|
-
// where nothing answers there simply has no board. Reads are
|
|
4
|
-
//
|
|
5
|
-
|
|
3
|
+
// where nothing answers there simply has no board. Reads and writes are short allowlists, and the only background traffic
|
|
4
|
+
// is the change log (/v1/changes, or its push stream /v1/changes/stream where the backend has one) while a phone is
|
|
5
|
+
// subscribed, because reading the lists counts as the user having seen them. Each subscriber has its own place in the
|
|
6
|
+
// log, so a phone that comes back says where it was and gets only what changed since (0.3.14).
|
|
7
|
+
// The bridge stays thin: it checks the shape of a write and leaves what it means to the backend, whose status and body
|
|
8
|
+
// (REC_NOT_FOUND, REC_STATE_CONFLICT, …) go back to the phone unchanged.
|
|
9
|
+
|
|
10
|
+
import { PACKAGE_VERSION } from "./install.js";
|
|
6
11
|
|
|
7
12
|
/** agens-backend's default port, which its own AGENS_BACKEND_PORT changes. */
|
|
8
13
|
export const DEFAULT_BOARD_PORT = 4753;
|
|
9
|
-
/**
|
|
10
|
-
const DEFAULT_POLL_SECONDS =
|
|
14
|
+
/** How often the log is read when the backend cannot push it; one indexed query on this computer. */
|
|
15
|
+
const DEFAULT_POLL_SECONDS = 1;
|
|
11
16
|
/** Past this the backend is treated as gone, so a hung one cannot hold a phone waiting. */
|
|
12
17
|
const READ_TIMEOUT_MS = 5000;
|
|
13
18
|
/** Well under RELAY_REPLY_LIMIT, since the bridge sends board answers in one frame. */
|
|
@@ -15,19 +20,64 @@ const BODY_MAX_BYTES = 8 * 1024 * 1024;
|
|
|
15
20
|
const CHANGES_PAGE = 100;
|
|
16
21
|
/** Stops a catch-up on a change log that never ends; the backend keeps 30 days of it. */
|
|
17
22
|
const CATCH_UP_PAGES = 1000;
|
|
23
|
+
/** More changed tasks than this in one event, and the phone reads the whole board instead. */
|
|
24
|
+
export const CHANGED_ITEMS_MAX = 200;
|
|
25
|
+
/** What this bridge can do beyond reading, for board/state (PROTOCOL.md "看板"). */
|
|
26
|
+
export const BOARD_CAPS = ["post", "delta"] ;
|
|
18
27
|
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
28
|
+
/** The backend's own cap on a request body (agens-backend src/http.mjs BODY_MAX). */
|
|
29
|
+
export const POST_BODY_MAX_BYTES = 16 * 1024;
|
|
30
|
+
const REC = "[A-Za-z0-9_.:-]+";
|
|
21
31
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
32
|
+
/**
|
|
33
|
+
* GETs (#367). /session also writes TASK.md in the task's working folder, which is what 开工 asks for; /share and /me
|
|
34
|
+
* only read. Not /assist, /mcp, /search, /changes (the bridge's own poll) or anything under /ui.
|
|
35
|
+
*/
|
|
36
|
+
const READS = [/^\/v1\/status$/, /^\/v1\/today$/, /^\/v1\/recommendations$/, new RegExp(`^/v1/recommendations/${REC}$`),
|
|
37
|
+
new RegExp(`^/v1/recommendations/${REC}/(share|session)$`), /^\/v1\/me$/];
|
|
38
|
+
|
|
39
|
+
/** POSTs (#367): a task's follow-up actions, cancelling its preparation note, and muting a conversation. Nothing else. */
|
|
40
|
+
const WRITES = [new RegExp(`^/v1/recommendations/${REC}/actions$`), new RegExp(`^/v1/recommendations/${REC}/prep/cancel$`), /^\/v1\/me\/muted$/];
|
|
41
|
+
/** agens-backend src/recs.mjs ACTIONS. */
|
|
42
|
+
export const BOARD_ACTIONS = ["accept", "snooze", "delegate", "dismiss", "done", "reopen"] ;
|
|
43
|
+
|
|
44
|
+
function localTarget(path ) {
|
|
24
45
|
if (typeof path !== "string" || !path.startsWith("/") || path.startsWith("//") || path.includes("\\")) return null;
|
|
25
46
|
let target ;
|
|
26
47
|
try { target = new URL(path, "http://board.invalid"); } catch { return null; }
|
|
27
|
-
|
|
48
|
+
return target.origin === "http://board.invalid" ? target : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The path and query to read, or null when it is not on the allowlist. */
|
|
52
|
+
export function allowedBoardPath(path ) {
|
|
53
|
+
const target = localTarget(path);
|
|
54
|
+
if (!target || !READS.some((read) => read.test(target.pathname))) return null;
|
|
28
55
|
return target.pathname + target.search;
|
|
29
56
|
}
|
|
30
57
|
|
|
58
|
+
/**
|
|
59
|
+
* The path and body to POST, or why not. The path is one of WRITES with no query; the body is JSON text of an object
|
|
60
|
+
* within the backend's size cap (absent or empty means `{}`, for prep/cancel), and on /actions names a known action.
|
|
61
|
+
*/
|
|
62
|
+
export function allowedBoardPost(path , body ) {
|
|
63
|
+
const target = localTarget(path);
|
|
64
|
+
if (!target || target.search || !WRITES.some((write) => write.test(target.pathname))) return { error: "not_allowed" };
|
|
65
|
+
if (body === undefined || body === "") body = "{}";
|
|
66
|
+
if (typeof body !== "string") return { error: "bad_body" };
|
|
67
|
+
if (Buffer.byteLength(body, "utf8") > POST_BODY_MAX_BYTES) return { error: "too_large" };
|
|
68
|
+
let value ;
|
|
69
|
+
try { value = JSON.parse(body); } catch { return { error: "bad_body" }; }
|
|
70
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { error: "bad_body" };
|
|
71
|
+
if (target.pathname.endsWith("/actions")) {
|
|
72
|
+
const action = (value ).action;
|
|
73
|
+
if (typeof action !== "string" || !(BOARD_ACTIONS ).includes(action)) return { error: "not_allowed" };
|
|
74
|
+
}
|
|
75
|
+
const sent = JSON.stringify(value);
|
|
76
|
+
// Rewriting can lengthen it (1e5 becomes 100000), and the backend counts what it receives.
|
|
77
|
+
if (Buffer.byteLength(sent, "utf8") > POST_BODY_MAX_BYTES) return { error: "too_large" };
|
|
78
|
+
return { path: target.pathname, body: sent };
|
|
79
|
+
}
|
|
80
|
+
|
|
31
81
|
/** The port from AGENS_BACKEND_PORT, the backend's own setting, when it names a real one. */
|
|
32
82
|
export function boardPortFrom(env = process.env) {
|
|
33
83
|
const port = Number(env.AGENS_BACKEND_PORT);
|
|
@@ -39,33 +89,79 @@ export function boardPortFrom(env = process.env) {
|
|
|
39
89
|
|
|
40
90
|
|
|
41
91
|
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
/** One subscriber and how far through the change log it has been told. */
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
/** Changes as a phone gets them: one per task, the latest, or a reset when there are too many. */
|
|
105
|
+
function changedEvent(items ) {
|
|
106
|
+
const latest = new Map ();
|
|
107
|
+
for (const item of items) { latest.delete(item.recId); latest.set(item.recId, item); }
|
|
108
|
+
return latest.size > CHANGED_ITEMS_MAX ? { t: "board/changed", reset: true } : { t: "board/changed", items: [...latest.values()] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function changeOf(value ) {
|
|
112
|
+
const item = value ;
|
|
113
|
+
if (!item || !Number.isSafeInteger(item.seq) || typeof item.recId !== "string" || typeof item.kind !== "string") return null;
|
|
114
|
+
return { seq: item.seq , recId: item.recId, kind: item.kind };
|
|
115
|
+
}
|
|
116
|
+
|
|
42
117
|
export class BoardReader {
|
|
43
118
|
port ;
|
|
44
119
|
pollMs ;
|
|
45
|
-
|
|
120
|
+
subs = new Set ();
|
|
46
121
|
timer = null;
|
|
47
122
|
polling = false;
|
|
48
|
-
/** Where the change log was read to; null until a
|
|
49
|
-
|
|
123
|
+
/** Where the change log was read to; null until a read has found its end. Kept while the backend is away. */
|
|
124
|
+
end = null;
|
|
50
125
|
up = null;
|
|
51
|
-
/** Bumped when polling stops, so a
|
|
126
|
+
/** Bumped when polling stops, so a read still on its way does not speak for the next round. */
|
|
52
127
|
round = 0;
|
|
128
|
+
/** The backend's push stream: open, unknown (try it), or not there (an older backend answers 404). */
|
|
129
|
+
stream = null;
|
|
130
|
+
streamSupported = null;
|
|
53
131
|
|
|
54
132
|
constructor(options = {}) {
|
|
55
133
|
this.port = options.port ?? boardPortFrom();
|
|
56
134
|
this.pollMs = (options.pollSeconds ?? DEFAULT_POLL_SECONDS) * 1000;
|
|
57
135
|
}
|
|
58
136
|
|
|
137
|
+
/** How many phones are subscribed. */
|
|
138
|
+
get listeners() { return this.subs; }
|
|
139
|
+
/** Whether the backend is pushing changes now, rather than being polled. */
|
|
140
|
+
get streaming() { return this.stream !== null; }
|
|
141
|
+
|
|
59
142
|
/** The fetch adds the Host header the backend insists on (127.0.0.1:<port>) and no Origin, as a local caller. */
|
|
60
|
-
fetch(pathAndQuery ) {
|
|
61
|
-
return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, {
|
|
143
|
+
fetch(pathAndQuery , post , signal = AbortSignal.timeout(READ_TIMEOUT_MS)) {
|
|
144
|
+
return fetch(`http://127.0.0.1:${this.port}${pathAndQuery}`, {
|
|
145
|
+
redirect: "error", signal,
|
|
146
|
+
...(post === undefined ? {} : { method: "POST", headers: { "content-type": "application/json" }, body: post }),
|
|
147
|
+
});
|
|
62
148
|
}
|
|
63
149
|
|
|
64
|
-
|
|
150
|
+
read(path ) {
|
|
65
151
|
const allowed = allowedBoardPath(path);
|
|
66
|
-
|
|
152
|
+
return allowed ? this.answer(allowed) : Promise.resolve({ error: "not_allowed" });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Sent once and never retried: a write that timed out may still have happened, so the phone reads the task again. */
|
|
156
|
+
post(path , body ) {
|
|
157
|
+
const allowed = allowedBoardPost(path, body);
|
|
158
|
+
return "error" in allowed ? Promise.resolve(allowed) : this.answer(allowed.path, allowed.body);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The backend's status and body as they are, errors included. */
|
|
162
|
+
async answer(pathAndQuery , post ) {
|
|
67
163
|
try {
|
|
68
|
-
const response = await this.fetch(
|
|
164
|
+
const response = await this.fetch(pathAndQuery, post);
|
|
69
165
|
const body = await response.text();
|
|
70
166
|
if (Buffer.byteLength(body, "utf8") > BODY_MAX_BYTES) return { error: "too_large" };
|
|
71
167
|
return { status: response.status, body };
|
|
@@ -74,72 +170,188 @@ export class BoardReader {
|
|
|
74
170
|
}
|
|
75
171
|
}
|
|
76
172
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Tells `listener` about the board until the returned function is called. Its first board/state carries `seq`, the
|
|
175
|
+
* point in the change log it starts from; with `after` (where the phone was) it then gets what changed since, else it
|
|
176
|
+
* reads the whole board. The log is read only while someone listens.
|
|
177
|
+
*/
|
|
178
|
+
subscribe(listener , after ) {
|
|
179
|
+
const sub = { listener, wanted: Number.isSafeInteger(after) && after >= 0 ? after : null, cursor: null, admitting: false };
|
|
180
|
+
this.subs.add(sub);
|
|
181
|
+
if (this.up === false) listener(this.state(false, null));
|
|
81
182
|
if (!this.timer) {
|
|
82
183
|
this.timer = setInterval(() => void this.poll(), this.pollMs);
|
|
83
184
|
this.timer.unref();
|
|
84
185
|
void this.poll();
|
|
85
|
-
}
|
|
186
|
+
} else if (this.up && this.end !== null) void this.admit(sub);
|
|
86
187
|
return () => {
|
|
87
|
-
this.
|
|
88
|
-
if (!this.
|
|
188
|
+
this.subs.delete(sub);
|
|
189
|
+
if (!this.subs.size) this.stop();
|
|
89
190
|
};
|
|
90
191
|
}
|
|
91
192
|
|
|
92
193
|
stop() {
|
|
93
|
-
this.
|
|
194
|
+
this.subs.clear();
|
|
94
195
|
if (this.timer) clearInterval(this.timer);
|
|
95
196
|
this.timer = null;
|
|
96
|
-
this.
|
|
197
|
+
this.stream?.abort();
|
|
198
|
+
this.stream = null;
|
|
199
|
+
this.streamSupported = null;
|
|
200
|
+
this.end = null;
|
|
97
201
|
this.up = null;
|
|
98
202
|
this.polling = false;
|
|
99
203
|
this.round++;
|
|
100
204
|
}
|
|
101
205
|
|
|
102
|
-
|
|
103
|
-
|
|
206
|
+
state(up , seq ) {
|
|
207
|
+
return up && seq !== null ? { t: "board/state", up, seq, caps: [...BOARD_CAPS], bridge: PACKAGE_VERSION } : { t: "board/state", up };
|
|
104
208
|
}
|
|
105
209
|
|
|
106
|
-
async changes(after )
|
|
210
|
+
async changes(after ) {
|
|
107
211
|
const response = await this.fetch(`/v1/changes?after=${after}&limit=${CHANGES_PAGE}`);
|
|
108
212
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
109
213
|
const page = await response.json() ;
|
|
110
214
|
if (!Array.isArray(page.items)) throw new Error("not a page of changes");
|
|
111
|
-
|
|
215
|
+
const items = page.items.map(changeOf).filter((item) => item !== null);
|
|
216
|
+
return { items, next: typeof page.next === "number" ? page.next : after };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Brings a new subscriber to the end of the log: its board/state, then what it missed, or a reset. */
|
|
220
|
+
async admit(sub ) {
|
|
221
|
+
if (sub.admitting || sub.cursor !== null || this.end === null) return;
|
|
222
|
+
sub.admitting = true;
|
|
223
|
+
const round = this.round;
|
|
224
|
+
const missed = [];
|
|
225
|
+
let reset = false, cursor = sub.wanted;
|
|
226
|
+
try {
|
|
227
|
+
if (cursor !== null && cursor > this.end) { reset = true; cursor = this.end; } // a log that went backwards: another backend, or a new database
|
|
228
|
+
// Read until this subscriber is at least where the shared reader is, so nothing falls between the two.
|
|
229
|
+
for (let page = 0; !reset && cursor !== null && cursor < this.end && page < CATCH_UP_PAGES; page++) {
|
|
230
|
+
const { items, next } = await this.changes(cursor);
|
|
231
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
232
|
+
// The log keeps 30 days: a first change past the next number means some were deleted unseen.
|
|
233
|
+
if (page === 0 && items.length && items[0].seq > cursor + 1) reset = true;
|
|
234
|
+
missed.push(...items);
|
|
235
|
+
if (missed.length > CHANGED_ITEMS_MAX * 4) reset = true;
|
|
236
|
+
if (next === cursor && items.length === 0) break;
|
|
237
|
+
cursor = next;
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
sub.admitting = false; // the next read of the log tries again
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (round !== this.round || !this.subs.has(sub)) return;
|
|
244
|
+
sub.admitting = false;
|
|
245
|
+
sub.cursor = Math.max(cursor ?? this.end , this.end );
|
|
246
|
+
sub.listener(this.state(true, sub.cursor));
|
|
247
|
+
if (reset) sub.listener({ t: "board/changed", reset: true });
|
|
248
|
+
else if (missed.length) sub.listener(changedEvent(missed));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** New changes up to `end`: each subscriber gets those past its own place. */
|
|
252
|
+
deliver(items , end ) {
|
|
253
|
+
for (const sub of this.subs) {
|
|
254
|
+
if (sub.cursor === null) continue;
|
|
255
|
+
const fresh = items.filter((item) => item.seq > sub.cursor );
|
|
256
|
+
sub.cursor = Math.max(sub.cursor, end);
|
|
257
|
+
if (fresh.length) sub.listener(changedEvent(fresh));
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
setUp(up ) {
|
|
262
|
+
if (up === this.up) return;
|
|
263
|
+
this.up = up;
|
|
264
|
+
for (const sub of this.subs) {
|
|
265
|
+
if (!up) sub.listener(this.state(false, null));
|
|
266
|
+
else if (sub.cursor !== null) sub.listener(this.state(true, sub.cursor));
|
|
267
|
+
}
|
|
112
268
|
}
|
|
113
269
|
|
|
114
270
|
async poll() {
|
|
115
|
-
if (this.polling) return;
|
|
271
|
+
if (this.polling || this.stream) return;
|
|
116
272
|
this.polling = true;
|
|
117
273
|
const round = this.round;
|
|
118
|
-
let
|
|
274
|
+
let items = [], up = true, end = this.end;
|
|
119
275
|
try {
|
|
120
|
-
// The first
|
|
121
|
-
if (
|
|
122
|
-
|
|
276
|
+
// The first read only finds the end of the log: what happened before someone was listening is not news.
|
|
277
|
+
if (end === null) {
|
|
278
|
+
end = 0;
|
|
123
279
|
for (let page = 0; page < CATCH_UP_PAGES; page++) {
|
|
124
|
-
const { items, next } = await this.changes(
|
|
125
|
-
|
|
126
|
-
if (
|
|
280
|
+
const { items: found, next } = await this.changes(end);
|
|
281
|
+
end = next;
|
|
282
|
+
if (found.length < CHANGES_PAGE) break;
|
|
127
283
|
}
|
|
128
284
|
} else {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
285
|
+
const page = await this.changes(end);
|
|
286
|
+
items = page.items;
|
|
287
|
+
end = page.next;
|
|
132
288
|
}
|
|
133
289
|
} catch {
|
|
134
290
|
up = false;
|
|
135
291
|
}
|
|
136
292
|
if (round !== this.round) return;
|
|
137
293
|
this.polling = false;
|
|
138
|
-
if (up) this.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
294
|
+
if (up) this.end = end;
|
|
295
|
+
this.setUp(up);
|
|
296
|
+
if (!up) return;
|
|
297
|
+
if (items.length) this.deliver(items, end );
|
|
298
|
+
for (const sub of this.subs) if (sub.cursor === null) void this.admit(sub);
|
|
299
|
+
if (this.streamSupported !== false) void this.openStream(round);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Listens to the backend's push stream from where the log was read to. A backend without one answers 404 (or not as
|
|
304
|
+
* an event stream), and the bridge keeps polling; a stream that ends hands back to polling, which reopens it.
|
|
305
|
+
*/
|
|
306
|
+
async openStream(round ) {
|
|
307
|
+
if (this.stream || this.end === null) return;
|
|
308
|
+
const controller = new AbortController();
|
|
309
|
+
this.stream = controller;
|
|
310
|
+
let response ;
|
|
311
|
+
try {
|
|
312
|
+
response = await this.fetch(`/v1/changes/stream?after=${this.end}`, undefined, controller.signal);
|
|
313
|
+
} catch {
|
|
314
|
+
if (this.stream === controller) this.stream = null;
|
|
315
|
+
return;
|
|
142
316
|
}
|
|
143
|
-
if (
|
|
317
|
+
if (round !== this.round || this.stream !== controller) { controller.abort(); return; }
|
|
318
|
+
if (!response.ok || !(response.headers.get("content-type") ?? "").startsWith("text/event-stream") || !response.body) {
|
|
319
|
+
this.streamSupported = false;
|
|
320
|
+
this.stream = null;
|
|
321
|
+
controller.abort();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
this.streamSupported = true;
|
|
325
|
+
let buffer = "", pending = [], flush = null;
|
|
326
|
+
const send = () => {
|
|
327
|
+
flush = null;
|
|
328
|
+
if (round !== this.round || this.stream !== controller) return;
|
|
329
|
+
const batch = pending;
|
|
330
|
+
pending = [];
|
|
331
|
+
const end = Math.max(this.end ?? 0, ...batch.map((item) => item.seq));
|
|
332
|
+
this.end = end;
|
|
333
|
+
this.deliver(batch, end);
|
|
334
|
+
};
|
|
335
|
+
try {
|
|
336
|
+
const decoder = new TextDecoder();
|
|
337
|
+
for await (const chunk of response.body ) {
|
|
338
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
339
|
+
let split ;
|
|
340
|
+
while ((split = buffer.search(/\r?\n\r?\n/)) >= 0) {
|
|
341
|
+
const event = buffer.slice(0, split);
|
|
342
|
+
buffer = buffer.slice(split).replace(/^\r?\n\r?\n/, "");
|
|
343
|
+
const data = event.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
344
|
+
if (!data) continue; // a heartbeat
|
|
345
|
+
let item = null;
|
|
346
|
+
try { item = changeOf(JSON.parse(data)); } catch { /* not a change */ }
|
|
347
|
+
if (!item || item.seq <= (this.end ?? 0)) continue;
|
|
348
|
+
pending.push(item);
|
|
349
|
+
// Changes that land together (a maintenance round) go to the phone as one event.
|
|
350
|
+
flush ??= setTimeout(send, 20);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
} catch { /* aborted, or the backend went away */ }
|
|
354
|
+
if (flush) { clearTimeout(flush); send(); }
|
|
355
|
+
if (this.stream === controller) this.stream = null;
|
|
144
356
|
}
|
|
145
357
|
}
|
package/src/bridge/service.js
CHANGED
|
@@ -269,8 +269,10 @@ export class BridgeService {
|
|
|
269
269
|
} else if (message.t === "board/get") {
|
|
270
270
|
// Not awaited: a slow backend should not hold up the frames behind this one.
|
|
271
271
|
void this.board.read(message.path).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
|
|
272
|
+
} else if (message.t === "board/post") {
|
|
273
|
+
void this.board.post(message.path, message.body).then((answer) => this.sendInner(conn, { t: "board/res", id: message.id, ...answer }));
|
|
272
274
|
} else if (message.t === "board/sub") {
|
|
273
|
-
conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event));
|
|
275
|
+
conn.boardUnsubscribe ??= this.board.subscribe((event) => this.sendInner(conn, event), message.after);
|
|
274
276
|
} else if (message.t === "board/unsub") {
|
|
275
277
|
conn.boardUnsubscribe?.();
|
|
276
278
|
conn.boardUnsubscribe = null;
|
package/src/protocol.js
CHANGED
|
@@ -239,14 +239,23 @@ export class FrameCipher {
|
|
|
239
239
|
|
|
240
240
|
|
|
241
241
|
|
|
242
|
+
|
|
242
243
|
|
|
243
|
-
|
|
244
|
+
|
|
244
245
|
|
|
245
246
|
|
|
246
247
|
|
|
247
|
-
/** A read of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
|
|
248
|
-
|
|
249
|
-
|
|
248
|
+
/** A read or write of the optional board: the backend's status and body, or why there is none (PROTOCOL.md "看板"). */
|
|
249
|
+
|
|
250
|
+
/** One task that changed, from agens-backend's change log (0.3.14). */
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* What a subscriber hears. From 0.3.14 a board/state that says up also gives `seq` (where in the change log the phone
|
|
254
|
+
* starts), `caps` and the bridge's version, and board/changed names the tasks, or says `reset` when there are too many.
|
|
255
|
+
*/
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
|
|
250
259
|
|
|
251
260
|
|
|
252
261
|
/** One segment of a long line: `data` is base64 of bytes [segment * chunk size, …) of the payload, `size` its length. */
|