SPB Git

Repository Public

http.ts 6.8 KB 116 lines typescript

1// HTTP plumbing shared by the dYdX and GMX read adapters: injectable fetch, VenueError mapping, zod validation, and a JSON2// parser that never turns a venue's number into a JS number.3import type { z } from 'zod';4import { VenueError, type VenueErrorCode } from '../models.ts';5import type { VenueId } from '../models.ts';6import { D, type Dec } from '../decimal.ts';78/** The single place where a VenueError is built by these adapters (one line to adapt if the shared constructor differs). */9export const venueError = (venue: VenueId, code: VenueErrorCode, message: string): VenueError => new VenueError(code, `${venue}: ${message}`, { venue });1011/** Marker for a JSON number kept as its exact source text (see `parseJsonExact`). */12export class JsonNumber {13  readonly text: string;14  constructor(text: string) { this.text = text; }15}16/**17 * JSON.parse that keeps every number as its source text (`JsonNumber`), so a price such as `73503.65` or an amount beyond18 * 2^53 is never rounded by a double. Uses the reviver's source-text access (Node ≥ 22). Strings, booleans, null are unchanged.19 */20export function parseJsonExact(text: string): unknown {21  return JSON.parse(text, function (this: unknown, _k: string, v: unknown, ctx?: { source?: string }) {22    if (typeof v !== 'number') return v;23    if (typeof ctx?.source !== 'string') throw new Error('this JSON runtime does not expose number source text');24    return new JsonNumber(ctx.source);25  } as (this: unknown, key: string, value: unknown) => unknown);26}27/** Exact decimal string of a JSON number literal (handles exponents with integer arithmetic on the digits). */28export function jsonNumberToDecStr(n: JsonNumber): string {29  const m = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([-+]?\d+))?$/.exec(n.text);30  if (!m) throw new Error(`not a JSON number: ${n.text.slice(0, 40)}`);31  const neg = m[1] === '-', int = m[2]!, frac = m[3] ?? '', exp = Number(m[4] ?? '0');32  if (!Number.isSafeInteger(exp) || Math.abs(exp) > 400) throw new Error('exponent out of range');33  let digits = int + frac, point = int.length + exp;            // decimal point position inside `digits`34  if (point <= 0) { digits = '0'.repeat(1 - point) + digits; point = 1; }35  if (point > digits.length) digits = digits + '0'.repeat(point - digits.length);36  const i = digits.slice(0, point).replace(/^0+(?=\d)/, ''), f = digits.slice(point).replace(/0+$/, '');37  const out = f ? `${i}.${f}` : i;38  return neg && /[1-9]/.test(out) ? `-${out}` : out;39}4041export interface HttpAnswer { status: number; text: string }42export interface VenueHttp {43  /** GET (or POST when `body` is given) → status + raw text. Maps transport failures, 429 and 5xx to VenueError; 4xx are returned. */44  raw(url: string, o?: { body?: unknown; signal?: AbortSignal }): Promise<HttpAnswer>;45  /** raw + JSON.parse + zod. A 2xx whose body is not JSON or does not match the schema is `bad_answer`; other 4xx go to `on4xx` or `rejected`. */46  get<T>(url: string, schema: z.ZodType<T>, o?: { exact?: boolean; body?: unknown; signal?: AbortSignal; on4xx?: (a: HttpAnswer) => VenueError | null }): Promise<T>;47}4849export function venueHttp(venue: VenueId, fetchImpl: typeof fetch = fetch, o: { timeoutMs?: number; maxBytes?: number } = {}): VenueHttp {50  const timeoutMs = o.timeoutMs ?? 15_000, maxBytes = o.maxBytes ?? 8_000_000;51  const raw: VenueHttp['raw'] = async (url, ro = {}) => {52    let r: Response;53    try {54      const timeout = AbortSignal.timeout(timeoutMs);55      r = await fetchImpl(url, {56        method: ro.body === undefined ? 'GET' : 'POST', redirect: 'error',57        headers: { accept: 'application/json', ...(ro.body === undefined ? {} : { 'content-type': 'application/json' }) },58        ...(ro.body === undefined ? {} : { body: JSON.stringify(ro.body) }),59        signal: ro.signal ? AbortSignal.any([ro.signal, timeout]) : timeout,60      });61    } catch (e) { throw venueError(venue, 'unavailable', `network error: ${String((e as Error)?.message ?? e).slice(0, 160)}`); }62    if (r.status === 429) throw venueError(venue, 'rate_limited', 'rate limit (HTTP 429)');63    let text: string;64    try { text = await r.text(); } catch (e) { throw venueError(venue, 'unavailable', `body read failed: ${String((e as Error)?.message ?? e).slice(0, 120)}`); }65    if (r.status >= 500) throw venueError(venue, 'unavailable', `HTTP ${r.status}: ${text.slice(0, 160)}`);66    if (text.length > maxBytes) throw venueError(venue, 'bad_answer', 'answer too large');67    return { status: r.status, text };68  };69  return {70    raw,71    async get(url, schema, go = {}) {72      const a = await raw(url, go);73      if (a.status < 200 || a.status >= 300) {74        throw go.on4xx?.(a) ?? venueError(venue, 'rejected', `HTTP ${a.status}: ${a.text.slice(0, 200)}`);75      }76      let body: unknown;77      try { body = go.exact ? parseJsonExact(a.text) : JSON.parse(a.text); } catch { throw venueError(venue, 'bad_answer', `non-JSON answer from ${new URL(url).pathname}`); }78      const p = schema.safeParse(body);79      if (!p.success) {80        const i = p.error.issues[0];81        throw venueError(venue, 'bad_answer', `${new URL(url).pathname}: ${i ? `${i.path.join('.')} ${i.message}` : 'schema mismatch'}`.slice(0, 300));82      }83      return p.data;84    },85  };86}8788/** `raw / 10^decimals` as an exact canonical decimal string (trailing zeros trimmed). Pure bigint. */89export function fixed(raw: bigint, decimals: number): string {90  if (!Number.isInteger(decimals) || decimals < 0) throw new Error('decimals must be a non-negative integer');91  const neg = raw < 0n, digits = (neg ? -raw : raw).toString().padStart(decimals + 1, '0');92  const int = digits.slice(0, digits.length - decimals), frac = decimals ? digits.slice(digits.length - decimals).replace(/0+$/, '') : '';93  const out = frac ? `${int}.${frac}` : int;94  return neg && raw !== 0n ? `-${out}` : out;95}96/** Exact `value × 10^decimals` as a bigint; throws if `value` has more than `decimals` significant decimals. */97export function toRaw(value: string, decimals: number): bigint {98  const m = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value);99  if (!m) throw new Error(`not a decimal: ${value.slice(0, 40)}`);100  const frac = (m[3] ?? '').replace(/0+$/, '');101  if (frac.length > decimals) throw new Error(`${value} has more than ${decimals} decimals`);102  const v = BigInt(m[2]! + frac.padEnd(decimals, '0'));103  return m[1] === '-' ? -v : v;104}105106/** canonical form of a plain decimal string: no leading zeros, no trailing fractional zeros, no "-0" */107export function canonDec(s: string): string {108  const m = /^(-?)(\d+)(?:\.(\d+))?$/.exec(s);109  if (!m) throw new Error(`not a decimal: ${s.slice(0, 40)}`);110  const int = m[2]!.replace(/^0+(?=\d)/, ''), frac = (m[3] ?? '').replace(/0+$/, '');111  const out = frac ? `${int}.${frac}` : int;112  return m[1] === '-' && /[1-9]/.test(out) ? `-${out}` : out;113}114/** `D.str` in canonical form, whatever scale the shared decimal implementation chooses to print */115export const ds = (d: Dec): string => canonDec(D.str(d));116
Files main
Menu

Theme