@fabrika/canvas-kernel 0.1.44

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.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/annotate.ts +94 -0
  4. package/daemon.ts +46 -0
  5. package/index.ts +84 -0
  6. package/package.json +74 -0
  7. package/src/client/AnnotationDraftFooter.tsx +76 -0
  8. package/src/client/AnnotationEditor.tsx +320 -0
  9. package/src/client/AnnotationList.tsx +190 -0
  10. package/src/client/AnnotationProvider.tsx +294 -0
  11. package/src/client/AnnotationSidebar.tsx +374 -0
  12. package/src/client/FileIcon.tsx +76 -0
  13. package/src/client/PlanRenderer.tsx +587 -0
  14. package/src/client/Popover.tsx +361 -0
  15. package/src/client/RenderErrorContext.ts +11 -0
  16. package/src/client/ResponsePreview.tsx +186 -0
  17. package/src/client/SessionContext.tsx +3 -0
  18. package/src/client/annotationContext.ts +130 -0
  19. package/src/client/annotationDom.ts +115 -0
  20. package/src/client/annotationDraft.ts +130 -0
  21. package/src/client/annotationMarkdown.ts +102 -0
  22. package/src/client/annotationTarget.ts +73 -0
  23. package/src/client/appContext.tsx +61 -0
  24. package/src/client/blockTarget.ts +127 -0
  25. package/src/client/components/Callout.tsx +27 -0
  26. package/src/client/components/Checklist.tsx +37 -0
  27. package/src/client/components/Choice.tsx +178 -0
  28. package/src/client/components/CodeBlock.tsx +35 -0
  29. package/src/client/components/Diff.tsx +36 -0
  30. package/src/client/components/FilePreview.tsx +88 -0
  31. package/src/client/components/Image.tsx +48 -0
  32. package/src/client/components/Markdown.tsx +44 -0
  33. package/src/client/components/Mermaid.tsx +576 -0
  34. package/src/client/components/Note.tsx +14 -0
  35. package/src/client/components/Priority.tsx +19 -0
  36. package/src/client/components/ResponseNote.tsx +19 -0
  37. package/src/client/components/Section.tsx +33 -0
  38. package/src/client/components/Table.tsx +35 -0
  39. package/src/client/components/Task.tsx +76 -0
  40. package/src/client/components/UserInput.tsx +176 -0
  41. package/src/client/components/index.ts +18 -0
  42. package/src/client/components/renderMarkdownChildren.tsx +100 -0
  43. package/src/client/diffLines.ts +50 -0
  44. package/src/client/generateMarkdown.ts +250 -0
  45. package/src/client/highlightRange.ts +262 -0
  46. package/src/client/hostContext.tsx +55 -0
  47. package/src/client/index.ts +128 -0
  48. package/src/client/popoverPosition.ts +46 -0
  49. package/src/client/preact-all.ts +4 -0
  50. package/src/client/regionTarget.ts +180 -0
  51. package/src/client/runtime.ts +149 -0
  52. package/src/client/styles.css +492 -0
  53. package/src/client/testing/dom.ts +58 -0
  54. package/src/client/textTarget.ts +31 -0
  55. package/src/client/theme.css +66 -0
  56. package/src/client/useRegionAnnotation.tsx +188 -0
  57. package/src/client/useTextAnnotation.tsx +194 -0
  58. package/src/client/utils.ts +33 -0
  59. package/src/engine/channel.ts +278 -0
  60. package/src/engine/compiler.ts +415 -0
  61. package/src/engine/http-utils.ts +14 -0
  62. package/src/engine/lifecycle.ts +302 -0
  63. package/src/engine/paths.ts +63 -0
  64. package/src/engine/router.ts +16 -0
  65. package/src/engine/session.ts +678 -0
  66. package/src/engine/waiter.ts +188 -0
  67. package/src/engine/watcher.ts +84 -0
  68. package/src/engine/websocket.ts +159 -0
