@apocaliss92/nodelink-js 0.5.0 → 0.5.1-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  ### [Manager UI (Web Dashboard)](./app/README.md)
10
10
 
11
- A complete web-based management interface for camera configuration and live streaming — no code required. Docker deployment, go2rtc restreamer, real-time events, MQTT, Home Assistant integration.
11
+ A complete web-based management interface for camera configuration and live streaming — no code required. Docker deployment, native RTSP + WebRTC, real-time events, MQTT, Home Assistant integration.
12
12
 
13
13
  ### [Library (`@apocaliss92/nodelink-js`)](./documentation/baichuan-api/README.md)
14
14
 
package/dist/index.cjs CHANGED
@@ -41646,17 +41646,47 @@ var import_node_events15 = require("events");
41646
41646
  var net6 = __toESM(require("net"), 1);
41647
41647
  var crypto3 = __toESM(require("crypto"), 1);
41648
41648
  var md5Hex = (s) => crypto3.createHash("md5").update(s).digest("hex");
41649
+ function normalizePath(path7) {
41650
+ if (!path7) return "/";
41651
+ let p = path7;
41652
+ if (!p.startsWith("/")) p = "/" + p;
41653
+ while (p.length > 1 && p.endsWith("/")) p = p.slice(0, -1);
41654
+ return p;
41655
+ }
41656
+ function extractBasePath(url) {
41657
+ if (!url || url === "*") return null;
41658
+ let pathPart;
41659
+ if (url.startsWith("rtsp://") || url.startsWith("rtsps://")) {
41660
+ try {
41661
+ const u = new URL(url.replace(/^rtsps?:/, "http:"));
41662
+ pathPart = u.pathname || "/";
41663
+ } catch {
41664
+ return null;
41665
+ }
41666
+ } else {
41667
+ const q = url.indexOf("?");
41668
+ pathPart = q >= 0 ? url.slice(0, q) : url;
41669
+ }
41670
+ if (pathPart.endsWith("/audiobackchannel")) {
41671
+ pathPart = pathPart.slice(0, -"/audiobackchannel".length);
41672
+ }
41673
+ return normalizePath(pathPart);
41674
+ }
41649
41675
  var BaichuanRtspBackchannelServer = class _BaichuanRtspBackchannelServer extends import_node_events15.EventEmitter {
41650
- api;
41651
- channel;
41652
41676
  listenHost;
41653
41677
  listenPort;
41654
- path;
41655
41678
  logger;
41656
41679
  authCredentials;
41657
41680
  requireAuth;
41658
41681
  authRealm;
41659
- deviceId;
41682
+ /**
41683
+ * Path → route table. Single-camera mode stores one entry; multi-tenant
41684
+ * mode can have many. The internal `singleCameraFallback` flag (below)
41685
+ * controls whether an unmatched request falls through to the sole
41686
+ * registered route (legacy behavior) or returns 404 (strict).
41687
+ */
41688
+ routes = /* @__PURE__ */ new Map();
41689
+ singleCameraFallback;
41660
41690
  server = void 0;
41661
41691
  /** Active backchannel sessions keyed by their per-client unique id. */
41662
41692
  sessionByClient = /* @__PURE__ */ new Map();
@@ -41664,11 +41694,8 @@ var BaichuanRtspBackchannelServer = class _BaichuanRtspBackchannelServer extends
41664
41694
  static NONCE_TTL_MS = 5 * 60 * 1e3;
41665
41695
  constructor(options) {
41666
41696
  super();
41667
- this.api = options.api;
41668
- this.channel = options.channel;
41669
41697
  this.listenHost = options.listenHost ?? "127.0.0.1";
41670
41698
  this.listenPort = options.listenPort ?? 8555;
41671
- this.path = options.path ?? "/talk";
41672
41699
  this.logger = options.logger ?? console;
41673
41700
  this.authCredentials = (options.credentials ?? []).map((c) => ({
41674
41701
  username: c.username,
@@ -41677,11 +41704,88 @@ var BaichuanRtspBackchannelServer = class _BaichuanRtspBackchannelServer extends
41677
41704
  }));
41678
41705
  this.requireAuth = options.requireAuth ?? this.authCredentials.length > 0;
41679
41706
  this.authRealm = options.authRealm ?? "BaichuanRtspBackchannelServer";
41680
- this.deviceId = options.deviceId;
41707
+ const singleCamera = options.api !== void 0 && options.channel !== void 0;
41708
+ const hasRoutes = options.routes !== void 0 && Object.keys(options.routes).length > 0;
41709
+ if (singleCamera && hasRoutes) {
41710
+ throw new Error(
41711
+ "BaichuanRtspBackchannelServer: pass either { api, channel } (single camera) or { routes } (multi tenant), not both"
41712
+ );
41713
+ }
41714
+ if (!singleCamera && options.routes === void 0) {
41715
+ throw new Error(
41716
+ "BaichuanRtspBackchannelServer: provide { api, channel } or { routes } (or both via constructor; addRoute() works too)"
41717
+ );
41718
+ }
41719
+ if (singleCamera) {
41720
+ const path7 = normalizePath(options.path ?? "/talk");
41721
+ this.routes.set(path7, {
41722
+ api: options.api,
41723
+ channel: options.channel,
41724
+ ...options.deviceId !== void 0 ? { deviceId: options.deviceId } : {}
41725
+ });
41726
+ this.singleCameraFallback = true;
41727
+ } else {
41728
+ this.singleCameraFallback = false;
41729
+ if (options.routes) {
41730
+ for (const [path7, route] of Object.entries(options.routes)) {
41731
+ this.routes.set(normalizePath(path7), { ...route });
41732
+ }
41733
+ }
41734
+ }
41681
41735
  }
41682
41736
  get listening() {
41683
41737
  return this.server !== void 0 && this.server.listening;
41684
41738
  }
41739
+ /**
41740
+ * Register (or replace) a camera route at runtime. The path is normalized
41741
+ * (leading slash, no trailing slash). The manager uses this to wire a
41742
+ * single shared port to N cameras as they come online.
41743
+ */
41744
+ addRoute(path7, route) {
41745
+ const normalized = normalizePath(path7);
41746
+ this.routes.set(normalized, { ...route });
41747
+ this.logger.info?.(
41748
+ `[BaichuanRtspBackchannelServer] route registered path=${normalized} channel=${route.channel}${route.deviceId ? ` deviceId="${route.deviceId}"` : ""} totalRoutes=${this.routes.size}`
41749
+ );
41750
+ }
41751
+ /** Remove a route. Returns true if a route was actually removed. */
41752
+ removeRoute(path7) {
41753
+ const normalized = normalizePath(path7);
41754
+ const had = this.routes.delete(normalized);
41755
+ if (had) {
41756
+ this.logger.info?.(
41757
+ `[BaichuanRtspBackchannelServer] route unregistered path=${normalized} totalRoutes=${this.routes.size}`
41758
+ );
41759
+ }
41760
+ return had;
41761
+ }
41762
+ /** Cheap presence check. */
41763
+ hasRoute(path7) {
41764
+ return this.routes.has(normalizePath(path7));
41765
+ }
41766
+ /** Snapshot of currently registered route paths. */
41767
+ listRoutes() {
41768
+ return Array.from(this.routes.keys());
41769
+ }
41770
+ /**
41771
+ * Resolve the {@link BackchannelRoute} for an incoming RTSP request URL.
41772
+ * In single-camera mode this falls back to the only registered route to
41773
+ * preserve permissive legacy behavior; in multi-tenant mode unmatched
41774
+ * paths return undefined so the caller can reply 404.
41775
+ */
41776
+ resolveRouteForRequest(url) {
41777
+ const base = extractBasePath(url);
41778
+ if (base !== null) {
41779
+ const exact = this.routes.get(base);
41780
+ if (exact) return { route: exact, path: base };
41781
+ }
41782
+ if (this.singleCameraFallback && this.routes.size === 1) {
41783
+ const path7 = this.routes.keys().next().value;
41784
+ const route = this.routes.get(path7);
41785
+ return { route, path: path7 };
41786
+ }
41787
+ return void 0;
41788
+ }
41685
41789
  async start() {
41686
41790
  if (this.server) return;
41687
41791
  await new Promise((resolve, reject) => {
@@ -41694,13 +41798,33 @@ var BaichuanRtspBackchannelServer = class _BaichuanRtspBackchannelServer extends
41694
41798
  server.listen(this.listenPort, this.listenHost, () => {
41695
41799
  server.removeListener("error", onError);
41696
41800
  this.server = server;
41801
+ const routeList = Array.from(this.routes.keys()).join(", ") || "(none)";
41697
41802
  this.logger.info?.(
41698
- `[BaichuanRtspBackchannelServer] listening on ${this.listenHost}:${this.listenPort} path=${this.path}`
41803
+ `[BaichuanRtspBackchannelServer] listening on ${this.listenHost}:${this.listenPort} routes=[${routeList}]`
41699
41804
  );
41700
41805
  resolve();
41701
41806
  });
41702
41807
  });
41703
41808
  }
41809
+ /**
41810
+ * Inject an already-accepted client socket from a multiplexer (e.g.
41811
+ * `LocalRtspMux`) that owns the listening port. The mux reads the first
41812
+ * RTSP request line to dispatch on path, then hands the socket over;
41813
+ * any bytes already consumed during routing are replayed via
41814
+ * `socket.unshift()` so the RTSP parser in `handleConnection` sees the
41815
+ * full original request.
41816
+ *
41817
+ * This lets the same backchannel server share a TCP port with the
41818
+ * per-profile video servers when the manager runs in local restreamer
41819
+ * mode. The server doesn't need to be `start()`ed in that mode — `stop()`
41820
+ * still tears down any in-flight talk sessions correctly.
41821
+ */
41822
+ injectSocket(socket, preReadData) {
41823
+ if (preReadData && preReadData.length > 0) {
41824
+ socket.unshift(preReadData);
41825
+ }
41826
+ this.handleConnection(socket);
41827
+ }
41704
41828
  async stop() {
41705
41829
  const server = this.server;
41706
41830
  this.server = void 0;
@@ -41724,7 +41848,7 @@ var BaichuanRtspBackchannelServer = class _BaichuanRtspBackchannelServer extends
41724
41848
  const connectedAt = Date.now();
41725
41849
  let buffer = Buffer.alloc(0);
41726
41850
  this.logger.info?.(
41727
- `[BaichuanRtspBackchannelServer] client connected client=${clientId} path=${this.path}`
41851
+ `[BaichuanRtspBackchannelServer] client connected client=${clientId}`
41728
41852
  );
41729
41853
  this.emit("client", clientId);
41730
41854
  const cleanup = () => {
@@ -41892,9 +42016,17 @@ CSeq: ${cseq}\r
41892
42016
  });
41893
42017
  return;
41894
42018
  case "DESCRIBE": {
42019
+ const resolved = this.resolveRouteForRequest(url);
42020
+ if (!resolved) {
42021
+ this.logger.warn?.(
42022
+ `[BaichuanRtspBackchannelServer] DESCRIBE no route matches url="${url}" client=${clientId} known=[${Array.from(this.routes.keys()).join(", ")}]`
42023
+ );
42024
+ send(404, "Not Found");
42025
+ return;
42026
+ }
41895
42027
  const sdp = this.buildSdp();
41896
42028
  this.logger.debug?.(
41897
- `[BaichuanRtspBackchannelServer] DESCRIBE sdp for ${clientId}:
42029
+ `[BaichuanRtspBackchannelServer] DESCRIBE sdp for ${clientId} path=${resolved.path}:
41898
42030
  ${sdp.trimEnd()}`
41899
42031
  );
41900
42032
  send(
@@ -41902,7 +42034,7 @@ ${sdp.trimEnd()}`
41902
42034
  "OK",
41903
42035
  {
41904
42036
  "Content-Type": "application/sdp",
41905
- "Content-Base": `rtsp://${this.listenHost}:${this.listenPort}${this.path}/`
42037
+ "Content-Base": `rtsp://${this.listenHost}:${this.listenPort}${resolved.path}/`
41906
42038
  },
41907
42039
  sdp
41908
42040
  );
@@ -41916,9 +42048,17 @@ ${sdp.trimEnd()}`
41916
42048
  send(404, "Not Found");
41917
42049
  return;
41918
42050
  }
42051
+ const resolved = this.resolveRouteForRequest(url);
42052
+ if (!resolved) {
42053
+ this.logger.warn?.(
42054
+ `[BaichuanRtspBackchannelServer] SETUP no route matches url="${url}" client=${clientId} known=[${Array.from(this.routes.keys()).join(", ")}]`
42055
+ );
42056
+ send(404, "Not Found");
42057
+ return;
42058
+ }
41919
42059
  const transportLine = requestText.match(/Transport:\s*([^\r\n]+)/i)?.[1] ?? "";
41920
42060
  this.logger.info?.(
41921
- `[BaichuanRtspBackchannelServer] SETUP transport="${transportLine}" client=${clientId}`
42061
+ `[BaichuanRtspBackchannelServer] SETUP transport="${transportLine}" client=${clientId} path=${resolved.path}`
41922
42062
  );
41923
42063
  if (!transportLine.toUpperCase().includes("RTP/AVP/TCP") && !transportLine.toLowerCase().includes("interleaved")) {
41924
42064
  this.logger.warn?.(
@@ -41939,10 +42079,12 @@ ${sdp.trimEnd()}`
41939
42079
  clientId,
41940
42080
  socket,
41941
42081
  rtpChannel: interleaved.rtp,
41942
- rtcpChannel: interleaved.rtcp
42082
+ rtcpChannel: interleaved.rtcp,
42083
+ route: resolved.route,
42084
+ routePath: resolved.path
41943
42085
  });
