@9b9387/android-stream-scrcpy 0.1.1 → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 9b9387
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -4,6 +4,8 @@ TypeScript scrcpy client library for Node.js and Electron. It starts `scrcpy-ser
4
4
 
5
5
  The bundled protocol implementation targets scrcpy `4.0`. Protocol code is versioned so future versions, such as `4.1`, can be added side by side without rewriting the backend or service layers.
6
6
 
7
+ > Runtime note: the root package is Node-only. Use it from a Node.js process, an Electron main process, a Next.js Node runtime route/server, or another long-lived backend process. Do not import the root package from browser code, Next.js Client Components, or Edge Runtime handlers.
8
+
7
9
  ## Project Structure
8
10
 
9
11
  ```text
@@ -45,6 +47,10 @@ node-lib/
45
47
  scrcpy-stream-service.ts
46
48
  types.ts
47
49
  index.ts
50
+ snapshot/
51
+ ffmpeg-snapshot-cache.ts
52
+ types.ts
53
+ index.ts
48
54
  websocket/
49
55
  binary-packet.ts
50
56
  control-json.ts
@@ -59,18 +65,19 @@ node-lib/
59
65
  - `src/protocol/`: scrcpy wire protocol implementation. `core/` contains shared binary helpers and protocol interfaces; `registry.ts` selects a protocol adapter by version; `v4_0/` contains scrcpy 4.0 frame, control, device, codec, and server-option logic.
60
66
  - `src/backend/`: adbkit-only device integration. `adb/` wraps adbkit operations, `io/` contains socket reading utilities, `server/` builds and normalizes scrcpy server launch details, and `scrcpy-backend.ts` orchestrates the streaming connection.
61
67
  - `src/service/`: public stream service. It manages state, caches decoder config/session snapshots, and exposes `for await...of` media packet subscriptions.
68
+ - `src/snapshot/`: optional Node-side screenshot cache. It pipes H.264 packets into ffmpeg and stores the latest JPEG frame.
62
69
  - `src/websocket/`: optional `ws` bridge for browser clients. It is exported from the `./websocket` subpath and is not part of the root API.
63
70
  - `examples/`: runnable examples kept outside the library build.
64
71
 
65
- ## Install And Build
72
+ ## Install
73
+
74
+ Install the package in your application:
66
75
 
67
76
  ```bash
68
- cd node-lib
69
- npm install
70
- npm run build
77
+ npm install @9b9387/android-stream-scrcpy
71
78
  ```
72
79
 
73
- Core runtime dependency is only `@devicefarmer/adbkit`. The library does not call the `adb` system command directly.
80
+ Core runtime dependency is `@devicefarmer/adbkit`. The library does not call the `adb` system command directly.
74
81
 
75
82
  If your application uses the optional WebSocket bridge, install `ws` in the application:
76
83
 
@@ -78,6 +85,25 @@ If your application uses the optional WebSocket bridge, install `ws` in the appl
78
85
  npm install ws
79
86
  ```
80
87
 
88
+ If your application uses the optional screenshot cache, make sure `ffmpeg` is available on `PATH`, or pass `ffmpegPath` to `FfmpegSnapshotCache`.
89
+
90
+ ## Build From Source
91
+
92
+ ```bash
93
+ cd node-lib
94
+ npm install
95
+ npm run build
96
+ ```
97
+
98
+ ## Package Entrypoints
99
+
100
+ - `@9b9387/android-stream-scrcpy`: Node-only service/backend API.
101
+ - `@9b9387/android-stream-scrcpy/protocol`: protocol adapters and types. This is the safest entrypoint for code that only needs scrcpy protocol constants, codecs, and message types.
102
+ - `@9b9387/android-stream-scrcpy/websocket`: optional Node-only `ws` bridge.
103
+ - `@9b9387/android-stream-scrcpy/snapshot`: optional Node-only ffmpeg screenshot cache.
104
+
105
+ The package is ESM-only and requires Node.js 20 or newer.
106
+
81
107
  ## Run Examples
82
108
 
83
109
  Run the console stream demo:
@@ -225,22 +251,82 @@ const snapshots = new FfmpegSnapshotCache(service, {
225
251
  enabled: true,
226
252
  fps: 2,
227
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
228
259
  });
229
260
 
230
261
  await service.start();
231
- snapshots.start();
262
+ snapshots.start(); // must be called after service.start()
232
263
 
264
+ // Non-blocking read of the most recent frame (may be null before the first):
233
265
  const latest = snapshots.latest();
234
- if (latest) {
235
- // latest.contentType === "image/jpeg"
236
- // 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
237
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
+ });
238
279
  ```
239
280
 
240
281
  The first implementation supports H.264 input and JPEG output. The configured
241
282
  `fps` limits how often ffmpeg emits JPEG frames; ffmpeg still receives the
242
283
  continuous H.264 stream so inter-frame decoding remains correct.