@@ -0,0 +1,188 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { useAnnotations } from "./AnnotationProvider";
3
+ import { AnnotationCreatePopover } from "./Popover";
4
+ import {
5
+ applyRegionStyle,
6
+ createRegionOverlay,
7
+ describeRegion,
8
+ isDrawableRegion,
9
+ regionBetween,
10
+ regionPointIn,
11
+ regionTarget,
12
+ REGION_HOST_ATTR,
13
+ type RegionPoint,
14
+ type RegionShape,
15
+ } from "./regionTarget";
16
+ import { generateAnnotationId } from "./utils";
17
+
18
+ interface UseRegionAnnotationOptions {
19
+ /** Container holding the image blocks a region can be drawn on. */
20
+ containerRef: React.RefObject<HTMLElement | null>;
21
+ /** Re-registers the drawing listener when the canvas remounts. */
22
+ restoreKey: unknown;
23
+ /** Shape a new region takes. */
24
+ shape?: RegionShape;
25
+ /** Canvas file the created annotation belongs to. */
26
+ canvasFile?: string;
27
+ /** Scroll container for popover positioning. */
28
+ scrollContainer?: HTMLElement | null;
29
+ }
30
+
31
+ /** One region being drawn out, held outside React: the box follows the pointer
32
+ * at pointer rate, which is no reason to re-render the canvas. */
33
+ interface RegionDrag {
34
+ pointerId: number;
35
+ host: HTMLElement;
36
+ src: string;
37
+ start: RegionPoint;
38
+ shape: RegionShape;
39
+ overlay: HTMLElement;
40
+ id: string;
41
+ }
42
+
43
+ /** A region drawn but not yet given a note. */
44
+ interface PendingRegion {
45
+ overlay: HTMLElement;
46
+ id: string;
47
+ snippet: string;
48
+ label: string;
49
+ }
50
+
51
+ /**
52
+ * Drag on an image block to annotate one part of it.
53
+ *
54
+ * An image carries no text, so a plain drag over one is unambiguous and takes
55
+ * no modifier: the selection this would compete with cannot start there. It
56
+ * still has to stay off the two things that already live on an image block —
57
+ * the whole-image comment button and any region already drawn.
58
+ */
59
+ export function useRegionAnnotation(options: UseRegionAnnotationOptions) {
60
+ const { containerRef, restoreKey, shape = "rect", canvasFile, scrollContainer } = options;
61
+ const { addAnnotationWithId } = useAnnotations();
62
+
63
+ const [pending, setPending] = useState<PendingRegion | null>(null);
64
+ const [isDrawing, setIsDrawing] = useState(false);
65
+
66
+ const addAnnotationRef = useRef(addAnnotationWithId);
67
+ addAnnotationRef.current = addAnnotationWithId;
68
+ const canvasFileRef = useRef(canvasFile);
69
+ canvasFileRef.current = canvasFile;
70
+ const shapeRef = useRef(shape);
71
+ shapeRef.current = shape;
72
+
73
+ useEffect(() => {
74
+ const container = containerRef.current;
75
+ if (!container) return;
76
+
77
+ let drag: RegionDrag | null = null;
78
+
79
+ /** Where the pointer is, on the region grid of `host`. */
80
+ function pointIn(host: HTMLElement, e: PointerEvent): RegionPoint | null {
81
+ const rect = host.getBoundingClientRect();
82
+ return regionPointIn(rect, e.clientX - rect.left, e.clientY - rect.top);
83
+ }
84
+
85
+ function endDrag() {
86
+ if (!drag) return;
87
+ const { host, pointerId } = drag;
88
+ if (host.hasPointerCapture(pointerId)) host.releasePointerCapture(pointerId);
89
+ host.removeEventListener("pointermove", onMove);
90
+ host.removeEventListener("pointerup", onUp);
91
+ host.removeEventListener("pointercancel", onCancel);
92
+ drag = null;
93
+ setIsDrawing(false);
94
+ }
95
+
96
+ function onDown(e: PointerEvent) {
97
+ if (drag || e.button !== 0) return;
98
+ const target = e.target;
99
+ if (!(target instanceof Element)) return;
100
+ // The comment button mints the whole-image annotation, and a region
101
+ // already drawn is its own click target; neither starts a new one.
102
+ if (target.closest("[data-block-comment-btn]")) return;
103
+ if (target.closest("[data-annotation-id]")) return;
104
+
105
+ const host = target.closest(`[${REGION_HOST_ATTR}]`);
106
+ if (!(host instanceof HTMLElement) || !container?.contains(host)) return;
107
+ const src = host.closest("[data-md='image']")?.getAttribute("data-md-src");
108
+ if (!src) return;
109
+ const start = pointIn(host, e);
110
+ if (!start) return;
111
+
112
+ // Without this the browser starts its own image drag instead.
113
+ e.preventDefault();
114
+ host.setPointerCapture(e.pointerId);
115
+
116
+ const id = generateAnnotationId();
117
+ const overlay = createRegionOverlay({ ...start, w: 0, h: 0 }, shapeRef.current, id);
118
+ overlay.classList.add("ann-region-draft");
119
+ host.appendChild(overlay);
120
+
121
+ drag = { pointerId: e.pointerId, host, src, start, shape: shapeRef.current, overlay, id };
122
+ host.addEventListener("pointermove", onMove);
123
+ host.addEventListener("pointerup", onUp);
124
+ host.addEventListener("pointercancel", onCancel);
125
+ setIsDrawing(true);
126
+ }
127
+
128
+ function onMove(e: PointerEvent) {
129
+ if (!drag || drag.pointerId !== e.pointerId) return;
130
+ const point = pointIn(drag.host, e);
131
+ if (!point) return;
132
+ applyRegionStyle(drag.overlay, regionBetween(drag.start, point));
133
+ }
134
+
135
+ function onUp(e: PointerEvent) {
136
+ if (!drag || drag.pointerId !== e.pointerId) return;
137
+ const { overlay, id, src, shape: drawnShape } = drag;
138
+ const point = pointIn(drag.host, e) ?? drag.start;
139
+ const box = regionBetween(drag.start, point);
140
+ endDrag();
141
+
142
+ if (!isDrawableRegion(box)) {
143
+ overlay.remove();
144
+ return;
145
+ }
146
+ applyRegionStyle(overlay, box);
147
+ const locator = { src, shape: drawnShape, ...box };
148
+ setPending({ overlay, id, snippet: regionTarget.format(locator), label: describeRegion(locator) });
149
+ }
150
+
151
+ function onCancel(e: PointerEvent) {
152
+ if (!drag || drag.pointerId !== e.pointerId) return;
153
+ drag.overlay.remove();
154
+ endDrag();
155
+ }
156
+
157
+ container.addEventListener("pointerdown", onDown);
158
+ return () => {
159
+ container.removeEventListener("pointerdown", onDown);
160
+ if (drag) {
161
+ drag.overlay.remove();
162
+ endDrag();
163
+ }
164
+ };
165
+ }, [restoreKey]);
166
+
167
+ const popovers = pending ? (
168
+ <AnnotationCreatePopover
169
+ anchorEl={pending.overlay}
170
+ scrollContainer={scrollContainer}
171
+ snippet={pending.label}
172
+ truncateAt={80}
173
+ onAdd={(note, images) => {
174
+ // The overlay already carries the annotation's real id, so committing
175
+ // is only dropping the draft look — no redraw, no flicker.
176
+ pending.overlay.classList.remove("ann-region-draft");
177
+ addAnnotationRef.current(pending.id, pending.snippet, note, undefined, undefined, images, canvasFileRef.current);
178
+ setPending(null);
179
+ }}
180
+ onCancel={() => {
181
+ pending.overlay.remove();
182
+ setPending(null);
183
+ }}
184
+ />
185
+ ) : null;
186
+
187
+ return { popovers, isDrawing, isPopoverOpen: !!pending };
188
+ }
@@ -0,0 +1,194 @@
1
+ import React, { useEffect, useState, useRef, useCallback } from "react";
2
+ import type { AnnotationContext, Annotation } from "./AnnotationProvider";
3
+ import { useAnnotations } from "./AnnotationProvider";
4
+ import { wrapRangeWithMark, updateAllMarkStates, renameMarkId, unwrapMarks, rangeIndexText } from "./highlightRange";
5
+ import { restoreAnnotationTargets } from "./annotationDom";
6
+ import { AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
7
+ import { generateAnnotationId } from "./utils";
8
+
9
+ interface UseTextAnnotationOptions {
10
+ /** Ref to the container element where text can be selected and marks are placed */
11
+ containerRef: React.RefObject<HTMLElement | null>;
12
+ /** Trigger for mark restoration — when this value changes, marks are restored */
13
+ restoreKey: unknown;
14
+ /** Subset of annotations to restore in this container */
15
+ restoreAnnotations: Annotation[];
16
+ /** Build AnnotationContext from a selection Range */
17
+ extractContext: (range: Range) => AnnotationContext | undefined;
18
+ /** Optional filePath to associate with created annotations */
19
+ filePath?: string;
20
+ /** Optional canvasFile to associate with created annotations */
21
+ canvasFile?: string;
22
+ /** Scroll container for popover positioning */
23
+ scrollContainer?: HTMLElement | null;
24
+ }
25
+
26
+ export function useTextAnnotation(options: UseTextAnnotationOptions) {
27
+ const { containerRef, restoreKey, restoreAnnotations, extractContext, filePath, canvasFile, scrollContainer } = options;
28
+ const { annotations: allAnnotations, addAnnotationWithId, removeAnnotation, updateAnnotation, addAnnotationImage, removeAnnotationImage, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
29
+
30
+ const [editPopover, setEditPopover] = useState<{ anchorEl: HTMLElement; annId: string } | null>(null);
31
+ const [createPopover, setCreatePopover] = useState<{ anchorEl: HTMLElement; tempId: string; snippet: string; ctx?: AnnotationContext } | null>(null);
32
+
33
+ // Refs for stable handlers (registered with empty or minimal deps)
34
+ const extractContextRef = useRef(extractContext);
35
+ extractContextRef.current = extractContext;
36
+ const filePathRef = useRef(filePath);
37
+ filePathRef.current = filePath;
38
+ const canvasFileRef = useRef(canvasFile);
39
+ canvasFileRef.current = canvasFile;
40
+ const addAnnotationRef = useRef(addAnnotationWithId);
41
+ addAnnotationRef.current = addAnnotationWithId;
42
+ const activeAnnotationIdRef = useRef(activeAnnotationId);
43
+ activeAnnotationIdRef.current = activeAnnotationId;
44
+ const setActiveAnnotationIdRef = useRef(setActiveAnnotationId);
45
+ setActiveAnnotationIdRef.current = setActiveAnnotationId;
46
+
47
+ // Decoration restoration after content renders or annotations change. Not
48
+ // only marks: every locator strategy puts its own decoration back here, so a
49
+ // host that mounts this hook gets region overlays restored too.
50
+ const restoreAnnotationsLen = restoreAnnotations.length;
51
+ useEffect(() => {
52
+ if (!containerRef.current) return;
53
+ const timer = setTimeout(() => {
54
+ if (containerRef.current) {
55
+ restoreAnnotationTargets(containerRef.current, restoreAnnotations);
56
+ }
57
+ }, 50);
58
+ return () => clearTimeout(timer);
59
+ }, [restoreKey, restoreAnnotationsLen]);
60
+
61
+ // Mark active state tracking
62
+ const prevActiveRef = useRef<string | null>(null);
63
+ useEffect(() => {
64
+ updateAllMarkStates(activeAnnotationId, prevActiveRef.current);
65
+ prevActiveRef.current = activeAnnotationId;
66
+ }, [activeAnnotationId]);
67
+
68
+ // Mark click/hover/out handlers on the container (uses refs to avoid re-registration on every hover)
69
+ useEffect(() => {
70
+ const container = containerRef.current;
71
+ if (!container) return;
72
+
73
+ const handleClick = (e: MouseEvent) => {
74
+ const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
75
+ if (!mark) return;
76
+ e.stopPropagation();
77
+ const annId = mark.getAttribute("data-annotation-id")!;
78
+ if (annId === activeAnnotationIdRef.current) {
79
+ setEditPopover({ anchorEl: mark, annId });
80
+ } else {
81
+ setActiveAnnotationIdRef.current(annId);
82
+ }
83
+ };
84
+
85
+ const handleMouseOver = (e: MouseEvent) => {
86
+ const mark = (e.target as HTMLElement).closest("[data-annotation-id]") as HTMLElement | null;
87
+ if (mark) setActiveAnnotationIdRef.current(mark.getAttribute("data-annotation-id")!);
88
+ };
89
+
90
+ const handleMouseOut = (e: MouseEvent) => {
91
+ const mark = (e.target as HTMLElement).closest("[data-annotation-id]");
92
+ const related = (e.relatedTarget as HTMLElement | null)?.closest?.("[data-annotation-id]");
93
+ if (mark && !related && !document.getElementById("ann-inline-popover")) setActiveAnnotationIdRef.current(null);
94
+ };
95
+
96
+ container.addEventListener("click", handleClick);
97
+ container.addEventListener("mouseover", handleMouseOver);
98
+ container.addEventListener("mouseout", handleMouseOut);
99
+ return () => {
100
+ container.removeEventListener("click", handleClick);
101
+ container.removeEventListener("mouseover", handleMouseOver);
102
+ container.removeEventListener("mouseout", handleMouseOut);
103
+ };
104
+ }, [restoreKey]);
105
+
106
+ // Document-level mouseup for text selection → mark → popover
107
+ useEffect(() => {
108
+ const handler = () => handleMouseUp();
109
+ document.addEventListener("mouseup", handler);
110
+ return () => document.removeEventListener("mouseup", handler);
111
+ }, []);
112
+
113
+ const handleMouseUp = useCallback(() => {
114
+ const sel = window.getSelection();
115
+ if (!sel || sel.isCollapsed || !sel.toString().trim()) return;
116
+ if (!containerRef.current) return;
117
+ const range = sel.getRangeAt(0);
118
+ if (!containerRef.current.contains(range.startContainer)) return;
119
+ if ((range.startContainer.parentElement as HTMLElement)?.closest?.("[data-annotation-id]")) return;
120
+ // Not sel.toString(): a selection reaching over an existing annotation
121
+ // carries text the index does not hold, and a snippet naming that text can
122
+ // never be restored.
123
+ const snippet = rangeIndexText(range).trim();
124
+ if (snippet.length < 2) return;
125
+
126
+ // Extract context before wrapping disturbs the DOM
127
+ const ctx = extractContextRef.current(range);
128
+ const savedRange = range.cloneRange();
129
+ const tempId = `__pending_${Date.now()}`;
130
+ try { wrapRangeWithMark(savedRange, tempId); } catch {}
131
+ window.getSelection()?.removeAllRanges();
132
+
133
+ const marks = document.querySelectorAll(`[data-annotation-id="${tempId}"]`);
134
+ const lastMark = marks[marks.length - 1] as HTMLElement | undefined;
135
+ if (!lastMark) return;
136
+
137
+ setCreatePopover({ anchorEl: lastMark, tempId, snippet, ctx });
138
+ }, []);
139
+
140
+ const openCreatePopover = useCallback((anchorEl: HTMLElement, tempId: string, snippet: string, ctx?: AnnotationContext) => {
141
+ setCreatePopover({ anchorEl, tempId, snippet, ctx });
142
+ }, []);
143
+
144
+ const popovers = (
145
+ <>
146
+ {editPopover && (() => {
147
+ const ann = allAnnotations.find((a) => a.id === editPopover.annId);
148
+ if (!ann) return null;
149
+ return (
150
+ <AnnotationEditPopover
151
+ anchorEl={editPopover.anchorEl}
152
+ scrollContainer={scrollContainer}
153
+ initialNote={ann.note}
154
+ initialImages={ann.images}
155
+ onUpdate={(note, images) => {
156
+ updateAnnotation(editPopover.annId, note);
157
+ // Sync images
158
+ const current = ann.images || [];
159
+ for (const img of images) {
160
+ if (!current.includes(img)) addAnnotationImage(editPopover.annId, img);
161
+ }
162
+ for (const img of current) {
163
+ if (!images.includes(img)) removeAnnotationImage(editPopover.annId, img);
164
+ }
165
+ }}
166
+ onDelete={() => { removeAnnotation(editPopover.annId); setActiveAnnotationId(null); }}
167
+ onClose={() => setEditPopover(null)}
168
+ />
169
+ );
170
+ })()}
171
+
172
+ {createPopover && (
173
+ <AnnotationCreatePopover
174
+ anchorEl={createPopover.anchorEl}
175
+ scrollContainer={scrollContainer}
176
+ snippet={createPopover.snippet}
177
+ truncateAt={80}
178
+ onAdd={(note, images) => {
179
+ const id = generateAnnotationId();
180
+ renameMarkId(createPopover.tempId, id);
181
+ addAnnotationRef.current(id, createPopover.snippet, note, filePathRef.current, createPopover.ctx, images, canvasFileRef.current);
182
+ setCreatePopover(null);
183
+ }}
184
+ onCancel={() => {
185
+ unwrapMarks(createPopover.tempId);
186
+ setCreatePopover(null);
187
+ }}
188
+ />
189
+ )}
190
+ </>
191
+ );
192
+
193
+ return { popovers, openCreatePopover, isPopoverOpen: !!(editPopover || createPopover) };
194
+ }
@@ -0,0 +1,33 @@
1
+ /** Sentinel filePath for annotations on the agent response banner */
2
+ export const RESPONSE_ANNOTATION_PATH = "__agent-response__";
3
+
4
+ /**
5
+ * The file this annotation lives in, or undefined if it lives in no file.
6
+ * Response annotations ride in the same field under a sentinel, so a bare
7
+ * `if (ann.filePath)` opens a file tab for a path that is not one.
8
+ */
9
+ export function fileAnnotationPath(ann: { filePath?: string }): string | undefined {
10
+ return ann.filePath && ann.filePath !== RESPONSE_ANNOTATION_PATH ? ann.filePath : undefined;
11
+ }
12
+
13
+ /**
14
+ * Whether the attach-image affordances are drawn at all.
15
+ *
16
+ * The button, the paste handler and the drop zone all end in one POST to the
17
+ * host's upload endpoint, so a host without one gets none of the three instead
18
+ * of controls that fail silently.
19
+ */
20
+ export function hostAcceptsUploads(host: { uploadUrl: (() => string) | null }): boolean {
21
+ return host.uploadUrl !== null;
22
+ }
23
+
24
+ /** Generate a unique annotation ID */
25
+ export function generateAnnotationId(): string {
26
+ return `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
27
+ }
28
+
29
+ /** Auto-resize a textarea to fit its content */
30
+ export function autoResizeTextarea(el: HTMLTextAreaElement, minHeight?: number) {
31
+ el.style.height = "auto";
32
+ el.style.height = (minHeight ? Math.max(minHeight, el.scrollHeight) : el.scrollHeight) + "px";
33
+ }
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The WebSocket notification channel: a two-role socket registry keyed by an
3
+ * opaque topic string.
4
+ *
5
+ * The kernel owns the plumbing — who is registered under which topic, how a
6
+ * message reaches browsers or waiters, when a waiter is announced as present,
7
+ * and the ping/pong sweep that evicts dead waiters. It owns no vocabulary: the
8
+ * host supplies the frames, decides what a frame does, and decides whether a
9
+ * relayed frame closes the waiters that received it.
10
+ *
11
+ * Two hosts map onto it today:
12
+ *
13
+ * - agent-canvas (`engine/websocket.ts`) — topic = session id, waiter policy
14
+ * `close-on-delivery` (the CLI's `wait` socket is satisfied by one frame),
15
+ * frames `submit` / `render-error`, presence frame `watcher-status`.
16
+ * - obrazovka — topic = Figma file key, waiter policy `keep-open` (a `--page`
17
+ * waiter must survive a relay meant for a different page and filter frames
18
+ * client-side), frames `confirm-screens` / `submit` registered by the host
19
+ * plus server-initiated relays `run` / `feedback`, presence frame `presence`.
20
+ */
21
+
22
+ export type ChannelRole = "browser" | "wait";
23
+
24
+ /** The minimum a host socket must offer; `Bun.ServerWebSocket` satisfies it. */
25
+ export interface ChannelSocket {
26
+ send(message: string): unknown;
27
+ close(): void;
28
+ ping(): unknown;
29
+ }
30
+
31
+ export interface RelayOptions {
32
+ /**
33
+ * Close every waiter the message was handed to and drop the topic's waiter
34
+ * set. Defaults to the channel's `waiterPolicy`.
35
+ */
36
+ close?: boolean;
37
+ }
38
+
39
+ /** Channel operations bound to one topic. */
40
+ export interface TopicChannel {
41
+ readonly topic: string;
42
+ /** Send to every browser on the topic. */
43
+ broadcast(message: unknown): void;
44
+ /** Send to every waiter on the topic; `false` when there was none. */
45
+ relayToWaiters(message: unknown, options?: RelayOptions): boolean;
46
+ hasWaiter(): boolean;
47
+ /** Announce current waiter presence to the topic's browsers. */
48
+ broadcastPresence(): void;
49
+ }
50
+
51
+ export interface BrowserFrameHandler {
52
+ /** The bound work when the frame is this handler's, `undefined` otherwise. */
53
+ bind(frame: unknown, channel: TopicChannel): (() => void | Promise<void>) | undefined;
54
+ }
55
+
56
+ /**
57
+ * Register one frame of a host's vocabulary. Matching is synchronous so the
58
+ * socket's `message` handler stays synchronous; handling may be async.
59
+ */
60
+ export function browserFrame<T>(
61
+ match: (frame: unknown) => frame is T,
62
+ handle: (frame: T, channel: TopicChannel) => void | Promise<void>,
63
+ ): BrowserFrameHandler {
64
+ return {
65
+ bind: (frame, channel) => (match(frame) ? () => handle(frame, channel) : undefined),
66
+ };
67
+ }
68
+
69
+ export interface NotificationChannelOptions<S extends ChannelSocket> {
70
+ /** Which registry a socket belongs to. */
71
+ roleOf(socket: S): ChannelRole;
72
+ /** The socket's routing key — a canvas session id, a Figma file key, … */
73
+ topicOf(socket: S): string;
74
+ /** The host's browser-frame vocabulary, tried in order; first match wins. */
75
+ frames?: readonly BrowserFrameHandler[];
76
+ /** Builds the presence frame. Presence is not announced when omitted. */
77
+ presenceFrame?(hasWaiter: boolean, topic: string): unknown;
78
+ /** What a relay does to the waiters it reached. Default `"keep-open"`. */
79
+ waiterPolicy?: "close-on-delivery" | "keep-open";
80
+ /**
81
+ * Runs after a waiter is registered and before presence is announced, so a
82
+ * host that buffers events for an absent waiter can flush them here.
83
+ */
84
+ onWaiterOpen?(channel: TopicChannel): void;
85
+ /** Frame handler failures. Dropped when omitted. */
86
+ onError?(error: unknown): void;
87
+ /** Liveness sweep period; `false` leaves the sweep to the host. */
88
+ pingIntervalMs?: number | false;
89
+ }
90
+
91
+ export interface NotificationChannel<S extends ChannelSocket> {
92
+ handlers: {
93
+ open(socket: S): void;
94
+ message(socket: S, message: string | Buffer): void;
95
+ pong(socket: S): void;
96
+ close(socket: S): void;
97
+ };
98
+ broadcast(topic: string, message: unknown): void;
99
+ relayToWaiters(topic: string, message: unknown, options?: RelayOptions): boolean;
100
+ hasWaiter(topic: string): boolean;
101
+ broadcastPresence(topic: string): void;
102
+ /** The topic-bound view handed to frame handlers. */
103
+ topic(topic: string): TopicChannel;
104
+ /** One liveness sweep — ping live waiters, evict the ones that never ponged. */
105
+ pingWaiters(): void;
106
+ /** Stop the liveness timer. */
107
+ stop(): void;
108
+ }
109
+
110
+ const DEFAULT_PING_INTERVAL_MS = 5_000;
111
+
112
+ function parseFrame(message: string | Buffer): unknown {
113
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message);
114
+ try {
115
+ return JSON.parse(text);
116
+ } catch {
117
+ return undefined;
118
+ }
119
+ }
120
+
121
+ export function createNotificationChannel<S extends ChannelSocket>(
122
+ options: NotificationChannelOptions<S>,
123
+ ): NotificationChannel<S> {
124
+ const { roleOf, topicOf } = options;
125
+ const frames = options.frames ?? [];
126
+ const closeOnDelivery = (options.waiterPolicy ?? "keep-open") === "close-on-delivery";
127
+ const browserSockets = new Map<string, Set<S>>();
128
+ const waitSockets = new Map<string, Set<S>>();
129
+ const pongReceived = new WeakSet<S>();
130
+
131
+ function registry(role: ChannelRole): Map<string, Set<S>> {
132
+ return role === "browser" ? browserSockets : waitSockets;
133
+ }
134
+
135
+ function hasWaiter(topic: string): boolean {
136
+ return (waitSockets.get(topic)?.size ?? 0) > 0;
137
+ }
138
+
139
+ function broadcast(topic: string, message: unknown): void {
140
+ const sockets = browserSockets.get(topic);
141
+ if (!sockets || sockets.size === 0) return;
142
+ const payload = JSON.stringify(message);
143
+ for (const socket of sockets) socket.send(payload);
144
+ }
145
+
146
+ function presencePayload(topic: string): string | undefined {
147
+ if (!options.presenceFrame) return undefined;
148
+ return JSON.stringify(options.presenceFrame(hasWaiter(topic), topic));
149
+ }
150
+
151
+ function broadcastPresence(topic: string): void {
152
+ const payload = presencePayload(topic);
153
+ if (payload === undefined) return;
154
+ const sockets = browserSockets.get(topic);
155
+ if (!sockets) return;
156
+ for (const socket of sockets) socket.send(payload);
157
+ }
158
+
159
+ function relayToWaiters(topic: string, message: unknown, relay: RelayOptions = {}): boolean {
160
+ const sockets = waitSockets.get(topic);
161
+ if (!sockets || sockets.size === 0) return false;
162
+ const close = relay.close ?? closeOnDelivery;
163
+ if (close) waitSockets.delete(topic);
164
+ const payload = JSON.stringify(message);
165
+ for (const socket of sockets) {
166
+ socket.send(payload);
167
+ if (close) socket.close();
168
+ }
169
+ return true;
170
+ }
171
+
172
+ function topicChannel(topic: string): TopicChannel {
173
+ return {
174
+ topic,
175
+ broadcast: (message) => broadcast(topic, message),
176
+ relayToWaiters: (message, relay) => relayToWaiters(topic, message, relay),
177
+ hasWaiter: () => hasWaiter(topic),
178
+ broadcastPresence: () => broadcastPresence(topic),
179
+ };
180
+ }
181
+
182
+ function reportError(error: unknown): void {
183
+ options.onError?.(error);
184
+ }
185
+
186
+ function dropWaiter(topic: string, socket: S): void {
187
+ const sockets = waitSockets.get(topic);
188
+ if (sockets) {
189
+ sockets.delete(socket);
190
+ if (sockets.size === 0) waitSockets.delete(topic);
191
+ }
192
+ try { socket.close(); } catch {}
193
+ broadcastPresence(topic);
194
+ }
195
+
196
+ // A dead socket never fires `close`, so presence would stay stuck on `true`
197
+ // until the daemon restarts — sweep for the missing pong instead.
198
+ function pingWaiters(): void {
199
+ for (const [topic, sockets] of waitSockets) {
200
+ for (const socket of sockets) {
201
+ if (!pongReceived.has(socket)) {
202
+ dropWaiter(topic, socket);
203
+ continue;
204
+ }
205
+ pongReceived.delete(socket);
206
+ try { socket.ping(); } catch { dropWaiter(topic, socket); }
207
+ }
208
+ }
209
+ }
210
+
211
+ const interval = options.pingIntervalMs === false
212
+ ? undefined
213
+ : setInterval(pingWaiters, options.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS);
214
+ interval?.unref();
215
+
216
+ const handlers = {
217
+ open(socket: S): void {
218
+ const topic = topicOf(socket);
219
+ const role = roleOf(socket);
220
+ const sockets = registry(role).get(topic) ?? new Set<S>();
221
+ sockets.add(socket);
222
+ registry(role).set(topic, sockets);
223
+
224
+ if (role === "browser") {
225
+ const payload = presencePayload(topic);
226
+ if (payload !== undefined) socket.send(payload);
227
+ return;
228
+ }
229
+ pongReceived.add(socket); // free pass on the first sweep
230
+ options.onWaiterOpen?.(topicChannel(topic));
231
+ broadcastPresence(topic);
232
+ },
233
+
234
+ message(socket: S, message: string | Buffer): void {
235
+ if (roleOf(socket) !== "browser") return;
236
+ const frame = parseFrame(message);
237
+ if (frame === undefined) return;
238
+ const channel = topicChannel(topicOf(socket));
239
+ for (const handler of frames) {
240
+ const run = handler.bind(frame, channel);
241
+ if (!run) continue;
242
+ try {
243
+ const result = run();
244
+ if (result instanceof Promise) result.catch(reportError);
245
+ } catch (error) {
246
+ reportError(error);
247
+ }
248
+ return;
249
+ }
250
+ },
251
+
252
+ pong(socket: S): void {
253
+ if (roleOf(socket) === "wait") pongReceived.add(socket);
254
+ },
255
+
256
+ close(socket: S): void {
257
+ const topic = topicOf(socket);
258
+ const role = roleOf(socket);
259
+ const sockets = registry(role).get(topic);
260
+ sockets?.delete(socket);
261
+ if (sockets && sockets.size === 0) registry(role).delete(topic);
262
+ if (role === "wait") broadcastPresence(topic);
263
+ },
264
+ };
265
+
266
+ return {
267
+ handlers,
268
+ broadcast,
269
+ relayToWaiters,
270
+ hasWaiter,
271
+ broadcastPresence,
272
+ topic: topicChannel,
273
+ pingWaiters,
274
+ stop() {
275
+ if (interval) clearInterval(interval);
276
+ },
277
+ };
278
+ }