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.tsBlame123 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
69/**
70 * Live log-tail script: subscribe to a workflow-run topic, append step-log
71 * chunks to a <pre>, and auto-close when 'run-done' arrives.
72 *
73 * Unlike liveSubscribeScript, this helper distinguishes SSE event types
74 * (step-log / step-start / step-done / run-done) and writes plain text
75 * (escaped to prevent HTML injection) into a <pre>. All interpolated
76 * option strings are JSON-encoded via safeJsonForScript so that the
77 * resulting script fragment is safe to splice into server-rendered HTML.
78 */
79export function liveLogTailScript(opts: {
80 topic: string;
81 targetElementId: string;
82 jobId?: string;
83 onRunDone?: string;
84}): string {
85 const topic = safeJsonForScript(opts.topic);
86 const targetId = safeJsonForScript(opts.targetElementId);
87 const jobFilter = safeJsonForScript(opts.jobId ?? "");
88 // onRunDone is raw JS supplied by the server. Wrap in try/catch.
89 const onRunDone = opts.onRunDone ? String(opts.onRunDone) : "";
90 const onRunDoneJson = safeJsonForScript(onRunDone);
91
92 return (
93 "(function(){try{" +
94 "if(typeof EventSource==='undefined')return;" +
95 "var t=" + topic + ",id=" + targetId + ",jf=" + jobFilter + ",onDone=" + onRunDoneJson + ";" +
96 "var el=document.getElementById(id);if(!el)return;" +
97 "var status=document.getElementById(id+'-status');" +
98 "function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[c];});}" +
99 "function setStatus(s){if(status)status.textContent=s;}" +
100 "function scroll(){try{el.scrollTop=el.scrollHeight;}catch(e){}}" +
101 "function append(txt){el.insertAdjacentHTML('beforeend',esc(txt));scroll();}" +
102 "function match(d){if(!jf)return true;return d&&d.jobId===jf;}" +
103 "var es;" +
104 "try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){return;}" +
105 "es.addEventListener('open',function(){setStatus('live');});" +
106 "es.addEventListener('step-log',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
107 "var prefix='[step '+d.stepIndex+' '+(d.stream||'stdout')+'] ';" +
108 "var chunk=String(d.chunk==null?'':d.chunk);" +
109 "var lines=chunk.split('\\n');" +
110 "for(var i=0;i<lines.length;i++){if(i===lines.length-1&&lines[i]==='')continue;append(prefix+lines[i]+'\\n');}" +
111 "}catch(e){}});" +
112 "es.addEventListener('step-start',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
113 "append('>>> step '+d.stepIndex+' ('+(d.name||'')+') started\\n');" +
114 "}catch(e){}});" +
115 "es.addEventListener('step-done',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
116 "var dur=typeof d.durationMs==='number'?(d.durationMs<1000?d.durationMs+'ms':(d.durationMs/1000).toFixed(1)+'s'):'';" +
117 "append('<<< step '+d.stepIndex+' done (exit '+d.exitCode+(dur?', '+dur:'')+')\\n');" +
118 "}catch(e){}});" +
119 "es.addEventListener('run-done',function(m){try{setStatus('done');try{es.close();}catch(e){}if(onDone){try{(new Function(onDone))();}catch(e){}}}catch(e){}});" +
120 "es.onerror=function(){setStatus('disconnected');};" +
121 "}catch(e){}})();"
122 );
123}