@vextrix3d/machine-agent 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,46 +5,8 @@ import { join } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
6
  import { ensureCameraBinaries, findCameraBinaryPins } from './camera-binaries.js';
7
7
  import { CAMERA_GATEWAY_PORT, CAMERA_REGISTRY_DIR, createCameraGateway, createCameraRegistry, } from './camera-gateway.js';
8
- import { createCameraJpegBridge, } from './camera-jpeg-bridge.js';
9
- import { createCameraStreamBridge, } from './camera-stream-bridge.js';
10
8
  import { resolveCameraTunnel } from './camera-tunnel.js';
11
9
  import { backoffDelayMs, isExecutable, isPortFree, readArmVersion, stopChildProcess, } from './utils.js';
12
- export const readCameraStream = (ipcam) => ipcam === undefined ? undefined : (ipcam.rtsp_url ?? null);
13
- const parseCameraStreamUrl = (rtspUrl) => {
14
- if (!rtspUrl) {
15
- return null;
16
- }
17
- let url;
18
- try {
19
- url = new URL(rtspUrl);
20
- }
21
- catch {
22
- return null;
23
- }
24
- return url.protocol === 'rtsps:' || url.protocol === 'rtsp:' ? url : null;
25
- };
26
- export const hasCameraStream = (rtspUrl, jpegCamera = null) => (jpegCamera ? rtspUrl !== undefined : parseCameraStreamUrl(rtspUrl) !== null);
27
- export const readCameraStreamTarget = (rtspUrl) => {
28
- const url = parseCameraStreamUrl(rtspUrl);
29
- return url?.protocol === 'rtsps:'
30
- ? {
31
- host: url.hostname,
32
- port: Number(url.port) || 322,
33
- }
34
- : null;
35
- };
36
- export const buildCameraStreamUrl = (rtspUrl, accessCode, bridgePort) => {
37
- const url = parseCameraStreamUrl(rtspUrl);
38
- if (!url) {
39
- return null;
40
- }
41
- const streamUrl = url.protocol === 'rtsps:'
42
- ? new URL(`rtsp://127.0.0.1:${bridgePort}${url.pathname}${url.search}`)
43
- : url;
44
- streamUrl.username = 'bblp';
45
- streamUrl.password = accessCode;
46
- return streamUrl.toString();
47
- };
48
10
  const quoteFfconcat = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
49
11
  export const buildCameraFile = ({ streamUrl, kind, readsOptions }) => [
50
12
  'ffconcat version 1.0',
@@ -74,22 +36,57 @@ export const findFreeCameraRelayPorts = async (isPortFree) => {
74
36
  }
75
37
  return null;
76
38
  };
