homebridge-plugin-utils 1.35.0 → 2.1.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.
Files changed (204) hide show
  1. package/README.md +130 -2
  2. package/build/eslint-plugin/README.md +164 -0
  3. package/build/eslint-plugin/config.mjs +308 -0
  4. package/build/eslint-plugin/index.mjs +7 -0
  5. package/build/eslint-plugin/plugin.mjs +46 -0
  6. package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
  7. package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
  8. package/build/eslint-plugin/rules/comment-style.mjs +190 -0
  9. package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
  10. package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
  11. package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
  12. package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
  13. package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
  14. package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
  15. package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
  16. package/build/eslint-plugin/test-setup.mjs +21 -0
  17. package/build/fs-ops.mjs +184 -0
  18. package/build/tsconfig.json +18 -3
  19. package/dist/backpressure.d.ts +94 -45
  20. package/dist/backpressure.js +229 -93
  21. package/dist/backpressure.js.map +1 -1
  22. package/dist/cli/index.d.ts +166 -0
  23. package/dist/cli/index.js +551 -0
  24. package/dist/cli/index.js.map +1 -0
  25. package/dist/clock-double.d.ts +93 -0
  26. package/dist/clock-double.js +141 -0
  27. package/dist/clock-double.js.map +1 -0
  28. package/dist/clock.d.ts +39 -0
  29. package/dist/clock.js +34 -0
  30. package/dist/clock.js.map +1 -0
  31. package/dist/disposable-stack.d.ts +59 -0
  32. package/dist/disposable-stack.js +155 -0
  33. package/dist/disposable-stack.js.map +1 -0
  34. package/dist/docChrome.d.ts +260 -0
  35. package/dist/docChrome.js +361 -0
  36. package/dist/docChrome.js.map +1 -0
  37. package/dist/eslint-plugin/config.d.mts +193 -0
  38. package/dist/eslint-plugin/index.d.mts +2 -0
  39. package/dist/eslint-plugin/plugin.d.mts +87 -0
  40. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  41. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  42. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  43. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  44. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  45. package/dist/featureOptions-docs.d.ts +96 -0
  46. package/dist/featureOptions-docs.js +193 -0
  47. package/dist/featureOptions-docs.js.map +1 -0
  48. package/dist/featureOptions.d.ts +674 -0
  49. package/dist/featureOptions.js +870 -0
  50. package/dist/featureOptions.js.map +1 -0
  51. package/dist/ffmpeg/codecs.d.ts +256 -72
  52. package/dist/ffmpeg/codecs.js +477 -262
  53. package/dist/ffmpeg/codecs.js.map +1 -1
  54. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  55. package/dist/ffmpeg/dgram-util.js +38 -0
  56. package/dist/ffmpeg/dgram-util.js.map +1 -0
  57. package/dist/ffmpeg/exec.d.ts +83 -64
  58. package/dist/ffmpeg/exec.js +77 -86
  59. package/dist/ffmpeg/exec.js.map +1 -1
  60. package/dist/ffmpeg/fmp4.d.ts +49 -2
  61. package/dist/ffmpeg/fmp4.js +47 -11
  62. package/dist/ffmpeg/fmp4.js.map +1 -1
  63. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  64. package/dist/ffmpeg/hap-enums.js +92 -0
  65. package/dist/ffmpeg/hap-enums.js.map +1 -0
  66. package/dist/ffmpeg/index.d.ts +16 -9
  67. package/dist/ffmpeg/index.js +6 -0
  68. package/dist/ffmpeg/index.js.map +1 -1
  69. package/dist/ffmpeg/mp4-assembler.d.ts +161 -0
  70. package/dist/ffmpeg/mp4-assembler.js +424 -0
  71. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  72. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  73. package/dist/ffmpeg/mp4-parser.js +130 -0
  74. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  75. package/dist/ffmpeg/options.d.ts +62 -149
  76. package/dist/ffmpeg/options.js +608 -499
  77. package/dist/ffmpeg/options.js.map +1 -1
  78. package/dist/ffmpeg/process.d.ts +142 -96
  79. package/dist/ffmpeg/process.js +406 -278
  80. package/dist/ffmpeg/process.js.map +1 -1
  81. package/dist/ffmpeg/record.d.ts +325 -186
  82. package/dist/ffmpeg/record.js +418 -565
  83. package/dist/ffmpeg/record.js.map +1 -1
  84. package/dist/ffmpeg/recording-process-double.d.ts +157 -0
  85. package/dist/ffmpeg/recording-process-double.js +190 -0
  86. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  87. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  88. package/dist/ffmpeg/rtp-parser.js +77 -0
  89. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  90. package/dist/ffmpeg/rtp.d.ts +198 -141
  91. package/dist/ffmpeg/rtp.js +474 -251
  92. package/dist/ffmpeg/rtp.js.map +1 -1
  93. package/dist/ffmpeg/settings.d.ts +5 -2
  94. package/dist/ffmpeg/settings.js +20 -5
  95. package/dist/ffmpeg/settings.js.map +1 -1
  96. package/dist/ffmpeg/stream.d.ts +57 -107
  97. package/dist/ffmpeg/stream.js +121 -150
  98. package/dist/ffmpeg/stream.js.map +1 -1
  99. package/dist/formatters.d.ts +106 -0
  100. package/dist/formatters.js +174 -0
  101. package/dist/formatters.js.map +1 -0
  102. package/dist/homebridge-enums.d.ts +30 -0
  103. package/dist/homebridge-enums.js +17 -0
  104. package/dist/homebridge-enums.js.map +1 -0
  105. package/dist/index.d.ts +13 -6
  106. package/dist/index.js +8 -2
  107. package/dist/index.js.map +1 -1
  108. package/dist/logclient/auth.d.ts +114 -0
  109. package/dist/logclient/auth.js +199 -0
  110. package/dist/logclient/auth.js.map +1 -0
  111. package/dist/logclient/cli-run.d.ts +76 -0
  112. package/dist/logclient/cli-run.js +639 -0
  113. package/dist/logclient/cli-run.js.map +1 -0
  114. package/dist/logclient/cli.d.ts +3 -0
  115. package/dist/logclient/cli.js +97 -0
  116. package/dist/logclient/cli.js.map +1 -0
  117. package/dist/logclient/client.d.ts +145 -0
  118. package/dist/logclient/client.js +600 -0
  119. package/dist/logclient/client.js.map +1 -0
  120. package/dist/logclient/config.d.ts +173 -0
  121. package/dist/logclient/config.js +199 -0
  122. package/dist/logclient/config.js.map +1 -0
  123. package/dist/logclient/endpoints.d.ts +54 -0
  124. package/dist/logclient/endpoints.js +73 -0
  125. package/dist/logclient/endpoints.js.map +1 -0
  126. package/dist/logclient/filter.d.ts +45 -0
  127. package/dist/logclient/filter.js +51 -0
  128. package/dist/logclient/filter.js.map +1 -0
  129. package/dist/logclient/frame.d.ts +93 -0
  130. package/dist/logclient/frame.js +203 -0
  131. package/dist/logclient/frame.js.map +1 -0
  132. package/dist/logclient/index.d.ts +31 -0
  133. package/dist/logclient/index.js +12 -0
  134. package/dist/logclient/index.js.map +1 -0
  135. package/dist/logclient/parser.d.ts +211 -0
  136. package/dist/logclient/parser.js +393 -0
  137. package/dist/logclient/parser.js.map +1 -0
  138. package/dist/logclient/rest.d.ts +41 -0
  139. package/dist/logclient/rest.js +111 -0
  140. package/dist/logclient/rest.js.map +1 -0
  141. package/dist/logclient/settings.d.ts +15 -0
  142. package/dist/logclient/settings.js +64 -0
  143. package/dist/logclient/settings.js.map +1 -0
  144. package/dist/logclient/socket-double.d.ts +201 -0
  145. package/dist/logclient/socket-double.js +384 -0
  146. package/dist/logclient/socket-double.js.map +1 -0
  147. package/dist/logclient/socket.d.ts +257 -0
  148. package/dist/logclient/socket.js +620 -0
  149. package/dist/logclient/socket.js.map +1 -0
  150. package/dist/logclient/stitch.d.ts +83 -0
  151. package/dist/logclient/stitch.js +146 -0
  152. package/dist/logclient/stitch.js.map +1 -0
  153. package/dist/logclient/time-expression.d.ts +42 -0
  154. package/dist/logclient/time-expression.js +181 -0
  155. package/dist/logclient/time-expression.js.map +1 -0
  156. package/dist/logclient/time-window.d.ts +38 -0
  157. package/dist/logclient/time-window.js +53 -0
  158. package/dist/logclient/time-window.js.map +1 -0
  159. package/dist/logclient/types.d.ts +107 -0
  160. package/dist/logclient/types.js +6 -0
  161. package/dist/logclient/types.js.map +1 -0
  162. package/dist/mqttClient.d.ts +287 -0
  163. package/dist/mqttClient.js +433 -0
  164. package/dist/mqttClient.js.map +1 -0
  165. package/dist/service.d.ts +64 -15
  166. package/dist/service.js +93 -66
  167. package/dist/service.js.map +1 -1
  168. package/dist/ui/featureOptions.js +870 -0
  169. package/dist/ui/featureOptions.js.map +1 -0
  170. package/dist/ui/formatters.js +174 -0
  171. package/dist/ui/formatters.js.map +1 -0
  172. package/dist/ui/pluginConfigSession.mjs +141 -0
  173. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  174. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  175. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  176. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  177. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  179. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  180. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  181. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  182. package/dist/ui/webUi-featureOptions/utils.mjs +213 -0
  183. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  184. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  185. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  186. package/dist/ui/webUi-featureOptions/views/nav.mjs +341 -0
  187. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  188. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  189. package/dist/ui/webUi-featureOptions.mjs +702 -0
  190. package/dist/ui/webUi.mjs +225 -88
  191. package/dist/util.d.ts +679 -45
  192. package/dist/util.js +830 -77
  193. package/dist/util.js.map +1 -1
  194. package/package.json +33 -15
  195. package/build/eslint-rules.mjs +0 -511
  196. package/dist/featureoptions.d.ts +0 -264
  197. package/dist/featureoptions.js +0 -480
  198. package/dist/featureoptions.js.map +0 -1
  199. package/dist/mqttclient.d.ts +0 -178
  200. package/dist/mqttclient.js +0 -310
  201. package/dist/mqttclient.js.map +0 -1
  202. package/dist/ui/featureoptions.js +0 -480
  203. package/dist/ui/featureoptions.js.map +0 -1
  204. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -1,340 +1,563 @@
