homebridge-plugin-utils 1.34.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -2
- package/build/eslint-plugin/README.md +164 -0
- package/build/eslint-plugin/config.mjs +308 -0
- package/build/eslint-plugin/index.mjs +7 -0
- package/build/eslint-plugin/plugin.mjs +46 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
- package/build/eslint-plugin/rules/comment-style.mjs +190 -0
- package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
- package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
- package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
- package/build/eslint-plugin/test-setup.mjs +21 -0
- package/build/fs-ops.mjs +184 -0
- package/build/tsconfig.json +18 -3
- package/dist/backpressure.d.ts +128 -0
- package/dist/backpressure.js +273 -0
- package/dist/backpressure.js.map +1 -0
- package/dist/cli/index.d.ts +95 -0
- package/dist/cli/index.js +355 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/clock-double.d.ts +93 -0
- package/dist/clock-double.js +141 -0
- package/dist/clock-double.js.map +1 -0
- package/dist/clock.d.ts +39 -0
- package/dist/clock.js +34 -0
- package/dist/clock.js.map +1 -0
- package/dist/eslint-plugin/config.d.mts +193 -0
- package/dist/eslint-plugin/index.d.mts +2 -0
- package/dist/eslint-plugin/plugin.d.mts +87 -0
- package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
- package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
- package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
- package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
- package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
- package/dist/featureOptions-docs.d.ts +96 -0
- package/dist/featureOptions-docs.js +193 -0
- package/dist/featureOptions-docs.js.map +1 -0
- package/dist/featureOptions.d.ts +674 -0
- package/dist/featureOptions.js +870 -0
- package/dist/featureOptions.js.map +1 -0
- package/dist/ffmpeg/codecs.d.ts +256 -72
- package/dist/ffmpeg/codecs.js +477 -257
- package/dist/ffmpeg/codecs.js.map +1 -1
- package/dist/ffmpeg/dgram-util.d.ts +46 -0
- package/dist/ffmpeg/dgram-util.js +38 -0
- package/dist/ffmpeg/dgram-util.js.map +1 -0
- package/dist/ffmpeg/exec.d.ts +83 -64
- package/dist/ffmpeg/exec.js +77 -86
- package/dist/ffmpeg/exec.js.map +1 -1
- package/dist/ffmpeg/fmp4.d.ts +70 -4
- package/dist/ffmpeg/fmp4.js +100 -11
- package/dist/ffmpeg/fmp4.js.map +1 -1
- package/dist/ffmpeg/hap-enums.d.ts +214 -0
- package/dist/ffmpeg/hap-enums.js +92 -0
- package/dist/ffmpeg/hap-enums.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +16 -9
- package/dist/ffmpeg/index.js +6 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
- package/dist/ffmpeg/mp4-assembler.js +387 -0
- package/dist/ffmpeg/mp4-assembler.js.map +1 -0
- package/dist/ffmpeg/mp4-parser.d.ts +94 -0
- package/dist/ffmpeg/mp4-parser.js +130 -0
- package/dist/ffmpeg/mp4-parser.js.map +1 -0
- package/dist/ffmpeg/options.d.ts +62 -149
- package/dist/ffmpeg/options.js +608 -499
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +143 -91
- package/dist/ffmpeg/process.js +406 -265
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +302 -188
- package/dist/ffmpeg/record.js +408 -560
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +144 -0
- package/dist/ffmpeg/recording-process-double.js +168 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -0
- package/dist/ffmpeg/rtp-parser.d.ts +70 -0
- package/dist/ffmpeg/rtp-parser.js +77 -0
- package/dist/ffmpeg/rtp-parser.js.map +1 -0
- package/dist/ffmpeg/rtp.d.ts +198 -141
- package/dist/ffmpeg/rtp.js +473 -244
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/settings.d.ts +5 -1
- package/dist/ffmpeg/settings.js +20 -2
- package/dist/ffmpeg/settings.js.map +1 -1
- package/dist/ffmpeg/stream.d.ts +57 -107
- package/dist/ffmpeg/stream.js +121 -148
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/formatters.d.ts +106 -0
- package/dist/formatters.js +174 -0
- package/dist/formatters.js.map +1 -0
- package/dist/homebridge-enums.d.ts +30 -0
- package/dist/homebridge-enums.js +17 -0
- package/dist/homebridge-enums.js.map +1 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +114 -0
- package/dist/logclient/auth.js +199 -0
- package/dist/logclient/auth.js.map +1 -0
- package/dist/logclient/cli-run.d.ts +76 -0
- package/dist/logclient/cli-run.js +639 -0
- package/dist/logclient/cli-run.js.map +1 -0
- package/dist/logclient/cli.d.ts +3 -0
- package/dist/logclient/cli.js +97 -0
- package/dist/logclient/cli.js.map +1 -0
- package/dist/logclient/client.d.ts +145 -0
- package/dist/logclient/client.js +600 -0
- package/dist/logclient/client.js.map +1 -0
- package/dist/logclient/config.d.ts +173 -0
- package/dist/logclient/config.js +199 -0
- package/dist/logclient/config.js.map +1 -0
- package/dist/logclient/endpoints.d.ts +54 -0
- package/dist/logclient/endpoints.js +73 -0
- package/dist/logclient/endpoints.js.map +1 -0
- package/dist/logclient/filter.d.ts +45 -0
- package/dist/logclient/filter.js +51 -0
- package/dist/logclient/filter.js.map +1 -0
- package/dist/logclient/frame.d.ts +93 -0
- package/dist/logclient/frame.js +203 -0
- package/dist/logclient/frame.js.map +1 -0
- package/dist/logclient/index.d.ts +31 -0
- package/dist/logclient/index.js +12 -0
- package/dist/logclient/index.js.map +1 -0
- package/dist/logclient/parser.d.ts +211 -0
- package/dist/logclient/parser.js +393 -0
- package/dist/logclient/parser.js.map +1 -0
- package/dist/logclient/rest.d.ts +41 -0
- package/dist/logclient/rest.js +111 -0
- package/dist/logclient/rest.js.map +1 -0
- package/dist/logclient/settings.d.ts +15 -0
- package/dist/logclient/settings.js +64 -0
- package/dist/logclient/settings.js.map +1 -0
- package/dist/logclient/socket-double.d.ts +201 -0
- package/dist/logclient/socket-double.js +384 -0
- package/dist/logclient/socket-double.js.map +1 -0
- package/dist/logclient/socket.d.ts +257 -0
- package/dist/logclient/socket.js +620 -0
- package/dist/logclient/socket.js.map +1 -0
- package/dist/logclient/stitch.d.ts +83 -0
- package/dist/logclient/stitch.js +146 -0
- package/dist/logclient/stitch.js.map +1 -0
- package/dist/logclient/time-expression.d.ts +42 -0
- package/dist/logclient/time-expression.js +181 -0
- package/dist/logclient/time-expression.js.map +1 -0
- package/dist/logclient/time-window.d.ts +38 -0
- package/dist/logclient/time-window.js +53 -0
- package/dist/logclient/time-window.js.map +1 -0
- package/dist/logclient/types.d.ts +107 -0
- package/dist/logclient/types.js +6 -0
- package/dist/logclient/types.js.map +1 -0
- package/dist/mqttClient.d.ts +287 -0
- package/dist/mqttClient.js +433 -0
- package/dist/mqttClient.js.map +1 -0
- package/dist/service.d.ts +64 -15
- package/dist/service.js +93 -66
- package/dist/service.js.map +1 -1
- package/dist/ui/featureOptions.js +870 -0
- package/dist/ui/featureOptions.js.map +1 -0
- package/dist/ui/formatters.js +174 -0
- package/dist/ui/formatters.js.map +1 -0
- package/dist/ui/pluginConfigSession.mjs +141 -0
- package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
- package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
- package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
- package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
- package/dist/ui/webUi-featureOptions/state.mjs +319 -0
- package/dist/ui/webUi-featureOptions/store.mjs +181 -0
- package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
- package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
- package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
- package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
- package/dist/ui/webUi-featureOptions.mjs +677 -0
- package/dist/ui/webUi.mjs +192 -87
- package/dist/util.d.ts +597 -45
- package/dist/util.js +787 -68
- package/dist/util.js.map +1 -1
- package/package.json +33 -15
- package/build/eslint-rules.mjs +0 -511
- package/dist/featureoptions.d.ts +0 -264
- package/dist/featureoptions.js +0 -480
- package/dist/featureoptions.js.map +0 -1
- package/dist/mqttclient.d.ts +0 -178
- package/dist/mqttclient.js +0 -310
- package/dist/mqttclient.js.map +0 -1
- package/dist/ui/featureoptions.js +0 -480
- package/dist/ui/featureoptions.js.map +0 -1
- package/dist/ui/webUi-featureoptions.mjs +0 -3765
package/dist/ffmpeg/rtp.js
CHANGED
|
@@ -1,333 +1,562 @@
|
|
|
1
1
|
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
2
|
*
|
|
3
|
-
* ffmpeg/rtp.ts:
|
|
3
|
+
* ffmpeg/rtp.ts: Signal-driven RTP/RTCP demultiplexer with FFmpeg keepalive heartbeat, and UDP port reservation registry.
|
|
4
4
|
*
|
|
5
5
|
* This module is heavily inspired by the homebridge and homebridge-camera-ffmpeg source code and borrows from both. Thank you for your contributions to the community.
|
|
6
6
|
*/
|
|
7
|
+
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
8
|
+
if (value !== null && value !== void 0) {
|
|
9
|
+
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
10
|
+
var dispose, inner;
|
|
11
|
+
if (async) {
|
|
12
|
+
if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
|
|
13
|
+
dispose = value[Symbol.asyncDispose];
|
|
14
|
+
}
|
|
15
|
+
if (dispose === void 0) {
|
|
16
|
+
if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
|
|
17
|
+
dispose = value[Symbol.dispose];
|
|
18
|
+
if (async) inner = dispose;
|
|
19
|
+
}
|
|
20
|
+
if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
|
|
21
|
+
if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
|
|
22
|
+
env.stack.push({ value: value, dispose: dispose, async: async });
|
|
23
|
+
}
|
|
24
|
+
else if (async) {
|
|
25
|
+
env.stack.push({ async: true });
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
};
|
|
29
|
+
var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
|
|
30
|
+
return function (env) {
|
|
31
|
+
function fail(e) {
|
|
32
|
+
env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
|
|
33
|
+
env.hasError = true;
|
|
34
|
+
}
|
|
35
|
+
var r, s = 0;
|
|
36
|
+
function next() {
|
|
37
|
+
while (r = env.stack.pop()) {
|
|
38
|
+
try {
|
|
39
|
+
if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
|
|
40
|
+
if (r.dispose) {
|
|
41
|
+
var result = r.dispose.call(r.value);
|
|
42
|
+
if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
|
|
43
|
+
}
|
|
44
|
+
else s |= 1;
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
fail(e);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
|
|
51
|
+
if (env.hasError) throw env.error;
|
|
52
|
+
}
|
|
53
|
+
return next();
|
|
54
|
+
};
|
|
55
|
+
})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
56
|
+
var e = new Error(message);
|
|
57
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
58
|
+
});
|
|
7
59
|
/**
|
|
8
|
-
* RTP
|
|
60
|
+
* Signal-driven RTP/RTCP demultiplexing, FFmpeg keepalive heartbeat, and UDP port reservation for FFmpeg-based HomeKit livestreaming.
|
|
9
61
|
*
|
|
10
|
-
* This module
|
|
11
|
-
* and RTCP packets on a single UDP port, as required by HomeKit and RFC 5761, working around FFmpeg’s lack of native support for RTP/RTCP multiplexing. It also manages
|
|
12
|
-
* the allocation and tracking of UDP ports for RTP and RTCP, helping prevent conflicts in dynamic, multi-session streaming scenarios.
|
|
62
|
+
* This module exposes the following cooperating surfaces:
|
|
13
63
|
*
|
|
14
|
-
*
|
|
64
|
+
* - {@link RtpDemuxer} - an {@link AsyncDisposable} that owns one bound UDP socket. Inbound datagrams are classified as RTP or RTCP per RFC 5761 and forwarded to the
|
|
65
|
+
* respective destination ports through the same socket so the outbound source endpoint equals the bound input port - a property downstream consumers can leverage
|
|
66
|
+
* for source-endpoint filtering against locally-spoofed traffic, and a property that simultaneously minimizes the demuxer's bound-port footprint to exactly one
|
|
67
|
+
* kernel-tracked socket. A self-rearming inactivity {@link Watchdog} re-emits the last received RTCP packet to the RTP destination port at the
|
|
68
|
+
* {@link RTCP_HEARTBEAT_INTERVAL} cadence whenever inbound RTCP traffic stalls, keeping inbound traffic arriving at FFmpeg's RTP input during legitimate quiet
|
|
69
|
+
* periods in two-way audio. A second optional inactivity {@link Watchdog} aborts the demuxer when no inbound traffic arrives within a caller-configurable window.
|
|
70
|
+
* - {@link RtpPortAllocator} - a plugin-singleton registry that issues {@link PortReservation} handles. The allocator itself holds no OS resources; each reservation
|
|
71
|
+
* is the scoped resource that owns one or two UDP ports.
|
|
72
|
+
* - {@link PortReservation} - an {@link AsyncDisposable} handle. Disposal releases the reserved ports back to the allocator's internal pool. Callers use
|
|
73
|
+
* `await using reservation = await allocator.reserve(...)` for scope-bound lifetimes, or store the handle and dispose it explicitly when ownership outlives a
|
|
74
|
+
* single scope.
|
|
15
75
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - Dynamically allocates and reserves UDP ports for RTP/RTCP, supporting consecutive port pairing for correct FFmpeg operation.
|
|
19
|
-
* - Event-driven architecture for integration with plugin or automation logic.
|
|
76
|
+
* Reservations are handles, not raw port numbers, so "did I remember to release?" is not a footgun: disposing the handle releases the held ports, and dropping a
|
|
77
|
+
* reference without disposing is a type-level mistake the `AsyncDisposable` contract and `await using` idiom make hard to commit.
|
|
20
78
|
*
|
|
21
|
-
*
|
|
22
|
-
* with FFmpeg.
|
|
79
|
+
* @see {@link https://tools.ietf.org/html/rfc5761 | RFC 5761}
|
|
23
80
|
*
|
|
24
81
|
* @module
|
|
25
82
|
*/
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
|
|
29
|
-
|
|
83
|
+
import { HbpuAbortError, Watchdog, composeSignals, isTimeoutReason, markHandled, onAbort } from "../util.js";
|
|
84
|
+
import { createDgramSocket, loopbackAddress } from "./dgram-util.js";
|
|
85
|
+
import { RTCP_HEARTBEAT_INTERVAL } from "./settings.js";
|
|
86
|
+
import { RtpPacketParser } from "./rtp-parser.js";
|
|
87
|
+
import { once } from "node:events";
|
|
88
|
+
// Upper bound on retry cycles when a `reserve()` call has to fall back to a fresh random-port pair because a specific consecutive port collided. The number is
|
|
89
|
+
// intentionally generous - ten attempts covers realistic host contention without masking true exhaustion, and a hard ceiling keeps the loop from spinning indefinitely
|
|
90
|
+
// when every ephemeral port is somehow in use.
|
|
91
|
+
const RESERVE_MAX_ATTEMPTS = 10;
|
|
30
92
|
/**
|
|
31
|
-
*
|
|
93
|
+
* Signal-driven RTP/RTCP demultiplexer with FFmpeg keepalive heartbeat.
|
|
32
94
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
95
|
+
* The class owns one bound UDP socket. Inbound datagrams are classified by an internal {@link RtpPacketParser} and forwarded through the same socket to the configured
|
|
96
|
+
* `rtpPort` (RTP-classified) or `rtcpPort` (RTCP-classified) on the loopback interface. Sharing the bound socket between receive and send gives the relay two
|
|
97
|
+
* load-bearing properties:
|
|
35
98
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
99
|
+
* 1. **Source-endpoint symmetry.** Every forwarded datagram leaves the bound socket with source = `loopback:inputPort`. Because the demuxer holds an exclusive
|
|
100
|
+
* kernel bind on that port, no other non-root process can spoof that source endpoint - downstream receivers (typically FFmpeg) can therefore enforce source
|
|
101
|
+
* filtering against locally-injected traffic without coordinating ephemeral allocations with the demuxer.
|
|
102
|
+
* 2. **Minimized bound-port footprint.** Exactly one kernel-tracked socket exists per demuxer instance; a dual-socket design would bind a second ephemeral port for
|
|
103
|
+
* the duration of the session, expanding the attack surface and the kernel-resource count for no architectural benefit.
|
|
104
|
+
*
|
|
105
|
+
* A self-rearming {@link Watchdog} replays the last observed RTCP packet to `rtpPort` whenever the gap between inbound RTCP arrivals exceeds the configured
|
|
106
|
+
* {@link RTCP_HEARTBEAT_INTERVAL}. The heartbeat is part of the demuxer's invariant contract - FFmpeg-bound two-way audio is the only use case that constructs this
|
|
107
|
+
* class, and that use case relies on the keepalive to keep inbound traffic arriving at FFmpeg's RTP input during legitimate quiet periods on the camera's audio
|
|
108
|
+
* backchannel. No FFmpeg input-timeout flag (`-timeout` / `rw_timeout`) is configured anywhere - the keepalive is a defensive guard against any FFmpeg-side idle
|
|
109
|
+
* handling of a quiet UDP/RTP input, not a timeout this code sets. The cadence is the single exported constant {@link RTCP_HEARTBEAT_INTERVAL}.
|
|
110
|
+
*
|
|
111
|
+
* An optional second {@link Watchdog} aborts the demuxer with `HbpuAbortError("timeout")` when no inbound traffic arrives within a caller-supplied window. Both
|
|
112
|
+
* watchdogs compose against the same lifetime signal, so disposal cleans them up uniformly.
|
|
38
113
|
*
|
|
39
114
|
* @example
|
|
40
115
|
*
|
|
41
116
|
* ```ts
|
|
42
|
-
*
|
|
43
|
-
*
|
|
117
|
+
* await using demuxer = new RtpDemuxer({
|
|
118
|
+
*
|
|
119
|
+
* inactivityTimeout: 5_000,
|
|
120
|
+
* inputPort: audioIncomingPort,
|
|
121
|
+
* ipFamily: "ipv4",
|
|
122
|
+
* log,
|
|
123
|
+
* rtcpPort: audioIncomingRtcpPort,
|
|
124
|
+
* rtpPort: audioIncomingRtpPort,
|
|
125
|
+
* signal: session.signal
|
|
126
|
+
* });
|
|
44
127
|
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
128
|
+
* try {
|
|
129
|
+
*
|
|
130
|
+
* await demuxer.mediaReady;
|
|
131
|
+
* } catch {
|
|
132
|
+
*
|
|
133
|
+
* // Demuxer aborted before any inbound RTP arrived; abandon the return-audio setup.
|
|
134
|
+
* return;
|
|
135
|
+
* }
|
|
136
|
+
*
|
|
137
|
+
* // Media is now flowing - safe to launch the consuming FFmpeg process.
|
|
47
138
|
* ```
|
|
48
139
|
*
|
|
49
|
-
* @see
|
|
50
|
-
* @see
|
|
140
|
+
* @see RtpPacketParser
|
|
141
|
+
* @see RTCP_HEARTBEAT_INTERVAL
|
|
51
142
|
*
|
|
52
143
|
* @category FFmpeg
|
|
53
144
|
*/
|
|
54
|
-
export class RtpDemuxer
|
|
55
|
-
heartbeatTimer;
|
|
56
|
-
heartbeatMsg;
|
|
57
|
-
_isRunning;
|
|
58
|
-
log;
|
|
59
|
-
inputPort;
|
|
60
|
-
socket;
|
|
145
|
+
export class RtpDemuxer {
|
|
61
146
|
/**
|
|
62
|
-
*
|
|
147
|
+
* The composed abort signal representing this demuxer's lifetime. Aborts exactly once when the socket errors, the inactivity watchdog fires, the parent signal
|
|
148
|
+
* propagates, or {@link RtpDemuxer.abort} is called; the reason encoded on `signal.reason` names the cause.
|
|
149
|
+
*/
|
|
150
|
+
signal;
|
|
151
|
+
/**
|
|
152
|
+
* Promise that resolves once the underlying UDP socket has finished binding and entered its `"listening"` state. Rejects with `this.signal.reason` when the bind
|
|
153
|
+
* fails, the parent signal propagates, or any other abort path fires before the socket becomes ready.
|
|
63
154
|
*
|
|
64
|
-
* @
|
|
65
|
-
*
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
155
|
+
* The unhandled-rejection tracker is suppressed via {@link markHandled} so consumers that never observe readiness (fire-and-forget demuxers) do not produce warnings;
|
|
156
|
+
* consumers that do observe rejection receive the same structured abort reason they would from `signal.reason`.
|
|
157
|
+
*/
|
|
158
|
+
ready;
|
|
159
|
+
/**
|
|
160
|
+
* Promise that resolves once the demuxer has forwarded its first RTP-classified packet - the application-level readiness milestone meaning "media is now flowing
|
|
161
|
+
* through the relay to the downstream consumer." Rejects with `this.signal.reason` if the demuxer aborts before any RTP packet arrives.
|
|
69
162
|
*
|
|
70
|
-
* @
|
|
163
|
+
* Pairs with {@link RtpDemuxer.ready} as a two-tier readiness model: `ready` resolves when the inbound socket has bound (network-level readiness); `mediaReady`
|
|
164
|
+
* resolves when classified RTP traffic has begun flowing (application-level readiness). The canonical gating pattern for two-way audio is "stand up the return-audio
|
|
165
|
+
* path, wait for inbound media before launching the consuming FFmpeg process":
|
|
71
166
|
*
|
|
72
167
|
* ```ts
|
|
73
|
-
*
|
|
168
|
+
* try {
|
|
169
|
+
* await demuxer.mediaReady;
|
|
170
|
+
* } catch {
|
|
171
|
+
* // Demuxer aborted before any RTP arrived; abandon the return-audio setup.
|
|
172
|
+
* return;
|
|
173
|
+
* }
|
|
174
|
+
* // Safe to launch the consuming FFmpeg process now that media is flowing.
|
|
74
175
|
* ```
|
|
176
|
+
*
|
|
177
|
+
* The unhandled-rejection tracker is suppressed via {@link markHandled} so consumers that never observe this promise produce no warnings.
|
|
75
178
|
*/
|
|
76
|
-
|
|
77
|
-
super();
|
|
78
|
-
this._isRunning = false;
|
|
79
|
-
this.log = log;
|
|
80
|
-
this.inputPort = inputPort;
|
|
81
|
-
this.socket = createSocket(ipFamily === "ipv6" ? "udp6" : "udp4");
|
|
82
|
-
// Catch errors when they happen on our demuxer.
|
|
83
|
-
this.socket.on("error", (error) => {
|
|
84
|
-
this.log?.error("RtpDemuxer Error: %s", error);
|
|
85
|
-
this.socket.close();
|
|
86
|
-
});
|
|
87
|
-
// Split the message into RTP and RTCP packets.
|
|
88
|
-
this.socket.on("message", (msg) => {
|
|
89
|
-
// Send RTP packets to the RTP port.
|
|
90
|
-
if (this.isRtpMessage(msg)) {
|
|
91
|
-
this.emit("rtp");
|
|
92
|
-
this.socket.send(msg, rtpPort);
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
// Save this RTCP message for heartbeat purposes for the RTP port. This works because RTCP packets will be ignored by ffmpeg on the RTP port, effectively
|
|
96
|
-
// providing a heartbeat to ensure FFmpeg doesn't timeout if there's an extended delay between data transmission.
|
|
97
|
-
this.heartbeatMsg = Buffer.from(msg);
|
|
98
|
-
// Clear the old heartbeat timer.
|
|
99
|
-
clearTimeout(this.heartbeatTimer);
|
|
100
|
-
this.heartbeat(rtpPort);
|
|
101
|
-
// RTCP control packets should go to the RTCP port.
|
|
102
|
-
this.socket.send(msg, rtcpPort);
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
this.log.debug("Creating an RtpDemuxer instance - inbound port: %s, RTCP port: %s, RTP port: %s.", this.inputPort, rtcpPort, rtpPort);
|
|
106
|
-
// Take the socket live.
|
|
107
|
-
this.socket.bind(this.inputPort);
|
|
108
|
-
this._isRunning = true;
|
|
109
|
-
}
|
|
179
|
+
mediaReady;
|
|
110
180
|
/**
|
|
111
|
-
*
|
|
181
|
+
* The UDP port the demuxer is bound to. For a specific-port construction (`init.inputPort` non-zero), this equals `init.inputPort` from the moment the constructor
|
|
182
|
+
* returns; for an ephemeral construction (`init.inputPort === 0`), the value is `0` until the kernel completes the bind, then the kernel-assigned port. Consumers
|
|
183
|
+
* that need the assigned ephemeral port `await demuxer.ready` before reading this property.
|
|
112
184
|
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* @param port - The RTP port to send the heartbeat to.
|
|
185
|
+
* Reads from the cached projection of `socket.address().port` captured at the `"listening"` event so the value reflects what the kernel actually bound, not just what
|
|
186
|
+
* was requested. The two coincide for specific-port binds; they diverge for ephemeral binds where the requested value is `0` and the bound value is the kernel's pick.
|
|
116
187
|
*/
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
clearTimeout(this.heartbeatTimer);
|
|
120
|
-
// Send a heartbeat to FFmpeg every few seconds to keep things open. FFmpeg has a five-second timeout in reading input, and we want to be comfortably within the
|
|
121
|
-
// margin for error to ensure the process continues to run.
|
|
122
|
-
this.heartbeatTimer = setTimeout(() => {
|
|
123
|
-
this.log?.debug("Sending ffmpeg a heartbeat.");
|
|
124
|
-
this.socket.send(this.heartbeatMsg, port);
|
|
125
|
-
this.heartbeat(port);
|
|
126
|
-
}, TWOWAY_HEARTBEAT_INTERVAL * 1000);
|
|
188
|
+
get inputPort() {
|
|
189
|
+
return this.#assignedInputPort ?? this.#requestedInputPort;
|
|
127
190
|
}
|
|
128
191
|
/**
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* @example
|
|
132
|
-
*
|
|
133
|
-
* ```ts
|
|
134
|
-
* demuxer.close();
|
|
135
|
-
* ```
|
|
192
|
+
* `true` once `this.signal` has aborted. Derived from the signal; no independent state.
|
|
136
193
|
*/
|
|
137
|
-
|
|
138
|
-
this.
|
|
139
|
-
clearTimeout(this.heartbeatTimer);
|
|
140
|
-
this.socket.close();
|
|
141
|
-
this._isRunning = false;
|
|
142
|
-
this.emit("rtp");
|
|
194
|
+
get aborted() {
|
|
195
|
+
return this.signal.aborted;
|
|
143
196
|
}
|
|
144
197
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* @param message - The UDP packet buffer.
|
|
150
|
-
*
|
|
151
|
-
* @returns The RTP payload type as a number.
|
|
198
|
+
* `true` when the abort reason indicates a timeout. Matches both the canonical `HbpuAbortError("timeout")` emitted by the inactivity watchdog and the platform
|
|
199
|
+
* `TimeoutError` emitted by `AbortSignal.timeout()` - consumers discriminate on a single getter regardless of which code path produced the timeout. The discrimination
|
|
200
|
+
* logic lives in {@link isTimeoutReason} so this getter stays a one-line delegation and every resource class in the library shares one definition of "timeout."
|
|
152
201
|
*/
|
|
153
|
-
|
|
154
|
-
return
|
|
202
|
+
get isTimedOut() {
|
|
203
|
+
return isTimeoutReason(this.signal.reason);
|
|
155
204
|
}
|
|
205
|
+
// The port requested at construction (verbatim from `init.inputPort`). Equals the bound port for specific-port constructions; `0` for ephemeral constructions until
|
|
206
|
+
// the kernel hands one out. Read through the {@link RtpDemuxer.inputPort} getter, which prefers {@link #assignedInputPort} once the bind settles.
|
|
207
|
+
#requestedInputPort;
|
|
208
|
+
// The port the kernel actually bound to, captured from `socket.address().port` once the `"listening"` event fires. Undefined until then. For specific-port binds
|
|
209
|
+
// this duplicates {@link #requestedInputPort} but the assignment keeps the post-bind read path uniform across both construction modes.
|
|
210
|
+
#assignedInputPort;
|
|
211
|
+
// The RTP destination port, supplied at construction. The destination of all RTP-classified forwards AND of the heartbeat replay (FFmpeg ignores the RTCP shape on
|
|
212
|
+
// its RTP input but the arriving traffic keeps that input fed during silence, which is the point of the keepalive).
|
|
213
|
+
#rtpPort;
|
|
214
|
+
// The RTCP destination port, supplied at construction. The destination of all RTCP-classified forwards.
|
|
215
|
+
#rtcpPort;
|
|
216
|
+
// The loopback address for the configured IP family, resolved once at construction. Reused on every outbound send so we never re-translate the family during the hot
|
|
217
|
+
// forwarding path.
|
|
218
|
+
#destAddress;
|
|
219
|
+
// The private AbortController whose signal is composed into `this.signal`. Owning the controller internally keeps teardown reachable from every handler - the socket
|
|
220
|
+
// error listener, the watchdog onFire callbacks, the message handler - without giving callers a handle to the raw controller.
|
|
221
|
+
#controller;
|
|
222
|
+
// The bound UDP socket. Receives inbound datagrams from HomeKit and sends forwarded datagrams (and heartbeats) to the loopback destination ports. Held privately so
|
|
223
|
+
// the only interaction points are the demuxer's public surface and the internal listeners.
|
|
224
|
+
#socket;
|
|
225
|
+
// The byte-to-record parser driving each datagram through classification. Stateless across datagrams (RTP wire format is datagram-framed) but instantiated once so
|
|
226
|
+
// the class shape mirrors the MP4 pipeline.
|
|
227
|
+
#parser;
|
|
228
|
+
// Self-rearming heartbeat watchdog: when the inbound-RTCP gap exceeds RTCP_HEARTBEAT_INTERVAL, the watchdog's onFire replays the last observed RTCP datagram to
|
|
229
|
+
// {@link #rtpPort} (keeping FFmpeg's RTP input fed during silence) and immediately re-arms itself so the cadence continues in the absence of fresh RTCP. Inbound
|
|
230
|
+
// RTCP arrivals also call `arm()` to push the next fire forward - busy RTCP traffic suppresses the heartbeat naturally, quiet periods synthesize one.
|
|
231
|
+
#heartbeat;
|
|
232
|
+
// Optional inactivity watchdog composed over this.signal, or `undefined` when no inactivityTimeout was configured. Re-armed on every inbound datagram; fires the
|
|
233
|
+
// composed controller's abort on lapse. Self-cleans when the signal aborts for any reason, so no teardown wiring is needed.
|
|
234
|
+
#inactivityWatchdog;
|
|
235
|
+
// Optional logger. Debug traces are emitted when present; absent logger is silent.
|
|
236
|
+
#log;
|
|
237
|
+
// Promise that resolves when the socket emits `"close"` - i.e., the kernel has released the bound port. {@link RtpDemuxer.[Symbol.asyncDispose]} awaits this so the
|
|
238
|
+
// `await using` contract truly means "the port is releasable again" by the time the surrounding scope exits, not merely "teardown has been scheduled."
|
|
239
|
+
#closed;
|
|
240
|
+
// The last RTCP datagram received, copied into our own buffer so the heartbeat replay sends stable bytes even after the underlying receive buffer is recycled by
|
|
241
|
+
// Node's dgram subsystem. Undefined until the first RTCP arrives; the heartbeat onFire treats undefined as a no-op for the duration of the dormant period.
|
|
242
|
+
#lastRtcp;
|
|
156
243
|
/**
|
|
157
|
-
*
|
|
244
|
+
* Construct and bind a new RTP demuxer.
|
|
158
245
|
*
|
|
159
|
-
*
|
|
246
|
+
* Socket binding happens synchronously as part of construction: by the time the constructor returns, the socket has been asked to bind on the configured port and
|
|
247
|
+
* the inactivity watchdog (if configured) is already armed. There is no separate `start()` step. The bind itself completes on a later turn; consumers that need to
|
|
248
|
+
* sequence work against bind completion `await demuxer.ready`.
|
|
160
249
|
*
|
|
161
|
-
* @
|
|
250
|
+
* @param init - Required init options. See {@link RtpDemuxerInit}.
|
|
162
251
|
*/
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
252
|
+
constructor(init) {
|
|
253
|
+
const { inactivityTimeout, inputPort, ipFamily = "ipv4", log, rtcpPort, rtpPort, signal: parentSignal } = init;
|
|
254
|
+
this.#controller = new AbortController();
|
|
255
|
+
this.signal = composeSignals(parentSignal, this.#controller.signal);
|
|
256
|
+
this.#requestedInputPort = inputPort;
|
|
257
|
+
this.#rtpPort = rtpPort;
|
|
258
|
+
this.#rtcpPort = rtcpPort;
|
|
259
|
+
this.#destAddress = loopbackAddress(ipFamily);
|
|
260
|
+
this.#log = log;
|
|
261
|
+
this.#parser = new RtpPacketParser();
|
|
262
|
+
this.#socket = createDgramSocket(ipFamily);
|
|
263
|
+
// Promise-based readiness milestones. `Promise.withResolvers` (ES2024) gives us the resolver handles directly, replacing the IIFE-with-try/catch pattern an
|
|
264
|
+
// older `events.once`-based wiring would require. Resolvers settle once - subsequent resolve/reject calls are silent no-ops - so each milestone has exactly one
|
|
265
|
+
// settling code path and rejection is centralized in the `onAbort` teardown handler below.
|
|
266
|
+
const readyResolvers = Promise.withResolvers();
|
|
267
|
+
const mediaReadyResolvers = Promise.withResolvers();
|
|
268
|
+
const closedResolvers = Promise.withResolvers();
|
|
269
|
+
this.ready = markHandled(readyResolvers.promise);
|
|
270
|
+
this.mediaReady = markHandled(mediaReadyResolvers.promise);
|
|
271
|
+
this.#closed = closedResolvers.promise;
|
|
272
|
+
// Socket `"listening"` handler. Bind succeeded. Cache the kernel-assigned port (which differs from `inputPort` only on ephemeral binds), then signal readiness.
|
|
273
|
+
this.#socket.once("listening", () => {
|
|
274
|
+
this.#assignedInputPort = this.#socket.address().port;
|
|
275
|
+
readyResolvers.resolve();
|
|
276
|
+
});
|
|
277
|
+
// Socket `"close"` handler. The kernel has released the bound port. Resolve the `#closed` promise so {@link [Symbol.asyncDispose]} can deterministically await
|
|
278
|
+
// the port release, eliminating the rebind race a fire-and-forget close would carry.
|
|
279
|
+
this.#socket.once("close", () => closedResolvers.resolve());
|
|
280
|
+
// Socket `"error"` handler. Bind failures (typically EADDRINUSE) and runtime socket errors both flow through here. Gating on `!this.aborted` keeps the abort
|
|
281
|
+
// idempotent when the socket error fires during a concurrent teardown (e.g., an ECONNRESET during an in-flight close). The error is preserved on `.cause` so
|
|
282
|
+
// downstream diagnostics see the original kernel error.
|
|
283
|
+
this.#socket.on("error", (error) => {
|
|
284
|
+
if (this.aborted) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
this.#log?.error("RtpDemuxer socket error on port %d: %s.", this.inputPort, error.message);
|
|
288
|
+
this.#controller.abort(new HbpuAbortError("failed", { cause: error }));
|
|
289
|
+
});
|
|
290
|
+
// Heartbeat watchdog. The onFire replays the last observed RTCP datagram to `rtpPort` and immediately re-arms the watchdog so the cadence continues. Inbound
|
|
291
|
+
// RTCP messages call `arm()` in the message handler, pushing the next fire forward - busy RTCP suppresses the heartbeat naturally, quiet periods synthesize one.
|
|
292
|
+
// The pre-fire `!this.aborted` guard is belt-and-suspenders: the Watchdog primitive itself already skips onFire after the observed signal aborts, but a defensive
|
|
293
|
+
// check here keeps the forwarding-path semantics legible at a glance. The watchdog stays dormant until the first inbound RTCP arrives - we deliberately do NOT
|
|
294
|
+
// arm it from the constructor because heartbeats are RTCP-replays and there is nothing meaningful to replay before the first RTCP.
|
|
295
|
+
this.#heartbeat = new Watchdog({
|
|
296
|
+
onFire: () => {
|
|
297
|
+
if ((this.#lastRtcp !== undefined) && !this.aborted) {
|
|
298
|
+
this.#log?.debug("RtpDemuxer sending FFmpeg a keepalive heartbeat.");
|
|
299
|
+
this.#socket.send(this.#lastRtcp, this.#rtpPort, this.#destAddress);
|
|
300
|
+
}
|
|
301
|
+
this.#heartbeat.arm();
|
|
302
|
+
},
|
|
303
|
+
signal: this.signal,
|
|
304
|
+
timeoutMs: RTCP_HEARTBEAT_INTERVAL
|
|
305
|
+
});
|
|
306
|
+
// Optional inactivity watchdog. Constructed only when the caller opted into liveness enforcement so absence carries no scheduled timer and no per-packet overhead.
|
|
307
|
+
// The watchdog composes against `this.signal` so a parent abort (or any other internal abort) self-cleans the pending timer without explicit teardown.
|
|
308
|
+
this.#inactivityWatchdog = (inactivityTimeout !== undefined) ? new Watchdog({
|
|
309
|
+
onFire: () => {
|
|
310
|
+
this.#log?.debug("RtpDemuxer inactivity watchdog fired after %d ms with no inbound packets on port %d.", inactivityTimeout, this.inputPort);
|
|
311
|
+
this.#controller.abort(new HbpuAbortError("timeout"));
|
|
312
|
+
},
|
|
313
|
+
signal: this.signal,
|
|
314
|
+
timeoutMs: inactivityTimeout
|
|
315
|
+
}) : undefined;
|
|
316
|
+
// Inbound message handler. Every datagram re-arms the inactivity watchdog. Each classified packet forwards out the same socket - source endpoint of the forward
|
|
317
|
+
// equals `loopback:inputPort`, preserving the source-port symmetry property documented at the class level. RTP-classified packets resolve `mediaReady` on the
|
|
318
|
+
// first arrival; RTCP-classified packets are captured for heartbeat replay and arm the heartbeat watchdog.
|
|
319
|
+
this.#socket.on("message", (datagram) => {
|
|
320
|
+
this.#inactivityWatchdog?.arm();
|
|
321
|
+
for (const packet of this.#parser.consume(datagram)) {
|
|
322
|
+
if (packet.kind === "rtp") {
|
|
323
|
+
this.#socket.send(packet.bytes, this.#rtpPort, this.#destAddress);
|
|
324
|
+
// Resolve the first-RTP milestone. `Promise.withResolvers` settles once, so subsequent calls on every later RTP packet are silent no-ops - no flag required.
|
|
325
|
+
mediaReadyResolvers.resolve();
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
// Copy the RTCP datagram into our own buffer because the heartbeat replays it later, well after Node's dgram subsystem may have recycled the receive buffer
|
|
329
|
+
// backing `datagram`. A reference assignment would leak corrupted bytes to the heartbeat replay; `Buffer.from(...)` is the canonical safe copy.
|
|
330
|
+
this.#lastRtcp = Buffer.from(packet.bytes);
|
|
331
|
+
this.#socket.send(packet.bytes, this.#rtcpPort, this.#destAddress);
|
|
332
|
+
this.#heartbeat.arm();
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
// Single teardown convergence point. `onAbort` registers the one-shot close listener for the normal abort path AND handles the "pre-aborted signal" edge case
|
|
336
|
+
// where `addEventListener("abort", ...)` would otherwise silently skip the handler (the AbortSignal spec does not re-dispatch historical events). Rejecting the
|
|
337
|
+
// milestone promises is idempotent - a milestone that already resolved is unaffected by the subsequent reject; a milestone still pending receives the signal's
|
|
338
|
+
// reason as its rejection. The socket close fires the `"close"` event which resolves `#closed`, ungating `[Symbol.asyncDispose]`.
|
|
339
|
+
onAbort(this.signal, () => {
|
|
340
|
+
readyResolvers.reject(this.signal.reason);
|
|
341
|
+
mediaReadyResolvers.reject(this.signal.reason);
|
|
342
|
+
this.#socket.close();
|
|
343
|
+
});
|
|
344
|
+
if (this.signal.aborted) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
// The pre-bind debug log differentiates specific-port and ephemeral construction. For `inputPort === 0` the kernel has not picked yet, so logging "port 0" would
|
|
348
|
+
// mislead an operator reading the trace; we name the intent ("kernel-assigned ephemeral port") and rely on subsequent error / timeout logs (which read
|
|
349
|
+
// `this.inputPort` after the listening event captures the assigned value) to show the actual bound port.
|
|
350
|
+
if (inputPort === 0) {
|
|
351
|
+
this.#log?.debug("Binding RtpDemuxer on %s with a kernel-assigned ephemeral port forwarding to RTP %d / RTCP %d.", ipFamily, rtpPort, rtcpPort);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
this.#log?.debug("Binding RtpDemuxer on %s port %d forwarding to RTP %d / RTCP %d.", ipFamily, inputPort, rtpPort, rtcpPort);
|
|
355
|
+
}
|
|
356
|
+
// Bind without an explicit address so the socket accepts inbound traffic from any interface (HomeKit's RTP traffic arrives via the LAN, not loopback). Outbound
|
|
357
|
+
// forwards use the explicit loopback address so the kernel routes those datagrams locally - the source IP is then the loopback address and the source port is
|
|
358
|
+
// the bound `inputPort`, completing the source-endpoint-symmetry property. A bind failure surfaces through the `"error"` listener above.
|
|
359
|
+
this.#socket.bind(inputPort);
|
|
360
|
+
// Arm the inactivity watchdog. The first packet's arrival re-arms it; if no packet ever arrives, the watchdog fires after `inactivityTimeout` ms from this point.
|
|
361
|
+
// The heartbeat watchdog deliberately stays dormant - no inbound RTCP has been seen yet, so there is nothing to replay.
|
|
362
|
+
this.#inactivityWatchdog?.arm();
|
|
166
363
|
}
|
|
167
364
|
/**
|
|
168
|
-
*
|
|
365
|
+
* Abort the demuxer and tear it down. Defaults to `HbpuAbortError("shutdown")` when no reason is supplied; explicit reasons pass through unchanged.
|
|
169
366
|
*
|
|
170
|
-
*
|
|
367
|
+
* Safe to call more than once: subsequent calls are no-ops because the underlying signal only aborts once. Calling `abort()` after a natural error or watchdog
|
|
368
|
+
* timeout is also safe for the same reason.
|
|
171
369
|
*
|
|
172
|
-
* @
|
|
370
|
+
* @param reason - Optional abort reason. Typically an {@link HbpuAbortError}; platform errors (`TimeoutError`, `AbortError`) also interoperate by convention.
|
|
371
|
+
*/
|
|
372
|
+
abort(reason) {
|
|
373
|
+
if (this.aborted) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.#controller.abort(reason ?? new HbpuAbortError("shutdown"));
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* `AsyncDisposable` implementation. Aborts the demuxer (defaulting to `"shutdown"`) and awaits the socket's `"close"` event so the bound port is releasable by the
|
|
380
|
+
* time the surrounding `await using` scope's next statement runs. Safe to invoke repeatedly: subsequent disposals collapse onto the same `#closed` promise.
|
|
173
381
|
*
|
|
174
|
-
*
|
|
175
|
-
* if(demuxer.isRunning) {
|
|
176
|
-
* // Demuxer is active.
|
|
177
|
-
* }
|
|
178
|
-
* ```
|
|
382
|
+
* @returns A promise that resolves once the kernel has released the bound port.
|
|
179
383
|
*/
|
|
180
|
-
|
|
181
|
-
|
|
384
|
+
async [Symbol.asyncDispose]() {
|
|
385
|
+
this.abort();
|
|
386
|
+
await this.#closed;
|
|
182
387
|
}
|
|
183
388
|
}
|
|
184
389
|
/**
|
|
185
|
-
*
|
|
390
|
+
* Registry that reserves consecutive UDP ports for FFmpeg-based RTP/RTCP sessions.
|
|
186
391
|
*
|
|
187
|
-
*
|
|
392
|
+
* The allocator itself is a plugin-singleton registry - it holds no OS resources of its own, only a `Set` of ports marked as in-use. Reservations are the scoped
|
|
393
|
+
* resource: each successful {@link RtpPortAllocator.reserve} call returns a {@link PortReservation} handle whose disposal releases the held ports back to the pool.
|
|
394
|
+
*
|
|
395
|
+
* The reservation bind-retry loop is signal-aware: passing `init.signal` to `reserve()` means an aborting parent signal cancels the pending reservation cleanly, even
|
|
396
|
+
* when the loop is in the middle of probing candidate ports.
|
|
188
397
|
*
|
|
189
398
|
* @example
|
|
190
399
|
*
|
|
191
400
|
* ```ts
|
|
192
|
-
*
|
|
401
|
+
* // Scope-bound: the reservation releases automatically when the block exits.
|
|
402
|
+
* await using reservation = await allocator.reserve({ count: 2, signal: session.controller.signal });
|
|
403
|
+
* const rtpPort = reservation.port;
|
|
404
|
+
* const rtcpPort = reservation.port + 1;
|
|
405
|
+
* // ...use rtpPort and rtcpPort for the duration of the session.
|
|
406
|
+
* ```
|
|
193
407
|
*
|
|
194
|
-
*
|
|
195
|
-
* const rtpPort = await allocator.reserve("ipv4", 2);
|
|
408
|
+
* @example
|
|
196
409
|
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
410
|
+
* ```ts
|
|
411
|
+
* // Non-scoped: store the handle on a session entry and dispose explicitly later.
|
|
412
|
+
* const reservation = await allocator.reserve({ count: 2, signal: session.controller.signal });
|
|
413
|
+
* session.ports = reservation;
|
|
414
|
+
* // ...later:
|
|
415
|
+
* await session.ports[Symbol.asyncDispose]();
|
|
199
416
|
* ```
|
|
200
417
|
*
|
|
201
418
|
* @category FFmpeg
|
|
202
419
|
*/
|
|
203
420
|
export class RtpPortAllocator {
|
|
204
|
-
|
|
421
|
+
// The pool of ports currently marked as in use. A `Set` because membership checks in the bind-retry loop need O(1), and the allocator never needs ordering.
|
|
422
|
+
#inUse = new Set();
|
|
205
423
|
/**
|
|
206
|
-
*
|
|
424
|
+
* The number of UDP ports currently held by live {@link PortReservation} handles. Exposed for operational diagnostics - a plugin that surfaces "how many
|
|
425
|
+
* reservations are in flight right now" through a status endpoint can read this directly, and it is a useful signal for detecting leaks (a monotonically growing
|
|
426
|
+
* count across normal session cycles indicates a reservation is not being disposed).
|
|
427
|
+
*
|
|
428
|
+
* @returns The number of reserved ports currently tracked by the allocator.
|
|
207
429
|
*/
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
this.portsInUse = new Set();
|
|
430
|
+
get reservedCount() {
|
|
431
|
+
return this.#inUse.size;
|
|
211
432
|
}
|
|
212
433
|
/**
|
|
213
|
-
*
|
|
434
|
+
* Reserve one or two consecutive UDP ports.
|
|
435
|
+
*
|
|
436
|
+
* For a two-port reservation, the allocator first picks a random free port, then probes the next sequential port. If the sequential port is unavailable (already in
|
|
437
|
+
* the in-use set or rejected by the OS), the first port is released and the allocator retries from scratch, up to `RESERVE_MAX_ATTEMPTS` attempts.
|
|
214
438
|
*
|
|
215
|
-
*
|
|
439
|
+
* @param init - Optional init options. See {@link PortReservationInit}.
|
|
216
440
|
*
|
|
217
|
-
* @
|
|
218
|
-
* @param port - Optional. The port to try to bind to. If 0, selects a random port.
|
|
441
|
+
* @returns A {@link PortReservation} handle whose disposal releases the reserved ports.
|
|
219
442
|
*
|
|
220
|
-
* @
|
|
443
|
+
* @throws The caller signal's abort reason, if the signal aborts before a reservation is obtained.
|
|
444
|
+
* @throws `RangeError` when `count` is anything other than `1` or `2`.
|
|
445
|
+
* @throws `Error` when no reservation could be obtained within the attempt budget.
|
|
221
446
|
*/
|
|
222
|
-
async
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
447
|
+
async reserve(init = {}) {
|
|
448
|
+
const { count = 1, ipFamily = "ipv4", signal } = init;
|
|
449
|
+
// Sanity-check the count. The type system narrows `count` to `1 | 2` at the call site, but a JS consumer can bypass the types and pass anything; widening to
|
|
450
|
+
// `number` here lets the runtime guard catch the out-of-range case with a clear message rather than an infinite reservation loop.
|
|
451
|
+
const requestedCount = count;
|
|
452
|
+
if ((requestedCount !== 1) && (requestedCount !== 2)) {
|
|
453
|
+
throw new RangeError("RtpPortAllocator.reserve: count must be 1 or 2.");
|
|
454
|
+
}
|
|
455
|
+
signal?.throwIfAborted();
|
|
456
|
+
for (let attempt = 0; attempt < RESERVE_MAX_ATTEMPTS; attempt++) {
|
|
457
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
458
|
+
try {
|
|
459
|
+
signal?.throwIfAborted();
|
|
460
|
+
// Two-phase commit via ES 2023 Explicit Resource Management. `#acquirePort` atomically adds to `#inUse` and registers the matching release on the stack, so a
|
|
461
|
+
// successful acquire always leaves the port paired with its cleanup. If we return a reservation, `stack.move()` transfers ownership away from scope-bound
|
|
462
|
+
// disposal and the ports stay in `#inUse`. Every other exit path - null-retry `continue`, thrown caller abort, any future exception - hits `using`'s automatic
|
|
463
|
+
// disposal and releases the tentative ports. This is the platform-standard pattern for "acquire, maybe commit, release on failure."
|
|
464
|
+
const stack = __addDisposableResource(env_1, new DisposableStack(), false);
|
|
235
465
|
// eslint-disable-next-line no-await-in-loop
|
|
236
|
-
await
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
466
|
+
const firstPort = await this.#acquirePort(stack, ipFamily, 0, signal);
|
|
467
|
+
if (firstPort === null) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
if (count === 1) {
|
|
471
|
+
const reservation = this.#makeReservation(firstPort, 1, ipFamily);
|
|
472
|
+
stack.move();
|
|
473
|
+
return reservation;
|
|
474
|
+
}
|
|
475
|
+
// eslint-disable-next-line no-await-in-loop
|
|
476
|
+
const secondPort = await this.#acquirePort(stack, ipFamily, firstPort + 1, signal);
|
|
477
|
+
if (secondPort === null) {
|
|
243
478
|
continue;
|
|
244
479
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
480
|
+
const reservation = this.#makeReservation(firstPort, 2, ipFamily);
|
|
481
|
+
stack.move();
|
|
482
|
+
return reservation;
|
|
483
|
+
}
|
|
484
|
+
catch (e_1) {
|
|
485
|
+
env_1.error = e_1;
|
|
486
|
+
env_1.hasError = true;
|
|
487
|
+
}
|
|
488
|
+
finally {
|
|
489
|
+
__disposeResources(env_1);
|
|
249
490
|
}
|
|
250
491
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
}
|
|
492
|
+
const portDescription = (count === 1) ? "a port" : count.toString() + " consecutive ports";
|
|
493
|
+
throw new Error("RtpPortAllocator: unable to reserve " + portDescription + " after " + RESERVE_MAX_ATTEMPTS.toString() + " attempts.");
|
|
254
494
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
495
|
+
// Acquire a single UDP port by binding a throwaway socket, reading back the assigned port, marking it in-use, and registering the matching release on the caller's
|
|
496
|
+
// `DisposableStack`. `requestedPort === 0` asks the OS for a random ephemeral port; non-zero asks for that specific port and expects it to be available. Returns the
|
|
497
|
+
// assigned port on success, or `null` on any non-abort failure so the caller can retry. Aborts propagate as thrown rejections (the caller's signal is honored by
|
|
498
|
+
// `events.once`). Registering the release on the caller-supplied stack keeps the add-with-cleanup contract atomic - no call site can acquire a port without also
|
|
499
|
+
// pairing it with a disposer, so the "forgot to clean up on failure" bug class is impossible by construction.
|
|
500
|
+
async #acquirePort(stack, ipFamily, requestedPort, signal) {
|
|
501
|
+
for (;;) {
|
|
502
|
+
signal?.throwIfAborted();
|
|
503
|
+
const socket = createDgramSocket(ipFamily);
|
|
504
|
+
// Exclude this probe socket from Node's reference counting so it never prevents the process from exiting while a reservation is pending.
|
|
505
|
+
socket.unref();
|
|
506
|
+
try {
|
|
507
|
+
// `events.once` listens for the `"listening"` event, rejecting on both abort and the socket's own `"error"` event. Wrapping the call once lets us treat bind
|
|
508
|
+
// failures (EADDRINUSE on a specific port) and abort propagation through a single catch. `{ signal }` accepts `undefined` verbatim - events.once's internal
|
|
509
|
+
// `if (options?.signal)` gate skips the abort wiring when signal is absent - so one unconditional call shape covers both with-signal and without-signal cases.
|
|
510
|
+
const listening = once(socket, "listening", { signal });
|
|
511
|
+
socket.bind(requestedPort);
|
|
512
|
+
// eslint-disable-next-line no-await-in-loop
|
|
513
|
+
await listening;
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
socket.close();
|
|
517
|
+
// Abort takes precedence: rethrow so the caller's `throwIfAborted` observes the same reason at the outer layer. Any other error is a bind failure; we surface
|
|
518
|
+
// `null` so the outer retry loop can fall through to the next attempt. The raw rejection from `events.once` is intentionally not propagated - bind failures
|
|
519
|
+
// converge on the `null` return so the outer retry loop can fall through to the next port without coupling to kernel-error specifics.
|
|
520
|
+
if (signal?.aborted) {
|
|
521
|
+
throw signal.reason;
|
|
522
|
+
}
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
const assignedPort = socket.address().port;
|
|
526
|
+
socket.close();
|
|
527
|
+
if (this.#inUse.has(assignedPort)) {
|
|
528
|
+
// Specific-port requests either land or fail; they must not retry internally because the caller is looking for that exact port. Random-port requests loop
|
|
529
|
+
// inside this function until a fresh, un-reserved port is found.
|
|
530
|
+
if (requestedPort !== 0) {
|
|
531
|
+
return null;
|
|
281
532
|
}
|
|
282
|
-
|
|
283
|
-
return this._reserve(ipFamily, portCount, ++attempts);
|
|
533
|
+
continue;
|
|
284
534
|
}
|
|
285
|
-
|
|
286
|
-
|
|
535
|
+
this.#inUse.add(assignedPort);
|
|
536
|
+
stack.defer(() => this.#inUse.delete(assignedPort));
|
|
537
|
+
return assignedPort;
|
|
287
538
|
}
|
|
288
|
-
// Return the first port we've found.
|
|
289
|
-
return firstPort;
|
|
290
539
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
* // Reserve two consecutive ports for RTP/RTCP.
|
|
312
|
-
* const rtpPort = await allocator.reserve("ipv4", 2);
|
|
313
|
-
* ```
|
|
314
|
-
*/
|
|
315
|
-
async reserve(ipFamily = "ipv4", portCount = 1) {
|
|
316
|
-
return this._reserve(ipFamily, portCount);
|
|
317
|
-
}
|
|
318
|
-
/**
|
|
319
|
-
* Cancels and releases a previously reserved port, making it available for future use.
|
|
320
|
-
*
|
|
321
|
-
* @param port - The port number to release.
|
|
322
|
-
*
|
|
323
|
-
* @example
|
|
324
|
-
*
|
|
325
|
-
* ```ts
|
|
326
|
-
* allocator.cancel(50000);
|
|
327
|
-
* ```
|
|
328
|
-
*/
|
|
329
|
-
cancel(port) {
|
|
330
|
-
this.portsInUse.delete(port);
|
|
540
|
+
// Construct the PortReservation handle. The returned object captures a closure over the ports to release and a local `disposed` flag so double-dispose is a no-op.
|
|
541
|
+
// We use an arrow function for the disposer so `this` resolves to the allocator, giving the closure lexical access to `this.#inUse` without threading a reference.
|
|
542
|
+
#makeReservation(firstPort, count, ipFamily) {
|
|
543
|
+
const portsHeld = (count === 2) ? [firstPort, firstPort + 1] : [firstPort];
|
|
544
|
+
let disposed = false;
|
|
545
|
+
const dispose = async () => {
|
|
546
|
+
if (disposed) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
disposed = true;
|
|
550
|
+
for (const port of portsHeld) {
|
|
551
|
+
this.#inUse.delete(port);
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
return {
|
|
555
|
+
count,
|
|
556
|
+
ipFamily,
|
|
557
|
+
port: firstPort,
|
|
558
|
+
[Symbol.asyncDispose]: dispose
|
|
559
|
+
};
|
|
331
560
|
}
|
|
332
561
|
}
|
|
333
562
|
//# sourceMappingURL=rtp.js.map
|