diff --git a/.changeset/busy-ends-shake.md b/.changeset/busy-ends-shake.md new file mode 100644 index 0000000000..ca666382cc --- /dev/null +++ b/.changeset/busy-ends-shake.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Add data track packet serialization + deserialization logic diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 891e2ef335..c5751f12cd 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -26,10 +26,13 @@ jobs: - name: Prettier run: pnpm format:check + - name: Throws check + run: pnpm throws:check + - name: Run Tests run: pnpm test - name: Check browser target compatibility run: | pnpm build:clean - pnpm compat \ No newline at end of file + pnpm compat diff --git a/package.json b/package.json index 9cdcd41247..896fd2deb9 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "deploy": "gh-pages -d examples/demo/dist", "format": "prettier --write src examples/**/*.ts", "format:check": "prettier --check src examples/**/*.ts", + "throws:check": "pnpm dlx tsx ./throws-transformer/cli.ts 'src/!(*.test).ts' 'src/**/!(*.test).ts'", "ci:publish": "pnpm build:clean && pnpm compat && changeset publish", "downlevel-dts": "downlevel-dts ./dist/src ./dist/ts4.2 --to=4.2", "compat": "eslint --config ./eslint.config.dist.mjs --no-inline-config ./dist/livekit-client.esm.mjs", @@ -98,6 +99,7 @@ "eslint-plugin-import-x": "^4.16.1", "eslint-plugin-prettier": "^5.5.4", "gh-pages": "6.3.0", + "glob": "^13.0.0", "happy-dom": "^17.2.0", "jsdom": "^26.1.0", "prettier": "^3.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d70f60d758..81461c01ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: gh-pages: specifier: 6.3.0 version: 6.3.0 + glob: + specifier: ^13.0.0 + version: 13.0.0 happy-dom: specifier: ^17.2.0 version: 17.6.3 @@ -1060,6 +1063,14 @@ packages: '@types/node': optional: true + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@jridgewell/gen-mapping@0.3.12': resolution: {integrity: sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==} @@ -2512,6 +2523,10 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@13.0.0: + resolution: {integrity: sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==} + engines: {node: 20 || >=22} + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -2909,6 +2924,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -2956,6 +2975,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2966,6 +2989,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3110,6 +3137,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3651,8 +3682,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20251130: - resolution: {integrity: sha512-KsAcpCYlgZmHwL8h3eqhbFbYidBQR8MeRhfP9yQvhUG4gLb8U1jYjL02dqn2jKp88+pxqpZa5qXggULszSam6g==} + typescript@6.0.0-dev.20260122: + resolution: {integrity: sha512-WnKEBDTdvo/zvxcMZvpXFNuVPupiMcKOhX51jSdniyaTMNvGvbgOgJklq6hswmV2dKpCVndtTkBCUNOO/klujA==} engines: {node: '>=14.17'} hasBin: true @@ -4989,6 +5020,12 @@ snapshots: chardet: 2.1.0 iconv-lite: 0.7.0 + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@jridgewell/gen-mapping@0.3.12': dependencies: '@jridgewell/sourcemap-codec': 1.5.0 @@ -6055,7 +6092,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20251130 + typescript: 6.0.0-dev.20260122 dunder-proto@1.0.1: dependencies: @@ -6638,6 +6675,12 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@13.0.0: + dependencies: + minimatch: 10.1.1 + minipass: 7.1.2 + path-scurry: 2.0.1 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -7052,6 +7095,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.4: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7099,6 +7144,10 @@ snapshots: dependencies: mime-db: 1.52.0 + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -7110,6 +7159,8 @@ snapshots: minimist@1.2.8: optional: true + minipass@7.1.2: {} + mri@1.2.0: {} ms@2.1.2: {} @@ -7245,6 +7296,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.4 + minipass: 7.1.2 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -7305,7 +7361,7 @@ snapshots: rechoir@0.6.2: dependencies: - resolve: 1.22.10 + resolve: 1.22.11 reflect.getprototypeof@1.0.10: dependencies: @@ -7371,7 +7427,6 @@ snapshots: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - optional: true reusify@1.0.4: {} @@ -7841,7 +7896,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20251130: {} + typescript@6.0.0-dev.20260122: {} uc.micro@2.1.0: {} diff --git a/src/index.ts b/src/index.ts index 9ded13c547..a7803b7b4a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -149,5 +149,11 @@ export type { ReconnectPolicy, RoomEventCallbacks, }; +export { DataTrackPacket, type DataTrackPacketHeader } from './room/data-track/packet'; +export { + type DataTrackExtensions, + type DataTrackUserTimestampExtension, + type DataTrackE2eeExtension, +} from './room/data-track/packet/extensions'; export { LocalTrackRecorder } from './room/track/record'; diff --git a/src/room/data-track/handle.test.ts b/src/room/data-track/handle.test.ts new file mode 100644 index 0000000000..81592e46e3 --- /dev/null +++ b/src/room/data-track/handle.test.ts @@ -0,0 +1,13 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DataTrackHandle } from './handle'; + +describe('DataTrackHandle', () => { + it('should parse handle raw inputs', () => { + expect(DataTrackHandle.fromNumber(3).value).toEqual(3); + expect(() => DataTrackHandle.fromNumber(0)).toThrow('0x0 is a reserved value'); + expect(() => DataTrackHandle.fromNumber(9999999)).toThrow( + 'Value too large to be a valid track handle', + ); + }); +}); diff --git a/src/room/data-track/handle.ts b/src/room/data-track/handle.ts new file mode 100644 index 0000000000..c02c6bc24c --- /dev/null +++ b/src/room/data-track/handle.ts @@ -0,0 +1,80 @@ +import { type Throws } from '../../utils/throws'; +import { LivekitReasonedError } from '../errors'; +import { U16_MAX_SIZE } from './utils'; + +export enum DataTrackHandleErrorReason { + Reserved = 0, + TooLarge = 1, +} + +export class DataTrackHandleError< + Reason extends DataTrackHandleErrorReason = DataTrackHandleErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackHandleError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason) { + super(19, message); + this.reason = reason; + this.reasonName = DataTrackHandleErrorReason[reason]; + } + + isReason(reason: R): this is DataTrackHandleError { + return (this.reason as unknown as R) === reason; + } + + static tooLarge() { + return new DataTrackHandleError( + 'Value too large to be a valid track handle', + DataTrackHandleErrorReason.TooLarge, + ); + } + + static reserved(value: number) { + return new DataTrackHandleError( + `0x${value.toString(16)} is a reserved value.`, + DataTrackHandleErrorReason.Reserved, + ); + } +} + +export class DataTrackHandle { + public value: number; + + static fromNumber( + raw: number, + ): Throws< + DataTrackHandle, + | DataTrackHandleError + | DataTrackHandleError + > { + if (raw === 0) { + throw DataTrackHandleError.reserved(raw); + } + if (raw > U16_MAX_SIZE) { + throw DataTrackHandleError.tooLarge(); + } + return new DataTrackHandle(raw); + } + + constructor(raw: number) { + this.value = raw; + } +} + +/** Manage allocating new handles which don't conflict over the lifetime of the client. */ +export class DataTrackHandleAllocator { + static value = 0; + + /** Returns a unique track handle for the next publication, if one can be obtained. */ + static get(): DataTrackHandle | null { + this.value += 1; + if (this.value > U16_MAX_SIZE) { + return null; + } + return new DataTrackHandle(this.value); + } +} diff --git a/src/room/data-track/packet/constants.ts b/src/room/data-track/packet/constants.ts new file mode 100644 index 0000000000..e1b928e371 --- /dev/null +++ b/src/room/data-track/packet/constants.ts @@ -0,0 +1,27 @@ +// Number type sizes +export const U8_LENGTH_BYTES = 1; +export const U16_LENGTH_BYTES = 2; +export const U32_LENGTH_BYTES = 4; +export const U64_LENGTH_BYTES = 8; + +/// Constants used for serialization and deserialization. +export const SUPPORTED_VERSION = 0; +export const BASE_HEADER_LEN = 12; + +// Bitfield shifts and masks for header flags +export const VERSION_SHIFT = 5; +export const VERSION_MASK = 0x07; + +export const FRAME_MARKER_SHIFT = 3; +export const FRAME_MARKER_MASK = 0x3; + +export const FRAME_MARKER_START = 0x2; +export const FRAME_MARKER_FINAL = 0x1; +export const FRAME_MARKER_INTER = 0x0; +export const FRAME_MARKER_SINGLE = 0x3; + +export const EXT_WORDS_INDICATOR_SIZE = 2; +export const EXT_FLAG_SHIFT = 0x2; +export const EXT_FLAG_MASK = 0x1; +export const EXT_MARKER_LEN = 4; +export const EXT_TAG_PADDING = 0; diff --git a/src/room/data-track/packet/errors.ts b/src/room/data-track/packet/errors.ts new file mode 100644 index 0000000000..f14d003783 --- /dev/null +++ b/src/room/data-track/packet/errors.ts @@ -0,0 +1,121 @@ +import { LivekitReasonedError } from '../../errors'; +import type { DataTrackHandleError } from '../handle'; + +export enum DataTrackDeserializeErrorReason { + TooShort = 0, + HeaderOverrun = 1, + MissingExtWords = 2, + UnsupportedVersion = 3, + InvalidHandle = 4, + MalformedExt = 5, +} + +export class DataTrackDeserializeError< + Reason extends DataTrackDeserializeErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackDeserializeError'; + + reason: Reason; + + reasonName: string; + + cause?: Reason extends DataTrackDeserializeErrorReason.InvalidHandle + ? DataTrackHandleError + : never; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(19, message, options); + this.reason = reason; + this.reasonName = DataTrackDeserializeErrorReason[reason]; + } + + static tooShort() { + return new DataTrackDeserializeError( + 'Too short to contain a valid header', + DataTrackDeserializeErrorReason.TooShort, + ); + } + + static headerOverrun() { + return new DataTrackDeserializeError( + 'Header exceeds total packet length', + DataTrackDeserializeErrorReason.HeaderOverrun, + ); + } + + static missingExtWords() { + return new DataTrackDeserializeError( + 'Extension word indicator is missing', + DataTrackDeserializeErrorReason.MissingExtWords, + ); + } + + static unsupportedVersion(version: number) { + return new DataTrackDeserializeError( + `Unsupported version ${version}`, + DataTrackDeserializeErrorReason.UnsupportedVersion, + ); + } + + static invalidHandle(cause: DataTrackHandleError) { + return new DataTrackDeserializeError( + `invalid track handle: ${cause.message}`, + DataTrackDeserializeErrorReason.InvalidHandle, + { cause }, + ); + } + + static malformedExt(tag: number) { + return new DataTrackDeserializeError( + `Extension with tag ${tag} is malformed`, + DataTrackDeserializeErrorReason.MalformedExt, + ); + } +} + +export type DataTrackDeserializeErrorAll = + | DataTrackDeserializeError + | DataTrackDeserializeError + | DataTrackDeserializeError + | DataTrackDeserializeError + | DataTrackDeserializeError + | DataTrackDeserializeError; + +export enum DataTrackSerializeErrorReason { + TooSmallForHeader = 0, + TooSmallForPayload = 1, +} + +export class DataTrackSerializeError< + Reason extends DataTrackSerializeErrorReason, +> extends LivekitReasonedError { + readonly name = 'DataTrackSerializeError'; + + reason: Reason; + + reasonName: string; + + constructor(message: string, reason: Reason, options?: { cause?: unknown }) { + super(19, message, options); + this.reason = reason; + this.reasonName = DataTrackSerializeErrorReason[reason]; + } + + static tooSmallForHeader() { + return new DataTrackSerializeError( + 'Buffer cannot fit header', + DataTrackSerializeErrorReason.TooSmallForHeader, + ); + } + + static tooSmallForPayload() { + return new DataTrackSerializeError( + 'Buffer cannot fit payload', + DataTrackSerializeErrorReason.TooSmallForPayload, + ); + } +} + +export type DataTrackSerializeErrorAll = + | DataTrackSerializeError + | DataTrackSerializeError; diff --git a/src/room/data-track/packet/extensions.ts b/src/room/data-track/packet/extensions.ts new file mode 100644 index 0000000000..a96d7847b5 --- /dev/null +++ b/src/room/data-track/packet/extensions.ts @@ -0,0 +1,259 @@ +import { type Throws } from '../../../utils/throws'; +import { coerceToDataView } from '../utils'; +import { EXT_TAG_PADDING, U8_LENGTH_BYTES, U16_LENGTH_BYTES, U64_LENGTH_BYTES } from './constants'; +import { DataTrackDeserializeError, DataTrackDeserializeErrorReason } from './errors'; +import Serializable from './serializable'; + +export enum DataTrackExtensionTag { + UserTimestamp = 2, + E2ee = 1, +} + +abstract class DataTrackExtension extends Serializable { + static tag: DataTrackExtensionTag; + + static lengthBytes: number; +} + +export class DataTrackUserTimestampExtension extends DataTrackExtension { + static tag = DataTrackExtensionTag.UserTimestamp; + + static lengthBytes = 8; + + private timestamp: bigint; + + constructor(timestamp: bigint) { + super(); + this.timestamp = timestamp; + } + + toBinaryLengthBytes(): number { + return ( + U16_LENGTH_BYTES /* tag */ + + U16_LENGTH_BYTES /* length */ + + DataTrackUserTimestampExtension.lengthBytes + ); + } + + toBinaryInto(dataView: DataView): Throws { + let byteIndex = 0; + + dataView.setUint16(byteIndex, DataTrackUserTimestampExtension.tag); + byteIndex += U16_LENGTH_BYTES; + + const rtpOrientedLength = DataTrackUserTimestampExtension.lengthBytes - 1; + dataView.setUint16(byteIndex, rtpOrientedLength); + byteIndex += U16_LENGTH_BYTES; + + dataView.setBigUint64(byteIndex, this.timestamp); + byteIndex += U64_LENGTH_BYTES; + + const totalLengthBytes = this.toBinaryLengthBytes(); + if (byteIndex !== totalLengthBytes) { + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error( + `DataTrackUserTimestampExtension.toBinaryInto: Wrote ${byteIndex} bytes but expected length was ${totalLengthBytes} bytes`, + ); + } + + return byteIndex; + } + + toJSON() { + return { + tag: DataTrackUserTimestampExtension.tag as number, + lengthBytes: DataTrackUserTimestampExtension.lengthBytes, + + timestamp: this.timestamp, + }; + } +} + +export class DataTrackE2eeExtension extends DataTrackExtension { + static tag = DataTrackExtensionTag.E2ee; + + static lengthBytes = 13; + + private keyIndex: number; + + private iv: Uint8Array; /* NOTE: According to the rust implementation, this should be 12 bytes long. */ + + constructor(keyIndex: number, iv: Uint8Array) { + super(); + this.keyIndex = keyIndex; + this.iv = iv; + } + + toBinaryLengthBytes(): number { + return ( + U16_LENGTH_BYTES /* tag */ + + U16_LENGTH_BYTES /* length */ + + DataTrackE2eeExtension.lengthBytes + ); + } + + toBinaryInto(dataView: DataView): Throws { + let byteIndex = 0; + + dataView.setUint16(byteIndex, DataTrackE2eeExtension.tag); + byteIndex += U16_LENGTH_BYTES; + + const rtpOrientedLength = DataTrackE2eeExtension.lengthBytes - 1; + dataView.setUint16(byteIndex, rtpOrientedLength); + byteIndex += U16_LENGTH_BYTES; + + dataView.setUint8(byteIndex, this.keyIndex); + byteIndex += U8_LENGTH_BYTES; + + for (let i = 0; i < this.iv.length; i += 1) { + dataView.setUint8(byteIndex, this.iv[i]); + byteIndex += U8_LENGTH_BYTES; + } + + const totalLengthBytes = this.toBinaryLengthBytes(); + if (byteIndex !== totalLengthBytes) { + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error( + `DataTrackE2eeExtension.toBinaryInto: Wrote ${byteIndex} bytes but expected length was ${totalLengthBytes} bytes`, + ); + } + + return byteIndex; + } + + toJSON() { + return { + tag: DataTrackE2eeExtension.tag as number, + lengthBytes: DataTrackE2eeExtension.lengthBytes, + + keyIndex: this.keyIndex, + iv: this.iv, + }; + } +} + +export class DataTrackExtensions extends Serializable { + userTimestamp?: DataTrackUserTimestampExtension; + + e2ee?: DataTrackE2eeExtension; + + constructor( + opts: { userTimestamp?: DataTrackUserTimestampExtension; e2ee?: DataTrackE2eeExtension } = {}, + ) { + super(); + this.userTimestamp = opts.userTimestamp; + this.e2ee = opts.e2ee; + } + + toBinaryLengthBytes() { + let lengthBytes = 0; + if (this.userTimestamp) { + lengthBytes += this.userTimestamp.toBinaryLengthBytes(); + } + if (this.e2ee) { + lengthBytes += this.e2ee.toBinaryLengthBytes(); + } + return lengthBytes; + } + + toBinaryInto(dataView: DataView): Throws { + let byteIndex = 0; + + if (this.e2ee) { + const e2eeBytes = this.e2ee.toBinaryInto(dataView); + byteIndex += e2eeBytes; + } + + if (this.userTimestamp) { + const userTimestampBytes = this.userTimestamp.toBinaryInto( + new DataView(dataView.buffer, dataView.byteOffset + byteIndex), + ); + byteIndex += userTimestampBytes; + } + + const totalLengthBytes = this.toBinaryLengthBytes(); + if (byteIndex !== totalLengthBytes) { + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error( + `DataTrackExtensions.toBinaryInto: Wrote ${byteIndex} bytes but expected length was ${totalLengthBytes} bytes`, + ); + } + + return byteIndex; + } + + static fromBinary( + input: Input, + ): Throws< + [extensions: DataTrackExtensions, byteLength: number], + DataTrackDeserializeError + > { + const dataView = coerceToDataView(input); + + let userTimestamp: DataTrackUserTimestampExtension | undefined; + let e2ee: DataTrackE2eeExtension | undefined; + + let byteIndex = 0; + while (dataView.byteLength - byteIndex >= U16_LENGTH_BYTES + U16_LENGTH_BYTES) { + const tag = dataView.getUint16(byteIndex); + byteIndex += U16_LENGTH_BYTES; + + const rtpOrientedLength = dataView.getUint16(byteIndex); + const lengthBytes = rtpOrientedLength + 1; + byteIndex += U16_LENGTH_BYTES; + + if (tag === EXT_TAG_PADDING) { + // Skip padding + continue; + } + + switch (tag) { + case DataTrackExtensionTag.UserTimestamp: + if (dataView.byteLength - byteIndex < DataTrackUserTimestampExtension.lengthBytes) { + throw DataTrackDeserializeError.malformedExt(tag); + } + userTimestamp = new DataTrackUserTimestampExtension(dataView.getBigUint64(byteIndex)); + byteIndex += lengthBytes; + break; + + case DataTrackExtensionTag.E2ee: + if (dataView.byteLength - byteIndex < DataTrackE2eeExtension.lengthBytes) { + throw DataTrackDeserializeError.malformedExt(tag); + } + + const keyIndex = dataView.getUint8(byteIndex); + + const iv = new Uint8Array(12); + for (let i = 0; i < iv.length; i += 1) { + let byteIndexForIv = byteIndex; + byteIndexForIv += U8_LENGTH_BYTES; // key index + byteIndexForIv += i * U8_LENGTH_BYTES; // Index into iv array + iv[i] = dataView.getUint8(byteIndexForIv); + } + + e2ee = new DataTrackE2eeExtension(keyIndex, iv); + byteIndex += lengthBytes; + break; + + default: + // Skip over unknown extensions (forward compatible). + if (dataView.byteLength - byteIndex < lengthBytes) { + throw DataTrackDeserializeError.malformedExt(tag); + } + byteIndex += lengthBytes; + break; + } + } + + // NOTE: padding bytes after extension data is intentionally not being processed + + return [new DataTrackExtensions({ userTimestamp, e2ee }), dataView.byteLength]; + } + + toJSON() { + return { + userTimestamp: this.userTimestamp?.toJSON() ?? null, + e2ee: this.e2ee?.toJSON() ?? null, + }; + } +} diff --git a/src/room/data-track/packet/index.test.ts b/src/room/data-track/packet/index.test.ts new file mode 100644 index 0000000000..6c9abdf1e1 --- /dev/null +++ b/src/room/data-track/packet/index.test.ts @@ -0,0 +1,615 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DataTrackPacket, DataTrackPacketHeader, FrameMarker } from '.'; +import { DataTrackHandle } from '../handle'; +import { DataTrackTimestamp, WrapAroundUnsignedInt } from '../utils'; +import { EXT_FLAG_SHIFT } from './constants'; +import { + DataTrackE2eeExtension, + DataTrackExtensionTag, + DataTrackExtensions, + DataTrackUserTimestampExtension, +} from './extensions'; + +describe('DataTrackPacket', () => { + describe('Serialization', () => { + it('should serialize a single packet', () => { + const header = new DataTrackPacketHeader({ + marker: FrameMarker.Single, + trackHandle: DataTrackHandle.fromNumber(101), + sequence: WrapAroundUnsignedInt.u16(102), + frameNumber: WrapAroundUnsignedInt.u16(103), + timestamp: DataTrackTimestamp.fromRtpTicks(104), + }); + + const payloadBytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + + const packet = new DataTrackPacket(header, payloadBytes.buffer); + + expect(packet.toBinaryLengthBytes()).toStrictEqual(22); + expect(packet.toBinary()).toStrictEqual( + new Uint8Array([ + 0x18, // Version 0, single, extension + 0, // Reserved + 0, // Track handle (big endian) + 101, + 0, // Sequence (big endian) + 102, + 0, // Frame number (big endian) + 103, + 0, // Timestamp (big endian) + 0, + 0, + 104, + /* (No extension words value) */ + 0, // Payload + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + ); + }); + it('should serialize a final packet with extensions', () => { + const header = new DataTrackPacketHeader({ + marker: FrameMarker.Final, + trackHandle: DataTrackHandle.fromNumber(0x8811), + sequence: WrapAroundUnsignedInt.u16(0x4422), + frameNumber: WrapAroundUnsignedInt.u16(0x4411), + timestamp: DataTrackTimestamp.fromRtpTicks(0x44221188), + extensions: new DataTrackExtensions({ + userTimestamp: new DataTrackUserTimestampExtension(0x4411221111118811n), + e2ee: new DataTrackE2eeExtension(0xfa, new Uint8Array(12).fill(0x3c)), + }), + }); + + const payloadBytes = new Uint8Array(32).fill(0xfa); + + const packet = new DataTrackPacket(header, payloadBytes.buffer); + + expect(packet.toBinaryLengthBytes()).toStrictEqual(78); + expect(packet.toBinary()).toStrictEqual( + new Uint8Array([ + 0xc, // Version 0, final, extension + 0, // Reserved + 136, // Track handle (big endian) + 17, + 68, // Sequence (big endian) + 34, + 68, // Frame number (big endian) + 17, + 68, // Timestamp (big endian) + 34, + 17, + 136, + 0, // Rtp oriented extension words (big endian) + 7, + + // E2ee extension + 0, // ID 1 (big endian) + 1, + 0, // Length 12 (big endian) + 12, + 0xfa, // Key index + 0x3c, // Iv array + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + + // User timestamp extension + 0, // ID 2 (big endian) + 2, + 0, // Length 7 (big endian) + 7, + 68, // Timestamp value (big endian) + 17, + 34, + 17, + 17, + 17, + 136, + 17, + + 0, // Extension padding + 0, + 0, + + 0xfa, // Payload + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + ]), + ); + }); + it('should serialize a start packet with only the e2ee extension', () => { + const header = new DataTrackPacketHeader({ + marker: FrameMarker.Start, + trackHandle: DataTrackHandle.fromNumber(101), + sequence: WrapAroundUnsignedInt.u16(102), + frameNumber: WrapAroundUnsignedInt.u16(103), + timestamp: DataTrackTimestamp.fromRtpTicks(104), + extensions: new DataTrackExtensions({ + e2ee: new DataTrackE2eeExtension(0xfa, new Uint8Array(12).fill(0x3c)), + }), + }); + + const payloadBytes = new Uint8Array(32).fill(0xfa); + + const packet = new DataTrackPacket(header, payloadBytes.buffer); + + expect(packet.toBinaryLengthBytes()).toStrictEqual(66); + expect(packet.toBinary()).toStrictEqual( + new Uint8Array([ + 0x14, // Version 0, start, extension + 0, // Reserved + 0, // Track handle (big endian) + 101, + 0, // Sequence (big endian) + 102, + 0, // Frame number (big endian) + 103, + 0, // Timestamp (big endian) + 0, + 0, + 104, + 0, // RTP oriented extension words (big endian) + 4, + + // E2ee extension + 0, // ID 1 (big endian) + 1, + 0, // Length 12 (big endian) + 12, + 0xfa, // Key index + 0x3c, // Iv array + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + + 0, // Extension padding + 0, + 0, + + 0xfa, // Payload + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + 0xfa, + ]), + ); + }); + + it('should be unable to serialize a packet header into a DataView which is too small', () => { + const header = new DataTrackPacketHeader({ + marker: FrameMarker.Single, + trackHandle: DataTrackHandle.fromNumber(101), + sequence: WrapAroundUnsignedInt.u16(102), + frameNumber: WrapAroundUnsignedInt.u16(103), + timestamp: DataTrackTimestamp.fromRtpTicks(104), + }); + const payloadBytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + const packet = new DataTrackPacket(header, payloadBytes.buffer); + + const twoByteLongDataView = new DataView(new ArrayBuffer(2)); + expect(() => packet.toBinaryInto(twoByteLongDataView)).toThrow('Buffer cannot fit header'); + }); + it('should be unable to serialize a packet payload into a DataView which is too small', () => { + const header = new DataTrackPacketHeader({ + marker: FrameMarker.Single, + trackHandle: DataTrackHandle.fromNumber(101), + sequence: WrapAroundUnsignedInt.u16(102), + frameNumber: WrapAroundUnsignedInt.u16(103), + timestamp: DataTrackTimestamp.fromRtpTicks(104), + }); + const payloadBytes = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); + const packet = new DataTrackPacket(header, payloadBytes.buffer); + + const fourteenByteLongDataView = new DataView( + new ArrayBuffer(14 /* 12 byte header + 2 extra bytes */), + ); + expect(() => packet.toBinaryInto(fourteenByteLongDataView)).toThrow( + 'Buffer cannot fit payload', + ); + }); + }); + + describe('Deserialization', () => { + const VALID_PACKET_BYTES = [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]; + + it('should deserialize a single packet', () => { + const [packet, bytes] = DataTrackPacket.fromBinary( + new Uint8Array([ + 0x18, // Version 0, single, extension + 0, // Reserved + 0, // Track handle (big endian) + 101, + 0, // Sequence (big endian) + 102, + 0, // Frame number (big endian) + 103, + 0, // Timestamp (big endian) + 0, + 0, + 104, + /* (No extension words value) */ + 1, // Payload + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + ); + + expect(bytes).toStrictEqual(21); + expect(packet.toJSON()).toStrictEqual({ + header: { + frameNumber: 103, + marker: FrameMarker.Single, + sequence: 102, + timestamp: 104, + trackHandle: 101, + extensions: { + e2ee: null, + userTimestamp: null, + }, + }, + payload: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]).buffer, + }); + }); + + it('should fail to deserialize a too short buffer', () => { + const packetBytes = new Uint8Array(VALID_PACKET_BYTES); + + expect(() => DataTrackPacket.fromBinary(packetBytes.slice(0, 5))).toThrow( + 'Too short to contain a valid header', + ); + }); + + it('should fail to deserialize a packet including extensions but missing the ext words value', () => { + const packetBytes = new Uint8Array(VALID_PACKET_BYTES); + packetBytes[0] |= 1 << EXT_FLAG_SHIFT; // Extension flag - should have ext word indicator here + + expect(() => DataTrackPacket.fromBinary(packetBytes)).toThrow( + 'Extension word indicator is missing', + ); + }); + + it('should fail to deserialize a packet which overruns headers', () => { + const packetBytes = new Uint8Array([ + ...VALID_PACKET_BYTES, + + 0, // Extension word (big endian) + 1, + ]); + packetBytes[0] |= 1 << EXT_FLAG_SHIFT; // Extension flag - should have ext word indicator here + + expect(() => DataTrackPacket.fromBinary(packetBytes)).toThrow( + 'Header exceeds total packet length', + ); + }); + + it('should fail to deserialize a packet with an unsupported version', () => { + const packetBytes = new Uint8Array(VALID_PACKET_BYTES); + packetBytes[0] = 0x20; // Version 1 (not supported yet) + + expect(() => DataTrackPacket.fromBinary(packetBytes)).toThrow('Unsupported version 1'); + }); + + it('should deserialize base header', () => { + const [packet, bytes] = DataTrackPacket.fromBinary( + new Uint8Array([ + 0x8, // Version 0, final, extension + 0x0, // Reserved + 0x88, // Track handle (big endian) + 0x11, + 0x44, // Sequence (big endian) + 0x22, + 0x44, // Frame number (big endian) + 0x11, + 0x44, // Timestamp (big endian) + 0x22, + 0x11, + 0x88, + ]), + ); + + expect(bytes).toStrictEqual(12); + expect(packet.toJSON()).toStrictEqual({ + header: { + marker: FrameMarker.Final, + trackHandle: 0x8811, + sequence: 0x4422, + frameNumber: 0x4411, + timestamp: 0x44221188, + extensions: { + e2ee: null, + userTimestamp: null, + }, + }, + payload: new Uint8Array([]).buffer, + }); + }); + + it.each([0, 1, 24])('should skip extension padding', (extensionWords) => { + const packetBytes = new Uint8Array([ + ...VALID_PACKET_BYTES, + + 0, // Extension words (big endian) + extensionWords, + + ...new Array((extensionWords + 1) /* RTP oriented extension words */ * 4).fill(0), // Padding + ]); + packetBytes[0] |= 1 << EXT_FLAG_SHIFT; // Extension flag + + const [packet] = DataTrackPacket.fromBinary(packetBytes); + + expect(new Uint8Array(packet.toJSON().payload).byteLength).toStrictEqual(0); + }); + + it('should deserialize e2ee extension properly', () => { + const packetBytes = new Uint8Array([ + ...VALID_PACKET_BYTES, + + 0, // RTP oriented extension words (big endian) + 4, + + // E2ee extension + 0, // ID 1 (big endian) + 1, + 0, // Length 12 (big endian) + 12, + 0xfa, // Key index + 0x3c, // Iv array + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + 0x3c, + + 0, // Padding + 0, + 0, + ]); + packetBytes[0] |= 1 << EXT_FLAG_SHIFT; // Extension flag + + const [packet] = DataTrackPacket.fromBinary(packetBytes); + + expect(packet.toJSON().header.extensions.e2ee).toStrictEqual({ + tag: DataTrackExtensionTag.E2ee, + lengthBytes: 13, + keyIndex: 0xfa, + iv: new Uint8Array(12).fill(0x3c), + }); + }); + + it('should deserialize user timestamp extension properly', () => { + const packetBytes = new Uint8Array([ + ...VALID_PACKET_BYTES, + + 0, // Extension words (big endian) + 2, + + // User timestamp extension + 0, // ID 2 (big endian) + 2, + 0, // Length 7 (big endian) + 7, + 0x44, // Timestamp (big endian) + 0x11, + 0x22, + 0x11, + 0x11, + 0x11, + 0x88, + 0x11, + ]); + packetBytes[0] |= 1 << EXT_FLAG_SHIFT; // Extension flag + + const [packet] = DataTrackPacket.fromBinary(packetBytes); + + expect(packet.toJSON().header.extensions.userTimestamp).toStrictEqual({ + tag: DataTrackExtensionTag.UserTimestamp, + lengthBytes: 8, + timestamp: 0x4411221111118811n, + }); + }); + + it('should deserialize unknown extension properly', () => { + const packetBytes = new Uint8Array([ + ...VALID_PACKET_BYTES, + + 0, // RTP oriented extension words (big endian) + 2, + + // Unknown / potential future extension + 0, // ID 8 (big endian) + 8, + 0, // Length 12 (big endian) + 6, + 0x1, // Payload + 0x2, + 0x3, + 0x4, + 0x5, + 0x6, + + 0x0, // Padding + 0x0, + ]); + packetBytes[0] |= 1 << EXT_FLAG_SHIFT; // Extension flag + + const [packet] = DataTrackPacket.fromBinary(packetBytes); + + expect(packet.toJSON().header.extensions).toStrictEqual({ + userTimestamp: null, + e2ee: null, + }); + }); + + it('should ensure extensions are word aligned', () => { + const packetBytes = new Uint8Array([ + ...VALID_PACKET_BYTES, + + 0, // RTP oriented extension words (big endian) + 0, + + 0x0, // Padding, missing one byte + 0x0, + 0x0, + ]); + packetBytes[0] |= 1 << EXT_FLAG_SHIFT; // Extension flag + + expect(() => DataTrackPacket.fromBinary(packetBytes)).toThrow( + 'Header exceeds total packet length', + ); + }); + }); + + describe('Round trip serialization + deserialization', () => { + it('should serialize a single packet', () => { + const header = new DataTrackPacketHeader({ + marker: FrameMarker.Single, + trackHandle: DataTrackHandle.fromNumber(101), + sequence: WrapAroundUnsignedInt.u16(102), + frameNumber: WrapAroundUnsignedInt.u16(103), + timestamp: DataTrackTimestamp.fromRtpTicks(104), + }); + + const payloadBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]); + + const encodedPacket = new DataTrackPacket(header, payloadBytes.buffer); + + expect(encodedPacket.toBinaryLengthBytes()).toStrictEqual(21); + expect(encodedPacket.toBinary()).toStrictEqual( + new Uint8Array([ + 0x18, // Version 0, single, extension + 0, // Reserved + 0, // Track handle (big endian) + 101, + 0, // Sequence (big endian) + 102, + 0, // Frame number (big endian) + 103, + 0, // Timestamp (big endian) + 0, + 0, + 104, + /* (No extension words value) */ + 1, // Payload + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ]), + ); + + const [decodedPacket, bytes] = DataTrackPacket.fromBinary(encodedPacket.toBinary()); + + expect(bytes).toStrictEqual(21); + expect(decodedPacket.toJSON()).toStrictEqual({ + header: { + frameNumber: 103, + marker: FrameMarker.Single, + sequence: 102, + timestamp: 104, + trackHandle: 101, + extensions: { + e2ee: null, + userTimestamp: null, + }, + }, + payload: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]).buffer, + }); + }); + }); +}); diff --git a/src/room/data-track/packet/index.ts b/src/room/data-track/packet/index.ts new file mode 100644 index 0000000000..20cf6c9488 --- /dev/null +++ b/src/room/data-track/packet/index.ts @@ -0,0 +1,361 @@ +import { type Throws } from '../../../utils/throws'; +import { DataTrackHandle, DataTrackHandleError, DataTrackHandleErrorReason } from '../handle'; +import { + DataTrackTimestamp, + U16_MAX_SIZE, + WrapAroundUnsignedInt, + coerceToDataView, +} from '../utils'; +import { + BASE_HEADER_LEN, + EXT_FLAG_MASK, + EXT_FLAG_SHIFT, + EXT_WORDS_INDICATOR_SIZE, + FRAME_MARKER_FINAL, + FRAME_MARKER_INTER, + FRAME_MARKER_MASK, + FRAME_MARKER_SHIFT, + FRAME_MARKER_SINGLE, + FRAME_MARKER_START, + SUPPORTED_VERSION, + U8_LENGTH_BYTES, + U16_LENGTH_BYTES, + U32_LENGTH_BYTES, + VERSION_MASK, + VERSION_SHIFT, +} from './constants'; +import { + DataTrackDeserializeError, + type DataTrackDeserializeErrorAll, + DataTrackSerializeError, + type DataTrackSerializeErrorAll, + DataTrackSerializeErrorReason, +} from './errors'; +import { DataTrackExtensions } from './extensions'; +import Serializable from './serializable'; + +/** A class for serializing / deserializing data track packet header sections. */ +export class DataTrackPacketHeader extends Serializable { + marker: FrameMarker; + + trackHandle: DataTrackHandle; + + sequence: WrapAroundUnsignedInt; + + frameNumber: WrapAroundUnsignedInt; + + timestamp: DataTrackTimestamp<90_000>; + + extensions: DataTrackExtensions; + + constructor(opts: { + marker: FrameMarker; + trackHandle: DataTrackHandle; + sequence: WrapAroundUnsignedInt; + frameNumber: WrapAroundUnsignedInt; + timestamp: DataTrackTimestamp<90_000>; + extensions?: DataTrackExtensions; + }) { + super(); + this.marker = opts.marker; + this.trackHandle = opts.trackHandle; + this.sequence = opts.sequence; + this.frameNumber = opts.frameNumber; + this.timestamp = opts.timestamp; + this.extensions = opts.extensions ?? new DataTrackExtensions(); + } + + private extensionsMetrics() { + const lengthBytes = this.extensions.toBinaryLengthBytes(); + const lengthWords = Math.ceil(lengthBytes / 4); + const paddingLengthBytes = lengthWords * 4 - lengthBytes; + + return { lengthBytes, lengthWords, paddingLengthBytes }; + } + + toBinaryLengthBytes() { + const { lengthBytes: extLengthBytes, paddingLengthBytes: extPaddingLengthBytes } = + this.extensionsMetrics(); + + let totalLengthBytes = BASE_HEADER_LEN; + if (extLengthBytes > 0) { + totalLengthBytes += EXT_WORDS_INDICATOR_SIZE + extLengthBytes + extPaddingLengthBytes; + } + return totalLengthBytes; + } + + toBinaryInto( + dataView: DataView, + ): Throws> { + if (dataView.byteLength < this.toBinaryLengthBytes()) { + throw DataTrackSerializeError.tooSmallForHeader(); + } + + let initial = SUPPORTED_VERSION << VERSION_SHIFT; + + let marker; + switch (this.marker) { + case FrameMarker.Inter: + marker = FRAME_MARKER_INTER; + break; + case FrameMarker.Final: + marker = FRAME_MARKER_FINAL; + break; + case FrameMarker.Start: + marker = FRAME_MARKER_START; + break; + case FrameMarker.Single: + marker = FRAME_MARKER_SINGLE; + break; + } + initial |= marker << FRAME_MARKER_SHIFT; + + const { + lengthBytes: extensionsLengthBytes, + lengthWords: extensionsLengthWords, + paddingLengthBytes: extensionsPaddingLengthBytes, + } = this.extensionsMetrics(); + + if (extensionsLengthBytes > 0) { + initial |= 1 << EXT_FLAG_SHIFT; + } + + let byteIndex = 0; + dataView.setUint8(byteIndex, initial); + byteIndex += U8_LENGTH_BYTES; + dataView.setUint8(byteIndex, 0); // Reserved + byteIndex += U8_LENGTH_BYTES; + + dataView.setUint16(byteIndex, this.trackHandle.value); + byteIndex += U16_LENGTH_BYTES; + dataView.setUint16(byteIndex, this.sequence.value); + byteIndex += U16_LENGTH_BYTES; + dataView.setUint16(byteIndex, this.frameNumber.value); + byteIndex += U16_LENGTH_BYTES; + dataView.setUint32(byteIndex, this.timestamp.asTicks()); + byteIndex += U32_LENGTH_BYTES; + + if (extensionsLengthBytes > 0) { + // NOTE: The protocol is implemented in a way where if the extension bit is set, any + // deserializer assumes the extensions section is at least one byte long, and the "length" + // field represents the "number of additional bytes" long the extensions section is. This is + // potentially unintuitive so I wanted to call it out. + const rtpOrientedExtensionLengthWords = extensionsLengthWords - 1; + + dataView.setUint16(byteIndex, rtpOrientedExtensionLengthWords); + byteIndex += U16_LENGTH_BYTES; + const extensionBytes = this.extensions.toBinaryInto( + new DataView(dataView.buffer, dataView.byteOffset + byteIndex), + ); + byteIndex += extensionBytes; + for (let i = 0; i < extensionsPaddingLengthBytes; i += 1) { + dataView.setUint8(byteIndex, 0); + byteIndex += U8_LENGTH_BYTES; + } + } + + const totalLengthBytes = this.toBinaryLengthBytes(); + if (byteIndex !== totalLengthBytes) { + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error( + `DataTrackPacketHeader.toBinaryInto: Wrote ${byteIndex} bytes but expected length was ${totalLengthBytes} bytes`, + ); + } + + return totalLengthBytes; + } + + static fromBinary( + input: Input, + ): Throws<[header: DataTrackPacketHeader, byteLength: number], DataTrackDeserializeErrorAll> { + const dataView = coerceToDataView(input); + + if (dataView.byteLength < BASE_HEADER_LEN) { + throw DataTrackDeserializeError.tooShort(); + } + + let byteIndex = 0; + + const initial = dataView.getUint8(byteIndex); + byteIndex += U8_LENGTH_BYTES; + + const version = (initial >> VERSION_SHIFT) & VERSION_MASK; + if (version > SUPPORTED_VERSION) { + throw DataTrackDeserializeError.unsupportedVersion(version); + } + + let marker; + switch ((initial >> FRAME_MARKER_SHIFT) & FRAME_MARKER_MASK) { + case FRAME_MARKER_START: + marker = FrameMarker.Start; + break; + case FRAME_MARKER_FINAL: + marker = FrameMarker.Final; + break; + case FRAME_MARKER_SINGLE: + marker = FrameMarker.Single; + break; + case FRAME_MARKER_INTER: + default: + marker = FrameMarker.Inter; + break; + } + + const extensionsFlag = ((initial >> EXT_FLAG_SHIFT) & EXT_FLAG_MASK) > 0; + + byteIndex += U8_LENGTH_BYTES; // Reserved + + let trackHandle: DataTrackHandle | undefined; + try { + trackHandle = DataTrackHandle.fromNumber(dataView.getUint16(byteIndex)); + } catch (e) { + if ( + e instanceof DataTrackHandleError && + (e.isReason(DataTrackHandleErrorReason.Reserved) || + e.isReason(DataTrackHandleErrorReason.TooLarge)) + ) { + throw DataTrackDeserializeError.invalidHandle(e); + } else { + throw e; + } + } + byteIndex += U16_LENGTH_BYTES; + + const sequence = WrapAroundUnsignedInt.u16(dataView.getUint16(byteIndex)); + byteIndex += U16_LENGTH_BYTES; + + const frameNumber = WrapAroundUnsignedInt.u16(dataView.getUint16(byteIndex)); + byteIndex += U16_LENGTH_BYTES; + + const timestamp = DataTrackTimestamp.fromRtpTicks(dataView.getUint32(byteIndex)); + byteIndex += U32_LENGTH_BYTES; + + let extensions = new DataTrackExtensions(); + if (extensionsFlag) { + if (dataView.byteLength - byteIndex < U16_LENGTH_BYTES) { + throw DataTrackDeserializeError.missingExtWords(); + } + let rtpOrientedExtensionWords = dataView.getUint16(byteIndex); + byteIndex += U16_LENGTH_BYTES; + + // NOTE: The protocol is implemented in a way where if the extension bit is set, any + // deserializer assumes the extensions section is at least one byte long, and the "length" + // field represents the "number of additional bytes" long the extensions section is. This is + // potentially unintuitive so I wanted to call it out. + const extensionWords = rtpOrientedExtensionWords + 1; + + let extensionLengthBytes = 4 * extensionWords; + + if (byteIndex + extensionLengthBytes > dataView.byteLength) { + throw DataTrackDeserializeError.headerOverrun(); + } + + let extensionDataView = new DataView( + dataView.buffer, + dataView.byteOffset + byteIndex, + extensionLengthBytes, + ); + + const [result, readBytes] = DataTrackExtensions.fromBinary(extensionDataView); + extensions = result; + byteIndex += readBytes; + } + + return [ + new DataTrackPacketHeader({ + marker, + trackHandle: trackHandle!, + sequence, + frameNumber, + timestamp, + extensions, + }), + byteIndex, + ]; + } + + toJSON() { + return { + marker: this.marker, + trackHandle: this.trackHandle.value, + sequence: this.sequence.value, + frameNumber: this.frameNumber.value, + timestamp: this.timestamp.timestamp, + extensions: this.extensions.toJSON(), + }; + } +} + +/** Marker indicating a packet's position in relation to a frame. */ +export enum FrameMarker { + /** Packet is the first in a frame. */ + Start = 0, + /** Packet is within a frame. */ + Inter = 1, + /** Packet is the last in a frame. */ + Final = 2, + /** Packet is the only one in a frame. */ + Single = 3, +} + +/** A class for serializing / deserializing data track packets. */ +export class DataTrackPacket extends Serializable { + header: DataTrackPacketHeader; + + payload: ArrayBuffer; + + constructor(header: DataTrackPacketHeader, payload: ArrayBuffer) { + super(); + this.header = header; + this.payload = payload; + } + + toBinaryLengthBytes() { + return this.header.toBinaryLengthBytes() + this.payload.byteLength; + } + + toBinaryInto(dataView: DataView): Throws { + let byteIndex = 0; + const headerLengthBytes = this.header.toBinaryInto(dataView); + byteIndex += headerLengthBytes; + + if (dataView.byteLength - byteIndex < this.payload.byteLength) { + throw DataTrackSerializeError.tooSmallForPayload(); + } + + const payloadBytes = new Uint8Array(this.payload); + for (let index = 0; index < payloadBytes.length; index += 1) { + dataView.setUint8(byteIndex, payloadBytes[index]); + byteIndex += U8_LENGTH_BYTES; + } + + const totalLengthBytes = this.toBinaryLengthBytes(); + if (byteIndex !== totalLengthBytes) { + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error( + `DataTrackPacket.toBinaryInto: Wrote ${byteIndex} bytes but expected length was ${totalLengthBytes} bytes`, + ); + } + + return totalLengthBytes; + } + + static fromBinary( + input: Input, + ): Throws<[packet: DataTrackPacket, byteLength: number], DataTrackDeserializeErrorAll> { + const dataView = coerceToDataView(input); + + const [header, headerByteLength] = DataTrackPacketHeader.fromBinary(dataView); + + const payload = dataView.buffer.slice( + dataView.byteOffset + headerByteLength, + dataView.byteOffset + dataView.byteLength, + ); + + return [new DataTrackPacket(header, payload), dataView.byteLength] as [DataTrackPacket, number]; + } + + toJSON() { + return { header: this.header.toJSON(), payload: this.payload }; + } +} diff --git a/src/room/data-track/packet/serializable.ts b/src/room/data-track/packet/serializable.ts new file mode 100644 index 0000000000..2176104516 --- /dev/null +++ b/src/room/data-track/packet/serializable.ts @@ -0,0 +1,29 @@ +import { type Throws } from '../../../utils/throws'; +import { type DataTrackSerializeErrorAll } from './errors'; + +/** An abstract class implementing common behavior related to data track binary serialization. */ +export default abstract class Serializable { + /** Returns the expected length of the serialized output in bytes */ + abstract toBinaryLengthBytes(): number; + + /** Given a DataView, serialize the instance inside and return the number of bytes written. */ + abstract toBinaryInto(dataView: DataView): Throws; + + /** Encodes the instance as binary and returns the data as a Uint8Array. */ + toBinary(): Throws { + const lengthBytes = this.toBinaryLengthBytes(); + const output = new ArrayBuffer(lengthBytes); + const view = new DataView(output); + + const writtenBytes = this.toBinaryInto(view); + + if (lengthBytes !== writtenBytes) { + // @throws-transformer ignore - this should be treated as a "panic" and not be caught + throw new Error( + `${this.constructor.name}.toBinary: written bytes (${writtenBytes} bytes) not equal to allocated array buffer length (${lengthBytes} bytes).`, + ); + } + + return new Uint8Array(output); // FIXME: return uint8array here? Or the arraybuffer? + } +} diff --git a/src/room/data-track/utils.test.ts b/src/room/data-track/utils.test.ts new file mode 100644 index 0000000000..da82773ef7 --- /dev/null +++ b/src/room/data-track/utils.test.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { WrapAroundUnsignedInt } from './utils'; + +describe('WrapAroundUnsignedInt', () => { + it('should test initialization + edge cases', () => { + expect(WrapAroundUnsignedInt.u16(1).value).toEqual(1); + expect(WrapAroundUnsignedInt.u16(196_609 /* (3 * 65536) + 1 */).value).toEqual(1); + expect(() => WrapAroundUnsignedInt.u16(-1).value).toThrow( + 'WrapAroundUnsignedInt: cannot faithfully represent an integer smaller than 0', + ); + }); + it('should test explicit positive wrap around behavior', () => { + const n = WrapAroundUnsignedInt.u16(65534); + n.update((v) => v + 1); + expect(n.value).toBe(65535); + n.update((v) => v + 1); + expect(n.value).toBe(0); + }); + + it('should test explicit negative wrap around behavior', () => { + const n = WrapAroundUnsignedInt.u16(1); + n.update((v) => v - 1); + expect(n.value).toBe(0); + n.update((v) => v - 1); + expect(n.value).toBe(65535); + }); +}); diff --git a/src/room/data-track/utils.ts b/src/room/data-track/utils.ts new file mode 100644 index 0000000000..b747732841 --- /dev/null +++ b/src/room/data-track/utils.ts @@ -0,0 +1,86 @@ +export const U16_MAX_SIZE = 0xffff; + +/** + * A number of fields withing the data tracks packet specification assume wrap around behavior when + * an unsigned type is incremented beyond its max size (ie, the packet `sequence` field). This + * wrapper type manually reimplements this wrap around behavior given javascript's lack of fixed + * size integer types. + */ +export class WrapAroundUnsignedInt { + value: number; + + private maxSize: MaxSize; + + static u16(raw: number) { + return new WrapAroundUnsignedInt(raw, U16_MAX_SIZE); + } + + constructor(raw: number, maxSize: MaxSize) { + this.value = raw; + if (raw < 0) { + throw new Error( + 'WrapAroundUnsignedInt: cannot faithfully represent an integer smaller than 0', + ); + } + if (maxSize > Number.MAX_SAFE_INTEGER) { + throw new Error( + 'WrapAroundUnsignedInt: cannot faithfully represent an integer bigger than MAX_SAFE_INTEGER.', + ); + } + this.maxSize = maxSize; + this.clamp(); + } + + /** Manually clamp the given containing value according to the wrap around max size bounds. Use + * this after out of bounds modification to the contained value by external code. */ + clamp() { + while (this.value > this.maxSize) { + this.value -= this.maxSize + 1; + } + while (this.value < 0) { + this.value += this.maxSize + 1; + } + } + + /** When called, maps the containing value to a new containing value. After mapping, the wrap + * around external max size bounds are applied. */ + update(updateFn: (value: number) => number) { + this.value = updateFn(this.value); + this.clamp(); + } +} + +export class DataTrackTimestamp { + rateInHz: RateInHz; + + timestamp: number; + + static fromRtpTicks(rtpTicks: number) { + return new DataTrackTimestamp(rtpTicks, 90_000); + } + + asTicks() { + return this.timestamp; + } + + private constructor(raw: number, rateInHz: RateInHz) { + this.timestamp = raw; + this.rateInHz = rateInHz; + } +} + +export function coerceToDataView( + input: Input, +): DataView { + if (input instanceof DataView) { + return input; + } else if (input instanceof ArrayBuffer) { + return new DataView(input); + } else if (input instanceof Uint8Array) { + return new DataView(input.buffer, input.byteOffset, input.byteLength); + } else { + throw new Error( + `Error coercing ${input} to DataView - input was not DataView, ArrayBuffer, or Uint8Array.`, + ); + } +} diff --git a/src/room/errors.ts b/src/room/errors.ts index 4e1ba64bd3..0a6574b9aa 100644 --- a/src/room/errors.ts +++ b/src/room/errors.ts @@ -1,15 +1,33 @@ import { DisconnectReason, RequestResponse_Reason } from '@livekit/protocol'; +/** Base error that all LiveKit specific custom errors inherit from. */ export class LivekitError extends Error { code: number; - constructor(code: number, message?: string) { - super(message || 'an error has occured'); + // More info: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause + cause?: unknown; + + constructor(code: number, message?: string, options?: { cause?: unknown }) { + super(message || 'an error has occurred'); this.name = 'LiveKitError'; this.code = code; + + if (typeof options?.cause !== 'undefined') { + this.cause = options?.cause; + } } } +/** + * LiveKit specific error type representing an error with an associated set of reasons. + * Use this to represent an error with multiple different but contextually related variants. + * */ +export abstract class LivekitReasonedError extends LivekitError { + abstract reason: Reason; + + abstract reasonName: string; +} + export class SimulatedError extends LivekitError { readonly name = 'simulated'; @@ -89,7 +107,7 @@ type ConnectionErrorVariants = export class ConnectionError< Variant extends ConnectionErrorVariants = ConnectionErrorVariants, -> extends LivekitError { +> extends LivekitReasonedError { status?: Variant['status']; context: Variant['context']; @@ -233,7 +251,7 @@ export type RequestErrorReason = | Exclude | 'TimeoutError'; -export class SignalRequestError extends LivekitError { +export class SignalRequestError extends LivekitReasonedError { readonly name = 'SignalRequestError'; reason: RequestErrorReason; @@ -271,7 +289,7 @@ export enum DataStreamErrorReason { EncryptionTypeMismatch = 8, } -export class DataStreamError extends LivekitError { +export class DataStreamError extends LivekitReasonedError { readonly name = 'DataStreamError'; reason: DataStreamErrorReason; diff --git a/src/utils/TypedPromise.ts b/src/utils/TypedPromise.ts index 3a5c4a8240..bcd9752455 100644 --- a/src/utils/TypedPromise.ts +++ b/src/utils/TypedPromise.ts @@ -14,6 +14,13 @@ export default class TypedPromise extends Promise { return super.catch(onrejected); } + static resolve: { + (): TypedPromise; + (value: V): TypedPromise, never>; + } = (value?: V): TypedPromise, never> => { + return super.resolve(value) as TypedPromise, never>; + }; + static reject(reason: E): TypedPromise { return super.reject(reason); } diff --git a/src/utils/throws.ts b/src/utils/throws.ts new file mode 100644 index 0000000000..063fe86f7a --- /dev/null +++ b/src/utils/throws.ts @@ -0,0 +1,40 @@ +/** + * Branded type that encodes possible thrown errors in the return type. + * + * Usage: + * function fetchUser(id: string): Throws { ... } + * + * The actual runtime value is just T - the error types are phantom types + * that exist only for static analysis. + * + * To indicate that a function shouldn't throw any errors, make it return `Throws`. + * + * For more info about how this is checked, see ./throws-transformer at the root of this repo. + */ +export type Throws = T & { readonly __throws?: E }; + +/** + * Extract the error types from a Throws type. + */ +export type ExtractErrors = T extends Throws ? E : never; + +/** + * Extract the success type from a Throws type. + */ +export type ExtractSuccess = T extends Throws ? S : T; + +/** + * Combine error types from multiple Throws types. + */ +export type CombineErrors = T extends [infer First, ...infer Rest] + ? ExtractErrors | CombineErrors + : never; + +/** + * Helper to propagate errors - use this when your function calls other + * throwing functions and wants to propagate their errors. + */ +export type PropagatesErrors = Throws< + ExtractSuccess, + ExtractErrors | AdditionalErrors +>; diff --git a/throws-transformer/README.md b/throws-transformer/README.md new file mode 100644 index 0000000000..e565822dff --- /dev/null +++ b/throws-transformer/README.md @@ -0,0 +1,263 @@ +# throws-transformer + +A TypeScript transformer that enforces error handling via branded `Throws` types. + +## The Problem + +TypeScript doesn't have checked exceptions. When you call a function, you have no compile-time knowledge of what errors it might throw: + +```typescript +function parseJSON(input: string): User { + return JSON.parse(input); // Can throw SyntaxError - nothing tells you! +} +``` + +## The Solution + +This transformer introduces a `Throws` branded type that encodes possible errors in the return type: + +```typescript +function parseJSON(input: string): Throws { + // ... +} +``` + +The transformer then enforces that callers either: + +1. **Catch** the declared errors, or +2. **Propagate** them by declaring them in their own return type + +The `Throws` type is entirely opt in - ie, if a function doesn't return a branded type or call +functions within that return a branded type, the check will pass. This makes gradual migration +possible. + +If you have a situation where you would like to throw inside of a function annotated with a `Throws` +type and don't want this throw to be part of the `Throws` branded type (for example: throwing plain +`Error`s for assertion type cases that should result in "panic"s), you can add a +`// @throws-transformer ignore` comment above and the checker will skip validating the given throw. + +For example: +```typescript +// The below should validate successfully. +function parseJSON(input: string): Throws { + if (input.length === 0) { + // @throws-transformer ignore + throw new Error('Assertion failed, input was not empty.'); + } + + // ... +} +``` + + +## VS Code Setup (Recommended) + +To get real-time error squiggles in VS Code: + +### Step 1: Configure tsconfig.json + +```json +{ + "compilerOptions": { + "plugins": [{ "name": "./throws-transformer/plugin.ts" }] + } +} +``` + +### Step 2: Configure VS Code to use workspace TypeScript + +Create or update `.vscode/settings.json`: + +```json +{ + "typescript.tsdk": "node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true +} +``` + +### Step 3: Select TypeScript version + +1. Open any `.ts` file +2. Click the TypeScript version in the bottom-right status bar (e.g., "TypeScript 5.0.0") +3. Select "Use Workspace Version" + +Or run the command: `TypeScript: Select TypeScript Version...` + +### Step 4: Restart the TypeScript server + +Run command: `TypeScript: Restart TS Server` + +You should now see red squiggles for unhandled `Throws` errors! + +## Build-time Errors with ts-patch + +For errors during `tsc` compilation (CI, build scripts): + +```bash +npm install ts-patch +npx ts-patch install +``` + +Add the transformer to `tsconfig.json`: + +```json +{ + "compilerOptions": { + "plugins": [ + { "name": "./throws-transformer/plugin.ts" }, + { + "name": "throws-transformer/transformer", + "transform": "./throws-transformer/transformer.ts" + } + ] + } +} +``` + +Now `tsc` will emit errors for unhandled throws. + +## CLI Checker + +For quick checks without modifying your build: + +```bash +# Check specific files +npx tsx ./throws-transformer/cli.ts src/myfile.ts + +# Check multiple files +npx tsx ./throws-transformer/cli.ts src/*.ts +``` + +## Usage + +### 1. Define your functions with `Throws` + +```typescript +import { Throws } from "./src/utils/throws"; + +export class NetworkError extends Error {} +export class NotFoundError extends Error {} + +function fetchUser(id: string): Throws { + if (!id) throw new NotFoundError(); + // ... fetch logic that might throw NetworkError + return user; +} +``` + +### 2. Handle or propagate the errors + +The checker will report unhandled errors: + +``` +Unhandled error(s) from 'fetchUser': NetworkError | NotFoundError. +Catch these errors or add 'Throws<..., NetworkError | NotFoundError>' to your function's return type. +``` + +## Handling Patterns + +### Pattern 1: Catch and Handle + +```typescript +function getUserName(id: string): string | null { + try { + const user = fetchUser(id); + return user.name; + } catch (e) { + if (e instanceof NetworkError) { + console.error("Network failed"); + return null; + } + if (e instanceof NotFoundError) { + console.error("User not found"); + return null; + } + throw e; // Re-throw unknown errors + } +} +``` + +### Pattern 2: Propagate in Return Type + +```typescript +function fetchAndValidate( + id: string, +): Throws { + const user = fetchUser(id); // NetworkError | NotFoundError propagated + const validated = validateUser(user); // ValidationError propagated + return validated; +} +``` + +### Pattern 3: Partial Handling + +```typescript +function fetchWithFallback(id: string): Throws { + try { + return fetchUser(id); + } catch (e) { + if (e instanceof NotFoundError) { + return getDefaultUser(); // Handle NotFoundError locally + } + throw e; // NetworkError is propagated (declared in return type) + } +} +``` + +## Async Functions + +Works with `Promise>`: + +```typescript +async function fetchUserAsync(id: string): Promise> { + const res = await fetch(`/api/users/${id}`); + if (!res.ok) throw new NetworkError(); + return res.json(); +} + +// ❌ Error: Unhandled NetworkError +async function getName(id: string): Promise { + const user = await fetchUserAsync(id); + return user.name; +} + +// ✅ OK: Error is handled +async function getNameSafe(id: string): Promise { + try { + const user = await fetchUserAsync(id); + return user.name; + } catch (e) { + if (e instanceof NetworkError) return null; + throw e; + } +} +``` + +## API + +### Types + +```typescript +// Brand a return type with possible errors +type Throws = T & { readonly __throws?: E }; + +// Extract error types from a Throws type +type ExtractErrors = T extends Throws ? E : never; + +// Extract success type from a Throws type +type ExtractSuccess = T extends Throws ? S : T; +``` + +## Troubleshooting + +### Errors not showing in VS Code + +1. Make sure you selected "Use Workspace Version" for TypeScript +2. Run `TypeScript: Restart TS Server` +3. Check the TypeScript output panel for plugin initialization messages + +## Limitations + +1. **Third-party libraries**: Only works with functions that use `Throws<>` annotations +2. **Dynamic throws**: Static analysis only - can't detect runtime-conditional throws +3. **VS Code only**: The language service plugin is VS Code specific (other editors may vary) diff --git a/throws-transformer/cli.ts b/throws-transformer/cli.ts new file mode 100644 index 0000000000..85278ca230 --- /dev/null +++ b/throws-transformer/cli.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env tsx +/** + * CLI tool to check TypeScript files for unhandled Throws errors. + * + * Usage: + * npx tsx ./throws-transformer/cli.ts 'src/*.ts' + */ + +import * as ts from "typescript"; +import * as path from "path"; +import { sync as globSync } from "glob"; +import { checkSourceFile } from "./engine"; + +function main(): void { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.log("Usage: throws-check [file2.ts ...]"); + console.log(""); + console.log("Checks TypeScript files for unhandled Throws errors."); + process.exit(1); + } + + // Resolve file paths + const files = args.flatMap((f) => globSync(f)); + + // Find tsconfig.json + const tsconfigPath = ts.findConfigFile( + process.cwd(), + ts.sys.fileExists, + "tsconfig.json", + ); + + let compilerOptions: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + strict: true, + esModuleInterop: true, + }; + + if (tsconfigPath) { + const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); + if (configFile.error) { + console.error(`Error reading tsconfig: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')}`); + process.exit(1); + } + const parsedConfig = ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + path.dirname(tsconfigPath), + ); + if (parsedConfig.errors.length > 0) { + const msg = ts.flattenDiagnosticMessageText( + parsedConfig.errors[0].messageText, + "\n", + ); + console.error(`Error parsing tsconfig: ${msg}`); + process.exit(1); + } + compilerOptions = parsedConfig.options; + } + + // Create program with all files + const program = ts.createProgram(files, compilerOptions); + const checker = program.getTypeChecker(); + + let totalErrors = 0; + + for (const fileName of files) { + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) { + console.error(`Could not load: ${fileName}`); + continue; + } + + const results = checkSourceFile(sourceFile, checker); + + for (const result of results) { + totalErrors++; + const relativePath = path.relative(process.cwd(), result.sourceFile.fileName); + console.log( + `\x1b[31m✗\x1b[0m ${relativePath}:${result.line}:${result.column}`, + ); + console.log(` ${result.message}`); + console.log(""); + } + } + + if (totalErrors === 0) { + console.log("\x1b[32m✓\x1b[0m All Throws errors are handled correctly!"); + process.exit(0); + } else { + console.log(`\x1b[31mFound ${totalErrors} unhandled error(s)\x1b[0m`); + process.exit(1); + } +} + +main(); diff --git a/throws-transformer/engine.ts b/throws-transformer/engine.ts new file mode 100644 index 0000000000..1d1b79d046 --- /dev/null +++ b/throws-transformer/engine.ts @@ -0,0 +1,855 @@ +#!/usr/bin/env tsx +/** + * CLI tool to check TypeScript files for unhandled Throws errors. + * + * Usage: + * npx ts-node src/cli.ts src/examples.ts + * npx ts-node src/cli.ts "src/*.ts" + */ + +import * as ts from "typescript"; +import * as path from "path"; +import { sync as globSync } from "glob"; + +// Symbol name for the Throws type brand +const THROWS_BRAND = "__throws"; + +interface CheckResult { + sourceFile: ts.SourceFile, + + line: number; + column: number; + start: number; + length: number; + + functionName: string; + unhandledErrors: string[]; + message: string; +} + +export function checkSourceFile( + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): CheckResult[] { + const results: CheckResult[] = []; + + function visit(node: ts.Node): void { + // Check regular call expressions + if (ts.isCallExpression(node)) { + const result = checkCallExpression(node, sourceFile, checker); + if (result) { + results.push(result); + } + + // Also check if this is a Promise.reject() call + const rejectResult = checkPromiseReject(node, sourceFile, checker); + if (rejectResult) { + results.push(rejectResult); + } + } + + // Check await expressions + if (ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)) { + const result = checkCallExpression(node.expression, sourceFile, checker); + if (result) { + results.push(result); + } + } + + // Check throw statements + if (ts.isThrowStatement(node)) { + const result = checkThrowStatement(node, sourceFile, checker); + if (result) { + results.push(result); + } + } + + // Check return statements for Promise> propagation + if (ts.isReturnStatement(node) && node.expression) { + const result = checkReturnStatement(node, sourceFile, checker); + if (result) { + results.push(result); + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + return results; +} + +function checkThrowStatement( + node: ts.ThrowStatement, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): CheckResult | null { + const containingFunction = getContainingFunction(node); + if (!containingFunction) { + return null; + } + + // Check if handled by local catch + if (isHandledByLocalCatch(node, containingFunction)) { + return null; + } + + // Get declared error types + const declaredErrors = getDeclaredErrorTypes(containingFunction, checker); + if (declaredErrors === null) { + return null; // Not using Throws<> + } + + if (!node.expression) { + return null; + } + + // Check to see if there is a comment about the throw site starting with "@throws-transformer + // ignore", and if so, disregard. + const foundComments = ts.getLeadingCommentRanges(sourceFile.text, node.pos); + if (foundComments) { + const foundCommentsText = foundComments.map((info) => { + return sourceFile + .text + .slice(info.pos, info.end) + .replace(/^(\/\/|\/\*)\s*/ /* Remove leading comment prefix */, ''); + }); + + const isIgnoreComment = foundCommentsText.find((commentText) => { + return commentText.startsWith('@throws-transformer ignore'); + }); + + if (isIgnoreComment) { + return null; + } + } + + const thrownType = checker.getTypeAtLocation(node.expression); + const thrownTypeName = checker.typeToString(thrownType); + + const isAllowed = isErrorTypeDeclared(checker, thrownType, declaredErrors); + + if (!isAllowed) { + const start = node.getStart(); + const length = node.getWidth(); + const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); + const declaredNames = declaredErrors + .map((t) => checker.typeToString(t)) + .join(" | "); + + return { + sourceFile, + + line: line + 1, + column: character + 1, + start, + length, + + functionName: "", + unhandledErrors: [thrownTypeName], + message: `Throwing '${thrownTypeName}' but it's not declared. Declared: ${declaredNames || "never"}. Add it to Throws<> or catch internally.`, + }; + } + + return null; +} + +function checkPromiseReject( + node: ts.CallExpression, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): CheckResult | null { + // Check if this is a Promise.reject() call + if (!isPromiseRejectCall(node)) { + return null; + } + + const containingFunction = getContainingFunction(node); + if (!containingFunction) { + return null; + } + + // Check if handled by local catch (or .catch() on the promise) + const tryCatch = getContainingTryCatch(node); + if (tryCatch) { + return null; // Handled by try-catch + } + + // Get declared error types from the function's return type + const declaredErrors = getDeclaredErrorTypes(containingFunction, checker); + if (declaredErrors === null) { + return null; // Not using Throws<> + } + + // Get the type of the rejected value + if (node.arguments.length === 0) { + return null; // Promise.reject() with no argument + } + + const rejectedType = checker.getTypeAtLocation(node.arguments[0]); + const rejectedTypeName = checker.typeToString(rejectedType); + + const isAllowed = isErrorTypeDeclared(checker, rejectedType, declaredErrors); + + if (!isAllowed) { + const start = node.getStart(); + const length = node.getWidth(); + const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); + const declaredNames = declaredErrors + .map((t) => checker.typeToString(t)) + .join(" | "); + + return { + sourceFile, + line: line + 1, + column: character + 1, + start, + length, + + functionName: "Promise.reject", + unhandledErrors: [rejectedTypeName], + message: `Promise.reject('${rejectedTypeName}') but it's not declared. Declared: ${declaredNames || "never"}. Add it to Throws<> in your return type.`, + }; + } + + return null; +} + +function checkReturnStatement( + node: ts.ReturnStatement, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): CheckResult | null { + if (!node.expression) { + return null; + } + + const containingFunction = getContainingFunction(node); + if (!containingFunction) { + return null; + } + + // Get the type of the returned expression + const returnedType = checker.getTypeAtLocation(node.expression); + + // Extract error types from the returned value + const returnedErrors = extractThrowsErrorTypes(returnedType, checker); + + if (returnedErrors.length === 0) { + return null; // No errors in returned value + } + + // Get declared error types from the function's return type + const declaredErrors = getDeclaredErrorTypes(containingFunction, checker); + if (declaredErrors === null) { + return null; // Not using Throws<> + } + + // Find errors that are in the returned value but not declared + const unhandledErrors = returnedErrors.filter((errorType) => { + return !isErrorTypeDeclared(checker, errorType, declaredErrors); + }); + + if (unhandledErrors.length === 0) { + return null; + } + + const start = node.getStart(); + const length = node.getWidth(); + const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); + const errorNames = unhandledErrors.map((e) => checker.typeToString(e)); + const declaredNames = declaredErrors + .map((t) => checker.typeToString(t)) + .join(" | "); + + return { + sourceFile, + line: line + 1, + column: character + 1, + start, + length, + + functionName: "", + unhandledErrors: errorNames, + message: `Returning value with errors [${errorNames.join(" | ")}] but only [${declaredNames || "never"}] declared. Add missing errors to your function's Throws<> return type.`, + }; +} + +function isHandledByLocalCatch( + throwNode: ts.ThrowStatement, + containingFunction: ts.FunctionLikeDeclaration, +): boolean { + let current: ts.Node | undefined = throwNode.parent; + + while (current && current !== containingFunction) { + if (ts.isTryStatement(current)) { + if (isInTryBlock(throwNode, current)) { + // Check if catch doesn't re-throw (catch-all) + const catchClause = current.catchClause; + if (catchClause && !containsThrowStatement(catchClause.block)) { + return true; + } + } + } + current = current.parent; + } + + return false; +} + +function getDeclaredErrorTypes( + func: ts.FunctionLikeDeclaration, + checker: ts.TypeChecker, +): ts.Type[] | null { + if (!func.type) { + return null; + } + + const returnType = checker.getTypeFromTypeNode(func.type); + const throwsProperty = returnType.getProperty(THROWS_BRAND); + + if (!throwsProperty) { + const promiseType = extractPromiseType(returnType, checker); + if (promiseType) { + const innerThrows = promiseType.getProperty(THROWS_BRAND); + if (!innerThrows) { + return null; + } + return extractThrowsErrorTypes(promiseType, checker); + } + return null; + } + + return extractThrowsErrorTypes(returnType, checker); +} + +function checkCallExpression( + node: ts.CallExpression, + sourceFile: ts.SourceFile, + checker: ts.TypeChecker, +): CheckResult | null { + // Get the return type of the call + const callType = checker.getTypeAtLocation(node); + + // Extract error types + const errorTypes = extractThrowsErrorTypes(callType, checker); + + if (errorTypes.length === 0) { + return null; + } + + // Check handling + const containingFunction = getContainingFunction(node); + const tryCatch = getContainingTryCatch(node); + + const handledErrors = tryCatch + ? getHandledErrorTypes(tryCatch, checker, node) + : new Set(); + + // If catch-all, everything is handled + if (handledErrors === "all") { + return null; + } + + const propagatedErrors = containingFunction + ? getPropagatedErrorTypes(containingFunction, checker) + : new Set(); + + // Find unhandled + const unhandledErrors = errorTypes.filter((errorType) => { + const errorName = checker.typeToString(errorType); + return !handledErrors.has(errorName) && !propagatedErrors.has(errorName); + }); + + if (unhandledErrors.length === 0) { + return null; + } + + const start = node.getStart(); + const length = node.getWidth(); + const { line, character } = sourceFile.getLineAndCharacterOfPosition(start); + const functionName = getFunctionName(node); + const errorNames = unhandledErrors.map((e) => checker.typeToString(e)); + + return { + sourceFile, + line: line + 1, + column: character + 1, + start, + length, + + functionName, + unhandledErrors: errorNames, + message: + `Unhandled errors from '${functionName}': ${errorNames.join(" | ")}. ` + + `Catch these errors or add 'Throws<..., ${errorNames.join(" | ")}>' to your return type.`, + }; +} + +function extractThrowsErrorTypes( + type: ts.Type, + checker: ts.TypeChecker, +): ts.Type[] { + const errors: ts.Type[] = []; + + // Check for __throws property + const throwsProperty = type.getProperty(THROWS_BRAND); + + if (!throwsProperty) { + // Check Promise> + const promiseType = extractPromiseType(type, checker); + if (promiseType) { + return extractThrowsErrorTypes(promiseType, checker); + } + return []; + } + + const declaration = throwsProperty.valueDeclaration; + if (!declaration) { + return []; + } + + const throwsType = checker.getTypeOfSymbolAtLocation( + throwsProperty, + declaration, + ); + + if (throwsType.isUnion()) { + for (const t of throwsType.types) { + if (!isUndefinedType(t) && !isNeverType(t)) { + errors.push(t); + } + } + } else if (!isUndefinedType(throwsType) && !isNeverType(throwsType)) { + errors.push(throwsType); + } + + return errors; +} + +function extractPromiseType( + type: ts.Type, + checker: ts.TypeChecker, +): ts.Type | null { + const symbol = type.getSymbol(); + if (!symbol || symbol.getName() !== "Promise") { + return null; + } + + const typeRef = type as ts.TypeReference; + if (typeRef.typeArguments?.length === 1) { + return typeRef.typeArguments[0]; + } + + return null; +} + +function isErrorTypeDeclared( + checker: ts.TypeChecker, + thrownType: ts.Type, + declaredTypes: ts.Type[], +): boolean { + // Always allow throwing unknown / any (ie, rethrowing errors) + if (isAnyOrUnknownType(thrownType)) { + return true; + } + + const thrownName = checker.typeToString(thrownType); + + for (const declared of declaredTypes) { + const declaredName = checker.typeToString(declared); + + // Exact name match + if (thrownName === declaredName) { + return true; + } + + // Check if thrown type extends the declared type + const baseTypes = getBaseTypes(checker, thrownType); + if (baseTypes.some((base) => checker.typeToString(base) === declaredName)) { + return true; + } + } + + return false; +} + +function getBaseTypes(checker: ts.TypeChecker, type: ts.Type): ts.Type[] { + const bases: ts.Type[] = []; + + const symbol = type.getSymbol(); + if (!symbol) { + return bases; + } + + const declarations = symbol.getDeclarations(); + if (!declarations) { + return bases; + } + + for (const decl of declarations) { + if (ts.isClassDeclaration(decl) && decl.heritageClauses) { + for (const heritage of decl.heritageClauses) { + if (heritage.token === ts.SyntaxKind.ExtendsKeyword) { + for (const typeNode of heritage.types) { + const baseType = checker.getTypeAtLocation(typeNode); + bases.push(baseType); + bases.push(...getBaseTypes(checker, baseType)); + } + } + } + } + } + + return bases; +} + +function isNeverType(type: ts.Type): boolean { + return (type.flags & ts.TypeFlags.Never) !== 0; +} + +function isUndefinedType(type: ts.Type): boolean { + return (type.flags & ts.TypeFlags.Undefined) !== 0; +} + +function getContainingFunction( + node: ts.Node, +): ts.FunctionLikeDeclaration | null { + let current: ts.Node | undefined = node.parent; + + while (current) { + if ( + ts.isFunctionDeclaration(current) || + ts.isFunctionExpression(current) || + ts.isArrowFunction(current) || + ts.isMethodDeclaration(current) + ) { + return current; + } + current = current.parent; + } + + return null; +} + +function getContainingTryCatch(node: ts.Node): ts.TryStatement | null { + let current: ts.Node | undefined = node.parent; + + while (current) { + if (ts.isTryStatement(current) && isInTryBlock(node, current)) { + return current; + } + if ( + ts.isFunctionDeclaration(current) || + ts.isFunctionExpression(current) || + ts.isArrowFunction(current) || + ts.isMethodDeclaration(current) + ) { + break; + } + current = current.parent; + } + + return null; +} + +function isInTryBlock(node: ts.Node, tryStatement: ts.TryStatement): boolean { + let current: ts.Node | undefined = node; + + while (current && current !== tryStatement) { + if (current === tryStatement.tryBlock) { + return true; + } + if ( + current === tryStatement.catchClause || + current === tryStatement.finallyBlock + ) { + return false; + } + current = current.parent; + } + + return false; +} + +function getHandledErrorTypes( + tryStatement: ts.TryStatement, + checker: ts.TypeChecker, + callNode: ts.Node, +): Set | "all" { + const handled = new Set(); + + const catchClause = tryStatement.catchClause; + if (!catchClause) { + return handled; + } + + // Check if this is a catch-all (no re-throw) + if (!containsThrowStatement(catchClause.block)) { + return "all"; + } + + // If there's re-throwing, use type narrowing to find handled types + if (!catchClause.variableDeclaration) { + return handled; + } + + const narrowedTypes = findNarrowedErrorTypes( + catchClause.block, + catchClause.variableDeclaration, + checker, + callNode, + ); + + for (const type of narrowedTypes) { + handled.add(checker.typeToString(type)); + } + + return handled; +} + +/** + * Check if a block contains a throw statement (indicating re-throwing). + */ +function containsThrowStatement(block: ts.Block): boolean { + let hasThrow = false; + + function visit(node: ts.Node): void { + if (hasThrow) { return; } + + if (ts.isThrowStatement(node)) { + hasThrow = true; + return; + } + + // Don't recurse into nested functions - their throws don't count + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) + ) { + return; + } + + ts.forEachChild(node, visit); + } + + visit(block); + return hasThrow; +} + +/** + * Find error types that are narrowed in the catch block using TypeScript's type narrowing. + * This supports instanceof checks, type predicates, and other type guards. + */ +function findNarrowedErrorTypes( + block: ts.Block, + errorVar: ts.VariableDeclaration, + checker: ts.TypeChecker, + originalCallNode: ts.Node, +): ts.Type[] { + const narrowedTypes: ts.Type[] = []; + const errorVarName = errorVar.name.getText(); + + /** + * Analyze if-statements to find type narrowing branches that don't re-throw. + * These represent error types that are handled. + */ + function visitIfStatement(ifStmt: ts.IfStatement): void { + // Get the type of the error variable after the type guard in the if condition + // const condition = ifStmt.expression; + + // Check if the then-block handles the error (doesn't re-throw) + const thenStatement = ifStmt.thenStatement; + const thenRethrows = ts.isBlock(thenStatement) + ? containsThrowStatement(thenStatement) + : ts.isThrowStatement(thenStatement); + + if (thenRethrows) { + // Get narrowed type in the then-block + const narrowedType = getNarrowedTypeInBlock( + thenStatement, + errorVarName, + checker, + ); + + if (narrowedType && !isAnyOrUnknownType(narrowedType)) { + // For union types, add each constituent type + if (narrowedType.isUnion()) { + narrowedTypes.push(...narrowedType.types.filter(t => !isAnyOrUnknownType(t))); + } else { + narrowedTypes.push(narrowedType); + } + } + } + + // Check else-if and else branches + if (ifStmt.elseStatement) { + if (ts.isIfStatement(ifStmt.elseStatement)) { + visitIfStatement(ifStmt.elseStatement); + } else { + const elseHandles = ts.isBlock(ifStmt.elseStatement) + ? !containsThrowStatement(ifStmt.elseStatement) + : !ts.isThrowStatement(ifStmt.elseStatement); + + if (elseHandles) { + const narrowedType = getNarrowedTypeInBlock( + ifStmt.elseStatement, + errorVarName, + checker, + ); + if (narrowedType && !isAnyOrUnknownType(narrowedType)) { + if (narrowedType.isUnion()) { + narrowedTypes.push(...narrowedType.types.filter(t => !isAnyOrUnknownType(t))); + } else { + narrowedTypes.push(narrowedType); + } + } + } + } + } + } + + function visit(node: ts.Node): void { + if (ts.isIfStatement(node)) { + visitIfStatement(node); + } + ts.forEachChild(node, visit); + } + + visit(block); + + // If no narrowed types found but we found type guards with instanceof, + // fall back to the old method for backwards compatibility + if (narrowedTypes.length === 0) { + const instanceofTypes = findInstanceofChecks(block, errorVar); + for (const typeExpr of instanceofTypes) { + const type = checker.getTypeAtLocation(typeExpr); + if (!isAnyOrUnknownType(type)) { + narrowedTypes.push(type); + } + } + } + + return narrowedTypes; +} + +/** + * Get the narrowed type of a variable within a specific block by finding + * the first reference to it and checking its type at that location. + */ +function getNarrowedTypeInBlock( + block: ts.Node, + varName: string, + checker: ts.TypeChecker, +): ts.Type | null { + let foundType: ts.Type | null = null; + + function visit(node: ts.Node): void { + if (foundType) { return; } + + // Look for references to the error variable + if (ts.isIdentifier(node) && node.text === varName) { + // Get the type at this location (after narrowing) + const type = checker.getTypeAtLocation(node); + if (!isAnyOrUnknownType(type)) { + foundType = type; + } + } + + // Don't recurse into nested functions + if ( + ts.isFunctionDeclaration(node) || + ts.isFunctionExpression(node) || + ts.isArrowFunction(node) + ) { + return; + } + + ts.forEachChild(node, visit); + } + + visit(block); + return foundType; +} + +/** + * Check if a type is any or unknown (which we should ignore for error handling). + */ +function isAnyOrUnknownType(type: ts.Type): boolean { + return ( + (type.flags & ts.TypeFlags.Any) !== 0 || + (type.flags & ts.TypeFlags.Unknown) !== 0 + ); +} + +/** + * Legacy function kept for backwards compatibility. + * Finds instanceof checks in the catch block. + */ +function findInstanceofChecks( + block: ts.Block, + errorVar: ts.VariableDeclaration, +): ts.Expression[] { + const checks: ts.Expression[] = []; + const errorVarName = errorVar.name.getText(); + + function visit(node: ts.Node): void { + if ( + ts.isBinaryExpression(node) && + node.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword + ) { + if (ts.isIdentifier(node.left) && node.left.text === errorVarName) { + checks.push(node.right); + } + } + ts.forEachChild(node, visit); + } + + visit(block); + return checks; +} + +function getPropagatedErrorTypes( + func: ts.FunctionLikeDeclaration, + checker: ts.TypeChecker, +): Set { + const propagated = new Set(); + + if (!func.type) { return propagated; } + + const returnType = checker.getTypeFromTypeNode(func.type); + const errorTypes = extractThrowsErrorTypes(returnType, checker); + + for (const errorType of errorTypes) { + propagated.add(checker.typeToString(errorType)); + } + + return propagated; +} + +function getFunctionName(node: ts.CallExpression): string { + if (ts.isIdentifier(node.expression)) { + return node.expression.text; + } + if (ts.isPropertyAccessExpression(node.expression)) { + return node.expression.name.text; + } + return ""; +} + +function isPromiseRejectCall(node: ts.CallExpression): boolean { + // Check for Promise.reject() + if (ts.isPropertyAccessExpression(node.expression)) { + const expr = node.expression; + if ( + ts.isIdentifier(expr.expression) && + expr.expression.text === "Promise" && + ts.isIdentifier(expr.name) && + expr.name.text === "reject" + ) { + return true; + } + } + return false; +} diff --git a/throws-transformer/plugin.ts b/throws-transformer/plugin.ts new file mode 100644 index 0000000000..95631c8b16 --- /dev/null +++ b/throws-transformer/plugin.ts @@ -0,0 +1,94 @@ +/** + * TypeScript Language Service Plugin + * + * This plugin runs inside VS Code's TypeScript service and adds + * diagnostics for unhandled Throws errors in real-time. + * + * Setup: + * 1. Add to tsconfig.json: + * { + * "compilerOptions": { + * "plugins": [{ "name": "./throws-transformer/plugin.ts" }] + * } + * } + * + * 2. Tell VS Code to use workspace TypeScript (.vscode/settings.json): + * { + * "typescript.tsdk": "node_modules/typescript/lib", + * "typescript.enablePromptUseWorkspaceTsdk": true + * } + * + * 3. Restart VS Code or run "TypeScript: Restart TS Server" + */ + +import type * as ts from "typescript"; +import type { server } from "typescript"; +import { checkSourceFile } from "./engine"; + +const PLUGIN_NAME = "throws-transformer"; + +function init(modules: { + typescript: typeof import("typescript"); +}): server.PluginModule { + const ts = modules.typescript; + + function create(info: ts.server.PluginCreateInfo): ts.LanguageService { + const log = (msg: string) => { + info.project.projectService.logger.info(`[${PLUGIN_NAME}] ${msg}`); + }; + + log("Plugin initialized"); + + // Create a proxy for the language service + const proxy: ts.LanguageService = Object.create(null); + + // Copy all methods from the original language service + for (const k of Object.keys(info.languageService) as Array< + keyof ts.LanguageService + >) { + const x = info.languageService[k]; + proxy[k] = typeof x === "function" ? x.bind(info.languageService) : x; + } + + // Override getSemanticDiagnostics to add our custom diagnostics + proxy.getSemanticDiagnostics = (fileName: string): ts.Diagnostic[] => { + const original = info.languageService.getSemanticDiagnostics(fileName); + + try { + const program = info.languageService.getProgram(); + if (!program) { + return original; + } + + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) { + return original; + } + + const checker = program.getTypeChecker(); + + const results = checkSourceFile(sourceFile, checker); + + const diagnostics = results.flatMap((result): ts.Diagnostic => ({ + file: result.sourceFile, + start: result.start, + length: result.length, + messageText: result.message, + category: ts.DiagnosticCategory.Error, + code: 90001, // Custom error code + })); + + return [...original, ...diagnostics]; + } catch (e) { + log(`Error checking file ${fileName}: ${e}`); + return original; + } + }; + + return proxy; + } + + return { create }; +} + +export default init; diff --git a/throws-transformer/transformer.ts b/throws-transformer/transformer.ts new file mode 100644 index 0000000000..ed999ad408 --- /dev/null +++ b/throws-transformer/transformer.ts @@ -0,0 +1,113 @@ +import * as ts from "typescript"; +import { checkSourceFile } from "./engine"; + +/** + * TypeScript Transformer that enforces handling of declared thrown errors. + * + * This transformer: + * 1. Finds all call expressions + * 2. Checks if the called function returns Throws + * 3. Extracts the error types E + * 4. Verifies the call is either: + * a) Inside a try-catch that handles those error types + * b) Inside a function that propagates those error types in its return type + * 5. Emits diagnostics for unhandled errors + * + * Usage with ts-patch: + * // tsconfig.json + * { + * "compilerOptions": { + * "plugins": [{ "transform": "./transformer.ts" }] + * } + * } + */ + +interface TransformerConfig { + // If true, also warn about partial handling in catch blocks + strictCatchHandling?: boolean; +} + +/** + * Create the transformer factory. + */ +export default function transformer( + program: ts.Program, + config?: TransformerConfig, +): ts.TransformerFactory { + return (context: ts.TransformationContext) => { + return (sourceFile: ts.SourceFile) => { + const checker = program.getTypeChecker(); + const results = checkSourceFile(sourceFile, checker); + + const diagnostics = results.flatMap((result): ts.Diagnostic => ({ + file: result.sourceFile, + start: result.start, + length: result.length, + messageText: result.message, + category: ts.DiagnosticCategory.Error, + code: 90001, // Custom error code + })); + + // Report diagnostics + for (const diagnostic of diagnostics) { + reportDiagnostic(diagnostic); + } + + // Return unchanged source file (we only emit diagnostics) + return sourceFile; + }; + }; +} + +/** + * Report a diagnostic to the console (in a real implementation, + * this would integrate with ts-patch's diagnostic system). + */ +function reportDiagnostic(diagnostic: ts.Diagnostic): void { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const file = diagnostic.file; + + if (file && diagnostic.start !== undefined) { + const { line, character } = file.getLineAndCharacterOfPosition( + diagnostic.start, + ); + console.error( + `${file.fileName}:${line + 1}:${character + 1} - error TS${diagnostic.code}: ${message}`, + ); + } else { + console.error(`error TS${diagnostic.code}: ${message}`); + } +} + +// ============================================================================= +// Standalone checker (can be run without ts-patch) +// ============================================================================= + +export function checkFile( + fileName: string, + compilerOptions: ts.CompilerOptions = {}, +): ts.Diagnostic[] { + const program = ts.createProgram([fileName], { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + strict: true, + ...compilerOptions, + }); + + const sourceFile = program.getSourceFile(fileName); + if (!sourceFile) { + throw new Error(`Could not load source file: ${fileName}`); + } + + const checker = program.getTypeChecker(); + const results = checkSourceFile(sourceFile, checker); + + return results.flatMap((result): ts.Diagnostic => ({ + file: result.sourceFile, + start: result.start, + length: result.length, + messageText: result.message, + category: ts.DiagnosticCategory.Error, + code: 90001, // Custom error code + })); +} diff --git a/tsconfig.json b/tsconfig.json index 4a81e38107..a2e7c8f3d8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "DOM.AsyncIterable", "ES2017", "ES2018.Promise", - "ES2021.WeakRef" + "ES2021.WeakRef", + "ES2022.Error" ], "rootDir": "./", "outDir": "dist", @@ -24,7 +25,8 @@ "moduleResolution": "node", "resolveJsonModule": true, "verbatimModuleSyntax": true, - "ignoreDeprecations": "5.0" + "ignoreDeprecations": "5.0", + "plugins": [{ "name": "./throws-transformer/plugin.ts" }], }, "exclude": ["dist", "**/*.test.ts", "test/**"], "include": ["src/**/*.ts"],