From 371d108978addd9fa3e44f32bb51e59c6068fb2e Mon Sep 17 00:00:00 2001 From: Donavan Becker Date: Sat, 12 Oct 2024 05:46:31 -0500 Subject: [PATCH] Update to ESM Module --- .github/workflows/beta-release.yml | 4 +- .github/workflows/release.yml | 4 +- CHANGELOG.md | 7 ++- build/github-releaser.js | 22 -------- package-lock.json | 1 + package.json | 42 ++++++++------- src/default-ip.ts | 18 +++---- src/ffmpeg-process.ts | 56 +++++++++++-------- src/index.ts | 16 +++--- src/ports.ts | 38 ++++++------- src/return-audio-transcoder.ts | 87 ++++++++++++++++-------------- src/rtp-splitter.ts | 8 +-- src/rtp.ts | 8 +-- src/srtp.ts | 4 +- tsconfig.json | 25 +++++---- 15 files changed, 179 insertions(+), 161 deletions(-) delete mode 100644 build/github-releaser.js diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index 36be26f..741846e 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -21,7 +21,7 @@ jobs: if: ${{ github.repository == 'homebridge/camera-utils' }} - uses: homebridge/.github/.github/workflows/npm-publish.yml@latest + uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest with: tag: 'beta' dynamically_adjust_version: true @@ -40,4 +40,4 @@ jobs: Version `v${{ needs.publish.outputs.NPM_VERSION }}` url: "https://github.com/homebridge/camera-utils/releases/tag/v${{ needs.publish.outputs.NPM_VERSION }}" secrets: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL_LATEST }} \ No newline at end of file + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL_LATEST }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7f4c44f..b822a8c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: if: ${{ github.repository == 'homebridge/camera-utils' }} - uses: homebridge/.github/.github/workflows/npm-publish.yml@latest + uses: homebridge/.github/.github/workflows/npm-publish-esm.yml@latest secrets: npm_auth_token: ${{ secrets.npm_token }} @@ -31,4 +31,4 @@ jobs: Version `v${{ needs.publish.outputs.NPM_VERSION }}` url: "https://github.com/homebridge/camera-utils/releases/tag/v${{ needs.publish.outputs.NPM_VERSION }}" secrets: - DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL_LATEST }} \ No newline at end of file + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL_LATEST }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 9643554..616e7aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,14 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. -### [3.0.0](https://github.com/homebridge/camera-utils/compare/v2.2.7...v3.0.0) (2024-11-XX) +### [3.0.0](https://github.com/homebridge/camera-utils/compare/v2.2.7...v3.0.0) (2024-11-03) -### Other Changes +### Major Changes * change to a esm module + +### Other Changes + * update dependencies ### [2.2.7](https://github.com/homebridge/camera-utils/compare/v2.2.6...v2.2.7) (2024-11-03) diff --git a/build/github-releaser.js b/build/github-releaser.js deleted file mode 100644 index baeb1f0..0000000 --- a/build/github-releaser.js +++ /dev/null @@ -1,22 +0,0 @@ -require('dotenv/config') -const conventionalGithubReleaser = require('conventional-github-releaser') - -conventionalGithubReleaser( - { - type: 'oauth', - url: 'https://api.github.com/', - token: process.env.GITHUB_TOKEN, - }, - { - preset: 'angular', - }, - (e, release) => { - if (e) { - console.error(e) //eslint-disable-line no-console - process.exit(1) - } - - console.log(release) //eslint-disable-line no-console - process.exit(0) - } -) diff --git a/package-lock.json b/package-lock.json index a09cf58..47934a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -552,6 +552,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "dev": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e1a7092..8f7b644 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,9 @@ { "name": "@homebridge/camera-utils", + "version": "3.0.0", "type": "module", - "version": "2.3.0", "description": "Utilities to simplify homebridge camera plugin development", "author": "dgreif", - "maintainers": [ - { - "name": "dgreif" - } - ], "license": "MIT", "homepage": "https://github.com/homebridge/camera-utils#readme", "repository": { @@ -27,17 +22,19 @@ "rtp", "ffmpeg" ], - "main": "lib/index.js", + "main": "dist/index.js", "scripts": { "check": "npm install && npm outdated", "test": "vitest", "test-coverage": "vitest run --coverage", "watch:link": "npm link && tsc --watch --declaration", "lint": "eslint '**/*.ts' --fix", - "build": "npm run clean && rm -rf lib && tsc --declaration", + "build": "npm run clean && tsc --declaration", "prepublishOnly": "npm i --package-lock-only && npm run lint && npm run build", "postpublish": "npm run clean", - "clean": "rimraf dist && rimraf coverage" + "clean": "rimraf dist && rimraf coverage", + "docs": "typedoc", + "lint-docs": "typedoc --emit none --treatWarningsAsErrors" }, "standard-version": { "scripts": { @@ -47,30 +44,37 @@ "publishConfig": { "access": "public" }, + "maintainers": [ + "dgreif" + ], "dependencies": { - "execa": "^9.4.0", - "ffmpeg-for-homebridge": "^2.1.2", + "execa": "^9.5.1", + "ffmpeg-for-homebridge": "^2.1.7", "pick-port": "^2.1.0", "rxjs": "^7.8.1", "systeminformation": "^5.23.5" }, + "files": [ + "dist" + ], "devDependencies": { - "@antfu/eslint-config": "^3.7.3", + "@antfu/eslint-config": "^3.8.0", "@types/debug": "^4.1.12", "@types/fs-extra": "^11.0.4", - "@types/node": "^22.7.4", + "@types/node": "^22.8.7", "@types/semver": "^7.5.8", "@types/source-map-support": "^0.5.10", - "@vitest/coverage-v8": "^2.1.2", + "@vitest/coverage-v8": "^2.1.4", "dotenv": "^16.4.5", - "eslint": "^9.10.0", + "eslint": "^9.14.0", "eslint-plugin-format": "^0.1.2", "nodemon": "^3.1.7", "rimraf": "^6.0.1", "standard-version": "^9.5.0", "ts-node": "^10.9.2", - "typescript": "^5.6.2", - "vite": "^5.4.8", - "vitest": "^2.1.2" + "typedoc": "^0.26.11", + "typescript": "^5.6.3", + "vite": "^5.4.10", + "vitest": "^2.1.4" } -} +} \ No newline at end of file diff --git a/src/default-ip.ts b/src/default-ip.ts index 52135a4..7a480ce 100644 --- a/src/default-ip.ts +++ b/src/default-ip.ts @@ -1,18 +1,18 @@ -import os from 'os' +import os from 'node:os' import { networkInterfaceDefault } from 'systeminformation' // NOTE: This is used to get the default ip address seen from outside the device. // This _was_ needed to set the `address` field for hap camera streams, but the address is automatically determined after homebridge 1.1.3 // Keeping this here for backwards compatibility for now. All camera plugins will eventually drop support for <1.1.3 and this can be removed export async function getDefaultIpAddress(preferIpv6 = false) { - const interfaces = os.networkInterfaces(), - defaultInterfaceName = await networkInterfaceDefault(), - defaultInterface = interfaces[defaultInterfaceName], - externalInfo = defaultInterface?.filter((info) => !info.internal), - preferredFamily = preferIpv6 ? 'IPv6' : 'IPv4', - addressInfo = - externalInfo?.find((info) => info.family === preferredFamily) || - externalInfo?.[0] + const interfaces = os.networkInterfaces() + const defaultInterfaceName = await networkInterfaceDefault() + const defaultInterface = interfaces[defaultInterfaceName] + const externalInfo = defaultInterface?.filter(info => !info.internal) + const preferredFamily = preferIpv6 ? 'IPv6' : 'IPv4' + const addressInfo + = externalInfo?.find(info => info.family === preferredFamily) + || externalInfo?.[0] if (!addressInfo) { throw new Error('Unable to get default network address') diff --git a/src/ffmpeg-process.ts b/src/ffmpeg-process.ts index 6fd34f2..7615af3 100644 --- a/src/ffmpeg-process.ts +++ b/src/ffmpeg-process.ts @@ -1,12 +1,14 @@ +import { spawn } from 'node:child_process' +import process from 'node:process' import { Subject } from 'rxjs' -import { spawn } from 'child_process' -import { defaultFfmpegPath } from './ffmpeg' -const noop = () => null, - onGlobalProcessStopped = new Subject() +import { defaultFfmpegPath } from './ffmpeg.js' -// register a single event listener, rather than listener per ffmpeg process -// this helps avoid a warning for hitting too many listeners +const noop = () => null +const onGlobalProcessStopped = new Subject() + +// Register a single event listener, rather than listener per ffmpeg process +// This helps avoid a warning for hitting too many listeners process.on('exit', () => onGlobalProcessStopped.next(null)) export interface FfmpegProcessOptions { @@ -23,31 +25,35 @@ export interface FfmpegProcessOptions { } export class FfmpegProcess { - private ff = spawn( - this.options.ffmpegPath || defaultFfmpegPath, - this.options.ffmpegArgs.map((x) => x.toString()), - ) + private ff: ReturnType + private processSubscription = onGlobalProcessStopped.subscribe(() => { this.stop() }) + private started = false private stopped = false private exited = false constructor(public readonly options: FfmpegProcessOptions) { - const { logger, logLabel } = options, - logError = logger?.error || noop, - logInfo = logger?.info || noop, - logPrefix = logLabel ? `${logLabel}: ` : '' + this.ff = spawn( + this.options.ffmpegPath || defaultFfmpegPath, + this.options.ffmpegArgs.map(x => x.toString()), + ) - if (options.stdoutCallback) { + const { logger, logLabel } = options + const logError = logger?.error || noop + const logInfo = logger?.info || noop + const logPrefix = logLabel ? `${logLabel}: ` : '' + + if (options.stdoutCallback && this.ff.stdout) { const { stdoutCallback } = options this.ff.stdout.on('data', (data: any) => { stdoutCallback(data) }) } - this.ff.stderr.on('data', (data: any) => { + this.ff.stderr?.on('data', (data: any) => { if (!this.started) { this.started = true options.startedCallback?.() @@ -56,7 +62,7 @@ export class FfmpegProcess { logInfo(logPrefix + data) }) - this.ff.stdin.on('error', (error) => { + this.ff.stdin?.on('error', (error) => { if (!error.message.includes('EPIPE')) { logError(logPrefix + error.message) } @@ -67,9 +73,9 @@ export class FfmpegProcess { this.options.exitCallback?.(code, signal) if (!code || code === 255) { - logInfo(logPrefix + 'stopped gracefully') + logInfo(`${logPrefix}stopped gracefully`) } else { - logError(logPrefix + `exited with code ${code} and signal ${signal}`) + logError(`${logPrefix}exited with code ${code} and signal ${signal}`) } this.stop() }) @@ -82,8 +88,8 @@ export class FfmpegProcess { this.stopped = true this.processSubscription.unsubscribe() - this.ff.stderr.pause() - this.ff.stdout.pause() + this.ff.stderr?.pause() + this.ff.stdout?.pause() if (!this.exited) { this.ff.kill() @@ -95,7 +101,11 @@ export class FfmpegProcess { return } - this.ff.stdin.write(input) - this.ff.stdin.end() + if (this.ff.stdin) { + this.ff.stdin.write(input) + } + if (this.ff.stdin) { + this.ff.stdin.end() + } } } diff --git a/src/index.ts b/src/index.ts index 75a3962..1b9ebd9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ -export * from './default-ip' -export * from './ffmpeg' -export * from './ffmpeg-process' -export * from './ports' -export * from './return-audio-transcoder' -export * from './rtp' -export * from './rtp-splitter' -export * from './srtp' +export * from './default-ip.js' +export * from './ffmpeg.js' +export * from './ffmpeg-process.js' +export * from './ports.js' +export * from './return-audio-transcoder.js' +export * from './rtp.js' +export * from './rtp-splitter.js' +export * from './srtp.js' diff --git a/src/ports.ts b/src/ports.ts index c0708ac..2d3f371 100644 --- a/src/ports.ts +++ b/src/ports.ts @@ -1,5 +1,5 @@ -import { Socket } from 'dgram' -import { AddressInfo } from 'net' +import type { Socket } from 'node:dgram' +import type { AddressInfo } from 'node:net' import { pickPort } from 'pick-port' // Need to reserve ports in sequence because ffmpeg uses the next port up by default. If it's taken, ffmpeg will error @@ -17,27 +17,27 @@ export async function reservePorts({ } const pickPortOptions = { + type, + reserveTimeout: 15, // 15 seconds is max setup time for HomeKit streams, so the port should be in use by then + } + const port = await pickPort(pickPortOptions) + const ports = [port] + const tryAgain = () => { + return reservePorts({ + count, type, - reserveTimeout: 15, // 15 seconds is max setup time for HomeKit streams, so the port should be in use by then - }, - port = await pickPort(pickPortOptions), - ports = [port], - tryAgain = () => { - return reservePorts({ - count, - type, - attemptNumber: attemptNumber + 1, - }) - } + attemptNumber: attemptNumber + 1, + }) + } for (let i = 1; i < count; i++) { try { - const targetConsecutivePort = port + i, - openPort = await pickPort({ - ...pickPortOptions, - minPort: targetConsecutivePort, - maxPort: targetConsecutivePort, - }) + const targetConsecutivePort = port + i + const openPort = await pickPort({ + ...pickPortOptions, + minPort: targetConsecutivePort, + maxPort: targetConsecutivePort, + }) ports.push(openPort) } catch (_) { diff --git a/src/return-audio-transcoder.ts b/src/return-audio-transcoder.ts index f39936a..b99e968 100644 --- a/src/return-audio-transcoder.ts +++ b/src/return-audio-transcoder.ts @@ -1,8 +1,10 @@ -import { RtpSplitter } from './rtp-splitter' -import { FfmpegProcess, FfmpegProcessOptions } from './ffmpeg-process' -import { createCryptoLine } from './srtp' -import { reservePorts } from './ports' -import { getSsrc } from './rtp' +import type { Buffer } from 'node:buffer' +import type { FfmpegProcessOptions } from './ffmpeg-process.js' +import { FfmpegProcess } from './ffmpeg-process.js' +import { reservePorts } from './ports.js' +import { getSsrc } from './rtp.js' +import { RtpSplitter } from './rtp-splitter.js' +import { createCryptoLine } from './srtp.js' interface Source { srtp_key: Buffer @@ -35,26 +37,9 @@ const defaultStartStreamReqeuest: StartStreamRequest = { export class ReturnAudioTranscoder { public readonly returnRtpSplitter - private startStreamRequest = - this.options.startStreamRequest || defaultStartStreamReqeuest - public readonly ffmpegProcess = new FfmpegProcess({ - ffmpegArgs: [ - '-hide_banner', - '-protocol_whitelist', - 'pipe,udp,rtp,file,crypto', - '-f', - 'sdp', - '-acodec', - this.startStreamRequest.audio.codec === 'OPUS' ? 'libopus' : 'libfdk_aac', - '-i', - 'pipe:', - '-map', - '0:0', - ...this.options.outputArgs, - ], - ...this.options, - }) - public readonly reservedPortsPromise = reservePorts({ count: 2 }) + private startStreamRequest + + public readonly ffmpegProcess constructor( private options: { @@ -68,25 +53,49 @@ export class ReturnAudioTranscoder { startStreamRequest?: StartStreamRequest } & Omit, ) { + this.startStreamRequest = this.options.startStreamRequest || defaultStartStreamReqeuest + + this.ffmpegProcess = new FfmpegProcess({ + ffmpegArgs: [ + '-hide_banner', + '-protocol_whitelist', + 'pipe,udp,rtp,file,crypto', + '-f', + 'sdp', + '-acodec', + this.startStreamRequest.audio.codec === 'OPUS' ? 'libopus' : 'libfdk_aac', + '-i', + 'pipe:', + '-map', + '0:0', + ...this.options.outputArgs, + ], + ...this.options, + }) + // allow return audio splitter to be passed in if you want to create one in the prepare stream phase, and create the transcoder in the stream request phase this.returnRtpSplitter = options.returnAudioSplitter || new RtpSplitter() } + public readonly reservedPortsPromise = reservePorts({ count: 2 }) + + // Removed duplicate constructor + async start() { - const [rtpPort, rtcpPort] = await this.reservedPortsPromise, - { - targetAddress, - addressVersion, - audio: { srtp_key: srtpKey, srtp_salt: srtpSalt }, - } = this.options.prepareStreamRequest, - { ssrc: incomingAudioSsrc, rtcpPort: incomingAudioRtcpPort } = - this.options.incomingAudioOptions, - { - codec, - sample_rate, - channel, - pt: packetType, - } = this.startStreamRequest.audio + const [rtpPort, rtcpPort] = await this.reservedPortsPromise + const { + targetAddress, + addressVersion, + audio: { srtp_key: srtpKey, srtp_salt: srtpSalt }, + } = this.options.prepareStreamRequest + const { ssrc: incomingAudioSsrc, rtcpPort: incomingAudioRtcpPort } + = this.options.incomingAudioOptions + const { + codec, + sample_rate, + channel, + pt: packetType, + } = this.startStreamRequest.audio this.ffmpegProcess.writeStdin( // This SDP was generated using ffmpeg, and describes the type of packets we expect to receive from HomeKit. diff --git a/src/rtp-splitter.ts b/src/rtp-splitter.ts index 379789b..e11e6fa 100644 --- a/src/rtp-splitter.ts +++ b/src/rtp-splitter.ts @@ -1,8 +1,10 @@ -import { createSocket, RemoteInfo } from 'dgram' -import { bindToPort } from './ports' +import type { Buffer } from 'node:buffer' +import type { RemoteInfo } from 'node:dgram' +import { createSocket } from 'node:dgram' import { fromEvent, merge, ReplaySubject } from 'rxjs' import { map, share, takeUntil } from 'rxjs/operators' -import { getPayloadType, isRtpMessagePayloadType } from './rtp' +import { bindToPort } from './ports.js' +import { getPayloadType, isRtpMessagePayloadType } from './rtp.js' export interface SocketTarget { port: number diff --git a/src/rtp.ts b/src/rtp.ts index ab8f8f1..fffd87e 100644 --- a/src/rtp.ts +++ b/src/rtp.ts @@ -1,5 +1,7 @@ +import type { Buffer } from 'node:buffer' + export function getPayloadType(message: Buffer) { - return message.readUInt8(1) & 0x7f + return message.readUInt8(1) & 0x7F } export function isRtpMessagePayloadType(payloadType: number) { @@ -8,8 +10,8 @@ export function isRtpMessagePayloadType(payloadType: number) { export function getSsrc(message: Buffer) { try { - const payloadType = getPayloadType(message), - isRtp = isRtpMessagePayloadType(payloadType) + const payloadType = getPayloadType(message) + const isRtp = isRtpMessagePayloadType(payloadType) return message.readUInt32BE(isRtp ? 8 : 4) } catch (_) { return null diff --git a/src/srtp.ts b/src/srtp.ts index 28ca352..5c0a542 100644 --- a/src/srtp.ts +++ b/src/srtp.ts @@ -1,4 +1,6 @@ -import { randomBytes } from 'crypto' +import { Buffer } from 'node:buffer' + +import { randomBytes } from 'node:crypto' export interface SrtpOptions { srtpKey: Buffer diff --git a/tsconfig.json b/tsconfig.json index b88bb4e..a902743 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,18 +1,25 @@ { "compilerOptions": { - "target":"es2015", + "target": "ESNext", "lib": [ - "es5", - "es2015", - "es2017", - "DOM" + "ESNext" ], - "module": "commonjs", - "outDir": "./lib", + "rootDir": "./src", + "module": "ESNext", + "moduleResolution": "node", + "strict": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "preserveConstEnums": true, + "sourceMap": true, "esModuleInterop": true, - "strict": true + "skipLibCheck": true }, "include": [ - "./src/**/*.ts" + "src/" + ], + "exclude": [ + "**/*.spec.ts" ] }