cruss-agent 1.0.7 → 1.0.8

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 +1 -1
  2. package/server.js +57 -42
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cruss-agent",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Local API relay for Cruss — test localhost APIs from cruss-ten.vercel.app",
5
5
  "main": "server.js",
6
6
  "bin": {
package/server.js CHANGED
@@ -6,17 +6,14 @@ const https = require('https')
6
6
  const url = require('url')
7
7
 
8
8
  const PORT = 9119
9
- const VERSION = '1.0.7'
9
+ const VERSION = '1.0.8'
10
10
 
11
11
  // ── CORS ─────────────────────────────────────────────────────────────────
12
- // Access-Control-Allow-Private-Network is required by Chrome 104+ (PNA policy).
13
- // When an HTTPS page (e.g. cruss-ten.vercel.app) makes a non-simple request
14
- // (POST with Content-Type: application/json) to localhost, Chrome first sends
15
- // an OPTIONS preflight with "Access-Control-Request-Private-Network: true".
16
- // The agent MUST respond with "Access-Control-Allow-Private-Network: true"
17
- // or Chrome silently blocks the actual request. GET /health works without it
18
- // because simple GET requests bypass the PNA preflight — which is exactly
19
- // why health checks passed but POST /proxy was never received.
12
+ // Access-Control-Allow-Private-Network: Chrome 104+ PNA policy requires this
13
+ // on OPTIONS preflights from HTTPS pages to localhost. Without it Chrome
14
+ // silently blocks the request before it even leaves the browser — which is
15
+ // why health-check GETs work (simple requests bypass PNA) but POST /proxy
16
+ // never arrives.
20
17
  function corsHeaders() {
21
18
  return {
22
19
  'Access-Control-Allow-Origin': '*',
@@ -53,19 +50,15 @@ function proxyOnce(hostname, port, path, method, outHeaders, body, isHttps) {
53
50
  const lib = isHttps ? https : http
54
51
  const resolvedPort = port || (isHttps ? 443 : 80)
55
52
  const options = {
56
- hostname,
57
- port: resolvedPort,
58
- path: path || '/',
59
- method: method || 'GET',
60
- headers: outHeaders,
61
- timeout: 30000,
53
+ hostname, port: resolvedPort, path: path || '/',
54
+ method: method || 'GET', headers: outHeaders, timeout: 30000,
62
55
  }
63
56
  console.log(` → trying ${hostname}:${resolvedPort}${path || '/'}`)
64
57
  const t0 = Date.now()
65
58
  const req = lib.request(options, res => {
66
59
  const chunks = []
67
60
  res.on('data', c => chunks.push(c))
68
- res.on('end', () => {
61
+ res.on('end', () => {
69
62
  const elapsed = Date.now() - t0
70
63
  const rawBody = Buffer.concat(chunks)
71
64
  const bodyStr = rawBody.toString('utf8')
@@ -77,11 +70,8 @@ function proxyOnce(hostname, port, path, method, outHeaders, body, isHttps) {
77
70
  }
78
71
  }
79
72
  console.log(` ✓ ${res.statusCode} ${res.statusMessage} (${elapsed}ms)`)
80
- resolve({
81
- status: res.statusCode, statusText: res.statusMessage,
82
- headers: respHeaders, body: bodyStr,
83
- responseTime: elapsed, size: rawBody.length,
84
- })
73
+ resolve({ status: res.statusCode, statusText: res.statusMessage,
74
+ headers: respHeaders, body: bodyStr, responseTime: elapsed, size: rawBody.length })
85
75
  })
86
76
  res.on('error', reject)
87
77
  })
@@ -121,15 +111,15 @@ async function proxyRequest(targetUrl, method, reqHeaders, body) {
121
111
  )
122
112
  }
123
113
 
124
- const server = http.createServer(async (req, res) => {
114
+ // ── Request handler (shared by both IPv4 and IPv6 servers) ───────────────
115
+ async function requestHandler(req, res) {
125
116
  const cors = corsHeaders()
126
117
  const reqPath = url.parse(req.url).pathname
127
118
 
128
- // Log ALL incoming requests so we can see preflights
129
119
  console.log(` [${req.method}] ${reqPath} — origin: ${req.headers['origin'] || '(none)'} pna: ${req.headers['access-control-request-private-network'] || 'no'}`)
130
120
 
131
121
  if (req.method === 'OPTIONS') {
132
- console.log(` ← preflight OK`)
122
+ console.log(` ← preflight responded with PNA header`)
133
123
  res.writeHead(204, cors)
134
124
  res.end()
135
125
  return
@@ -147,15 +137,13 @@ const server = http.createServer(async (req, res) => {
147
137
 
148
138
  if (req.method === 'POST' && reqPath === '/proxy') {
149
139
  let payload
150
- try {
151
- payload = JSON.parse(await readBody(req))
152
- } catch {
153
- send(400, { error: 'Request body must be valid JSON' })
154
- return
155
- }
140
+ try { payload = JSON.parse(await readBody(req)) }
141
+ catch { send(400, { error: 'Request body must be valid JSON' }); return }
142
+
156
143
  const { method, url: targetUrl, headers, body } = payload || {}
157
144
  if (!targetUrl || typeof targetUrl !== 'string') { send(400, { error: 'url is required' }); return }
158
145
  if (!method || typeof method !== 'string') { send(400, { error: 'method is required' }); return }
146
+
159
147
  try {
160
148
  const result = await proxyRequest(targetUrl, method, headers || {}, body)
161
149
  send(200, { data: result })
@@ -168,9 +156,41 @@ const server = http.createServer(async (req, res) => {
168
156
  }
169
157
 
170
158
  send(404, { error: 'Not found' })
159
+ }
160
+
161
+ // ── Two servers: one IPv4, one IPv6 ─────────────────────────────────────
162
+ // macOS resolves `localhost` to ::1 (IPv6) by default. Chrome's PNA
163
+ // preflight for POST requests goes to whichever address localhost resolves
164
+ // to. We must listen on BOTH stacks so the preflight always finds us,
165
+ // regardless of OS resolver behaviour.
166
+ const server4 = http.createServer(requestHandler)
167
+ const server6 = http.createServer(requestHandler)
168
+
169
+ function onError(label) {
170
+ return (err) => {
171
+ if (err.code === 'EADDRINUSE') {
172
+ console.error(`\n ${label} port ${PORT} already in use. Run: kill $(lsof -ti:${PORT})`)
173
+ } else if (err.code === 'EADDRNOTAVAIL') {
174
+ console.log(` ${label} not available on this system — skipping`)
175
+ } else {
176
+ console.error(`\n ${label} error:`, err.message)
177
+ }
178
+ }
179
+ }
180
+
181
+ server4.on('error', onError('IPv4'))
182
+ server6.on('error', onError('IPv6'))
183
+
184
+ server4.listen(PORT, '127.0.0.1', () => {
185
+ console.log(` IPv4 listening on 127.0.0.1:${PORT}`)
186
+ })
187
+
188
+ server6.listen(PORT, '::1', () => {
189
+ console.log(` IPv6 listening on [::1]:${PORT}`)
171
190
  })
172
191
 
173
- server.listen(PORT, '0.0.0.0', () => {
192
+ // Banner after brief delay to let both servers start
193
+ setTimeout(() => {
174
194
  console.log('')
175
195
  console.log(' ╔═══════════════════════════════════════╗')
176
196
  console.log(' ║ Cruss Agent v' + VERSION + ' ║')
@@ -185,16 +205,11 @@ server.listen(PORT, '0.0.0.0', () => {
185
205
  console.log('')
186
206
  console.log(' All requests logged below.')
187
207
  console.log('')
188
- })
208
+ }, 200)
189
209
 
190
- server.on('error', err => {
191
- if (err.code === 'EADDRINUSE') {
192
- console.error(`\n Port ${PORT} already in use. Run: kill $(lsof -ti:${PORT})`)
193
- } else {
194
- console.error('\n Agent failed to start:', err.message)
195
- }
196
- process.exit(1)
210
+ process.on('SIGINT', () => {
211
+ server4.close(); server6.close()
212
+ console.log('\n Agent stopped.')
213
+ process.exit(0)
197
214
  })
198
-
199
- process.on('SIGINT', () => { console.log('\n Agent stopped.'); process.exit(0) })
200
- process.on('SIGTERM', () => { process.exit(0) })
215
+ process.on('SIGTERM', () => { server4.close(); server6.close(); process.exit(0) })