Skip to content

Commit b5cf42b

Browse files
committed
feat: migrate from @sinclair/typebox 0.34.x to typebox 1.0
1 parent 3870a42 commit b5cf42b

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+863
-160
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Before proceeding, ensure you have TypeScript 5 installed and configured appropr
4747
To use River, install the required packages using npm:
4848

4949
```bash
50-
npm i @replit/river @sinclair/typebox
50+
npm i @replit/river typebox
5151
```
5252

5353
## Writing services
@@ -72,7 +72,7 @@ First, we create a service:
7272

7373
```ts
7474
import { createServiceSchema, Procedure, Ok } from '@replit/river';
75-
import { Type } from '@sinclair/typebox';
75+
import { Type } from 'typebox';
7676

7777
const ServiceSchema = createServiceSchema();
7878
export const ExampleService = ServiceSchema.define(
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
/**
2+
* Backwards compatibility tests for codec message adapters.
3+
*
4+
* These tests verify that messages encoded with the legacy TypeBox (0.34.x)
5+
* can be decoded and validated by the new TypeBox (1.0) CodecMessageAdapter,
6+
* and vice versa. This ensures that during a rolling upgrade, servers/clients
7+
* using different river versions can communicate.
8+
*/
9+
import { describe, test, expect } from 'vitest';
10+
import { Type as LegacyType } from 'legacyTypebox';
11+
import { Value as LegacyValue } from 'legacyTypebox/value';
12+
import { Type as NewType } from 'typebox';
13+
import { Value as NewValue } from 'typebox/value';
14+
import { NaiveJsonCodec, BinaryCodec, CodecMessageAdapter } from '../../codec';
15+
import {
16+
OpaqueTransportMessageSchema,
17+
type OpaqueTransportMessage,
18+
} from '../../transport/message';
19+
import { Uint8ArrayType } from '../../customSchemas';
20+
21+
/**
22+
* Helper: Build a complete OpaqueTransportMessage for testing.
23+
*/
24+
function makeTransportMessage(
25+
payload: unknown,
26+
overrides: Partial<OpaqueTransportMessage> = {},
27+
): OpaqueTransportMessage {
28+
return {
29+
id: 'msg-1',
30+
from: 'client-1',
31+
to: 'server-1',
32+
seq: 0,
33+
ack: 0,
34+
streamId: 'stream-1',
35+
controlFlags: 0,
36+
payload,
37+
...overrides,
38+
};
39+
}
40+
41+
/**
42+
* The legacy OpaqueTransportMessageSchema, reconstructed using legacy TypeBox.
43+
* This mirrors what the old river code would have used for validation.
44+
*/
45+
const LegacyOpaqueTransportMessageSchema = LegacyType.Object({
46+
id: LegacyType.String(),
47+
from: LegacyType.String(),
48+
to: LegacyType.String(),
49+
seq: LegacyType.Integer(),
50+
ack: LegacyType.Integer(),
51+
serviceName: LegacyType.Optional(LegacyType.String()),
52+
procedureName: LegacyType.Optional(LegacyType.String()),
53+
streamId: LegacyType.String(),
54+
controlFlags: LegacyType.Integer(),
55+
tracing: LegacyType.Optional(
56+
LegacyType.Object({
57+
traceparent: LegacyType.String(),
58+
tracestate: LegacyType.String(),
59+
}),
60+
),
61+
payload: LegacyType.Unknown(),
62+
});
63+
64+
describe.each([
65+
{ name: 'naive JSON codec', codec: NaiveJsonCodec },
66+
{ name: 'binary codec', codec: BinaryCodec },
67+
])('codec backwards compatibility ($name)', ({ codec }) => {
68+
const adapter = new CodecMessageAdapter(codec);
69+
70+
describe('basic message round-trip', () => {
71+
test('message with object payload survives encode/decode', () => {
72+
const msg = makeTransportMessage({ greeting: 'hello', count: 42 });
73+
const encoded = adapter.toBuffer(msg);
74+
expect(encoded.ok).toBe(true);
75+
if (!encoded.ok) return;
76+
77+
const decoded = adapter.fromBuffer(encoded.value);
78+
expect(decoded.ok).toBe(true);
79+
if (!decoded.ok) return;
80+
expect(decoded.value).toEqual(msg);
81+
});
82+
83+
test('message with nested object payload', () => {
84+
const msg = makeTransportMessage({
85+
ok: true,
86+
payload: { result: 42 },
87+
});
88+
const encoded = adapter.toBuffer(msg);
89+
expect(encoded.ok).toBe(true);
90+
if (!encoded.ok) return;
91+
92+
const decoded = adapter.fromBuffer(encoded.value);
93+
expect(decoded.ok).toBe(true);
94+
if (!decoded.ok) return;
95+
expect(decoded.value).toEqual(msg);
96+
});
97+
98+
test('message with error payload (Err result format)', () => {
99+
const msg = makeTransportMessage({
100+
ok: false,
101+
payload: {
102+
code: 'SOME_ERROR',
103+
message: 'something went wrong',
104+
extras: { detail: 'extra info' },
105+
},
106+
});
107+
const encoded = adapter.toBuffer(msg);
108+
expect(encoded.ok).toBe(true);
109+
if (!encoded.ok) return;
110+
111+
const decoded = adapter.fromBuffer(encoded.value);
112+
expect(decoded.ok).toBe(true);
113+
if (!decoded.ok) return;
114+
expect(decoded.value).toEqual(msg);
115+
});
116+
});
117+
118+
describe('Uint8Array payload handling', () => {
119+
test('message with Uint8Array in payload survives round-trip', () => {
120+
const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello"
121+
const msg = makeTransportMessage({
122+
ok: true,
123+
payload: { contents: bytes },
124+
});
125+
const encoded = adapter.toBuffer(msg);
126+
expect(encoded.ok).toBe(true);
127+
if (!encoded.ok) return;
128+
129+
const decoded = adapter.fromBuffer(encoded.value);
130+
expect(decoded.ok).toBe(true);
131+
if (!decoded.ok) return;
132+
133+
// The decoded Uint8Array should have the same bytes
134+
const decodedPayload = decoded.value.payload as {
135+
ok: boolean;
136+
payload: { contents: Uint8Array };
137+
};
138+
expect(decodedPayload.ok).toBe(true);
139+
expect(new Uint8Array(decodedPayload.payload.contents)).toEqual(bytes);
140+
});
141+
});
142+
143+
describe('new TypeBox 1.0 validation accepts messages from legacy codec', () => {
144+
test('encoded message passes new OpaqueTransportMessageSchema validation', () => {
145+
const msg = makeTransportMessage({ ok: true, payload: { result: 1 } });
146+
const encoded = adapter.toBuffer(msg);
147+
expect(encoded.ok).toBe(true);
148+
if (!encoded.ok) return;
149+
150+
const decoded = adapter.fromBuffer(encoded.value);
151+
expect(decoded.ok).toBe(true);
152+
if (!decoded.ok) return;
153+
154+
// Validate with new TypeBox
155+
expect(NewValue.Check(OpaqueTransportMessageSchema, decoded.value)).toBe(
156+
true,
157+
);
158+
});
159+
160+
test('encoded message also passes legacy schema validation', () => {
161+
const msg = makeTransportMessage({ ok: true, payload: { result: 1 } });
162+
const encoded = adapter.toBuffer(msg);
163+
expect(encoded.ok).toBe(true);
164+
if (!encoded.ok) return;
165+
166+
const decoded = adapter.fromBuffer(encoded.value);
167+
expect(decoded.ok).toBe(true);
168+
if (!decoded.ok) return;
169+
170+
// Validate with legacy TypeBox
171+
expect(
172+
LegacyValue.Check(LegacyOpaqueTransportMessageSchema, decoded.value),
173+
).toBe(true);
174+
});
175+
});
176+
177+
describe('cross-version validation of payloads', () => {
178+
test('object validated by legacy TypeBox is also valid under new TypeBox', () => {
179+
const legacySchema = LegacyType.Object({
180+
name: LegacyType.String(),
181+
age: LegacyType.Number(),
182+
});
183+
const newSchema = NewType.Object({
184+
name: NewType.String(),
185+
age: NewType.Number(),
186+
});
187+
188+
const data = { name: 'Alice', age: 30 };
189+
expect(LegacyValue.Check(legacySchema, data)).toBe(true);
190+
expect(NewValue.Check(newSchema, data)).toBe(true);
191+
});
192+
193+
test('union validated by legacy TypeBox is also valid under new TypeBox', () => {
194+
const legacySchema = LegacyType.Union([
195+
LegacyType.Object({
196+
code: LegacyType.Literal('ERR_A'),
197+
message: LegacyType.String(),
198+
}),
199+
LegacyType.Object({
200+
code: LegacyType.Literal('ERR_B'),
201+
message: LegacyType.String(),
202+
extras: LegacyType.Object({ detail: LegacyType.String() }),
203+
}),
204+
]);
205+
const newSchema = NewType.Union([
206+
NewType.Object({
207+
code: NewType.Literal('ERR_A'),
208+
message: NewType.String(),
209+
}),
210+
NewType.Object({
211+
code: NewType.Literal('ERR_B'),
212+
message: NewType.String(),
213+
extras: NewType.Object({ detail: NewType.String() }),
214+
}),
215+
]);
216+
217+
const data1 = { code: 'ERR_A', message: 'oops' };
218+
const data2 = {
219+
code: 'ERR_B',
220+
message: 'oops',
221+
extras: { detail: 'info' },
222+
};
223+
const invalidData = { code: 'ERR_C', message: 'unknown' };
224+
225+
expect(LegacyValue.Check(legacySchema, data1)).toBe(true);
226+
expect(NewValue.Check(newSchema, data1)).toBe(true);
227+
228+
expect(LegacyValue.Check(legacySchema, data2)).toBe(true);
229+
expect(NewValue.Check(newSchema, data2)).toBe(true);
230+
231+
expect(LegacyValue.Check(legacySchema, invalidData)).toBe(false);
232+
expect(NewValue.Check(newSchema, invalidData)).toBe(false);
233+
});
234+
235+
test('Uint8Array validated by legacy Type.Uint8Array matches new Uint8ArrayType', () => {
236+
const legacySchema = LegacyType.Uint8Array();
237+
const newSchema = Uint8ArrayType();
238+
239+
const validData = new Uint8Array([1, 2, 3]);
240+
expect(LegacyValue.Check(legacySchema, validData)).toBe(true);
241+
expect(NewValue.Check(newSchema, validData)).toBe(true);
242+
243+
// Both should reject non-Uint8Array values
244+
expect(LegacyValue.Check(legacySchema, [1, 2, 3])).toBe(false);
245+
expect(NewValue.Check(newSchema, [1, 2, 3])).toBe(false);
246+
247+
expect(LegacyValue.Check(legacySchema, 'not bytes')).toBe(false);
248+
expect(NewValue.Check(newSchema, 'not bytes')).toBe(false);
249+
});
250+
251+
test('Uint8ArrayType with byte length constraints', () => {
252+
const newSchema = Uint8ArrayType({ minByteLength: 2, maxByteLength: 5 });
253+
254+
expect(NewValue.Check(newSchema, new Uint8Array([1]))).toBe(false);
255+
expect(NewValue.Check(newSchema, new Uint8Array([1, 2]))).toBe(true);
256+
expect(NewValue.Check(newSchema, new Uint8Array([1, 2, 3, 4, 5]))).toBe(
257+
true,
258+
);
259+
expect(
260+
NewValue.Check(newSchema, new Uint8Array([1, 2, 3, 4, 5, 6])),
261+
).toBe(false);
262+
});
263+
});
264+
265+
describe('full transport message round-trip with validation', () => {
266+
test('encode with new TypeBox, validate with legacy', () => {
267+
const msg = makeTransportMessage(
268+
{ ok: true, payload: { name: 'test', value: 42 } },
269+
{
270+
serviceName: 'myService',
271+
procedureName: 'myProcedure',
272+
controlFlags: 1, // StreamOpenBit
273+
},
274+
);
275+
276+
const encoded = adapter.toBuffer(msg);
277+
expect(encoded.ok).toBe(true);
278+
if (!encoded.ok) return;
279+
280+
const decoded = adapter.fromBuffer(encoded.value);
281+
expect(decoded.ok).toBe(true);
282+
if (!decoded.ok) return;
283+
284+
// Both old and new schemas should accept the decoded message
285+
expect(
286+
LegacyValue.Check(LegacyOpaqueTransportMessageSchema, decoded.value),
287+
).toBe(true);
288+
expect(NewValue.Check(OpaqueTransportMessageSchema, decoded.value)).toBe(
289+
true,
290+
);
291+
});
292+
293+
test('handshake request message round-trip', () => {
294+
const msg = makeTransportMessage(
295+
{
296+
type: 'HANDSHAKE_REQ',
297+
protocolVersion: 'v2.0',
298+
sessionId: 'session-1',
299+
expectedSessionState: {
300+
nextExpectedSeq: 0,
301+
nextSentSeq: 0,
302+
},
303+
},
304+
{ controlFlags: 1 },
305+
);
306+
307+
const encoded = adapter.toBuffer(msg);
308+
expect(encoded.ok).toBe(true);
309+
if (!encoded.ok) return;
310+
311+
const decoded = adapter.fromBuffer(encoded.value);
312+
expect(decoded.ok).toBe(true);
313+
if (!decoded.ok) return;
314+
315+
expect(decoded.value).toEqual(msg);
316+
});
317+
318+
test('handshake response message round-trip', () => {
319+
const msg = makeTransportMessage(
320+
{
321+
type: 'HANDSHAKE_RESP',
322+
status: { ok: true, sessionId: 'session-123' },
323+
},
324+
{ controlFlags: 1 },
325+
);
326+
327+
const encoded = adapter.toBuffer(msg);
328+
expect(encoded.ok).toBe(true);
329+
if (!encoded.ok) return;
330+
331+
const decoded = adapter.fromBuffer(encoded.value);
332+
expect(decoded.ok).toBe(true);
333+
if (!decoded.ok) return;
334+
335+
expect(decoded.value).toEqual(msg);
336+
});
337+
});
338+
});

0 commit comments

Comments
 (0)