@torrent-tv/proxy 2.0.1 → 2.2.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.
@@ -1,9 +1,19 @@
1
+ /**
2
+ * @file HLS transcode session manager.
3
+ *
4
+ * Spawns one ffmpeg process per unique source+settings combination and
5
+ * streams the resulting HLS playlist and segments from a temporary directory.
6
+ * Sessions are expired automatically via a periodic cleanup interval, or
7
+ * immediately when all registered consumers release them.
8
+ */
9
+
1
10
  import { createReadStream } from "node:fs";
2
11
  import { access, mkdir, readdir, readFile, rm } from "node:fs/promises";
3
12
  import os from "node:os";
4
13
  import path from "node:path";
5
14
  import { randomUUID } from "node:crypto";
6
15
  import { spawn } from "node:child_process";
16
+ import { logger } from "../utils/logger.js";
7
17
 
8
18
  const PLAYLIST_FILE_NAME = "index.m3u8";
9
19
  const SEGMENT_FILE_NAME_PATTERN = /^segment-\d{5}\.ts$/;
@@ -17,12 +27,25 @@ const VIDEO_TRANSCODE_PRESET = "superfast";
17
27
  const VIDEO_TRANSCODE_CRF = "24";
18
28
  const VIDEO_TRANSCODE_FPS = 24;
19
29
 
30
+ /**
31
+ * Resolve after a given number of milliseconds.
32
+ *
33
+ * @param {number} ms
34
+ * @returns {Promise<void>}
35
+ */
20
36
  function delay(ms) {
21
37
  return new Promise((resolve) => {
22
38
  setTimeout(resolve, ms);
23
39
  });
24
40
  }
25
41
 
