From b5cf42b554f80a721bb7c37f41016b981aa4de4b Mon Sep 17 00:00:00 2001 From: masad-frost Date: Thu, 12 Mar 2026 17:19:39 -0700 Subject: [PATCH 1/3] feat: migrate from @sinclair/typebox 0.34.x to typebox 1.0 --- README.md | 4 +- __tests__/backwardsCompat/codec.test.ts | 338 ++++++++++++++++++ .../schemaSerialization.test.ts | 264 ++++++++++++++ __tests__/cancellation.test.ts | 2 +- __tests__/cleanup.test.ts | 2 +- __tests__/context.test.ts | 2 +- __tests__/deferCleanup.test.ts | 2 +- __tests__/e2e.test.ts | 2 +- __tests__/invalid-request.test.ts | 65 ++-- __tests__/middleware.test.ts | 2 +- __tests__/negative.test.ts | 2 +- __tests__/serialize.test.ts | 2 +- __tests__/typescript-stress.test.ts | 2 +- __tests__/unserializable.test.ts | 2 +- codec/adapter.ts | 2 +- customSchemas/index.ts | 80 +++++ package-lock.json | 42 ++- package.json | 9 +- router/client.ts | 15 +- router/context.ts | 2 +- router/errors.ts | 41 +-- router/handshake.ts | 2 +- router/procedures.ts | 28 +- router/result.ts | 2 +- router/server.ts | 10 +- router/services.ts | 7 +- router/streams.ts | 2 +- testUtil/fixtures/cleanup.ts | 2 +- testUtil/fixtures/mockTransport.ts | 2 +- testUtil/fixtures/services.ts | 37 +- testUtil/fixtures/transports.ts | 2 +- testUtil/index.ts | 2 +- tracing/index.ts | 2 +- transport/client.ts | 9 +- transport/events.ts | 2 +- transport/impls/ws/server.ts | 2 +- transport/message.ts | 2 +- transport/server.ts | 17 +- .../sessionStateMachine/SessionConnected.ts | 2 +- .../sessionStateMachine/SessionHandshaking.ts | 2 +- .../SessionWaitingForHandshake.ts | 2 +- .../sessionStateMachine/stateMachine.test.ts | 2 +- transport/transport.test.ts | 2 +- tsup.config.ts | 1 + 44 files changed, 863 insertions(+), 160 deletions(-) create mode 100644 __tests__/backwardsCompat/codec.test.ts create mode 100644 __tests__/backwardsCompat/schemaSerialization.test.ts create mode 100644 customSchemas/index.ts diff --git a/README.md b/README.md index c59e18d9..741152f3 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ Before proceeding, ensure you have TypeScript 5 installed and configured appropr To use River, install the required packages using npm: ```bash - npm i @replit/river @sinclair/typebox + npm i @replit/river typebox ``` ## Writing services @@ -72,7 +72,7 @@ First, we create a service: ```ts import { createServiceSchema, Procedure, Ok } from '@replit/river'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; const ServiceSchema = createServiceSchema(); export const ExampleService = ServiceSchema.define( diff --git a/__tests__/backwardsCompat/codec.test.ts b/__tests__/backwardsCompat/codec.test.ts new file mode 100644 index 00000000..3ec80d19 --- /dev/null +++ b/__tests__/backwardsCompat/codec.test.ts @@ -0,0 +1,338 @@ +/** + * Backwards compatibility tests for codec message adapters. + * + * These tests verify that messages encoded with the legacy TypeBox (0.34.x) + * can be decoded and validated by the new TypeBox (1.0) CodecMessageAdapter, + * and vice versa. This ensures that during a rolling upgrade, servers/clients + * using different river versions can communicate. + */ +import { describe, test, expect } from 'vitest'; +import { Type as LegacyType } from 'legacyTypebox'; +import { Value as LegacyValue } from 'legacyTypebox/value'; +import { Type as NewType } from 'typebox'; +import { Value as NewValue } from 'typebox/value'; +import { NaiveJsonCodec, BinaryCodec, CodecMessageAdapter } from '../../codec'; +import { + OpaqueTransportMessageSchema, + type OpaqueTransportMessage, +} from '../../transport/message'; +import { Uint8ArrayType } from '../../customSchemas'; + +/** + * Helper: Build a complete OpaqueTransportMessage for testing. + */ +function makeTransportMessage( + payload: unknown, + overrides: Partial = {}, +): OpaqueTransportMessage { + return { + id: 'msg-1', + from: 'client-1', + to: 'server-1', + seq: 0, + ack: 0, + streamId: 'stream-1', + controlFlags: 0, + payload, + ...overrides, + }; +} + +/** + * The legacy OpaqueTransportMessageSchema, reconstructed using legacy TypeBox. + * This mirrors what the old river code would have used for validation. + */ +const LegacyOpaqueTransportMessageSchema = LegacyType.Object({ + id: LegacyType.String(), + from: LegacyType.String(), + to: LegacyType.String(), + seq: LegacyType.Integer(), + ack: LegacyType.Integer(), + serviceName: LegacyType.Optional(LegacyType.String()), + procedureName: LegacyType.Optional(LegacyType.String()), + streamId: LegacyType.String(), + controlFlags: LegacyType.Integer(), + tracing: LegacyType.Optional( + LegacyType.Object({ + traceparent: LegacyType.String(), + tracestate: LegacyType.String(), + }), + ), + payload: LegacyType.Unknown(), +}); + +describe.each([ + { name: 'naive JSON codec', codec: NaiveJsonCodec }, + { name: 'binary codec', codec: BinaryCodec }, +])('codec backwards compatibility ($name)', ({ codec }) => { + const adapter = new CodecMessageAdapter(codec); + + describe('basic message round-trip', () => { + test('message with object payload survives encode/decode', () => { + const msg = makeTransportMessage({ greeting: 'hello', count: 42 }); + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value).toEqual(msg); + }); + + test('message with nested object payload', () => { + const msg = makeTransportMessage({ + ok: true, + payload: { result: 42 }, + }); + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value).toEqual(msg); + }); + + test('message with error payload (Err result format)', () => { + const msg = makeTransportMessage({ + ok: false, + payload: { + code: 'SOME_ERROR', + message: 'something went wrong', + extras: { detail: 'extra info' }, + }, + }); + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + expect(decoded.value).toEqual(msg); + }); + }); + + describe('Uint8Array payload handling', () => { + test('message with Uint8Array in payload survives round-trip', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); // "Hello" + const msg = makeTransportMessage({ + ok: true, + payload: { contents: bytes }, + }); + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + + // The decoded Uint8Array should have the same bytes + const decodedPayload = decoded.value.payload as { + ok: boolean; + payload: { contents: Uint8Array }; + }; + expect(decodedPayload.ok).toBe(true); + expect(new Uint8Array(decodedPayload.payload.contents)).toEqual(bytes); + }); + }); + + describe('new TypeBox 1.0 validation accepts messages from legacy codec', () => { + test('encoded message passes new OpaqueTransportMessageSchema validation', () => { + const msg = makeTransportMessage({ ok: true, payload: { result: 1 } }); + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + + // Validate with new TypeBox + expect(NewValue.Check(OpaqueTransportMessageSchema, decoded.value)).toBe( + true, + ); + }); + + test('encoded message also passes legacy schema validation', () => { + const msg = makeTransportMessage({ ok: true, payload: { result: 1 } }); + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + + // Validate with legacy TypeBox + expect( + LegacyValue.Check(LegacyOpaqueTransportMessageSchema, decoded.value), + ).toBe(true); + }); + }); + + describe('cross-version validation of payloads', () => { + test('object validated by legacy TypeBox is also valid under new TypeBox', () => { + const legacySchema = LegacyType.Object({ + name: LegacyType.String(), + age: LegacyType.Number(), + }); + const newSchema = NewType.Object({ + name: NewType.String(), + age: NewType.Number(), + }); + + const data = { name: 'Alice', age: 30 }; + expect(LegacyValue.Check(legacySchema, data)).toBe(true); + expect(NewValue.Check(newSchema, data)).toBe(true); + }); + + test('union validated by legacy TypeBox is also valid under new TypeBox', () => { + const legacySchema = LegacyType.Union([ + LegacyType.Object({ + code: LegacyType.Literal('ERR_A'), + message: LegacyType.String(), + }), + LegacyType.Object({ + code: LegacyType.Literal('ERR_B'), + message: LegacyType.String(), + extras: LegacyType.Object({ detail: LegacyType.String() }), + }), + ]); + const newSchema = NewType.Union([ + NewType.Object({ + code: NewType.Literal('ERR_A'), + message: NewType.String(), + }), + NewType.Object({ + code: NewType.Literal('ERR_B'), + message: NewType.String(), + extras: NewType.Object({ detail: NewType.String() }), + }), + ]); + + const data1 = { code: 'ERR_A', message: 'oops' }; + const data2 = { + code: 'ERR_B', + message: 'oops', + extras: { detail: 'info' }, + }; + const invalidData = { code: 'ERR_C', message: 'unknown' }; + + expect(LegacyValue.Check(legacySchema, data1)).toBe(true); + expect(NewValue.Check(newSchema, data1)).toBe(true); + + expect(LegacyValue.Check(legacySchema, data2)).toBe(true); + expect(NewValue.Check(newSchema, data2)).toBe(true); + + expect(LegacyValue.Check(legacySchema, invalidData)).toBe(false); + expect(NewValue.Check(newSchema, invalidData)).toBe(false); + }); + + test('Uint8Array validated by legacy Type.Uint8Array matches new Uint8ArrayType', () => { + const legacySchema = LegacyType.Uint8Array(); + const newSchema = Uint8ArrayType(); + + const validData = new Uint8Array([1, 2, 3]); + expect(LegacyValue.Check(legacySchema, validData)).toBe(true); + expect(NewValue.Check(newSchema, validData)).toBe(true); + + // Both should reject non-Uint8Array values + expect(LegacyValue.Check(legacySchema, [1, 2, 3])).toBe(false); + expect(NewValue.Check(newSchema, [1, 2, 3])).toBe(false); + + expect(LegacyValue.Check(legacySchema, 'not bytes')).toBe(false); + expect(NewValue.Check(newSchema, 'not bytes')).toBe(false); + }); + + test('Uint8ArrayType with byte length constraints', () => { + const newSchema = Uint8ArrayType({ minByteLength: 2, maxByteLength: 5 }); + + expect(NewValue.Check(newSchema, new Uint8Array([1]))).toBe(false); + expect(NewValue.Check(newSchema, new Uint8Array([1, 2]))).toBe(true); + expect(NewValue.Check(newSchema, new Uint8Array([1, 2, 3, 4, 5]))).toBe( + true, + ); + expect( + NewValue.Check(newSchema, new Uint8Array([1, 2, 3, 4, 5, 6])), + ).toBe(false); + }); + }); + + describe('full transport message round-trip with validation', () => { + test('encode with new TypeBox, validate with legacy', () => { + const msg = makeTransportMessage( + { ok: true, payload: { name: 'test', value: 42 } }, + { + serviceName: 'myService', + procedureName: 'myProcedure', + controlFlags: 1, // StreamOpenBit + }, + ); + + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + + // Both old and new schemas should accept the decoded message + expect( + LegacyValue.Check(LegacyOpaqueTransportMessageSchema, decoded.value), + ).toBe(true); + expect(NewValue.Check(OpaqueTransportMessageSchema, decoded.value)).toBe( + true, + ); + }); + + test('handshake request message round-trip', () => { + const msg = makeTransportMessage( + { + type: 'HANDSHAKE_REQ', + protocolVersion: 'v2.0', + sessionId: 'session-1', + expectedSessionState: { + nextExpectedSeq: 0, + nextSentSeq: 0, + }, + }, + { controlFlags: 1 }, + ); + + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + + expect(decoded.value).toEqual(msg); + }); + + test('handshake response message round-trip', () => { + const msg = makeTransportMessage( + { + type: 'HANDSHAKE_RESP', + status: { ok: true, sessionId: 'session-123' }, + }, + { controlFlags: 1 }, + ); + + const encoded = adapter.toBuffer(msg); + expect(encoded.ok).toBe(true); + if (!encoded.ok) return; + + const decoded = adapter.fromBuffer(encoded.value); + expect(decoded.ok).toBe(true); + if (!decoded.ok) return; + + expect(decoded.value).toEqual(msg); + }); + }); +}); diff --git a/__tests__/backwardsCompat/schemaSerialization.test.ts b/__tests__/backwardsCompat/schemaSerialization.test.ts new file mode 100644 index 00000000..3763ee21 --- /dev/null +++ b/__tests__/backwardsCompat/schemaSerialization.test.ts @@ -0,0 +1,264 @@ +/** + * Backwards compatibility tests for schema serialization. + * + * These tests verify that schemas defined with typebox 1.0 serialize to the + * same JSON Schema output as schemas defined with @sinclair/typebox 0.34.x. + * This is critical because serialized schemas are shared across the wire + * between clients and servers that may be running different versions of river. + */ +import { describe, test, expect } from 'vitest'; +import { Type as LegacyType } from 'legacyTypebox'; +import { Type as NewType } from 'typebox'; +import { Uint8ArrayType } from '../../customSchemas'; + +/** + * Strips internal TypeBox symbols by JSON roundtripping, matching what + * river's `Strict()` function does during serialization. + */ +function strip(schema: object): unknown { + return JSON.parse(JSON.stringify(schema)); +} + +describe('schema serialization backwards compatibility', () => { + describe('primitive types', () => { + test('Type.String()', () => { + expect(strip(NewType.String())).toEqual(strip(LegacyType.String())); + }); + + test('Type.Number()', () => { + expect(strip(NewType.Number())).toEqual(strip(LegacyType.Number())); + }); + + test('Type.Integer()', () => { + expect(strip(NewType.Integer())).toEqual(strip(LegacyType.Integer())); + }); + + test('Type.Boolean()', () => { + expect(strip(NewType.Boolean())).toEqual(strip(LegacyType.Boolean())); + }); + + test('Type.Null()', () => { + expect(strip(NewType.Null())).toEqual(strip(LegacyType.Null())); + }); + + test('Type.Unknown()', () => { + expect(strip(NewType.Unknown())).toEqual(strip(LegacyType.Unknown())); + }); + }); + + describe('literal types', () => { + test('Type.Literal(string)', () => { + expect(strip(NewType.Literal('hello'))).toEqual( + strip(LegacyType.Literal('hello')), + ); + }); + + test('Type.Literal(number)', () => { + expect(strip(NewType.Literal(42))).toEqual(strip(LegacyType.Literal(42))); + }); + + test('Type.Literal(boolean)', () => { + expect(strip(NewType.Literal(true))).toEqual( + strip(LegacyType.Literal(true)), + ); + }); + }); + + describe('composite types', () => { + test('Type.Object with required properties', () => { + const legacy = LegacyType.Object({ + name: LegacyType.String(), + age: LegacyType.Number(), + }); + const current = NewType.Object({ + name: NewType.String(), + age: NewType.Number(), + }); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Type.Object with optional properties', () => { + const legacy = LegacyType.Object({ + name: LegacyType.String(), + nickname: LegacyType.Optional(LegacyType.String()), + }); + const current = NewType.Object({ + name: NewType.String(), + nickname: NewType.Optional(NewType.String()), + }); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Type.Object with description', () => { + const legacy = LegacyType.Object( + { a: LegacyType.Number() }, + { description: 'test object' }, + ); + const current = NewType.Object( + { a: NewType.Number() }, + { description: 'test object' }, + ); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Type.Array of primitives', () => { + const legacy = LegacyType.Array(LegacyType.String()); + const current = NewType.Array(NewType.String()); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Type.Array of objects', () => { + const legacy = LegacyType.Array( + LegacyType.Object({ id: LegacyType.Number() }), + ); + const current = NewType.Array(NewType.Object({ id: NewType.Number() })); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Type.Union of objects', () => { + const legacy = LegacyType.Union([ + LegacyType.Object({ code: LegacyType.Literal('A') }), + LegacyType.Object({ code: LegacyType.Literal('B') }), + ]); + const current = NewType.Union([ + NewType.Object({ code: NewType.Literal('A') }), + NewType.Object({ code: NewType.Literal('B') }), + ]); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Type.Union of literals', () => { + const legacy = LegacyType.Union([ + LegacyType.Literal('a'), + LegacyType.Literal('b'), + LegacyType.Literal('c'), + ]); + const current = NewType.Union([ + NewType.Literal('a'), + NewType.Literal('b'), + NewType.Literal('c'), + ]); + expect(strip(current)).toEqual(strip(legacy)); + }); + }); + + describe('Uint8Array custom type', () => { + test('Uint8ArrayType() matches legacy Type.Uint8Array() serialization', () => { + const legacy = LegacyType.Uint8Array(); + const current = Uint8ArrayType(); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Uint8ArrayType with minByteLength matches legacy', () => { + const legacy = LegacyType.Uint8Array({ minByteLength: 1 }); + const current = Uint8ArrayType({ minByteLength: 1 }); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Uint8ArrayType with maxByteLength matches legacy', () => { + const legacy = LegacyType.Uint8Array({ maxByteLength: 1024 }); + const current = Uint8ArrayType({ maxByteLength: 1024 }); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('Uint8ArrayType with both constraints matches legacy', () => { + const legacy = LegacyType.Uint8Array({ + minByteLength: 1, + maxByteLength: 1024, + }); + const current = Uint8ArrayType({ + minByteLength: 1, + maxByteLength: 1024, + }); + expect(strip(current)).toEqual(strip(legacy)); + }); + }); + + describe('river-specific schema patterns', () => { + test('transport message schema shape', () => { + const legacy = LegacyType.Object({ + id: LegacyType.String(), + from: LegacyType.String(), + to: LegacyType.String(), + seq: LegacyType.Integer(), + ack: LegacyType.Integer(), + serviceName: LegacyType.Optional(LegacyType.String()), + procedureName: LegacyType.Optional(LegacyType.String()), + streamId: LegacyType.String(), + controlFlags: LegacyType.Integer(), + payload: LegacyType.Unknown(), + }); + const current = NewType.Object({ + id: NewType.String(), + from: NewType.String(), + to: NewType.String(), + seq: NewType.Integer(), + ack: NewType.Integer(), + serviceName: NewType.Optional(NewType.String()), + procedureName: NewType.Optional(NewType.String()), + streamId: NewType.String(), + controlFlags: NewType.Integer(), + payload: NewType.Unknown(), + }); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('result schema shape (Ok/Err)', () => { + const legacy = LegacyType.Union([ + LegacyType.Object({ + ok: LegacyType.Literal(false), + payload: LegacyType.Object({ + code: LegacyType.String(), + message: LegacyType.String(), + extras: LegacyType.Optional(LegacyType.Unknown()), + }), + }), + LegacyType.Object({ + ok: LegacyType.Literal(true), + payload: LegacyType.Unknown(), + }), + ]); + const current = NewType.Union([ + NewType.Object({ + ok: NewType.Literal(false), + payload: NewType.Object({ + code: NewType.String(), + message: NewType.String(), + extras: NewType.Optional(NewType.Unknown()), + }), + }), + NewType.Object({ + ok: NewType.Literal(true), + payload: NewType.Unknown(), + }), + ]); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('error schema with code literal and extras', () => { + const legacy = LegacyType.Object({ + code: LegacyType.Literal('SOME_ERROR'), + message: LegacyType.String(), + extras: LegacyType.Object({ detail: LegacyType.String() }), + }); + const current = NewType.Object({ + code: NewType.Literal('SOME_ERROR'), + message: NewType.String(), + extras: NewType.Object({ detail: NewType.String() }), + }); + expect(strip(current)).toEqual(strip(legacy)); + }); + + test('service schema with Uint8Array field', () => { + const legacy = LegacyType.Object({ + file: LegacyType.String(), + contents: LegacyType.Uint8Array(), + }); + const current = NewType.Object({ + file: NewType.String(), + contents: Uint8ArrayType(), + }); + expect(strip(current)).toEqual(strip(legacy)); + }); + }); +}); diff --git a/__tests__/cancellation.test.ts b/__tests__/cancellation.test.ts index d1957ff7..743c10ea 100644 --- a/__tests__/cancellation.test.ts +++ b/__tests__/cancellation.test.ts @@ -1,4 +1,4 @@ -import { TNever, TObject, Type } from '@sinclair/typebox'; +import { TNever, TObject, Type } from 'typebox'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { Err, diff --git a/__tests__/cleanup.test.ts b/__tests__/cleanup.test.ts index 497a6854..e04186d2 100644 --- a/__tests__/cleanup.test.ts +++ b/__tests__/cleanup.test.ts @@ -30,7 +30,7 @@ import { import { testMatrix } from '../testUtil/fixtures/matrix'; import { TestSetupHelpers } from '../testUtil/fixtures/transports'; import { ControlFlags } from '../transport/message'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { nanoid } from 'nanoid'; describe.each(testMatrix())( diff --git a/__tests__/context.test.ts b/__tests__/context.test.ts index 3d51dd6a..d939a23f 100644 --- a/__tests__/context.test.ts +++ b/__tests__/context.test.ts @@ -6,7 +6,7 @@ import { import { testMatrix } from '../testUtil/fixtures/matrix'; import { TestSetupHelpers } from '../testUtil/fixtures/transports'; import { Ok, Procedure, createClient, createServer } from '../router'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { createServiceSchema } from '../router/services'; describe('should handle incompatabilities', async () => { diff --git a/__tests__/deferCleanup.test.ts b/__tests__/deferCleanup.test.ts index 60371bbb..3b4952d9 100644 --- a/__tests__/deferCleanup.test.ts +++ b/__tests__/deferCleanup.test.ts @@ -7,7 +7,7 @@ import { test, vi, } from 'vitest'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { createClient, createServer, diff --git a/__tests__/e2e.test.ts b/__tests__/e2e.test.ts index 1071f857..abf82340 100644 --- a/__tests__/e2e.test.ts +++ b/__tests__/e2e.test.ts @@ -29,7 +29,7 @@ import { waitFor, } from '../testUtil/fixtures/cleanup'; import { testMatrix } from '../testUtil/fixtures/matrix'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { Procedure, createServiceSchema, diff --git a/__tests__/invalid-request.test.ts b/__tests__/invalid-request.test.ts index cc007759..0debbf1d 100644 --- a/__tests__/invalid-request.test.ts +++ b/__tests__/invalid-request.test.ts @@ -1,4 +1,4 @@ -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { Err, @@ -354,7 +354,10 @@ describe('cancels invalid request', () => { service: ServiceSchema.define({ stream: Procedure.stream({ requestInit: Type.Object({}), - requestData: Type.Object({ mustSendThings: Type.String() }), + requestData: Type.Object({ + mustSendThings: Type.String(), + shouldBeNumber: Type.Number(), + }), responseData: Type.Object({}), handler: async () => undefined, }), @@ -376,7 +379,9 @@ describe('cancels invalid request', () => { clientSendFn({ streamId, - payload: {}, + payload: { + shouldBeNumber: '1', + }, controlFlags: 0, }); @@ -396,14 +401,16 @@ describe('cancels invalid request', () => { message: 'message in requestData position did not match schema', extras: { totalErrors: 2, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - firstValidationErrors: expect.arrayContaining([ + firstValidationErrors: [ { - path: '/mustSendThings', - message: 'Expected required property', + path: '#', + message: 'must have required properties mustSendThings', }, - { path: '/mustSendThings', message: 'Expected string' }, - ]), + { + message: 'must be number', + path: '#/properties/shouldBeNumber', + }, + ], }, }), }), @@ -463,11 +470,27 @@ describe('cancels invalid request', () => { code: INVALID_REQUEST_CODE, message: 'message in control payload position did not match schema', extras: { - totalErrors: 1, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - firstValidationErrors: expect.arrayContaining([ - { path: '', message: 'Expected union value' }, - ]), + totalErrors: 5, + firstValidationErrors: [ + { + path: '#/anyOf/0', + message: 'must have required properties type', + }, + { + path: '#/anyOf/1', + message: 'must have required properties type', + }, + { + path: '#/anyOf/2', + message: + 'must have required properties type, protocolVersion, sessionId, expectedSessionState', + }, + { + path: '#/anyOf/3', + message: 'must have required properties type, status', + }, + { path: '#', message: 'must match a schema in anyOf' }, + ], }, }), }), @@ -583,7 +606,7 @@ describe('cancels invalid request', () => { // @ts-expect-error monkey-patched incompatible change :D services.service.procedures.stream.requestData = Type.Object({ - newRequiredField: Type.String(), + newRequiredField: Type.Object({ a: Type.String() }), }); const { reqWritable, resReadable } = client.service.stream.stream({}); @@ -597,15 +620,13 @@ describe('cancels invalid request', () => { 'message in requestData position did not match schema', ), extras: { - totalErrors: 2, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - firstValidationErrors: expect.arrayContaining([ + totalErrors: 1, + firstValidationErrors: [ { - path: '/newRequiredField', - message: 'Expected required property', + path: '#', + message: 'must have required properties newRequiredField', }, - { path: '/newRequiredField', message: 'Expected string' }, - ]), + ], }, }), ]); diff --git a/__tests__/middleware.test.ts b/__tests__/middleware.test.ts index 37334dab..9dba9b3d 100644 --- a/__tests__/middleware.test.ts +++ b/__tests__/middleware.test.ts @@ -16,7 +16,7 @@ import { Middleware, } from '../router'; import { createMockTransportNetwork } from '../testUtil/fixtures/mockTransport'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; describe('middleware test', () => { let mockTransportNetwork: ReturnType; diff --git a/__tests__/negative.test.ts b/__tests__/negative.test.ts index 50c3accb..c7d2acd2 100644 --- a/__tests__/negative.test.ts +++ b/__tests__/negative.test.ts @@ -20,7 +20,7 @@ import { handshakeRequestMessage, } from '../transport/message'; import { NaiveJsonCodec } from '../codec'; -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { WebSocketClientTransport } from '../transport/impls/ws/client'; import { ProtocolError } from '../transport/events'; import NodeWs from 'ws'; diff --git a/__tests__/serialize.test.ts b/__tests__/serialize.test.ts index 593b720e..8efbe423 100644 --- a/__tests__/serialize.test.ts +++ b/__tests__/serialize.test.ts @@ -5,7 +5,7 @@ import { TestServiceSchema, } from '../testUtil/fixtures/services'; import { serializeSchema } from '../router'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; describe('serialize server to jsonschema', () => { test('serialize entire service schema', () => { diff --git a/__tests__/typescript-stress.test.ts b/__tests__/typescript-stress.test.ts index d1e0d6e2..3acf6e9d 100644 --- a/__tests__/typescript-stress.test.ts +++ b/__tests__/typescript-stress.test.ts @@ -1,7 +1,7 @@ import { assert, describe, expect, test } from 'vitest'; import { Procedure } from '../router/procedures'; import { createServiceSchema } from '../router/services'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { createServer } from '../router/server'; import { createClient } from '../router/client'; import { diff --git a/__tests__/unserializable.test.ts b/__tests__/unserializable.test.ts index c7754aed..f4180eef 100644 --- a/__tests__/unserializable.test.ts +++ b/__tests__/unserializable.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test } from 'vitest'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { Procedure, createServiceSchema, diff --git a/codec/adapter.ts b/codec/adapter.ts index 450a371b..c79d63b3 100644 --- a/codec/adapter.ts +++ b/codec/adapter.ts @@ -1,4 +1,4 @@ -import { Value } from '@sinclair/typebox/value'; +import { Value } from 'typebox/value'; import { OpaqueTransportMessage, OpaqueTransportMessageSchema, diff --git a/customSchemas/index.ts b/customSchemas/index.ts new file mode 100644 index 00000000..659cde4c --- /dev/null +++ b/customSchemas/index.ts @@ -0,0 +1,80 @@ +import { Type } from 'typebox'; + +/** + * Creates a TypeBox schema for `Uint8Array` values with optional byte length constraints. + * This replaces the removed `Type.Uint8Array()` from TypeBox 0.34.x. + * + * The schema serializes with `{ type: 'Uint8Array' }` for backwards compatibility + * with older River clients/servers that used the built-in `Type.Uint8Array()`. + * + * @param options - Optional constraints for minimum and maximum byte length. + * @returns A TypeBox schema that validates `Uint8Array` instances. + */ +export function Uint8ArrayType( + options: { + minByteLength?: number; + maxByteLength?: number; + } = {}, +) { + return Type.Refine( + Type.Unsafe({ + type: 'Uint8Array', + ...options, + }), + (value): value is Uint8Array => { + if (!(value instanceof Uint8Array)) return false; + if ( + typeof options.minByteLength === 'number' && + value.byteLength < options.minByteLength + ) + return false; + if ( + typeof options.maxByteLength === 'number' && + value.byteLength > options.maxByteLength + ) + return false; + + return true; + }, + ); +} + +/** + * Creates a TypeBox schema for `Date` values. + * This replaces the removed `Type.Date()` from TypeBox 0.34.x. + * + * The schema serializes with `{ type: 'Date' }` for backwards compatibility + * with older River clients/servers that used the built-in `Type.Date()`. + * + * @param options - Optional constraints for minimum and maximum date values. + * @returns A TypeBox schema that validates `Date` instances (rejects invalid dates). + */ +export function DateType( + options: { + minimumTimestamp?: number; + maximumTimestamp?: number; + } = {}, +) { + return Type.Refine( + Type.Unsafe({ + type: 'Date', + ...options, + }), + (value): value is Date => { + if (!(value instanceof Date)) return false; + if (isNaN(value.getTime())) return false; + if ( + typeof options.minimumTimestamp === 'number' && + value.getTime() < options.minimumTimestamp + ) + return false; + if ( + typeof options.maximumTimestamp === 'number' && + value.getTime() > options.maximumTimestamp + ) + return false; + + return true; + }, + ); +} diff --git a/package-lock.json b/package-lock.json index ace3e0bd..c685a9c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@opentelemetry/context-async-hooks": "^1.26.0", "@opentelemetry/core": "^1.7.0", "@opentelemetry/sdk-trace-base": "^1.24.1", - "@sinclair/typebox": "~0.34.0", "@stylistic/eslint-plugin": "^2.6.4", "@types/ws": "^8.5.5", "@typescript-eslint/eslint-plugin": "^7.8.0", @@ -27,8 +26,10 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", + "legacyTypebox": "npm:@sinclair/typebox@^0.34.48", "prettier": "^3.0.0", "tsup": "^8.4.0", + "typebox": "^1.0.0", "typescript": "^5.4.5", "vitest": "^3.1.1" }, @@ -37,7 +38,7 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.7.0", - "@sinclair/typebox": "~0.34.0" + "typebox": "^1.0.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1096,12 +1097,6 @@ "win32" ] }, - "node_modules/@sinclair/typebox": { - "version": "0.34.33", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.33.tgz", - "integrity": "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g==", - "dev": true - }, "node_modules/@stylistic/eslint-plugin": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.6.4.tgz", @@ -2818,6 +2813,13 @@ "json-buffer": "3.0.1" } }, + "node_modules/legacyTypebox": { + "name": "@sinclair/typebox", + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -4489,6 +4491,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typebox": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.6.tgz", + "integrity": "sha512-O2iWCF+RboQfDqr6n83eOq0dKCjVchMWklKgdwKFeR01MGTskILHYEFi9n3lQvfuua4CtvG/EJEIg3P8H9eBcw==", + "dev": true + }, "node_modules/typescript": { "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", @@ -5425,12 +5433,6 @@ "dev": true, "optional": true }, - "@sinclair/typebox": { - "version": "0.34.33", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.33.tgz", - "integrity": "sha512-5HAV9exOMcXRUxo+9iYB5n09XxzCXnfy4VTNW4xnDv+FgjzAGY989C28BIdljKqmF+ZltUwujE3aossvcVtq6g==", - "dev": true - }, "@stylistic/eslint-plugin": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.6.4.tgz", @@ -6629,6 +6631,12 @@ "json-buffer": "3.0.1" } }, + "legacyTypebox": { + "version": "npm:@sinclair/typebox@0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true + }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -7624,6 +7632,12 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true }, + "typebox": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.6.tgz", + "integrity": "sha512-O2iWCF+RboQfDqr6n83eOq0dKCjVchMWklKgdwKFeR01MGTskILHYEFi9n3lQvfuua4CtvG/EJEIg3P8H9eBcw==", + "dev": true + }, "typescript": { "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", diff --git a/package.json b/package.json index e1d6b519..3163034a 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,10 @@ "./test-util": { "import": "./dist/testUtil/index.js", "require": "./dist/testUtil/index.cjs" + }, + "./customSchemas": { + "import": "./dist/customSchemas/index.js", + "require": "./dist/customSchemas/index.cjs" } }, "sideEffects": [ @@ -46,14 +50,13 @@ }, "peerDependencies": { "@opentelemetry/api": "^1.7.0", - "@sinclair/typebox": "~0.34.0" + "typebox": "^1.0.0" }, "devDependencies": { "@opentelemetry/api": "^1.7.0", "@opentelemetry/context-async-hooks": "^1.26.0", "@opentelemetry/core": "^1.7.0", "@opentelemetry/sdk-trace-base": "^1.24.1", - "@sinclair/typebox": "~0.34.0", "@stylistic/eslint-plugin": "^2.6.4", "@types/ws": "^8.5.5", "@typescript-eslint/eslint-plugin": "^7.8.0", @@ -62,8 +65,10 @@ "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.1.3", + "legacyTypebox": "npm:@sinclair/typebox@^0.34.48", "prettier": "^3.0.0", "tsup": "^8.4.0", + "typebox": "^1.0.0", "typescript": "^5.4.5", "vitest": "^3.1.1" }, diff --git a/router/client.ts b/router/client.ts index 818a02bc..a1268339 100644 --- a/router/client.ts +++ b/router/client.ts @@ -18,7 +18,7 @@ import { closeStreamMessage, cancelMessage, } from '../transport/message'; -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { Err, Result, AnyResultSchema } from './result'; import { EventMap } from '../transport/events'; import { Connection } from '../transport/connection'; @@ -28,13 +28,14 @@ import { ClientHandshakeOptions } from './handshake'; import { ClientTransport } from '../transport/client'; import { generateId } from '../transport/id'; import { Readable, ReadableImpl, Writable, WritableImpl } from './streams'; -import { Value } from '@sinclair/typebox/value'; +import { Value } from 'typebox/value'; import { PayloadType, ValidProcType } from './procedures'; import { BaseErrorSchemaType, CANCEL_CODE, ReaderErrorResultSchema, UNEXPECTED_DISCONNECT_CODE, + castTypeboxValueErrors, } from './errors'; interface CallOptions { @@ -425,9 +426,9 @@ function handleProc( { clientId: transport.clientId, transportMessage: msg, - validationErrors: [ - ...Value.Errors(ReaderErrorResultSchema, msg.payload), - ], + validationErrors: castTypeboxValueErrors( + Value.Errors(ReaderErrorResultSchema, msg.payload), + ), }, ); } @@ -462,7 +463,9 @@ function handleProc( { clientId: transport.clientId, transportMessage: msg, - validationErrors: [...Value.Errors(AnyResultSchema, msg.payload)], + validationErrors: castTypeboxValueErrors( + Value.Errors(AnyResultSchema, msg.payload), + ), }, ); } diff --git a/router/context.ts b/router/context.ts index 540c51e7..f1b28201 100644 --- a/router/context.ts +++ b/router/context.ts @@ -3,7 +3,7 @@ import { TransportClientId } from '../transport/message'; import { SessionId } from '../transport/sessionStateMachine/common'; import { ErrResult } from './result'; import { CancelErrorSchema } from './errors'; -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; /** * This is passed to every procedure handler and contains various context-level diff --git a/router/errors.ts b/router/errors.ts index e1002ce4..462c25a9 100644 --- a/router/errors.ts +++ b/router/errors.ts @@ -1,5 +1,4 @@ import { - Kind, Static, TEnum, TLiteral, @@ -9,8 +8,8 @@ import { TString, TUnion, Type, -} from '@sinclair/typebox'; -import { ValueErrorIterator } from '@sinclair/typebox/errors'; +} from 'typebox'; +import { type TLocalizedValidationError } from 'typebox/error'; /** * {@link UNCAUGHT_ERROR_CODE} is the code that is used when an error is thrown @@ -33,7 +32,7 @@ export const CANCEL_CODE = 'CANCEL'; type TLiteralString = TLiteral; -type TEnumString = TEnum>; +type TEnumString = TEnum>; export type BaseErrorSchemaType = | TObject<{ @@ -62,12 +61,12 @@ const ValidationErrorDetails = Type.Object({ export const ValidationErrors = Type.Array(ValidationErrorDetails); export function castTypeboxValueErrors( - errors: ValueErrorIterator, + errors: Array, ): Static { const result = []; for (const error of errors) { result.push({ - path: error.path, + path: error.schemaPath, message: error.message, }); } @@ -135,19 +134,16 @@ type NestableProcedureErrorSchemaType = interface NestableProcedureErrorSchemaTypeArray extends Array {} -function isUnion(schema: TSchema): schema is TUnion { - return schema[Kind] === 'Union'; -} - -export type Flatten = T extends BaseErrorSchemaType +type Flatten = T extends BaseErrorSchemaType ? T : T extends TUnion> ? Flatten : unknown; /** - * In the case where API consumers for some god-forsaken reason want to use - * arbitrarily nested unions, this helper flattens them to a single level. + * Flattens a union-nested error schema into a single level in order to + * satisfy ProcedureErrorSchemaType which accepts only a single level of union + * so that we can enforce a schema validation on the error schema. * * Note that loses some metadata information on the nested unions like * nested description fields, etc. @@ -161,22 +157,5 @@ export function flattenErrorType( export function flattenErrorType( errType: NestableProcedureErrorSchemaType, ): ProcedureErrorSchemaType { - if (!isUnion(errType)) { - return errType; - } - - const flattenedTypes: Array = []; - function flatten(type: NestableProcedureErrorSchemaType) { - if (isUnion(type)) { - for (const t of type.anyOf) { - flatten(t); - } - } else { - flattenedTypes.push(type); - } - } - - flatten(errType); - - return Type.Union(flattenedTypes); + return Type.Evaluate(errType) as ProcedureErrorSchemaType; } diff --git a/router/handshake.ts b/router/handshake.ts index 3e87458d..b3e08f00 100644 --- a/router/handshake.ts +++ b/router/handshake.ts @@ -1,4 +1,4 @@ -import { Static, TSchema } from '@sinclair/typebox'; +import { Static, TSchema } from 'typebox'; import { HandshakeErrorCustomHandlerFatalResponseCodes } from '../transport/message'; type ConstructHandshake = () => diff --git a/router/procedures.ts b/router/procedures.ts index 33289da6..90614142 100644 --- a/router/procedures.ts +++ b/router/procedures.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ -import { Static, TNever, TSchema, Type } from '@sinclair/typebox'; +import { Static, TNever, TSchema, Type } from 'typebox'; import { ProcedureHandlerContext } from './context'; import { Result } from './result'; import { Readable, Writable } from './streams'; @@ -352,14 +352,12 @@ function rpc({ responseData: PayloadType; responseError?: ProcedureErrorSchemaType; description?: string; - handler: RpcProcedure< - object, - object, - object, - PayloadType, - PayloadType, - ProcedureErrorSchemaType - >['handler']; + // In TypeBox 1.0, TSchema is {} so Static doesn't resolve the same way + // as in 0.34. Using `any` for schema type params in the implementation signature + // is safe because the public overload signatures still enforce correct types for + // all callers. The implementation body doesn't inspect handler types at runtime. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: RpcProcedure['handler']; }) { return { ...(description ? { description } : {}), @@ -459,15 +457,9 @@ function upload({ responseData: PayloadType; responseError?: ProcedureErrorSchemaType; description?: string; - handler: UploadProcedure< - object, - object, - object, - PayloadType, - PayloadType, - PayloadType, - ProcedureErrorSchemaType - >['handler']; + // See comment in rpc() implementation above for why `any` is used here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + handler: UploadProcedure['handler']; }) { return { type: 'upload', diff --git a/router/result.ts b/router/result.ts index 11abd837..b36cc94d 100644 --- a/router/result.ts +++ b/router/result.ts @@ -1,4 +1,4 @@ -import { Static, Type } from '@sinclair/typebox'; +import { Static, Type } from 'typebox'; import { Client } from './client'; import { Readable } from './streams'; import { BaseErrorSchemaType } from './errors'; diff --git a/router/server.ts b/router/server.ts index ea50f416..22540dff 100644 --- a/router/server.ts +++ b/router/server.ts @@ -1,4 +1,4 @@ -import { Static, TSchema } from '@sinclair/typebox'; +import { Static, TSchema } from 'typebox'; import { PayloadType, AnyProcedure } from './procedures'; import { ReaderErrorSchema, @@ -31,7 +31,7 @@ import { } from '../transport/message'; import { ProcedureHandlerContext } from './context'; import { Logger } from '../logging/log'; -import { Value } from '@sinclair/typebox/value'; +import { Value } from 'typebox/value'; import { Err, Result, Ok, ErrResult } from './result'; import { EventMap } from '../transport/events'; import { coerceErrorString } from '../transport/stringifyError'; @@ -324,9 +324,9 @@ class RiverServer< this.log?.warn('got stream cancel without a valid protocol error', { ...loggingMetadata, transportMessage: msg, - validationErrors: [ - ...Value.Errors(CancelResultSchema, msg.payload), - ], + validationErrors: castTypeboxValueErrors( + Value.Errors(CancelResultSchema, msg.payload), + ), tags: ['invalid-request'], }); } diff --git a/router/services.ts b/router/services.ts index 7d65ab2e..ab00252a 100644 --- a/router/services.ts +++ b/router/services.ts @@ -1,4 +1,4 @@ -import { Type, TSchema, Static, Kind } from '@sinclair/typebox'; +import { Type, TSchema, Static } from 'typebox'; import { Branded, ProcedureMap, @@ -621,10 +621,7 @@ export function createServiceSchema< export function getSerializedProcErrors( procDef: AnyProcedure, ): ProcedureErrorSchemaType { - if ( - !('responseError' in procDef) || - procDef.responseError[Kind] === 'Never' - ) { + if (!('responseError' in procDef) || Type.IsNever(procDef.responseError)) { return Strict(ReaderErrorSchema); } diff --git a/router/streams.ts b/router/streams.ts index a180ac0c..3668caea 100644 --- a/router/streams.ts +++ b/router/streams.ts @@ -1,4 +1,4 @@ -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { Err, Result } from './result'; import { BaseErrorSchemaType } from './errors'; diff --git a/testUtil/fixtures/cleanup.ts b/testUtil/fixtures/cleanup.ts index 43cf38eb..423d5122 100644 --- a/testUtil/fixtures/cleanup.ts +++ b/testUtil/fixtures/cleanup.ts @@ -8,7 +8,7 @@ import { import { Server } from '../../router'; import { AnyServiceSchemaMap, MaybeDisposable } from '../../router/services'; import { numberOfConnections, testingSessionOptions } from '..'; -import { Value } from '@sinclair/typebox/value'; +import { Value } from 'typebox/value'; import { ControlMessageAckSchema } from '../../transport/message'; const waitUntilOptions = { diff --git a/testUtil/fixtures/mockTransport.ts b/testUtil/fixtures/mockTransport.ts index 715f474a..2bbe43aa 100644 --- a/testUtil/fixtures/mockTransport.ts +++ b/testUtil/fixtures/mockTransport.ts @@ -8,7 +8,7 @@ import { TestSetupHelpers, TestTransportOptions } from './transports'; import { Duplex } from 'node:stream'; import { duplexPair } from '../duplex/duplexPair'; import { nanoid } from 'nanoid'; -import { TSchema } from '@sinclair/typebox'; +import { TSchema } from 'typebox'; import { ServerHandshakeOptions } from '../../router/handshake'; export class InMemoryConnection extends Connection { diff --git a/testUtil/fixtures/services.ts b/testUtil/fixtures/services.ts index 09a788b4..36201c53 100644 --- a/testUtil/fixtures/services.ts +++ b/testUtil/fixtures/services.ts @@ -1,8 +1,10 @@ -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { createServiceSchema } from '../../router/services'; import { Err, Ok, unwrapOrThrow } from '../../router/result'; import { Observable } from '../observable/observable'; import { Procedure } from '../../router'; +import { flattenErrorType } from '../../router/errors'; +import { Uint8ArrayType } from '../../customSchemas'; const ServiceSchema = createServiceSchema(); @@ -189,7 +191,7 @@ export const OrderingServiceSchema = ServiceSchema.define( export const BinaryFileServiceSchema = ServiceSchema.define({ getFile: Procedure.rpc({ requestInit: Type.Object({ file: Type.String() }), - responseData: Type.Object({ contents: Type.Uint8Array() }), + responseData: Type.Object({ contents: Uint8ArrayType() }), async handler({ reqInit: { file } }) { const bytes: Uint8Array = Buffer.from(`contents for file ${file}`); @@ -205,19 +207,21 @@ export const FallibleServiceSchema = ServiceSchema.define({ divide: Procedure.rpc({ requestInit: Type.Object({ a: Type.Number(), b: Type.Number() }), responseData: Type.Object({ result: Type.Number() }), - responseError: Type.Union([ - Type.Object({ - code: Type.Literal(DIV_BY_ZERO), - message: Type.String(), - extras: Type.Object({ test: Type.String() }), - }), + responseError: flattenErrorType( Type.Union([ Type.Object({ - code: Type.Literal('INFINITY'), + code: Type.Literal(DIV_BY_ZERO), message: Type.String(), + extras: Type.Object({ test: Type.String() }), }), + Type.Union([ + Type.Object({ + code: Type.Literal('INFINITY'), + message: Type.String(), + }), + ]), ]), - ]), + ), async handler({ reqInit: { a, b } }) { if (b === 0) { return Err({ @@ -344,11 +348,14 @@ export const UploadableServiceSchema = ServiceSchema.define({ }), }); -const RecursivePayload = Type.Recursive((This) => - Type.Object({ - n: Type.Number(), - next: Type.Optional(This), - }), +const RecursivePayload = Type.Cyclic( + { + RecursivePayload: Type.Object({ + n: Type.Number(), + next: Type.Optional(Type.Ref('RecursivePayload')), + }), + }, + 'RecursivePayload', ); export const NonObjectSchemas = ServiceSchema.define({ diff --git a/testUtil/fixtures/transports.ts b/testUtil/fixtures/transports.ts index 500e5e1a..bba1eea2 100644 --- a/testUtil/fixtures/transports.ts +++ b/testUtil/fixtures/transports.ts @@ -20,7 +20,7 @@ import { TransportClientId } from '../../transport/message'; import { ClientTransport } from '../../transport/client'; import { Connection } from '../../transport/connection'; import { ServerTransport } from '../../transport/server'; -import { TSchema } from '@sinclair/typebox'; +import { TSchema } from 'typebox'; export type ValidTransports = 'ws' | 'mock'; diff --git a/testUtil/index.ts b/testUtil/index.ts index ae9a4330..437af0e6 100644 --- a/testUtil/index.ts +++ b/testUtil/index.ts @@ -1,6 +1,6 @@ import NodeWs, { WebSocketServer } from 'ws'; import http from 'node:http'; -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { OpaqueTransportMessage, PartialTransportMessage, diff --git a/tracing/index.ts b/tracing/index.ts index a704cc37..34270b3c 100644 --- a/tracing/index.ts +++ b/tracing/index.ts @@ -13,7 +13,7 @@ import { Connection } from '../transport'; import { MessageMetadata } from '../logging'; import { ClientSession } from '../transport/sessionStateMachine/transitions'; import { IdentifiedSession } from '../transport/sessionStateMachine/common'; -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; export interface PropagationContext { traceparent: string; diff --git a/transport/client.ts b/transport/client.ts index 0d5d876c..280e7235 100644 --- a/transport/client.ts +++ b/transport/client.ts @@ -17,7 +17,8 @@ import { LeakyBucketRateLimit } from './rateLimit'; import { Transport } from './transport'; import { coerceErrorString } from './stringifyError'; import { ProtocolError } from './events'; -import { Value } from '@sinclair/typebox/value'; +import { Value } from 'typebox/value'; +import { castTypeboxValueErrors } from '../router/errors'; import { getPropagationContext } from '../tracing'; import { Connection } from './connection'; import { MessageMetadata } from '../logging'; @@ -243,9 +244,9 @@ export abstract class ClientTransport< this.rejectHandshakeResponse(session, reason, { ...session.loggingMetadata, transportMessage: msg, - validationErrors: [ - ...Value.Errors(ControlMessageHandshakeResponseSchema, msg.payload), - ], + validationErrors: castTypeboxValueErrors( + Value.Errors(ControlMessageHandshakeResponseSchema, msg.payload), + ), }); return; diff --git a/transport/events.ts b/transport/events.ts index 41e832c4..b18353c5 100644 --- a/transport/events.ts +++ b/transport/events.ts @@ -1,4 +1,4 @@ -import { type Static } from '@sinclair/typebox'; +import { type Static } from 'typebox'; import { Connection } from './connection'; import { OpaqueTransportMessage, HandshakeErrorResponseCodes } from './message'; import { Session, SessionState } from './sessionStateMachine'; diff --git a/transport/impls/ws/server.ts b/transport/impls/ws/server.ts index d0b397bc..aea89ad8 100644 --- a/transport/impls/ws/server.ts +++ b/transport/impls/ws/server.ts @@ -5,7 +5,7 @@ import { WsLike } from './wslike'; import { ServerTransport } from '../../server'; import { ProvidedServerTransportOptions } from '../../options'; import { type IncomingMessage } from 'http'; -import { TSchema } from '@sinclair/typebox'; +import { TSchema } from 'typebox'; function cleanHeaders( headers: IncomingMessage['headers'], diff --git a/transport/message.ts b/transport/message.ts index 40861281..e347edb3 100644 --- a/transport/message.ts +++ b/transport/message.ts @@ -1,4 +1,4 @@ -import { Type, TSchema, Static } from '@sinclair/typebox'; +import { Type, TSchema, Static } from 'typebox'; import { PropagationContext } from '../tracing'; import { generateId } from './id'; import { ErrResult, ReaderErrorSchema } from '../router'; diff --git a/transport/server.ts b/transport/server.ts index 78cf8042..8c7f6aa9 100644 --- a/transport/server.ts +++ b/transport/server.ts @@ -18,11 +18,12 @@ import { } from './options'; import { DeleteSessionOptions, Transport } from './transport'; import { coerceErrorString } from './stringifyError'; -import { Static, TSchema } from '@sinclair/typebox'; -import { Value } from '@sinclair/typebox/value'; +import { Static, TSchema } from 'typebox'; +import { Value } from 'typebox/value'; import { ProtocolError } from './events'; import { Connection } from './connection'; import { MessageMetadata } from '../logging'; +import { castTypeboxValueErrors } from '../router/errors'; import { SessionWaitingForHandshake } from './sessionStateMachine/SessionWaitingForHandshake'; import { SessionState } from './sessionStateMachine/common'; import { @@ -237,9 +238,9 @@ export abstract class ServerTransport< ...session.loggingMetadata, transportMessage: msg, connectedTo: msg.from, - validationErrors: [ - ...Value.Errors(ControlMessageHandshakeRequestSchema, msg.payload), - ], + validationErrors: castTypeboxValueErrors( + Value.Errors(ControlMessageHandshakeRequestSchema, msg.payload), + ), }, ); @@ -276,12 +277,12 @@ export abstract class ServerTransport< { ...session.loggingMetadata, connectedTo: msg.from, - validationErrors: [ - ...Value.Errors( + validationErrors: castTypeboxValueErrors( + Value.Errors( this.handshakeExtensions.schema, msg.payload.metadata, ), - ], + ), }, ); diff --git a/transport/sessionStateMachine/SessionConnected.ts b/transport/sessionStateMachine/SessionConnected.ts index 1316ca5f..b2e7a441 100644 --- a/transport/sessionStateMachine/SessionConnected.ts +++ b/transport/sessionStateMachine/SessionConnected.ts @@ -1,4 +1,4 @@ -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { ControlFlags, ControlMessageAckSchema, diff --git a/transport/sessionStateMachine/SessionHandshaking.ts b/transport/sessionStateMachine/SessionHandshaking.ts index 50359952..c4c9ff7a 100644 --- a/transport/sessionStateMachine/SessionHandshaking.ts +++ b/transport/sessionStateMachine/SessionHandshaking.ts @@ -1,4 +1,4 @@ -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { Connection } from '../connection'; import { OpaqueTransportMessage, diff --git a/transport/sessionStateMachine/SessionWaitingForHandshake.ts b/transport/sessionStateMachine/SessionWaitingForHandshake.ts index 6a8107d9..403f5863 100644 --- a/transport/sessionStateMachine/SessionWaitingForHandshake.ts +++ b/transport/sessionStateMachine/SessionWaitingForHandshake.ts @@ -1,4 +1,4 @@ -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { Connection } from '../connection'; import { HandshakeErrorResponseCodes, diff --git a/transport/sessionStateMachine/stateMachine.test.ts b/transport/sessionStateMachine/stateMachine.test.ts index 0d825daa..34a05d71 100644 --- a/transport/sessionStateMachine/stateMachine.test.ts +++ b/transport/sessionStateMachine/stateMachine.test.ts @@ -11,7 +11,7 @@ import { handshakeRequestMessage, } from '../message'; import { ERR_CONSUMED, IdentifiedSession, SessionState } from './common'; -import { Static } from '@sinclair/typebox'; +import { Static } from 'typebox'; import { SessionHandshaking, SessionHandshakingListeners, diff --git a/transport/transport.test.ts b/transport/transport.test.ts index 8c93f287..c0ab69f5 100644 --- a/transport/transport.test.ts +++ b/transport/transport.test.ts @@ -21,7 +21,7 @@ import { } from '../testUtil/fixtures/cleanup'; import { testMatrix } from '../testUtil/fixtures/matrix'; import { PartialTransportMessage } from './message'; -import { Type } from '@sinclair/typebox'; +import { Type } from 'typebox'; import { TestSetupHelpers } from '../testUtil/fixtures/transports'; import { createPostTestCleanups } from '../testUtil/fixtures/cleanup'; import { SessionState } from './sessionStateMachine'; diff --git a/tsup.config.ts b/tsup.config.ts index 6cce9b10..74ad5827 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ 'transport/impls/ws/server.ts', 'transport/impls/uds/client.ts', 'transport/impls/uds/server.ts', + 'customSchemas/index.ts', ], format: ['esm', 'cjs'], sourcemap: true, From 597f9ce87b1c013228d6fbf97398d6b9a5e8fbb8 Mon Sep 17 00:00:00 2001 From: masad-frost Date: Thu, 12 Mar 2026 19:56:31 -0700 Subject: [PATCH 2/3] Use a cache for custom schemas and export a TSchema for them --- customSchemas/index.ts | 49 ++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/customSchemas/index.ts b/customSchemas/index.ts index 659cde4c..c2272768 100644 --- a/customSchemas/index.ts +++ b/customSchemas/index.ts @@ -1,5 +1,8 @@ import { Type } from 'typebox'; +export type TUint8Array = Type.TUnsafe; +const uint8ArrayCache = new Map(); + /** * Creates a TypeBox schema for `Uint8Array` values with optional byte length constraints. * This replaces the removed `Type.Uint8Array()` from TypeBox 0.34.x. @@ -16,29 +19,36 @@ export function Uint8ArrayType( maxByteLength?: number; } = {}, ) { - return Type.Refine( + const min = options.minByteLength; + const max = options.maxByteLength; + + const key = `${min ?? ''}:${max ?? ''}`; + const existing = uint8ArrayCache.get(key); + if (existing) return existing; + + const schema = Type.Refine( Type.Unsafe({ type: 'Uint8Array', - ...options, + ...(min !== undefined ? { minByteLength: min } : {}), + ...(max !== undefined ? { maxByteLength: max } : {}), }), (value): value is Uint8Array => { if (!(value instanceof Uint8Array)) return false; - if ( - typeof options.minByteLength === 'number' && - value.byteLength < options.minByteLength - ) - return false; - if ( - typeof options.maxByteLength === 'number' && - value.byteLength > options.maxByteLength - ) - return false; + if (min !== undefined && value.byteLength < min) return false; + if (max !== undefined && value.byteLength > max) return false; return true; }, ); + + uint8ArrayCache.set(key, schema); + + return schema; } +export type TDate = Type.TUnsafe; +const dateCache = new Map(); + /** * Creates a TypeBox schema for `Date` values. * This replaces the removed `Type.Date()` from TypeBox 0.34.x. @@ -54,8 +64,15 @@ export function DateType( minimumTimestamp?: number; maximumTimestamp?: number; } = {}, -) { - return Type.Refine( +): TDate { + const min = options.minimumTimestamp; + const max = options.maximumTimestamp; + + const key = `${min ?? ''}:${max ?? ''}`; + const existing = dateCache.get(key); + if (existing) return existing; + + const schema = Type.Refine( Type.Unsafe({ type: 'Date', ...options, @@ -77,4 +94,8 @@ export function DateType( return true; }, ); + + dateCache.set(key, schema); + + return schema; } From 98c7f9266be739edbffcc4fa8f3abccfabb0e789 Mon Sep 17 00:00:00 2001 From: masad-frost Date: Thu, 12 Mar 2026 20:20:27 -0700 Subject: [PATCH 3/3] oops forgot to actually use min-max in date --- customSchemas/index.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/customSchemas/index.ts b/customSchemas/index.ts index c2272768..b8962dc3 100644 --- a/customSchemas/index.ts +++ b/customSchemas/index.ts @@ -75,21 +75,14 @@ export function DateType( const schema = Type.Refine( Type.Unsafe({ type: 'Date', - ...options, + ...(min !== undefined ? { minimumTimestamp: min } : {}), + ...(max !== undefined ? { maximumTimestamp: max } : {}), }), (value): value is Date => { if (!(value instanceof Date)) return false; if (isNaN(value.getTime())) return false; - if ( - typeof options.minimumTimestamp === 'number' && - value.getTime() < options.minimumTimestamp - ) - return false; - if ( - typeof options.maximumTimestamp === 'number' && - value.getTime() > options.maximumTimestamp - ) - return false; + if (typeof min === 'number' && value.getTime() < min) return false; + if (typeof max === 'number' && value.getTime() > max) return false; return true; },