1
1
  /* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
2
2
  *
3
- * ffmpeg/rtp.ts: RTP-related utilities to slice and dice RTP streams.
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 and RTCP packet demultiplexer and UDP port management for FFmpeg-based HomeKit livestreaming.
60
+ * Signal-driven RTP/RTCP demultiplexing, FFmpeg keepalive heartbeat, and UDP port reservation for FFmpeg-based HomeKit livestreaming.
9
61
  *
10
- * This module supplies classes and helpers to support realtime streaming via FFmpeg in Homebridge and similar HomeKit environments. It enables the demultiplexing of RTP
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
- * Key features:
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
- * - Demultiplexes RTP and RTCP packets received on a single UDP port, forwarding them to the correct FFmpeg destinations for HomeKit livestream compatibility.
17
- * - Injects periodic heartbeat messages to keep two-way audio streams alive with FFmpeg’s strict timeout requirements.
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
- * Designed for plugin developers and advanced users implementing HomeKit livestreaming, audio/video bridging, or similar applications requiring precise RTP/RTCP handling
22
- * with FFmpeg.
79
+ * @see {@link https://tools.ietf.org/html/rfc5761 | RFC 5761}
23
80
  *
24
81
  * @module
25
82
  */
26
- import { EventEmitter, once } from "node:events";
27
- import { createSocket } from "node:dgram";
28
- // How often, in seconds, should we heartbeat FFmpeg in two-way audio sessions. This should be less than 5 seconds, which is FFmpeg's input timeout interval.
29
- const TWOWAY_HEARTBEAT_INTERVAL = 3;
83
+ import { HbpuAbortError, Watchdog, composeSignals, isTimeoutReason, markHandled, onAbort } from "../util.js";
84
+ import { createDgramSocket, loopbackAddress } from "./dgram-util.js";
85
+ import { DisposableStack } from "../disposable-stack.js";
86
+ import { RTCP_HEARTBEAT_INTERVAL } from "./settings.js";
87
+ import { RtpPacketParser } from "./rtp-parser.js";
88
+ import { once } from "node:events";
89
+ // 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
90
+ // intentionally generous - ten attempts covers realistic host contention without masking true exhaustion, and a hard ceiling keeps the loop from spinning indefinitely
91
+ // when every ephemeral port is somehow in use.
92
+ const RESERVE_MAX_ATTEMPTS = 10;
30
93
  /**
31
- * Utility for demultiplexing RTP and RTCP packets on a single UDP port for HomeKit compatibility.
94
+ * Signal-driven RTP/RTCP demultiplexer with FFmpeg keepalive heartbeat.
32
95
  *
33
- * FFmpeg does not support multiplexing RTP and RTCP data on a single UDP port (RFC 5761) and HomeKit requires this for livestreaming. This class listens on a UDP port
34
- * and demultiplexes RTP and RTCP traffic, forwarding them to separate RTP and RTCP ports as required by FFmpeg.
96
+ * 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
97
+ * `rtpPort` (RTP-classified) or `rtcpPort` (RTCP-classified) on the loopback interface. Sharing the bound socket between receive and send gives the relay two
98
+ * essential properties:
35
99
  *
36
- * Credit to [dgreif](https://github.com/dgreif), [brandawg93](https://github.com/brandawg93), and [Sunoo](https://github.com/Sunoo) for foundational ideas and
37
- * collaboration.
100
+ * 1. **Source-endpoint symmetry.** Every forwarded datagram leaves the bound socket with source = `loopback:inputPort`. Because the demuxer holds an exclusive
101
+ * kernel bind on that port, no other non-root process can spoof that source endpoint - downstream receivers (typically FFmpeg) can therefore enforce source
102
+ * filtering against locally-injected traffic without coordinating ephemeral allocations with the demuxer.
103
+ * 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
104
+ * the duration of the session, expanding the attack surface and the kernel-resource count for no architectural benefit.
105
+ *
106
+ * A self-rearming {@link Watchdog} replays the last observed RTCP packet to `rtpPort` whenever the gap between inbound RTCP arrivals exceeds the configured
107
+ * {@link RTCP_HEARTBEAT_INTERVAL}. The heartbeat is part of the demuxer's contract - FFmpeg-bound two-way audio is the only use case that constructs this
108
+ * 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
109
+ * backchannel. No FFmpeg input-timeout flag (`-timeout` / `rw_timeout`) is configured anywhere - the keepalive is a defensive guard against any FFmpeg-side idle
110
+ * handling of a quiet UDP/RTP input, not a timeout this code sets. The cadence is the single exported constant {@link RTCP_HEARTBEAT_INTERVAL}.
111
+ *
112
+ * An optional second {@link Watchdog} aborts the demuxer with `HbpuAbortError("timeout")` when no inbound traffic arrives within a caller-supplied window. Both
113
+ * watchdogs compose against the same lifetime signal, so disposal cleans them up uniformly.
38
114
  *
39
115
  * @example
40
116
  *
41
117
  * ```ts
42
- * // Create an RtpDemuxer to split packets for FFmpeg compatibility.
43
- * const demuxer = new RtpDemuxer("ipv4", 50000, 50002, 50004, log);
118
+ * await using demuxer = new RtpDemuxer({
119
+ *
120
+ * inactivityTimeout: 5_000,
121
+ * inputPort: audioIncomingPort,
122
+ * ipFamily: "ipv4",
123
+ * log,
124
+ * rtcpPort: audioIncomingRtcpPort,
125
+ * rtpPort: audioIncomingRtpPort,
126
+ * signal: session.signal
127
+ * });
44
128
  *
45
- * // Close the demuxer when finished.
46
- * demuxer.close();
129
+ * try {
130
+ *
131
+ * await demuxer.mediaReady;
132
+ * } catch {
133
+ *
134
+ * // Demuxer aborted before any inbound RTP arrived; abandon the return-audio setup.
135
+ * return;
136
+ * }
137
+ *
138
+ * // Media is now flowing - safe to launch the consuming FFmpeg process.
47
139
  * ```
48
140
  *
49
- * @see {@link https://tools.ietf.org/html/rfc5761 | RFC 5761}
50
- * @see {@link https://github.com/homebridge/homebridge-camera-ffmpeg | homebridge-camera-ffmpeg}
141
+ * @see RtpPacketParser
142
+ * @see RTCP_HEARTBEAT_INTERVAL
51
143
  *
52
144
  * @category FFmpeg
53
145
  */
