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