@telorun/debug-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +96 -0
- package/app-dist/assets/index-npnCjha3.css +1 -0
- package/app-dist/assets/index-tx0habRA.js +49 -0
- package/app-dist/index.html +22 -0
- package/app-single/index.html +70 -0
- package/package.json +68 -0
- package/src/App.tsx +10 -0
- package/src/components/DebugWatcher.tsx +86 -0
- package/src/components/EventTable.tsx +65 -0
- package/src/components/FilterBar.tsx +77 -0
- package/src/components/PayloadInspector.tsx +71 -0
- package/src/components/index.ts +5 -0
- package/src/components/styles.css +219 -0
- package/src/css.d.ts +3 -0
- package/src/filter.test.ts +53 -0
- package/src/filter.ts +52 -0
- package/src/index.ts +14 -0
- package/src/main.tsx +9 -0
- package/src/media.test.ts +39 -0
- package/src/media.ts +55 -0
- package/src/sse-client.ts +39 -0
- package/src/wire.ts +77 -0
package/src/filter.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type DebugEvent, eventSuffix } from "./wire.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A debug-event filter. All fields are AND-combined; an empty filter matches
|
|
5
|
+
* everything. Pure and framework-agnostic so the standalone app and the editor
|
|
6
|
+
* panel filter identically.
|
|
7
|
+
*/
|
|
8
|
+
export interface EventFilter {
|
|
9
|
+
/** Free-text, case-insensitive; matched against the event name and the
|
|
10
|
+
* JSON-stringified payload. */
|
|
11
|
+
text?: string;
|
|
12
|
+
/** Allowed event suffixes (`Invoked`, `Failed`, …). Empty/undefined = all. */
|
|
13
|
+
suffixes?: string[];
|
|
14
|
+
/** Substring matched against the event name's prefix (the kind/resource part). */
|
|
15
|
+
kind?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const EMPTY: readonly string[] = [];
|
|
19
|
+
|
|
20
|
+
/** True when `event` passes `filter`. */
|
|
21
|
+
export function matchesFilter(event: DebugEvent, filter: EventFilter): boolean {
|
|
22
|
+
const suffixes = filter.suffixes ?? EMPTY;
|
|
23
|
+
if (suffixes.length > 0 && !suffixes.includes(eventSuffix(event.event))) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
if (filter.kind && !event.event.toLowerCase().includes(filter.kind.toLowerCase())) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (filter.text) {
|
|
30
|
+
const needle = filter.text.toLowerCase();
|
|
31
|
+
const haystack = `${event.event} ${safePayloadText(event.payload)}`.toLowerCase();
|
|
32
|
+
if (!haystack.includes(needle)) return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Distinct event suffixes present in `events`, sorted — drives the facet list. */
|
|
38
|
+
export function distinctSuffixes(events: readonly DebugEvent[]): string[] {
|
|
39
|
+
const set = new Set<string>();
|
|
40
|
+
for (const e of events) set.add(eventSuffix(e.event));
|
|
41
|
+
return [...set].sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safePayloadText(payload: unknown): string {
|
|
45
|
+
if (payload == null) return "";
|
|
46
|
+
if (typeof payload === "string") return payload;
|
|
47
|
+
try {
|
|
48
|
+
return JSON.stringify(payload);
|
|
49
|
+
} catch {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Browser-safe, framework-agnostic surface: wire types + filtering + SSE client.
|
|
2
|
+
// No React, no Node built-ins — importable by the CLI (bun `source` condition),
|
|
3
|
+
// the editor webview, and the standalone app alike.
|
|
4
|
+
export {
|
|
5
|
+
type DebugEvent,
|
|
6
|
+
type WireRef,
|
|
7
|
+
type WireBlob,
|
|
8
|
+
isWireRef,
|
|
9
|
+
isWireBlob,
|
|
10
|
+
eventSuffix,
|
|
11
|
+
} from "./wire.js";
|
|
12
|
+
export { type EventFilter, matchesFilter, distinctSuffixes } from "./filter.js";
|
|
13
|
+
export { type DebugStreamHandlers, connectDebugStream } from "./sse-client.js";
|
|
14
|
+
export { type FoundBlob, collectBlobs, blobDimensions, formatBytes } from "./media.js";
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { blobDimensions, collectBlobs, formatBytes } from "./media.js";
|
|
3
|
+
|
|
4
|
+
const blob = (mediaType: string) => ({ $blob: "blobs/abc", mediaType, byteLength: 10 });
|
|
5
|
+
|
|
6
|
+
describe("collectBlobs", () => {
|
|
7
|
+
it("finds a nested blob with its path and parent", () => {
|
|
8
|
+
const payload = { outputs: { image: blob("image/png"), width: 1024, height: 768 } };
|
|
9
|
+
const found = collectBlobs(payload);
|
|
10
|
+
expect(found).toHaveLength(1);
|
|
11
|
+
expect(found[0].path).toBe("outputs.image");
|
|
12
|
+
expect(found[0].parent).toBe(payload.outputs);
|
|
13
|
+
expect(blobDimensions(found[0].parent)).toBe("1024×768");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("finds blobs in arrays", () => {
|
|
17
|
+
const payload = { files: [blob("application/pdf"), { nested: blob("image/jpeg") }] };
|
|
18
|
+
const found = collectBlobs(payload);
|
|
19
|
+
expect(found.map((f) => f.path)).toEqual(["files[0]", "files[1].nested"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns nothing for a blob-free payload", () => {
|
|
23
|
+
expect(collectBlobs({ a: 1, b: "x", c: [1, 2] })).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("is cycle-safe", () => {
|
|
27
|
+
const p: Record<string, unknown> = { img: blob("image/png") };
|
|
28
|
+
p.self = p;
|
|
29
|
+
expect(collectBlobs(p)).toHaveLength(1);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("formatBytes", () => {
|
|
34
|
+
it("formats sizes", () => {
|
|
35
|
+
expect(formatBytes(512)).toBe("512 B");
|
|
36
|
+
expect(formatBytes(48211)).toBe("47 KB");
|
|
37
|
+
expect(formatBytes(2_200_000)).toBe("2.1 MB");
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { isWireBlob, type WireBlob } from "./wire.js";
|
|
2
|
+
|
|
3
|
+
export interface FoundBlob {
|
|
4
|
+
/** Dotted path to the blob within the payload, e.g. `outputs.image`. */
|
|
5
|
+
path: string;
|
|
6
|
+
blob: WireBlob;
|
|
7
|
+
/** The object the blob sits in — lets a consumer read sibling `width`/`height`. */
|
|
8
|
+
parent: Record<string, unknown> | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Walk a payload and collect every {@link WireBlob} with its path and parent.
|
|
13
|
+
* Pure and cycle-safe (a debug payload shouldn't be cyclic, but a defensive
|
|
14
|
+
* `seen` set keeps a malformed one from looping). Order is depth-first.
|
|
15
|
+
*/
|
|
16
|
+
export function collectBlobs(payload: unknown): FoundBlob[] {
|
|
17
|
+
const found: FoundBlob[] = [];
|
|
18
|
+
const seen = new WeakSet<object>();
|
|
19
|
+
|
|
20
|
+
const walk = (value: unknown, path: string, parent: Record<string, unknown> | null): void => {
|
|
21
|
+
if (isWireBlob(value)) {
|
|
22
|
+
found.push({ path, blob: value, parent });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!value || typeof value !== "object") return;
|
|
26
|
+
if (seen.has(value)) return;
|
|
27
|
+
seen.add(value);
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
value.forEach((v, i) => walk(v, `${path}[${i}]`, parent));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const obj = value as Record<string, unknown>;
|
|
33
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
34
|
+
walk(v, path ? `${path}.${k}` : k, obj);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
walk(payload, "", null);
|
|
39
|
+
return found;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sibling pixel dimensions of a blob, when the producer put them next to it. */
|
|
43
|
+
export function blobDimensions(parent: Record<string, unknown> | null): string | null {
|
|
44
|
+
if (!parent) return null;
|
|
45
|
+
const w = parent.width;
|
|
46
|
+
const h = parent.height;
|
|
47
|
+
return typeof w === "number" && typeof h === "number" ? `${w}×${h}` : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Human-readable byte size, e.g. `47 KB`, `2.1 MB`. */
|
|
51
|
+
export function formatBytes(n: number): string {
|
|
52
|
+
if (n < 1024) return `${n} B`;
|
|
53
|
+
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
|
|
54
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { DebugEvent } from "./wire.js";
|
|
2
|
+
|
|
3
|
+
export interface DebugStreamHandlers {
|
|
4
|
+
onEvent: (event: DebugEvent) => void;
|
|
5
|
+
/** Connection state changes — drives a status indicator. */
|
|
6
|
+
onStatus?: (status: "connecting" | "open" | "closed") => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Connect to a producer's `/events` SSE endpoint. The browser's `EventSource`
|
|
11
|
+
* handles reconnection on its own; on each (re)connect the producer re-flushes
|
|
12
|
+
* its replay buffer, so a late or reconnected client always sees history.
|
|
13
|
+
*
|
|
14
|
+
* Returns a disposer that closes the connection. Runtime-agnostic: the URL can
|
|
15
|
+
* point at a Node CLI run, a Docker-mapped port, or a future Rust kernel —
|
|
16
|
+
* they all speak the same wire format.
|
|
17
|
+
*/
|
|
18
|
+
export function connectDebugStream(url: string, handlers: DebugStreamHandlers): () => void {
|
|
19
|
+
handlers.onStatus?.("connecting");
|
|
20
|
+
const source = new EventSource(url);
|
|
21
|
+
|
|
22
|
+
source.onopen = () => handlers.onStatus?.("open");
|
|
23
|
+
|
|
24
|
+
source.onmessage = (msg) => {
|
|
25
|
+
if (!msg.data) return;
|
|
26
|
+
try {
|
|
27
|
+
handlers.onEvent(JSON.parse(msg.data) as DebugEvent);
|
|
28
|
+
} catch {
|
|
29
|
+
// A malformed frame shouldn't kill the stream; skip it.
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
source.onerror = () => handlers.onStatus?.("connecting");
|
|
34
|
+
|
|
35
|
+
return () => {
|
|
36
|
+
source.close();
|
|
37
|
+
handlers.onStatus?.("closed");
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/wire.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Telo debug event wire format — the language-neutral contract between a
|
|
3
|
+
* kernel runtime (the producer: Node today, Rust/Go later) and any consumer
|
|
4
|
+
* (this UI, the editor). A producer serializes each emitted event to this shape;
|
|
5
|
+
* consumers only ever parse it. The companion JSON Schema (`wire-schema.json`)
|
|
6
|
+
* is the source of truth a non-TypeScript producer conforms to.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** One event as it travels over the wire (one JSON object per SSE message / JSONL line). */
|
|
10
|
+
export interface DebugEvent {
|
|
11
|
+
/** ISO-8601 timestamp set by the producer when the event was emitted. */
|
|
12
|
+
timestamp: string;
|
|
13
|
+
/** Dotted event name, e.g. `Server.Listening`, `MyKind.MyName.Invoked`. */
|
|
14
|
+
event: string;
|
|
15
|
+
/** Arbitrary event payload, already reduced to wire-safe values (see encoding rules). */
|
|
16
|
+
payload?: unknown;
|
|
17
|
+
/** Optional producer-attached metadata (namespace, resource, kind, name, …). */
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Value-encoding rules a producer applies before a value enters `payload`:
|
|
23
|
+
* - a resolved `!ref` (a live resource instance) → `{ kind, name }`
|
|
24
|
+
* - any other live / unrepresentable value (controller instance, stream, client,
|
|
25
|
+
* function, bigint) → a `"[Marker]"` string (e.g. `"[Stream]"`, `"[Kernel]"`)
|
|
26
|
+
* - a reference cycle → `"[Circular]"`
|
|
27
|
+
* - everything else → plain JSON
|
|
28
|
+
* Consumers shouldn't assume `payload` is any particular shape.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** A resolved-reference value as it appears in a payload. */
|
|
32
|
+
export interface WireRef {
|
|
33
|
+
kind: string;
|
|
34
|
+
name: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A binary value offloaded to the producer's blob store. The bytes are NOT in the
|
|
39
|
+
* payload — `$blob` is a path (relative to the producer origin) the consumer
|
|
40
|
+
* fetches on demand (e.g. `<img src>`, a download link). The object key this sits
|
|
41
|
+
* under is preserved; only the bytes left the log.
|
|
42
|
+
*/
|
|
43
|
+
export interface WireBlob {
|
|
44
|
+
$blob: string;
|
|
45
|
+
mediaType: string;
|
|
46
|
+
byteLength: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Narrow a payload value to a {@link WireBlob}. */
|
|
50
|
+
export function isWireBlob(value: unknown): value is WireBlob {
|
|
51
|
+
return (
|
|
52
|
+
!!value &&
|
|
53
|
+
typeof value === "object" &&
|
|
54
|
+
!Array.isArray(value) &&
|
|
55
|
+
typeof (value as WireBlob).$blob === "string" &&
|
|
56
|
+
typeof (value as WireBlob).mediaType === "string"
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Narrow a payload value to a {@link WireRef} (e.g. to render it as a chip/link). */
|
|
61
|
+
export function isWireRef(value: unknown): value is WireRef {
|
|
62
|
+
return (
|
|
63
|
+
!!value &&
|
|
64
|
+
typeof value === "object" &&
|
|
65
|
+
!Array.isArray(value) &&
|
|
66
|
+
typeof (value as WireRef).kind === "string" &&
|
|
67
|
+
typeof (value as WireRef).name === "string" &&
|
|
68
|
+
Object.keys(value as object).length === 2
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The trailing segment of a dotted event name — the "kind" of event
|
|
73
|
+
* (`Invoked`, `Failed`, `Listening`, …). Used for facet filtering and color. */
|
|
74
|
+
export function eventSuffix(event: string): string {
|
|
75
|
+
const dot = event.lastIndexOf(".");
|
|
76
|
+
return dot >= 0 ? event.slice(dot + 1) : event;
|
|
77
|
+
}
|