54
- export class RtpDemuxer extends EventEmitter {
55
- heartbeatTimer;
56
- heartbeatMsg;
57
- _isRunning;
58
- log;
59
- inputPort;
60
- socket;
146
+ export class RtpDemuxer {
61
147
  /**
62
- * Constructs a new RtpDemuxer for a specified IP family and port set.
148
+ * The composed abort signal representing this demuxer's lifetime. Aborts exactly once when the socket errors, the inactivity watchdog fires, the parent signal
149
+ * propagates, or {@link RtpDemuxer.abort} is called; the reason encoded on `signal.reason` names the cause.
150
+ */
151
+ signal;
152
+ /**
153
+ * Promise that resolves once the underlying UDP socket has finished binding and entered its `"listening"` state. Rejects with `this.signal.reason` when the bind
154
+ * fails, the parent signal propagates, or any other abort path fires before the socket becomes ready.
63
155
  *
64
- * @param ipFamily - The IP family: "ipv4" or "ipv6".
65
- * @param inputPort - The UDP port to listen on for incoming packets.
66
- * @param rtcpPort - The UDP port to forward RTCP packets to.
67
- * @param rtpPort - The UDP port to forward RTP packets to.
68
- * @param log - Logger instance for debug and error messages.
156
+ * The unhandled-rejection tracker is suppressed via {@link markHandled} so consumers that never observe readiness (fire-and-forget demuxers) do not produce warnings;
157
+ * consumers that do observe rejection receive the same structured abort reason they would from `signal.reason`.
158
+ */
159
+ ready;
160
+ /**
161
+ * Promise that resolves once the demuxer has forwarded its first RTP-classified packet - the application-level readiness milestone meaning "media is now flowing
162
+ * through the relay to the downstream consumer." Rejects with `this.signal.reason` if the demuxer aborts before any RTP packet arrives.
69
163
  *
70
- * @example
164
+ * Pairs with {@link RtpDemuxer.ready} as a two-tier readiness model: `ready` resolves when the inbound socket has bound (network-level readiness); `mediaReady`
165
+ * 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
166
+ * path, wait for inbound media before launching the consuming FFmpeg process":
71
167
  *
72
168
  * ```ts
73
- * const demuxer = new RtpDemuxer("ipv4", 50000, 50002, 50004, log);
169
+ * try {
170
+ * await demuxer.mediaReady;
171
+ * } catch {
172
+ * // Demuxer aborted before any RTP arrived; abandon the return-audio setup.
173
+ * return;
174
+ * }
175
+ * // Safe to launch the consuming FFmpeg process now that media is flowing.
74
176
  * ```
177
+ *
178
+ * The unhandled-rejection tracker is suppressed via {@link markHandled} so consumers that never observe this promise produce no warnings.
75
179
  */
76
- constructor(ipFamily, inputPort, rtcpPort, rtpPort, log) {
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
- }
180
+ mediaReady;
110
181
  /**
111
- * Sends periodic heartbeat messages to the RTP port to keep the FFmpeg process alive.
112
- *
113
- * This is necessary because FFmpeg times out input streams if it does not receive data for more than five seconds.
182
+ * 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
183
+ * returns; for an ephemeral construction (`init.inputPort === 0`), the value is `0` until the kernel completes the bind, then the kernel-assigned port. Consumers
184
+ * that need the assigned ephemeral port `await demuxer.ready` before reading this property.
114
185
  *
115
- * @param port - The RTP port to send the heartbeat to.
186
+ * 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
187
+ * 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
188
  */
117
- heartbeat(port) {
118
- // Clear the old heartbeat timer.
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
- if (!this.heartbeatMsg) {
124
- return;
125
- }
126
- this.log?.debug("Sending ffmpeg a heartbeat.");
127
- this.socket.send(this.heartbeatMsg, port);
128
- this.heartbeat(port);
129
- }, TWOWAY_HEARTBEAT_INTERVAL * 1000);
189
+ get inputPort() {
190
+ return this.#assignedInputPort ?? this.#requestedInputPort;
130
191
  }
131
192
  /**
132
- * Closes the demuxer, its socket, and any heartbeat timers.
133
- *
134
- * @example
135
- *
136
- * ```ts
137
- * demuxer.close();
138
- * ```
193
+ * `true` once `this.signal` has aborted. Derived from the signal; no independent state.
139
194
  */
140
- close() {
141
- this.log?.debug("Closing the RtpDemuxer instance on port %s.", this.inputPort);
142
- clearTimeout(this.heartbeatTimer);
143
- this.socket.close();
144
- this._isRunning = false;
145
- this.emit("rtp");
195
+ get aborted() {
196
+ return this.signal.aborted;
146
197
  }
147
198
  /**
148
- * Extracts the RTP payload type from a UDP packet.
149
- *
150
- * Used internally to distinguish RTP from RTCP messages.
151
- *
152
- * @param message - The UDP packet buffer.
153
- *
154
- * @returns The RTP payload type as a number.
199
+ * `true` when the abort reason indicates a timeout. Matches both the canonical `HbpuAbortError("timeout")` emitted by the inactivity watchdog and the platform
200
+ * `TimeoutError` emitted by `AbortSignal.timeout()` - consumers branch on a single getter regardless of which code path produced the timeout. The branching
201
+ * 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."
155
202
  */
156
- getPayloadType(message) {
157
- return message.readUInt8(1) & 0x7f;
203
+ get isTimedOut() {
204
+ return isTimeoutReason(this.signal.reason);
158
205
  }
206
+ // The port requested at construction (verbatim from `init.inputPort`). Equals the bound port for specific-port constructions; `0` for ephemeral constructions until
207
+ // the kernel hands one out. Read through the {@link RtpDemuxer.inputPort} getter, which prefers {@link #assignedInputPort} once the bind settles.
208
+ #requestedInputPort;
209
+ // The port the kernel actually bound to, captured from `socket.address().port` once the `"listening"` event fires. Undefined until then. For specific-port binds
210
+ // this duplicates {@link #requestedInputPort} but the assignment keeps the post-bind read path uniform across both construction modes.
211
+ #assignedInputPort;
212
+ // 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
213
+ // its RTP input but the arriving traffic keeps that input fed during silence, which is the point of the keepalive).
214
+ #rtpPort;
215
+ // The RTCP destination port, supplied at construction. The destination of all RTCP-classified forwards.
216
+ #rtcpPort;
217
+ // 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
218
+ // forwarding path.
219
+ #destAddress;
220
+ // The private AbortController whose signal is composed into `this.signal`. Owning the controller internally keeps teardown reachable from every handler - the socket
221
+ // error listener, the watchdog onFire callbacks, the message handler - without giving callers a handle to the raw controller.
222
+ #controller;
223
+ // The bound UDP socket. Receives inbound datagrams from HomeKit and sends forwarded datagrams (and heartbeats) to the loopback destination ports. Held privately so
224
+ // the only interaction points are the demuxer's public surface and the internal listeners.
225
+ #socket;
226
+ // The byte-to-record parser driving each datagram through classification. Stateless across datagrams (RTP wire format is datagram-framed) but instantiated once so
227
+ // the class shape mirrors the MP4 pipeline.
228
+ #parser;
229
+ // Self-rearming heartbeat watchdog: when the inbound-RTCP gap exceeds RTCP_HEARTBEAT_INTERVAL, the watchdog's onFire replays the last observed RTCP datagram to
230
+ // {@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
231
+ // RTCP arrivals also call `arm()` to push the next fire forward - busy RTCP traffic suppresses the heartbeat naturally, quiet periods synthesize one.
232
+ #heartbeat;
233
+ // Optional inactivity watchdog composed over this.signal, or `undefined` when no inactivityTimeout was configured. Re-armed on every inbound datagram; fires the
234
+ // composed controller's abort on lapse. Self-cleans when the signal aborts for any reason, so no teardown wiring is needed.
235
+ #inactivityWatchdog;
236
+ // Optional logger. Debug traces are emitted when present; absent logger is silent.
237
+ #log;
238
+ // 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
239
+ // `await using` contract truly means "the port is releasable again" by the time the surrounding scope exits, not merely "teardown has been scheduled."
240
+ #closed;
241
+ // 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
242
+ // 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.
243
+ #lastRtcp;
159
244
  /**
160
- * Determines if the provided UDP packet is an RTP message.
245
+ * Construct and bind a new RTP demuxer.
161
246
  *
162
- * @param message - The UDP packet buffer.
247
+ * 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
248
+ * 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
249
+ * sequence work against bind completion `await demuxer.ready`.
163
250
  *
164
- * @returns `true` if the packet is RTP, `false` if RTCP.
251
+ * @param init - Required init options. See {@link RtpDemuxerInit}.
165
252
  */
166
- isRtpMessage(message) {
167
- const payloadType = this.getPayloadType(message);
168
- return (payloadType > 90) || (payloadType === 0);
253
+ constructor(init) {
254
+ const { inactivityTimeout, inputPort, ipFamily = "ipv4", log, rtcpPort, rtpPort, signal: parentSignal } = init;
255
+ this.#controller = new AbortController();
256
+ this.signal = composeSignals(parentSignal, this.#controller.signal);
257
+ this.#requestedInputPort = inputPort;
258
+ this.#rtpPort = rtpPort;
259
+ this.#rtcpPort = rtcpPort;
260
+ this.#destAddress = loopbackAddress(ipFamily);
261
+ this.#log = log;
262
+ this.#parser = new RtpPacketParser();
263
+ this.#socket = createDgramSocket(ipFamily);
264
+ // Promise-based readiness milestones. `Promise.withResolvers` (ES2024) gives us the resolver handles directly, replacing the IIFE-with-try/catch pattern an
265
+ // older `events.once`-based wiring would require. Resolvers settle once - subsequent resolve/reject calls are silent no-ops - so each milestone has exactly one
266
+ // settling code path and rejection is centralized in the `onAbort` teardown handler below.
267
+ const readyResolvers = Promise.withResolvers();
268
+ const mediaReadyResolvers = Promise.withResolvers();
269
+ const closedResolvers = Promise.withResolvers();
270
+ this.ready = markHandled(readyResolvers.promise);
271
+ this.mediaReady = markHandled(mediaReadyResolvers.promise);
272
+ this.#closed = closedResolvers.promise;
273
+ // Socket `"listening"` handler. Bind succeeded. Cache the kernel-assigned port (which differs from `inputPort` only on ephemeral binds), then signal readiness.
274
+ this.#socket.once("listening", () => {
275
+ this.#assignedInputPort = this.#socket.address().port;
276
+ readyResolvers.resolve();
277
+ });
278
+ // Socket `"close"` handler. The kernel has released the bound port. Resolve the `#closed` promise so {@link [Symbol.asyncDispose]} can deterministically await
279
+ // the port release, eliminating the rebind race a fire-and-forget close would carry.
280
+ this.#socket.once("close", () => closedResolvers.resolve());
281
+ // Socket `"error"` handler. Bind failures (typically EADDRINUSE) and runtime socket errors both flow through here. Gating on `!this.aborted` keeps a repeat abort
282
+ // a no-op 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
283
+ // downstream diagnostics see the original kernel error.
284
+ this.#socket.on("error", (error) => {
285
+ if (this.aborted) {
286
+ return;
287
+ }
288
+ this.#log?.error("RtpDemuxer socket error on port %d: %s.", this.inputPort, error.message);
289
+ this.#controller.abort(new HbpuAbortError("failed", { cause: error }));
290
+ });
291
+ // Heartbeat watchdog. The onFire replays the last observed RTCP datagram to `rtpPort` and immediately re-arms the watchdog so the cadence continues. Inbound
292
+ // RTCP messages call `arm()` in the message handler, pushing the next fire forward - busy RTCP suppresses the heartbeat naturally, quiet periods synthesize one.
293
+ // 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
294
+ // 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
295
+ // arm it from the constructor because heartbeats are RTCP-replays and there is nothing meaningful to replay before the first RTCP.
296
+ this.#heartbeat = new Watchdog({
297
+ onFire: () => {
298
+ if ((this.#lastRtcp !== undefined) && !this.aborted) {
299
+ this.#log?.debug("RtpDemuxer sending FFmpeg a keepalive heartbeat.");
300
+ this.#socket.send(this.#lastRtcp, this.#rtpPort, this.#destAddress);
301
+ }
302
+ this.#heartbeat.arm();
303
+ },
304
+ signal: this.signal,
305
+ timeoutMs: RTCP_HEARTBEAT_INTERVAL
306
+ });
307
+ // Optional inactivity watchdog. Constructed only when the caller opted into liveness enforcement so absence carries no scheduled timer and no per-packet overhead.
308
+ // The watchdog composes against `this.signal` so a parent abort (or any other internal abort) self-cleans the pending timer without explicit teardown.
309
+ this.#inactivityWatchdog = (inactivityTimeout !== undefined) ? new Watchdog({
310
+ onFire: () => {
311
+ this.#log?.debug("RtpDemuxer inactivity watchdog fired after %d ms with no inbound packets on port %d.", inactivityTimeout, this.inputPort);
312
+ this.#controller.abort(new HbpuAbortError("timeout"));
313
+ },
314
+ signal: this.signal,
315
+ timeoutMs: inactivityTimeout
316
+ }) : undefined;
317
+ // Inbound message handler. Every datagram re-arms the inactivity watchdog. Each classified packet forwards out the same socket - source endpoint of the forward
318
+ // equals `loopback:inputPort`, preserving the source-port symmetry property documented at the class level. RTP-classified packets resolve `mediaReady` on the
319
+ // first arrival; RTCP-classified packets are captured for heartbeat replay and arm the heartbeat watchdog.
320
+ this.#socket.on("message", (datagram) => {
321
+ this.#inactivityWatchdog?.arm();
322
+ for (const packet of this.#parser.consume(datagram)) {
323
+ if (packet.kind === "rtp") {
324
+ this.#socket.send(packet.bytes, this.#rtpPort, this.#destAddress);
325
+ // Resolve the first-RTP milestone. `Promise.withResolvers` settles once, so subsequent calls on every later RTP packet are silent no-ops - no flag required.
326
+ mediaReadyResolvers.resolve();
327
+ continue;
328
+ }
329
+ // 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
330
+ // backing `datagram`. A reference assignment would leak corrupted bytes to the heartbeat replay; `Buffer.from(...)` is the canonical safe copy.
331
+ this.#lastRtcp = Buffer.from(packet.bytes);
332
+ this.#socket.send(packet.bytes, this.#rtcpPort, this.#destAddress);
333
+ this.#heartbeat.arm();
334
+ }
335
+ });
336
+ // Single teardown convergence point. `onAbort` registers the one-shot close listener for the normal abort path AND handles the "pre-aborted signal" edge case
337
+ // where `addEventListener("abort", ...)` would otherwise silently skip the handler (the AbortSignal spec does not re-dispatch historical events). Rejecting the
338
+ // milestone promises is safe to repeat - a milestone that already resolved is unaffected by the subsequent reject; a milestone still pending receives the signal's
339
+ // reason as its rejection. The socket close fires the `"close"` event which resolves `#closed`, ungating `[Symbol.asyncDispose]`.
340
+ onAbort(this.signal, () => {
341
+ readyResolvers.reject(this.signal.reason);
342
+ mediaReadyResolvers.reject(this.signal.reason);
343
+ this.#socket.close();
344
+ });
345
+ if (this.signal.aborted) {
346
+ return;
347
+ }
348
+ // 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
349
+ // mislead an operator reading the trace; we name the intent ("kernel-assigned ephemeral port") and rely on subsequent error / timeout logs (which read
350
+ // `this.inputPort` after the listening event captures the assigned value) to show the actual bound port.
351
+ if (inputPort === 0) {
352
+ this.#log?.debug("Binding RtpDemuxer on %s with a kernel-assigned ephemeral port forwarding to RTP %d / RTCP %d.", ipFamily, rtpPort, rtcpPort);
353
+ }
354
+ else {
355
+ this.#log?.debug("Binding RtpDemuxer on %s port %d forwarding to RTP %d / RTCP %d.", ipFamily, inputPort, rtpPort, rtcpPort);
356
+ }
357
+ // 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
358
+ // 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
359
+ // the bound `inputPort`, completing the source-endpoint-symmetry property. A bind failure surfaces through the `"error"` listener above.
360
+ this.#socket.bind(inputPort);
361
+ // 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.
362
+ // The heartbeat watchdog deliberately stays dormant - no inbound RTCP has been seen yet, so there is nothing to replay.
363
+ this.#inactivityWatchdog?.arm();
169
364
  }