243
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
+
302
+ ## Next.js And Electron Usage
303
+
304
+ ### Next.js
305
+
306
+ Use this package only from the server side of a self-hosted or long-lived Node.js runtime:
307
+
308
+ ```typescript
309
+ export const runtime = "nodejs";
310
+
311
+ import { ScrcpyStreamService } from "@9b9387/android-stream-scrcpy";
312
+ ```
313
+
314
+ Do not import the root package from Client Components, browser bundles, Middleware,
315
+ or Edge Runtime route handlers. The backend opens ADB sockets and long-lived media
316
+ streams, so a custom Node.js server or separate local daemon is usually a better
317
+ fit than a short-lived serverless function.
318
+
319
+ ### Electron
320
+
321
+ Create and manage `ScrcpyStreamService` in the main process. Renderer processes
322
+ should communicate with the main process through IPC or connect to the optional
323
+ WebSocket bridge.
324
+
325
+ When packaging Electron apps, make sure `assets/scrcpy-server-v4.0.jar` is copied
326
+ as a runtime resource. If your packager moves or packs assets into an archive,
327
+ pass an explicit `serverJarPath` to `ScrcpyStreamService`. If screenshot caching
328
+ is enabled, also ship `ffmpeg` yourself or pass `ffmpegPath`.
329
+
244
330
  ## Protocol Versioning
245
331
 
246
332
  `protocolVersion` defaults to `"4.0"`:
@@ -266,6 +352,32 @@ npm run lint
266
352
  npm run typecheck
267
353
  npm test
268
354
  npm run build
355
+ npm pack --dry-run
269
356
  ```
270
357
 
271
358
  End-to-end streaming requires a connected Android device. Unit tests cover protocol serialization/parsing, backend option normalization, subscriber queue behavior, and WebSocket packet encoding.
359
+
360
+ ## Publish
361
+
362
+ Before publishing, make sure you are logged in to the npm account that owns the
363
+ `@9b9387` scope:
364
+
365
+ ```bash
366
+ npm whoami
367
+ ```
368
+
369
+ Run the release checks and inspect the tarball contents:
370
+
371
+ ```bash
372
+ npm run typecheck
373
+ npm run test
374
+ npm run lint
375
+ npm run build
376
+ npm pack --dry-run
377
+ ```
378
+
379
+ Publish the public scoped package:
380
+
381
+ ```bash
382
+ npm publish --access public
383
+ ```
@@ -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) => {
@@ -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,13 +1,19 @@
1
1
  {
2
2
  "name": "@9b9387/android-stream-scrcpy",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Versioned scrcpy protocol client for Node.js/Electron",
5
5
  "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
6
8
  "exports": {
7
9
  ".": {
8
10
  "import": "./dist/index.js",
9
11
  "types": "./dist/index.d.ts"
10
12
  },
13
+ "./protocol": {
14
+ "import": "./dist/protocol/index.js",
15
+ "types": "./dist/protocol/index.d.ts"
16
+ },
11
17
  "./websocket": {
12
18
  "import": "./dist/websocket/index.js",
13
19
  "types": "./dist/websocket/index.d.ts"
@@ -15,12 +21,18 @@
15
21
  "./snapshot": {
16
22
  "import": "./dist/snapshot/index.js",
17
23
  "types": "./dist/snapshot/index.d.ts"
18
- }
24
+ },
25
+ "./package.json": "./package.json"
19
26
  },
20
27
  "files": [
21
28
  "dist",
22
- "assets"
29
+ "assets",
30
+ "README.md",
31
+ "LICENSE"
23
32
  ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
24
36
  "scripts": {
25
37
  "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
26
38
  "build": "npm run clean && tsc",
@@ -28,9 +40,33 @@
28
40
  "test": "vitest run",
29
41
  "lint": "prettier --check .",
30
42
  "format": "prettier --write .",
43
+ "prepack": "npm run build",
44
+ "prepublishOnly": "npm run typecheck && npm run test && npm run lint && npm run build",
31
45
  "example:demo": "npm run build && node --loader ts-node/esm examples/demo.ts",
32
46
  "example:server": "npm run build && node --loader ts-node/esm examples/server.ts"
33
47
  },
48
+ "keywords": [
49
+ "scrcpy",
50
+ "android",
51
+ "adb",
52
+ "streaming",
53
+ "electron",
54
+ "nextjs",
55
+ "typescript"
56
+ ],
57
+ "license": "MIT",
58
+ "repository": {
59
+ "type": "git",
60
+ "url": "git+ssh://git@github.com/9b9387/android-stream.git",
61
+ "directory": "node-lib"
62
+ },
63
+ "bugs": {
64
+ "url": "https://github.com/9b9387/android-stream/issues"
65
+ },
66
+ "homepage": "https://github.com/9b9387/android-stream/tree/main/node-lib#readme",
67
+ "publishConfig": {
68
+ "access": "public"
69
+ },
34
70
  "dependencies": {
35
71
  "@devicefarmer/adbkit": "^3.2.3"
36
72
  },