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.
Files changed (2) hide show
  1. package/package.json +17 -0
  2. 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) })