cruss-agent 1.0.4 → 1.0.5
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/package.json +4 -10
- package/server.js +39 -122
package/package.json
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cruss-agent",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "Local API relay for Cruss — test localhost APIs from cruss-ten.vercel.app",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
7
|
-
"cruss-agent": "server.js"
|
|
7
|
+
"cruss-agent": "./server.js"
|
|
8
8
|
},
|
|
9
|
-
"keywords": [
|
|
10
|
-
"cruss",
|
|
11
|
-
"api",
|
|
12
|
-
"localhost",
|
|
13
|
-
"cors",
|
|
14
|
-
"proxy"
|
|
15
|
-
],
|
|
9
|
+
"keywords": ["cruss", "api", "localhost", "cors", "proxy"],
|
|
16
10
|
"license": "MIT"
|
|
17
|
-
}
|
|
11
|
+
}
|
package/server.js
CHANGED
|
@@ -1,27 +1,4 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* cruss-agent — Cruss Local API Agent v1.0.4
|
|
4
|
-
*
|
|
5
|
-
* A lightweight HTTP proxy that runs on your machine (port 9119).
|
|
6
|
-
* The Cruss web app routes local API requests through this agent
|
|
7
|
-
* instead of the cloud proxy or the browser — so there are zero
|
|
8
|
-
* CORS or mixed-content restrictions, on any port, any protocol.
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
* npx cruss-agent # recommended
|
|
12
|
-
* node agent/server.js # from repo root
|
|
13
|
-
*
|
|
14
|
-
* The agent listens on http://localhost:9119 and accepts:
|
|
15
|
-
* GET /health — liveness check (Cruss pings this)
|
|
16
|
-
* POST /proxy — proxy a request
|
|
17
|
-
*
|
|
18
|
-
* Changelog:
|
|
19
|
-
* v1.0.4 — bind to 0.0.0.0 (fixes macOS IPv6 detection), IPv4/IPv6
|
|
20
|
-
* fallback in proxyRequest (fixes 127.0.0.1 vs ::1 mismatch),
|
|
21
|
-
* wildcard CORS (no more domain allowlist to maintain)
|
|
22
|
-
*
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
2
|
'use strict'
|
|
26
3
|
|
|
27
4
|
const http = require('http')
|
|
@@ -29,13 +6,8 @@ const https = require('https')
|
|
|
29
6
|
const url = require('url')
|
|
30
7
|
|
|
31
8
|
const PORT = 9119
|
|
32
|
-
const VERSION = '1.0.
|
|
9
|
+
const VERSION = '1.0.5'
|
|
33
10
|
|
|
34
|
-
// ── CORS ─────────────────────────────────────────────────────────────────
|
|
35
|
-
// The agent binds to 0.0.0.0 but is never reachable from the internet
|
|
36
|
-
// (no port forwarding, no public IP exposure). Wildcard CORS is therefore
|
|
37
|
-
// safe here — the only pages that can reach localhost:9119 are pages
|
|
38
|
-
// already running on the user's own machine.
|
|
39
11
|
function corsHeaders() {
|
|
40
12
|
return {
|
|
41
13
|
'Access-Control-Allow-Origin': '*',
|
|
@@ -45,7 +17,6 @@ function corsHeaders() {
|
|
|
45
17
|
}
|
|
46
18
|
}
|
|
47
19
|
|
|
48
|
-
// ── Body reader ──────────────────────────────────────────────────────────
|
|
49
20
|
function readBody(req) {
|
|
50
21
|
return new Promise((resolve, reject) => {
|
|
51
22
|
const chunks = []
|
|
@@ -55,27 +26,22 @@ function readBody(req) {
|
|
|
55
26
|
})
|
|
56
27
|
}
|
|
57
28
|
|
|
58
|
-
// ── Hop-by-hop headers to strip ─────────────────────────────────────────
|
|
59
29
|
const HOP_BY_HOP = new Set([
|
|
60
30
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
61
31
|
'te', 'trailers', 'transfer-encoding', 'upgrade',
|
|
62
32
|
])
|
|
63
33
|
|
|
64
|
-
//
|
|
65
|
-
// On macOS,
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
if (hostname === '::1') return '127.0.0.1'
|
|
74
|
-
if (hostname === 'localhost') return null // node will resolve — no manual retry needed
|
|
75
|
-
return null
|
|
34
|
+
// Returns all addresses to try for a given hostname.
|
|
35
|
+
// On macOS, python3 -m http.server binds to :: (IPv6 only).
|
|
36
|
+
// Node resolves localhost -> 127.0.0.1 (IPv4) -> ECONNREFUSED.
|
|
37
|
+
// Trying both 127.0.0.1 and ::1 guarantees one works.
|
|
38
|
+
function loopbackCandidates(hostname) {
|
|
39
|
+
if (hostname === 'localhost') return ['127.0.0.1', '::1']
|
|
40
|
+
if (hostname === '127.0.0.1') return ['127.0.0.1', '::1']
|
|
41
|
+
if (hostname === '::1') return ['::1', '127.0.0.1']
|
|
42
|
+
return [hostname]
|
|
76
43
|
}
|
|
77
44
|
|
|
78
|
-
// ── Single proxy attempt ──────────────────────────────────────────────────
|
|
79
45
|
function proxyOnce(hostname, port, path, method, outHeaders, body, isHttps) {
|
|
80
46
|
return new Promise((resolve, reject) => {
|
|
81
47
|
const lib = isHttps ? https : http
|
|
@@ -87,7 +53,6 @@ function proxyOnce(hostname, port, path, method, outHeaders, body, isHttps) {
|
|
|
87
53
|
headers: outHeaders,
|
|
88
54
|
timeout: 30000,
|
|
89
55
|
}
|
|
90
|
-
|
|
91
56
|
const t0 = Date.now()
|
|
92
57
|
const req = lib.request(options, res => {
|
|
93
58
|
const chunks = []
|
|
@@ -96,38 +61,29 @@ function proxyOnce(hostname, port, path, method, outHeaders, body, isHttps) {
|
|
|
96
61
|
const elapsed = Date.now() - t0
|
|
97
62
|
const rawBody = Buffer.concat(chunks)
|
|
98
63
|
const bodyStr = rawBody.toString('utf8')
|
|
99
|
-
|
|
100
64
|
const respHeaders = {}
|
|
101
65
|
for (const [k, v] of Object.entries(res.headers || {})) {
|
|
102
|
-
|
|
103
|
-
if (!['access-control-allow-origin', 'access-control-allow-headers',
|
|
66
|
+
if (!['access-control-allow-origin','access-control-allow-headers',
|
|
104
67
|
'access-control-allow-methods'].includes(k.toLowerCase())) {
|
|
105
68
|
respHeaders[k] = Array.isArray(v) ? v.join(', ') : v
|
|
106
69
|
}
|
|
107
70
|
}
|
|
108
|
-
|
|
109
71
|
resolve({
|
|
110
|
-
status:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
body: bodyStr,
|
|
114
|
-
responseTime: elapsed,
|
|
115
|
-
size: rawBody.length,
|
|
72
|
+
status: res.statusCode, statusText: res.statusMessage,
|
|
73
|
+
headers: respHeaders, body: bodyStr,
|
|
74
|
+
responseTime: elapsed, size: rawBody.length,
|
|
116
75
|
})
|
|
117
76
|
})
|
|
118
77
|
res.on('error', reject)
|
|
119
78
|
})
|
|
120
|
-
|
|
121
79
|
req.on('timeout', () => { req.destroy(); reject(new Error('Request timed out (30s)')) })
|
|
122
80
|
req.on('error', reject)
|
|
123
|
-
|
|
124
|
-
const hasBody = body && !['GET', 'HEAD', 'OPTIONS'].includes((method || 'GET').toUpperCase())
|
|
81
|
+
const hasBody = body && !['GET','HEAD','OPTIONS'].includes((method||'GET').toUpperCase())
|
|
125
82
|
if (hasBody) req.write(typeof body === 'string' ? body : JSON.stringify(body))
|
|
126
83
|
req.end()
|
|
127
84
|
})
|
|
128
85
|
}
|
|
129
86
|
|
|
130
|
-
// ── Main proxy logic — with automatic IPv4/IPv6 fallback ─────────────────
|
|
131
87
|
async function proxyRequest(targetUrl, method, reqHeaders, body) {
|
|
132
88
|
const parsed = url.parse(targetUrl)
|
|
133
89
|
const isHttps = parsed.protocol === 'https:'
|
|
@@ -137,94 +93,61 @@ async function proxyRequest(targetUrl, method, reqHeaders, body) {
|
|
|
137
93
|
if (!HOP_BY_HOP.has(k.toLowerCase())) outHeaders[k] = v
|
|
138
94
|
}
|
|
139
95
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
if (firstErr.code === 'ECONNREFUSED') {
|
|
150
|
-
const alt = loopbackAlternative(parsed.hostname)
|
|
151
|
-
if (alt) {
|
|
152
|
-
try {
|
|
153
|
-
return await proxyOnce(
|
|
154
|
-
alt, parsed.port, parsed.path,
|
|
155
|
-
method, outHeaders, body, isHttps
|
|
156
|
-
)
|
|
157
|
-
} catch (secondErr) {
|
|
158
|
-
// Throw a clear message combining both attempts
|
|
159
|
-
throw new Error(
|
|
160
|
-
`Connection refused on both ${parsed.hostname} and ${alt}:${parsed.port || (isHttps ? 443 : 80)}. ` +
|
|
161
|
-
`Is your server actually running on that port?`
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
96
|
+
const candidates = loopbackCandidates(parsed.hostname)
|
|
97
|
+
let lastErr
|
|
98
|
+
|
|
99
|
+
for (const host of candidates) {
|
|
100
|
+
try {
|
|
101
|
+
return await proxyOnce(host, parsed.port, parsed.path, method, outHeaders, body, isHttps)
|
|
102
|
+
} catch (err) {
|
|
103
|
+
lastErr = err
|
|
104
|
+
if (err.code !== 'ECONNREFUSED' && err.code !== 'EADDRNOTAVAIL') throw err
|
|
165
105
|
}
|
|
166
|
-
throw firstErr
|
|
167
106
|
}
|
|
107
|
+
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Connection refused on all addresses (${candidates.join(', ')}) port ${parsed.port || (isHttps ? 443 : 80)}. ` +
|
|
110
|
+
`Is your server running?`
|
|
111
|
+
)
|
|
168
112
|
}
|
|
169
113
|
|
|
170
|
-
// ── HTTP server ──────────────────────────────────────────────────────────
|
|
171
114
|
const server = http.createServer(async (req, res) => {
|
|
172
115
|
const cors = corsHeaders()
|
|
173
116
|
const reqPath = url.parse(req.url).pathname
|
|
174
117
|
|
|
175
|
-
// Preflight
|
|
176
118
|
if (req.method === 'OPTIONS') {
|
|
177
|
-
res.writeHead(204, cors)
|
|
178
|
-
res.end()
|
|
179
|
-
return
|
|
119
|
+
res.writeHead(204, cors); res.end(); return
|
|
180
120
|
}
|
|
181
121
|
|
|
182
122
|
function send(status, body) {
|
|
183
|
-
const json = JSON.stringify(body)
|
|
184
123
|
res.writeHead(status, { 'Content-Type': 'application/json', ...cors })
|
|
185
|
-
res.end(
|
|
124
|
+
res.end(JSON.stringify(body))
|
|
186
125
|
}
|
|
187
126
|
|
|
188
|
-
// ── GET /health ──────────────────────────────────────────────────────
|
|
189
127
|
if (req.method === 'GET' && reqPath === '/health') {
|
|
190
128
|
send(200, { ok: true, version: VERSION, agent: 'cruss-agent', port: PORT })
|
|
191
129
|
return
|
|
192
130
|
}
|
|
193
131
|
|
|
194
|
-
// ── POST /proxy ──────────────────────────────────────────────────────
|
|
195
132
|
if (req.method === 'POST' && reqPath === '/proxy') {
|
|
196
133
|
let payload
|
|
197
134
|
try {
|
|
198
|
-
|
|
199
|
-
payload = JSON.parse(raw)
|
|
135
|
+
payload = JSON.parse(await readBody(req))
|
|
200
136
|
} catch {
|
|
201
|
-
send(400, { error: 'Request body must be valid JSON' })
|
|
202
|
-
return
|
|
137
|
+
send(400, { error: 'Request body must be valid JSON' }); return
|
|
203
138
|
}
|
|
204
139
|
|
|
205
140
|
const { method, url: targetUrl, headers, body } = payload || {}
|
|
206
141
|
|
|
207
|
-
if (!targetUrl || typeof targetUrl !== 'string') {
|
|
208
|
-
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
if (!method || typeof method !== 'string') {
|
|
212
|
-
send(400, { error: 'method is required' })
|
|
213
|
-
return
|
|
214
|
-
}
|
|
142
|
+
if (!targetUrl || typeof targetUrl !== 'string') { send(400, { error: 'url is required' }); return }
|
|
143
|
+
if (!method || typeof method !== 'string') { send(400, { error: 'method is required' }); return }
|
|
215
144
|
|
|
216
145
|
try {
|
|
217
146
|
const result = await proxyRequest(targetUrl, method, headers || {}, body)
|
|
218
147
|
send(200, { data: result })
|
|
219
148
|
} catch (err) {
|
|
220
149
|
const msg = err instanceof Error ? err.message : String(err)
|
|
221
|
-
send(200, {
|
|
222
|
-
data: {
|
|
223
|
-
status: 0, statusText: 'Network Error',
|
|
224
|
-
headers: {}, body: null, error: msg,
|
|
225
|
-
responseTime: 0, size: 0,
|
|
226
|
-
}
|
|
227
|
-
})
|
|
150
|
+
send(200, { data: { status: 0, statusText: 'Network Error', headers: {}, body: null, error: msg, responseTime: 0, size: 0 } })
|
|
228
151
|
}
|
|
229
152
|
return
|
|
230
153
|
}
|
|
@@ -232,10 +155,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
232
155
|
send(404, { error: 'Not found' })
|
|
233
156
|
})
|
|
234
157
|
|
|
235
|
-
// ── Listen on 0.0.0.0 ────────────────────────────────────────────────────
|
|
236
|
-
// Binding to 0.0.0.0 (all interfaces) instead of 127.0.0.1 means the agent
|
|
237
|
-
// accepts connections whether the browser resolves localhost to 127.0.0.1
|
|
238
|
-
// (IPv4) or ::1 (IPv6). It is still only reachable from the local machine.
|
|
239
158
|
server.listen(PORT, '0.0.0.0', () => {
|
|
240
159
|
console.log('')
|
|
241
160
|
console.log(' ╔═══════════════════════════════════════╗')
|
|
@@ -245,7 +164,7 @@ server.listen(PORT, '0.0.0.0', () => {
|
|
|
245
164
|
console.log(' ║ Listening on http://localhost:' + PORT + ' ║')
|
|
246
165
|
console.log(' ║ Cruss will auto-detect this agent. ║')
|
|
247
166
|
console.log(' ║ ║')
|
|
248
|
-
console.log(' ║ Open cruss-ten.vercel.app
|
|
167
|
+
console.log(' ║ Open cruss-ten.vercel.app -> ║')
|
|
249
168
|
console.log(' ║ Ctrl+C to stop ║')
|
|
250
169
|
console.log(' ╚═══════════════════════════════════════╝')
|
|
251
170
|
console.log('')
|
|
@@ -255,11 +174,9 @@ server.listen(PORT, '0.0.0.0', () => {
|
|
|
255
174
|
|
|
256
175
|
server.on('error', err => {
|
|
257
176
|
if (err.code === 'EADDRINUSE') {
|
|
258
|
-
console.error(`\n
|
|
259
|
-
console.error(' Stop the existing agent first: find the terminal running it and press Ctrl+C.')
|
|
260
|
-
console.error(` Or force-kill it: kill $(lsof -ti:${PORT})`)
|
|
177
|
+
console.error(`\n Port ${PORT} is already in use. Run: kill $(lsof -ti:${PORT})`)
|
|
261
178
|
} else {
|
|
262
|
-
console.error('\n
|
|
179
|
+
console.error('\n Agent failed to start:', err.message)
|
|
263
180
|
}
|
|
264
181
|
process.exit(1)
|
|
265
182
|
})
|