170
365
  /**
171
- * Indicates if the demuxer is running and accepting packets.
366
+ * Abort the demuxer and tear it down. Defaults to `HbpuAbortError("shutdown")` when no reason is supplied; explicit reasons pass through unchanged.
172
367
  *
173
- * @returns `true` if running, otherwise `false`.
368
+ * 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
369
+ * timeout is also safe for the same reason.
174
370
  *
175
- * @example
371
+ * @param reason - Optional abort reason. Typically an {@link HbpuAbortError}; platform errors (`TimeoutError`, `AbortError`) also interoperate by convention.
372
+ */
373
+ abort(reason) {
374
+ if (this.aborted) {
375
+ return;
376
+ }
377
+ this.#controller.abort(reason ?? new HbpuAbortError("shutdown"));
378
+ }
379
+ /**
380
+ * `AsyncDisposable` implementation. Aborts the demuxer (defaulting to `"shutdown"`) and awaits the socket's `"close"` event so the bound port is releasable by the
381
+ * time the surrounding `await using` scope's next statement runs. Safe to invoke repeatedly: subsequent disposals collapse onto the same `#closed` promise.
176
382
  *
177
- * ```ts
178
- * if(demuxer.isRunning) {
179
- * // Demuxer is active.
180
- * }
181
- * ```
383
+ * @returns A promise that resolves once the kernel has released the bound port.
182
384
  */
