@bivy/bivy 0.18.5-staging.4 → 0.18.5-staging.6
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/dist/apps/styles.css +1 -1
- package/dist/apps/tokens.css +3 -0
- package/dist/relay-attach.js +17 -5
- package/dist/relay-chunk.js +71 -38
- package/dist/remote/event-transfer-receiver.js +142 -0
- package/dist/remote/event-transfer.js +81 -0
- package/dist/remote/relay-client.js +16 -3
- package/dist/server.js +9 -0
- package/dist/session/client-projection.js +71 -0
- package/dist/session/sibling-client.js +12 -2
- package/dist/session/transcript-persistence.js +5 -2
- package/dist/wire-format.js +3 -0
- package/package.json +1 -1
package/dist/apps/styles.css
CHANGED
|
@@ -3056,7 +3056,7 @@ p.setup-note .btn.link { min-height: 44px; margin-block: calc((1lh - 44px) / 2);
|
|
|
3056
3056
|
.review-card { margin-block: var(--space-2); max-inline-size: 440px; }
|
|
3057
3057
|
.review-stage { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); margin: 0 var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); background: var(--surface-2); }
|
|
3058
3058
|
.review-shot { display: grid; place-items: center; padding: 0; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--bg); overflow: hidden; cursor: zoom-in; box-shadow: var(--shadow-sm); }
|
|
3059
|
-
.review-shot img { display: block; inline-size: auto; block-size: auto; max-inline-size: 100%; max-block-size:
|
|
3059
|
+
.review-shot img { display: block; inline-size: auto; block-size: auto; max-inline-size: 100%; max-block-size: var(--review-shot-height); }
|
|
3060
3060
|
.review-loading { display: grid; place-items: center; inline-size: 139px; block-size: min(300px, 40vh); max-inline-size: 100%; }
|
|
3061
3061
|
.review-gone { margin: 0; padding: var(--space-6) var(--space-3); color: var(--muted); font-size: var(--text-sm); text-align: center; }
|
|
3062
3062
|
.review-side .seg-btn { min-block-size: 44px; min-inline-size: 72px; }
|
package/dist/apps/tokens.css
CHANGED
|
@@ -105,6 +105,9 @@
|
|
|
105
105
|
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
|
|
106
106
|
--space-5: 24px; --space-6: 32px; --space-7: 48px; --space-8: 64px;
|
|
107
107
|
|
|
108
|
+
/* Stable review thumbnail footprint, including while its bytes load. */
|
|
109
|
+
--review-shot-height: min(300px, 40vh);
|
|
110
|
+
|
|
108
111
|
/* ---- Radius ---- */
|
|
109
112
|
--radius: 8px; /* app default — same value as --radius-md */
|
|
110
113
|
--radius-sm: 6px;
|
package/dist/relay-attach.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { EventTransferReceiver } from "./remote/event-transfer-receiver.js";
|
|
3
4
|
//
|
|
4
5
|
// Relay-tunnelled attach bridge for `bivy run --node <account-node>`.
|
|
5
6
|
//
|
|
@@ -155,7 +156,7 @@ async function main() {
|
|
|
155
156
|
throw new Error("no relay URL available for the target node");
|
|
156
157
|
// 3. open the relay /client socket in the target's room and pair.
|
|
157
158
|
const rly = new WebSocket(`${relayBase}/client?ticket=${encodeURIComponent(ticket)}&nodeId=${encodeURIComponent(args.nodeId)}`);
|
|
158
|
-
const reassembler = new FrameReassembler();
|
|
159
|
+
const reassembler = new FrameReassembler({ onReject: message => fail(message) });
|
|
159
160
|
let cipher = null;
|
|
160
161
|
let local = null;
|
|
161
162
|
let paired = false;
|
|
@@ -170,6 +171,18 @@ async function main() {
|
|
|
170
171
|
}
|
|
171
172
|
process.exit(code);
|
|
172
173
|
};
|
|
174
|
+
const transfers = new EventTransferReceiver({
|
|
175
|
+
send: async (command) => {
|
|
176
|
+
if (!cipher || rly.readyState !== WebSocket.OPEN)
|
|
177
|
+
throw new Error("Relay disconnected");
|
|
178
|
+
for (const frame of frameMessages(cipher.seal(command)))
|
|
179
|
+
rly.send(frame);
|
|
180
|
+
},
|
|
181
|
+
emit: event => { if (local?.readyState === WebSocket.OPEN)
|
|
182
|
+
local.send(JSON.stringify(event)); },
|
|
183
|
+
fault: message => fail(message),
|
|
184
|
+
});
|
|
185
|
+
rly.on("close", () => { transfers.reset(); reassembler.reset(); });
|
|
173
186
|
const pairTimer = setTimeout(() => {
|
|
174
187
|
if (!paired)
|
|
175
188
|
fail(`Node "${args.nodeName}" did not respond to pairing — it may be offline.`, 1);
|
|
@@ -258,8 +271,7 @@ async function main() {
|
|
|
258
271
|
return; // undecryptable frame — ignore
|
|
259
272
|
}
|
|
260
273
|
// Forward the bare terminal.* event to the local attach socket verbatim.
|
|
261
|
-
|
|
262
|
-
local.send(JSON.stringify(event));
|
|
274
|
+
transfers.accept(event);
|
|
263
275
|
}
|
|
264
276
|
});
|
|
265
277
|
// Stand up the loopback /ws server + spawn attach, only once paired.
|
|
@@ -293,8 +305,8 @@ async function main() {
|
|
|
293
305
|
rly.send(frame);
|
|
294
306
|
}
|
|
295
307
|
}
|
|
296
|
-
catch {
|
|
297
|
-
|
|
308
|
+
catch (error) {
|
|
309
|
+
sock.send(JSON.stringify({ type: "session.error", code: "delivery_failed", error: error.message }));
|
|
298
310
|
}
|
|
299
311
|
});
|
|
300
312
|
sock.on("close", () => {
|
package/dist/relay-chunk.js
CHANGED
|
@@ -11,6 +11,8 @@ export { FRAME_CHUNK_BYTES };
|
|
|
11
11
|
* chunk messages for large ones.
|
|
12
12
|
*/
|
|
13
13
|
export function frameMessages(payload) {
|
|
14
|
+
if (payload.length > MAX_REASSEMBLY_BYTES)
|
|
15
|
+
throw new Error("Relay payload exceeds the receiver limit; use a bounded transfer.");
|
|
14
16
|
if (payload.length <= FRAME_CHUNK_BYTES) {
|
|
15
17
|
return [JSON.stringify({ t: "frame", p: payload })];
|
|
16
18
|
}
|
|
@@ -23,51 +25,82 @@ export function frameMessages(payload) {
|
|
|
23
25
|
}
|
|
24
26
|
return out;
|
|
25
27
|
}
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
/** null means incomplete or rejected; onReject distinguishes delivery failure.
|
|
29
|
+
* Limits apply to aggregate buffering as well as individual messages. */
|
|
30
|
+
export function createFrameReassembler(opts = {}) {
|
|
31
|
+
const groups = new Map();
|
|
32
|
+
const rejected = new Map();
|
|
33
|
+
const maxGroups = opts.maxGroups ?? MAX_REASSEMBLY_GROUPS;
|
|
34
|
+
const maxBytes = opts.maxBytes ?? MAX_REASSEMBLY_BYTES;
|
|
35
|
+
const timeoutMs = opts.timeoutMs ?? 30_000;
|
|
36
|
+
let buffered = 0;
|
|
37
|
+
const remove = (id) => {
|
|
38
|
+
const group = groups.get(id);
|
|
39
|
+
if (group) {
|
|
40
|
+
clearTimeout(group.timer);
|
|
41
|
+
buffered -= group.bytes;
|
|
42
|
+
groups.delete(id);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const reject = (id, reason) => {
|
|
46
|
+
remove(id);
|
|
47
|
+
if (rejected.size >= 256)
|
|
48
|
+
rejected.delete(rejected.keys().next().value);
|
|
49
|
+
rejected.set(id, Date.now() + timeoutMs);
|
|
50
|
+
opts.onReject?.(reason);
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
const accept = (env) => {
|
|
54
|
+
for (const [id, expires] of rejected)
|
|
55
|
+
if (expires <= Date.now())
|
|
56
|
+
rejected.delete(id);
|
|
57
|
+
if (!env || typeof env.p !== "string")
|
|
39
58
|
return null;
|
|
40
59
|
if (env.fc === undefined)
|
|
41
|
-
return env.p
|
|
60
|
+
return env.p.length <= maxBytes ? env.p : reject("unchunked", "Relay response exceeds the receive limit.");
|
|
42
61
|
const id = String(env.fc);
|
|
43
|
-
|
|
44
|
-
const total = Number(env.fn);
|
|
45
|
-
if (!Number.isInteger(index) || !Number.isInteger(total) || total <= 0 || total > MAX_FRAME_CHUNKS || index < 0 || index >= total) {
|
|
62
|
+
if (rejected.has(id))
|
|
46
63
|
return null;
|
|
47
|
-
|
|
48
|
-
|
|
64
|
+
const i = Number(env.fi);
|
|
65
|
+
const n = Number(env.fn);
|
|
66
|
+
if (!Number.isInteger(i) || !Number.isInteger(n) || n <= 0 || n > MAX_FRAME_CHUNKS || i < 0 || i >= n)
|
|
67
|
+
return reject(id, "Invalid relay chunk metadata.");
|
|
68
|
+
let group = groups.get(id);
|
|
69
|
+
if (group && group.total !== n)
|
|
70
|
+
return reject(id, "Inconsistent relay chunk count.");
|
|
49
71
|
if (!group) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
group = { total, parts: new Array(total), have: 0, bytes: 0 };
|
|
57
|
-
this.groups.set(id, group);
|
|
72
|
+
if (groups.size >= maxGroups)
|
|
73
|
+
return reject(id, "Too many incomplete relay responses.");
|
|
74
|
+
const timer = setTimeout(() => reject(id, "Relay response timed out before all chunks arrived."), timeoutMs);
|
|
75
|
+
timer.unref?.();
|
|
76
|
+
group = { total: n, parts: new Array(n), have: 0, bytes: 0, timer };
|
|
77
|
+
groups.set(id, group);
|
|
58
78
|
}
|
|
59
|
-
if (group.parts[
|
|
60
|
-
return null; // duplicate chunk
|
|
61
|
-
group.parts[index] = env.p;
|
|
62
|
-
group.have += 1;
|
|
63
|
-
group.bytes += env.p.length;
|
|
64
|
-
if (group.bytes > MAX_REASSEMBLY_BYTES) {
|
|
65
|
-
this.groups.delete(id);
|
|
79
|
+
if (group.parts[i] !== undefined)
|
|
66
80
|
return null;
|
|
67
|
-
|
|
81
|
+
if (buffered + env.p.length > maxBytes)
|
|
82
|
+
return reject(id, "Relay responses exceed the receive memory limit.");
|
|
83
|
+
group.parts[i] = env.p;
|
|
84
|
+
group.have++;
|
|
85
|
+
group.bytes += env.p.length;
|
|
86
|
+
buffered += env.p.length;
|
|
68
87
|
if (group.have < group.total)
|
|
69
88
|
return null;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
89
|
+
const payload = group.parts.join("");
|
|
90
|
+
remove(id);
|
|
91
|
+
// Ignore straggling duplicates of a completed group instead of creating a
|
|
92
|
+
// new incomplete group that later reports a spurious timeout.
|
|
93
|
+
if (rejected.size >= 256)
|
|
94
|
+
rejected.delete(rejected.keys().next().value);
|
|
95
|
+
rejected.set(id, Date.now() + timeoutMs);
|
|
96
|
+
return payload;
|
|
97
|
+
};
|
|
98
|
+
return Object.assign(accept, { reset() { for (const id of groups.keys())
|
|
99
|
+
remove(id); rejected.clear(); } });
|
|
100
|
+
}
|
|
101
|
+
export class FrameReassembler {
|
|
102
|
+
acceptFrame;
|
|
103
|
+
constructor(options = {}) { this.acceptFrame = createFrameReassembler(options); }
|
|
104
|
+
accept(env) { return this.acceptFrame(env); }
|
|
105
|
+
reset() { this.acceptFrame.reset(); }
|
|
73
106
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
// Standalone node mirror of packages/core/src/event-transfer.ts. The release
|
|
4
|
+
// omits packages/; behavior is covered by the cross-stack transfer tests.
|
|
5
|
+
const unb64 = (data) => Uint8Array.from(atob(data), c => c.charCodeAt(0));
|
|
6
|
+
import { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES } from "../wire-format.js";
|
|
7
|
+
export { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES };
|
|
8
|
+
const MAX_QUEUED_BYTES = 8 * 1024 * 1024;
|
|
9
|
+
/** Deliver complete events in wire order. A history snapshot and its baseline
|
|
10
|
+
* are never exposed partially; later live events wait behind that snapshot.
|
|
11
|
+
* Only one page is in flight, with bounded retries and memory. */
|
|
12
|
+
export class EventTransferReceiver {
|
|
13
|
+
deps;
|
|
14
|
+
queue = [];
|
|
15
|
+
queuedBytes = 0;
|
|
16
|
+
pending;
|
|
17
|
+
timer;
|
|
18
|
+
generation = 0;
|
|
19
|
+
constructor(deps) {
|
|
20
|
+
this.deps = deps;
|
|
21
|
+
}
|
|
22
|
+
accept(event) {
|
|
23
|
+
if (event.type === "transfer.part") {
|
|
24
|
+
void this.part(event);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (this.pending || this.queue.length) {
|
|
28
|
+
this.queuedBytes += new TextEncoder().encode(JSON.stringify(event)).length;
|
|
29
|
+
if (this.queuedBytes > MAX_QUEUED_BYTES || this.queue.length >= 4096) {
|
|
30
|
+
this.reset();
|
|
31
|
+
this.deps.fault("Transcript delivery fell behind. Reconnecting to synchronize again.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.queue.push(event);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.deliver(event);
|
|
38
|
+
}
|
|
39
|
+
deliver(event) {
|
|
40
|
+
if (event.code !== "transfer_required") {
|
|
41
|
+
this.deps.emit(event);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const offer = event.transfer;
|
|
45
|
+
if (!offer || !/^[a-f0-9]{48}$/.test(offer.id) || !/^[a-f0-9]{64}$/.test(offer.key) || !/^[a-f0-9]{24}$/.test(offer.iv) ||
|
|
46
|
+
!Number.isSafeInteger(offer.bytes) || offer.bytes <= 0 || offer.bytes > MAX_TRANSFER_BYTES || offer.pageBytes !== TRANSFER_PAGE_BYTES) {
|
|
47
|
+
this.deps.emit({ type: "session.error", code: "delivery_invalid", sessionId: event.sessionId, requestId: event.requestId, error: "Invalid large-response transfer. Update the app and retry." });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.pending = { offer, envelope: event, bytes: new Uint8Array(offer.bytes), offset: 0, requestId: "", retries: 0 };
|
|
51
|
+
this.request();
|
|
52
|
+
}
|
|
53
|
+
request() {
|
|
54
|
+
const pending = this.pending;
|
|
55
|
+
if (!pending)
|
|
56
|
+
return;
|
|
57
|
+
pending.requestId = `transfer-${crypto.randomUUID()}`;
|
|
58
|
+
clearTimeout(this.timer);
|
|
59
|
+
this.timer = setTimeout(() => {
|
|
60
|
+
if (this.pending !== pending)
|
|
61
|
+
return;
|
|
62
|
+
if (++pending.retries <= 2)
|
|
63
|
+
this.request();
|
|
64
|
+
else
|
|
65
|
+
this.fail("Large-response delivery timed out. Retry to synchronize.");
|
|
66
|
+
}, this.deps.timeoutMs ?? 10_000);
|
|
67
|
+
void this.deps.send({ kind: "transfer.read", transferId: pending.offer.id, offset: pending.offset, requestId: pending.requestId })
|
|
68
|
+
.catch(() => { if (this.pending === pending)
|
|
69
|
+
this.fail("Could not request the next response page. Retry to synchronize."); });
|
|
70
|
+
}
|
|
71
|
+
async part(event) {
|
|
72
|
+
const pending = this.pending;
|
|
73
|
+
// Other paired clients use the same encrypted room; their replies are not ours.
|
|
74
|
+
if (!pending || event.requestId !== pending.requestId || event.transferId !== pending.offer.id || event.offset !== pending.offset)
|
|
75
|
+
return;
|
|
76
|
+
clearTimeout(this.timer);
|
|
77
|
+
if (typeof event.error === "string") {
|
|
78
|
+
this.fail(event.error);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
if (typeof event.data !== "string" || event.data.length > Math.ceil(TRANSFER_PAGE_BYTES / 3) * 4)
|
|
83
|
+
throw new Error("Invalid response page.");
|
|
84
|
+
const bytes = unb64(event.data);
|
|
85
|
+
if (bytes.length !== Math.min(TRANSFER_PAGE_BYTES, pending.offer.bytes - pending.offset))
|
|
86
|
+
throw new Error("Incomplete response page.");
|
|
87
|
+
pending.bytes.set(bytes, pending.offset);
|
|
88
|
+
pending.offset += bytes.length;
|
|
89
|
+
pending.retries = 0;
|
|
90
|
+
if (pending.offset < pending.offer.bytes) {
|
|
91
|
+
this.request();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
// Reject duplicate final pages while the authenticated decryption is asynchronous.
|
|
95
|
+
pending.requestId = "";
|
|
96
|
+
const generation = this.generation;
|
|
97
|
+
const fromHex = (value) => Uint8Array.from(value.match(/../g), pair => parseInt(pair, 16));
|
|
98
|
+
const key = await crypto.subtle.importKey("raw", fromHex(pending.offer.key), "AES-GCM", false, ["decrypt"]);
|
|
99
|
+
let plaintext;
|
|
100
|
+
try {
|
|
101
|
+
plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromHex(pending.offer.iv) }, key, pending.bytes);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
throw new Error("Response integrity check failed.");
|
|
105
|
+
}
|
|
106
|
+
if (generation !== this.generation || this.pending !== pending)
|
|
107
|
+
return;
|
|
108
|
+
const complete = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(plaintext));
|
|
109
|
+
if (!complete || typeof complete.type !== "string" || complete.code === "transfer_required" || complete.type === "transfer.part")
|
|
110
|
+
throw new Error("Invalid response envelope.");
|
|
111
|
+
this.pending = undefined;
|
|
112
|
+
this.deps.emit(complete);
|
|
113
|
+
this.drain();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (this.pending === pending)
|
|
117
|
+
this.fail(`${error.message} Retry to synchronize.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
fail(message) {
|
|
121
|
+
const envelope = this.pending?.envelope;
|
|
122
|
+
this.reset();
|
|
123
|
+
this.deps.emit({ type: "session.error", code: "delivery_failed", sessionId: envelope?.sessionId, requestId: envelope?.requestId, error: message });
|
|
124
|
+
// Dropping queued events invalidates the stream. Reconnect triggers the
|
|
125
|
+
// existing history/replay recovery; never apply a tail as a full snapshot.
|
|
126
|
+
this.deps.fault(message);
|
|
127
|
+
}
|
|
128
|
+
drain() {
|
|
129
|
+
while (!this.pending && this.queue.length) {
|
|
130
|
+
const next = this.queue.shift();
|
|
131
|
+
this.queuedBytes -= new TextEncoder().encode(JSON.stringify(next)).length;
|
|
132
|
+
this.deliver(next);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
reset() {
|
|
136
|
+
clearTimeout(this.timer);
|
|
137
|
+
this.pending = undefined;
|
|
138
|
+
this.queue = [];
|
|
139
|
+
this.queuedBytes = 0;
|
|
140
|
+
this.generation++;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createCipheriv, randomBytes } from "node:crypto";
|
|
4
|
+
// A logical event is immutable while clients pull its pages. Pages are encoded
|
|
5
|
+
// independently, so neither history nor an individual attachment must fit in a
|
|
6
|
+
// single sealed frame. Keep in sync with core/event-transfer.ts (conformance test).
|
|
7
|
+
import { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES } from "../wire-format.js";
|
|
8
|
+
export { TRANSFER_PAGE_BYTES, MAX_TRANSFER_BYTES };
|
|
9
|
+
const MAX_STORED_BYTES = 128 * 1024 * 1024;
|
|
10
|
+
const TTL_MS = 120_000;
|
|
11
|
+
const INLINE_EVENT_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
const MAX_LIFETIME_MS = 10 * 60_000;
|
|
13
|
+
export class EventTransferStore {
|
|
14
|
+
now;
|
|
15
|
+
inlineBytes;
|
|
16
|
+
entries = new Map();
|
|
17
|
+
bytes = 0;
|
|
18
|
+
timer;
|
|
19
|
+
constructor(now = Date.now, inlineBytes = INLINE_EVENT_BYTES) {
|
|
20
|
+
this.now = now;
|
|
21
|
+
this.inlineBytes = inlineBytes;
|
|
22
|
+
}
|
|
23
|
+
prune() {
|
|
24
|
+
for (const [id, entry] of this.entries)
|
|
25
|
+
if (entry.expires <= this.now()) {
|
|
26
|
+
this.bytes -= entry.body.length;
|
|
27
|
+
this.entries.delete(id);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
offer(event) {
|
|
31
|
+
const body = Buffer.from(JSON.stringify(event));
|
|
32
|
+
if (body.length <= this.inlineBytes)
|
|
33
|
+
return null;
|
|
34
|
+
const source = event;
|
|
35
|
+
const context = { sessionId: source.sessionId, requestId: source.requestId };
|
|
36
|
+
this.prune();
|
|
37
|
+
if (body.length + 16 > MAX_TRANSFER_BYTES)
|
|
38
|
+
return {
|
|
39
|
+
type: "session.error", ...context, code: "delivery_too_large",
|
|
40
|
+
error: "This response exceeds the machine's 64 MiB delivery limit. The original data is retained on the machine.",
|
|
41
|
+
};
|
|
42
|
+
// Retain only authenticated ciphertext. This is response encryption, not
|
|
43
|
+
// a password digest or a second durable copy of credential-bearing events.
|
|
44
|
+
if (this.bytes + body.length + 16 > MAX_STORED_BYTES || this.entries.size >= 16)
|
|
45
|
+
return {
|
|
46
|
+
type: "session.error", ...context, code: "delivery_busy", error: "The machine is serving large responses. Please retry shortly.",
|
|
47
|
+
};
|
|
48
|
+
const id = randomBytes(24).toString("hex");
|
|
49
|
+
const key = randomBytes(32);
|
|
50
|
+
const iv = randomBytes(12);
|
|
51
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
52
|
+
// WebCrypto's native AES-GCM layout is ciphertext followed by its tag.
|
|
53
|
+
const sealed = Buffer.concat([cipher.update(body), cipher.final(), cipher.getAuthTag()]);
|
|
54
|
+
this.entries.set(id, { body: sealed, key: key.toString("hex"), iv: iv.toString("hex"),
|
|
55
|
+
expires: this.now() + TTL_MS, deadline: this.now() + MAX_LIFETIME_MS });
|
|
56
|
+
this.bytes += sealed.length;
|
|
57
|
+
if (!this.timer) {
|
|
58
|
+
this.timer = setInterval(() => this.prune(), TTL_MS);
|
|
59
|
+
this.timer.unref();
|
|
60
|
+
}
|
|
61
|
+
// Legacy clients already display session.notice messages. Updated clients consume the
|
|
62
|
+
// transfer before the reducer sees this compatibility envelope.
|
|
63
|
+
return { type: "session.notice", ...context, code: "transfer_required",
|
|
64
|
+
message: "Update the Bivy app to load this large response.",
|
|
65
|
+
transfer: { id, bytes: sealed.length, key: key.toString("hex"), iv: iv.toString("hex"), pageBytes: TRANSFER_PAGE_BYTES } };
|
|
66
|
+
}
|
|
67
|
+
read(command) {
|
|
68
|
+
this.prune();
|
|
69
|
+
const { transferId, offset, requestId } = command;
|
|
70
|
+
const entry = typeof transferId === "string" ? this.entries.get(transferId) : undefined;
|
|
71
|
+
const base = { type: "transfer.part", transferId, requestId, offset };
|
|
72
|
+
if (!entry)
|
|
73
|
+
return { ...base, error: "This response expired. Retry to fetch a fresh snapshot." };
|
|
74
|
+
if (typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0 || offset >= entry.body.length || offset % TRANSFER_PAGE_BYTES !== 0) {
|
|
75
|
+
return { ...base, error: "Invalid transfer offset." };
|
|
76
|
+
}
|
|
77
|
+
entry.expires = Math.min(entry.deadline, this.now() + TTL_MS);
|
|
78
|
+
return { ...base, data: entry.body.subarray(offset, offset + TRANSFER_PAGE_BYTES).toString("base64") };
|
|
79
|
+
}
|
|
80
|
+
clear() { clearInterval(this.timer); this.timer = undefined; this.entries.clear(); this.bytes = 0; }
|
|
81
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
3
|
import fs from "node:fs";
|
|
4
|
+
import { EventTransferStore } from "./event-transfer.js";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { WebSocket } from "ws";
|
|
6
7
|
import { seal, sealFrame, openFrame, ReplayGuard } from "../e2e.js";
|
|
@@ -94,7 +95,11 @@ export class RelayConnector {
|
|
|
94
95
|
// explains why instead of silently showing "configured".
|
|
95
96
|
lastErrorMessage;
|
|
96
97
|
replay = new ReplayGuard();
|
|
97
|
-
|
|
98
|
+
transfers = new EventTransferStore();
|
|
99
|
+
reassembler = new FrameReassembler({ onReject: message => {
|
|
100
|
+
console.warn(`[relay] ${message}`);
|
|
101
|
+
this.sendEvent({ type: "session.error", code: "delivery_failed", error: message });
|
|
102
|
+
} });
|
|
98
103
|
pairing;
|
|
99
104
|
onWorkAvailable;
|
|
100
105
|
constructor(config, onClientMessage, options) {
|
|
@@ -135,6 +140,8 @@ export class RelayConnector {
|
|
|
135
140
|
void this.connect();
|
|
136
141
|
}
|
|
137
142
|
stop() {
|
|
143
|
+
this.transfers.clear();
|
|
144
|
+
this.reassembler.reset();
|
|
138
145
|
this.options.previews?.disconnect();
|
|
139
146
|
this.closed = true;
|
|
140
147
|
this.ready = false;
|
|
@@ -204,7 +211,8 @@ export class RelayConnector {
|
|
|
204
211
|
sendEvent(event) {
|
|
205
212
|
if (this.ws?.readyState !== WebSocket.OPEN)
|
|
206
213
|
return;
|
|
207
|
-
const
|
|
214
|
+
const offered = event?.type === "transfer.part" ? null : this.transfers.offer(event);
|
|
215
|
+
const payload = sealFrame(this.roomKey(), offered ?? event);
|
|
208
216
|
// Large events (big file reads, long diffs, image attachments) are split
|
|
209
217
|
// into multiple relay frames so none exceeds the relay's max-frame limit;
|
|
210
218
|
// the client reassembles them before decrypting.
|
|
@@ -395,7 +403,11 @@ export class RelayConnector {
|
|
|
395
403
|
console.warn("[relay] dropped stale or replayed client frame");
|
|
396
404
|
return;
|
|
397
405
|
}
|
|
398
|
-
|
|
406
|
+
const command = frame.data;
|
|
407
|
+
if (command.kind === "transfer.read")
|
|
408
|
+
this.sendEvent(this.transfers.read(command));
|
|
409
|
+
else
|
|
410
|
+
this.onClientMessage(command);
|
|
399
411
|
}
|
|
400
412
|
catch {
|
|
401
413
|
console.warn("[relay] failed to decrypt client frame");
|
|
@@ -424,6 +436,7 @@ export class RelayConnector {
|
|
|
424
436
|
ws.on("close", () => {
|
|
425
437
|
if (this.ws !== ws)
|
|
426
438
|
return;
|
|
439
|
+
this.reassembler.reset();
|
|
427
440
|
this.options.previews?.disconnect();
|
|
428
441
|
this.ready = false;
|
|
429
442
|
this.remoteClients = 0;
|
package/dist/server.js
CHANGED
|
@@ -55,6 +55,7 @@ import { createSessionNamer, fallbackSessionName } from "./session/session-namer
|
|
|
55
55
|
import { createBranchPublish } from "./session/branch-publish.js";
|
|
56
56
|
import { createForkStandUp } from "./session/fork-standup.js";
|
|
57
57
|
import { createForkRetire } from "./session/fork-retire.js";
|
|
58
|
+
import { createClientProjection } from "./session/client-projection.js";
|
|
58
59
|
import { createTranscriptPersistence } from "./session/transcript-persistence.js";
|
|
59
60
|
import { createRunTerminals } from "./session/run-terminal.js";
|
|
60
61
|
import { createRunLogStore } from "./session/run-log-store.js";
|
|
@@ -1450,7 +1451,11 @@ const commands = [
|
|
|
1450
1451
|
// A local client whose send buffer has grown past this is behind on reads (slow
|
|
1451
1452
|
// network, background tab); used by both broadcast paths below.
|
|
1452
1453
|
const CLIENT_BACKPRESSURE_BYTES = 8 * 1024 * 1024;
|
|
1454
|
+
let projectClientMessage;
|
|
1453
1455
|
function broadcast(payload) {
|
|
1456
|
+
const p = payload;
|
|
1457
|
+
if (p?.type === "session.event" && p.sessionId && projectClientMessage)
|
|
1458
|
+
payload = projectClientMessage(p.sessionId, payload);
|
|
1454
1459
|
const data = JSON.stringify(payload);
|
|
1455
1460
|
for (const client of clients) {
|
|
1456
1461
|
if (client.readyState !== WebSocket.OPEN)
|
|
@@ -1481,6 +1486,9 @@ function broadcast(payload) {
|
|
|
1481
1486
|
// out as broadcast(), but skips any local client that is backed up: dropping a
|
|
1482
1487
|
// superseded update is lossless because a newer one always follows.
|
|
1483
1488
|
function broadcastCoalesced(payload) {
|
|
1489
|
+
const p = payload;
|
|
1490
|
+
if (p?.type === "session.event" && p.sessionId && projectClientMessage)
|
|
1491
|
+
payload = projectClientMessage(p.sessionId, payload);
|
|
1484
1492
|
const data = JSON.stringify(payload);
|
|
1485
1493
|
for (const client of clients) {
|
|
1486
1494
|
if (client.readyState !== WebSocket.OPEN)
|
|
@@ -1630,6 +1638,7 @@ const attachmentStore = new AttachmentStore(path.join(appDir, "attachments"), {
|
|
|
1630
1638
|
maxStoreBytes: positiveEnvNumber("BIVY_ATTACHMENT_STORE_MAX_BYTES", 2 * 1024 * 1024 * 1024),
|
|
1631
1639
|
retentionMs: positiveEnvNumber("BIVY_ATTACHMENT_RETENTION_MS", 30 * 24 * 60 * 60 * 1000),
|
|
1632
1640
|
});
|
|
1641
|
+
projectClientMessage = createClientProjection(attachmentStore, eventLog);
|
|
1633
1642
|
let attachmentGcStats = attachmentStore.stats();
|
|
1634
1643
|
function referencedAttachmentHashes() {
|
|
1635
1644
|
// If transcript history is unreadable, collecting nothing would make its
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
/** A display-only projection. Never edit runtime messages or persisted bases:
|
|
5
|
+
* the model still needs its native image blocks when a session resumes. */
|
|
6
|
+
export function createClientProjection(store, log) {
|
|
7
|
+
const refs = new Map();
|
|
8
|
+
return (sessionId, value) => {
|
|
9
|
+
let retained;
|
|
10
|
+
let changed = false;
|
|
11
|
+
function visit(value) {
|
|
12
|
+
if (!value || typeof value !== "object")
|
|
13
|
+
return value;
|
|
14
|
+
if (Array.isArray(value))
|
|
15
|
+
return value.map(visit);
|
|
16
|
+
const block = value;
|
|
17
|
+
const source = block.source;
|
|
18
|
+
let data;
|
|
19
|
+
let mime;
|
|
20
|
+
if (block.type === "image") {
|
|
21
|
+
data = block.data ?? (source?.type === "base64" ? source.data : undefined);
|
|
22
|
+
mime = block.mimeType ?? source?.media_type;
|
|
23
|
+
}
|
|
24
|
+
else if (block.type === "image_url" || block.type === "input_image") {
|
|
25
|
+
const url = typeof block.image_url === "string" ? block.image_url : block.image_url?.url;
|
|
26
|
+
if (typeof url === "string") {
|
|
27
|
+
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,([\s\S]*)$/.exec(url);
|
|
28
|
+
if (match) {
|
|
29
|
+
mime = match[1];
|
|
30
|
+
data = match[2];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (typeof data === "string") {
|
|
35
|
+
try {
|
|
36
|
+
if (data.length > 36 * 1024 * 1024 || !/^[A-Za-z0-9+/]*={0,2}$/.test(data) || data.length % 4 === 1)
|
|
37
|
+
throw new Error("Invalid image encoding");
|
|
38
|
+
const mimeType = typeof mime === "string" && /^image\/[a-zA-Z0-9.+-]+$/.test(mime) ? mime : "image/png";
|
|
39
|
+
const cacheKey = createHash("sha256").update(mimeType).update(data).digest("hex");
|
|
40
|
+
let ref = refs.get(cacheKey);
|
|
41
|
+
if (!ref || !store.getPath(ref.hash)) {
|
|
42
|
+
ref = store.put(Buffer.from(data, "base64"), { name: `image.${mimeType.split("/")[1]}`, mimeType, kind: "image" });
|
|
43
|
+
if (refs.size >= 1024)
|
|
44
|
+
refs.delete(refs.keys().next().value);
|
|
45
|
+
refs.set(cacheKey, ref);
|
|
46
|
+
}
|
|
47
|
+
const url = `bivy-embedded:${ref.hash}`;
|
|
48
|
+
retained ??= new Set(log.readInlineImages(sessionId).map(([url]) => url));
|
|
49
|
+
if (!retained.has(url)) {
|
|
50
|
+
log.appendInlineImage(sessionId, { url, ref });
|
|
51
|
+
retained.add(url);
|
|
52
|
+
changed = true;
|
|
53
|
+
}
|
|
54
|
+
return { type: "bivy_attachment", ref };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// Failure is visible in the display, while original data remains in
|
|
58
|
+
// the native transcript. Never fall back to sending the huge bytes.
|
|
59
|
+
return { type: "text", text: "[Image unavailable: could not store this image for display. The original is retained on the machine.]" };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Only recognized image blocks are rewritten. Arbitrary strings and tool
|
|
63
|
+
// arguments remain byte-for-byte intact, including large textual results.
|
|
64
|
+
return Object.fromEntries(Object.entries(block).map(([key, child]) => [key, visit(child)]));
|
|
65
|
+
}
|
|
66
|
+
const projected = visit(value);
|
|
67
|
+
if (changed)
|
|
68
|
+
log.flush(sessionId);
|
|
69
|
+
return projected;
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
// Copyright (c) 2026 Petter André Sjulstad
|
|
3
|
+
import { EventTransferReceiver } from "../remote/event-transfer-receiver.js";
|
|
3
4
|
//
|
|
4
5
|
// A NODE acting as a relay CLIENT of a sibling node it co-owns — the transport
|
|
5
6
|
// that lets an owner daemon stream replication frames to its standby
|
|
@@ -30,7 +31,12 @@ export class SiblingClient {
|
|
|
30
31
|
ws;
|
|
31
32
|
cipher;
|
|
32
33
|
keypair = newDeviceKeypair();
|
|
33
|
-
reassembler = new FrameReassembler();
|
|
34
|
+
reassembler = new FrameReassembler({ onReject: () => this.ws?.close() });
|
|
35
|
+
transfers = new EventTransferReceiver({
|
|
36
|
+
send: async (command) => this.sendSealed(command),
|
|
37
|
+
emit: event => this.dispatchEvent(event),
|
|
38
|
+
fault: () => this.ws?.close(),
|
|
39
|
+
});
|
|
34
40
|
pending = new Map();
|
|
35
41
|
paired;
|
|
36
42
|
resolvePaired;
|
|
@@ -101,7 +107,7 @@ export class SiblingClient {
|
|
|
101
107
|
return;
|
|
102
108
|
try {
|
|
103
109
|
const frame = this.cipher.open(full);
|
|
104
|
-
this.
|
|
110
|
+
this.transfers.accept(frame.data);
|
|
105
111
|
}
|
|
106
112
|
catch {
|
|
107
113
|
/* undecryptable frame — ignore */
|
|
@@ -179,6 +185,8 @@ export class SiblingClient {
|
|
|
179
185
|
return (await res.json());
|
|
180
186
|
}
|
|
181
187
|
handleClose() {
|
|
188
|
+
this.transfers.reset();
|
|
189
|
+
this.reassembler.reset();
|
|
182
190
|
if (this.closed)
|
|
183
191
|
return;
|
|
184
192
|
for (const [, waiter] of this.pending) {
|
|
@@ -190,6 +198,8 @@ export class SiblingClient {
|
|
|
190
198
|
this.opts.onClose?.();
|
|
191
199
|
}
|
|
192
200
|
close() {
|
|
201
|
+
this.transfers.reset();
|
|
202
|
+
this.reassembler.reset();
|
|
193
203
|
this.closed = true;
|
|
194
204
|
try {
|
|
195
205
|
this.ws?.close();
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// so its logic is unit-testable without a live daemon. The EventLog /
|
|
14
14
|
// AttachmentStore singletons stay server-owned (GC, delete, replication use them)
|
|
15
15
|
// and are injected.
|
|
16
|
+
import { createClientProjection } from "./client-projection.js";
|
|
16
17
|
import { randomBytes } from "node:crypto";
|
|
17
18
|
import { mergeBases } from "./event-log.js";
|
|
18
19
|
import { thinkingTextFromContent } from "./transcript-merge.js";
|
|
@@ -72,6 +73,7 @@ function thinkingTextFromEvent(event) {
|
|
|
72
73
|
const INLINE_IMAGE_RETRY_COOLDOWN_MS = 10 * 60 * 1000;
|
|
73
74
|
export function createTranscriptPersistence(deps) {
|
|
74
75
|
const { eventLog, attachmentStore } = deps;
|
|
76
|
+
const project = createClientProjection(attachmentStore, eventLog);
|
|
75
77
|
const liveIntermediateBySession = new Map();
|
|
76
78
|
const lastPersistedIntermediateText = new Map();
|
|
77
79
|
// In-flight dedupe + failure cooldown so a repeated remote image URL only ever
|
|
@@ -204,7 +206,8 @@ export function createTranscriptPersistence(deps) {
|
|
|
204
206
|
return eventLog.deriveHistory(record.id, record.session.getMessages());
|
|
205
207
|
}
|
|
206
208
|
function buildHistoryEvent(opts) {
|
|
207
|
-
const
|
|
209
|
+
const messages = opts.sessionId ? project(opts.sessionId, opts.messages) : opts.messages;
|
|
210
|
+
const delta = historyDelta(messages, opts.cursor);
|
|
208
211
|
const record = opts.sessionId ? deps.getOpenSession(opts.sessionId) : undefined;
|
|
209
212
|
const bSess = record ? deps.bivySessionEnvelope(record) : undefined;
|
|
210
213
|
return {
|
|
@@ -244,7 +247,7 @@ export function createTranscriptPersistence(deps) {
|
|
|
244
247
|
epoch: deps.streamEpoch,
|
|
245
248
|
mode: outcome.mode,
|
|
246
249
|
head: outcome.head,
|
|
247
|
-
events: outcome.mode === "replay" ? outcome.events : [],
|
|
250
|
+
events: outcome.mode === "replay" ? project(sessionId, outcome.events) : [],
|
|
248
251
|
};
|
|
249
252
|
}
|
|
250
253
|
return {
|
package/dist/wire-format.js
CHANGED
|
@@ -69,3 +69,6 @@ export const FRAME_CHUNK_BYTES = 192 * 1024;
|
|
|
69
69
|
export const MAX_REASSEMBLY_BYTES = 32 * 1024 * 1024;
|
|
70
70
|
export const MAX_FRAME_CHUNKS = 4096;
|
|
71
71
|
export const MAX_REASSEMBLY_GROUPS = 16;
|
|
72
|
+
/** Independently sealed large-response pages and maximum logical JSON size. */
|
|
73
|
+
export const TRANSFER_PAGE_BYTES = 384 * 1024;
|
|
74
|
+
export const MAX_TRANSFER_BYTES = 64 * 1024 * 1024;
|
package/package.json
CHANGED