Functional Weave
Code in TypeScript

monitor.parse-access-log@1.0.0

impl/typescript.ts

4,989 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { isoToUnix } from "./time_iso_to_unix.ts";  ← from time.iso-to-unix ^1.0.0 · built alongside by fune
import { type AccessLogEntry } from "./monitor_parse_access_log_types.ts";

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

const isDigit = (c: string | undefined): boolean => c !== undefined && c >= "0" && c <= "9";

function digits(text: string, from: number, count: number): number | null {
  let n = 0;
  for (let i = from; i < from + count; i++) {
    if (!isDigit(text[i])) return null;
    n = n * 10 + (text.charCodeAt(i) - 48);
  }
  return n;
}

const dash = (field: string): string | null => (field === "-" ? null : field);

/** "[10/Oct/2000:13:55:36 -0700]" starting at `i` to Unix seconds, or null. */
function parseTime(t: string, i: number): number | null {
  if (t[i] !== "[" || t[i + 3] !== "/" || t[i + 7] !== "/" || t[i + 12] !== ":" || t[i + 15] !== ":" || t[i + 18] !== ":" || t[i + 21] !== " " || t[i + 27] !== "]") return null;
  const day = digits(t, i + 1, 2);
  const month = MONTHS.indexOf(t.slice(i + 4, i + 7)) + 1;
  const year = digits(t, i + 8, 4);
  const hour = digits(t, i + 13, 2);
  const minute = digits(t, i + 16, 2);
  const second = digits(t, i + 19, 2);
  const sign = t[i + 22];
  const oh = digits(t, i + 23, 2);
  const om = digits(t, i + 25, 2);
  if (day === null || year === null || hour === null || minute === null || second === null || oh === null || om === null) return null;
  if (month === 0 || year < 1 || hour > 23 || minute > 59 || second > 59 || oh > 23 || om > 59) return null;
  if (sign !== "+" && sign !== "-") return null;
  const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
  const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
  if (day < 1 || day > monthDays) return null;
  // Every field is checked above, so isoToUnix cannot throw here.
  return isoToUnix(`${t.slice(i + 8, i + 12)}-${String(month).padStart(2, "0")}-${t.slice(i + 1, i + 3)}T${t.slice(i + 13, i + 21)}${sign}${t.slice(i + 23, i + 25)}:${t.slice(i + 25, i + 27)}`);
}

/** The end (index of the closing quote) of a quoted field opening at `i`, honouring \" escapes; -1 if none. */
function closingQuote(t: string, i: number): number {
  if (t[i] !== '"') return -1;
  for (let j = i + 1; j < t.length; j++) {
    if (t[j] === "\\") j++;
    else if (t[j] === '"') return j;
  }
  return -1;
}

const isMethod = (m: string): boolean => m.length > 0 && [...m].every((c) => c >= "A" && c <= "Z");

/**
 * One Common or Combined Log Format line, or null when the line is in neither
 * format: logs hold junk, and a reader should skip it rather than stop.
 */
export function parseAccessLog(line: string): AccessLogEntry | null {
  let t = line;
  while (t.endsWith("\n") || t.endsWith("\r")) t = t.slice(0, -1);
  const hostEnd = t.indexOf(" ");
  if (hostEnd < 1) return null;
  const identEnd = t.indexOf(" ", hostEnd + 1);
  if (identEnd < hostEnd + 2) return null;
  const userEnd = t.indexOf(" [", identEnd + 1);
  if (userEnd < identEnd + 2) return null;
  const at = parseTime(t, userEnd + 1);
  if (at === null) return null;
  let i = userEnd + 29;
  if (t[i] !== " ") return null;
  const reqEnd = closingQuote(t, i + 1);
  if (reqEnd < 0) return null;
  const request = t.slice(i + 2, reqEnd);
  i = reqEnd + 1;
  // A three-digit status, then the byte count: both are required.
  if (t[i] !== " " || t[i + 4] !== " ") return null;
  const status = digits(t, i + 1, 3);
  if (status === null || status < 100 || status > 599) return null;
  i += 4;
  let bytesEnd = t.indexOf(" ", i + 1);
  if (bytesEnd < 0) bytesEnd = t.length;
  const bytesText = t.slice(i + 1, bytesEnd);
  let bytes: number | null = null;
  if (bytesText !== "-") {
    if (bytesText.length < 1 || bytesText.length > 15) return null;
    bytes = digits(bytesText, 0, bytesText.length);
    if (bytes === null) return null;
  }
  let referer: string | null = null;
  let userAgent: string | null = null;
  if (bytesEnd < t.length) {
    const refEnd = closingQuote(t, bytesEnd + 1);
    if (refEnd < 0 || t[refEnd + 1] !== " ") return null;
    const uaEnd = closingQuote(t, refEnd + 2);
    if (uaEnd < 0 || (uaEnd + 1 < t.length && t[uaEnd + 1] !== " ")) return null;
    referer = dash(t.slice(bytesEnd + 2, refEnd));
    userAgent = dash(t.slice(refEnd + 3, uaEnd));
  }
  let method: string | null = null;
  let path: string | null = null;
  let protocol: string | null = null;
  const parts = request.split(" ");
  if (parts.length === 3 && isMethod(parts[0]) && parts[1] !== "" && parts[2].startsWith("HTTP/")) {
    [method, path, protocol] = parts;
  } else if (parts.length === 2 && isMethod(parts[0]) && parts[1] !== "") {
    [method, path] = parts;
  }
  return {
    remoteHost: t.slice(0, hostEnd),
    ident: dash(t.slice(hostEnd + 1, identEnd)),
    user: dash(t.slice(identEnd + 1, userEnd)),
    at,
    method,
    path,
    protocol,
    status,
    bytes,
    referer,
    userAgent,
  };
}