apiplant-js 0.8.0

TypeScript/JavaScript functions for apiplant: build-time transpile, V8 isolates at runtime
// `fetch`, and the three classes it is defined in terms of.
//
// The network itself is one op, `op_apiplant_fetch`, which takes a fully
// resolved request and returns a fully buffered response. Everything that makes
// `fetch` recognisable -- header case-insensitivity, the body being readable
// exactly once, `Response.json()`, the `TypeError` on a network failure -- is
// here, because it is behaviour rather than I/O and there is no reason to spend
// a boundary crossing on it.
//
// What this deliberately does *not* do is stream. A response is read into
// memory before `fetch` resolves, so `body` is a `ReadableStream` over bytes
// that have already arrived. For the request/response sizes a function handler
// deals with that is the right trade; a function that needs to process a
// gigabyte as it arrives wants a different tool, not a leakier `fetch`.

const { op_apiplant_fetch } = Deno.core.ops;

/// Header names are case-insensitive, so the map is keyed lowercase and the
/// original spelling is never needed again -- HTTP/2 lowercases them anyway.
const HEADERS = Symbol("headers");

/// Set once a body has been read. The spec calls this "disturbed", and it is
/// what makes a second `.json()` on the same response a `TypeError` rather than
/// an empty string.
const BODY = Symbol("body");
const USED = Symbol("used");