41944
42086
  this.logger.info?.(
41945
- `[BaichuanRtspBackchannelServer] SETUP ok client=${clientId} session=${sessionId} interleaved=${interleaved.rtp}-${interleaved.rtcp}`
42087
+ `[BaichuanRtspBackchannelServer] SETUP ok client=${clientId} session=${sessionId} interleaved=${interleaved.rtp}-${interleaved.rtcp} path=${resolved.path}`
41946
42088
  );
41947
42089
  send(200, "OK", {
41948
42090
  Transport: `RTP/AVP/TCP;unicast;interleaved=${interleaved.rtp}-${interleaved.rtcp};mode=record`,
@@ -41968,10 +42110,10 @@ ${sdp.trimEnd()}`
41968
42110
  send(200, "OK", { Session: session.sessionId });
41969
42111
  return;
41970
42112
  }
41971
- const apiRef = this.api;
41972
- const channelForCamera = this.channel;
42113
+ const apiRef = session.route.api;
42114
+ const channelForCamera = session.route.channel;
41973
42115
  const loggerRef = this.logger;
41974
- const deviceIdRef = this.deviceId ?? `rtsp-backchannel-${clientId}`;
42116
+ const deviceIdRef = session.route.deviceId ?? `rtsp-backchannel-${clientId}`;
41975
42117
  const handler = new RtspBackchannel({
41976
42118
  openTalkSession: () => apiRef.createDedicatedTalkSession(channelForCamera, {
41977
42119
  deviceId: deviceIdRef,
@@ -41981,7 +42123,7 @@ ${sdp.trimEnd()}`
41981
42123
  });
41982
42124
  const recordStart = Date.now();
41983
42125
  this.logger.info?.(
41984
- `[BaichuanRtspBackchannelServer] RECORD opening TalkSession client=${clientId} session=${session.sessionId} channel=${channelForCamera} deviceId="${deviceIdRef}"`
42126
+ `[BaichuanRtspBackchannelServer] RECORD opening TalkSession client=${clientId} session=${session.sessionId} path=${session.routePath} channel=${channelForCamera} deviceId="${deviceIdRef}"`
41985
42127
  );
41986
42128
  try {
41987
42129
  const talk = await handler.start();