@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.
- package/index.js +244 -0
- 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
|
+
}
|