function normalizeName(name) {
  const value = String(name);
  if (value === "" || /[^!#$%&'*+\-.^_`|~0-9A-Za-z]/.test(value)) {
    throw new TypeError(`\`${value}\` is not a valid header name`);
  }
  return value.toLowerCase();
}

function normalizeValue(value) {
  // Leading and trailing whitespace is stripped; embedded newlines would let a
  // header value forge a header, so they are refused outright.
  const normalized = String(value).replace(/^[\s]+|[\s]+$/g, "");
  if (/[\r\n]/.test(normalized)) {
    throw new TypeError("a header value cannot contain a newline");
  }
  return normalized;
}

class Headers {
  constructor(init) {
    this[HEADERS] = new Map();
    if (init === undefined || init === null) return;
    if (init instanceof Headers) {
      for (const [name, value] of init) this.append(name, value);
    } else if (Array.isArray(init)) {
      for (const pair of init) {
        if (!Array.isArray(pair) || pair.length !== 2) {
          throw new TypeError("a header pair must be a [name, value] array");
        }
        this.append(pair[0], pair[1]);
      }
    } else if (typeof init === "object") {
      for (const name of Object.keys(init)) this.append(name, init[name]);
    } else {
      throw new TypeError("headers must be a Headers, an array or an object");
    }
  }

  append(name, value) {
    const key = normalizeName(name);
    const next = normalizeValue(value);
    const current = this[HEADERS].get(key);
    // Repeated headers combine with ", ", which is how the wire format and
    // `get()` agree: `get("accept")` returns every value, joined.
    this[HEADERS].set(key, current === undefined ? next : `${current}, ${next}`);
  }

  set(name, value) {
    this[HEADERS].set(normalizeName(name), normalizeValue(value));
  }

  get(name) {
    const value = this[HEADERS].get(normalizeName(name));
    return value === undefined ? null : value;
  }

  has(name) {
    return this[HEADERS].has(normalizeName(name));
  }

  delete(name) {
    this[HEADERS].delete(normalizeName(name));
  }

  /// `Set-Cookie` is the one header that must not be joined, so it gets its own
  /// accessor rather than corrupting `get()`.
  getSetCookie() {
    const value = this[HEADERS].get("set-cookie");
    return value === undefined ? [] : value.split(", ");
  }

  forEach(callback, thisArg) {
    for (const [name, value] of this) callback.call(thisArg, value, name, this);
  }

  // Iteration is sorted by name, which the spec requires so that two equal
  // header sets iterate identically regardless of insertion order.
  *entries() {
    for (const key of [...this[HEADERS].keys()].sort()) {
      yield [key, this[HEADERS].get(key)];
    }
  }
  *keys() {
    for (const [name] of this.entries()) yield name;
  }
  *values() {
    for (const [, value] of this.entries()) yield value;
  }
  [Symbol.iterator]() {
    return this.entries();
  }
}

/// The body methods, shared by `Request` and `Response` exactly as the spec's
/// `Body` mixin is.
const bodyMethods = {
  get bodyUsed() {
    return this[USED];
  },

  get body() {
    // A stream over bytes already in memory. Present so that code written
    // against the streaming shape works, not because anything streams.
    if (this[BODY] === null) return null;
    const bytes = this[BODY];
    return new ReadableStream({
      start(controller) {
        controller.enqueue(bytes);
        controller.close();
      },
    });
  },

  arrayBuffer() {
    return consume(this).then((bytes) => bytes.buffer);
  },
  bytes() {
    return consume(this);
  },
  text() {
    return consume(this).then((bytes) => new TextDecoder().decode(bytes));
  },
  json() {
    return this.text().then((text) => JSON.parse(text));
  },
  blob() {
    const type = this.headers.get("content-type") ?? "";
    return consume(this).then((bytes) => new Blob([bytes], { type }));
  },
};

/// Take the body, once. The second call is the error, not the first.
function consume(target) {
  if (target[USED]) {
    return Promise.reject(new TypeError("the body has already been read"));
  }
  target[USED] = true;
  return Promise.resolve(target[BODY] ?? new Uint8Array(0));
}

/// Turn whatever a caller passed as `body` into bytes, and report the
/// `Content-Type` that choice implies. An explicit header always wins.
function encodeBody(body) {
  if (body === undefined || body === null) return { bytes: null, type: null };
  if (typeof body === "string") {
    return {
      bytes: new TextEncoder().encode(body),
      type: "text/plain;charset=UTF-8",
    };
  }
  if (body instanceof URLSearchParams) {
    return {
      bytes: new TextEncoder().encode(body.toString()),
      type: "application/x-www-form-urlencoded;charset=UTF-8",
    };
  }
  if (body instanceof Uint8Array) return { bytes: body, type: null };
  if (ArrayBuffer.isView(body)) {
    return {
      bytes: new Uint8Array(body.buffer, body.byteOffset, body.byteLength),
      type: null,
    };
  }
  if (body instanceof ArrayBuffer) {
    return { bytes: new Uint8Array(body), type: null };
  }
  throw new TypeError(
    "a body must be a string, URLSearchParams, ArrayBuffer or typed array",
  );
}

class Request {
  constructor(input, init = {}) {
    const base = input instanceof Request ? input : null;
    const url = base ? base.url : String(input instanceof URL ? input.href : input);

    this.url = new URL(url).href;
    this.method = String(init.method ?? base?.method ?? "GET").toUpperCase();
    this.redirect = init.redirect ?? base?.redirect ?? "follow";
    this.signal = init.signal ?? base?.signal ?? null;
    this.headers = new Headers(init.headers ?? base?.headers);

    const source = init.body !== undefined ? init.body : base?.[BODY] ?? null;
    const { bytes, type } = encodeBody(source);
    if (bytes !== null && (this.method === "GET" || this.method === "HEAD")) {
      throw new TypeError(`a ${this.method} request cannot have a body`);
    }
    this[BODY] = bytes;
    this[USED] = false;
    if (type !== null && !this.headers.has("content-type")) {
      this.headers.set("content-type", type);
    }
  }

  clone() {
    if (this[USED]) throw new TypeError("the body has already been read");
    return new Request(this, { body: this[BODY] });
  }
}
Object.defineProperties(Request.prototype, Object.getOwnPropertyDescriptors(bodyMethods));

class Response {
  constructor(body = null, init = {}) {
    const status = init.status ?? 200;
    if (status < 200 || status > 599) {
      throw new RangeError(`\`${status}\` is not a valid HTTP status`);
    }
    this.status = status;
    this.statusText = init.statusText ?? "";
    this.headers = new Headers(init.headers);
    this.url = init.url ?? "";
    this.redirected = init.redirected ?? false;
    this.type = "basic";

    const { bytes, type } = encodeBody(body);
    this[BODY] = bytes;
    this[USED] = false;
    if (type !== null && !this.headers.has("content-type")) {
      this.headers.set("content-type", type);
    }
  }

  get ok() {
    return this.status >= 200 && this.status < 300;
  }

  clone() {
    if (this[USED]) throw new TypeError("the body has already been read");
    const copy = new Response(this[BODY], {
      status: this.status,
      statusText: this.statusText,
      headers: this.headers,
      url: this.url,
      redirected: this.redirected,
    });
    return copy;
  }

  static json(data, init = {}) {
    const response = new Response(JSON.stringify(data), init);
    if (!response.headers.has("content-type")) {
      response.headers.set("content-type", "application/json");
    }
    return response;
  }

  static error() {
    const response = new Response(null, { status: 200 });
    response.type = "error";
    response.status = 0;
    return response;
  }
}
Object.defineProperties(Response.prototype, Object.getOwnPropertyDescriptors(bodyMethods));

async function fetch(input, init) {
  const request = input instanceof Request && init === undefined
    ? input
    : new Request(input, init);

  // An already-aborted signal must reject before anything is sent.
  request.signal?.throwIfAborted();

  const call = op_apiplant_fetch(
    {
      method: request.method,
      url: request.url,
      headers: [...request.headers],
      // `error` means "reject rather than follow"; the op only knows how to
      // follow or not, so the distinction is settled here.
      redirect: request.redirect === "follow" ? "follow" : "manual",
    },
    request[BODY],
  );

  // An abort stops this call *waiting*; it does not cancel the request already
  // in flight, which continues until it completes or hits
  // APIPLANT_FETCH_TIMEOUT_MS. For a caller that only wants to stop blocking
  // the difference is invisible, but it does mean an aborted request still
  // costs the upstream, so a signal is not a way to cheaply cancel work.
  const raw = request.signal
    ? await Promise.race([
        call,
        new Promise((_, reject) => {
          request.signal.addEventListener(
            "abort",
            () => reject(request.signal.reason ?? new DOMException("Aborted", "AbortError")),
            { once: true },
          );
        }),
      ])
    : await call;

  if (request.redirect === "error" && raw.redirected) {
    throw new TypeError(`cannot fetch \`${request.url}\`: it redirected`);
  }

  const response = new Response(raw.body, {
    status: raw.status,
    statusText: raw.status_text,
    headers: raw.headers,
    url: raw.url,
    redirected: raw.redirected,
  });
  return response;
}

export { fetch, Headers, Request, Response };