183
- get isRunning() {
184
- return this._isRunning;
385
+ async [Symbol.asyncDispose]() {
386
+ this.abort();
387
+ await this.#closed;
185
388
  }
186
389
  }
187
390
  /**
188
- * Allocates and tracks UDP ports for RTP and RTCP to avoid port conflicts in environments with high network activity.
391
+ * Registry that reserves consecutive UDP ports for FFmpeg-based RTP/RTCP sessions.
392
+ *
393
+ * 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
394
+ * resource: each successful {@link RtpPortAllocator.reserve} call returns a {@link PortReservation} handle whose disposal releases the held ports back to the pool.
189
395
  *
190
- * This utility class is used to find and reserve available UDP ports for demuxing FFmpeg streams or other network activities.
396
+ * The reservation bind-retry loop is signal-aware: passing `init.signal` to `reserve()` means an aborting parent signal cancels the pending reservation cleanly, even
397
+ * when the loop is in the middle of probing candidate ports.
191
398
  *
192
399
  * @example
193
400
  *
194
401
  * ```ts
195
- * const allocator = new RtpPortAllocator();
402
+ * // Scope-bound: the reservation releases automatically when the block exits.
403
+ * await using reservation = await allocator.reserve({ count: 2, signal: session.controller.signal });
404
+ * const rtpPort = reservation.port;
405
+ * const rtcpPort = reservation.port + 1;
406
+ * // ...use rtpPort and rtcpPort for the duration of the session.
407
+ * ```
196
408
  *
197
- * // Reserve two consecutive ports for RTP and RTCP.
198
- * const rtpPort = await allocator.reserve("ipv4", 2);
409
+ * @example
199
410
  *
200
- * // Cancel reservation if not needed.
201
- * allocator.cancel(rtpPort);
411
+ * ```ts
412
+ * // Non-scoped: store the handle on a session entry and dispose explicitly later.
413
+ * const reservation = await allocator.reserve({ count: 2, signal: session.controller.signal });
414
+ * session.ports = reservation;
415
+ * // ...later:
416
+ * await session.ports[Symbol.asyncDispose]();
202
417
  * ```
203
418
  *
204
419
  * @category FFmpeg
205
420
  */
