@robota-sdk/agent-transport 3.0.0-beta.84 → 3.0.0-beta.85
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/CHANGELOG.md +19 -0
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts.map +1 -1
- 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/package.json +8 -8
- package/src/__tests__/handoff-chunking.test.ts +44 -1
- package/src/handoff-chunking.ts +52 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robota-sdk/agent-transport",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.85",
|
|
4
4
|
"description": "Carrier-neutral wire protocol, session dispatch and delivery for Robota SDK transports",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/node/index.js",
|
|
@@ -89,13 +89,13 @@
|
|
|
89
89
|
],
|
|
90
90
|
"dependencies": {
|
|
91
91
|
"jose": "6.2.3",
|
|
92
|
-
"@robota-sdk/agent-
|
|
93
|
-
"@robota-sdk/agent-interface-
|
|
94
|
-
"@robota-sdk/agent-
|
|
95
|
-
"@robota-sdk/agent-interface-execution": "3.0.0-beta.
|
|
96
|
-
"@robota-sdk/agent-interface-
|
|
97
|
-
"@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.
|
|
98
|
-
"@robota-sdk/agent-interface-transport": "3.0.0-beta.
|
|
92
|
+
"@robota-sdk/agent-interface-command": "3.0.0-beta.85",
|
|
93
|
+
"@robota-sdk/agent-interface-session": "3.0.0-beta.85",
|
|
94
|
+
"@robota-sdk/agent-core": "3.0.0-beta.85",
|
|
95
|
+
"@robota-sdk/agent-interface-execution": "3.0.0-beta.85",
|
|
96
|
+
"@robota-sdk/agent-interface-analytics": "3.0.0-beta.85",
|
|
97
|
+
"@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.85",
|
|
98
|
+
"@robota-sdk/agent-interface-transport": "3.0.0-beta.85"
|
|
99
99
|
},
|
|
100
100
|
"devDependencies": {
|
|
101
101
|
"rimraf": "^5.0.10",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
chunkCountFor,
|
|
@@ -213,3 +213,46 @@ describe('HANDOFF-001 TC-06 — a mangled chunk is caught where it arrives', ()
|
|
|
213
213
|
expect(JSON.parse(last?.serialized ?? '{}')).toEqual(record);
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
|
+
|
|
217
|
+
describe('without Node’s Buffer, as in the browser build', () => {
|
|
218
|
+
afterEach(() => {
|
|
219
|
+
vi.unstubAllGlobals();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('chunks and reassembles a multi-byte payload', () => {
|
|
223
|
+
vi.stubGlobal('Buffer', undefined);
|
|
224
|
+
const serialized = JSON.stringify({ text: '핸드오프 ✓ '.repeat(200) });
|
|
225
|
+
const assembler = new HandoffChunkAssembler('h-browser');
|
|
226
|
+
let result: ReturnType<HandoffChunkAssembler['accept']> | undefined;
|
|
227
|
+
|
|
228
|
+
for (const chunk of chunkHandoffPayload('h-browser', serialized, 64)) {
|
|
229
|
+
result = assembler.accept(chunk);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
expect(result).toMatchObject({ outcome: 'complete', serialized });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('keeps a leading byte-order mark', () => {
|
|
236
|
+
vi.stubGlobal('Buffer', undefined);
|
|
237
|
+
const serialized = '\uFEFF{"text":"bom"}';
|
|
238
|
+
const assembler = new HandoffChunkAssembler('h-bom');
|
|
239
|
+
let result: ReturnType<HandoffChunkAssembler['accept']> | undefined;
|
|
240
|
+
|
|
241
|
+
for (const chunk of chunkHandoffPayload('h-bom', serialized, 4)) {
|
|
242
|
+
result = assembler.accept(chunk);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
expect(result).toMatchObject({ outcome: 'complete', serialized });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('refuses a chunk whose base64 is not canonical', () => {
|
|
249
|
+
vi.stubGlobal('Buffer', undefined);
|
|
250
|
+
const [chunk] = chunkHandoffPayload('h-browser', 'hello', 64);
|
|
251
|
+
const assembler = new HandoffChunkAssembler('h-browser');
|
|
252
|
+
|
|
253
|
+
expect(assembler.accept({ ...chunk!, data: ` ${chunk!.data}` })).toMatchObject({
|
|
254
|
+
outcome: 'refused',
|
|
255
|
+
rejection: 'undecodable',
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
package/src/handoff-chunking.ts
CHANGED
|
@@ -56,6 +56,39 @@ export interface IHandoffChunk {
|
|
|
56
56
|
*/
|
|
57
57
|
export const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024;
|
|
58
58
|
|
|
59
|
+
// This module is exported from the package root, which also builds for the browser, so it uses the
|
|
60
|
+
// web platform's bytes, text codecs and base64 (`btoa`/`atob`) rather than Node's `Buffer`.
|
|
61
|
+
|
|
62
|
+
/** How many bytes go through `String.fromCharCode` at once, well under the argument-count limit. */
|
|
63
|
+
const BINARY_STRING_STEP = 0x8000;
|
|
64
|
+
|
|
65
|
+
function toBase64(bytes: Uint8Array): string {
|
|
66
|
+
let binary = '';
|
|
67
|
+
for (let offset = 0; offset < bytes.length; offset += BINARY_STRING_STEP) {
|
|
68
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + BINARY_STRING_STEP));
|
|
69
|
+
}
|
|
70
|
+
return btoa(binary);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The bytes `data` encodes, or `undefined` when it is not base64 at all. */
|
|
74
|
+
function fromBase64(data: string): Uint8Array | undefined {
|
|
75
|
+
let binary: string;
|
|
76
|
+
try {
|
|
77
|
+
binary = atob(data);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const bytes = new Uint8Array(binary.length);
|
|
82
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
83
|
+
return bytes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
87
|
+
if (a.length !== b.length) return false;
|
|
88
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
59
92
|
/**
|
|
60
93
|
* Cut a sealed payload into chunks.
|
|
61
94
|
*
|
|
@@ -75,14 +108,14 @@ export function chunkHandoffPayload(
|
|
|
75
108
|
'as a hang rather than as the configuration error it is.',
|
|
76
109
|
);
|
|
77
110
|
}
|
|
78
|
-
const bytes =
|
|
111
|
+
const bytes = new TextEncoder().encode(serialized);
|
|
79
112
|
// An empty payload is still one chunk. Zero chunks would leave the receiver unable to tell a
|
|
80
113
|
// completed empty transfer from one that never started.
|
|
81
114
|
const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
|
|
82
115
|
const chunks: IHandoffChunk[] = [];
|
|
83
116
|
for (let index = 0; index < total; index += 1) {
|
|
84
117
|
const slice = bytes.subarray(index * maxChunkBytes, (index + 1) * maxChunkBytes);
|
|
85
|
-
chunks.push({ handoffId, index, total, data: slice
|
|
118
|
+
chunks.push({ handoffId, index, total, data: toBase64(slice) });
|
|
86
119
|
}
|
|
87
120
|
return chunks;
|
|
88
121
|
}
|
|
@@ -127,7 +160,7 @@ export interface IChunkResult {
|
|
|
127
160
|
* would make this a dispatcher as well as a buffer.
|
|
128
161
|
*/
|
|
129
162
|
export class HandoffChunkAssembler {
|
|
130
|
-
private readonly chunks = new Map<number,
|
|
163
|
+
private readonly chunks = new Map<number, Uint8Array>();
|
|
131
164
|
private declaredTotal: number | undefined;
|
|
132
165
|
|
|
133
166
|
constructor(private readonly handoffId: string) {}
|
|
@@ -161,19 +194,19 @@ export class HandoffChunkAssembler {
|
|
|
161
194
|
return refuse('out-of-range');
|
|
162
195
|
}
|
|
163
196
|
|
|
164
|
-
const decoded =
|
|
165
|
-
//
|
|
166
|
-
// chunk
|
|
167
|
-
// digest would catch, after the whole transfer completed. Re-encoding is how the leniency
|
|
168
|
-
// detected here instead.
|
|
169
|
-
if (decoded
|
|
197
|
+
const decoded = fromBase64(chunk.data);
|
|
198
|
+
// Base64 decoding is lenient — it skips whitespace and accepts non-canonical padding — so a
|
|
199
|
+
// mangled chunk could silently become a different one and corrupt the payload in a way only the
|
|
200
|
+
// final digest would catch, after the whole transfer completed. Re-encoding is how the leniency
|
|
201
|
+
// is detected here instead.
|
|
202
|
+
if (decoded === undefined || toBase64(decoded) !== chunk.data) return refuse('undecodable');
|
|
170
203
|
|
|
171
204
|
this.declaredTotal = chunk.total;
|
|
172
205
|
const held = this.chunks.get(chunk.index);
|
|
173
206
|
if (held !== undefined) {
|
|
174
207
|
// A retry re-sending the same bytes is normal. Different bytes for the same index is the
|
|
175
208
|
// inconsistent case again, and the same reasoning applies.
|
|
176
|
-
if (held
|
|
209
|
+
if (sameBytes(held, decoded)) {
|
|
177
210
|
return { outcome: 'duplicate', received: this.chunks.size, expected: chunk.total };
|
|
178
211
|
}
|
|
179
212
|
return refuse('inconsistent-total');
|
|
@@ -206,7 +239,7 @@ export class HandoffChunkAssembler {
|
|
|
206
239
|
}
|
|
207
240
|
|
|
208
241
|
private reassemble(total: number): string {
|
|
209
|
-
const ordered:
|
|
242
|
+
const ordered: Uint8Array[] = [];
|
|
210
243
|
for (let i = 0; i < total; i += 1) {
|
|
211
244
|
const part = this.chunks.get(i);
|
|
212
245
|
if (part === undefined) {
|
|
@@ -217,7 +250,14 @@ export class HandoffChunkAssembler {
|
|
|
217
250
|
}
|
|
218
251
|
ordered.push(part);
|
|
219
252
|
}
|
|
220
|
-
|
|
253
|
+
const joined = new Uint8Array(ordered.reduce((size, part) => size + part.length, 0));
|
|
254
|
+
let offset = 0;
|
|
255
|
+
for (const part of ordered) {
|
|
256
|
+
joined.set(part, offset);
|
|
257
|
+
offset += part.length;
|
|
258
|
+
}
|
|
259
|
+
// Keep a leading byte-order mark: it is part of the payload, as it was with `Buffer`.
|
|
260
|
+
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(joined);
|
|
221
261
|
}
|
|
222
262
|
}
|
|
223
263
|
|