OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / blind-client / direct-http.js

170 lines · javascript ref:
import b4a from 'b4a'
import {
  FAMILY,
  FAMILY_ROUTES,
  OUTER_CLASS,
  PROTOCOL,
  TRANSPORT_ID,
  TRANSPORT_SUPPORT
} from '@hiverelay/blind-protocol/wire-runtime-authority'
import { asBytes } from './bytes.js'
import { BlindClientError, fail } from './errors.js'
import { unwrapVerifiedEndpoint } from './verified-endpoint.js'
import { decodeUnaryResponse, encodeUnaryRequest } from './wire.js'
import { selectedOperationProfile } from './selected-operation-profile.js'

function resolvedEndpoint (value) {
  const verified = unwrapVerifiedEndpoint(value)
  if (verified) return verified
  fail('UNVERIFIED_ENDPOINT', 'ordinary relay operations require an opaque VerifiedEndpoint')
}

function endpointUrl (endpoint, familyId, allowInsecureLoopback) {
  if (!endpoint || typeof endpoint !== 'object') fail('BAD_CLIENT_INPUT', 'signed transport endpoint is required')
  if (!Number.isInteger(endpoint.endpointId) || endpoint.endpointId < 1 || endpoint.endpointId > 255) {
    fail('BAD_CLIENT_INPUT', 'endpointId must be within 1..255')
  }
  if (endpoint.transportId !== TRANSPORT_ID.HTTPS_DIRECT) fail('TRANSPORT_MISMATCH', 'endpoint is not HTTPS_DIRECT')
  const raw = asBytes(endpoint.canonicalUrl, 'endpoint canonicalUrl')
  const text = b4a.toString(raw, 'utf8')
  let url
  try {
    url = new URL(text)
  } catch (error) {
    fail('BAD_CLIENT_INPUT', 'endpoint canonicalUrl is invalid', { cause: error })
  }
  const route = FAMILY_ROUTES[familyId]
  if (!route || url.pathname !== FAMILY_ROUTES[FAMILY.DESCRIBE] || url.search || url.hash || url.username || url.password) {
    fail('TRANSPORT_MISMATCH', 'endpoint URL is not the signed generic listener-authority anchor')
  }
  const loopback = url.hostname === '127.0.0.1' || url.hostname === '::1' || url.hostname === '[::1]'
  if (url.protocol !== 'https:' && !(allowInsecureLoopback && loopback && url.protocol === 'http:')) {
    fail('TRANSPORT_MISMATCH', 'direct endpoint must use HTTPS')
  }
  url.pathname = route
  return url.href
}

function assertEndpointSupportsRequest (endpoint, encoded) {
  if (endpoint.qualifiedFamilyId !== encoded.familyId ||
      endpoint.qualifiedOperationId !== encoded.operationId ||
      endpoint.transportSupportBit !== TRANSPORT_SUPPORT.DIRECT_HTTP) {
    fail('TRANSPORT_MISMATCH', 'VerifiedEndpoint was not qualified for this exact direct operation')
  }
  if (!Number.isInteger(endpoint.envelopeClassBits) || (endpoint.envelopeClassBits & ~0x007e) !== 0 ||
      (endpoint.envelopeClassBits & (1 << encoded.outerClass)) === 0) {
    fail('TRANSPORT_MISMATCH', 'endpoint did not advertise the selected outer class')
  }
  const profile = selectedOperationProfile(encoded.familyId, encoded.operationId)
  if ((profile.transportSupportBits & TRANSPORT_SUPPORT.DIRECT_HTTP) === 0) {
    fail('TRANSPORT_MISMATCH', 'operation is not available over direct HTTP')
  }
}

function deadlineForFamily (familyId, override) {
  const maximum = familyId === FAMILY.INBOX ? 35_000 : 15_000
  if (override == null) return maximum
  if (!Number.isSafeInteger(override) || override < 1 || override > maximum) {
    fail('BAD_CLIENT_INPUT', `timeoutMillis must be within 1..${maximum}`)
  }
  return override
}

function abortScope (parent, timeoutMillis) {
  const controller = new AbortController()
  const abort = () => controller.abort(parent && parent.reason)
  if (parent) {
    if (parent.aborted) abort()
    else parent.addEventListener('abort', abort, { once: true })
  }
  const timer = setTimeout(() => controller.abort(new Error('blind direct request deadline elapsed')), timeoutMillis)
  return {
    signal: controller.signal,
    close () {
      clearTimeout(timer)
      if (parent) parent.removeEventListener('abort', abort)
    }
  }
}

