@farmslot/capabilities 0.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.
- package/CHANGELOG.md +12 -0
- package/README.md +55 -0
- package/package.json +24 -0
- package/src/fs-watch.ts +94 -0
- package/src/index.ts +15 -0
- package/src/screen-frame.ts +60 -0
- package/src/screen-h264.ts +101 -0
- package/tsconfig.json +12 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@farmslot/capabilities` are tracked here.
|
|
4
|
+
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
- Initial package (ADR-046): machine-local capability primitives shared by the node
|
|
8
|
+
(primary owner) and the gateway (local fallback when no node).
|
|
9
|
+
- `fs-watch` — `watchFile()` native file-change watch primitive.
|
|
10
|
+
- `screen-frame` — `encodeNodeFrame()` / `decodeNodeFrame()` node→gateway capture-frame
|
|
11
|
+
envelope codec.
|
|
12
|
+
- `screen-h264` — `createH264FrameSplitter()` raw H.264 stream frame splitter.
|
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# @farmslot/capabilities
|
|
2
|
+
|
|
3
|
+
Machine-local capability primitives shared by the **node** (`services/node`) and the
|
|
4
|
+
**gateway** (`services/gateway`).
|
|
5
|
+
|
|
6
|
+
The node owns machine-local capabilities; the gateway keeps a degraded **local fallback**
|
|
7
|
+
for a machine that has no connected node. To provide that fallback without duplicating
|
|
8
|
+
logic, the primitive lives here once and both services import it — node as primary owner,
|
|
9
|
+
gateway as fallback caller.
|
|
10
|
+
|
|
11
|
+
## Canonical documents
|
|
12
|
+
|
|
13
|
+
- [ADR-046: Mandatory co-located local node](../../docs/adr/046-mandatory-local-node.md) — why this package exists, what does and does not get a gateway fallback, and the future direction (node as a thin transport over this library).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
yarn add @farmslot/capabilities @farmslot/protocol
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Main exports
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { watchFile } from '@farmslot/capabilities/fs-watch';
|
|
25
|
+
import { encodeNodeFrame, decodeNodeFrame } from '@farmslot/capabilities/screen-frame';
|
|
26
|
+
import { createH264FrameSplitter } from '@farmslot/capabilities/screen-h264';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Import | What it is |
|
|
30
|
+
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `fs-watch` — `watchFile()` | Native `fs.watch` with the "watch the parent directory until the file appears" fallback and tilde expansion. Returns a stop handle. |
|
|
32
|
+
| `screen-frame` — `encodeNodeFrame()` / `decodeNodeFrame()` | The node→gateway capture-frame binary envelope codec (`0xAF` magic). Node encodes, gateway decodes. |
|
|
33
|
+
| `screen-h264` — `createH264FrameSplitter()` | Splits a raw `adb screenrecord --output-format=h264` byte stream into individual video frames. |
|
|
34
|
+
|
|
35
|
+
## Source layout
|
|
36
|
+
|
|
37
|
+
| Path | Owns |
|
|
38
|
+
| --------------------- | ---------------------------------------------------------------------------------- |
|
|
39
|
+
| `src/index.ts` | Public package export surface. |
|
|
40
|
+
| `src/fs-watch.ts` | `watchFile()` — native file-change watch primitive. |
|
|
41
|
+
| `src/screen-frame.ts` | `encodeNodeFrame()` / `decodeNodeFrame()` — the node→gateway frame envelope codec. |
|
|
42
|
+
| `src/screen-h264.ts` | `createH264FrameSplitter()` — raw H.264 elementary-stream frame splitter. |
|
|
43
|
+
|
|
44
|
+
## Maintenance rules
|
|
45
|
+
|
|
46
|
+
1. **Node-only runtime.** Uses `node:fs` / `node:child_process`; never import DOM or gateway/node-internal modules here.
|
|
47
|
+
2. **Protocol types only.** Shared constants (e.g. the frame magic) come from `@farmslot/protocol`; keep `Buffer`-based helpers out of the UI-shared protocol bundle.
|
|
48
|
+
3. **One implementation, two callers.** A primitive belongs here only when both the node (primary) and the gateway (local fallback) genuinely use it — not as a util grab-bag.
|
|
49
|
+
4. **No hidden shared state.** Each primitive is instance-scoped (a handle/splitter per caller); do not add module-level singletons that leak across callers.
|
|
50
|
+
|
|
51
|
+
## Local quality
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
yarn workspace @farmslot/capabilities quality
|
|
55
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@farmslot/capabilities",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Machine-local capability primitives (fs-watch, screen capture) shared by the node (primary owner) and the gateway (local fallback when no node) — ADR-046.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./fs-watch": "./src/fs-watch.ts",
|
|
10
|
+
"./screen-frame": "./src/screen-frame.ts",
|
|
11
|
+
"./screen-h264": "./src/screen-h264.ts"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"typecheck": "tsc --noEmit --project tsconfig.json",
|
|
15
|
+
"quality": "yarn typecheck"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@farmslot/protocol": "0.7.2"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.0.0",
|
|
22
|
+
"typescript": "^5.6.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/fs-watch.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Shared file-watch primitive (ADR-046). Native `fs.watch` with the parent-dir
|
|
2
|
+
// "watch until the file appears" fallback and tilde expansion. Used by the node
|
|
3
|
+
// (primary owner of machine-local monitoring) and by the gateway as a local
|
|
4
|
+
// fallback when a machine has no connected node — one implementation, two callers.
|
|
5
|
+
|
|
6
|
+
import { existsSync, type FSWatcher, watch } from 'node:fs';
|
|
7
|
+
import { readFile } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { basename, dirname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Pool configs can declare repos as `~/...`. `~` is a shell token, not a
|
|
12
|
+
// filesystem prefix — node:fs treats it literally. Expand to $HOME first.
|
|
13
|
+
export function expandTilde(p: string): string {
|
|
14
|
+
if (p === '~') return homedir();
|
|
15
|
+
if (p.startsWith('~/')) return `${homedir()}/${p.slice(2)}`;
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FileWatchHandle {
|
|
20
|
+
stop(): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Watch a single file for content changes. `onChange` receives the file's UTF-8
|
|
25
|
+
* content on every write. If the file does not exist yet, its parent directory is
|
|
26
|
+
* watched until the file appears, then the watch switches to the file directly.
|
|
27
|
+
* Returns a handle the caller stops when done.
|
|
28
|
+
*/
|
|
29
|
+
export function watchFile(filePath: string, onChange: (content: string) => void): FileWatchHandle {
|
|
30
|
+
const targetPath = expandTilde(filePath);
|
|
31
|
+
let watcher: FSWatcher | null = null;
|
|
32
|
+
let stopped = false;
|
|
33
|
+
|
|
34
|
+
const start = (): void => {
|
|
35
|
+
if (stopped) return;
|
|
36
|
+
if (existsSync(targetPath)) {
|
|
37
|
+
// File exists — watch it directly.
|
|
38
|
+
watcher = watch(targetPath, { persistent: false }, async () => {
|
|
39
|
+
try {
|
|
40
|
+
const content = await readFile(targetPath, 'utf-8');
|
|
41
|
+
onChange(content);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// ENOENT is expected: git rewrites HEAD via an atomic rename, so the path is
|
|
44
|
+
// briefly absent between the watch firing and the read — the next event re-reads.
|
|
45
|
+
// Anything else (permissions, I/O) is a real fault and is surfaced, not hidden.
|
|
46
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
47
|
+
if (code !== 'ENOENT') {
|
|
48
|
+
console.warn(`[fs.watch] read failed for ${targetPath}: ${(err as Error).message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
// File doesn't exist yet — watch the parent directory for its creation.
|
|
54
|
+
const dir = dirname(targetPath);
|
|
55
|
+
if (!existsSync(dir)) {
|
|
56
|
+
console.log(`[fs.watch] parent dir doesn't exist: ${dir} — skipping watch`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const target = basename(targetPath);
|
|
60
|
+
watcher = watch(dir, { persistent: false }, async (_, filename) => {
|
|
61
|
+
if (filename !== target) return;
|
|
62
|
+
if (!existsSync(targetPath)) return;
|
|
63
|
+
// File appeared — read, notify, then switch to watching it directly.
|
|
64
|
+
try {
|
|
65
|
+
const content = await readFile(targetPath, 'utf-8');
|
|
66
|
+
onChange(content);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// ENOENT is expected: git rewrites HEAD via an atomic rename, so the path is
|
|
69
|
+
// briefly absent between the watch firing and the read — the next event re-reads.
|
|
70
|
+
// Anything else (permissions, I/O) is a real fault and is surfaced, not hidden.
|
|
71
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
72
|
+
if (code !== 'ENOENT') {
|
|
73
|
+
console.warn(`[fs.watch] read failed for ${targetPath}: ${(err as Error).message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (watcher) watcher.close();
|
|
77
|
+
watcher = null;
|
|
78
|
+
start();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
start();
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
stop(): void {
|
|
87
|
+
stopped = true;
|
|
88
|
+
if (watcher) {
|
|
89
|
+
watcher.close();
|
|
90
|
+
watcher = null;
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @farmslot/capabilities — machine-local capability primitives shared by the node
|
|
2
|
+
// (primary owner) and the gateway (local fallback when no node). ADR-046.
|
|
3
|
+
|
|
4
|
+
export { expandTilde, type FileWatchHandle, watchFile } from './fs-watch.js';
|
|
5
|
+
export {
|
|
6
|
+
type DecodedNodeFrame,
|
|
7
|
+
decodeNodeFrame,
|
|
8
|
+
encodeNodeFrame,
|
|
9
|
+
NODE_FRAME_MAGIC,
|
|
10
|
+
} from './screen-frame.js';
|
|
11
|
+
export {
|
|
12
|
+
createH264FrameSplitter,
|
|
13
|
+
type H264FrameHandler,
|
|
14
|
+
type H264FrameSplitter,
|
|
15
|
+
} from './screen-h264.js';
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Shared node→gateway capture-frame codec (ADR-046). Single source of truth for the
|
|
2
|
+
// binary envelope so the node (encode) and the gateway (decode) never drift. The magic
|
|
3
|
+
// byte lives in @farmslot/protocol; the Buffer-based codec lives here (node-only) so it
|
|
4
|
+
// stays out of the UI-shared protocol bundle.
|
|
5
|
+
//
|
|
6
|
+
// Envelope: [0xAF magic][1B flags][2B width BE][2B height BE][1B slotIdLen][slotId…][payload…]
|
|
7
|
+
// flags bit0 = keyframe.
|
|
8
|
+
|
|
9
|
+
import { NODE_FRAME_MAGIC } from '@farmslot/protocol';
|
|
10
|
+
|
|
11
|
+
export { NODE_FRAME_MAGIC };
|
|
12
|
+
|
|
13
|
+
const HEADER_FIXED = 7; // magic + flags + width(2) + height(2) + slotIdLen
|
|
14
|
+
|
|
15
|
+
export function encodeNodeFrame(
|
|
16
|
+
slotId: string,
|
|
17
|
+
payload: Uint8Array,
|
|
18
|
+
keyFrame: boolean,
|
|
19
|
+
width: number,
|
|
20
|
+
height: number,
|
|
21
|
+
): Buffer {
|
|
22
|
+
const slotBytes = Buffer.from(slotId, 'utf-8');
|
|
23
|
+
const headerSize = HEADER_FIXED + slotBytes.length;
|
|
24
|
+
const buf = Buffer.alloc(headerSize + payload.length);
|
|
25
|
+
buf[0] = NODE_FRAME_MAGIC;
|
|
26
|
+
buf[1] = keyFrame ? 1 : 0;
|
|
27
|
+
buf.writeUInt16BE(width, 2);
|
|
28
|
+
buf.writeUInt16BE(height, 4);
|
|
29
|
+
buf[6] = slotBytes.length;
|
|
30
|
+
slotBytes.copy(buf, HEADER_FIXED);
|
|
31
|
+
buf.set(payload, headerSize);
|
|
32
|
+
return buf;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DecodedNodeFrame {
|
|
36
|
+
slotId: string;
|
|
37
|
+
payload: Uint8Array;
|
|
38
|
+
keyFrame: boolean;
|
|
39
|
+
width: number;
|
|
40
|
+
height: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Decode a node capture frame. Returns null when the buffer is truncated. Callers
|
|
44
|
+
* should have already checked `raw[0] === NODE_FRAME_MAGIC`. */
|
|
45
|
+
export function decodeNodeFrame(raw: Buffer): DecodedNodeFrame | null {
|
|
46
|
+
if (raw.length < HEADER_FIXED) return null;
|
|
47
|
+
const flags = raw[1];
|
|
48
|
+
const keyFrame = (flags & 1) !== 0;
|
|
49
|
+
const width = raw.readUInt16BE(2);
|
|
50
|
+
const height = raw.readUInt16BE(4);
|
|
51
|
+
const slotIdLen = raw[6];
|
|
52
|
+
if (raw.length < HEADER_FIXED + slotIdLen) return null;
|
|
53
|
+
const slotId = raw.subarray(HEADER_FIXED, HEADER_FIXED + slotIdLen).toString('utf-8');
|
|
54
|
+
const payload = new Uint8Array(
|
|
55
|
+
raw.buffer,
|
|
56
|
+
raw.byteOffset + HEADER_FIXED + slotIdLen,
|
|
57
|
+
raw.length - HEADER_FIXED - slotIdLen,
|
|
58
|
+
);
|
|
59
|
+
return { slotId, payload, keyFrame, width, height };
|
|
60
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Shared H.264 frame splitter. The node and the gateway both read the raw byte stream
|
|
2
|
+
// from `adb screenrecord --output-format=h264` and slice it into individual video frames
|
|
3
|
+
// the same way: split on the 00 00 00 01 NAL start codes and group the NAL units into
|
|
4
|
+
// frames (SPS + PPS + IDR = a keyframe, a lone slice = a delta frame). This logic was
|
|
5
|
+
// copied verbatim into both services; it now lives in one place.
|
|
6
|
+
|
|
7
|
+
export type H264FrameHandler = (frame: Uint8Array, keyFrame: boolean) => void;
|
|
8
|
+
|
|
9
|
+
export interface H264FrameSplitter {
|
|
10
|
+
/** Feed a raw stdout chunk; emits complete video frames via the handler. */
|
|
11
|
+
push(chunk: Buffer): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function concatBuffers(parts: Buffer[]): Uint8Array {
|
|
15
|
+
const total = parts.reduce((s, p) => s + p.length, 0);
|
|
16
|
+
const out = new Uint8Array(total);
|
|
17
|
+
let offset = 0;
|
|
18
|
+
for (const p of parts) {
|
|
19
|
+
out.set(p, offset);
|
|
20
|
+
offset += p.length;
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createH264FrameSplitter(onFrame: H264FrameHandler): H264FrameSplitter {
|
|
26
|
+
let buffer = Buffer.alloc(0);
|
|
27
|
+
|
|
28
|
+
function drain(): void {
|
|
29
|
+
const buf = buffer;
|
|
30
|
+
const startCodes: number[] = [];
|
|
31
|
+
|
|
32
|
+
// Find all 4-byte start codes.
|
|
33
|
+
for (let i = 0; i <= buf.length - 4; i++) {
|
|
34
|
+
if (buf[i] === 0 && buf[i + 1] === 0 && buf[i + 2] === 0 && buf[i + 3] === 1) {
|
|
35
|
+
startCodes.push(i);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (startCodes.length < 2) return; // need at least 2 start codes to extract a NAL
|
|
40
|
+
|
|
41
|
+
let consumed = 0;
|
|
42
|
+
let frameStart = -1;
|
|
43
|
+
let frameIsKey = false;
|
|
44
|
+
let frameData: Buffer[] = [];
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < startCodes.length - 1; i++) {
|
|
47
|
+
const nalStart = startCodes[i];
|
|
48
|
+
const nalEnd = startCodes[i + 1];
|
|
49
|
+
const nalType = buf[nalStart + 4] & 0x1f;
|
|
50
|
+
|
|
51
|
+
if (nalType === 7 || nalType === 8) {
|
|
52
|
+
// SPS or PPS — start accumulating a keyframe.
|
|
53
|
+
if (frameStart === -1) frameStart = nalStart;
|
|
54
|
+
frameIsKey = true;
|
|
55
|
+
frameData.push(buf.subarray(nalStart, nalEnd));
|
|
56
|
+
} else if (nalType === 5) {
|
|
57
|
+
// IDR slice — completes the keyframe.
|
|
58
|
+
if (frameStart === -1) {
|
|
59
|
+
frameStart = nalStart;
|
|
60
|
+
frameIsKey = true;
|
|
61
|
+
}
|
|
62
|
+
frameData.push(buf.subarray(nalStart, nalEnd));
|
|
63
|
+
onFrame(concatBuffers(frameData), true);
|
|
64
|
+
consumed = nalEnd;
|
|
65
|
+
frameStart = -1;
|
|
66
|
+
frameIsKey = false;
|
|
67
|
+
frameData = [];
|
|
68
|
+
} else if (nalType === 1) {
|
|
69
|
+
// P-slice — standalone frame.
|
|
70
|
+
if (frameData.length > 0) {
|
|
71
|
+
// Flush accumulated SPS/PPS without IDR (shouldn't happen, but safe).
|
|
72
|
+
onFrame(concatBuffers(frameData), frameIsKey);
|
|
73
|
+
frameData = [];
|
|
74
|
+
frameIsKey = false;
|
|
75
|
+
frameStart = -1;
|
|
76
|
+
}
|
|
77
|
+
onFrame(new Uint8Array(buf.buffer, buf.byteOffset + nalStart, nalEnd - nalStart), false);
|
|
78
|
+
consumed = nalEnd;
|
|
79
|
+
} else {
|
|
80
|
+
// Skip other NAL types (SEI=6, AUD=9, etc.).
|
|
81
|
+
if (frameStart !== -1) {
|
|
82
|
+
frameData.push(buf.subarray(nalStart, nalEnd));
|
|
83
|
+
} else {
|
|
84
|
+
consumed = nalEnd;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Keep unconsumed data (last incomplete NAL + any accumulated frame data).
|
|
90
|
+
if (consumed > 0) {
|
|
91
|
+
buffer = Buffer.from(buf.subarray(frameData.length > 0 ? frameStart! : consumed));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
push(chunk: Buffer): void {
|
|
97
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
98
|
+
drain();
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../apps/command-center/tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"paths": {
|
|
6
|
+
"@farmslot/protocol": ["../protocol/src/index.ts"],
|
|
7
|
+
"@farmslot/protocol/*": ["../protocol/src/*"]
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*.ts"],
|
|
11
|
+
"exclude": ["src/**/*.test.ts"]
|
|
12
|
+
}
|