@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-transport",
3
- "version": "3.0.0-beta.84",
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-core": "3.0.0-beta.84",
93
- "@robota-sdk/agent-interface-command": "3.0.0-beta.84",
94
- "@robota-sdk/agent-interface-analytics": "3.0.0-beta.84",
95
- "@robota-sdk/agent-interface-execution": "3.0.0-beta.84",
96
- "@robota-sdk/agent-interface-session": "3.0.0-beta.84",
97
- "@robota-sdk/agent-interface-session-mobility": "3.0.0-beta.84",
98
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.84"
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
+ });
@@ -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 = Buffer.from(serialized, 'utf8');
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.toString('base64') });
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, Buffer>();
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 = 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');
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.equals(decoded)) {
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: Buffer[] = [];
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
- return Buffer.concat(ordered).toString('utf8');
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