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.
Files changed (198) 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 +128 -0
  20. package/dist/backpressure.js +273 -0
  21. package/dist/backpressure.js.map +1 -0
  22. package/dist/cli/index.d.ts +95 -0
  23. package/dist/cli/index.js +355 -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/eslint-plugin/config.d.mts +193 -0
  32. package/dist/eslint-plugin/index.d.mts +2 -0
  33. package/dist/eslint-plugin/plugin.d.mts +87 -0
  34. package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
  35. package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
  36. package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
  37. package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
  38. package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
  39. package/dist/featureOptions-docs.d.ts +96 -0
  40. package/dist/featureOptions-docs.js +193 -0
  41. package/dist/featureOptions-docs.js.map +1 -0
  42. package/dist/featureOptions.d.ts +674 -0
  43. package/dist/featureOptions.js +870 -0
  44. package/dist/featureOptions.js.map +1 -0
  45. package/dist/ffmpeg/codecs.d.ts +256 -72
  46. package/dist/ffmpeg/codecs.js +477 -257
  47. package/dist/ffmpeg/codecs.js.map +1 -1
  48. package/dist/ffmpeg/dgram-util.d.ts +46 -0
  49. package/dist/ffmpeg/dgram-util.js +38 -0
  50. package/dist/ffmpeg/dgram-util.js.map +1 -0
  51. package/dist/ffmpeg/exec.d.ts +83 -64
  52. package/dist/ffmpeg/exec.js +77 -86
  53. package/dist/ffmpeg/exec.js.map +1 -1
  54. package/dist/ffmpeg/fmp4.d.ts +70 -4
  55. package/dist/ffmpeg/fmp4.js +100 -11
  56. package/dist/ffmpeg/fmp4.js.map +1 -1
  57. package/dist/ffmpeg/hap-enums.d.ts +214 -0
  58. package/dist/ffmpeg/hap-enums.js +92 -0
  59. package/dist/ffmpeg/hap-enums.js.map +1 -0
  60. package/dist/ffmpeg/index.d.ts +16 -9
  61. package/dist/ffmpeg/index.js +6 -0
  62. package/dist/ffmpeg/index.js.map +1 -1
  63. package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
  64. package/dist/ffmpeg/mp4-assembler.js +387 -0
  65. package/dist/ffmpeg/mp4-assembler.js.map +1 -0
  66. package/dist/ffmpeg/mp4-parser.d.ts +94 -0
  67. package/dist/ffmpeg/mp4-parser.js +130 -0
  68. package/dist/ffmpeg/mp4-parser.js.map +1 -0
  69. package/dist/ffmpeg/options.d.ts +62 -149
  70. package/dist/ffmpeg/options.js +608 -499
  71. package/dist/ffmpeg/options.js.map +1 -1
  72. package/dist/ffmpeg/process.d.ts +143 -91
  73. package/dist/ffmpeg/process.js +406 -265
  74. package/dist/ffmpeg/process.js.map +1 -1
  75. package/dist/ffmpeg/record.d.ts +302 -188
  76. package/dist/ffmpeg/record.js +408 -560
  77. package/dist/ffmpeg/record.js.map +1 -1
  78. package/dist/ffmpeg/recording-process-double.d.ts +144 -0
  79. package/dist/ffmpeg/recording-process-double.js +168 -0
  80. package/dist/ffmpeg/recording-process-double.js.map +1 -0
  81. package/dist/ffmpeg/rtp-parser.d.ts +70 -0
  82. package/dist/ffmpeg/rtp-parser.js +77 -0
  83. package/dist/ffmpeg/rtp-parser.js.map +1 -0
  84. package/dist/ffmpeg/rtp.d.ts +198 -141
  85. package/dist/ffmpeg/rtp.js +473 -244
  86. package/dist/ffmpeg/rtp.js.map +1 -1
  87. package/dist/ffmpeg/settings.d.ts +5 -1
  88. package/dist/ffmpeg/settings.js +20 -2
  89. package/dist/ffmpeg/settings.js.map +1 -1
  90. package/dist/ffmpeg/stream.d.ts +57 -107
  91. package/dist/ffmpeg/stream.js +121 -148
  92. package/dist/ffmpeg/stream.js.map +1 -1
  93. package/dist/formatters.d.ts +106 -0
  94. package/dist/formatters.js +174 -0
  95. package/dist/formatters.js.map +1 -0
  96. package/dist/homebridge-enums.d.ts +30 -0
  97. package/dist/homebridge-enums.js +17 -0
  98. package/dist/homebridge-enums.js.map +1 -0
  99. package/dist/index.d.ts +12 -5
  100. package/dist/index.js +8 -2
  101. package/dist/index.js.map +1 -1
  102. package/dist/logclient/auth.d.ts +114 -0
  103. package/dist/logclient/auth.js +199 -0
  104. package/dist/logclient/auth.js.map +1 -0
  105. package/dist/logclient/cli-run.d.ts +76 -0
  106. package/dist/logclient/cli-run.js +639 -0
  107. package/dist/logclient/cli-run.js.map +1 -0
  108. package/dist/logclient/cli.d.ts +3 -0
  109. package/dist/logclient/cli.js +97 -0
  110. package/dist/logclient/cli.js.map +1 -0
  111. package/dist/logclient/client.d.ts +145 -0
  112. package/dist/logclient/client.js +600 -0
  113. package/dist/logclient/client.js.map +1 -0
  114. package/dist/logclient/config.d.ts +173 -0
  115. package/dist/logclient/config.js +199 -0
  116. package/dist/logclient/config.js.map +1 -0
  117. package/dist/logclient/endpoints.d.ts +54 -0
  118. package/dist/logclient/endpoints.js +73 -0
  119. package/dist/logclient/endpoints.js.map +1 -0
  120. package/dist/logclient/filter.d.ts +45 -0
  121. package/dist/logclient/filter.js +51 -0
  122. package/dist/logclient/filter.js.map +1 -0
  123. package/dist/logclient/frame.d.ts +93 -0
  124. package/dist/logclient/frame.js +203 -0
  125. package/dist/logclient/frame.js.map +1 -0
  126. package/dist/logclient/index.d.ts +31 -0
  127. package/dist/logclient/index.js +12 -0
  128. package/dist/logclient/index.js.map +1 -0
  129. package/dist/logclient/parser.d.ts +211 -0
  130. package/dist/logclient/parser.js +393 -0
  131. package/dist/logclient/parser.js.map +1 -0
  132. package/dist/logclient/rest.d.ts +41 -0
  133. package/dist/logclient/rest.js +111 -0
  134. package/dist/logclient/rest.js.map +1 -0
  135. package/dist/logclient/settings.d.ts +15 -0
  136. package/dist/logclient/settings.js +64 -0
  137. package/dist/logclient/settings.js.map +1 -0
  138. package/dist/logclient/socket-double.d.ts +201 -0
  139. package/dist/logclient/socket-double.js +384 -0
  140. package/dist/logclient/socket-double.js.map +1 -0
  141. package/dist/logclient/socket.d.ts +257 -0
  142. package/dist/logclient/socket.js +620 -0
  143. package/dist/logclient/socket.js.map +1 -0
  144. package/dist/logclient/stitch.d.ts +83 -0
  145. package/dist/logclient/stitch.js +146 -0
  146. package/dist/logclient/stitch.js.map +1 -0
  147. package/dist/logclient/time-expression.d.ts +42 -0
  148. package/dist/logclient/time-expression.js +181 -0
  149. package/dist/logclient/time-expression.js.map +1 -0
  150. package/dist/logclient/time-window.d.ts +38 -0
  151. package/dist/logclient/time-window.js +53 -0
  152. package/dist/logclient/time-window.js.map +1 -0
  153. package/dist/logclient/types.d.ts +107 -0
  154. package/dist/logclient/types.js +6 -0
  155. package/dist/logclient/types.js.map +1 -0
  156. package/dist/mqttClient.d.ts +287 -0
  157. package/dist/mqttClient.js +433 -0
  158. package/dist/mqttClient.js.map +1 -0
  159. package/dist/service.d.ts +64 -15
  160. package/dist/service.js +93 -66
  161. package/dist/service.js.map +1 -1
  162. package/dist/ui/featureOptions.js +870 -0
  163. package/dist/ui/featureOptions.js.map +1 -0
  164. package/dist/ui/formatters.js +174 -0
  165. package/dist/ui/formatters.js.map +1 -0
  166. package/dist/ui/pluginConfigSession.mjs +141 -0
  167. package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
  168. package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
  169. package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
  170. package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
  171. package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
  172. package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
  173. package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
  174. package/dist/ui/webUi-featureOptions/state.mjs +319 -0
  175. package/dist/ui/webUi-featureOptions/store.mjs +181 -0
  176. package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
  177. package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
  178. package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
  179. package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
  180. package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
  181. package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
  182. package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
  183. package/dist/ui/webUi-featureOptions.mjs +677 -0
  184. package/dist/ui/webUi.mjs +192 -87
  185. package/dist/util.d.ts +597 -45
  186. package/dist/util.js +787 -68
  187. package/dist/util.js.map +1 -1
  188. package/package.json +33 -15
  189. package/build/eslint-rules.mjs +0 -511
  190. package/dist/featureoptions.d.ts +0 -264
  191. package/dist/featureoptions.js +0 -480
  192. package/dist/featureoptions.js.map +0 -1
  193. package/dist/mqttclient.d.ts +0 -178
  194. package/dist/mqttclient.js +0 -310
  195. package/dist/mqttclient.js.map +0 -1
  196. package/dist/ui/featureoptions.js +0 -480
  197. package/dist/ui/featureoptions.js.map +0 -1
  198. package/dist/ui/webUi-featureoptions.mjs +0 -3765