206
421
  export class RtpPortAllocator {
207
- portsInUse;
422
+ // 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.
423
+ #inUse = new Set();
208
424
  /**
209
- * Instantiates a new RTP port allocator and tracker.
425
+ * The number of UDP ports currently held by live {@link PortReservation} handles. Exposed for operational diagnostics - a plugin that surfaces "how many
426
+ * 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
427
+ * count across normal session cycles indicates a reservation is not being disposed).
428
+ *
429
+ * @returns The number of reserved ports currently tracked by the allocator.
210
430
  */
211
- constructor() {
212
- // Initialize our in use tracker.
213
- this.portsInUse = new Set();
431
+ get reservedCount() {
432
+ return this.#inUse.size;
214
433
  }
215
434
  /**
216
- * Finds an available UDP port by attempting to bind a new socket.
435
+ * Reserve one or two consecutive UDP ports.
436
+ *
437
+ * 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
438
+ * 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.
217
439
  *
218
- * Loops until an available port not already marked as in use is found.
440
+ * @param init - Optional init options. See {@link PortReservationInit}.
219
441
  *
220
- * @param ipFamily - "ipv4" or "ipv6".
221
- * @param port - Optional. The port to try to bind to. If 0, selects a random port.
442
+ * @returns A {@link PortReservation} handle whose disposal releases the reserved ports.
222
443
  *
223
- * @returns A promise resolving to the available port number, or `-1` on error.
444
+ * @throws The caller signal's abort reason, if the signal aborts before a reservation is obtained.
445
+ * @throws `RangeError` when `count` is anything other than `1` or `2`.
446
+ * @throws `Error` when no reservation could be obtained within the attempt budget.
224
447
  */
225
- async getPort(ipFamily, port = 0) {
226
- try {
227
- // Keep looping until we find what we're looking for: local UDP ports that are unspoken for.
228
- for (;;) {
229
- // Create a datagram socket, so we can use it to find a port.
230
- const socket = createSocket(ipFamily === "ipv6" ? "udp6" : "udp4");
231
- // Exclude this socket from Node's reference counting so we don't have issues later.
232
- socket.unref();
233
- // Listen for the bind event.
234
- const eventListener = once(socket, "listening");
235
- // Bind to the port in question. If port is set to 0, we'll get a randomly generated port generated for us.
236
- socket.bind(port);
237
- // Ensure we wait for the socket to be bound.
448
+ async reserve(init = {}) {
449
+ const { count = 1, ipFamily = "ipv4", signal } = init;
450
+ // 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
451
+ // `number` here lets the runtime guard catch the out-of-range case with a clear message rather than an infinite reservation loop.
452
+ const requestedCount = count;
453
+ if ((requestedCount !== 1) && (requestedCount !== 2)) {
454
+ throw new RangeError("RtpPortAllocator.reserve: count must be 1 or 2.");
455
+ }
456
+ signal?.throwIfAborted();
457
+ for (let attempt = 0; attempt < RESERVE_MAX_ATTEMPTS; attempt++) {
458
+ const env_1 = { stack: [], error: void 0, hasError: false };
459
+ try {
460
+ signal?.throwIfAborted();
461
+ // Two-phase commit via ES 2023 Explicit Resource Management. `#acquirePort` atomically adds to `#inUse` and registers the matching release on the stack, so a
462
+ // successful acquire always leaves the port paired with its cleanup. If we return a reservation, `stack.move()` transfers ownership away from scope-bound
463
+ // disposal and the ports stay in `#inUse`. Every other exit path - null-retry `continue`, thrown caller abort, any future exception - hits `using`'s automatic
464
+ // disposal and releases the tentative ports. This is the platform-standard pattern for "acquire, maybe commit, release on failure."
465
+ const stack = __addDisposableResource(env_1, new DisposableStack(), false);
238
466
  // eslint-disable-next-line no-await-in-loop
239
- await eventListener;
240
- // Retrieve the port number we've gotten from the bind request.
241
- const assignedPort = socket.address().port;
242
- // We're done with the socket, let's cleanup.
243
- socket.close();
244
- // Check to see if the port is one we're already using. For specific port requests, return failure and let the caller retry with a fresh port pair. For random
245
- // port requests, loop and let the OS assign a different one.
246
- if (this.portsInUse.has(assignedPort)) {
247
- if (port) {
248
- return -1;
249
- }
467
+ const firstPort = await this.#acquirePort(stack, ipFamily, 0, signal);
468
+ if (firstPort === null) {
250
469
  continue;
251
470
  }
252
- // Now let's mark the port in use.
253
- this.portsInUse.add(assignedPort);
254
- // Return the port.
255
- return assignedPort;
471
+ if (count === 1) {
472
+ const reservation = this.#makeReservation(firstPort, 1, ipFamily);
473
+ stack.move();
474
+ return reservation;
475
+ }
476
+ // eslint-disable-next-line no-await-in-loop
477
+ const secondPort = await this.#acquirePort(stack, ipFamily, firstPort + 1, signal);
478
+ if (secondPort === null) {
479
+ continue;
480
+ }
481
+ const reservation = this.#makeReservation(firstPort, 2, ipFamily);
482
+ stack.move();
483
+ return reservation;
484
+ }
485
+ catch (e_1) {
486
+ env_1.error = e_1;
487
+ env_1.hasError = true;
488
+ }
489
+ finally {
490
+ __disposeResources(env_1);
256
491
  }
257
492
  }
258
- catch (error) {
259
- return -1;
260
- }
493
+ const portDescription = (count === 1) ? "a port" : count.toString() + " consecutive ports";
494
+ throw new Error("RtpPortAllocator: unable to reserve " + portDescription + " after " + RESERVE_MAX_ATTEMPTS.toString() + " attempts.");
261
495
  }
262
- /**
263
- * Internal method to reserve one or two consecutive UDP ports for FFmpeg or network use.
264
- *
265
- * If two ports are reserved, ensures they are consecutive for RTP and RTCP usage. Returns the first port in the sequence, or `-1` if we're unable to allocate.
266
- *
267
- * @param ipFamily - Optional. "ipv4" or "ipv6". Defaults to "ipv4".
268
- * @param portCount - Optional. The number of consecutive ports to reserve (1 or 2). Defaults to 1.
269
- * @param attempts - Internal. The number of allocation attempts. Used for recursion.
270
- *
271
- * @returns A promise resolving to the first reserved port, or `-1` if unavailable.
272
- */
273
- async _reserve(ipFamily = "ipv4", portCount = 1, attempts = 0) {
274
- // Sanity check and make sure we're not requesting any more than two ports at a time, or if we've exceeded our attempt limit.
275
- if (![1, 2].includes(portCount) || (attempts > 10)) {
276
- return -1;
277
- }
278
- let firstPort = 0;
279
- // Find the appropriate number of ports being requested.
280
- for (let i = 0; i < portCount; i++) {
281
- // eslint-disable-next-line no-await-in-loop
282
- const assignedPort = await this.getPort(ipFamily, firstPort ? firstPort + 1 : 0);
283
- // We haven't gotten a port, let's try again.
284
- if (assignedPort === -1) {
285
- // If we've gotten the first port of a pair of ports, make sure we release it here.
286
- if (firstPort) {
287
- this.cancel(firstPort);
496
+ // 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
497
+ // `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
498
+ // 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
499
+ // `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
500
+ // pairing it with a disposer, so the "forgot to clean up on failure" bug class is impossible by construction.
501
+ async #acquirePort(stack, ipFamily, requestedPort, signal) {
502
+ for (;;) {
503
+ signal?.throwIfAborted();
504
+ const socket = createDgramSocket(ipFamily);
505
+ // Exclude this probe socket from Node's reference counting so it never prevents the process from exiting while a reservation is pending.
506
+ socket.unref();
507
+ try {
508
+ // `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
509
+ // failures (EADDRINUSE on a specific port) and abort propagation through a single catch. `{ signal }` accepts `undefined` verbatim - events.once's internal
510
+ // `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.
511
+ const listening = once(socket, "listening", { signal });
512
+ socket.bind(requestedPort);
513
+ // eslint-disable-next-line no-await-in-loop
514
+ await listening;
515
+ }
516
+ catch {
517
+ socket.close();
518
+ // 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
519
+ // `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
520
+ // converge on the `null` return so the outer retry loop can fall through to the next port without coupling to kernel-error specifics.
521
+ if (signal?.aborted) {
522
+ throw signal.reason;
288
523
  }
289
- // We still haven't found what we're looking for...keep looking.
290
- return this._reserve(ipFamily, portCount, ++attempts);
524
+ return null;
291
525
  }
292
- // We've seen the first port we may be looking for, let's save it.
293
- firstPort ||= assignedPort;
526
+ const assignedPort = socket.address().port;
527
+ socket.close();
528
+ if (this.#inUse.has(assignedPort)) {
529
+ // 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
530
+ // inside this function until a fresh, un-reserved port is found.
531
+ if (requestedPort !== 0) {
532
+ return null;
533
+ }
534
+ continue;
535
+ }
536
+ this.#inUse.add(assignedPort);
537
+ stack.defer(() => this.#inUse.delete(assignedPort));
538
+ return assignedPort;
294
539
  }
295
- // Return the first port we've found.
296
- return firstPort;
297
540
  }
298
- /**
299
- * Reserves one or two consecutive UDP ports for FFmpeg or network use.
300
- *
301
- * If two ports are reserved, ensures they are consecutive for RTP and RTCP usage. Returns the first port in the sequence, or `-1` if we're unable to allocate.
302
- *
303
- * @param ipFamily - Optional. "ipv4" or "ipv6". Defaults to "ipv4".
304
- * @param portCount - Optional. The number of consecutive ports to reserve (1 or 2). Defaults to 1.
305
- *
306
- * @returns A promise resolving to the first reserved port, or `-1` if unavailable.
307
- *
308
- * @remarks FFmpeg currently lacks the ability to specify both the RTP and RTCP ports. FFmpeg always assumes, by convention, that when you specify an RTP port, the RTCP
309
- * port is the RTP port + 1. In order to work around that challenge, we need to always ensure that when we reserve multiple ports for RTP (primarily for two-way audio
310
- * use cases) that we we are reserving consecutive ports only.
311
- *
312
- * @example
313
- *
314
- * ```ts
315
- * // Reserve a single port.
316
- * const port = await allocator.reserve();
317
- *
318
- * // Reserve two consecutive ports for RTP/RTCP.
319
- * const rtpPort = await allocator.reserve("ipv4", 2);
320
- * ```
321
- */
322
- async reserve(ipFamily = "ipv4", portCount = 1) {
323
- return this._reserve(ipFamily, portCount);
324
- }
325
- /**
326
- * Cancels and releases a previously reserved port, making it available for future use.
327
- *
328
- * @param port - The port number to release.
329
- *
330
- * @example
331
- *
332
- * ```ts
333
- * allocator.cancel(50000);
334
- * ```
335
- */
336
- cancel(port) {
337
- this.portsInUse.delete(port);
541
+ // 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.
542
+ // 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.
543
+ #makeReservation(firstPort, count, ipFamily) {
544
+ const portsHeld = (count === 2) ? [firstPort, firstPort + 1] : [firstPort];
545
+ let disposed = false;
546
+ const dispose = async () => {
547
+ if (disposed) {
548
+ return;
549
+ }
550
+ disposed = true;
551
+ for (const port of portsHeld) {
552
+ this.#inUse.delete(port);
553
+ }
554
+ };
555
+ return {
556
+ count,
557
+ ipFamily,
558
+ port: firstPort,
559
+ [Symbol.asyncDispose]: dispose
560
+ };
338
561
  }
339
562
  }
340
563
  //# sourceMappingURL=rtp.js.map