async function readExactResponse (response, exactBytes, signal) {
  const declared = response.headers && response.headers.get ? response.headers.get('content-length') : null
  const contentEncoding = response.headers && response.headers.get ? response.headers.get('content-encoding') : null
  const transferEncoding = response.headers && response.headers.get ? response.headers.get('transfer-encoding') : null
  if (contentEncoding != null || transferEncoding != null) {
    fail('RELAY_PROTOCOL_VIOLATION', 'encoded or transfer-framed response bodies are forbidden')
  }
  if (declared == null) fail('RELAY_PROTOCOL_VIOLATION', 'response requires an exact content-length')
  if (declared != null && !/^(0|[1-9][0-9]*)$/.test(declared)) fail('RELAY_PROTOCOL_VIOLATION', 'response content-length is not canonical')
  if (declared != null && Number(declared) !== exactBytes) fail('RELAY_PROTOCOL_VIOLATION', 'response content-length changed the selected class')
  if (!response.body || typeof response.body.getReader !== 'function') {
    fail('TRANSPORT_UNAVAILABLE', 'a bounded streaming response is required')
  }
  const reader = response.body.getReader()
  const output = b4a.alloc(exactBytes)
  let total = 0
  const onAbort = () => Promise.resolve(reader.cancel(signal.reason)).catch(() => {})
  if (signal) {
    if (signal.aborted) onAbort()
    else signal.addEventListener('abort', onAbort, { once: true })
  }
  try {
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const chunk = asBytes(value, 'direct response chunk')
      total += chunk.byteLength
      if (total > exactBytes) fail('RELAY_PROTOCOL_VIOLATION', 'response exceeds the selected class')
      b4a.copy(chunk, output, total - chunk.byteLength)
    }
  } finally {
    if (signal) signal.removeEventListener('abort', onAbort)
    if (total > exactBytes && typeof reader.cancel === 'function') await reader.cancel().catch(() => {})
    if (typeof reader.releaseLock === 'function') reader.releaseLock()
  }
  if (total !== exactBytes) fail('RELAY_PROTOCOL_VIOLATION', 'response is shorter than the selected class')
  return output
}

export class BlindDirectHttpClient {
  constructor (options = {}) {
    this.runtime = options.runtime
    this.fetch = options.fetch || globalThis.fetch
    if (typeof this.fetch !== 'function') fail('TRANSPORT_UNAVAILABLE', 'fetch implementation is required')
    this.allowInsecureLoopback = options.allowInsecureLoopback === true
  }

  async request (options) {
    if (!options || typeof options !== 'object') fail('BAD_CLIENT_INPUT', 'direct request options are required')
    const encoded = encodeUnaryRequest({ ...options, runtime: this.runtime })
    const endpoint = resolvedEndpoint(options.endpoint)
    assertEndpointSupportsRequest(endpoint, encoded)
    const url = endpointUrl(endpoint, encoded.familyId, this.allowInsecureLoopback)
    const scope = abortScope(options.signal, deadlineForFamily(encoded.familyId, options.timeoutMillis))
    try {
      const response = await this.fetch(url, {
        method: 'POST',
        headers: [
          ['content-type', PROTOCOL.mediaType]
        ],
        body: encoded.body,
        credentials: 'omit',
        cache: 'no-store',
        redirect: 'error',
        referrerPolicy: 'no-referrer',
        signal: scope.signal
      })
      if (!response || response.status !== 200) fail('TRANSPORT_FAILURE', 'blind direct transport returned a non-protocol status')
      const contentType = response.headers && response.headers.get ? response.headers.get('content-type') : null
      if (contentType !== PROTOCOL.mediaType) fail('RELAY_PROTOCOL_VIOLATION', 'response media type is not the blind protocol')
      const bytes = await readExactResponse(response, OUTER_CLASS[encoded.outerClass], scope.signal)
      return decodeUnaryResponse(bytes, encoded)
    } catch (error) {
      if (error instanceof BlindClientError) throw error
      fail('TRANSPORT_FAILURE', 'blind direct transport failed', { cause: error })
    } finally {
      scope.close()
    }
  }
}