// https.js — a minimal fetch-shaped client over bare-https.
//
// WHY NOT bare-fetch: it depends on bare-performance, which declares `"bare": ">=1.27.0"`, and the
// fleet's runtime is 1.26.0. Rather than upgrade a runtime every other app on this machine shares
// — for two HTTP calls — we wrap bare-https directly. We make exactly two requests in this whole
// codebase (GET an actor document, POST an activity to an inbox), so the fetch surface we actually
// need is small enough to own.
//
// Returns { ok, status, headers, text() } — the subset of the fetch Response contract the callers
// use, so swapping back to a real fetch later is a one-line change in each call site.
import https from 'bare-https'
const MAX_REDIRECTS = 3
const TIMEOUT_MS = 15_000
export function createHttpClient ({ timeout = TIMEOUT_MS } = {}) {
return async function request (url, { method = 'GET', headers = {}, body = null, _redirects = 0 } = {}) {
const u = new URL(url)
if (u.protocol !== 'https:') throw new Error(`https: refusing non-https request to ${url}`)
const res = await once(u, { method, headers, body, timeout })
// Actor documents commonly redirect (bare domain -> canonical host). Follow a bounded number,
// and only to https, so a redirect can't downgrade the transport.
if (res.status >= 300 && res.status < 400 && res.headers.location) {
if (_redirects >= MAX_REDIRECTS) throw new Error(`https: too many redirects for ${url}`)
const next = new URL(res.headers.location, url).toString()
// A redirected POST must not silently replay the body elsewhere — signatures are bound to
// the original target. Fail loudly instead.
if (method !== 'GET') throw new Error(`https: refusing to follow a redirect for ${method} ${url}`)
return request(next, { method, headers, _redirects: _redirects + 1 })
}
return res
}
}
function once (u, { method, headers, body, timeout }) {
return new Promise((resolve, reject) => {
const req = https.request({
protocol: u.protocol,
host: u.hostname,
port: u.port || 443,
path: u.pathname + u.search,
method,
headers: body ? { ...headers, 'content-length': Buffer.byteLength(body) } : headers
}, (res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
// Keep the raw bytes: a blob decoded to utf8 and re-encoded is corrupt, and the corruption
// is silent — the image simply fails to render with a 200 everywhere along the path.
const buf = chunks.length ? Buffer.concat(chunks) : Buffer.alloc(0)
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
headers: res.headers,
text: async () => buf.toString('utf8'),
bytes: async () => buf
})
})
res.on('error', reject)
})
const timer = setTimeout(() => {
req.destroy(new Error(`https: timeout after ${timeout}ms for ${u.href}`))
}, timeout)
req.on('error', (err) => { clearTimeout(timer); reject(err) })
req.on('close', () => clearTimeout(timer))
if (body) req.write(body)
req.end()
})
}
export const httpClient = createHttpClient()