@twinsearth/w2m-dsh-plugin 1.1.15 → 1.1.17
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +112 -0
- package/README.md +252 -870
- package/codex/mcp-servers.toml +1 -1
- package/docs/PLATFORMS.md +22 -0
- package/package.json +8 -4
- package/plugin.json +1 -1
- package/src/plugin/tools.mjs +119 -5
- package/src/plugin/tools.mjs.bak-c7 +7029 -0
- package/src/relay/fleet-messages.mjs +145 -0
- package/src/relay/message-shards.mjs +124 -0
- package/src/relay/server.mjs +71 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fleet chat: plain-text messages scoped to a SESSION, with a byte budget.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the session is the scope
|
|
5
|
+
*
|
|
6
|
+
* The operator's decision: messages belong to the conversation, like a group chat, and disappear with it. A machine that
|
|
7
|
+
* joins or leaves a session changes who can read the flow, but never rewrites what was said -- and deleting the session
|
|
8
|
+
* deletes the history, because keeping it would be a second, silently-growing store nobody asked for.
|
|
9
|
+
*
|
|
10
|
+
* ## The quota, as specified
|
|
11
|
+
*
|
|
12
|
+
* 100 MB per fleet per 24 hours is the baseline; when that is exceeded, the 7-day retention figure is compared and the
|
|
13
|
+
* larger is kept, capped at 200 MB:
|
|
14
|
+
*
|
|
15
|
+
* limit = min(hardCap, max(quota24h, used7d))
|
|
16
|
+
*
|
|
17
|
+
* A message over the limit is REFUSED with the current usage attached -- never silently dropped, and never accepted by
|
|
18
|
+
* evicting somebody else's words. The relay is the only clock here: a client-chosen timestamp would let one machine
|
|
19
|
+
* claim its messages are older than they are.
|
|
20
|
+
*
|
|
21
|
+
* ## Sharding is not this file
|
|
22
|
+
*
|
|
23
|
+
* This keeps the authoritative list in memory for the relay to serve and to shard out; distributing encrypted shards
|
|
24
|
+
* across the fleet's machines is the next step and depends on this shape staying the same.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 100 MB per 24h per fleet (operator decision). */
|
|
28
|
+
export const QUOTA_24H_BYTES = 100 * 1024 * 1024;
|
|
29
|
+
/** Hard ceiling regardless of how much the 7-day window would allow. */
|
|
30
|
+
export const HARD_CAP_BYTES = 200 * 1024 * 1024;
|
|
31
|
+
/** Retention window used for the "keep the larger" comparison. */
|
|
32
|
+
export const RETENTION_DAYS = 7;
|
|
33
|
+
/** One message. Text is cheap; a 4 KiB cap keeps a single paste from eating the budget. */
|
|
34
|
+
export const MAX_MESSAGE_BYTES = 4 * 1024;
|
|
35
|
+
|
|
36
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{now?: () => number, quota24hBytes?: number, hardCapBytes?: number, retentionDays?: number,
|
|
40
|
+
* maxMessageBytes?: number}} [options]
|
|
41
|
+
*/
|
|
42
|
+
export function createFleetMessages(options = {}) {
|
|
43
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
44
|
+
const quota24hBytes = Number.isInteger(options.quota24hBytes) ? options.quota24hBytes : QUOTA_24H_BYTES;
|
|
45
|
+
const hardCapBytes = Number.isInteger(options.hardCapBytes) ? options.hardCapBytes : HARD_CAP_BYTES;
|
|
46
|
+
const retentionDays = Number.isInteger(options.retentionDays) ? options.retentionDays : RETENTION_DAYS;
|
|
47
|
+
const maxMessageBytes = Number.isInteger(options.maxMessageBytes) ? options.maxMessageBytes : MAX_MESSAGE_BYTES;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Notification subscribers. An event carries NO text: the notification says "there is something new to read", and the
|
|
51
|
+
* reader fetches the text through the endpoint that already authenticates them. Putting the text in a broadcast would
|
|
52
|
+
* send every message to every connected listener regardless of session membership.
|
|
53
|
+
*/
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
/** session_id -> [{ message_id, session_id, machine_id, text, at_ms, bytes }] */
|
|
56
|
+
const sessions = new Map();
|
|
57
|
+
let seq = 0;
|
|
58
|
+
|
|
59
|
+
const all = () => {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const rows of sessions.values()) out.push(...rows);
|
|
62
|
+
return out;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function usage() {
|
|
66
|
+
const t = now();
|
|
67
|
+
const day = all().filter((m) => t - m.at_ms <= DAY_MS);
|
|
68
|
+
const week = all().filter((m) => t - m.at_ms <= retentionDays * DAY_MS);
|
|
69
|
+
const sum = (rows) => rows.reduce((total, m) => total + m.bytes, 0);
|
|
70
|
+
return { used_24h_bytes: sum(day), used_retention_bytes: sum(week), messages: all().length };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function limitBytes() {
|
|
74
|
+
const { used_retention_bytes: week } = usage();
|
|
75
|
+
return Math.min(hardCapBytes, Math.max(quota24hBytes, week));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* @param {{session_id: string, fleet_id?: string|null, machine_id: string, text: string}} input
|
|
80
|
+
* @returns {{ok: true, message: object} | {ok: false, code: string, detail?: object}}
|
|
81
|
+
*/
|
|
82
|
+
function post(input) {
|
|
83
|
+
const sessionId = typeof input?.session_id === 'string' ? input.session_id.trim() : '';
|
|
84
|
+
if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
|
|
85
|
+
const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
|
|
86
|
+
if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
|
|
87
|
+
if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
|
|
88
|
+
const text = input.text;
|
|
89
|
+
if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
|
|
90
|
+
// Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
|
|
91
|
+
// Control characters are the thing being rejected here, so the rule that forbids them in a pattern
|
|
92
|
+
// does not apply -- the escape IS the contract.
|
|
93
|
+
// eslint-disable-next-line no-control-regex
|
|
94
|
+
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
|
|
95
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
96
|
+
if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
|
|
97
|
+
|
|
98
|
+
const before = usage();
|
|
99
|
+
const limit = limitBytes();
|
|
100
|
+
if (before.used_24h_bytes + bytes > limit) {
|
|
101
|
+
return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
seq += 1;
|
|
105
|
+
const message = {
|
|
106
|
+
message_id: 'msg-' + String(seq).padStart(6, '0'),
|
|
107
|
+
session_id: sessionId,
|
|
108
|
+
fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
|
|
109
|
+
machine_id: machineId,
|
|
110
|
+
text,
|
|
111
|
+
at_ms: now(),
|
|
112
|
+
bytes,
|
|
113
|
+
};
|
|
114
|
+
if (!sessions.has(sessionId)) sessions.set(sessionId, []);
|
|
115
|
+
sessions.get(sessionId).push(message);
|
|
116
|
+
for (const listener of listeners) {
|
|
117
|
+
if (listener.sessionId !== null && listener.sessionId !== sessionId) continue;
|
|
118
|
+
try { listener.fn({ session_id: sessionId, message_id: message.message_id, machine_id: message.machine_id, at_ms: message.at_ms }); }
|
|
119
|
+
catch { /* a dead listener must not fail the write */ }
|
|
120
|
+
}
|
|
121
|
+
return { ok: true, message };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
|
|
125
|
+
function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50 } = {}) {
|
|
126
|
+
const rows = sessions.get(sessionId) ?? [];
|
|
127
|
+
const filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
|
|
128
|
+
const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
|
|
129
|
+
return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Deleting a session deletes its history -- the operator's rule, not a side effect. */
|
|
133
|
+
function deleteSession(sessionId) {
|
|
134
|
+
return sessions.delete(sessionId);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {string|null} sessionId null subscribes to every session; returns the unsubscribe function. */
|
|
138
|
+
function subscribe(sessionId, fn) {
|
|
139
|
+
const listener = { sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null, fn };
|
|
140
|
+
listeners.add(listener);
|
|
141
|
+
return () => listeners.delete(listener);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { post, list, usage, limitBytes, deleteSession, subscribe, _sessions: sessions };
|
|
145
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat history, encrypted and split across the fleet's own machines.
|
|
3
|
+
*
|
|
4
|
+
* ## The three rules that keep "distributed storage" from becoming "lost data"
|
|
5
|
+
*
|
|
6
|
+
* 1. **Rebuildable.** Any single message must be recoverable from the shards that exist, and the metadata says where they
|
|
7
|
+
* are: \`{machine_id, part, total, sha256}\` per shard. A reader never has to guess who holds what.
|
|
8
|
+
* 2. **Offline tolerated.** Each part is assigned to **two distinct** machines. One machine being down is the normal
|
|
9
|
+
* case, not the failure case, and a single copy would make it a data-loss case.
|
|
10
|
+
* 3. **Integrity.** Every shard carries sha256 of its own bytes. A shard that does not match is corrupt and is named;
|
|
11
|
+
* it is never silently decrypted into garbage. Missing shards are named too -- "some data is gone" without saying
|
|
12
|
+
* which part is gone is not a report, it is a shrug.
|
|
13
|
+
*
|
|
14
|
+
* ## Encryption
|
|
15
|
+
*
|
|
16
|
+
* AES-256-GCM with a key derived from the fleet key (the same secret that already authorises joining, so no new
|
|
17
|
+
* long-lived secret appears). The IV is generated per day-batch and travels in the header; the auth tag is verified before
|
|
18
|
+
* any plaintext is returned, so a wrong key or a tampered ciphertext fails loudly.
|
|
19
|
+
*
|
|
20
|
+
* ## What this file does NOT do
|
|
21
|
+
*
|
|
22
|
+
* It does not decide where the machines are or how bytes reach them. It produces the shards and reassembles them; the
|
|
23
|
+
* relay owns transport. Keeping the split here is what makes it testable without a fleet.
|
|
24
|
+
*/
|
|
25
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
/** Default slice size for one shard: small enough to distribute, large enough not to explode the shard count. */
|
|
28
|
+
export const DEFAULT_SHARD_BYTES = 256 * 1024;
|
|
29
|
+
/** Two copies, per rule 2. */
|
|
30
|
+
export const DEFAULT_REPLICAS = 2;
|
|
31
|
+
|
|
32
|
+
const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
|
|
33
|
+
const dayKey = (atMs) => new Date(atMs).toISOString().slice(0, 10);
|
|
34
|
+
|
|
35
|
+
/** Derive the encryption key from the fleet key. Same secret, different purpose, separated label. */
|
|
36
|
+
export function deriveShardKey(fleetKey) {
|
|
37
|
+
if (typeof fleetKey !== 'string' || fleetKey === '') throw new TypeError('fleetKey must be a non-empty string');
|
|
38
|
+
return createHash('sha256').update('w2m-chat-shards-v1').update('\u0000').update(fleetKey).digest();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Split one day's messages into encrypted shards and assign each part to replica machines.
|
|
43
|
+
*
|
|
44
|
+
* @param {{messages: object[], fleetKey: string, machines: string[], shardBytes?: number, replicas?: number,
|
|
45
|
+
* at_ms?: number}} input
|
|
46
|
+
* @returns {{ok: true, day: string, shards: object[], header: object} | {ok: false, code: string}}
|
|
47
|
+
*/
|
|
48
|
+
export function buildShards({ messages, fleetKey, machines, shardBytes = DEFAULT_SHARD_BYTES, replicas = DEFAULT_REPLICAS, at_ms }) {
|
|
49
|
+
if (!Array.isArray(messages) || messages.length === 0) return { ok: false, code: 'NOTHING_TO_SHARD' };
|
|
50
|
+
if (!Array.isArray(machines) || machines.length === 0) return { ok: false, code: 'NO_MACHINES' };
|
|
51
|
+
if (replicas > machines.length) return { ok: false, code: 'NOT_ENOUGH_MACHINES', detail: { machines: machines.length, replicas } };
|
|
52
|
+
const key = deriveShardKey(fleetKey);
|
|
53
|
+
const at = Number.isFinite(at_ms) ? at_ms : messages[0].at_ms ?? Date.now();
|
|
54
|
+
const day = dayKey(at);
|
|
55
|
+
const plaintext = Buffer.from(JSON.stringify(messages), 'utf8');
|
|
56
|
+
|
|
57
|
+
const iv = randomBytes(12);
|
|
58
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
59
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
60
|
+
const tag = cipher.getAuthTag();
|
|
61
|
+
const sealed = Buffer.concat([ciphertext, tag]);
|
|
62
|
+
|
|
63
|
+
const total = Math.max(1, Math.ceil(sealed.length / shardBytes));
|
|
64
|
+
const shards = [];
|
|
65
|
+
for (let part = 0; part < total; part += 1) {
|
|
66
|
+
const body = sealed.subarray(part * shardBytes, (part + 1) * shardBytes);
|
|
67
|
+
const digest = sha256(body);
|
|
68
|
+
// Rule 2: distinct machines per part, rotating so the copies are not all on the same pair.
|
|
69
|
+
const holders = [];
|
|
70
|
+
for (let r = 0; r < replicas; r += 1) holders.push(machines[(part + r) % machines.length]);
|
|
71
|
+
for (const machineId of holders) {
|
|
72
|
+
shards.push({
|
|
73
|
+
day, part, total, machine_id: machineId, sha256: digest,
|
|
74
|
+
bytes: body.length, body: body.toString('base64'),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, day, shards, header: { iv: iv.toString('base64'), algorithm: 'aes-256-gcm', total, day } };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reassemble the day's messages from whatever shards are available.
|
|
83
|
+
*
|
|
84
|
+
* Every failure is named: a missing part, a corrupt part, and a failed authentication are three different operational
|
|
85
|
+
* facts and a caller that cannot tell them apart cannot decide whether to retry another replica.
|
|
86
|
+
*
|
|
87
|
+
* @param {{shards: object[], header: object, fleetKey: string}} input
|
|
88
|
+
* @returns {{ok: true, messages: object[]} | {ok: false, code: string, detail?: object}}
|
|
89
|
+
*/
|
|
90
|
+
export function assembleShards({ shards, header, fleetKey }) {
|
|
91
|
+
if (!Array.isArray(shards) || shards.length === 0) return { ok: false, code: 'NO_SHARDS' };
|
|
92
|
+
if (header === null || typeof header !== 'object') return { ok: false, code: 'NO_HEADER' };
|
|
93
|
+
const total = header.total;
|
|
94
|
+
const byPart = new Map();
|
|
95
|
+
for (const shard of shards) {
|
|
96
|
+
if (sha256(Buffer.from(shard.body, 'base64')) !== shard.sha256) {
|
|
97
|
+
return { ok: false, code: 'SHARD_CORRUPT', detail: { part: shard.part, machine_id: shard.machine_id } };
|
|
98
|
+
}
|
|
99
|
+
if (!byPart.has(shard.part)) byPart.set(shard.part, shard);
|
|
100
|
+
}
|
|
101
|
+
const missing = [];
|
|
102
|
+
for (let part = 0; part < total; part += 1) if (!byPart.has(part)) missing.push(part);
|
|
103
|
+
if (missing.length > 0) return { ok: false, code: 'MISSING_SHARDS', detail: { missing } };
|
|
104
|
+
|
|
105
|
+
const ordered = [];
|
|
106
|
+
for (let part = 0; part < total; part += 1) ordered.push(Buffer.from(byPart.get(part).body, 'base64'));
|
|
107
|
+
const sealed = Buffer.concat(ordered);
|
|
108
|
+
const tag = sealed.subarray(sealed.length - 16);
|
|
109
|
+
const ciphertext = sealed.subarray(0, sealed.length - 16);
|
|
110
|
+
try {
|
|
111
|
+
const decipher = createDecipheriv('aes-256-gcm', deriveShardKey(fleetKey), Buffer.from(header.iv, 'base64'));
|
|
112
|
+
decipher.setAuthTag(tag);
|
|
113
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
114
|
+
return { ok: true, messages: JSON.parse(plaintext.toString('utf8')) };
|
|
115
|
+
} catch {
|
|
116
|
+
// Wrong key or tampered ciphertext: never return partial plaintext.
|
|
117
|
+
return { ok: false, code: 'AUTH_FAILED' };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Which shards does a reader hold for one machine? Transport uses this; it does not read the bodies. */
|
|
122
|
+
export function shardsForMachine(shards, machineId) {
|
|
123
|
+
return shards.filter((shard) => shard.machine_id === machineId).map(({ body, ...meta }) => meta);
|
|
124
|
+
}
|
package/src/relay/server.mjs
CHANGED
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
ProtocolError,
|
|
22
22
|
RabbitState,
|
|
23
23
|
rfc3339,
|
|
24
|
-
} from './state.mjs';
|
|
24
|
+
} from './state.mjs';
|
|
25
|
+
import { createFleetMessages } from './fleet-messages.mjs';
|
|
25
26
|
import { createJoinChallenges } from './join-challenge.mjs';
|
|
26
27
|
import { createAdmission } from './admission.mjs';
|
|
27
28
|
import { Persistence } from './persistence.mjs';
|
|
@@ -1037,6 +1038,75 @@ export class RelayServer {
|
|
|
1037
1038
|
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
|
|
1038
1039
|
}
|
|
1039
1040
|
|
|
1041
|
+
/* ---- v1.1.17: fleet chat ---- */
|
|
1042
|
+
//
|
|
1043
|
+
// Both endpoints sit AFTER the device gate on purpose: a message must be attributable to a paired machine, and
|
|
1044
|
+
// "who said this" is exactly the machine_id the token resolves to -- never a body field, which would let one machine
|
|
1045
|
+
// speak as another. The refusal codes from the store are passed through unchanged (FLEET_QUOTA_EXCEEDED included)
|
|
1046
|
+
// because a caller that cannot tell "too big" from "over quota" from "not text" cannot tell the operator what to do.
|
|
1047
|
+
this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
|
|
1048
|
+
|
|
1049
|
+
if (method === 'POST' && path === '/v1/fleet/messages') {
|
|
1050
|
+
const body = takeJson();
|
|
1051
|
+
const posted = this.fleetMessages.post({
|
|
1052
|
+
session_id: body.session_id,
|
|
1053
|
+
fleet_id: typeof body.fleet_id === 'string' ? body.fleet_id : null,
|
|
1054
|
+
machine_id: device.machine_id,
|
|
1055
|
+
text: body.text,
|
|
1056
|
+
});
|
|
1057
|
+
if (posted.ok !== true) {
|
|
1058
|
+
throw new ProtocolError('BAD_REQUEST', 'message refused: ' + posted.code, { code: posted.code, ...(posted.detail ?? {}) });
|
|
1059
|
+
}
|
|
1060
|
+
return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), message: posted.message });
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (method === 'GET' && path === '/v1/fleet/messages/stream') {
|
|
1064
|
+
/**
|
|
1065
|
+
* v1.1.17: a notification channel, not a message channel.
|
|
1066
|
+
*
|
|
1067
|
+
* The event carries the id and the session -- never the text -- so a client that connects learns "something was
|
|
1068
|
+
* said" and then fetches it through GET /v1/fleet/messages, which is where membership is actually checked. A
|
|
1069
|
+
* broadcast carrying text would hand every message to every connected listener and make the check decorative.
|
|
1070
|
+
*
|
|
1071
|
+
* The stream sits behind the device gate: an unpaired client cannot listen.
|
|
1072
|
+
*/
|
|
1073
|
+
const sessionId = url.searchParams.get('session_id');
|
|
1074
|
+
res.writeHead(200, {
|
|
1075
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1076
|
+
'cache-control': 'no-store',
|
|
1077
|
+
connection: 'keep-alive',
|
|
1078
|
+
'x-accel-buffering': 'no',
|
|
1079
|
+
});
|
|
1080
|
+
res.write(': connected\n\n');
|
|
1081
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1082
|
+
const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, 15_000);
|
|
1083
|
+
if (typeof keepalive.unref === 'function') keepalive.unref();
|
|
1084
|
+
const unsubscribe = this.fleetMessages.subscribe(sessionId, (event) => {
|
|
1085
|
+
try {
|
|
1086
|
+
res.write('event: new_message\ndata: ' + JSON.stringify(event) + '\n\n');
|
|
1087
|
+
} catch { /* the reader went away; close() below cleans up */ }
|
|
1088
|
+
});
|
|
1089
|
+
const done = () => { clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
|
|
1090
|
+
req.on('close', done);
|
|
1091
|
+
return undefined;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
if (method === 'GET' && path === '/v1/fleet/messages') {
|
|
1095
|
+
const sinceRaw = url.searchParams.get('since_ms');
|
|
1096
|
+
const limitRaw = url.searchParams.get('limit');
|
|
1097
|
+
const listed = this.fleetMessages.list({
|
|
1098
|
+
session_id: url.searchParams.get('session_id') ?? '',
|
|
1099
|
+
since_ms: sinceRaw === null ? null : Number(sinceRaw),
|
|
1100
|
+
limit: limitRaw === null ? 50 : Number(limitRaw),
|
|
1101
|
+
});
|
|
1102
|
+
return sendJson(res, 200, {
|
|
1103
|
+
protocol_version: PROTOCOL_VERSION,
|
|
1104
|
+
rabbit_time: this.state.nowIso(),
|
|
1105
|
+
...listed,
|
|
1106
|
+
usage: this.fleetMessages.usage(), // shown with every read so a client can warn before the quota refuses a post
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1040
1110
|
throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
|
|
1041
1111
|
}
|
|
1042
1112
|
|