@tsufbarkai/relay-stream 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/README.md +54 -0
- package/bin/relay-stream.js +336 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @relay/stream
|
|
2
|
+
|
|
3
|
+
Stream your terminal output to a Relay session in real time.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @relay/stream
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
All modes require `--relay <id>` and `--token <jwt>` (or the environment variables `RELAY_ID` and `RELAY_TOKEN`). Use `--url <base>` to point at a non-default API server (defaults to `http://localhost:3002`).
|
|
14
|
+
|
|
15
|
+
### Pipe mode
|
|
16
|
+
|
|
17
|
+
Pipe any command's output into a Relay tile:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
some-command | relay-stream --relay <id> --token <jwt>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
### Wrap mode
|
|
24
|
+
|
|
25
|
+
Wrap a command — stdout and stderr are captured and streamed:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
relay-stream --relay <id> --token <jwt> -- npm test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### PTY mode
|
|
32
|
+
|
|
33
|
+
Stream a full interactive terminal session with ANSI colors and cursor control:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
relay-stream --relay <id> --token <jwt> --pty
|
|
37
|
+
relay-stream --relay <id> --token <jwt> --pty -- python3
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
PTY mode requires the optional `node-pty-prebuilt-multiarch` dependency, which is installed automatically on supported platforms. If it fails to install (e.g. missing build tools), pipe and wrap modes still work.
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
| Flag | Env variable | Description |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--relay <id>` | `RELAY_ID` | Relay ID to stream to (required) |
|
|
47
|
+
| `--token <jwt>` | `RELAY_TOKEN` | JWT auth token (required) |
|
|
48
|
+
| `--url <base>` | `RELAY_URL` | API base URL (default: `http://localhost:3002`) |
|
|
49
|
+
| `--label <name>` | — | Display label for the stream tile |
|
|
50
|
+
| `--pty` | — | Enable PTY mode |
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ─── HTTP helper ─────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function post(url, body, token) {
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const parsed = new URL(url);
|
|
9
|
+
const lib = parsed.protocol === 'https:' ? require('https') : require('http');
|
|
10
|
+
const data = JSON.stringify(body);
|
|
11
|
+
const req = lib.request(
|
|
12
|
+
{
|
|
13
|
+
hostname: parsed.hostname,
|
|
14
|
+
port: parsed.port,
|
|
15
|
+
path: parsed.pathname + parsed.search,
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: {
|
|
18
|
+
'Content-Type': 'application/json',
|
|
19
|
+
'Content-Length': Buffer.byteLength(data),
|
|
20
|
+
Authorization: `Bearer ${token}`,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
(res) => {
|
|
24
|
+
let body = '';
|
|
25
|
+
res.on('data', (d) => (body += d));
|
|
26
|
+
res.on('end', () => {
|
|
27
|
+
try {
|
|
28
|
+
resolve({ status: res.statusCode, body: JSON.parse(body) });
|
|
29
|
+
} catch {
|
|
30
|
+
resolve({ status: res.statusCode, body });
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
);
|
|
35
|
+
req.on('error', reject);
|
|
36
|
+
req.write(data);
|
|
37
|
+
req.end();
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function parseArgs(argv) {
|
|
44
|
+
const result = {
|
|
45
|
+
token: process.env.RELAY_TOKEN || null,
|
|
46
|
+
relay: process.env.RELAY_ID || null,
|
|
47
|
+
url: process.env.RELAY_URL || 'http://localhost:3002',
|
|
48
|
+
label: null,
|
|
49
|
+
pty: false,
|
|
50
|
+
command: [],
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let i = 0;
|
|
54
|
+
while (i < argv.length) {
|
|
55
|
+
const arg = argv[i];
|
|
56
|
+
if (arg === '--') {
|
|
57
|
+
result.command = argv.slice(i + 1);
|
|
58
|
+
break;
|
|
59
|
+
} else if (arg === '--token' && argv[i + 1]) {
|
|
60
|
+
result.token = argv[++i];
|
|
61
|
+
} else if (arg === '--relay' && argv[i + 1]) {
|
|
62
|
+
result.relay = argv[++i];
|
|
63
|
+
} else if (arg === '--url' && argv[i + 1]) {
|
|
64
|
+
result.url = argv[++i];
|
|
65
|
+
} else if (arg === '--label' && argv[i + 1]) {
|
|
66
|
+
result.label = argv[++i];
|
|
67
|
+
} else if (arg === '--pty') {
|
|
68
|
+
result.pty = true;
|
|
69
|
+
}
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
async function main() {
|
|
79
|
+
const args = parseArgs(process.argv.slice(2));
|
|
80
|
+
|
|
81
|
+
if (!args.token) {
|
|
82
|
+
process.stderr.write('Error: --token or RELAY_TOKEN required\n');
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
if (!args.relay) {
|
|
86
|
+
process.stderr.write('Error: --relay or RELAY_ID required\n');
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { token, relay, url: baseUrl, label, command, pty: isPtyMode } = args;
|
|
91
|
+
const isWrapMode = !isPtyMode && command.length > 0;
|
|
92
|
+
const tileLabel =
|
|
93
|
+
label ||
|
|
94
|
+
(isPtyMode
|
|
95
|
+
? command.length > 0
|
|
96
|
+
? command.join(' ')
|
|
97
|
+
: 'terminal session'
|
|
98
|
+
: isWrapMode
|
|
99
|
+
? command.join(' ')
|
|
100
|
+
: 'stdin stream');
|
|
101
|
+
|
|
102
|
+
// ── Create stream tile ───────────────────────────────────────────────────
|
|
103
|
+
let tileId;
|
|
104
|
+
try {
|
|
105
|
+
const tileBody = { type: 'stream', content: '', label: tileLabel, status: 'running' };
|
|
106
|
+
if (isPtyMode) tileBody.terminal = true;
|
|
107
|
+
const res = await post(
|
|
108
|
+
`${baseUrl}/api/relays/${relay}/tiles`,
|
|
109
|
+
tileBody,
|
|
110
|
+
token
|
|
111
|
+
);
|
|
112
|
+
if (res.status < 200 || res.status >= 300) {
|
|
113
|
+
process.stderr.write(
|
|
114
|
+
`[relay-stream] failed to create tile (HTTP ${res.status}): ${JSON.stringify(res.body)}\n`
|
|
115
|
+
);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
tileId = res.body.id;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
process.stderr.write(`[relay-stream] failed to create tile: ${err.message}\n`);
|
|
121
|
+
process.exit(1);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
process.stderr.write(
|
|
125
|
+
`[relay-stream] streaming to relay ${relay}, tile ${tileId}\n[relay-stream] ${baseUrl}/api/relays/${relay}/tiles/${tileId}\n`
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// ── Buffer + flush machinery ─────────────────────────────────────────────
|
|
129
|
+
const FLUSH_INTERVAL_MS = 200;
|
|
130
|
+
const FLUSH_SIZE_BYTES = 4 * 1024; // 4 KB
|
|
131
|
+
|
|
132
|
+
let buffer = '';
|
|
133
|
+
let flushTimer = null;
|
|
134
|
+
|
|
135
|
+
async function flushBuffer() {
|
|
136
|
+
if (!buffer) return;
|
|
137
|
+
const chunk = buffer;
|
|
138
|
+
buffer = '';
|
|
139
|
+
try {
|
|
140
|
+
const res = await post(
|
|
141
|
+
`${baseUrl}/api/relays/${relay}/tiles/${tileId}/stream`,
|
|
142
|
+
{ chunk },
|
|
143
|
+
token
|
|
144
|
+
);
|
|
145
|
+
if (res.status < 200 || res.status >= 300) {
|
|
146
|
+
process.stderr.write(
|
|
147
|
+
`[relay-stream] stream chunk failed (HTTP ${res.status}): ${JSON.stringify(res.body)}\n`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
process.stderr.write(`[relay-stream] stream chunk error: ${err.message}\n`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function scheduleFlush() {
|
|
156
|
+
if (!flushTimer) {
|
|
157
|
+
flushTimer = setTimeout(async () => {
|
|
158
|
+
flushTimer = null;
|
|
159
|
+
await flushBuffer();
|
|
160
|
+
}, FLUSH_INTERVAL_MS);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function appendChunk(text) {
|
|
165
|
+
buffer += text;
|
|
166
|
+
if (Buffer.byteLength(buffer) >= FLUSH_SIZE_BYTES) {
|
|
167
|
+
if (flushTimer) {
|
|
168
|
+
clearTimeout(flushTimer);
|
|
169
|
+
flushTimer = null;
|
|
170
|
+
}
|
|
171
|
+
flushBuffer();
|
|
172
|
+
} else {
|
|
173
|
+
scheduleFlush();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function closeStream(exitCode) {
|
|
178
|
+
if (flushTimer) {
|
|
179
|
+
clearTimeout(flushTimer);
|
|
180
|
+
flushTimer = null;
|
|
181
|
+
}
|
|
182
|
+
await flushBuffer();
|
|
183
|
+
|
|
184
|
+
const status = exitCode === 0 ? 'done' : 'error';
|
|
185
|
+
try {
|
|
186
|
+
const res = await post(
|
|
187
|
+
`${baseUrl}/api/relays/${relay}/tiles/${tileId}/stream/close`,
|
|
188
|
+
{ status },
|
|
189
|
+
token
|
|
190
|
+
);
|
|
191
|
+
if (res.status < 200 || res.status >= 300) {
|
|
192
|
+
process.stderr.write(
|
|
193
|
+
`[relay-stream] close failed (HTTP ${res.status}): ${JSON.stringify(res.body)}\n`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
process.stderr.write(`[relay-stream] close error: ${err.message}\n`);
|
|
198
|
+
}
|
|
199
|
+
process.stderr.write('[relay-stream] done\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Mode: pty (interactive terminal) ────────────────────────────────────
|
|
203
|
+
if (isPtyMode) {
|
|
204
|
+
let pty;
|
|
205
|
+
try {
|
|
206
|
+
pty = require('node-pty-prebuilt-multiarch');
|
|
207
|
+
} catch {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
'PTY mode requires node-pty-prebuilt-multiarch. Install it with: npm install node-pty-prebuilt-multiarch\n'
|
|
210
|
+
);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const shell = String(command.length > 0 ? command[0] : (process.env.SHELL || '/bin/bash'));
|
|
215
|
+
const shellArgs = command.length > 1 ? command.slice(1) : [];
|
|
216
|
+
|
|
217
|
+
if (!shell) {
|
|
218
|
+
process.stderr.write('[relay-stream] could not determine shell to spawn\n');
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ptyProcess = pty.spawn(shell, shellArgs, {
|
|
223
|
+
name: 'xterm-256color',
|
|
224
|
+
cols: process.stdout.columns || 120,
|
|
225
|
+
rows: process.stdout.rows || 30,
|
|
226
|
+
env: process.env,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// Forward PTY output to stdout and relay buffer
|
|
230
|
+
ptyProcess.onData((data) => {
|
|
231
|
+
process.stdout.write(data);
|
|
232
|
+
appendChunk(data);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Forward stdin to PTY
|
|
236
|
+
if (process.stdin.isTTY) {
|
|
237
|
+
process.stdin.setRawMode(true);
|
|
238
|
+
}
|
|
239
|
+
process.stdin.resume();
|
|
240
|
+
process.stdin.on('data', (data) => {
|
|
241
|
+
ptyProcess.write(data.toString());
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Handle terminal resize
|
|
245
|
+
process.stdout.on('resize', () => {
|
|
246
|
+
ptyProcess.resize(process.stdout.columns, process.stdout.rows);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// Cleanup helper
|
|
250
|
+
let exiting = false;
|
|
251
|
+
async function cleanup(exitCode) {
|
|
252
|
+
if (exiting) return;
|
|
253
|
+
exiting = true;
|
|
254
|
+
try {
|
|
255
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
256
|
+
} catch {}
|
|
257
|
+
await closeStream(exitCode);
|
|
258
|
+
process.exit(exitCode);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Handle signals
|
|
262
|
+
process.on('SIGINT', () => {
|
|
263
|
+
ptyProcess.kill();
|
|
264
|
+
cleanup(0);
|
|
265
|
+
});
|
|
266
|
+
process.on('SIGTERM', () => {
|
|
267
|
+
ptyProcess.kill();
|
|
268
|
+
cleanup(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Handle PTY exit
|
|
272
|
+
ptyProcess.onExit(({ exitCode }) => {
|
|
273
|
+
cleanup(exitCode ?? 0);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return; // let PTY drive exit
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── Mode: wrap (spawn child) ─────────────────────────────────────────────
|
|
280
|
+
if (isWrapMode) {
|
|
281
|
+
const { spawn } = require('child_process');
|
|
282
|
+
const [cmd, ...cmdArgs] = command;
|
|
283
|
+
const child = spawn(cmd, cmdArgs, {
|
|
284
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
285
|
+
env: process.env,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
child.stdout.on('data', (chunk) => {
|
|
289
|
+
process.stdout.write(chunk);
|
|
290
|
+
appendChunk(chunk.toString());
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
child.stderr.on('data', (chunk) => {
|
|
294
|
+
process.stderr.write(chunk);
|
|
295
|
+
appendChunk(chunk.toString());
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
child.on('close', async (code) => {
|
|
299
|
+
const exitCode = code ?? 1;
|
|
300
|
+
await closeStream(exitCode);
|
|
301
|
+
process.exit(exitCode);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
child.on('error', async (err) => {
|
|
305
|
+
process.stderr.write(`[relay-stream] failed to spawn command: ${err.message}\n`);
|
|
306
|
+
await closeStream(1);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
return; // let child drive exit
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Mode: pipe (read from stdin) ─────────────────────────────────────────
|
|
314
|
+
const readline = require('readline');
|
|
315
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
316
|
+
|
|
317
|
+
rl.on('line', (line) => {
|
|
318
|
+
appendChunk(line + '\n');
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
rl.on('close', async () => {
|
|
322
|
+
await closeStream(0);
|
|
323
|
+
process.exit(0);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
process.stdin.on('error', async (err) => {
|
|
327
|
+
process.stderr.write(`[relay-stream] stdin error: ${err.message}\n`);
|
|
328
|
+
await closeStream(1);
|
|
329
|
+
process.exit(1);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
main().catch((err) => {
|
|
334
|
+
process.stderr.write(`[relay-stream] unexpected error: ${err.message}\n`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tsufbarkai/relay-stream",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stream your terminal to a Relay session",
|
|
5
|
+
"bin": {
|
|
6
|
+
"relay-stream": "./bin/relay-stream.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin/"
|
|
10
|
+
],
|
|
11
|
+
"keywords": [
|
|
12
|
+
"relay",
|
|
13
|
+
"terminal",
|
|
14
|
+
"stream",
|
|
15
|
+
"pty",
|
|
16
|
+
"cli"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/tsufbarkai/relay.git",
|
|
22
|
+
"directory": "packages/relay-cli"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"postinstall": "node -e \"require('fs').chmodSync(require('path').join(__dirname, 'bin/relay-stream.js'), '755')\""
|
|
32
|
+
},
|
|
33
|
+
"optionalDependencies": {
|
|
34
|
+
"node-pty-prebuilt-multiarch": "^0.10.0"
|
|
35
|
+
}
|
|
36
|
+
}
|