66 lines · 2.5 KB
import { useEffect, useState } from "react";
import { z } from "zod";

const logRow = z.object({ seq: z.number(), step: z.number(), text: z.string() });
const snapshot = z.object({ rows: z.array(logRow), done: z.boolean() });
const event = z.discriminatedUnion("type", [
  z.object({ type: z.literal("log"), seq: z.number(), step: z.number(), text: z.string() }),
  z.object({ type: z.literal("step"), index: z.number(), status: z.string(), conclusion: z.string().nullable() }),
  z.object({ type: z.literal("done"), conclusion: z.string().nullable() }),
]);

export type LogRow = z.infer<typeof logRow>;
type State = { rows: LogRow[]; live: boolean; error: string | null };

/** Loads a job's log and, while it runs, follows the Runner Durable Object's server-sent event stream. */
export function useJobLog(jobId: string, onStepChange: () => void) {
  const [state, setState] = useState<State>({ rows: [], live: false, error: null });

  useEffect(() => {
    let source: EventSource | null = null;
    let cancelled = false;

    async function start() {
      const response = await fetch(`/api/jobs/${jobId}/logs`);
      if (!response.ok) {
        setState((current) => ({ ...current, error: `Failed to load logs (${response.status})` }));
        return;
      }
      const initial = snapshot.parse(await response.json());
      if (cancelled) return;
      setState({ rows: initial.rows, live: !initial.done, error: null });
      if (initial.done) return;
      const after = initial.rows.at(-1)?.seq ?? 0;
      source = new EventSource(`/api/jobs/${jobId}/logs/stream?after=${after}`);
      source.onmessage = (message) => {
        const parsed = event.safeParse(JSON.parse(message.data));
        if (!parsed.success) return;
        const payload = parsed.data;
        if (payload.type === "log") {
          setState((current) =>