Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

sse-client.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

sse-client.tsBlame159 lines · 1 contributor
febd4f0Claude1/**
2 * SSE client helper — builds a plain-JS initialization snippet that can be
3 * dropped into an SSR'd view via a <script> tag. Intentionally returns a
4 * string (not a function export) so it works without any bundler.
5 *
6 * The returned snippet:
7 * - opens an EventSource on /live-events/<topic>
8 * - for each message event, parses JSON and calls the user-supplied
9 * formatFn (expected to return an HTML string)
10 * - appends the HTML to the target element
11 * - reconnects with a 1-second backoff on error
12 * - no-ops gracefully if EventSource is not supported
13 */
14
04f6b7fClaude15// U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are valid
16// whitespace in HTML but not in JS string literals. Referenced via
17// String.fromCharCode so we never embed the raw codepoints in source.
18const LS = String.fromCharCode(0x2028);
19const PS = String.fromCharCode(0x2029);
20
febd4f0Claude21/**
22 * JSON-encode a value for safe inlining inside an HTML <script> block.
23 *
24 * Plain JSON.stringify is not sufficient: a string containing "</script>"
25 * would break out of the surrounding <script> tag. We additionally escape
26 * `<`, `>`, `&`, and U+2028/U+2029 so the result is safe to splice verbatim
27 * into server-rendered HTML.
28 */
29function safeJsonForScript(v: unknown): string {
30 return JSON.stringify(v)
04f6b7fClaude31 .split("<").join("\\u003C")
32 .split(">").join("\\u003E")
33 .split("&").join("\\u0026")
34 .split(LS).join("\\u2028")
35 .split(PS).join("\\u2029");
febd4f0Claude36}
37
38export function liveSubscribeScript(args: {
39 /** Topic name, e.g. "repo:abc" or "user:42" */
40 topic: string;
41 /** id of the DOM element that receives appended event HTML */
42 targetElementId: string;
43 /** Optional JS function body taking (event) and returning an HTML string.
44 * If omitted, a default escapes the JSON payload as text. */
45 formatFn?: string;
46}): string {
47 const topic = safeJsonForScript(args.topic);
48 const targetId = safeJsonForScript(args.targetElementId);
49 const formatFnBody =
50 args.formatFn ??
51 "return '<li>' + String(event && event.data || '').replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];}) + '</li>';";
52
53 // Keep compact to stay under 2KB.
54 return (
55 "(function(){try{" +
56 "if(typeof EventSource==='undefined')return;" +
57 "var t=" + topic + ",id=" + targetId + ";" +
58 "var el=document.getElementById(id);if(!el)return;" +
59 "function fmt(event){" + formatFnBody + "}" +
60 "var es,delay=1000;" +
61 "function connect(){" +
62 "try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){setTimeout(connect,delay);return;}" +
63 "es.onmessage=function(m){try{var d=JSON.parse(m.data);var h=fmt(d);if(h&&el)el.insertAdjacentHTML('beforeend',h);}catch(e){}};" +
64 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
65 "}connect();}catch(e){}})();"
66 );
67}
abfa9adClaude68
b584e52Claude69/**
70 * Live comment-banner script: subscribe to a topic and increment a
71 * counter inside a banner element when new events arrive. Use on
72 * issue / PR detail pages to nudge the user to reload when remote
73 * comments land while their tab is open.
74 *
75 * Banner contract: the page renders a hidden element with the given
76 * id and an inner `<a class="js-live-count">` and `<a class="js-live-link">`.
77 * The script reveals the banner on the first event, updates the count
78 * text, and points the link at the current URL so a click reloads the
79 * page (cleanest way to pick up the new comment HTML server-side).
80 */
81export function liveCommentBannerScript(args: {
82 topic: string;
83 bannerElementId: string;
84}): string {
85 const topic = safeJsonForScript(args.topic);
86 const id = safeJsonForScript(args.bannerElementId);
87 return (
88 "(function(){try{" +
89 "if(typeof EventSource==='undefined')return;" +
90 "var t=" + topic + ",bid=" + id + ";" +
91 "var b=document.getElementById(bid);if(!b)return;" +
92 "var n=0;var es,delay=1000;" +
93 "function show(){if(b.style)b.style.display='';" +
94 "var c=b.querySelector('.js-live-count');" +
95 "if(c)c.textContent=String(n);" +
96 "var lk=b.querySelector('.js-live-link');" +
97 "if(lk)lk.setAttribute('href',window.location.pathname+window.location.search);}" +
98 "function connect(){try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){setTimeout(connect,delay);return;}" +
99 "es.onmessage=function(){n++;show();};" +
100 "es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
101 "}connect();}catch(e){}})();"
102 );
103}
104
abfa9adClaude105/**
106 * Live log-tail script: subscribe to a workflow-run topic, append step-log
107 * chunks to a <pre>, and auto-close when 'run-done' arrives.
108 *
109 * Unlike liveSubscribeScript, this helper distinguishes SSE event types
110 * (step-log / step-start / step-done / run-done) and writes plain text
111 * (escaped to prevent HTML injection) into a <pre>. All interpolated
112 * option strings are JSON-encoded via safeJsonForScript so that the
113 * resulting script fragment is safe to splice into server-rendered HTML.
114 */
115export function liveLogTailScript(opts: {
116 topic: string;
117 targetElementId: string;
118 jobId?: string;
119 onRunDone?: string;
120}): string {
121 const topic = safeJsonForScript(opts.topic);
122 const targetId = safeJsonForScript(opts.targetElementId);
123 const jobFilter = safeJsonForScript(opts.jobId ?? "");
124 // onRunDone is raw JS supplied by the server. Wrap in try/catch.
125 const onRunDone = opts.onRunDone ? String(opts.onRunDone) : "";
126 const onRunDoneJson = safeJsonForScript(onRunDone);
127
128 return (
129 "(function(){try{" +
130 "if(typeof EventSource==='undefined')return;" +
131 "var t=" + topic + ",id=" + targetId + ",jf=" + jobFilter + ",onDone=" + onRunDoneJson + ";" +
132 "var el=document.getElementById(id);if(!el)return;" +
133 "var status=document.getElementById(id+'-status');" +
134 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
135 "function setStatus(s){if(status)status.textContent=s;}" +
136 "function scroll(){try{el.scrollTop=el.scrollHeight;}catch(e){}}" +
137 "function append(txt){el.insertAdjacentHTML('beforeend',esc(txt));scroll();}" +
138 "function match(d){if(!jf)return true;return d&&d.jobId===jf;}" +
139 "var es;" +
140 "try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){return;}" +
141 "es.addEventListener('open',function(){setStatus('live');});" +
142 "es.addEventListener('step-log',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
143 "var prefix='[step '+d.stepIndex+' '+(d.stream||'stdout')+'] ';" +
144 "var chunk=String(d.chunk==null?'':d.chunk);" +
145 "var lines=chunk.split('\\n');" +
146 "for(var i=0;i<lines.length;i++){if(i===lines.length-1&&lines[i]==='')continue;append(prefix+lines[i]+'\\n');}" +
147 "}catch(e){}});" +
148 "es.addEventListener('step-start',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
149 "append('>>> step '+d.stepIndex+' ('+(d.name||'')+') started\\n');" +
150 "}catch(e){}});" +
151 "es.addEventListener('step-done',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
152 "var dur=typeof d.durationMs==='number'?(d.durationMs<1000?d.durationMs+'ms':(d.durationMs/1000).toFixed(1)+'s'):'';" +
153 "append('<<< step '+d.stepIndex+' done (exit '+d.exitCode+(dur?', '+dur:'')+')\\n');" +
154 "}catch(e){}});" +
155 "es.addEventListener('run-done',function(m){try{setStatus('done');try{es.close();}catch(e){}if(onDone){try{(new Function(onDone))();}catch(e){}}}catch(e){}});" +
156 "es.onerror=function(){setStatus('disconnected');};" +
157 "}catch(e){}})();"
158 );
159}