@sharnix/agent 1.0.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.
Files changed (2) hide show
  1. package/index.js +244 -0
  2. package/package.json +18 -0
package/index.js ADDED
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const WebSocket = require('ws');
5
+ const http = require('http');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const { randomBytes, createHash } = require('crypto');
10
+
11
+ const RELAY_BASE = (process.env.SHARNIX_URL || 'https://relay.sharnix.com').replace(/\/$/, '');
12
+ const WS_BASE = RELAY_BASE.replace(/^http/, 'ws');
13
+ const API_KEY = process.env.SHARNIX_API_KEY || '';
14
+
15
+ // ── CLI args ──────────────────────────────────────────────────────────────────
16
+ const args = process.argv.slice(2);
17
+ const get = (flag) => {
18
+ const i = args.findIndex((a) => a === flag);
19
+ return i !== -1 ? args[i + 1] : null;
20
+ };
21
+ const has = (flag) => args.includes(flag);
22
+
23
+ const port = parseInt(get('--port') || get('-p') || '3000', 10);
24
+ const label = get('--label') || get('-l') || null;
25
+ const agentName = get('--name') || 'local-dev';
26
+
27
+ if (has('--help') || has('-h')) {
28
+ console.log(`
29
+ sharnix — tunnel your local app through Sharnix
30
+
31
+ Usage:
32
+ npx @sharnix/agent --port <port> [options]
33
+ SHARNIX_API_KEY=shx_... npx @sharnix/agent --port 3000
34
+
35
+ Options:
36
+ --port, -p <n> Local port to forward (default: 3000)
37
+ --label, -l <s> Human-readable label for this tunnel
38
+ --name <s> Agent name used on first setup (default: local-dev)
39
+ --help, -h Show this message
40
+
41
+ Environment:
42
+ SHARNIX_API_KEY API key from relay.sharnix.com/app/settings (required)
43
+ SHARNIX_URL Override relay base URL
44
+ `);
45
+ process.exit(0);
46
+ }
47
+
48
+ if (!API_KEY) {
49
+ console.error('\n Error: SHARNIX_API_KEY is not set.\n');
50
+ console.error(' Get your key at: https://relay.sharnix.com/app/settings\n');
51
+ process.exit(1);
52
+ }
53
+
54
+ if (isNaN(port) || port < 1 || port > 65535) {
55
+ console.error(`\n Error: invalid port "${get('--port') || get('-p')}"\n`);
56
+ process.exit(1);
57
+ }
58
+
59
+ // ── Config persistence ────────────────────────────────────────────────────────
60
+ const CONFIG_DIR = path.join(os.homedir(), '.sharnix');
61
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
62
+
63
+ function loadJson(file) {
64
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
65
+ }
66
+ function saveJson(file, data) {
67
+ fs.writeFileSync(file, JSON.stringify(data, null, 2), { mode: 0o600 });
68
+ }
69
+
70
+ // Stable tunnel ID per working directory — same tunnel across restarts
71
+ function getOrCreateTunnelId() {
72
+ const cwdHash = createHash('sha256').update(process.cwd()).digest('hex').slice(0, 16);
73
+ const f = path.join(CONFIG_DIR, `tunnel-${cwdHash}.json`);
74
+ const existing = loadJson(f);
75
+ if (existing?.tunnelId) return existing.tunnelId;
76
+ const tunnelId = randomBytes(16).toString('hex');
77
+ saveJson(f, { tunnelId, cwd: process.cwd(), createdAt: new Date().toISOString() });
78
+ return tunnelId;
79
+ }
80
+
81
+ // ── REST API helper ───────────────────────────────────────────────────────────
82
+ async function api(method, urlPath, body) {
83
+ const res = await fetch(`${RELAY_BASE}${urlPath}`, {
84
+ method,
85
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${API_KEY}` },
86
+ body: body ? JSON.stringify(body) : undefined,
87
+ });
88
+ const data = await res.json().catch(() => ({}));
89
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
90
+ return data;
91
+ }
92
+
93
+ // ── Bootstrap: create agent credentials once, keyed to this API key ───────────
94
+ async function bootstrap() {
95
+ const credsFile = path.join(CONFIG_DIR, 'agent.json');
96
+ const stored = loadJson(credsFile);
97
+ if (stored?.agentId && stored?.secret && stored?.apiKeyPrefix === API_KEY.slice(0, 12)) {
98
+ return stored;
99
+ }
100
+
101
+ process.stdout.write(' Setting up agent…');
102
+ const orgs = await api('GET', '/api/v1/orgs');
103
+ if (!orgs.length) throw new Error('No workspaces found. Sign up at relay.sharnix.com first.');
104
+ const orgSlug = orgs[0].slug;
105
+
106
+ const agent = await api('POST', `/api/v1/orgs/${orgSlug}/agents`, { name: agentName });
107
+ const creds = {
108
+ agentId: agent.agent_id,
109
+ secret: agent.secret,
110
+ orgSlug,
111
+ apiKeyPrefix: API_KEY.slice(0, 12),
112
+ };
113
+ saveJson(credsFile, creds);
114
+ process.stdout.write(` done (${agent.agent_id})\n`);
115
+ return creds;
116
+ }
117
+
118
+ // ── WebSocket tunnel ──────────────────────────────────────────────────────────
119
+ const MSG = {
120
+ REGISTER: 'REGISTER', RESPONSE: 'RESPONSE',
121
+ HEARTBEAT: 'HEARTBEAT', REQUEST: 'REQUEST', ACK: 'ACK',
122
+ };
123
+
124
+ let ws = null;
125
+ let heartbeatTimer = null;
126
+ let reconnectTimer = null;
127
+ let isShuttingDown = false;
128
+ let creds = null;
129
+ const tunnelId = getOrCreateTunnelId();
130
+
131
+ function connect() {
132
+ if (isShuttingDown) return;
133
+ const wsUrl = `${WS_BASE}?agentId=${encodeURIComponent(creds.agentId)}&secret=${encodeURIComponent(creds.secret)}`;
134
+ ws = new WebSocket(wsUrl);
135
+
136
+ ws.on('open', () => {
137
+ clearTimeout(reconnectTimer);
138
+ ws.send(JSON.stringify({ type: MSG.REGISTER, tunnelId, label: label || agentName }));
139
+ startHeartbeat();
140
+ const tunnelUrl = `${RELAY_BASE}/p/${tunnelId}`;
141
+ console.log(`\n Connected!\n`);
142
+ console.log(` Tunnel URL : ${tunnelUrl}`);
143
+ console.log(` Forwarding : localhost:${port}`);
144
+ console.log(`\n Create a share link:`);
145
+ console.log(` Dashboard → relay.sharnix.com/app/tunnels/${tunnelId}`);
146
+ console.log(` MCP agent → "create a share link for my tunnel"\n`);
147
+ console.log(` Press Ctrl+C to disconnect.\n`);
148
+ });
149
+
150
+ ws.on('message', async (data) => {
151
+ let msg;
152
+ try { msg = JSON.parse(data); } catch { return; }
153
+ if (msg.type === MSG.REQUEST) await forwardRequest(msg);
154
+ });
155
+
156
+ ws.on('close', (code) => {
157
+ clearInterval(heartbeatTimer);
158
+ if (!isShuttingDown) {
159
+ process.stdout.write(`\r Disconnected (${code}). Reconnecting in 5s…`);
160
+ reconnectTimer = setTimeout(connect, 5000);
161
+ }
162
+ });
163
+
164
+ ws.on('error', (err) => {
165
+ if (!isShuttingDown) console.error(`\n Tunnel error: ${err.message}`);
166
+ });
167
+ }
168
+
169
+ function startHeartbeat() {
170
+ clearInterval(heartbeatTimer);
171
+ heartbeatTimer = setInterval(() => {
172
+ if (ws?.readyState === WebSocket.OPEN) {
173
+ ws.send(JSON.stringify({ type: MSG.HEARTBEAT, agentId: creds.agentId }));
174
+ }
175
+ }, 20000);
176
+ }
177
+
178
+ async function forwardRequest(msg) {
179
+ const { requestId, payload } = msg;
180
+
181
+ const headers = { ...payload.headers };
182
+ delete headers['host'];
183
+ delete headers['connection'];
184
+ delete headers['transfer-encoding'];
185
+
186
+ const options = {
187
+ hostname: '127.0.0.1',
188
+ port,
189
+ path: payload.path || '/',
190
+ method: payload.method || 'GET',
191
+ headers,
192
+ };
193
+
194
+ const proxyReq = http.request(options, (proxyRes) => {
195
+ const chunks = [];
196
+ proxyRes.on('data', (c) => chunks.push(c));
197
+ proxyRes.on('end', () => {
198
+ sendResponse(requestId, {
199
+ statusCode: proxyRes.statusCode,
200
+ headers: proxyRes.headers,
201
+ body: Buffer.concat(chunks).toString('base64'),
202
+ bodyEncoding: 'base64',
203
+ });
204
+ });
205
+ proxyRes.on('error', (err) => {
206
+ sendResponse(requestId, { statusCode: 502, headers: { 'content-type': 'text/plain' }, body: err.message });
207
+ });
208
+ });
209
+
210
+ proxyReq.setTimeout(25000, () => {
211
+ proxyReq.destroy();
212
+ sendResponse(requestId, { statusCode: 504, headers: { 'content-type': 'text/plain' }, body: 'Gateway timeout' });
213
+ });
214
+
215
+ proxyReq.on('error', (err) => {
216
+ sendResponse(requestId, { statusCode: 502, headers: { 'content-type': 'text/plain' }, body: err.message });
217
+ });
218
+
219
+ if (payload.body) proxyReq.write(Buffer.from(payload.body, 'base64'));
220
+ proxyReq.end();
221
+ }
222
+
223
+ function sendResponse(requestId, response) {
224
+ if (ws?.readyState === WebSocket.OPEN) {
225
+ ws.send(JSON.stringify({ type: MSG.RESPONSE, requestId, tunnelId, payload: response }));
226
+ }
227
+ }
228
+
229
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
230
+ process.on('SIGINT', () => {
231
+ isShuttingDown = true;
232
+ console.log('\n Disconnecting…');
233
+ clearInterval(heartbeatTimer);
234
+ clearTimeout(reconnectTimer);
235
+ if (ws) ws.close();
236
+ process.exit(0);
237
+ });
238
+
239
+ // ── Main ──────────────────────────────────────────────────────────────────────
240
+ console.log(`\n Sharnix Agent → localhost:${port}\n`);
241
+
242
+ bootstrap()
243
+ .then((c) => { creds = c; connect(); })
244
+ .catch((err) => { console.error(`\n Error: ${err.message}\n`); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@sharnix/agent",
3
+ "version": "1.0.0",
4
+ "description": "Tunnel your local app through Sharnix — share previews with one command",
5
+ "keywords": ["tunnel", "preview", "sharing", "localhost", "sharnix"],
6
+ "homepage": "https://relay.sharnix.com",
7
+ "license": "MIT",
8
+ "engines": { "node": ">=18" },
9
+ "main": "./index.js",
10
+ "bin": {
11
+ "sharnix-agent": "./index.js",
12
+ "sharnix": "./index.js"
13
+ },
14
+ "files": ["index.js"],
15
+ "dependencies": {
16
+ "ws": "^8.18.0"
17
+ }
18
+ }