diff --git a/.changeset/published-types-skiplibcheck.md b/.changeset/published-types-skiplibcheck.md new file mode 100644 index 0000000000..f6f3bf3d3c --- /dev/null +++ b/.changeset/published-types-skiplibcheck.md @@ -0,0 +1,5 @@ +--- +"livekit-client": patch +--- + +fix published type declarations for consumers with `skipLibCheck: false` or `exactOptionalPropertyTypes` diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 13c2e3c921..bfb1dcee91 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -39,6 +39,9 @@ jobs: run: | pnpm build:clean pnpm compat - + + - name: Check published types in a consumer with skipLibCheck off + run: pnpm check:published-types + - name: Ensure package publish is configured correctly run: pnpm publint diff --git a/AGENTS.md b/AGENTS.md index 7b9b56f9a3..0e3e090694 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,12 @@ user-facing API. - **Run all tests:** `npx vitest run` - **Run specific test file:** `npx vitest run path/to/file.test.ts` - **Format:** `npm run format` +- **Published types:** `pnpm check:published-types` (after `pnpm build`) installs the packed tarball + into a temp project outside the repo and type-checks every entry with `skipLibCheck` off and + `exactOptionalPropertyTypes` on. Declarations must name only packages in `dependencies`, and their + `@types/*` packages must be there too. Import `Throws` from `src/utils/throws.ts`, never from + `@livekit/throws-transformer` (a devDependency). If `dist` still shows an old inferred type after + a type-only change, delete `node_modules/.cache/rollup-plugin-typescript2`: its cache misses those. ## Examples diff --git a/package.json b/package.json index 8ed92d1ddb..9938d00b28 100644 --- a/package.json +++ b/package.json @@ -83,11 +83,13 @@ "downlevel-dts": "downlevel-dts ./dist/src ./dist/ts4.2 --to=4.2 && downlevel-dts ./dist/core/types/src ./dist/core/ts4.2 --to=4.2", "compat": "eslint --config ./eslint.config.dist.mjs --no-inline-config ./dist/livekit-client.esm.mjs", "size-limit": "size-limit", - "check:core": "node scripts/check-core-imports.mjs && node scripts/check-core-types.mjs" + "check:core": "node scripts/check-core-imports.mjs && node scripts/check-core-types.mjs", + "check:published-types": "node scripts/check-published-types.mjs" }, "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.52.1", + "@types/sdp-transform": "^2.15.0", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", @@ -118,7 +120,6 @@ "@stylistic/eslint-plugin": "^5.10.0", "@trivago/prettier-plugin-sort-imports": "^6.0.2", "@types/events": "^3.0.3", - "@types/sdp-transform": "2.15.0", "@types/ua-parser-js": "0.7.39", "@typescript-eslint/eslint-plugin": "8.64.0", "@typescript-eslint/parser": "8.64.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e24149076a..789a414fd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 + '@types/sdp-transform': + specifier: ^2.15.0 + version: 2.15.0 events: specifier: ^3.3.0 version: 3.3.0 @@ -108,9 +111,6 @@ importers: '@types/events': specifier: ^3.0.3 version: 3.0.3 - '@types/sdp-transform': - specifier: 2.15.0 - version: 2.15.0 '@types/ua-parser-js': specifier: 0.7.39 version: 0.7.39 diff --git a/scripts/check-published-types.mjs b/scripts/check-published-types.mjs new file mode 100644 index 0000000000..aceb30f815 --- /dev/null +++ b/scripts/check-published-types.mjs @@ -0,0 +1,79 @@ +// Gate for the published declarations, as a consumer sees them. Packs the built package +// (run `pnpm build` first), installs the tarball into a temp project and type-checks every +// entry with `skipLibCheck` off and the strictest options a consumer may set. That catches: +// - a declaration that imports a package the consumer does not install (a devDependency, or a +// dependency whose types live in a devDependency `@types/*` package); +// - declarations that contradict each other under `exactOptionalPropertyTypes`. +// The temp project sits outside the repo: inside it, module resolution walks up to the repo's +// node_modules and finds the devDependencies, which hides the first case. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const tsc = path.join(REPO, 'node_modules', '.bin', 'tsc'); +const tmp = mkdtempSync(path.join(os.tmpdir(), 'livekit-published-types-')); + +const run = (cmd, args, cwd) => { + try { + return { ok: true, out: execFileSync(cmd, args, { cwd, encoding: 'utf8', stdio: 'pipe' }) }; + } catch (err) { + return { ok: false, out: `${err.stdout ?? ''}${err.stderr ?? ''}` }; + } +}; + +const must = (result) => { + if (!result.ok) { + console.error(result.out); + process.exit(1); + } +}; + +try { + must(run('pnpm', ['pack', '--pack-destination', tmp], REPO)); + const tarball = readdirSync(tmp).find((file) => file.endsWith('.tgz')); + writeFileSync( + path.join(tmp, 'package.json'), + JSON.stringify({ name: 'consumer', private: true, type: 'module' }), + ); + // npm, not pnpm: a flat node_modules with only what the package.json declares (and its peers). + must(run('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts', `./${tarball}`], tmp)); + + writeFileSync( + path.join(tmp, 'probe.ts'), + `export * as full from 'livekit-client'; +export * as core from 'livekit-client/core'; +export * as e2eeWorker from 'livekit-client/e2ee-worker'; +export * as frameMetadataWorker from 'livekit-client/frame-metadata-worker'; +`, + ); + writeFileSync( + path.join(tmp, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + noEmit: true, + strict: true, + exactOptionalPropertyTypes: true, + skipLibCheck: false, + module: 'ESNext', + moduleResolution: 'bundler', + target: 'ES2022', + lib: ['DOM', 'DOM.Iterable', 'ES2022'], + }, + files: ['probe.ts'], + }), + ); + + const check = run(tsc, ['-p', path.join(tmp, 'tsconfig.json')], tmp); + const errors = check.out.split('\n').filter((line) => /error TS\d+/.test(line)); + if (!check.ok || errors.length > 0) { + console.error('published types: a consumer with skipLibCheck off gets errors:'); + for (const line of errors.length > 0 ? errors : [check.out]) console.error(` ${line.trim()}`); + process.exit(1); + } + console.log('published types: all entries type-check in a consumer with skipLibCheck off'); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} diff --git a/src/room/PCTransportManager.ts b/src/room/PCTransportManager.ts index b818bac0e1..cb7f62a2a2 100644 --- a/src/room/PCTransportManager.ts +++ b/src/room/PCTransportManager.ts @@ -1,8 +1,8 @@ import { Mutex } from '@livekit/mutex'; import { SignalTarget } from '@livekit/protocol'; -import type { Throws } from '@livekit/throws-transformer/throws'; import log, { LoggerNames, getLogger } from '../logger'; import TypedPromise from '../utils/TypedPromise'; +import type { Throws } from '../utils/throws'; import PCTransport, { PCEvents } from './PCTransport'; import { roomConnectOptionDefaults } from './defaults'; import { ConnectionError, NegotiationError } from './errors'; diff --git a/src/room/data-track/depacketizer.ts b/src/room/data-track/depacketizer.ts index 6a30718b56..601bf4bc55 100644 --- a/src/room/data-track/depacketizer.ts +++ b/src/room/data-track/depacketizer.ts @@ -1,6 +1,6 @@ -import { type Throws } from '@livekit/throws-transformer/throws'; import { LoggerNames, getLogger } from '../../logger'; import type { NonSharedUint8Array } from '../../type-polyfills/non-shared-typed-arrays'; +import { type Throws } from '../../utils/throws'; import { LivekitReasonedError } from '../errors'; import { type DataTrackFrameInternal } from './frame'; import { DataTrackPacket, FrameMarker } from './packet'; diff --git a/src/room/data-track/handle.ts b/src/room/data-track/handle.ts index a6c8e2f056..9c4864fc35 100644 --- a/src/room/data-track/handle.ts +++ b/src/room/data-track/handle.ts @@ -1,4 +1,4 @@ -import { type Throws } from '@livekit/throws-transformer/throws'; +import { type Throws } from '../../utils/throws'; import { LivekitReasonedError } from '../errors'; import { U16_MAX_SIZE } from './utils'; diff --git a/src/room/data-track/incoming/IncomingDataTrackManager.ts b/src/room/data-track/incoming/IncomingDataTrackManager.ts index 1e076ace2c..109b969cc6 100644 --- a/src/room/data-track/incoming/IncomingDataTrackManager.ts +++ b/src/room/data-track/incoming/IncomingDataTrackManager.ts @@ -1,10 +1,10 @@ import { type JoinResponse, type ParticipantUpdate } from '@livekit/protocol'; import { EventEmitter } from 'events'; -import type { Throws } from '@livekit/throws-transformer/throws'; import type TypedEmitter from 'typed-emitter'; import type { BaseE2EEManager } from '../../../e2ee/E2eeManager'; import { LoggerNames, getLogger } from '../../../logger'; import { abortSignalAny, abortSignalTimeout } from '../../../utils/abort-signal-polyfill'; +import type { Throws } from '../../../utils/throws'; import type Participant from '../../participant/Participant'; import type RemoteParticipant from '../../participant/RemoteParticipant'; import { Future } from '../../utils'; diff --git a/src/room/data-track/incoming/pipeline.ts b/src/room/data-track/incoming/pipeline.ts index 394767e178..e5b93d8a19 100644 --- a/src/room/data-track/incoming/pipeline.ts +++ b/src/room/data-track/incoming/pipeline.ts @@ -1,6 +1,6 @@ -import type { Throws } from '@livekit/throws-transformer/throws'; import type { BaseE2EEManager } from '../../../e2ee/E2eeManager'; import { LoggerNames, getLogger } from '../../../logger'; +import type { Throws } from '../../../utils/throws'; import DataTrackDepacketizer, { DataTrackDepacketizerDropError } from '../depacketizer'; import type { DataTrackFrameInternal } from '../frame'; import { DataTrackPacket } from '../packet'; diff --git a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts index 70fb4115c8..f6e68df1a2 100644 --- a/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +++ b/src/room/data-track/outgoing/OutgoingDataTrackManager.ts @@ -1,9 +1,9 @@ import { EventEmitter } from 'events'; -import type { Throws } from '@livekit/throws-transformer/throws'; import type TypedEmitter from 'typed-emitter'; import type { BaseE2EEManager } from '../../../e2ee/E2eeManager'; import { LoggerNames, getLogger } from '../../../logger'; import { abortSignalAny, abortSignalTimeout } from '../../../utils/abort-signal-polyfill'; +import type { Throws } from '../../../utils/throws'; import { Future } from '../../utils'; import LocalDataTrack from '../LocalDataTrack'; import type { DataTrackFrameInternal } from '../frame'; diff --git a/src/room/data-track/outgoing/pipeline.ts b/src/room/data-track/outgoing/pipeline.ts index a031fa95fc..d82c18b6af 100644 --- a/src/room/data-track/outgoing/pipeline.ts +++ b/src/room/data-track/outgoing/pipeline.ts @@ -1,5 +1,5 @@ -import { type Throws } from '@livekit/throws-transformer/throws'; import type { BaseE2EEManager } from '../../../e2ee/E2eeManager'; +import { type Throws } from '../../../utils/throws'; import { type DataTrackFrameInternal } from '../frame'; import { DataTrackPacket } from '../packet'; import { DataTrackE2eeExtension } from '../packet/extensions'; diff --git a/src/room/data-track/packet/extensions.ts b/src/room/data-track/packet/extensions.ts index ed05b776bd..e5d5a2a08d 100644 --- a/src/room/data-track/packet/extensions.ts +++ b/src/room/data-track/packet/extensions.ts @@ -1,5 +1,5 @@ -import { type Throws } from '@livekit/throws-transformer/throws'; import type { NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays'; +import { type Throws } from '../../../utils/throws'; import { coerceToDataView } from '../utils'; import { EXT_TAG_PADDING, U8_LENGTH_BYTES, U64_LENGTH_BYTES } from './constants'; import { DataTrackDeserializeError, DataTrackDeserializeErrorReason } from './errors'; diff --git a/src/room/data-track/packet/index.ts b/src/room/data-track/packet/index.ts index 6627e4533e..6a4c17d077 100644 --- a/src/room/data-track/packet/index.ts +++ b/src/room/data-track/packet/index.ts @@ -1,5 +1,5 @@ -import { type Throws } from '@livekit/throws-transformer/throws'; import type { NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays'; +import { type Throws } from '../../../utils/throws'; import { DataTrackHandle, DataTrackHandleError, DataTrackHandleErrorReason } from '../handle'; import { DataTrackTimestamp, diff --git a/src/room/data-track/packet/serializable.ts b/src/room/data-track/packet/serializable.ts index 6cd86b9b13..4937e1833d 100644 --- a/src/room/data-track/packet/serializable.ts +++ b/src/room/data-track/packet/serializable.ts @@ -1,5 +1,5 @@ -import { type Throws } from '@livekit/throws-transformer/throws'; import type { NonSharedUint8Array } from '../../../type-polyfills/non-shared-typed-arrays'; +import { type Throws } from '../../../utils/throws'; import { DataTrackSerializeError } from './errors'; /** An abstract class implementing common behavior related to data track binary serialization. */ diff --git a/src/room/data-track/packetizer.ts b/src/room/data-track/packetizer.ts index 0d603c67ad..3d073532d2 100644 --- a/src/room/data-track/packetizer.ts +++ b/src/room/data-track/packetizer.ts @@ -1,4 +1,4 @@ -import type { Throws } from '@livekit/throws-transformer/throws'; +import type { Throws } from '../../utils/throws'; import { LivekitReasonedError } from '../errors'; import { type DataTrackFrameInternal } from './frame'; import { DataTrackHandle } from './handle'; diff --git a/src/room/data-track/track-interfaces.ts b/src/room/data-track/track-interfaces.ts index 7306f29ce8..f2d5f66c17 100644 --- a/src/room/data-track/track-interfaces.ts +++ b/src/room/data-track/track-interfaces.ts @@ -45,7 +45,7 @@ export const DataTrackSymbol: symbol = /* @__PURE__ */ Symbol.for('lk.data-track export interface IDataTrack extends ITrack { readonly typeSymbol: typeof DataTrackSymbol; - readonly info?: DataTrackInfo; + readonly info?: DataTrackInfo | undefined; } export function isDataTrack(subject: unknown): subject is IDataTrack { diff --git a/src/room/track/LocalTrack.ts b/src/room/track/LocalTrack.ts index 3042f8d5e7..9eef5f5df8 100644 --- a/src/room/track/LocalTrack.ts +++ b/src/room/track/LocalTrack.ts @@ -63,7 +63,7 @@ export default abstract class LocalTrack< protected processorElement?: HTMLMediaElement; - protected processor?: TrackProcessor; + protected processor?: TrackProcessor | undefined; protected audioContext?: AudioContext; diff --git a/src/room/utils.ts b/src/room/utils.ts index 83d84c58a3..88507cbfc9 100644 --- a/src/room/utils.ts +++ b/src/room/utils.ts @@ -6,11 +6,11 @@ import { DisconnectReason, Transcription as TranscriptionModel, } from '@livekit/protocol'; -import { type Throws } from '@livekit/throws-transformer/throws'; import type { NonSharedUint8Array } from '../type-polyfills/non-shared-typed-arrays'; import TypedPromise from '../utils/TypedPromise'; import { getBrowser } from '../utils/browserParser'; import type { BrowserDetails } from '../utils/browserParser'; +import { type Throws } from '../utils/throws'; import { clientProtocol, protocolVersion, version } from '../version'; import { type ConnectionError, ConnectionErrorReason } from './errors'; import type { LocalParticipant } from './participant/LocalParticipant'; diff --git a/src/utils/deferrable-map.ts b/src/utils/deferrable-map.ts index 3437087de3..3ba2752eb5 100644 --- a/src/utils/deferrable-map.ts +++ b/src/utils/deferrable-map.ts @@ -1,5 +1,5 @@ -import { type Throws } from '@livekit/throws-transformer/throws'; import { Future } from '../room/utils'; +import { type Throws } from './throws'; /** An error which is thrown if a {@link DeferrableMap#getDeferred} call is aborted midway * through. */ diff --git a/src/utils/throws.ts b/src/utils/throws.ts new file mode 100644 index 0000000000..e385d018ba --- /dev/null +++ b/src/utils/throws.ts @@ -0,0 +1,15 @@ +// A copy of `Throws` from `@livekit/throws-transformer/throws`. The published declarations name +// this type, and the transformer is a devDependency, so a consumer could not resolve an import +// of it. The transformer matches the `__throws` brand, not the import path: keep the brand as is. + +type Primitives = void | null | undefined | string | number | bigint | boolean | symbol; + +/** + * Branded type that encodes possible thrown errors in the return type. The error types are + * phantom types for static analysis; the runtime value is just T. + */ +export type Throws = + | (T & { + readonly __throws?: E; + }) + | Extract;