Skip to content

Commit 55c1c4a

Browse files
Copilotstreamich
andcommitted
feat: implement XDR encoder with schema validation and comprehensive tests
Co-authored-by: streamich <[email protected]>
1 parent c8249f8 commit 55c1c4a

File tree

7 files changed

+2545
-2
lines changed

7 files changed

+2545
-2
lines changed

src/xdr/XdrEncoder.ts

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import type {IWriter, IWriterGrowable} from '@jsonjoy.com/buffers/lib';
2+
import type {BinaryJsonEncoder} from '../types';
3+
4+
/**
5+
* XDR (External Data Representation) binary encoder for basic value encoding.
6+
* Implements XDR binary encoding according to RFC 4506.
7+
*
8+
* Key XDR encoding principles:
9+
* - All data types are aligned to 4-byte boundaries
10+
* - Multi-byte quantities are transmitted in big-endian byte order
11+
* - Strings and opaque data are padded to 4-byte boundaries
12+
* - Variable-length arrays and strings are preceded by their length
13+
*/
14+
export class XdrEncoder implements BinaryJsonEncoder {
15+
constructor(public readonly writer: IWriter & IWriterGrowable) {}
16+
17+
public encode(value: unknown): Uint8Array {
18+
const writer = this.writer;
19+
writer.reset();
20+
this.writeAny(value);
21+
return writer.flush();
22+
}
23+
24+
/**
25+
* Called when the encoder encounters a value that it does not know how to encode.
26+
*/
27+
public writeUnknown(value: unknown): void {
28+
this.writeVoid();
29+
}
30+
31+
public writeAny(value: unknown): void {
32+
switch (typeof value) {
33+
case 'boolean':
34+
return this.writeBoolean(value);
35+
case 'number':
36+
return this.writeNumber(value);
37+
case 'string':
38+
return this.writeStr(value);
39+
case 'object': {
40+
if (value === null) return this.writeVoid();
41+
const constructor = value.constructor;
42+
switch (constructor) {
43+
case Object:
44+
return this.writeObj(value as Record<string, unknown>);
45+
case Array:
46+
return this.writeArr(value as unknown[]);
47+
case Uint8Array:
48+
return this.writeBin(value as Uint8Array);
49+
default:
50+
return this.writeUnknown(value);
51+
}
52+
}
53+
case 'bigint':
54+
return this.writeHyper(value);
55+
case 'undefined':
56+
return this.writeVoid();
57+
default:
58+
return this.writeUnknown(value);
59+
}
60+
}
61+
62+
/**
63+
* Writes an XDR void value (no data is actually written).
64+
*/
65+
public writeVoid(): void {
66+
// Void values are encoded as no data
67+
}
68+
69+
/**
70+
* Writes an XDR null value (for interface compatibility).
71+
*/
72+
public writeNull(): void {
73+
this.writeVoid();
74+
}
75+
76+
/**
77+
* Writes an XDR boolean value as a 4-byte integer.
78+
*/
79+
public writeBoolean(bool: boolean): void {
80+
this.writeInt(bool ? 1 : 0);
81+
}
82+
83+
/**
84+
* Writes an XDR signed 32-bit integer in big-endian format.
85+
*/
86+
public writeInt(int: number): void {
87+
const writer = this.writer;
88+
writer.ensureCapacity(4);
89+
writer.view.setInt32(writer.x, Math.trunc(int), false); // big-endian
90+
writer.move(4);
91+
}
92+
93+
/**
94+
* Writes an XDR unsigned 32-bit integer in big-endian format.
95+
*/
96+
public writeUnsignedInt(uint: number): void {
97+
const writer = this.writer;
98+
writer.ensureCapacity(4);
99+
writer.view.setUint32(writer.x, Math.trunc(uint) >>> 0, false); // big-endian
100+
writer.move(4);
101+
}
102+
103+
/**
104+
* Writes an XDR signed 64-bit integer (hyper) in big-endian format.
105+
*/
106+
public writeHyper(hyper: number | bigint): void {
107+
const writer = this.writer;
108+
writer.ensureCapacity(8);
109+
110+
if (typeof hyper === 'bigint') {
111+
// Convert bigint to two 32-bit values for big-endian encoding
112+
const high = Number((hyper >> BigInt(32)) & BigInt(0xFFFFFFFF));
113+
const low = Number(hyper & BigInt(0xFFFFFFFF));
114+
writer.view.setInt32(writer.x, high, false); // high 32 bits
115+
writer.view.setUint32(writer.x + 4, low, false); // low 32 bits
116+
} else {
117+
const truncated = Math.trunc(hyper);
118+
const high = Math.floor(truncated / 0x100000000);
119+
const low = truncated >>> 0;
120+
writer.view.setInt32(writer.x, high, false); // high 32 bits
121+
writer.view.setUint32(writer.x + 4, low, false); // low 32 bits
122+
}
123+
writer.move(8);
124+
}
125+
126+
/**
127+
* Writes an XDR unsigned 64-bit integer (unsigned hyper) in big-endian format.
128+
*/
129+
public writeUnsignedHyper(uhyper: number | bigint): void {
130+
const writer = this.writer;
131+
writer.ensureCapacity(8);
132+
133+
if (typeof uhyper === 'bigint') {
134+
// Convert bigint to two 32-bit values for big-endian encoding
135+
const high = Number((uhyper >> BigInt(32)) & BigInt(0xFFFFFFFF));
136+
const low = Number(uhyper & BigInt(0xFFFFFFFF));
137+
writer.view.setUint32(writer.x, high, false); // high 32 bits
138+
writer.view.setUint32(writer.x + 4, low, false); // low 32 bits
139+
} else {
140+
const truncated = Math.trunc(Math.abs(uhyper));
141+
const high = Math.floor(truncated / 0x100000000);
142+
const low = truncated >>> 0;
143+
writer.view.setUint32(writer.x, high, false); // high 32 bits
144+
writer.view.setUint32(writer.x + 4, low, false); // low 32 bits
145+
}
146+
writer.move(8);
147+
}
148+
149+
/**
150+
* Writes an XDR float value using IEEE 754 single-precision in big-endian format.
151+
*/
152+
public writeFloat(float: number): void {
153+
const writer = this.writer;
154+
writer.ensureCapacity(4);
155+
writer.view.setFloat32(writer.x, float, false); // big-endian
156+
writer.move(4);
157+
}
158+
159+
/**
160+
* Writes an XDR double value using IEEE 754 double-precision in big-endian format.
161+
*/
162+
public writeDouble(double: number): void {
163+
const writer = this.writer;
164+
writer.ensureCapacity(8);
165+
writer.view.setFloat64(writer.x, double, false); // big-endian
166+
writer.move(8);
167+
}
168+
169+
/**
170+
* Writes an XDR quadruple value (128-bit float).
171+
* Note: JavaScript doesn't have native 128-bit float support, so this is a placeholder.
172+
*/
173+
public writeQuadruple(quad: number): void {
174+
// Write as two doubles for now (this is not standard XDR)
175+
this.writeDouble(quad);
176+
this.writeDouble(0); // padding
177+
}
178+
179+
/**
180+
* Writes XDR opaque data with fixed length.
181+
* Data is padded to 4-byte boundary.
182+
*/
183+
public writeOpaque(data: Uint8Array, size: number): void {
184+
if (data.length !== size) {
185+
throw new Error(`Opaque data length ${data.length} does not match expected size ${size}`);
186+
}
187+
188+
const writer = this.writer;
189+
const paddedSize = this.getPaddedSize(size);
190+
writer.ensureCapacity(paddedSize);
191+
192+
// Write data
193+
writer.buf(data, size);
194+
195+
// Write padding bytes
196+
const padding = paddedSize - size;
197+
for (let i = 0; i < padding; i++) {
198+
writer.u8(0);
199+
}
200+
}
201+
202+
/**
203+
* Writes XDR variable-length opaque data.
204+
* Length is written first, followed by data padded to 4-byte boundary.
205+
*/
206+
public writeVarlenOpaque(data: Uint8Array): void {
207+
this.writeUnsignedInt(data.length);
208+
209+
const writer = this.writer;
210+
const paddedSize = this.getPaddedSize(data.length);
211+
writer.ensureCapacity(paddedSize);
212+
213+
// Write data
214+
writer.buf(data, data.length);
215+
216+
// Write padding bytes
217+
const padding = paddedSize - data.length;
218+
for (let i = 0; i < padding; i++) {
219+
writer.u8(0);
220+
}
221+
}
222+
223+
/**
224+
* Writes an XDR string with UTF-8 encoding.
225+
* Length is written first, followed by UTF-8 bytes padded to 4-byte boundary.
226+
*/
227+
public writeStr(str: string): void {
228+
const writer = this.writer;
229+
const encoder = new TextEncoder();
230+
const utf8Bytes = encoder.encode(str);
231+
232+
// Write length
233+
this.writeUnsignedInt(utf8Bytes.length);
234+
235+
// Write string data with padding
236+
const paddedSize = this.getPaddedSize(utf8Bytes.length);
237+
writer.ensureCapacity(paddedSize);
238+
239+
// Write UTF-8 bytes
240+
writer.buf(utf8Bytes, utf8Bytes.length);
241+
242+
// Write padding bytes
243+
const padding = paddedSize - utf8Bytes.length;
244+
for (let i = 0; i < padding; i++) {
245+
writer.u8(0);
246+
}
247+
}
248+
249+
/**
250+
* Writes XDR variable-length array.
251+
* Length is written first, followed by array elements.
252+
*/
253+
public writeArr(arr: unknown[]): void {
254+
this.writeUnsignedInt(arr.length);
255+
for (const item of arr) {
256+
this.writeAny(item);
257+
}
258+
}
259+
260+
/**
261+
* Writes XDR structure as a simple mapping (not standard XDR, for compatibility).
262+
*/
263+
public writeObj(obj: Record<string, unknown>): void {
264+
const entries = Object.entries(obj);
265+
this.writeUnsignedInt(entries.length);
266+
for (const [key, value] of entries) {
267+
this.writeStr(key);
268+
this.writeAny(value);
269+
}
270+
}
271+
272+
// BinaryJsonEncoder interface methods
273+
274+
/**
275+
* Generic number writing - determines type based on value
276+
*/
277+
public writeNumber(num: number): void {
278+
if (Number.isInteger(num)) {
279+
if (num >= -2147483648 && num <= 2147483647) {
280+
this.writeInt(num);
281+
} else {
282+
this.writeHyper(num);
283+
}
284+
} else {
285+
this.writeDouble(num);
286+
}
287+
}
288+
289+
/**
290+
* Writes an integer value
291+
*/
292+
public writeInteger(int: number): void {
293+
this.writeInt(int);
294+
}
295+
296+
/**
297+
* Writes an unsigned integer value
298+
*/
299+
public writeUInteger(uint: number): void {
300+
this.writeUnsignedInt(uint);
301+
}
302+
303+
/**
304+
* Writes binary data
305+
*/
306+
public writeBin(buf: Uint8Array): void {
307+
this.writeVarlenOpaque(buf);
308+
}
309+
310+
/**
311+
* Writes an ASCII string (same as regular string in XDR)
312+
*/
313+
public writeAsciiStr(str: string): void {
314+
this.writeStr(str);
315+
}
316+
317+
/**
318+
* Calculates the padded size for 4-byte alignment.
319+
*/
320+
private getPaddedSize(size: number): number {
321+
return Math.ceil(size / 4) * 4;
322+
}
323+
}

0 commit comments

Comments
 (0)