@9b9387/android-stream-scrcpy 0.1.0 → 0.1.2

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
@@ -251,22 +251,54 @@ const snapshots = new FfmpegSnapshotCache(service, {
251
251
  enabled: true,
252
252
  fps: 2,
253
253
  quality: 85,
254
+ // Optional robustness knobs (defaults shown):
255
+ drainTimeoutMs: 5000, // tear down ffmpeg if stdin stalls this long
256
+ killTimeoutMs: 2000, // SIGTERM grace before SIGKILL on stop()
257
+ staleTimeoutMs: 10000, // emit "stale" when no new frame for this long (0 = off)
258
+ maxStdoutBytes: 16 * 1024 * 1024, // cap on the JPEG reassembly buffer
254
259
  });
255
260
 
256
261
  await service.start();
257
- snapshots.start();
262
+ snapshots.start(); // must be called after service.start()
258
263
 
264
+ // Non-blocking read of the most recent frame (may be null before the first):
259
265
  const latest = snapshots.latest();
260
- if (latest) {
261
- // latest.contentType === "image/jpeg"
262
- // latest.data is a Buffer containing JPEG bytes.
266
+
267
+ // Or wait up to N ms for the first/next frame instead of busy-polling 404s:
268
+ try {
269
+ const shot = await snapshots.waitForFresh(3000);
270
+ // shot.contentType === "image/jpeg"; shot.data is a JPEG Buffer
271
+ } catch {
272
+ // no frame within the timeout — surface a 503 / "warming up" to the client
263
273
  }
274
+
275
+ // Observe a stalled stream (device asleep, encoder paused, etc.):
276
+ snapshots.on("stale", ({ ageMs }) => {
277
+ console.warn(`no new snapshot for ${ageMs}ms`);
278
+ });
264
279
  ```
265
280
 
266
281
  The first implementation supports H.264 input and JPEG output. The configured
267
282
  `fps` limits how often ffmpeg emits JPEG frames; ffmpeg still receives the
268
283
  continuous H.264 stream so inter-frame decoding remains correct.
269
284
 
285
+ ### Lifecycle & resource safety
286
+
287
+ - `FfmpegSnapshotCache` listens to the service state: when the service stops or
288
+ errors, the ffmpeg process is killed automatically, so it never lingers as an
289
+ orphan across reconnects. You should still call `snapshots.stop()` explicitly
290
+ when you tear a session down yourself.
291
+ - `waitForFresh()` resolves immediately if a frame is cached, otherwise it
292
+ resolves on the next frame or rejects after the timeout — use it in HTTP
293
+ handlers so a single request never hangs and clients never busy-poll.
294
+ - The WebSocket bridge applies backpressure: a slow client's droppable video
295
+ frames are dropped (config/keyframe/session packets are preserved) once its
296
+ outbound buffer exceeds `maxBufferedBytes` (8MiB default), preventing
297
+ unbounded memory growth. Tune via `new ScrcpyWebSocketBridge(service, { maxBufferedBytes })`.
298
+
299
+ For multi-device streaming and per-device screenshot HTTP routes, see
300
+ [docs/multi-device-screenshot.md](./docs/multi-device-screenshot.md) (Web integration guide, 中文).
301
+
270
302
  ## Next.js And Electron Usage
271
303
 
272
304
  ### Next.js
@@ -1,9 +1,10 @@
1
1
  import * as net from "node:net";
2
2
  export declare class BufferedStreamReader {
3
+ private readonly maxBufferBytes;
3
4
  private buffer;
4
5
  private waiters;
5
6
  private ended;
6
- constructor(stream: net.Socket);
7
+ constructor(stream: net.Socket, maxBufferBytes?: number);
7
8
  readExact(n: number): Promise<Uint8Array>;
8
9
  private rejectAll;
9
10
  private checkWaiters;
@@ -1,11 +1,23 @@
1
1
  import { concatBytes } from "../../protocol/core/binary.js";
2
+ // Safety cap on the reassembly buffer. A single scrcpy frame is well under
3
+ // this; exceeding it means the stream is desynchronized or a bogus frame size
4
+ // was read, so we fail fast instead of growing memory without bound.
5
+ const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
2
6
  export class BufferedStreamReader {
7
+ maxBufferBytes;
3
8
  buffer = new Uint8Array(0);
4
9
  waiters = [];
5
10
  ended = false;
6
- constructor(stream) {
11
+ constructor(stream, maxBufferBytes = DEFAULT_MAX_BUFFER_BYTES) {
12
+ this.maxBufferBytes = maxBufferBytes;
7
13
  stream.on("data", (chunk) => {
8
14
  this.buffer = concatBytes([this.buffer, chunk]);
15
+ if (this.buffer.byteLength > this.maxBufferBytes) {
16
+ this.ended = true;
17
+ this.rejectAll(new Error(`stream reader buffer exceeded ${this.maxBufferBytes} bytes; aborting`));
18
+ stream.destroy();
19
+ return;
20
+ }
9
21
  this.checkWaiters();
10
22
  });
11
23
  stream.on("error", (err) => {
@@ -13,14 +13,16 @@ export interface ScrcpyBackendOptions {
13
13
  control?: boolean;
14
14
  videoCodec?: VideoCodec;
15
15
  audioCodec?: AudioCodec;
16
+ videoEncoder?: string;
16
17
  connectionTimeoutMs?: number;
17
18
  deployTimeoutMs?: number;
18
19
  pushTimeoutMs?: number;
19
20
  serverJarPath?: string;
20
21
  }
21
- export interface NormalizedScrcpyBackendOptions extends Required<Omit<ScrcpyBackendOptions, "serverJarPath">> {
22
+ export interface NormalizedScrcpyBackendOptions extends Required<Omit<ScrcpyBackendOptions, "serverJarPath" | "videoEncoder">> {
22
23
  protocol: ScrcpyProtocol;
23
24
  socketName: string;
24
25
  serverJarPath: string;
26
+ videoEncoder?: string;
25
27
  }
26
28
  export declare function normalizeBackendOptions(options?: ScrcpyBackendOptions): NormalizedScrcpyBackendOptions;
@@ -22,6 +22,7 @@ export function normalizeBackendOptions(options = {}) {
22
22
  control: options.control !== false,
23
23
  videoCodec: options.videoCodec ?? VideoCodec.H264,
24
24
  audioCodec: options.audioCodec ?? AudioCodec.OPUS,
25
+ videoEncoder: options.videoEncoder,
25
26
  connectionTimeoutMs: options.connectionTimeoutMs ?? 8000,
26
27
  deployTimeoutMs: options.deployTimeoutMs ?? 5000,
27
28
  pushTimeoutMs: options.pushTimeoutMs ?? 60000,
@@ -1,5 +1,5 @@
1
1
  export function getDeviceServerPath(serverVersion) {
2
- return `/data/local/tmp/scrcpy-server-v${serverVersion}.jar`;
2
+ return "/data/local/tmp/scrcpy-server.jar";
3
3
  }
4
4
  export function buildServerCommand(deviceServerPath, options) {
5
5
  const args = [
@@ -14,6 +14,7 @@ export function buildServerCommand(deviceServerPath, options) {
14
14
  control: options.control,
15
15
  videoCodec: options.videoCodec,
16
16
  audioCodec: options.audioCodec,
17
+ videoEncoder: options.videoEncoder,
17
18
  maxSize: options.maxSize,
18
19
  maxFps: options.maxFps,
19
20
  videoBitRate: options.videoBitRate,
@@ -10,6 +10,7 @@ export interface ScrcpyServerOptionInput {
10
10
  control: boolean;
11
11
  videoCodec: VideoCodec;
12
12
  audioCodec: AudioCodec;
13
+ videoEncoder?: string;
13
14
  maxSize: number;
14
15
  maxFps: number;
15
16
  videoBitRate: number;
@@ -11,28 +11,37 @@ export function getSocketName(scid = DEFAULT_SCRCPY_SCID) {
11
11
  }
12
12
  export function buildServerOptions(input) {
13
13
  const scid = input.scid ?? DEFAULT_SCRCPY_SCID;
14
- return [
14
+ const opts = [
15
15
  SCRCPY_4_0_SERVER_VERSION,
16
+ `scid=${(scid >>> 0).toString(16).padStart(8, "0")}`,
16
17
  `log_level=${input.logLevel ?? "info"}`,
17
18
  "tunnel_forward=true",
18
- `scid=${scid.toString(16)}`,
19
19
  `video=${input.video}`,
20
20
  `audio=${input.audio}`,
21
21
  `control=${input.control}`,
22
- `video_codec=${input.videoCodec}`,
23
- `audio_codec=${input.audioCodec}`,
24
- `max_size=${input.maxSize}`,
25
- `max_fps=${input.maxFps}`,
26
- `video_bit_rate=${input.videoBitRate}`,
27
- `audio_bit_rate=${input.audioBitRate}`,
28
- "send_device_meta=true",
29
- "send_frame_meta=true",
30
- "send_dummy_byte=true",
31
- "send_stream_meta=true",
32
- "cleanup=true",
33
- "stay_awake=false",
34
- "show_touches=false",
35
- "power_off_on_close=false",
36
- "clipboard_autosync=false",
37
22
  ];
23
+ if (input.videoCodec) {
24
+ opts.push(`video_codec=${input.videoCodec}`);
25
+ }
26
+ if (input.audioCodec) {
27
+ opts.push(`audio_codec=${input.audioCodec}`);
28
+ }
29
+ if (input.videoEncoder) {
30
+ opts.push(`video_encoder=${input.videoEncoder}`);
31
+ }
32
+ if (input.maxSize > 0) {
33
+ opts.push(`max_size=${input.maxSize}`);
34
+ }
35
+ if (input.maxFps > 0) {
36
+ opts.push(`max_fps=${input.maxFps}`);
37
+ }
38
+ if (input.videoBitRate > 0 && input.videoBitRate !== 8000000) {
39
+ opts.push(`video_bit_rate=${input.videoBitRate}`);
40
+ }
41
+ if (input.audioBitRate > 0 && input.audioBitRate !== 128000) {
42
+ opts.push(`audio_bit_rate=${input.audioBitRate}`);
43
+ }
44
+ // Other options like cleanup, send_device_meta, etc. use server-side defaults
45
+ // to keep the command line short and avoid Samsung-specific crashes.
46
+ return opts;
38
47
  }
@@ -12,6 +12,10 @@ export declare class FfmpegSnapshotCache extends EventEmitter {
12
12
  private readonly fps;
13
13
  private readonly quality;
14
14
  private readonly ffmpegPath;
15
+ private readonly drainTimeoutMs;
16
+ private readonly killTimeoutMs;
17
+ private readonly staleTimeoutMs;
18
+ private readonly maxStdoutBytes;
15
19
  private readonly spawnProcess;
16
20
  private process;
17
21
  private subscription;
@@ -19,16 +23,31 @@ export declare class FfmpegSnapshotCache extends EventEmitter {
19
23
  private stderrTail;
20
24
  private currentSnapshot;
21
25
  private running;
26
+ private staleTimer;
27
+ private staleEmitted;
28
+ private readonly onServiceState;
22
29
  constructor(service: ScrcpyStreamService, options?: SnapshotCacheOptions);
23
30
  start(): void;
24
31
  stop(): void;
25
32
  latest(): Snapshot | null;
33
+ /**
34
+ * Resolve with a snapshot, waiting up to `timeoutMs` for the first frame if
35
+ * the cache has not produced one yet. Lets HTTP handlers avoid both a busy
36
+ * 404-poll loop and an unbounded hang while ffmpeg warms up.
37
+ */
38
+ waitForFresh(timeoutMs?: number): Promise<Snapshot>;
26
39
  private buildFfmpegArgs;
27
40
  private pumpVideoPackets;
28
41
  private writePacket;
42
+ private waitForDrain;
29
43
  private handleStdout;
30
44
  private handleStderr;
31
45
  private handleProcessError;
46
+ private handlePumpError;
47
+ private startStaleWatchdog;
48
+ private bindServiceState;
49
+ private unbindServiceState;
32
50
  private cleanup;
51
+ private killProcess;
33
52
  private formatStderrTail;
34
53
  }
@@ -1,12 +1,16 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
- import { once } from "node:events";
4
- import { MediaKind, } from "../service/index.js";
3
+ import { MediaKind, StreamState, } from "../service/index.js";
5
4
  import { VideoCodec } from "../protocol/index.js";
6
5
  const JPEG_SOI = Buffer.from([0xff, 0xd8]);
7
6
  const JPEG_EOI = Buffer.from([0xff, 0xd9]);
8
7
  const DEFAULT_SNAPSHOT_FPS = 2;
9
8
  const DEFAULT_JPEG_QUALITY = 85;
9
+ const DEFAULT_DRAIN_TIMEOUT_MS = 5000;
10
+ const DEFAULT_KILL_TIMEOUT_MS = 2000;
11
+ const DEFAULT_STALE_TIMEOUT_MS = 10000;
12
+ const DEFAULT_MAX_STDOUT_BYTES = 16 * 1024 * 1024;
13
+ const STALE_CHECK_INTERVAL_MS = 1000;
10
14
  export function extractJpegFrames(buffer) {
11
15
  const frames = [];
12
16
  let offset = 0;
@@ -34,6 +38,10 @@ export class FfmpegSnapshotCache extends EventEmitter {
34
38
  fps;
35
39
  quality;
36
40
  ffmpegPath;
41
+ drainTimeoutMs;
42
+ killTimeoutMs;
43
+ staleTimeoutMs;
44
+ maxStdoutBytes;
37
45
  spawnProcess;
38
46
  process = null;
39
47
  subscription = null;
@@ -41,6 +49,9 @@ export class FfmpegSnapshotCache extends EventEmitter {
41
49
  stderrTail = "";
42
50
  currentSnapshot = null;
43
51
  running = false;
52
+ staleTimer = null;
53
+ staleEmitted = false;
54
+ onServiceState;
44
55
  constructor(service, options = {}) {
45
56
  super();
46
57
  this.service = service;
@@ -48,11 +59,23 @@ export class FfmpegSnapshotCache extends EventEmitter {
48
59
  this.fps = normalizeFps(options.fps ?? DEFAULT_SNAPSHOT_FPS);
49
60
  this.quality = normalizeJpegQuality(options.quality ?? DEFAULT_JPEG_QUALITY);
50
61
  this.ffmpegPath = options.ffmpegPath ?? "ffmpeg";
62
+ this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
63
+ this.killTimeoutMs = options.killTimeoutMs ?? DEFAULT_KILL_TIMEOUT_MS;
64
+ this.staleTimeoutMs = options.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS;
65
+ this.maxStdoutBytes = options.maxStdoutBytes ?? DEFAULT_MAX_STDOUT_BYTES;
51
66
  this.spawnProcess =
52
67
  options.spawnProcess ??
53
68
  ((command, args) => spawn(command, args, {
54
69
  stdio: ["pipe", "pipe", "pipe"],
55
70
  }));
71
+ this.onServiceState = (state) => {
72
+ // When the underlying stream stops or errors out, the subscription dries
73
+ // up but ffmpeg would otherwise linger as an orphan process. Tear it down
74
+ // so each device's ffmpeg lifetime is bounded by its service.
75
+ if (state === StreamState.STOPPED || state === StreamState.ERROR) {
76
+ this.stop();
77
+ }
78
+ };
56
79
  }
57
80
  start() {
58
81
  if (!this.enabled || this.running)
@@ -62,6 +85,8 @@ export class FfmpegSnapshotCache extends EventEmitter {
62
85
  throw new Error(`snapshot cache only supports h264 video, got ${videoCodec}`);
63
86
  }
64
87
  this.running = true;
88
+ this.staleEmitted = false;
89
+ this.bindServiceState();
65
90
  this.process = this.spawnProcess(this.ffmpegPath, this.buildFfmpegArgs());
66
91
  this.process.stdout.on("data", (chunk) => this.handleStdout(chunk));
67
92
  this.process.stderr.on("data", (chunk) => {
@@ -75,6 +100,7 @@ export class FfmpegSnapshotCache extends EventEmitter {
75
100
  }
76
101
  });
77
102
  this.subscription = this.service.subscribe();
103
+ this.startStaleWatchdog();
78
104
  void this.pumpVideoPackets();
79
105
  }
80
106
  stop() {
@@ -83,6 +109,40 @@ export class FfmpegSnapshotCache extends EventEmitter {
83
109
  latest() {
84
110
  return this.currentSnapshot;
85
111
  }
112
+ /**
113
+ * Resolve with a snapshot, waiting up to `timeoutMs` for the first frame if
114
+ * the cache has not produced one yet. Lets HTTP handlers avoid both a busy
115
+ * 404-poll loop and an unbounded hang while ffmpeg warms up.
116
+ */
117
+ waitForFresh(timeoutMs = 3000) {
118
+ if (this.currentSnapshot)
119
+ return Promise.resolve(this.currentSnapshot);
120
+ if (!this.running || !this.enabled) {
121
+ return Promise.reject(new Error("snapshot cache is not running"));
122
+ }
123
+ return new Promise((resolve, reject) => {
124
+ const onShot = (shot) => {
125
+ cleanup();
126
+ resolve(shot);
127
+ };
128
+ const onError = (e) => {
129
+ cleanup();
130
+ reject(e);
131
+ };
132
+ const timer = setTimeout(() => {
133
+ cleanup();
134
+ reject(new Error(`snapshot not available within ${timeoutMs}ms`));
135
+ }, timeoutMs);
136
+ timer.unref?.();
137
+ const cleanup = () => {
138
+ clearTimeout(timer);
139
+ this.removeListener("snapshot", onShot);
140
+ this.removeListener("error", onError);
141
+ };
142
+ this.once("snapshot", onShot);
143
+ this.once("error", onError);
144
+ });
145
+ }
86
146
  buildFfmpegArgs() {
87
147
  return [
88
148
  "-hide_banner",
@@ -124,17 +184,45 @@ export class FfmpegSnapshotCache extends EventEmitter {
124
184
  }
125
185
  }
126
186
  catch (e) {
127
- if (this.running)
128
- this.emit("error", e);
187
+ this.handlePumpError(e);
129
188
  }
130
189
  }
131
190
  async writePacket(proc, packet) {
132
191
  if (proc.stdin.write(packet.payload))
133
192
  return;
134
- await once(proc.stdin, "drain");
193
+ await this.waitForDrain(proc);
194
+ }
195
+ waitForDrain(proc) {
196
+ return new Promise((resolve, reject) => {
197
+ const onDrain = () => {
198
+ cleanup();
199
+ resolve();
200
+ };
201
+ const onError = (e) => {
202
+ cleanup();
203
+ reject(e);
204
+ };
205
+ const timer = setTimeout(() => {
206
+ cleanup();
207
+ reject(new Error(`ffmpeg stdin did not drain within ${this.drainTimeoutMs}ms`));
208
+ }, this.drainTimeoutMs);
209
+ timer.unref?.();
210
+ const cleanup = () => {
211
+ clearTimeout(timer);
212
+ proc.stdin.removeListener("drain", onDrain);
213
+ proc.stdin.removeListener("error", onError);
214
+ };
215
+ proc.stdin.once("drain", onDrain);
216
+ proc.stdin.once("error", onError);
217
+ });
135
218
  }
136
219
  handleStdout(chunk) {
137
220
  this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
221
+ // Guard against a corrupt/headerless ffmpeg stream where no EOI ever
222
+ // arrives: without a cap the remainder would grow without bound.
223
+ if (this.stdoutBuffer.byteLength > this.maxStdoutBytes) {
224
+ this.stdoutBuffer = this.stdoutBuffer.subarray(-this.maxStdoutBytes);
225
+ }
138
226
  const result = extractJpegFrames(this.stdoutBuffer);
139
227
  this.stdoutBuffer = result.remainder;
140
228
  for (const frame of result.frames) {
@@ -143,6 +231,7 @@ export class FfmpegSnapshotCache extends EventEmitter {
143
231
  data: frame,
144
232
  timestampMs: Date.now(),
145
233
  };
234
+ this.staleEmitted = false;
146
235
  this.emit("snapshot", this.currentSnapshot);
147
236
  }
148
237
  }
@@ -157,8 +246,44 @@ export class FfmpegSnapshotCache extends EventEmitter {
157
246
  this.cleanup({ clearSnapshot: true, killProcess: false });
158
247
  this.emit("error", e);
159
248
  }
249
+ handlePumpError(e) {
250
+ if (!this.running)
251
+ return;
252
+ // A pump failure (e.g. stdin stalled past the drain timeout) means ffmpeg
253
+ // is wedged; kill it so the cache can be restarted cleanly.
254
+ this.cleanup({ clearSnapshot: true, killProcess: true });
255
+ this.emit("error", e);
256
+ }
257
+ startStaleWatchdog() {
258
+ if (this.staleTimeoutMs <= 0)
259
+ return;
260
+ const timer = setInterval(() => {
261
+ if (!this.running || !this.currentSnapshot || this.staleEmitted)
262
+ return;
263
+ const age = Date.now() - this.currentSnapshot.timestampMs;
264
+ if (age > this.staleTimeoutMs) {
265
+ this.staleEmitted = true;
266
+ this.emit("stale", { ageMs: age, snapshot: this.currentSnapshot });
267
+ }
268
+ }, STALE_CHECK_INTERVAL_MS);
269
+ timer.unref?.();
270
+ this.staleTimer = timer;
271
+ }
272
+ bindServiceState() {
273
+ const emitter = this.service;
274
+ emitter.on?.("state", this.onServiceState);
275
+ }
276
+ unbindServiceState() {
277
+ const emitter = this.service;
278
+ emitter.removeListener?.("state", this.onServiceState);
279
+ }
160
280
  cleanup(options) {
161
281
  this.running = false;
282
+ this.unbindServiceState();
283
+ if (this.staleTimer) {
284
+ clearInterval(this.staleTimer);
285
+ this.staleTimer = null;
286
+ }
162
287
  this.subscription?.return?.();
163
288
  this.subscription = null;
164
289
  const proc = this.process;
@@ -166,13 +291,27 @@ export class FfmpegSnapshotCache extends EventEmitter {
166
291
  if (proc) {
167
292
  proc.stdin.end();
168
293
  if (options.killProcess)
169
- proc.kill();
294
+ this.killProcess(proc);
170
295
  }
171
296
  this.stdoutBuffer = Buffer.alloc(0);
172
297
  this.stderrTail = "";
173
298
  if (options.clearSnapshot)
174
299
  this.currentSnapshot = null;
175
300
  }
301
+ killProcess(proc) {
302
+ proc.kill();
303
+ // Escalate to SIGKILL if ffmpeg ignores the polite signal.
304
+ const timer = setTimeout(() => {
305
+ try {
306
+ proc.kill("SIGKILL");
307
+ }
308
+ catch {
309
+ // process already gone
310
+ }
311
+ }, this.killTimeoutMs);
312
+ timer.unref?.();
313
+ proc.once("exit", () => clearTimeout(timer));
314
+ }
176
315
  formatStderrTail() {
177
316
  const tail = this.stderrTail.trim();
178
317
  return tail ? ` stderr=${tail}` : "";
@@ -9,6 +9,27 @@ export interface SnapshotCacheOptions {
9
9
  fps?: number;
10
10
  quality?: number;
11
11
  ffmpegPath?: string;
12
+ /**
13
+ * Max time to wait for ffmpeg stdin to drain before treating the process as
14
+ * stalled and tearing it down. Defaults to 5000ms.
15
+ */
16
+ drainTimeoutMs?: number;
17
+ /**
18
+ * Grace period after SIGTERM before escalating to SIGKILL on stop. Defaults
19
+ * to 2000ms.
20
+ */
21
+ killTimeoutMs?: number;
22
+ /**
23
+ * Emit a `stale` event when the newest snapshot is older than this many ms
24
+ * (e.g. the device stopped producing frames). 0 disables. Defaults to
25
+ * 10000ms.
26
+ */
27
+ staleTimeoutMs?: number;
28
+ /**
29
+ * Cap on the internal ffmpeg stdout reassembly buffer, guarding against a
30
+ * corrupt stream with no JPEG end marker. Defaults to 16MiB.
31
+ */
32
+ maxStdoutBytes?: number;
12
33
  spawnProcess?: FfmpegProcessFactory;
13
34
  }
14
35
  export type FfmpegProcess = Pick<ChildProcessWithoutNullStreams, "stdin" | "stdout" | "stderr" | "kill" | "on" | "once">;
@@ -3,8 +3,10 @@ import { WebSocketBridgeOptions } from "./types.js";
3
3
  export declare class ScrcpyWebSocketBridge {
4
4
  private service;
5
5
  private wss;
6
+ private readonly maxBufferedBytes;
6
7
  constructor(service: ScrcpyStreamService, options?: WebSocketBridgeOptions);
7
8
  close(): void;
8
9
  private handleConnection;
10
+ private isDroppable;
9
11
  private createInitMessage;
10
12
  }
@@ -1,12 +1,17 @@
1
1
  import { WebSocket, WebSocketServer } from "ws";
2
+ import { MediaKind } from "../service/types.js";
2
3
  import { serializeMediaPacket } from "./binary-packet.js";
3
4
  import { parseControlJson } from "./control-json.js";
4
5
  import { WEBSOCKET_HEADER_SIZE, WEBSOCKET_KIND_AUDIO, WEBSOCKET_KIND_SESSION, WEBSOCKET_KIND_VIDEO, } from "./types.js";
6
+ const DEFAULT_MAX_BUFFERED_BYTES = 8 * 1024 * 1024;
5
7
  export class ScrcpyWebSocketBridge {
6
8
  service;
7
9
  wss;
10
+ maxBufferedBytes;
8
11
  constructor(service, options = {}) {
9
12
  this.service = service;
13
+ this.maxBufferedBytes =
14
+ options.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES;
10
15
  this.wss = new WebSocketServer({
11
16
  port: options.port,
12
17
  server: options.server,
@@ -38,6 +43,14 @@ export class ScrcpyWebSocketBridge {
38
43
  for await (const packet of subscription) {
39
44
  if (ws.readyState !== WebSocket.OPEN)
40
45
  break;
46
+ // Apply backpressure: when the client falls behind, drop droppable
47
+ // frames instead of letting ws buffer them without bound. Config,
48
+ // key frames and session packets are kept so the decoder can recover.
49
+ if (this.maxBufferedBytes > 0 &&
50
+ ws.bufferedAmount > this.maxBufferedBytes &&
51
+ this.isDroppable(packet)) {
52
+ continue;
53
+ }
41
54
  ws.send(serializeMediaPacket(packet));
42
55
  }
43
56
  }
@@ -45,6 +58,9 @@ export class ScrcpyWebSocketBridge {
45
58
  ws.close();
46
59
  }
47
60
  }
61
+ isDroppable(packet) {
62
+ return (packet.kind === MediaKind.VIDEO && !packet.config && !packet.keyFrame);
63
+ }
48
64
  createInitMessage() {
49
65
  const meta = this.service.currentMeta;
50
66
  return {
@@ -2,6 +2,12 @@ export interface WebSocketBridgeOptions {
2
2
  port?: number;
3
3
  server?: any;
4
4
  path?: string;
5
+ /**
6
+ * Drop media frames for a connection whose outbound buffer exceeds this many
7
+ * bytes. Protects the process from unbounded memory growth when a client is
8
+ * slower than the stream. 0 disables. Defaults to 8MiB.
9
+ */
10
+ maxBufferedBytes?: number;
5
11
  }
6
12
  export declare const WEBSOCKET_HEADER_SIZE = 16;
7
13
  export declare const WEBSOCKET_KIND_VIDEO = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@9b9387/android-stream-scrcpy",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Versioned scrcpy protocol client for Node.js/Electron",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",