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 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) {
@@ -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.send(JSON.stringify({ type: 'screen', data: session.getScreenData() }));
23
- ws.send(JSON.stringify({ type: 'status', data: session.status }));
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
  }