(opts: {
inbound: IncomingMessage;
body: Buffer;
tunnel: DeviceTunnel;
sessionId: string | null;
agentIdentity?: string;
})
| 28 | * response or a ProxyError. Caller writes to the ServerResponse. |
| 29 | */ |
| 30 | export async function proxyToDevice(opts: { |
| 31 | inbound: IncomingMessage; |
| 32 | body: Buffer; |
| 33 | tunnel: DeviceTunnel; |
| 34 | sessionId: string | null; |
| 35 | agentIdentity?: string; |
| 36 | }): Promise<{ status: number; headers: Record<string, string>; body: Buffer }> { |
| 37 | const { inbound, body, tunnel, sessionId, agentIdentity } = opts; |
| 38 | if (body.length > MAX_BODY) { |
| 39 | return makeError(413, 'body_too_large'); |
| 40 | } |
| 41 | |
| 42 | const headers: Record<string, string> = { |
| 43 | 'authorization': `Bearer ${tunnel.bootTokenRotated}`, |
| 44 | 'content-type': inbound.headers['content-type'] || 'application/json', |
| 45 | 'content-length': String(body.length), |
| 46 | }; |
| 47 | if (sessionId) headers['x-session-id'] = sessionId; |
| 48 | if (agentIdentity) headers['x-agent-identity'] = agentIdentity; |
| 49 | |
| 50 | // Bracket IPv6 literals; pass IPv4 + hostnames bare. The CoreDevice tunnel |
| 51 | // is always IPv6 in production, but tests inject 127.0.0.1 to talk to a |
| 52 | // local stub. Detect by `:` count (IPv6 has multiple colons) or `:` absence |
| 53 | // (IPv4/hostname). |
| 54 | const isIPv6 = (tunnel.ipv6Addr.match(/:/g)?.length ?? 0) >= 2; |
| 55 | const hostPart = isIPv6 ? `[${tunnel.ipv6Addr}]` : tunnel.ipv6Addr; |
| 56 | const url = `http://${hostPart}:${tunnel.port}${inbound.url ?? '/'}`; |
| 57 | return new Promise((resolve, reject) => { |
| 58 | const req = httpRequest(url, { |
| 59 | method: inbound.method, |
| 60 | headers, |
| 61 | timeout: 30_000, |
| 62 | }, (res) => { |
| 63 | const chunks: Buffer[] = []; |
| 64 | res.on('data', (c) => chunks.push(c)); |
| 65 | res.on('end', () => { |
| 66 | const respHeaders: Record<string, string> = {}; |
| 67 | for (const [k, v] of Object.entries(res.headers)) { |
| 68 | if (typeof v === 'string') respHeaders[k] = v; |
| 69 | } |
| 70 | resolve({ |
| 71 | status: res.statusCode ?? 502, |
| 72 | headers: respHeaders, |
| 73 | body: Buffer.concat(chunks), |
| 74 | }); |
| 75 | }); |
| 76 | }); |
| 77 | req.on('error', (err) => { |
| 78 | const e = err as { code?: string }; |
| 79 | if (e.code === 'ECONNREFUSED' || e.code === 'EHOSTUNREACH') { |
| 80 | resolve(makeError(503, 'device_disconnected')); |
| 81 | } else if (e.code === 'ETIMEDOUT') { |
| 82 | resolve(makeError(504, 'upstream_timeout')); |
| 83 | } else { |
| 84 | reject(err); |
| 85 | } |
| 86 | }); |
| 87 | req.write(body); |
no test coverage detected