@@ -1,333 +1,562 @@
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 { 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
- * Utility for demultiplexing RTP and RTCP packets on a single UDP port for HomeKit compatibility.
93
+ * Signal-driven RTP/RTCP demultiplexer with FFmpeg keepalive heartbeat.
32
94
  *
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.
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
- * Credit to [dgreif](https://github.com/dgreif), [brandawg93](https://github.com/brandawg93), and [Sunoo](https://github.com/Sunoo) for foundational ideas and
37
- * collaboration.
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
- * // Create an RtpDemuxer to split packets for FFmpeg compatibility.
43
- * const demuxer = new RtpDemuxer("ipv4", 50000, 50002, 50004, log);
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
- * // Close the demuxer when finished.
46
- * demuxer.close();
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 {@link https://tools.ietf.org/html/rfc5761 | RFC 5761}
50
- * @see {@link https://github.com/homebridge/homebridge-camera-ffmpeg | homebridge-camera-ffmpeg}
140
+ * @see RtpPacketParser
141
+ * @see RTCP_HEARTBEAT_INTERVAL
51
142
  *
52
143
  * @category FFmpeg
53
144
  */
54
- export class RtpDemuxer extends EventEmitter {
55
- heartbeatTimer;
56
- heartbeatMsg;
57
- _isRunning;
58
- log;
59
- inputPort;
60
- socket;
145
+ export class RtpDemuxer {
61
146
  /**
62
- * Constructs a new RtpDemuxer for a specified IP family and port set.
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
- * @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.
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
- * @example
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
- * const demuxer = new RtpDemuxer("ipv4", 50000, 50002, 50004, log);
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
- 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
- }
179
+ mediaReady;
110
180
  /**
111
- * Sends periodic heartbeat messages to the RTP port to keep the FFmpeg process alive.
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
- * This is necessary because FFmpeg times out input streams if it does not receive data for more than five seconds.
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
- 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
- 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
- * Closes the demuxer, its socket, and any heartbeat timers.
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
- close() {
138
- this.log?.debug("Closing the RtpDemuxer instance on port %s.", this.inputPort);
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
- * Extracts the RTP payload type from a UDP packet.
146
- *
147
- * Used internally to distinguish RTP from RTCP messages.
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
- getPayloadType(message) {
154
- return message.readUInt8(1) & 0x7f;
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
- * Determines if the provided UDP packet is an RTP message.
244
+ * Construct and bind a new RTP demuxer.
158
245
  *
159
- * @param message - The UDP packet buffer.
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
- * @returns `true` if the packet is RTP, `false` if RTCP.
250
+ * @param init - Required init options. See {@link RtpDemuxerInit}.
162
251
  */
163
- isRtpMessage(message) {
164
- const payloadType = this.getPayloadType(message);
165
- return (payloadType > 90) || (payloadType === 0);
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
- * Indicates if the demuxer is running and accepting packets.
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
- * @returns `true` if running, otherwise `false`.
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
- * @example
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
- * ```ts
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
- get isRunning() {
181
- return this._isRunning;
384
+ async [Symbol.asyncDispose]() {
385
+ this.abort();
386
+ await this.#closed;
182
387
  }
183
388
  }
184
389
  /**
185
- * Allocates and tracks UDP ports for RTP and RTCP to avoid port conflicts in environments with high network activity.
390
+ * Registry that reserves consecutive UDP ports for FFmpeg-based RTP/RTCP sessions.
186
391
  *
187
- * This utility class is used to find and reserve available UDP ports for demuxing FFmpeg streams or other network activities.
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
- * const allocator = new RtpPortAllocator();
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
- * // Reserve two consecutive ports for RTP and RTCP.
195
- * const rtpPort = await allocator.reserve("ipv4", 2);
408
+ * @example
196
409
  *
197
- * // Cancel reservation if not needed.
198
- * allocator.cancel(rtpPort);
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
- portsInUse;
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
- * Instantiates a new RTP port allocator and tracker.
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
- constructor() {
209
- // Initialize our in use tracker.
210
- this.portsInUse = new Set();
430
+ get reservedCount() {
431
+ return this.#inUse.size;
211
432
  }
212
433
  /**
213
- * Finds an available UDP port by attempting to bind a new socket.
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
- * Loops until an available port not already marked as in use is found.
439
+ * @param init - Optional init options. See {@link PortReservationInit}.
216
440
  *
217
- * @param ipFamily - "ipv4" or "ipv6".
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
- * @returns A promise resolving to the available port number, or `-1` on error.
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 getPort(ipFamily, port = 0) {
223
- try {
224
- // Keep looping until we find what we're looking for: local UDP ports that are unspoken for.
225
- for (;;) {
226
- // Create a datagram socket, so we can use it to find a port.
227
- const socket = createSocket(ipFamily === "ipv6" ? "udp6" : "udp4");
228
- // Exclude this socket from Node's reference counting so we don't have issues later.
229
- socket.unref();
230
- // Listen for the bind event.
231
- const eventListener = once(socket, "listening");
232
- // Bind to the port in question. If port is set to 0, we'll get a randomly generated port generated for us.
233
- socket.bind(port);
234
- // Ensure we wait for the socket to be bound.
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 eventListener;
237
- // Retrieve the port number we've gotten from the bind request.
238
- const assignedPort = socket.address().port;
239
- // We're done with the socket, let's cleanup.
240
- socket.close();
241
- // Check to see if the port is one we're already using. If it is, try again.
242
- if (this.portsInUse.has(assignedPort)) {
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
- // Now let's mark the port in use.
246
- this.portsInUse.add(assignedPort);
247
- // Return the port.
248
- return assignedPort;
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
- catch (error) {
252
- return -1;
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
- * Internal method to reserve one or two consecutive UDP ports for FFmpeg or network use.
257
- *
258
- * 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.
259
- *
260
- * @param ipFamily - Optional. "ipv4" or "ipv6". Defaults to "ipv4".
261
- * @param portCount - Optional. The number of consecutive ports to reserve (1 or 2). Defaults to 1.
262
- * @param attempts - Internal. The number of allocation attempts. Used for recursion.
263
- *
264
- * @returns A promise resolving to the first reserved port, or `-1` if unavailable.
265
- */
266
- async _reserve(ipFamily = "ipv4", portCount = 1, attempts = 0) {
267
- // 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.
268
- if (![1, 2].includes(portCount) || (attempts > 10)) {
269
- return -1;
270
- }
271
- let firstPort = 0;
272
- // Find the appropriate number of ports being requested.
273
- for (let i = 0; i < portCount; i++) {
274
- // eslint-disable-next-line no-await-in-loop
275
- const assignedPort = await this.getPort(ipFamily, firstPort ? firstPort + 1 : 0);
276
- // We haven't gotten a port, let's try again.
277
- if (assignedPort === -1) {
278
- // If we've gotten the first port of a pair of ports, make sure we release it here.
279
- if (firstPort) {
280
- this.cancel(firstPort);
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
- // We still haven't found what we're looking for...keep looking.
283
- return this._reserve(ipFamily, portCount, ++attempts);
533
+ continue;
284
534
  }
285
- // We've seen the first port we may be looking for, let's save it.
286
- firstPort ||= assignedPort;
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
- * Reserves one or two consecutive UDP ports for FFmpeg or network use.
293
- *
294
- * 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.
295
- *
296
- * @param ipFamily - Optional. "ipv4" or "ipv6". Defaults to "ipv4".
297
- * @param portCount - Optional. The number of consecutive ports to reserve (1 or 2). Defaults to 1.
298
- *
299
- * @returns A promise resolving to the first reserved port, or `-1` if unavailable.
300
- *
301
- * @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
302
- * 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
303
- * use cases) that we we are reserving consecutive ports only.
304
- *
305
- * @example
306
- *
307
- * ```ts
308
- * // Reserve a single port.
309
- * const port = await allocator.reserve();
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