diff --git a/apps/web/src/app/(landing)/play/checker/api/route.ts b/apps/web/src/app/(landing)/play/checker/api/route.ts index e56d05932..ef45dbebc 100644 --- a/apps/web/src/app/(landing)/play/checker/api/route.ts +++ b/apps/web/src/app/(landing)/play/checker/api/route.ts @@ -130,7 +130,15 @@ async function* makeIterator({ ); } catch (error) { console.log(error); - return encoder.encode(""); + // Stream the failure so the client doesn't stop at N-1 regions. + return encoder.encode( + `${JSON.stringify({ + state: "error", + region, + message: "Check failed in this region", + index, + })}\n`, + ); } }); diff --git a/apps/web/src/app/(landing)/play/checker/client.tsx b/apps/web/src/app/(landing)/play/checker/client.tsx index 5061c6327..67f0a6727 100644 --- a/apps/web/src/app/(landing)/play/checker/client.tsx +++ b/apps/web/src/app/(landing)/play/checker/client.tsx @@ -27,27 +27,27 @@ import { import { IconCloudProvider } from "../../../../components/icon-cloud-provider"; import { - type Timing, - is32CharHex, latencyFormatter, - regionCheckerSchema, regionFormatter, } from "../../../../lib/checker/utils"; import { toast } from "../../../../lib/toast"; -import { cn, notEmpty } from "../../../../lib/utils"; +import { cn } from "../../../../lib/utils"; +import { + type CheckerFailure, + type CheckerSuccess, + parseCheckerLine, + splitStreamLines, +} from "./parse"; import { searchParamsParsers } from "./search-params"; import { handleExportCSV } from "./utils"; -type Values = { - region: string; - latency: number; - status: number; - timing: Timing; -}; +type Values = CheckerSuccess; type CheckerContextType = { values: Values[]; setValues: React.Dispatch>; + failures: CheckerFailure[]; + setFailures: React.Dispatch>; id: string | null; setId: React.Dispatch>; }; @@ -55,6 +55,8 @@ type CheckerContextType = { const CheckerContext = createContext({ values: [], setValues: () => {}, + failures: [], + setFailures: () => {}, id: null, setId: () => {}, }); @@ -67,6 +69,7 @@ export function CheckerProvider({ defaultValues?: Values[]; }) { const [values, setValues] = useState(defaultValues); + const [failures, setFailures] = useState([]); const [{ id: urlId }, setSearchParams] = useQueryStates(searchParamsParsers); const [id, setId] = useState(urlId); @@ -85,7 +88,16 @@ export function CheckerProvider({ }; return ( - + {children} ); @@ -106,7 +118,7 @@ export function Form({ defaultMethod?: string; defaultUrl?: string; }) { - const { setValues, setId } = useCheckerContext(); + const { setValues, setFailures, setId } = useCheckerContext(); const [isPending, startTransition] = useTransition(); const router = useRouter(); @@ -127,6 +139,7 @@ export function Form({ // Reset values and ID setValues([]); + setFailures([]); setId(null); // This will also clear the URL param startTransition(async () => { @@ -184,46 +197,36 @@ export function Form({ const decoder = new TextDecoder(); let done = false; + let buffer = ""; while (!done) { const { value, done: streamDone } = await reader.read(); done = streamDone; - if (value) { - const decoded = decoder.decode(value, { stream: true }); - if (!decoded) continue; - - const array = decoded.split("\n").filter(Boolean); - - const results = array - .map((item) => { - try { - // Store the ID if it's a 32-char hex string - if (is32CharHex(item)) { - setId(item); - resultId = item; - return null; - } - - const parsed = JSON.parse(item); - const validation = regionCheckerSchema.safeParse(parsed); - if (!validation.success) return null; - - const check = validation.data; - // Only process successful checks - if (check.state === "success") { - return { - region: check.region, - latency: check.latency, - status: check.status, - timing: check.timing, - }; - } - return null; - } catch { - return null; - } - }) - .filter(notEmpty); + buffer += value + ? decoder.decode(value, { stream: true }) + : decoder.decode(); + const { lines, rest } = splitStreamLines(buffer, done); + buffer = rest; + if (lines.length > 0) { + const results: Values[] = []; + const failed: CheckerFailure[] = []; + + for (const item of lines) { + const line = parseCheckerLine(item); + if (!line) continue; + if (line.type === "id") { + setId(line.id); + resultId = line.id; + } else if (line.type === "success") { + results.push(line.value); + } else { + failed.push(line.value); + } + } + + if (failed.length > 0) { + setFailures((prev) => [...prev, ...failed]); + } if (results.length > 0) { successCount += results.length; @@ -332,7 +335,7 @@ export function Form({ } export function ResultTable() { - const { values } = useCheckerContext(); + const { values, failures } = useCheckerContext(); return (
@@ -345,7 +348,7 @@ export function ResultTable() { - {values.length === 0 ? ( + {values.length === 0 && failures.length === 0 ? (
( + + ))}
- Results of your check ({values.length} / {AVAILABLE_REGIONS.length}{" "} - regions) + Results of your check ({values.length + failures.length} /{" "} + {AVAILABLE_REGIONS.length} regions + {failures.length > 0 ? `, ${failures.length} failed` : ""})
); } +function FailedRow({ failure }: { failure: CheckerFailure }) { + const regionConfig = regionDict[failure.region as Region]; + return ( + + + + + +
+ + + {regionConfig.flag} {regionConfig.code}{" "} + {regionConfig.location} + + + Failed + + {failure.message} + + + + ); +} + const STATUS_CODES = { "1": "bg-muted-foreground", "2": "bg-success", diff --git a/apps/web/src/app/(landing)/play/checker/parse.test.ts b/apps/web/src/app/(landing)/play/checker/parse.test.ts new file mode 100644 index 000000000..3cd1edc04 --- /dev/null +++ b/apps/web/src/app/(landing)/play/checker/parse.test.ts @@ -0,0 +1,111 @@ +import { expect } from "@std/expect"; +import { describe, test } from "@std/testing/bdd"; + +import { parseCheckerLine, splitStreamLines } from "./parse"; + +const timing = { + dnsStart: 0, + dnsDone: 2, + connectStart: 2, + connectDone: 3, + tlsHandshakeStart: 3, + tlsHandshakeDone: 8, + firstByteStart: 8, + firstByteDone: 46, + transferStart: 46, + transferDone: 49, +}; + +describe("parseCheckerLine", () => { + test("result id", () => { + const id = "aec4e0ec3c4f4557b8ce46e55078fc95"; + expect(parseCheckerLine(id)).toEqual({ type: "id", id }); + }); + + test("successful region", () => { + const line = JSON.stringify({ + type: "http", + state: "success", + region: "ams", + status: 200, + latency: 49, + headers: {}, + timestamp: 1724415466739, + timing, + index: 0, + }); + expect(parseCheckerLine(line)).toEqual({ + type: "success", + value: { region: "ams", latency: 49, status: 200, timing }, + }); + }); + + test("failed region is kept, not dropped", () => { + const line = JSON.stringify({ + state: "error", + region: "bom", + message: "Check failed in this region", + index: 3, + }); + expect(parseCheckerLine(line)).toEqual({ + type: "failure", + value: { region: "bom", message: "Check failed in this region" }, + }); + }); + + test("invalid json", () => { + expect(parseCheckerLine("{not json")).toBeNull(); + }); + + test("unknown region", () => { + const line = JSON.stringify({ + state: "error", + region: "nowhere", + message: "x", + }); + expect(parseCheckerLine(line)).toBeNull(); + }); +}); + +describe("splitStreamLines", () => { + test("keeps an unfinished line for the next read", () => { + expect(splitStreamLines('{"a":1}\n{"b":', false)).toEqual({ + lines: ['{"a":1}'], + rest: '{"b":', + }); + }); + + test("flushes the tail when the stream is done", () => { + const id = "aec4e0ec3c4f4557b8ce46e55078fc95"; + expect(splitStreamLines(`{"a":1}\n${id}`, true)).toEqual({ + lines: ['{"a":1}', id], + rest: "", + }); + }); + + test("a failed region split across chunks is still parsed", () => { + const record = `${JSON.stringify({ + state: "error", + region: "bom", + message: "Check failed in this region", + index: 3, + })}\n`; + const chunks = [record.slice(0, 20), record.slice(20)]; + + let buffer = ""; + const parsed = []; + for (const [i, chunk] of chunks.entries()) { + buffer += chunk; + const { lines, rest } = splitStreamLines(buffer, i === chunks.length - 1); + buffer = rest; + parsed.push(...lines.map(parseCheckerLine)); + } + + expect(parsed).toEqual([ + { + type: "failure", + value: { region: "bom", message: "Check failed in this region" }, + }, + ]); + }); +}); diff --git a/apps/web/src/app/(landing)/play/checker/parse.ts b/apps/web/src/app/(landing)/play/checker/parse.ts new file mode 100644 index 000000000..7c6474770 --- /dev/null +++ b/apps/web/src/app/(landing)/play/checker/parse.ts @@ -0,0 +1,65 @@ +import { + type Timing, + is32CharHex, + regionCheckerSchemaResponse, +} from "../../../../lib/checker/utils"; + +export type CheckerSuccess = { + region: string; + latency: number; + status: number; + timing: Timing; +}; + +export type CheckerFailure = { + region: string; + message: string; +}; + +export type CheckerLine = + | { type: "id"; id: string } + | { type: "success"; value: CheckerSuccess } + | { type: "failure"; value: CheckerFailure }; + +export function parseCheckerLine(line: string): CheckerLine | null { + if (is32CharHex(line)) return { type: "id", id: line }; + + let json: unknown; + try { + json = JSON.parse(line); + } catch { + return null; + } + + const validation = regionCheckerSchemaResponse.safeParse(json); + if (!validation.success) return null; + + const check = validation.data; + if (check.state === "error") { + return { + type: "failure", + value: { region: check.region, message: check.message }, + }; + } + + return { + type: "success", + value: { + region: check.region, + latency: check.latency, + status: check.status, + timing: check.timing, + }, + }; +} + +// Reader chunks can end mid-line: keep the unfinished tail for the next read, +// and flush it once the stream is done (the result id has no trailing newline). +export function splitStreamLines( + buffer: string, + done: boolean, +): { lines: string[]; rest: string } { + const parts = buffer.split("\n"); + const rest = done ? "" : (parts.pop() ?? ""); + return { lines: parts.filter(Boolean), rest }; +}