OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / https.js

79 lines · javascript ref:
// 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()