@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81
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/README.md +84 -26
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +520 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
- package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
- package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
- package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +766 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +765 -27
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +109 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +109 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +68 -47
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/channel-frames.test.ts +214 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/handoff-chunking.test.ts +215 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/message-decoders.test.ts +249 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler.test.ts +590 -0
- package/src/__tests__/session-resume-bridge.test.ts +406 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +84 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +9 -0
- package/src/handoff-chunking.ts +235 -0
- package/src/index.ts +112 -3
- package/src/message-decoders.ts +229 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +10 -0
- package/src/outbound-delivery.ts +231 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/protocol-session.ts +28 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-events.ts +198 -0
- package/src/session-message-handler.ts +282 -0
- package/src/session-query-messages.ts +42 -0
- package/src/session-resume-bridge.ts +252 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +165 -0
- package/dist/node/headless/index.cjs +0 -1
- package/dist/node/headless/index.d.ts +0 -2
- package/dist/node/headless/index.js +0 -1
- package/dist/node/headless-DG_MEbf0.js +0 -19
- package/dist/node/headless-DG_MEbf0.js.map +0 -1
- package/dist/node/headless-xq77JBq0.cjs +0 -18
- package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
- package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
- package/dist/node/index-JIvzrtaA.d.ts +0 -52
- package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
- package/dist/node/index-JTD_xtEQ.d.ts +0 -96
- package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
- package/dist/node/index-y_HBOfzO.d.ts +0 -52
- package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
- package/dist/node/programmatic/index.cjs +0 -1
- package/dist/node/programmatic/index.d.ts +0 -2
- package/dist/node/programmatic/index.js +0 -1
- package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
- package/dist/node/programmatic-70dxIXtS.js +0 -2
- package/dist/node/programmatic-70dxIXtS.js.map +0 -1
- package/dist/node/testing/index.cjs +0 -1
- package/dist/node/testing/index.d.ts +0 -2
- package/dist/node/testing/index.js +0 -1
- package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
- package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
- package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
- package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
- package/src/headless/HeadlessInteractionChannel.ts +0 -129
- package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
- package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
- package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
- package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
- package/src/headless/__tests__/headless-runner.test.ts +0 -508
- package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
- package/src/headless/__tests__/headless-transport.test.ts +0 -267
- package/src/headless/cli-input.ts +0 -50
- package/src/headless/headless-runner.ts +0 -230
- package/src/headless/headless-stream-json.ts +0 -148
- package/src/headless/headless-transport.ts +0 -43
- package/src/headless/index.ts +0 -8
- package/src/headless/print-terminal.ts +0 -57
- package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
- package/src/programmatic/createProgrammaticAgent.ts +0 -71
- package/src/programmatic/index.ts +0 -5
- package/src/testing/index.ts +0 -10
- package/src/transport-registry.ts +0 -103
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { TOutboundDeliver } from './outbound-delivery.js';
|
|
2
|
+
import type { IProtocolSession } from './protocol-session.js';
|
|
3
|
+
import type { TBackgroundControlAction, TClientMessage } from './wire-messages.js';
|
|
4
|
+
|
|
5
|
+
export function handleBackgroundQueryMessage(
|
|
6
|
+
session: IProtocolSession,
|
|
7
|
+
deliver: TOutboundDeliver,
|
|
8
|
+
msg: Extract<
|
|
9
|
+
TClientMessage,
|
|
10
|
+
| { type: 'get-background-tasks' | 'get-background-task' | 'read-background-task-log' }
|
|
11
|
+
| {
|
|
12
|
+
type:
|
|
13
|
+
'get-background-job-groups' | 'get-background-job-group' | 'wait-background-job-group';
|
|
14
|
+
}
|
|
15
|
+
>,
|
|
16
|
+
): void {
|
|
17
|
+
if (msg.type === 'get-background-tasks') {
|
|
18
|
+
deliver({ type: 'background_tasks', tasks: session.listBackgroundTasks(msg.filter) });
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (msg.type === 'get-background-task') {
|
|
22
|
+
sendBackgroundTaskSnapshot(session, deliver, msg);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (msg.type === 'get-background-job-groups') {
|
|
26
|
+
deliver({ type: 'background_job_groups', groups: session.listBackgroundJobGroups() });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (msg.type === 'get-background-job-group') {
|
|
30
|
+
sendBackgroundJobGroupSnapshot(session, deliver, msg);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (msg.type === 'wait-background-job-group') {
|
|
34
|
+
sendBackgroundJobGroupWaitResult(session, deliver, msg);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
sendBackgroundTaskLogPage(session, deliver, msg);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function handleBackgroundControlMessage(
|
|
41
|
+
session: IProtocolSession,
|
|
42
|
+
deliver: TOutboundDeliver,
|
|
43
|
+
msg: Extract<
|
|
44
|
+
TClientMessage,
|
|
45
|
+
{ type: 'cancel-background-task' | 'close-background-task' | 'send-background-task' }
|
|
46
|
+
>,
|
|
47
|
+
): void {
|
|
48
|
+
if (!msg.taskId) {
|
|
49
|
+
deliver({ type: 'protocol_error', message: 'taskId is required' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (msg.type === 'cancel-background-task') {
|
|
53
|
+
sendBackgroundTaskControlResult(
|
|
54
|
+
deliver,
|
|
55
|
+
'cancel',
|
|
56
|
+
msg.taskId,
|
|
57
|
+
session.cancelBackgroundTask(msg.taskId, msg.reason),
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (msg.type === 'close-background-task') {
|
|
62
|
+
sendBackgroundTaskControlResult(
|
|
63
|
+
deliver,
|
|
64
|
+
'close',
|
|
65
|
+
msg.taskId,
|
|
66
|
+
session.closeBackgroundTask(msg.taskId),
|
|
67
|
+
);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
sendBackgroundTaskInput(session, deliver, msg);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sendBackgroundTaskSnapshot(
|
|
74
|
+
session: IProtocolSession,
|
|
75
|
+
deliver: TOutboundDeliver,
|
|
76
|
+
msg: Extract<TClientMessage, { type: 'get-background-task' }>,
|
|
77
|
+
): void {
|
|
78
|
+
if (!msg.taskId) {
|
|
79
|
+
deliver({ type: 'protocol_error', message: 'taskId is required' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
deliver({
|
|
83
|
+
type: 'background_task',
|
|
84
|
+
taskId: msg.taskId,
|
|
85
|
+
task: session.getBackgroundTask(msg.taskId) ?? null,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function sendBackgroundTaskLogPage(
|
|
90
|
+
session: IProtocolSession,
|
|
91
|
+
deliver: TOutboundDeliver,
|
|
92
|
+
msg: Extract<TClientMessage, { type: 'read-background-task-log' }>,
|
|
93
|
+
): void {
|
|
94
|
+
if (!msg.taskId) {
|
|
95
|
+
deliver({ type: 'protocol_error', message: 'taskId is required' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
session.readBackgroundTaskLog(msg.taskId, msg.cursor).then(
|
|
99
|
+
(page) => deliver({ type: 'background_task_log', taskId: msg.taskId, page }),
|
|
100
|
+
(error: Error) => deliver({ type: 'protocol_error', message: error.message }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sendBackgroundJobGroupSnapshot(
|
|
105
|
+
session: IProtocolSession,
|
|
106
|
+
deliver: TOutboundDeliver,
|
|
107
|
+
msg: Extract<TClientMessage, { type: 'get-background-job-group' }>,
|
|
108
|
+
): void {
|
|
109
|
+
if (!msg.groupId) {
|
|
110
|
+
deliver({ type: 'protocol_error', message: 'groupId is required' });
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
deliver({
|
|
114
|
+
type: 'background_job_group',
|
|
115
|
+
groupId: msg.groupId,
|
|
116
|
+
group: session.getBackgroundJobGroup(msg.groupId) ?? null,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sendBackgroundJobGroupWaitResult(
|
|
121
|
+
session: IProtocolSession,
|
|
122
|
+
deliver: TOutboundDeliver,
|
|
123
|
+
msg: Extract<TClientMessage, { type: 'wait-background-job-group' }>,
|
|
124
|
+
): void {
|
|
125
|
+
if (!msg.groupId) {
|
|
126
|
+
deliver({ type: 'protocol_error', message: 'groupId is required' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
session.waitBackgroundJobGroup(msg.groupId).then(
|
|
130
|
+
(group) => deliver({ type: 'background_job_group', groupId: msg.groupId, group }),
|
|
131
|
+
(error: Error) => deliver({ type: 'protocol_error', message: error.message }),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function sendBackgroundTaskInput(
|
|
136
|
+
session: IProtocolSession,
|
|
137
|
+
deliver: TOutboundDeliver,
|
|
138
|
+
msg: Extract<TClientMessage, { type: 'send-background-task' }>,
|
|
139
|
+
): void {
|
|
140
|
+
if (!msg.input) {
|
|
141
|
+
deliver({ type: 'protocol_error', message: 'input is required' });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
sendBackgroundTaskControlResult(
|
|
145
|
+
deliver,
|
|
146
|
+
'send',
|
|
147
|
+
msg.taskId,
|
|
148
|
+
session.sendBackgroundTask(msg.taskId, msg.input),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sendBackgroundTaskControlResult(
|
|
153
|
+
deliver: TOutboundDeliver,
|
|
154
|
+
action: TBackgroundControlAction,
|
|
155
|
+
taskId: string,
|
|
156
|
+
operation: Promise<void>,
|
|
157
|
+
): void {
|
|
158
|
+
operation.then(
|
|
159
|
+
() => deliver({ type: 'background_task_control_result', action, taskId, success: true }),
|
|
160
|
+
(error: Error) =>
|
|
161
|
+
deliver({
|
|
162
|
+
type: 'background_task_control_result',
|
|
163
|
+
action,
|
|
164
|
+
taskId,
|
|
165
|
+
success: false,
|
|
166
|
+
message: error.message,
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload-agnostic channel frame codec (TRANS-001).
|
|
3
|
+
*
|
|
4
|
+
* The single place that knows the channel envelope. Pure and transport-neutral: a WebSocket binary
|
|
5
|
+
* frame, a WebRTC data-channel message, or any other byte carrier can use it unchanged. It NEVER
|
|
6
|
+
* interprets a binary body — that is the whole point of the payload-agnostic profile.
|
|
7
|
+
*
|
|
8
|
+
* Wire layout (all multi-byte integers big-endian):
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* 0..2 magic 'R','B','F' ← lets a carrier route a frame without parsing it
|
|
12
|
+
* 3 version (1)
|
|
13
|
+
* 4 kind: 0x01 = opaque binary, 0x02 = structured event (UTF-8 JSON body)
|
|
14
|
+
* 5 channel-name length in bytes (1..255)
|
|
15
|
+
* 6.. channel name (UTF-8)
|
|
16
|
+
* +4 seq (uint32)
|
|
17
|
+
* ... body — opaque bytes (kind 0x01) or UTF-8 JSON `{ "event": string, "payload": … }` (0x02)
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* The envelope deliberately carries NO length prefix for the body: the carrier already delimits the
|
|
21
|
+
* message (a WebSocket frame is a message boundary), so a length field would only be a second source
|
|
22
|
+
* of truth that can disagree with the first.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
IBinaryFrame,
|
|
27
|
+
IChannelEventFrame,
|
|
28
|
+
TChannelReceiveResult,
|
|
29
|
+
} from '@robota-sdk/agent-interface-transport';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The JSON-serializable payload type an event frame carries. Read off the contract (rather than
|
|
33
|
+
* re-importing `agent-core`'s `TUniversalValue`) so this package keeps its single contract edge.
|
|
34
|
+
*/
|
|
35
|
+
type TEventPayload = IChannelEventFrame['payload'];
|
|
36
|
+
|
|
37
|
+
const encoder = new TextEncoder();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
|
|
40
|
+
/** `R`, `B`, `F` — the shared prefix every channel frame starts with. */
|
|
41
|
+
export const CHANNEL_FRAME_MAGIC: Uint8Array = encoder.encode('RBF');
|
|
42
|
+
|
|
43
|
+
/** Envelope version. Bumped only on an incompatible layout change. */
|
|
44
|
+
export const CHANNEL_FRAME_VERSION = 1;
|
|
45
|
+
|
|
46
|
+
const KIND_BINARY = 0x01;
|
|
47
|
+
const KIND_EVENT = 0x02;
|
|
48
|
+
|
|
49
|
+
/** Bytes before the channel name: magic(3) + version(1) + kind(1) + name length(1). */
|
|
50
|
+
const PREFIX_BYTES = 6;
|
|
51
|
+
/** Bytes after the channel name and before the body: seq(4). */
|
|
52
|
+
const SEQ_BYTES = 4;
|
|
53
|
+
|
|
54
|
+
const MAX_CHANNEL_NAME_BYTES = 255;
|
|
55
|
+
const MAX_SEQ = 0xffff_ffff;
|
|
56
|
+
|
|
57
|
+
/** Validate the envelope fields both encoders share, then return the encoded channel name. */
|
|
58
|
+
function encodeChannelName(channel: string, seq: number): Uint8Array {
|
|
59
|
+
const name = encoder.encode(channel);
|
|
60
|
+
if (name.byteLength === 0 || name.byteLength > MAX_CHANNEL_NAME_BYTES) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`channel name must be 1..${MAX_CHANNEL_NAME_BYTES} UTF-8 bytes (got ${name.byteLength})`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isInteger(seq) || seq < 0 || seq > MAX_SEQ) {
|
|
66
|
+
throw new Error(`seq must be an integer in 0..${MAX_SEQ} (got ${seq})`);
|
|
67
|
+
}
|
|
68
|
+
return name;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Write the shared header into `out` and return the offset where the body starts. */
|
|
72
|
+
function writeHeader(out: Uint8Array, kind: number, name: Uint8Array, seq: number): number {
|
|
73
|
+
out.set(CHANNEL_FRAME_MAGIC, 0);
|
|
74
|
+
out[3] = CHANNEL_FRAME_VERSION;
|
|
75
|
+
out[4] = kind;
|
|
76
|
+
out[5] = name.byteLength;
|
|
77
|
+
out.set(name, PREFIX_BYTES);
|
|
78
|
+
const seqAt = PREFIX_BYTES + name.byteLength;
|
|
79
|
+
new DataView(out.buffer, out.byteOffset, out.byteLength).setUint32(seqAt, seq, false);
|
|
80
|
+
return seqAt + SEQ_BYTES;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Encode an opaque binary frame. The payload bytes are copied verbatim. */
|
|
84
|
+
export function encodeBinaryFrame(frame: IBinaryFrame): Uint8Array {
|
|
85
|
+
const name = encodeChannelName(frame.channel, frame.seq);
|
|
86
|
+
const out = new Uint8Array(PREFIX_BYTES + name.byteLength + SEQ_BYTES + frame.payload.byteLength);
|
|
87
|
+
const bodyAt = writeHeader(out, KIND_BINARY, name, frame.seq);
|
|
88
|
+
out.set(frame.payload, bodyAt);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Encode a structured, consumer-declared event frame (UTF-8 JSON body). */
|
|
93
|
+
export function encodeChannelEventFrame(frame: IChannelEventFrame): Uint8Array {
|
|
94
|
+
const name = encodeChannelName(frame.channel, frame.seq);
|
|
95
|
+
const body = encoder.encode(JSON.stringify({ event: frame.event, payload: frame.payload }));
|
|
96
|
+
const out = new Uint8Array(PREFIX_BYTES + name.byteLength + SEQ_BYTES + body.byteLength);
|
|
97
|
+
const bodyAt = writeHeader(out, KIND_EVENT, name, frame.seq);
|
|
98
|
+
out.set(body, bodyAt);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when `bytes` starts with the channel-frame magic — a cheap, non-parsing routing check. */
|
|
103
|
+
export function isChannelFrame(bytes: Uint8Array): boolean {
|
|
104
|
+
if (bytes.byteLength < CHANNEL_FRAME_MAGIC.length) return false;
|
|
105
|
+
return CHANNEL_FRAME_MAGIC.every((b, i) => bytes[i] === b);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The decoded shape of an event frame's JSON body. */
|
|
109
|
+
interface IEventBody {
|
|
110
|
+
readonly event?: TEventPayload;
|
|
111
|
+
readonly payload?: TEventPayload;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Parse the JSON body of an event frame into its `event` + `payload` fields, or state why not. */
|
|
115
|
+
function parseEventBody(
|
|
116
|
+
body: Uint8Array,
|
|
117
|
+
): { ok: true; event: string; payload: TEventPayload } | { ok: false; error: string } {
|
|
118
|
+
const text = decoder.decode(body);
|
|
119
|
+
let parsed: TEventPayload;
|
|
120
|
+
try {
|
|
121
|
+
parsed = JSON.parse(text) as TEventPayload;
|
|
122
|
+
} catch {
|
|
123
|
+
// An error RESULT, not a fallback: the caller reports it to the peer as a protocol error.
|
|
124
|
+
return { ok: false, error: 'channel frame: malformed JSON body' };
|
|
125
|
+
}
|
|
126
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
127
|
+
return { ok: false, error: 'channel frame: event body must be a JSON object' };
|
|
128
|
+
}
|
|
129
|
+
const { event, payload } = parsed as IEventBody;
|
|
130
|
+
if (typeof event !== 'string' || event.length === 0) {
|
|
131
|
+
return { ok: false, error: 'channel frame: event body is missing a non-empty `event` name' };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, event, payload: payload ?? null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Decode a channel frame. Returns an explicit result union — a non-channel, truncated, or malformed
|
|
138
|
+
* input is a stated error the carrier reports to the peer, never a thrown exception on the hot path
|
|
139
|
+
* and never a silently dropped frame.
|
|
140
|
+
*/
|
|
141
|
+
export function decodeChannelFrame(bytes: Uint8Array): TChannelReceiveResult {
|
|
142
|
+
if (!isChannelFrame(bytes)) {
|
|
143
|
+
return { ok: false, error: 'channel frame: bad magic (not a channel frame)' };
|
|
144
|
+
}
|
|
145
|
+
if (bytes.byteLength < PREFIX_BYTES) {
|
|
146
|
+
return { ok: false, error: 'channel frame: truncated header' };
|
|
147
|
+
}
|
|
148
|
+
if (bytes[3] !== CHANNEL_FRAME_VERSION) {
|
|
149
|
+
return { ok: false, error: `channel frame: unsupported version ${String(bytes[3])}` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const kind = bytes[4];
|
|
153
|
+
const nameLength = bytes[5] ?? 0;
|
|
154
|
+
if (nameLength === 0) return { ok: false, error: 'channel frame: empty channel name' };
|
|
155
|
+
|
|
156
|
+
const seqAt = PREFIX_BYTES + nameLength;
|
|
157
|
+
const bodyAt = seqAt + SEQ_BYTES;
|
|
158
|
+
if (bytes.byteLength < bodyAt) {
|
|
159
|
+
return { ok: false, error: 'channel frame: truncated (header longer than the frame)' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const channel = decoder.decode(bytes.subarray(PREFIX_BYTES, seqAt));
|
|
163
|
+
const seq = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(
|
|
164
|
+
seqAt,
|
|
165
|
+
false,
|
|
166
|
+
);
|
|
167
|
+
const body = bytes.subarray(bodyAt);
|
|
168
|
+
|
|
169
|
+
if (kind === KIND_BINARY) {
|
|
170
|
+
// Copy so the frame does not alias the carrier's (possibly pooled/reused) receive buffer.
|
|
171
|
+
return { ok: true, frame: { kind: 'binary', channel, seq, payload: Uint8Array.from(body) } };
|
|
172
|
+
}
|
|
173
|
+
if (kind === KIND_EVENT) {
|
|
174
|
+
const parsed = parseEventBody(body);
|
|
175
|
+
if (!parsed.ok) return { ok: false, error: parsed.error };
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
frame: { kind: 'event', channel, seq, event: parsed.event, payload: parsed.payload },
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { ok: false, error: `channel frame: unknown kind ${String(kind)}` };
|
|
182
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Browser-safe protocol surface: wire unions plus runtime frame decoders, with no Node admission code. */
|
|
2
|
+
export {
|
|
3
|
+
MAX_INBOUND_FRAME_BYTES,
|
|
4
|
+
decodeClientMessage,
|
|
5
|
+
decodeFrame,
|
|
6
|
+
decodeServerMessage,
|
|
7
|
+
} from './message-decoders.js';
|
|
8
|
+
export type { TMessageDecodeResult } from './message-decoders.js';
|
|
9
|
+
export type { TClientMessage, TServerMessage, TSeqServerMessage } from './wire-messages.js';
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HANDOFF-001 (#1811): moving a session record that does not fit in one frame.
|
|
3
|
+
*
|
|
4
|
+
* A data channel has a maximum message size; a session record is a whole conversation. So the
|
|
5
|
+
* payload travels in pieces, and this owns the two halves of that: cutting it up, and putting it
|
|
6
|
+
* back together in the face of retries, reordering and a peer that says something inconsistent.
|
|
7
|
+
*
|
|
8
|
+
* ## Why the cut is made on BYTES rather than on the string
|
|
9
|
+
*
|
|
10
|
+
* The channel's limit is in bytes, and the serialized record is a JavaScript string — UTF-16 code
|
|
11
|
+
* units, where one character can be one byte or four. Slicing by characters and hoping the pieces
|
|
12
|
+
* fit means either measuring each slice's encoded length anyway, or shipping frames that overflow
|
|
13
|
+
* the channel for anyone whose conversation contains an emoji or a non-Latin script.
|
|
14
|
+
*
|
|
15
|
+
* Cutting the encoded bytes makes the budget exact. It costs base64's third: a byte slice is not a
|
|
16
|
+
* valid string on its own — it can end mid-character — so it cannot ride in a JSON frame as text.
|
|
17
|
+
* That cost is deliberate and is the smaller risk. The alternative works only because
|
|
18
|
+
* `JSON.stringify` has escaped lone surrogates since ES2019; a transfer whose correctness rests on
|
|
19
|
+
* that, and which fails as silent corruption when something in the chain does not, is not a trade
|
|
20
|
+
* worth a third of the bandwidth.
|
|
21
|
+
*
|
|
22
|
+
* ## What this does NOT decide
|
|
23
|
+
*
|
|
24
|
+
* It does not verify the digest. `verifyHandoffPayload` owns that, and asking the same question in
|
|
25
|
+
* two places is how the two answers drift. This reports that all the pieces arrived; whether they
|
|
26
|
+
* are the RIGHT pieces is the next check, and it deliberately runs on the whole reassembled payload
|
|
27
|
+
* rather than per chunk — a per-chunk digest would say each piece is intact while the set is not the
|
|
28
|
+
* set that was sent.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { IHandoffIntegrity } from '@robota-sdk/agent-interface-session-mobility';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One piece of a payload in flight.
|
|
35
|
+
*
|
|
36
|
+
* `total` travels on every chunk rather than in a separate header frame: a header can be the frame
|
|
37
|
+
* that gets lost, and a receiver that learned the count from a message it never got would wait
|
|
38
|
+
* forever for a number it cannot ask for again.
|
|
39
|
+
*/
|
|
40
|
+
export interface IHandoffChunk {
|
|
41
|
+
readonly handoffId: string;
|
|
42
|
+
/** 0-based position in the sequence. */
|
|
43
|
+
readonly index: number;
|
|
44
|
+
/** How many chunks make up the whole payload. */
|
|
45
|
+
readonly total: number;
|
|
46
|
+
/** base64 of this slice of the UTF-8 encoded payload. */
|
|
47
|
+
readonly data: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Default frame budget, in bytes of encoded chunk data.
|
|
52
|
+
*
|
|
53
|
+
* 16 KiB because that is the size every SCTP implementation carries without negotiation. Larger
|
|
54
|
+
* works between two peers that both support it and fails between two that do not — and it fails at
|
|
55
|
+
* transfer time, on the user's session, not in a test.
|
|
56
|
+
*/
|
|
57
|
+
export const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Cut a sealed payload into chunks.
|
|
61
|
+
*
|
|
62
|
+
* Takes the serialized string the seal produced, not the record — the digest is over those exact
|
|
63
|
+
* bytes, and re-serializing here would let a chunked payload differ from the one the manifest
|
|
64
|
+
* describes.
|
|
65
|
+
*/
|
|
66
|
+
export function chunkHandoffPayload(
|
|
67
|
+
handoffId: string,
|
|
68
|
+
serialized: string,
|
|
69
|
+
maxChunkBytes: number = DEFAULT_MAX_CHUNK_BYTES,
|
|
70
|
+
): readonly IHandoffChunk[] {
|
|
71
|
+
if (!Number.isInteger(maxChunkBytes) || maxChunkBytes < 1) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`handoff chunking: maxChunkBytes must be a positive integer, got ${maxChunkBytes}. ` +
|
|
74
|
+
'A zero or negative budget produces an infinite sequence of empty chunks, which presents ' +
|
|
75
|
+
'as a hang rather than as the configuration error it is.',
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const bytes = Buffer.from(serialized, 'utf8');
|
|
79
|
+
// An empty payload is still one chunk. Zero chunks would leave the receiver unable to tell a
|
|
80
|
+
// completed empty transfer from one that never started.
|
|
81
|
+
const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
|
|
82
|
+
const chunks: IHandoffChunk[] = [];
|
|
83
|
+
for (let index = 0; index < total; index += 1) {
|
|
84
|
+
const slice = bytes.subarray(index * maxChunkBytes, (index + 1) * maxChunkBytes);
|
|
85
|
+
chunks.push({ handoffId, index, total, data: slice.toString('base64') });
|
|
86
|
+
}
|
|
87
|
+
return chunks;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Why a chunk was not accepted. */
|
|
91
|
+
export type TChunkRejection =
|
|
92
|
+
/** Belongs to a different transfer. */
|
|
93
|
+
| 'wrong-handoff'
|
|
94
|
+
/** `index` is negative, or beyond the declared `total`. */
|
|
95
|
+
| 'out-of-range'
|
|
96
|
+
/** Its `total` disagrees with what earlier chunks declared. */
|
|
97
|
+
| 'inconsistent-total'
|
|
98
|
+
/** Its `data` is not decodable base64. */
|
|
99
|
+
| 'undecodable';
|
|
100
|
+
|
|
101
|
+
export type TChunkOutcome =
|
|
102
|
+
/** Stored; more are still needed. */
|
|
103
|
+
| 'accepted'
|
|
104
|
+
/** Already had this index, and it matched. A retry, not a problem. */
|
|
105
|
+
| 'duplicate'
|
|
106
|
+
/** Stored, and that was the last one missing. */
|
|
107
|
+
| 'complete'
|
|
108
|
+
| 'refused';
|
|
109
|
+
|
|
110
|
+
export interface IChunkResult {
|
|
111
|
+
readonly outcome: TChunkOutcome;
|
|
112
|
+
readonly rejection?: TChunkRejection;
|
|
113
|
+
/** Present when complete: the reassembled payload, ready for the integrity check. */
|
|
114
|
+
readonly serialized?: string;
|
|
115
|
+
/** How many distinct chunks are held. */
|
|
116
|
+
readonly received: number;
|
|
117
|
+
/** How many are expected, once anything has declared it. */
|
|
118
|
+
readonly expected?: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Collects the chunks of ONE transfer.
|
|
123
|
+
*
|
|
124
|
+
* One assembler per `handoffId`, because a shared one would have to key everything by transfer and
|
|
125
|
+
* would then be a place where two transfers can be confused for each other. A chunk naming a
|
|
126
|
+
* different transfer is refused rather than routed — routing is the caller's job and doing it here
|
|
127
|
+
* would make this a dispatcher as well as a buffer.
|
|
128
|
+
*/
|
|
129
|
+
export class HandoffChunkAssembler {
|
|
130
|
+
private readonly chunks = new Map<number, Buffer>();
|
|
131
|
+
private declaredTotal: number | undefined;
|
|
132
|
+
|
|
133
|
+
constructor(private readonly handoffId: string) {}
|
|
134
|
+
|
|
135
|
+
get received(): number {
|
|
136
|
+
return this.chunks.size;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get expected(): number | undefined {
|
|
140
|
+
return this.declaredTotal;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Take one chunk. Reordering and retries are normal; inconsistency is not. */
|
|
144
|
+
accept(chunk: IHandoffChunk): IChunkResult {
|
|
145
|
+
const refuse = (rejection: TChunkRejection): IChunkResult => ({
|
|
146
|
+
outcome: 'refused',
|
|
147
|
+
rejection,
|
|
148
|
+
received: this.chunks.size,
|
|
149
|
+
...(this.declaredTotal !== undefined ? { expected: this.declaredTotal } : {}),
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
if (chunk.handoffId !== this.handoffId) return refuse('wrong-handoff');
|
|
153
|
+
if (!Number.isInteger(chunk.total) || chunk.total < 1) return refuse('inconsistent-total');
|
|
154
|
+
if (this.declaredTotal !== undefined && chunk.total !== this.declaredTotal) {
|
|
155
|
+
// A peer that changes its mind about the size has either restarted the transfer under the
|
|
156
|
+
// same id or is not the peer we started with. Either way the pieces held so far can no longer
|
|
157
|
+
// be assumed to belong together.
|
|
158
|
+
return refuse('inconsistent-total');
|
|
159
|
+
}
|
|
160
|
+
if (!Number.isInteger(chunk.index) || chunk.index < 0 || chunk.index >= chunk.total) {
|
|
161
|
+
return refuse('out-of-range');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const decoded = Buffer.from(chunk.data, 'base64');
|
|
165
|
+
// Buffer.from is lenient — it drops what it cannot parse rather than throwing, so a mangled
|
|
166
|
+
// chunk would silently become a shorter one and corrupt the payload in a way only the final
|
|
167
|
+
// digest would catch, after the whole transfer completed. Re-encoding is how the leniency is
|
|
168
|
+
// detected here instead.
|
|
169
|
+
if (decoded.toString('base64') !== chunk.data) return refuse('undecodable');
|
|
170
|
+
|
|
171
|
+
this.declaredTotal = chunk.total;
|
|
172
|
+
const held = this.chunks.get(chunk.index);
|
|
173
|
+
if (held !== undefined) {
|
|
174
|
+
// A retry re-sending the same bytes is normal. Different bytes for the same index is the
|
|
175
|
+
// inconsistent case again, and the same reasoning applies.
|
|
176
|
+
if (held.equals(decoded)) {
|
|
177
|
+
return { outcome: 'duplicate', received: this.chunks.size, expected: chunk.total };
|
|
178
|
+
}
|
|
179
|
+
return refuse('inconsistent-total');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
this.chunks.set(chunk.index, decoded);
|
|
183
|
+
if (this.chunks.size < chunk.total) {
|
|
184
|
+
return { outcome: 'accepted', received: this.chunks.size, expected: chunk.total };
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
outcome: 'complete',
|
|
188
|
+
serialized: this.reassemble(chunk.total),
|
|
189
|
+
received: this.chunks.size,
|
|
190
|
+
expected: chunk.total,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Which indices are still missing, so a caller can ask for them rather than restarting. */
|
|
195
|
+
missing(): readonly number[] {
|
|
196
|
+
if (this.declaredTotal === undefined) return [];
|
|
197
|
+
const gaps: number[] = [];
|
|
198
|
+
for (let i = 0; i < this.declaredTotal; i += 1) if (!this.chunks.has(i)) gaps.push(i);
|
|
199
|
+
return gaps;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Drop everything held. The transfer was abandoned, or its integrity check failed. */
|
|
203
|
+
discard(): void {
|
|
204
|
+
this.chunks.clear();
|
|
205
|
+
this.declaredTotal = undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private reassemble(total: number): string {
|
|
209
|
+
const ordered: Buffer[] = [];
|
|
210
|
+
for (let i = 0; i < total; i += 1) {
|
|
211
|
+
const part = this.chunks.get(i);
|
|
212
|
+
if (part === undefined) {
|
|
213
|
+
// Unreachable: size equalled total and every index was range-checked on the way in. Stated
|
|
214
|
+
// as an error rather than a non-null assertion, because the assertion would be the thing
|
|
215
|
+
// that hid it if the invariant ever broke.
|
|
216
|
+
throw new Error(`handoff chunking: chunk ${i} of ${total} is missing at reassembly.`);
|
|
217
|
+
}
|
|
218
|
+
ordered.push(part);
|
|
219
|
+
}
|
|
220
|
+
return Buffer.concat(ordered).toString('utf8');
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* How many chunks a payload of this size will take, without building them.
|
|
226
|
+
*
|
|
227
|
+
* For a progress indicator at the sending end, and for a receiver deciding whether it is willing to
|
|
228
|
+
* hold that much before the first chunk arrives.
|
|
229
|
+
*/
|
|
230
|
+
export function chunkCountFor(
|
|
231
|
+
integrity: IHandoffIntegrity,
|
|
232
|
+
maxChunkBytes: number = DEFAULT_MAX_CHUNK_BYTES,
|
|
233
|
+
): number {
|
|
234
|
+
return Math.max(1, Math.ceil(integrity.byteLength / maxChunkBytes));
|
|
235
|
+
}
|