~james/core

core/web/src/lib/api/spindleLogs.ts -rw-r--r-- 2.9 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 15 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
// spindle websocket log stream
// replays from line one on connect and closes when all workflows settle

import { decodeFirst } from "@atcute/cbor";
import { spindleUrl } from "#lib/api/spindle.ts";
import type * as SubscribeLogs from "#lib/api/lexicons/types/org/tangled/ci/subscribePipelineLogs.ts";

export type LogControl = SubscribeLogs.Control;

export type LogData = SubscribeLogs.Data;

export type LogFrame =
	| { kind: "control"; control: LogControl }
	| { kind: "data"; data: LogData }
	| { kind: "error"; error: string; message: string };

// indigo event header ops
const OP_MESSAGE = 1;
const OP_ERROR = -1;

const asRecord = (value: unknown): Record<string, unknown> =>
	typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};

const str = (value: unknown): string => (typeof value === "string" ? value : "");

// decodes binary websocket frame into dag-cbor header and body
export const decodeLogFrame = (bytes: Uint8Array): LogFrame | null => {
	const [rawHeader, rest] = decodeFirst(bytes);
	const header = asRecord(rawHeader);
	if (header.op !== OP_MESSAGE && header.op !== OP_ERROR) return null;

	const [rawBody] = decodeFirst(rest);
	const body = asRecord(rawBody);

	if (header.op === OP_ERROR) {
		return { kind: "error", error: str(body.error), message: str(body.message) };
	}

	switch (header.t) {
		case "#control":
			return { kind: "control", control: body as unknown as LogControl };
		case "#data":
			return { kind: "data", data: body as unknown as LogData };
		default:
			return null;
	}
};

export const pipelineLogsUrl = (
	host: string,
	repo: string,
	pipeline: string,
	workflows: string[]
): string => {
	const url = new URL(
		"/xrpc/org.tangled.ci.subscribePipelineLogs",
		`${spindleUrl(host).replace(/^http/, "ws")}/`
	);
	url.searchParams.set("repo", repo);
	url.searchParams.set("pipeline", pipeline);
	for (const workflow of workflows) url.searchParams.append("workflows", workflow);
	return url.toString();
};

export interface LogStreamHandlers {
	onFrame: (frame: LogFrame) => void;
	onClose?: (clean: boolean) => void;
}

// opens log subscription, returns teardown function
export const subscribePipelineLogs = (
	host: string,
	repo: string,
	pipeline: string,
	workflows: string[],
	handlers: LogStreamHandlers
): (() => void) => {
	const socket = new WebSocket(pipelineLogsUrl(host, repo, pipeline, workflows));
	socket.binaryType = "arraybuffer";
	let live = true;

	socket.onmessage = (event: MessageEvent<ArrayBuffer | string>) => {
		if (!live || typeof event.data === "string") return;
		const frame = decodeLogFrame(new Uint8Array(event.data));
		if (frame) handlers.onFrame(frame);
	};

	socket.onclose = (event: CloseEvent) => {
		if (!live) return;
		live = false;
		handlers.onClose?.(event.wasClean);
	};

	// refused upgrades trigger an error then close, close handler handles it
	socket.onerror = () => {};

	return () => {
		live = false;
		socket.close();
	};
};