77
- const buildJpegEncoding = (ffmpegFpsModeFlag) => [
39
+ const buildH264Encoding = ({ ffmpegFpsModeFlag, fps, filters, maxrateKbps, }) => [
40
+ ...(filters ? [`-vf ${filters}`] : []),
78
41
  '-c:v libx264 -preset ultrafast -tune zerolatency -pix_fmt yuv420p',
42
+ ...(maxrateKbps ? [`-maxrate ${maxrateKbps}k -bufsize ${maxrateKbps}k`] : []),
79
43
  '-force_key_frames expr:gte(t,n_forced*2)',
80
- `${ffmpegFpsModeFlag} cfr -r 2`,
44
+ `${ffmpegFpsModeFlag} cfr -r ${fps}`,
81
45
  ].join(' ');
82
- export const buildGo2rtcConfig = ({ printerId, cameraFile, kind, ffmpegFpsModeFlag, ports, }) => `${JSON.stringify({
46
+ export const WEBCAM_MAX_FPS = 10;
47
+ const WEBCAM_MAX_HEIGHT = 720;
48
+ export const buildWebcamFilters = (orientation) => [
49
+ `scale=-2:'min(ih,${WEBCAM_MAX_HEIGHT})'`,
50
+ ...(orientation?.rotation === 90 ? ['transpose=clock'] : []),
51
+ ...(orientation?.rotation === 180 ? ['hflip,vflip'] : []),
52
+ ...(orientation?.rotation === 270 ? ['transpose=cclock'] : []),
53
+ ...(orientation?.flipHorizontal ? ['hflip'] : []),
54
+ ...(orientation?.flipVertical ? ['vflip'] : []),
55
+ ].join(',');
56
+ const PROTOCOL_WHITELISTS = {
57
+ rtsp: 'file,rtsp,tcp,udp,rtp',
58
+ jpeg: 'file,http,tcp',
59
+ mjpeg: 'file,http,tcp',
60
+ h264: 'file,http,https,tcp,tls',
61
+ };
62
+ export const buildFfmpegCommand = ({ cameraFile, kind, orientation, ffmpegFpsModeFlag, }) => {
63
+ const encodings = {
64
+ rtsp: '-c copy',
65
+ jpeg: buildH264Encoding({
66
+ ffmpegFpsModeFlag,
67
+ fps: 2,
68
+ }),
69
+ mjpeg: buildH264Encoding({
70
+ ffmpegFpsModeFlag,
71
+ fps: WEBCAM_MAX_FPS,
72
+ filters: buildWebcamFilters(orientation),
73
+ maxrateKbps: 1500,
74
+ }),
75
+ h264: '-c copy',
76
+ };
77
+ return [
78
+ 'exec:ffmpeg -hide_banner -loglevel error',
79
+ '-fflags nobuffer -analyzeduration 1000000 -probesize 1000000',
80
+ '-use_wallclock_as_timestamps 1',
81
+ `-f concat -safe 0 -protocol_whitelist ${PROTOCOL_WHITELISTS[kind]}`,
82
+ `-i ${cameraFile}`,
83
+ `-map 0:v ${encodings[kind]}`,
84
+ '-rtsp_transport tcp -f rtsp {output}',
85
+ ].join(' ');
86
+ };
87
+ export const buildGo2rtcConfig = ({ printerId, ports, ffmpegCommand, }) => `${JSON.stringify({
83
88
  streams: {
84
- [printerId]: [
85
- 'exec:ffmpeg -hide_banner -loglevel error',
86
- '-fflags nobuffer -analyzeduration 1000000 -probesize 1000000',
87
- '-use_wallclock_as_timestamps 1',
88
- `-f concat -safe 0 -protocol_whitelist ${kind === 'jpeg' ? 'file,http,tcp' : 'file,rtsp,tcp,udp,rtp'}`,
89
- `-i ${cameraFile}`,
90
- `-map 0:v ${kind === 'jpeg' ? buildJpegEncoding(ffmpegFpsModeFlag) : '-c copy'}`,
91
- '-rtsp_transport tcp -f rtsp {output}',
92
- ].join(' '),
89
+ [printerId]: ffmpegCommand,
93
90
  },
94
91
  api: {
95
92
  listen: `127.0.0.1:${ports.apiPort}`,
@@ -126,54 +123,28 @@ const defaultDependencies = {
126
123
  chmodSync(path, 0o600);
127
124
  },
128
125
  findFreePorts: () => findFreeCameraRelayPorts(isPortFree),
129
- createStreamBridge: options => createCameraStreamBridge(options),
130
- createJpegBridge: options => createCameraJpegBridge(options),
131
126
  };
132
- export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialNumber, go2rtcPath, runtimeDir, caFile, ffmpegReadsOptions, ffmpegFpsModeFlag, jpegCamera, relayKey, registry, }, dependencies = defaultDependencies) => {
127
+ export const createCameraRelay = ({ printerId, printerLabel, go2rtcPath, runtimeDir, ffmpegReadsOptions, ffmpegFpsModeFlag, source, relayKey, registry, }, dependencies = defaultDependencies) => {
133
128
  const configPath = join(runtimeDir, 'go2rtc.json');
134
129
  const cameraFilePath = join(runtimeDir, 'stream.ffconcat');
135
- const kind = jpegCamera ? 'jpeg' : 'rtsp';
136
- const createInput = () => {
137
- if (jpegCamera) {
138
- const bridge = dependencies.createJpegBridge({
139
- host: jpegCamera.host,
140
- port: jpegCamera.port,
141
- serialNumber,
142
- accessCode,
143
- caFile,
144
- printerLabel,
145
- });
146
- return {
147
- resolveStreamUrl: () => bridge.streamUrl,
148
- close: bridge.close,
149
- };
150
- }
151
- const bridge = dependencies.createStreamBridge({
152
- serialNumber,
153
- caFile,
154
- printerLabel,
155
- });
156
- return {
157
- resolveStreamUrl: async (rtspUrl) => {
158
- const target = readCameraStreamTarget(rtspUrl);
159
- if (target) {
160
- bridge.setTarget(target.host, target.port);
161
- }
162
- return buildCameraStreamUrl(rtspUrl, accessCode, await bridge.port);
163
- },
164
- close: bridge.close,
165
- };
166
- };
167
- const input = createInput();
130
+ const input = source.openInput();
168
131
  let currentRelayKey = relayKey;
169
- let reportedUrl;
132
+ let reported;
133
+ let target = null;
134
+ let startedCommand = null;
170
135
  let go2rtc = null;
171
136
  let registeredPort = null;
172
137
  let restartTimeoutId;
173
138
  let failures = 0;
174
139
  let startedAt = 0;
175
- let hasStarted = false;
140
+ let isRestarting = false;
176
141
  let isStopping = false;
142
+ const buildCommand = ({ kind, orientation }) => buildFfmpegCommand({
143
+ cameraFile: cameraFilePath,
144
+ kind,
145
+ orientation,
146
+ ffmpegFpsModeFlag,
147
+ });
177
148
  const forward = (output, log) => {
178
149
  if (!output) {
179
150
  return;
@@ -191,7 +162,7 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
191
162
  const start = async () => {
192
163
  startedAt = Date.now();
193
164
  const ports = await dependencies.findFreePorts();
194
- if (isStopping) {
165
+ if (isStopping || !target) {
195
166
  return;
196
167
  }
197
168
  if (!ports) {
@@ -199,12 +170,11 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
199
170
  scheduleStart(`No pair of free ports for go2rtc between ${firstApiPort} and ${lastApiPort}`);
200
171
  return;
201
172
  }
173
+ startedCommand = buildCommand(target);
202
174
  dependencies.writePrivateFile(configPath, buildGo2rtcConfig({
203
175
  printerId,
204
- cameraFile: cameraFilePath,
205
- kind,
206
- ffmpegFpsModeFlag,
207
176
  ports,
177
+ ffmpegCommand: startedCommand,
208
178
  }));
209
179
  const child = dependencies.spawn(go2rtcPath, ['-config', configPath]);
210
180
  go2rtc = child;
@@ -218,35 +188,48 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
218
188
  });
219
189
  child.once('exit', (code, signal) => {
220
190
  go2rtc = null;
221
- if (!isStopping) {
222
- scheduleStart(`go2rtc stopped (${signal ?? `exit code ${code}`})`);
191
+ if (isStopping) {
192
+ return;
193
+ }
194
+ if (isRestarting) {
195
+ isRestarting = false;
196
+ void start();
197
+ return;
223
198
  }
199
+ scheduleStart(`go2rtc stopped (${signal ?? `exit code ${code}`})`);
224
200
  });
225
201
  };
226
- const writeCameraFile = async (rtspUrl) => {
227
- const streamUrl = await input.resolveStreamUrl(rtspUrl);
228
- if (isStopping || !streamUrl || rtspUrl !== reportedUrl) {
202
+ const writeCameraFile = async (report) => {
203
+ const nextTarget = await input.resolveTarget(report);
204
+ if (isStopping || !nextTarget || report !== reported) {
229
205
  return;
230
206
  }
231
207
  dependencies.ensurePrivateDir(runtimeDir);
232
208
  dependencies.writePrivateFile(cameraFilePath, buildCameraFile({
233
- streamUrl,
234
- kind,
209
+ streamUrl: nextTarget.streamUrl,
210
+ kind: nextTarget.kind,
235
211
  readsOptions: ffmpegReadsOptions,
236
212
  }));
237
- if (hasStarted) {
213
+ const isFirst = target === null;
214
+ target = nextTarget;
215
+ if (isFirst) {
216
+ void start();
238
217
  return;
239
218
  }
240
- hasStarted = true;
241
- void start();
219
+ if (!go2rtc || isRestarting || buildCommand(nextTarget) === startedCommand) {
220
+ return;
221
+ }
222
+ console.log(`[camera] The camera of "${printerLabel}" is to be read another way; starting go2rtc again for it`);
223
+ isRestarting = true;
224
+ void stopChildProcess(go2rtc);
242
225
  };
243
226
  return {
244
- update: rtspUrl => {
245
- if (isStopping || !hasCameraStream(rtspUrl, jpegCamera) || rtspUrl === reportedUrl) {
227
+ update: report => {
228
+ if (isStopping || !source.hasStream(report) || report === reported) {
246
229
  return;
247
230
  }
248
- reportedUrl = rtspUrl;
249
- void writeCameraFile(rtspUrl).catch(error => {
231
+ reported = report;
232
+ void writeCameraFile(report).catch(error => {
250
233
  console.error('[camera] Could not write the camera file:', error);
251
234
  });
252
235
  },
@@ -259,7 +242,7 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
259
242
  stop: async () => {
260
243
  isStopping = true;
261
244
  clearTimeout(restartTimeoutId);
262
- if (hasStarted) {
245
+ if (target) {
263
246
  registry.unregister(printerId);
264
247
  }
265
248
  await input.close();
@@ -270,7 +253,7 @@ export const createCameraRelay = ({ printerId, printerLabel, accessCode, serialN
270
253
  };
271
254
  };
272
255
  export const fingerprintRelayKey = (relayKey) => createHash('sha256').update(relayKey).digest('hex').slice(0, 16);
273
- const defaultResolveDependencies = {
256
+ const createDefaultResolveDependencies = () => ({
274
257
  readFfmpegVersion: () => {
275
258
  try {
276
259
  return execFileSync('ffmpeg', ['-version'], {
@@ -292,17 +275,16 @@ const defaultResolveDependencies = {
292
275
  }),
293
276
  createRelay: options => createCameraRelay(options),
294
277
  resolveTunnel: options => resolveCameraTunnel(options),
295
- };
278
+ });
296
279
  const CAMERA_ACCESS_REFRESH_MS = 24 * 60 * 60_000;
297
- export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCameraAccess, printerGone, ...options }, dependencies = defaultResolveDependencies) => {
280
+ export const resolveCameraRelay = ({ printerId, printerLabel, source, workDir, fetchesBinaries, getCameraAccess, printerGone, }, dependencies = createDefaultResolveDependencies()) => {
298
281
  const ffmpegVersion = dependencies.readFfmpegVersion();
299
282
  if (ffmpegVersion === null) {
300
283
  console.log('[camera] Relay off: ffmpeg is not installed');
301
284
  return null;
302
285
  }
303
- const { jpegCamera } = options;
304
- if (jpegCamera && !/--enable-libx264\b/.test(ffmpegVersion)) {
305
- console.log(`[camera] Relay off: ffmpeg has no libx264, which a Bambu ${jpegCamera.model}'s camera is encoded to H.264 with`);
286
+ if (source.encodedCamera && !/--enable-libx264\b/.test(ffmpegVersion)) {
287
+ console.log(`[camera] Relay off: ffmpeg has no libx264, which ${source.encodedCamera} is encoded to H.264 with`);
306
288
  return null;
307
289
  }
308
290
  const ffmpegMajorVersion = parseFfmpegMajorVersion(ffmpegVersion);
@@ -317,16 +299,17 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
317
299
  console.log(`[camera] No go2rtc or cloudflared build is pinned for ${process.platform} ${process.arch}: running camera/go2rtc and camera/cloudflared as they are`);
318
300
  }
319
301
  const relayOptions = {
320
- ...options,
302
+ printerId,
303
+ printerLabel,
304
+ source,
321
305
  go2rtcPath,
322
306
  runtimeDir: join(cameraDir, 'runtime'),
323
- caFile,
324
307
  ffmpegReadsOptions: ffmpegMajorVersion === null || ffmpegMajorVersion >= 5,
325
308
  ffmpegFpsModeFlag: ffmpegMajorVersion === null || ffmpegMajorVersion >= 6 ? '-fps_mode' : '-vsync',
326
309
  };
327
310
  let camera = null;
328
311
  let lastAccess = null;
329
- let rtspUrl;
312
+ let report;
330
313
  let isWaitingForStream = true;
331
314
  let hasReportedNoStream = false;
332
315
  let timeoutId;
@@ -367,7 +350,7 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
367
350
  const startTunnel = (tunnelToken) => dependencies.resolveTunnel({
368
351
  workDir,
369
352
  tunnelToken,
370
- printerLabel: options.printerLabel,
353
+ printerLabel,
371
354
  });
372
355
  const startCamera = (relayKey, tunnelToken) => {
373
356
  const registry = dependencies.createRegistry();
@@ -383,7 +366,9 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
383
366
  relay,
384
367
  tunnel: startTunnel(tunnelToken),
385
368
  };
386
- relay.update(rtspUrl);
369
+ if (report !== undefined) {
370
+ relay.update(report);
371
+ }
387
372
  };
388
373
  const stopCamera = async () => {
389
374
  const running = camera;
@@ -529,26 +514,27 @@ export const resolveCameraRelay = ({ workDir, caFile, fetchesBinaries, getCamera
529
514
  };
530
515
  return {
531
516
  noteRelayKeyFingerprint,
532
- update: nextRtspUrl => {
533
- rtspUrl = nextRtspUrl;
517
+ update: nextReport => {
518
+ report = nextReport;
534
519
  if (isStopping) {
535
520
  return;
536
521
  }
537
522
  if (!isWaitingForStream) {
538
- camera?.relay.update(nextRtspUrl);
523
+ camera?.relay.update(nextReport);
539
524
  return;
540
525
  }
541
- if (hasCameraStream(nextRtspUrl, jpegCamera)) {
526
+ if (source.hasStream(nextReport)) {
542
527
  isWaitingForStream = false;
543
- if (jpegCamera) {
544
- console.log(`[camera] "${options.printerLabel}" is a Bambu ${jpegCamera.model}, whose camera has no RTSP: its JPEG frames are read from ${jpegCamera.host}:${jpegCamera.port} and encoded to H.264 while somebody watches`);
528
+ const description = source.describeStream(nextReport);
529
+ if (description) {
530
+ console.log(`[camera] ${description}`);
545
531
  }
546
532
  run(start);
547
533
  return;
548
534
  }
549
- if (nextRtspUrl !== undefined && !hasReportedNoStream) {
535
+ if (nextReport !== undefined && !hasReportedNoStream) {
550
536
  hasReportedNoStream = true;
551
- console.log(`[camera] "${options.printerLabel}" reports no camera stream (${nextRtspUrl || 'none'}) - its LAN live view is off, or its camera is of a model the agent does not read - so its camera is not relayed until it names one`);
537
+ console.log(`[camera] ${source.describeNoStream(nextReport)}`);
552
538
  }
553
539
  },
554
540
  stop,
@@ -0,0 +1,186 @@
1
+ import { request as requestHttp } from 'node:http';
2
+ import { request as requestHttps } from 'node:https';
3
+ import { isJpeg } from './utils.js';
4
+ export const readMjpegBoundary = (contentType) => {
5
+ const match = contentType?.match(/^\s*multipart\/x-mixed-replace\s*;(?:.*;)?\s*boundary=([^;]+)/i);
6
+ const boundary = match?.[1].trim().replace(/^"(.*)"$/, '$1');
7
+ return boundary || null;
8
+ };
9
+ const MAX_FRAME_SIZE = 16 * 1024 * 1024;
10
+ const JPEG_END = Buffer.from([0xff, 0xd9]);
11
+ export const createMjpegFrameReader = (boundary, onFrame) => {
12
+ const delimiter = Buffer.from(boundary, 'latin1');
13
+ const pieces = [];
14
+ let buffer = Buffer.alloc(0);
15
+ let piecesSize = 0;
16
+ let state = 'boundary';
17
+ let contentLength = null;
18
+ const takeLine = () => {
19
+ const end = buffer.indexOf(0x0a);
20
+ if (end < 0) {
21
+ return null;
22
+ }
23
+ const line = buffer.toString('latin1', 0, end).replace(/\r$/, '');
24
+ buffer = buffer.subarray(end + 1);
25
+ return line;
26
+ };
27
+ const emit = (frame) => {
28
+ if (isJpeg(frame)) {
29
+ onFrame(frame);
30
+ }
31
+ };
32
+ const step = () => {
33
+ switch (state) {
34
+ case 'boundary': {
35
+ const at = buffer.indexOf(delimiter);
36
+ if (at < 0) {
37
+ buffer = buffer.subarray(Math.max(0, buffer.length - delimiter.length));
38
+ return false;
39
+ }
40
+ const lineEnd = buffer.indexOf(0x0a, at + delimiter.length);
41
+ if (lineEnd < 0) {
42
+ buffer = buffer.subarray(at);
43
+ return false;
44
+ }
45
+ buffer = buffer.subarray(lineEnd + 1);
46
+ state = 'headers';
47
+ contentLength = null;
48
+ return true;
49
+ }
50
+ case 'headers': {
51
+ const line = takeLine();
52
+ if (line === null) {
53
+ return false;
54
+ }
55
+ if (line) {
56
+ const length = /^content-length\s*:\s*(\d+)\s*$/i.exec(line);
57
+ if (length) {
58
+ contentLength = Number(length[1]);
59
+ if (contentLength > MAX_FRAME_SIZE) {
60
+ throw new Error(`it sent a part of ${contentLength} bytes`);
61
+ }
62
+ }
63
+ return true;
64
+ }
65
+ state = 'body';
66
+ return true;
67
+ }
68
+ case 'body': {
69
+ if (contentLength !== null) {
70
+ if (buffer.length < contentLength) {
71
+ return false;
72
+ }
73
+ emit(buffer.subarray(0, contentLength));
74
+ buffer = buffer.subarray(contentLength);
75
+ state = 'boundary';
76
+ return true;
77
+ }
78
+ const next = buffer.indexOf(delimiter);
79
+ if (next < 0) {
80
+ return false;
81
+ }
82
+ const body = buffer.subarray(0, next);
83
+ const end = body.lastIndexOf(JPEG_END);
84
+ emit(end < 0 ? body : body.subarray(0, end + 2));
85
+ buffer = buffer.subarray(next);
86
+ state = 'boundary';
87
+ return true;
88
+ }
89
+ }
90
+ };
91
+ return (chunk) => {
92
+ pieces.push(chunk);
93
+ piecesSize += chunk.length;
94
+ const isBodyComing = state === 'body' && contentLength !== null && buffer.length + piecesSize < contentLength;
95
+ if (isBodyComing) {
96
+ return;
97
+ }
98
+ buffer =
99
+ buffer.length || pieces.length > 1
100
+ ? Buffer.concat([buffer, ...pieces], buffer.length + piecesSize)
101
+ : chunk;
102
+ pieces.length = 0;
103
+ piecesSize = 0;
104
+ while (step()) {
105
+ }
106
+ if (buffer.length > MAX_FRAME_SIZE) {
107
+ throw new Error(`it sent ${MAX_FRAME_SIZE / 1024 / 1024} MB with no frame in it`);
108
+ }
109
+ };
110
+ };
111
+ const FRAME_TIMEOUT_MS = 10_000;
112
+ export const describeWebcamUrl = (url) => {
113
+ try {
114
+ const parsed = new URL(url);
115
+ parsed.username = '';
116
+ parsed.password = '';
117
+ return parsed.toString();
118
+ }
119
+ catch {
120
+ return url;
121
+ }
122
+ };
123
+ export class CameraWebcamConnection {
124
+ request;
125
+ frameTimeoutId;
126
+ isClosed = false;
127
+ constructor({ url, onFrame, onFailure, onClose }) {
128
+ const target = new URL(url);
129
+ const fail = (error) => {
130
+ if (!this.isClosed) {
131
+ onFailure(error);
132
+ this.close();
133
+ }
134
+ };
135
+ const awaitFrame = () => {
136
+ clearTimeout(this.frameTimeoutId);
137
+ this.frameTimeoutId = setTimeout(() => fail(new Error(`it sent no frame in ${FRAME_TIMEOUT_MS / 1000}s`)), FRAME_TIMEOUT_MS);
138
+ };
139
+ const read = (response) => {
140
+ const boundary = readMjpegBoundary(response.headers['content-type']);
141
+ response.on('error', fail);
142
+ if (response.statusCode !== 200) {
143
+ fail(new Error(`it answered ${response.statusCode}`));
144
+ return;
145
+ }
146
+ if (!boundary) {
147
+ fail(new Error(`it answered with ${response.headers['content-type'] ?? 'no content type'}, not an MJPEG stream`));
148
+ return;
149
+ }
150
+ const readFrames = createMjpegFrameReader(boundary, frame => {
151
+ awaitFrame();
152
+ onFrame(frame);
153
+ });
154
+ response.on('data', (chunk) => {
155
+ try {
156
+ readFrames(chunk);
157
+ }
158
+ catch (error) {
159
+ fail(error);
160
+ }
161
+ });
162
+ response.once('end', () => fail(new Error('it ended the stream')));
163
+ };
164
+ this.request = (target.protocol === 'https:' ? requestHttps : requestHttp)(target, {
165
+ headers: {
166
+ Accept: 'multipart/x-mixed-replace, */*',
167
+ },
168
+ });
169
+ this.request.once('response', read);
170
+ this.request.on('error', fail);
171
+ this.request.once('close', () => {
172
+ this.close();
173
+ onClose();
174
+ });
175
+ this.request.end();
176
+ awaitFrame();
177
+ }
178
+ close() {
179
+ if (this.isClosed) {
180
+ return;
181
+ }
182
+ this.isClosed = true;
183
+ clearTimeout(this.frameTimeoutId);
184
+ this.request.destroy();
185
+ }
186
+ }