42
+ /**
43
+ * Wait for a child process to exit, with a hard timeout fallback.
44
+ *
45
+ * @param {import("node:child_process").ChildProcess} child
46
+ * @param {number} [timeoutMs=2000]
47
+ * @returns {Promise<void>}
48
+ */
26
49
  function waitForChildExit(child, timeoutMs = 2_000) {
27
50
  return new Promise((resolve) => {
28
51
  let settled = false;
@@ -38,6 +61,13 @@ function waitForChildExit(child, timeoutMs = 2_000) {
38
61
  });
39
62
  }
40
63
 
64
+ /**
65
+ * Convert a bind-all host address to the loopback address so that
66
+ * the HLS input URL is always reachable from the same machine.
67
+ *
68
+ * @param {string} host
69
+ * @returns {string}
70
+ */
41
71
  function toLoopbackHost(host) {
42
72
  if (host === "0.0.0.0" || host === "::") {
43
73
  return "127.0.0.1";
@@ -45,6 +75,13 @@ function toLoopbackHost(host) {
45
75
  return host;
46
76
  }
47
77
 
78
+ /**
79
+ * Build the HTTP base URL (scheme + host + port) for the local proxy server.
80
+ *
81
+ * @param {string} host - Bind host (may be "0.0.0.0" or "::").
82
+ * @param {number} port
83
+ * @returns {string} e.g. "http://127.0.0.1:9090"
84
+ */
48
85
  function buildHttpBaseUrl(host, port) {
49
86
  const url = new URL("http://localhost");
50
87
  url.hostname = toLoopbackHost(host);
@@ -52,18 +89,44 @@ function buildHttpBaseUrl(host, port) {
52
89
  return url.origin;
53
90
  }
54
91
 
92
+ /**
93
+ * Return the temporary directory path for a given HLS session.
94
+ *
95
+ * @param {string} sessionId - UUID of the session.
96
+ * @returns {string}
97
+ */
55
98
  function createSessionDirPath(sessionId) {
56
99
  return path.join(os.tmpdir(), "torrent-tv-hls", sessionId);
57
100
  }
58
101
 
102
+ /**
103
+ * Guard against path traversal by validating that a session ID is a UUID.
104
+ *
105
+ * @param {unknown} value
106
+ * @returns {boolean}
107
+ */
59
108
  function isSafeSessionId(value) {
60
109
  return /^[a-f0-9-]{36}$/i.test(value);
61
110
  }
62
111
 
112
+ /**
113
+ * Guard against path traversal by restricting file names to the known
114
+ * playlist and segment patterns produced by ffmpeg.
115
+ *
116
+ * @param {string} fileName
117
+ * @returns {boolean}
118
+ */
63
119
  function isSafeFileName(fileName) {
64
120
  return fileName === PLAYLIST_FILE_NAME || SEGMENT_FILE_NAME_PATTERN.test(fileName);
65
121
  }
66
122
 
123
+ /**
124
+ * Parse an ffmpeg `HH:MM:SS.mmm` timestamp string into total seconds.
125
+ * Returns `null` if the value is absent or malformed.
126
+ *
127
+ * @param {string | undefined} value
128
+ * @returns {number | null}
129
+ */
67
130
  function parseFfmpegTimestamp(value) {
68
131
  if (!value || typeof value !== "string") {
69
132
  return null;
@@ -81,6 +144,13 @@ function parseFfmpegTimestamp(value) {
81
144
  return hours * 3600 + minutes * 60 + seconds;
82
145
  }
83
146
 
147
+ /**
148
+ * Extract the total duration in seconds from ffmpeg stderr output.
149
+ * Returns `null` if the duration line is absent or unparseable.
150
+ *
151
+ * @param {string} stderrText
152
+ * @returns {number | null}
153
+ */
84
154
  function parseFfmpegDurationSeconds(stderrText) {
85
155
  if (typeof stderrText !== "string" || stderrText.length === 0) {
86
156
  return null;
@@ -98,6 +168,12 @@ function parseFfmpegDurationSeconds(stderrText) {
98
168
  return hours * 3600 + minutes * 60 + seconds;
99
169
  }
100
170
 
171
+ /**
172
+ * Format a seconds value as `HH:MM:SS`, or `"n/a"` if not finite.
173
+ *
174
+ * @param {number} seconds
175
+ * @returns {string}
176
+ */
101
177
  function formatSeconds(seconds) {
102
178
  if (!Number.isFinite(seconds) || seconds < 0) {
103
179
  return "n/a";
@@ -109,6 +185,13 @@ function formatSeconds(seconds) {
109
185
  return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(rest).padStart(2, "0")}`;
110
186
  }
111
187
 
188
+ /**
189
+ * Compute derived progress metrics from raw ffmpeg output values.
190
+ *
191
+ * @param {number} processedSeconds - Seconds of content encoded so far.
192
+ * @param {number | null} totalSeconds - Total duration, or `null` if unknown.
193
+ * @returns {{ totalSeconds: number | null, percent: number | null, remainingSeconds: number | null, processedSeconds: number }}
194
+ */
112
195
  function computeProgressMetrics(processedSeconds, totalSeconds) {
113
196
  const processed = Number.isFinite(processedSeconds) ? Math.max(0, processedSeconds) : 0;
114
197
  if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) {
@@ -125,6 +208,14 @@ function computeProgressMetrics(processedSeconds, totalSeconds) {
125
208
  };
126
209
  }
127
210
 
211
+ /**
212
+ * Run a short ffmpeg probe to extract the total duration of a stream.
213
+ * Times out after 8 s and returns `null` on failure.
214
+ *
215
+ * @param {string} ffmpegBin - Path to the ffmpeg executable.
216
+ * @param {string | URL} inputUrl - URL of the stream to probe.
217
+ * @returns {Promise<number | null>} Duration in seconds, or `null`.
218
+ */
128
219
  async function probeInputDurationSeconds(ffmpegBin, inputUrl) {
129
220
  return new Promise((resolve) => {
130
221
  const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
@@ -179,7 +270,43 @@ function normalizeLogFileName(fileName, fileIndex) {
179
270
  return value;
180
271
  }
181
272
 
273
+ /**
274
+ * @typedef {Object} HlsSessionManagerOptions
275
+ * @property {boolean} enabled - Whether HLS transcoding is enabled.
276
+ * @property {string} ffmpegBin - Path to the ffmpeg executable.
277
+ * @property {string} localBindHost - Host the proxy HTTP server is bound to.
278
+ * @property {number} localPort - Port the proxy HTTP server is listening on.
279
+ * @property {number} [segmentDurationSec] - HLS segment length in seconds.
280
+ * @property {number} [sessionTtlMs] - Session idle TTL in milliseconds.
281
+ * @property {number} [startupWaitMs] - Max time to wait for the first playlist file.
282
+ */
283
+
284
+ /**
285
+ * @typedef {Object} HlsSession
286
+ * @property {string} id - UUID of the session.
287
+ * @property {string} sourceMapKey - Cache key combining source + transcode settings.
288
+ * @property {string} fileName - Display name of the file being transcoded.
289
+ * @property {string} dirPath - Temp directory containing HLS output.
290
+ * @property {"starting" | "ready" | "failed" | "disposed"} state
291
+ * @property {number} startedAt - Unix ms timestamp when the session was created.
292
+ * @property {number} lastAccessedAt - Unix ms timestamp of the last consumer access.
293
+ * @property {import("node:child_process").ChildProcess} ffmpeg
294
+ * @property {string} lastError
295
+ * @property {Set<string>} consumers - Consumer IDs currently using this session.
296
+ * @property {object} progress - Live progress metrics updated from ffmpeg stdout.
297
+ */
298
+
299
+ /**
300
+ * Manages HLS transcode sessions backed by ffmpeg child processes.
301
+ *
302
+ * One session is created per unique (source, fileIndex, transcode settings)
303
+ * combination. Sessions are reused across consumers and are automatically
304
+ * expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
305
+ */
182
306
  export class HlsSessionManager {
307
+ /**
308
+ * @param {HlsSessionManagerOptions} options
309
+ */
183
310
  constructor({
184
311
  enabled,
185
312
  ffmpegBin,
@@ -203,6 +330,24 @@ export class HlsSessionManager {
203
330
  this.cleanupTimer.unref();
204
331
  }
205
332
 
333
+ /**
334
+ * Return an existing HLS session for the given source/settings, or create
335
+ * one by spawning a new ffmpeg process.
336
+ *
337
+ * Throws with `error.code === "TRANSCODE_DISABLED"` when transcoding is
338
+ * disabled on this proxy instance.
339
+ *
340
+ * @param {object} options
341
+ * @param {string} options.sourceKey - Registry source key.
342
+ * @param {number} options.fileIndex - Zero-based file index in the torrent.
343
+ * @param {boolean} [options.transcodeVideo=false]
344
+ * @param {boolean} [options.transcodeAudio=false]
345
+ * @param {string} [options.consumerId=""] - Caller ID for reference counting.
346
+ * @param {string} [options.fileName=""] - Display name for log output.
347
+ * @param {number} [options.targetWidth=0] - Target video width (0 = keep source).
348
+ * @param {number} [options.targetHeight=0] - Target video height (0 = keep source).
349
+ * @returns {Promise<HlsSession>}
350
+ */
206
351
  async createOrGetSession({
207
352
  sourceKey,
208
353
  fileIndex,
@@ -377,8 +522,8 @@ export class HlsSessionManager {
377
522
  session.progress.updatedAt - session.progress.lastLoggedAt >= PROGRESS_LOG_INTERVAL_MS;
378
523
  if (shouldLog) {
379
524
  session.progress.lastLoggedAt = session.progress.updatedAt;
380
- console.log(
381
- `[proxy-client] transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
525
+ logger.info(
526
+ `transcode ${session.id} "${session.fileName}" ${session.progress.percent.toFixed(1)}% ` +
382
527
  `(${formatSeconds(session.progress.processedSeconds)} / ${formatSeconds(session.progress.totalSeconds)})` +
383
528
  ` speed=${session.progress.speed || "n/a"}`
384
529
  );
@@ -390,7 +535,7 @@ export class HlsSessionManager {
390
535
  const line = String(chunk).trim();
391
536
  if (line.length > 0) {
392
537
  session.lastError = line;
393
- console.warn(`[proxy-client] ffmpeg: ${line}`);
538
+ logger.warn(`ffmpeg: ${line}`);
394
539
  }
395
540
  });
396
541
 
@@ -399,7 +544,7 @@ export class HlsSessionManager {
399
544
  session.lastError = error instanceof Error ? error.message : String(error);
400
545
  session.progress.state = "failed";
401
546
  session.progress.updatedAt = Date.now();
402
- console.error(`[proxy-client] ffmpeg process error: ${session.lastError}`);
547
+ logger.error(`ffmpeg process error: ${session.lastError}`);
403
548
  });
404
549
 
405
550
  ffmpeg.on("exit", (code) => {
@@ -439,6 +584,14 @@ export class HlsSessionManager {
439
584
  return `scale=${safeWidth}:${safeHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2,fps=${VIDEO_TRANSCODE_FPS}`;
440
585
  }
441
586
 
587
+ /**
588
+ * Poll until the HLS playlist file exists and contains a valid `#EXTM3U`
589
+ * header, or until the session fails, or until the startup timeout elapses.
590
+ * Throws with message `"HLS playlist is still warming up."` on timeout.
591
+ *
592
+ * @param {HlsSession} session
593
+ * @returns {Promise<void>}
594
+ */
442
595
  async waitUntilReady(session) {
443
596
  const playlistPath = path.join(session.dirPath, PLAYLIST_FILE_NAME);
444
597
  const deadline = Date.now() + this.startupWaitMs;
@@ -463,6 +616,18 @@ export class HlsSessionManager {
463
616
  throw new Error("HLS playlist is still warming up.");
464
617
  }
465
618
 
619
+ /**
620
+ * Open a read stream for an HLS segment or playlist file from a session.
621
+ *
622
+ * @param {string} sessionId
623
+ * @param {string} fileName - Must match the playlist or segment name pattern.
624
+ * @returns {Promise<
625
+ * | { kind: "not-found" }
626
+ * | { kind: "warming-up" }
627
+ * | { kind: "failed"; message: string }
628
+ * | { kind: "file"; stream: import("node:fs").ReadStream; contentType: string; isPlaylist: boolean }
629
+ * >}
630
+ */
466
631
  async getFileStream(sessionId, fileName) {
467
632
  if (!isSafeSessionId(sessionId) || !isSafeFileName(fileName)) {
468
633
  return { kind: "not-found" };
@@ -495,6 +660,12 @@ export class HlsSessionManager {
495
660
  };
496
661
  }
497
662
 
663
+ /**
664
+ * Dispose all sessions that have been idle longer than `sessionTtlMs`.
665
+ * Called automatically on the cleanup interval.
666
+ *
667
+ * @returns {Promise<void>}
668
+ */
498
669
  async cleanupExpired() {
499
670
  const now = Date.now();
500
671
  const idsToDispose = [];
@@ -508,6 +679,13 @@ export class HlsSessionManager {
508
679
  }
509
680
  }
510
681
 
682
+ /**
683
+ * Return a progress snapshot for the given session, or `null` if not found.
684
+ * Also refreshes `lastAccessedAt` to prevent the session from expiring.
685
+ *
686
+ * @param {string} sessionId
687
+ * @returns {object | null}
688
+ */
511
689
  getSessionProgress(sessionId) {
512
690
  if (!isSafeSessionId(sessionId)) {
513
691
  return null;
@@ -541,6 +719,15 @@ export class HlsSessionManager {
541
719
  };
542
720
  }
543
721
 
722
+ /**
723
+ * Remove a consumer from a session. Disposes the session when the last
724
+ * consumer leaves.
725
+ *
726
+ * @param {string} sessionId
727
+ * @param {string} [consumerId=""]
728
+ * @param {string} [reason=""] - Human-readable reason shown in logs.
729
+ * @returns {Promise<boolean>} `false` if the session was not found.
730
+ */
544
731
  async releaseSessionConsumer(sessionId, consumerId = "", reason = "") {
545
732
  if (!isSafeSessionId(sessionId) || typeof consumerId !== "string" || consumerId.length === 0) {
546
733
  return false;
@@ -555,8 +742,8 @@ export class HlsSessionManager {
555
742
  session.consumers.delete(consumerId);
556
743
  session.lastAccessedAt = Date.now();
557
744
  const logReason = typeof reason === "string" && reason.length > 0 ? reason : "unspecified";
558
- console.log(
559
- `[proxy-client] consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
745
+ logger.info(
746
+ `consumer released (${logReason}) session=${session.id} consumer=${consumerId} ` +
560
747
  `remaining=${session.consumers.size}`
561
748
  );
562
749
  if (session.consumers.size > 0) {
@@ -566,6 +753,12 @@ export class HlsSessionManager {
566
753
  return true;
567
754
  }
568
755
 
756
+ /**
757
+ * Kill the ffmpeg process, remove it from all maps, and delete the temp dir.
758
+ *
759
+ * @param {string} sessionId
760
+ * @returns {Promise<void>}
761
+ */
569
762
  async disposeSession(sessionId) {
570
763
  const session = this.sessionsById.get(sessionId);
571
764
  if (!session) {
@@ -583,10 +776,17 @@ export class HlsSessionManager {
583
776
  await rm(session.dirPath, { recursive: true, force: true });
584
777
  } catch (error) {
585
778
  const message = error instanceof Error ? error.message : String(error);
586
- console.warn(`[proxy-client] failed to cleanup HLS temp dir: ${message}`);
779
+ logger.warn(`failed to cleanup HLS temp dir: ${message}`);
587
780
  }
588
781
  }
589
782
 
783
+ /**
784
+ * Stop the cleanup timer, dispose all active sessions, and attempt to
785
+ * remove the shared temp root directory if it is empty.
786
+ * Called by Fastify's `onClose` hook during graceful shutdown.
787
+ *
788
+ * @returns {Promise<void>}
789
+ */
590
790
  async disposeAll() {
591
791
  clearInterval(this.cleanupTimer);
592
792
  const activeIds = Array.from(this.sessionsById.keys());
@@ -1,7 +1,22 @@
1
+ /**
2
+ * @file Playback planner service.
3
+ *
4
+ * Determines whether a torrent file can be served directly or requires
5
+ * HLS audio transcoding by probing the stream codecs with ffmpeg.
6
+ * Results are cached indefinitely (keyed by source + file index).
7
+ */
8
+
1
9
  import { spawn } from "node:child_process";
2
10
 
11
+ /** Audio codecs that browsers can decode natively without transcoding. */
3
12
  const DIRECT_AUDIO_CODECS = new Set(["aac", "mp3", "opus", "vorbis", "flac"]);
4
13
 
14
+ /**
15
+ * Parse audio and video codec names from ffmpeg stderr output.
16
+ *
17
+ * @param {string} ffmpegOutput
18
+ * @returns {{ audioCodec: string, videoCodec: string }}
19
+ */
5
20
  function parseStreamCodecs(ffmpegOutput) {
6
21
  const audioMatch = ffmpegOutput.match(/Audio:\s*([A-Za-z0-9_]+)/i);
7
22
  const videoMatch = ffmpegOutput.match(/Video:\s*([A-Za-z0-9_]+)/i);
@@ -11,6 +26,17 @@ function parseStreamCodecs(ffmpegOutput) {
11
26
  };
12
27
  }
13
28
 
29
+ /**
30
+ * Run a brief ffmpeg probe to identify the audio and video codecs of a stream.
31
+ * Times out after `timeoutMs` and returns empty strings on failure.
32
+ *
33
+ * @param {object} options
34
+ * @param {string} options.ffmpegBin
35
+ * @param {string} options.inputUrl
36
+ * @param {string} [options.userAgent=""]
37
+ * @param {number} [options.timeoutMs=8000]
38
+ * @returns {Promise<{ audioCodec: string, videoCodec: string }>}
39
+ */
14
40
  function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_000 }) {
15
41
  return new Promise((resolve) => {
16
42
  const args = ["-hide_banner", "-loglevel", "info"];
@@ -57,6 +83,14 @@ function probeStreamCodecs({ ffmpegBin, inputUrl, userAgent = "", timeoutMs = 8_
57
83
  });
58
84
  }
59
85
 
86
+ /**
87
+ * Build the direct stream URL for a source file served by the local proxy.
88
+ *
89
+ * @param {string} localBaseUrl - e.g. "http://127.0.0.1:9090"
90
+ * @param {string} sourceKey
91
+ * @param {number} fileIndex
92
+ * @returns {string}
93
+ */
60
94
  function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
61
95
  const directUrl = new URL("/stream", `${localBaseUrl}/`);
62
96
  directUrl.searchParams.set("sourceKey", sourceKey);
@@ -64,6 +98,31 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
64
98
  return directUrl.toString();
65
99
  }
66
100
 
101
+ /**
102
+ * @typedef {Object} PlaybackPlan
103
+ * @property {"direct" | "hls"} mode
104
+ * @property {string} directUrl
105
+ * @property {string} reason - Human-readable explanation of the chosen mode.
106
+ * @property {string} audioCodec
107
+ * @property {string} videoCodec
108
+ */
109
+
110
+ /**
111
+ * @typedef {Object} PlaybackPlannerOptions
112
+ * @property {string} ffmpegBin
113
+ * @property {boolean} transcodeAudioEnabled
114
+ * @property {string} localBaseUrl
115
+ * @property {ReturnType<import("../store/source-registry.js").createSourceRegistry>} sourceRegistry
116
+ * @property {import("./torrent-pool.js").TorrentPool} torrentPool
117
+ */
118
+
119
+ /**
120
+ * Create a playback planner that decides the optimal streaming mode for
121
+ * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
122
+ *
123
+ * @param {PlaybackPlannerOptions} options
124
+ * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
125
+ */
67
126
  export function createPlaybackPlanner({
68
127
  ffmpegBin,
69
128
  transcodeAudioEnabled,
@@ -71,9 +130,21 @@ export function createPlaybackPlanner({
71
130
  sourceRegistry,
72
131
  torrentPool
73
132
  }) {
133
+ /** @type {Map<string, PlaybackPlan>} */
74
134
  const cache = new Map();
75
135
 
76
136
  return {
137
+ /**
138
+ * Return the playback plan for the given source file.
139
+ * Throws with `error.code === "SOURCE_NOT_FOUND"` or `"FILE_NOT_FOUND"`
140
+ * when the source or file cannot be located.
141
+ *
142
+ * @param {object} params
143
+ * @param {string} params.sourceKey
144
+ * @param {number} params.fileIndex
145
+ * @param {string} [params.userAgent=""]
146
+ * @returns {Promise<PlaybackPlan>}
147
+ */
77
148
  async getPlan({ sourceKey, fileIndex, userAgent = "" }) {
78
149
  const cacheKey = `${sourceKey}:${fileIndex}`;
79
150
  const cached = cache.get(cacheKey);
@@ -1,21 +1,56 @@
1
+ /**
2
+ * @file HTTP client for the registry server API.
3
+ *
4
+ * Handles proxy registration and periodic heartbeat requests.
5
+ * The auth token is sent as the `x-proxy-token` request header.
6
+ */
7
+
8
+ /**
9
+ * Build a full URL to a registry API endpoint.
10
+ *
11
+ * @param {string} serverUrl - Base URL of the registry server.
12
+ * @param {string} pathname - Relative path (e.g. "api/proxy-clients/register").
13
+ * @returns {URL}
14
+ */
1
15
  function buildRegistryUrl(serverUrl, pathname) {
2
16
  return new URL(pathname, ensureBaseUrl(serverUrl));
3
17
  }
4
18
 
19
+ /**
20
+ * Ensure the server URL ends with a trailing slash so that relative
21
+ * paths in `new URL(pathname, base)` resolve correctly.
22
+ *
23
+ * @param {string} serverUrl
24
+ * @returns {string}
25
+ */
5
26
  function ensureBaseUrl(serverUrl) {
6
27
  return serverUrl.endsWith("/") ? serverUrl : `${serverUrl}/`;
7
28
  }
8
29
 
30
+ /**
31
+ * @typedef {Object} RegisterClientParams
32
+ * @property {string} serverUrl - Base URL of the registry server.
33
+ * @property {string} id - Stable unique identifier for this proxy.
34
+ * @property {string} name - Human-readable display name.
35
+ * @property {string} baseUrl - Publicly reachable base URL of this proxy.
36
+ * @property {string} token - Auth token sent as `x-proxy-token`.
37
+ */
38
+
39
+ /**
40
+ * Register this proxy with the registry server.
41
+ * Throws if the server responds with a non-2xx status.
42
+ *
43
+ * @param {RegisterClientParams} params
44
+ * @returns {Promise<{ client: { id: string, name: string, baseUrl: string, createdAt: string, lastSeenAt: string } }>}
45
+ */
9
46
  export async function registerClient({ serverUrl, id, name, baseUrl, token }) {
10
47
  const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/register"), {
11
48
  method: "POST",
12
- headers: { "Content-Type": "application/json" },
13
- body: JSON.stringify({
14
- id,
15
- name,
16
- baseUrl,
17
- token
18
- })
49
+ headers: {
50
+ "Content-Type": "application/json",
51
+ "x-proxy-token": token
52
+ },
53
+ body: JSON.stringify({ id, name, baseUrl })
19
54
  });
20
55
 
21
56
  if (!response.ok) {
@@ -26,15 +61,30 @@ export async function registerClient({ serverUrl, id, name, baseUrl, token }) {
26
61
  return response.json();
27
62
  }
28
63
 
64
+ /**
65
+ * @typedef {Object} SendHeartbeatParams
66
+ * @property {string} serverUrl - Base URL of the registry server.
67
+ * @property {string} id - Proxy ID to refresh.
68
+ * @property {string} token - Auth token sent as `x-proxy-token`.
69
+ */
70
+
71
+ /**
72
+ * Send a heartbeat to the registry server to refresh `lastSeenAt`.
73
+ * Returns the HTTP status code, or `null` if the request failed entirely
74
+ * (e.g. network error).
75
+ *
76
+ * @param {SendHeartbeatParams} params
77
+ * @returns {Promise<number | null>}
78
+ */
29
79
  export async function sendHeartbeat({ serverUrl, id, token }) {
30
80
  try {
31
81
  const response = await fetch(buildRegistryUrl(serverUrl, "api/proxy-clients/heartbeat"), {
32
82
  method: "POST",
33
- headers: { "Content-Type": "application/json" },
34
- body: JSON.stringify({
35
- id,
36
- token
37
- })
83
+ headers: {
84
+ "Content-Type": "application/json",
85
+ "x-proxy-token": token
86
+ },
87
+ body: JSON.stringify({ id })
38
88
  });
39
89
  return response.status;
40
90
  } catch (_error) {