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.
Files changed (2) hide show
  1. package/package.json +4 -10
  2. package/server.js +39 -122
package/package.json CHANGED
@@ -1,17 +1,11 @@
1
1
  {
2
2
  "name": "cruss-agent",
3
- "version": "1.0.4",
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.2'
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
- // ── IPv4 / IPv6 loopback alternatives ────────────────────────────────────
65
- // On macOS, localhost resolves to ::1 (IPv6) by default.
66
- // A dev server started with `python3 -m http.server` binds to :: (IPv6).
67
- // A server started with `node server.js` may bind to 0.0.0.0 (IPv4).
68
- // When the two stacks don't match we get ECONNREFUSED.
69
- // Fix: if the first attempt fails with ECONNREFUSED on a loopback address,
70
- // retry automatically on the other stack.
71
- function loopbackAlternative(hostname) {
72
- if (hostname === '127.0.0.1') return '::1'
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
- // Strip upstream CORS headers — the agent adds its own
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: res.statusCode,
111
- statusText: res.statusMessage,
112
- headers: respHeaders,
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
- try {
141
- return await proxyOnce(
142
- parsed.hostname, parsed.port, parsed.path,
143
- method, outHeaders, body, isHttps
144
- )
145
- } catch (firstErr) {
146
- // On ECONNREFUSED for a loopback address, retry on the other IP stack.
147
- // This handles the macOS case where python3's HTTP server binds to ::
148
- // but the user typed 127.0.0.1 (IPv4), or vice versa.
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(json)
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
- const raw = await readBody(req)
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
- send(400, { error: 'url is required' })
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 Port ${PORT} is already in use.`)
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 Agent failed to start:', err.message)
179
+ console.error('\n Agent failed to start:', err.message)
263
180
  }
264
181
  process.exit(1)
265
182
  })