green-screen-proxy 0.3.0 → 0.4.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/dist/cli.js +13 -1
- package/dist/deploy.d.ts +1 -0
- package/dist/deploy.js +251 -0
- package/dist/websocket.js +87 -7
- package/dist/worker/index.js +5176 -0
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Check for deploy subcommand first (before parseArgs)
|
|
3
|
+
const subcommand = process.argv[2];
|
|
4
|
+
if (subcommand === 'deploy') {
|
|
5
|
+
const { deploy } = await import('./deploy.js');
|
|
6
|
+
deploy(process.argv.slice(3));
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
2
9
|
import { parseArgs } from 'node:util';
|
|
3
10
|
const { values } = parseArgs({
|
|
4
11
|
options: {
|
|
@@ -11,6 +18,10 @@ if (values.help) {
|
|
|
11
18
|
console.log(`green-screen-proxy — WebSocket/REST proxy for legacy terminal connections
|
|
12
19
|
|
|
13
20
|
Usage: green-screen-proxy [options]
|
|
21
|
+
green-screen-proxy deploy [deploy-options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
deploy Deploy as a Cloudflare Worker (run "deploy --help" for options)
|
|
14
25
|
|
|
15
26
|
Options:
|
|
16
27
|
--mock Run with mock data (no real host connection needed)
|
|
@@ -20,7 +31,8 @@ Options:
|
|
|
20
31
|
Examples:
|
|
21
32
|
npx green-screen-proxy # Start proxy on port 3001
|
|
22
33
|
npx green-screen-proxy --mock # Start with mock screens
|
|
23
|
-
npx green-screen-proxy --port 8080 # Start on port 8080
|
|
34
|
+
npx green-screen-proxy --port 8080 # Start on port 8080
|
|
35
|
+
npx green-screen-proxy deploy # Deploy to Cloudflare Workers`);
|
|
24
36
|
process.exit(0);
|
|
25
37
|
}
|
|
26
38
|
if (values.port) {
|
package/dist/deploy.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function deploy(args: string[]): void;
|
package/dist/deploy.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
2
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, appendFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { dirname } from 'path';
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const WRANGLER_TOML = `name = "__WORKER_NAME__"
|
|
10
|
+
main = "index.js"
|
|
11
|
+
compatibility_date = "2024-12-18"
|
|
12
|
+
compatibility_flags = ["nodejs_compat"]
|
|
13
|
+
|
|
14
|
+
[durable_objects]
|
|
15
|
+
bindings = [
|
|
16
|
+
{ name = "TERMINAL_SESSION", class_name = "TerminalSession" }
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[[migrations]]
|
|
20
|
+
tag = "v1"
|
|
21
|
+
new_sqlite_classes = ["TerminalSession"]
|
|
22
|
+
`;
|
|
23
|
+
function printHelp() {
|
|
24
|
+
console.log(`green-screen-proxy deploy — Deploy the Cloudflare Worker for browser-to-host connections
|
|
25
|
+
|
|
26
|
+
Usage: green-screen-proxy deploy [options]
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--name NAME Worker name (default: green-screen-worker)
|
|
30
|
+
--origins URL,... CORS allowed origins, comma-separated (default: * = all)
|
|
31
|
+
-h, --help Show this help message
|
|
32
|
+
|
|
33
|
+
Examples:
|
|
34
|
+
npx green-screen-proxy deploy
|
|
35
|
+
npx green-screen-proxy deploy --name my-terminal-worker
|
|
36
|
+
npx green-screen-proxy deploy --origins https://myapp.com,https://staging.myapp.com
|
|
37
|
+
|
|
38
|
+
Prerequisites:
|
|
39
|
+
- A free Cloudflare account (https://dash.cloudflare.com/sign-up)
|
|
40
|
+
- Wrangler CLI (installed automatically if missing)`);
|
|
41
|
+
}
|
|
42
|
+
function runCommand(command, args, options) {
|
|
43
|
+
const result = spawnSync(command, args, {
|
|
44
|
+
stdio: options?.stdio ?? 'pipe',
|
|
45
|
+
cwd: options?.cwd,
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
ok: result.status === 0,
|
|
50
|
+
stdout: typeof result.stdout === 'string' ? result.stdout : '',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function checkWrangler() {
|
|
54
|
+
return runCommand('npx', ['wrangler', '--version']).ok;
|
|
55
|
+
}
|
|
56
|
+
function installWrangler() {
|
|
57
|
+
console.log('\nInstalling wrangler...');
|
|
58
|
+
const result = runCommand('npm', ['install', '-g', 'wrangler'], { stdio: 'inherit' });
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
console.error('Failed to install wrangler. Please install it manually:');
|
|
61
|
+
console.error(' npm install -g wrangler');
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
console.log('Wrangler installed successfully.\n');
|
|
65
|
+
}
|
|
66
|
+
function checkAuth() {
|
|
67
|
+
const result = runCommand('npx', ['wrangler', 'whoami']);
|
|
68
|
+
return result.ok && !result.stdout.includes('not authenticated');
|
|
69
|
+
}
|
|
70
|
+
function login() {
|
|
71
|
+
console.log('\nYou need to log in to Cloudflare first.\n');
|
|
72
|
+
const result = runCommand('npx', ['wrangler', 'login'], { stdio: 'inherit' });
|
|
73
|
+
if (!result.ok) {
|
|
74
|
+
console.error('Login failed. Please try: npx wrangler login');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export function deploy(args) {
|
|
79
|
+
const options = parseDeployArgs(args);
|
|
80
|
+
if (options.help) {
|
|
81
|
+
printHelp();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
console.log('green-screen-proxy deploy\n');
|
|
85
|
+
// Step 1: Check wrangler
|
|
86
|
+
console.log('Checking for wrangler...');
|
|
87
|
+
if (!checkWrangler()) {
|
|
88
|
+
console.log('Wrangler not found.');
|
|
89
|
+
installWrangler();
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
console.log('Wrangler found.\n');
|
|
93
|
+
}
|
|
94
|
+
// Step 2: Check auth
|
|
95
|
+
console.log('Checking Cloudflare authentication...');
|
|
96
|
+
if (!checkAuth()) {
|
|
97
|
+
login();
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.log('Authenticated.\n');
|
|
101
|
+
}
|
|
102
|
+
// Step 3: Prepare temp directory with worker bundle
|
|
103
|
+
console.log('Preparing worker bundle...');
|
|
104
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'green-screen-worker-'));
|
|
105
|
+
// Read the pre-built worker bundle
|
|
106
|
+
const workerBundlePath = join(__dirname, 'worker', 'index.js');
|
|
107
|
+
let workerCode;
|
|
108
|
+
try {
|
|
109
|
+
workerCode = readFileSync(workerBundlePath, 'utf-8');
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
console.error(`Worker bundle not found at ${workerBundlePath}`);
|
|
113
|
+
console.error('This is a packaging error. Please report it at:');
|
|
114
|
+
console.error(' https://github.com/visionbridge-solutions/green-screen-react/issues');
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
// Step 4: Inject CORS origins
|
|
118
|
+
workerCode = workerCode.replace('__CORS_ORIGINS_PLACEHOLDER__', options.origins);
|
|
119
|
+
// Write files to temp dir
|
|
120
|
+
writeFileSync(join(tmpDir, 'index.js'), workerCode);
|
|
121
|
+
writeFileSync(join(tmpDir, 'wrangler.toml'), WRANGLER_TOML.replace('__WORKER_NAME__', options.name));
|
|
122
|
+
console.log(`Worker name: ${options.name}`);
|
|
123
|
+
console.log(`CORS origins: ${options.origins}\n`);
|
|
124
|
+
// Step 5: Deploy
|
|
125
|
+
console.log('Deploying to Cloudflare...\n');
|
|
126
|
+
const result = runCommand('npx', ['wrangler', 'deploy'], { cwd: tmpDir, stdio: ['inherit', 'pipe', 'inherit'] });
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
console.error('\nDeployment failed. Check the error above.');
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
// Extract URL from wrangler output
|
|
132
|
+
const urlMatch = result.stdout.match(/https:\/\/[\w.-]+\.workers\.dev/);
|
|
133
|
+
const workerUrl = urlMatch ? urlMatch[0] : null;
|
|
134
|
+
console.log('\nWorker deployed successfully!\n');
|
|
135
|
+
if (workerUrl) {
|
|
136
|
+
console.log(`Worker URL: ${workerUrl}\n`);
|
|
137
|
+
// Try to save URL to .env.local automatically
|
|
138
|
+
const saved = saveWorkerUrl(workerUrl);
|
|
139
|
+
if (saved) {
|
|
140
|
+
console.log(`Saved to ${saved}\n`);
|
|
141
|
+
console.log('Use it in your React app:\n');
|
|
142
|
+
console.log(` import { GreenScreenTerminal, WebSocketAdapter } from 'green-screen-react';`);
|
|
143
|
+
console.log(` import 'green-screen-react/styles.css';`);
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log(` const adapter = new WebSocketAdapter({`);
|
|
146
|
+
console.log(` workerUrl: process.env.${getEnvVarName(saved)}`);
|
|
147
|
+
console.log(` });`);
|
|
148
|
+
console.log('');
|
|
149
|
+
console.log(` <GreenScreenTerminal adapter={adapter} />`);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.log('Use it in your React app:\n');
|
|
153
|
+
console.log(` import { GreenScreenTerminal, WebSocketAdapter } from 'green-screen-react';`);
|
|
154
|
+
console.log(` import 'green-screen-react/styles.css';`);
|
|
155
|
+
console.log('');
|
|
156
|
+
console.log(` const adapter = new WebSocketAdapter({`);
|
|
157
|
+
console.log(` workerUrl: '${workerUrl}'`);
|
|
158
|
+
console.log(` });`);
|
|
159
|
+
console.log('');
|
|
160
|
+
console.log(` <GreenScreenTerminal adapter={adapter} />`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Detect the project framework and save the worker URL to the appropriate .env.local file.
|
|
166
|
+
* Returns the file path if saved, null if detection failed.
|
|
167
|
+
*/
|
|
168
|
+
function saveWorkerUrl(url) {
|
|
169
|
+
const cwd = process.cwd();
|
|
170
|
+
// Check for package.json to detect framework
|
|
171
|
+
const pkgPath = join(cwd, 'package.json');
|
|
172
|
+
if (!existsSync(pkgPath))
|
|
173
|
+
return null;
|
|
174
|
+
let pkg;
|
|
175
|
+
try {
|
|
176
|
+
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
182
|
+
const envFile = join(cwd, '.env.local');
|
|
183
|
+
// Detect framework and choose env var prefix
|
|
184
|
+
let envVar;
|
|
185
|
+
if (deps['next']) {
|
|
186
|
+
envVar = `NEXT_PUBLIC_GREEN_SCREEN_URL=${url}`;
|
|
187
|
+
}
|
|
188
|
+
else if (deps['vite'] || deps['@vitejs/plugin-react']) {
|
|
189
|
+
envVar = `VITE_GREEN_SCREEN_URL=${url}`;
|
|
190
|
+
}
|
|
191
|
+
else if (deps['react-scripts']) {
|
|
192
|
+
envVar = `REACT_APP_GREEN_SCREEN_URL=${url}`;
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Generic — use VITE_ as most common
|
|
196
|
+
envVar = `VITE_GREEN_SCREEN_URL=${url}`;
|
|
197
|
+
}
|
|
198
|
+
// Append to .env.local (create if needed, don't overwrite existing)
|
|
199
|
+
try {
|
|
200
|
+
if (existsSync(envFile)) {
|
|
201
|
+
const content = readFileSync(envFile, 'utf-8');
|
|
202
|
+
// Check if already set
|
|
203
|
+
const varName = envVar.split('=')[0];
|
|
204
|
+
if (content.includes(varName)) {
|
|
205
|
+
// Update existing value
|
|
206
|
+
const updated = content.replace(new RegExp(`^${varName}=.*$`, 'm'), envVar);
|
|
207
|
+
writeFileSync(envFile, updated);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
appendFileSync(envFile, `\n${envVar}\n`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
writeFileSync(envFile, `${envVar}\n`);
|
|
215
|
+
}
|
|
216
|
+
return envFile;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function getEnvVarName(envFile) {
|
|
223
|
+
try {
|
|
224
|
+
const content = readFileSync(envFile, 'utf-8');
|
|
225
|
+
const match = content.match(/((?:VITE|NEXT_PUBLIC|REACT_APP)_GREEN_SCREEN_URL)=/);
|
|
226
|
+
return match ? match[1] : 'VITE_GREEN_SCREEN_URL';
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return 'VITE_GREEN_SCREEN_URL';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function parseDeployArgs(args) {
|
|
233
|
+
const options = {
|
|
234
|
+
name: 'green-screen-worker',
|
|
235
|
+
origins: '*',
|
|
236
|
+
help: false,
|
|
237
|
+
};
|
|
238
|
+
for (let i = 0; i < args.length; i++) {
|
|
239
|
+
const arg = args[i];
|
|
240
|
+
if (arg === '--name' && i + 1 < args.length) {
|
|
241
|
+
options.name = args[++i];
|
|
242
|
+
}
|
|
243
|
+
else if (arg === '--origins' && i + 1 < args.length) {
|
|
244
|
+
options.origins = args[++i];
|
|
245
|
+
}
|
|
246
|
+
else if (arg === '-h' || arg === '--help') {
|
|
247
|
+
options.help = true;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return options;
|
|
251
|
+
}
|
package/dist/websocket.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
2
2
|
import { URL } from 'url';
|
|
3
|
-
import { getSession, getDefaultSession } from './session.js';
|
|
3
|
+
import { createSession, getSession, getDefaultSession, destroySession, } from './session.js';
|
|
4
4
|
const clients = new Set();
|
|
5
5
|
export function setupWebSocket(server) {
|
|
6
6
|
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
7
7
|
wss.on('connection', (ws, req) => {
|
|
8
|
-
// Extract sessionId from query params
|
|
9
8
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
|
10
9
|
const sessionId = url.searchParams.get('sessionId');
|
|
11
10
|
const client = { ws, sessionId };
|
|
@@ -16,15 +15,99 @@ export function setupWebSocket(server) {
|
|
|
16
15
|
ws.on('error', () => {
|
|
17
16
|
clients.delete(client);
|
|
18
17
|
});
|
|
18
|
+
// Handle incoming commands (bidirectional protocol)
|
|
19
|
+
ws.on('message', async (raw) => {
|
|
20
|
+
try {
|
|
21
|
+
const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString());
|
|
22
|
+
await handleWsCommand(ws, client, msg);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
wsSend(ws, { type: 'error', message: err instanceof Error ? err.message : String(err) });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
19
28
|
// Send current screen immediately if a session is available
|
|
20
29
|
const session = sessionId ? getSession(sessionId) : getDefaultSession();
|
|
21
30
|
if (session && session.status.connected) {
|
|
22
|
-
ws
|
|
23
|
-
ws
|
|
31
|
+
wsSend(ws, { type: 'screen', data: session.getScreenData() });
|
|
32
|
+
wsSend(ws, { type: 'status', data: session.status });
|
|
24
33
|
}
|
|
25
34
|
});
|
|
26
35
|
return wss;
|
|
27
36
|
}
|
|
37
|
+
/** Handle an incoming WebSocket command from the client */
|
|
38
|
+
async function handleWsCommand(ws, client, msg) {
|
|
39
|
+
switch (msg.type) {
|
|
40
|
+
case 'connect': {
|
|
41
|
+
const { host = 'pub400.com', port = 23, protocol = 'tn5250' } = msg;
|
|
42
|
+
const session = createSession(protocol);
|
|
43
|
+
client.sessionId = session.id;
|
|
44
|
+
bindSessionToWebSocket(session);
|
|
45
|
+
wsSend(ws, { type: 'status', data: { connected: false, status: 'connecting', protocol, host } });
|
|
46
|
+
try {
|
|
47
|
+
await session.connect(host, port);
|
|
48
|
+
wsSend(ws, { type: 'status', data: { connected: true, status: 'connected', protocol, host } });
|
|
49
|
+
// Wait for initial screen data
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
51
|
+
const screenData = session.getScreenData();
|
|
52
|
+
wsSend(ws, { type: 'screen', data: screenData });
|
|
53
|
+
wsSend(ws, { type: 'connected', sessionId: session.id });
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
57
|
+
wsSend(ws, { type: 'error', message });
|
|
58
|
+
wsSend(ws, { type: 'status', data: { connected: false, status: 'error', protocol, host, error: message } });
|
|
59
|
+
}
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
case 'text': {
|
|
63
|
+
const session = resolveSession(client);
|
|
64
|
+
if (!session) {
|
|
65
|
+
wsSend(ws, { type: 'error', message: 'Not connected' });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
session.sendText(msg.text);
|
|
69
|
+
const screenData = session.getScreenData();
|
|
70
|
+
wsSend(ws, { type: 'screen', data: screenData });
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
case 'key': {
|
|
74
|
+
const session = resolveSession(client);
|
|
75
|
+
if (!session) {
|
|
76
|
+
wsSend(ws, { type: 'error', message: 'Not connected' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const ok = session.sendKey(msg.key);
|
|
80
|
+
if (!ok) {
|
|
81
|
+
wsSend(ws, { type: 'error', message: `Unknown key: ${msg.key}` });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// Wait for host response
|
|
85
|
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
|
86
|
+
const screenData = session.getScreenData();
|
|
87
|
+
wsSend(ws, { type: 'screen', data: screenData });
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
case 'disconnect': {
|
|
91
|
+
const session = resolveSession(client);
|
|
92
|
+
if (session) {
|
|
93
|
+
destroySession(session.id);
|
|
94
|
+
client.sessionId = null;
|
|
95
|
+
}
|
|
96
|
+
wsSend(ws, { type: 'status', data: { connected: false, status: 'disconnected' } });
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function resolveSession(client) {
|
|
102
|
+
if (client.sessionId)
|
|
103
|
+
return getSession(client.sessionId);
|
|
104
|
+
return getDefaultSession();
|
|
105
|
+
}
|
|
106
|
+
function wsSend(ws, data) {
|
|
107
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
108
|
+
ws.send(JSON.stringify(data));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
28
111
|
/** Subscribe to a session's events and push to connected WS clients */
|
|
29
112
|
export function bindSessionToWebSocket(session) {
|
|
30
113
|
session.on('screenChange', (screenData) => {
|
|
@@ -40,9 +123,6 @@ function broadcastToSession(sessionId, message) {
|
|
|
40
123
|
for (const client of clients) {
|
|
41
124
|
if (client.ws.readyState !== WebSocket.OPEN)
|
|
42
125
|
continue;
|
|
43
|
-
// Send to clients that are either:
|
|
44
|
-
// 1. Explicitly bound to this session
|
|
45
|
-
// 2. Not bound to any session (will receive from default/single session)
|
|
46
126
|
if (client.sessionId === sessionId || client.sessionId === null) {
|
|
47
127
|
client.ws.send(message);
|
|
48
128
|
}
|