CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 | /**
* SSE client helper — builds a plain-JS initialization snippet that can be
* dropped into an SSR'd view via a <script> tag. Intentionally returns a
* string (not a function export) so it works without any bundler.
*
* The returned snippet:
* - opens an EventSource on /live-events/<topic>
* - for each message event, parses JSON and calls the user-supplied
* formatFn (expected to return an HTML string)
* - appends the HTML to the target element
* - reconnects with a 1-second backoff on error
* - no-ops gracefully if EventSource is not supported
*/
// U+2028 (LINE SEPARATOR) and U+2029 (PARAGRAPH SEPARATOR) are valid
// whitespace in HTML but not in JS string literals. Referenced via
// String.fromCharCode so we never embed the raw codepoints in source.
const LS = String.fromCharCode(0x2028);
const PS = String.fromCharCode(0x2029);
/**
* JSON-encode a value for safe inlining inside an HTML <script> block.
*
* Plain JSON.stringify is not sufficient: a string containing "</script>"
* would break out of the surrounding <script> tag. We additionally escape
* `<`, `>`, `&`, and U+2028/U+2029 so the result is safe to splice verbatim
* into server-rendered HTML.
*/
function safeJsonForScript(v: unknown): string {
return JSON.stringify(v)
.split("<").join("\\u003C")
.split(">").join("\\u003E")
.split("&").join("\\u0026")
.split(LS).join("\\u2028")
.split(PS).join("\\u2029");
}
export function liveSubscribeScript(args: {
/** Topic name, e.g. "repo:abc" or "user:42" */
topic: string;
/** id of the DOM element that receives appended event HTML */
targetElementId: string;
/** Optional JS function body taking (event) and returning an HTML string.
* If omitted, a default escapes the JSON payload as text. */
formatFn?: string;
}): string {
const topic = safeJsonForScript(args.topic);
const targetId = safeJsonForScript(args.targetElementId);
const formatFnBody =
args.formatFn ??
"return '<li>' + String(event && event.data || '').replace(/[&<>\"']/g,function(c){return {'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c];}) + '</li>';";
// Keep compact to stay under 2KB.
return (
"(function(){try{" +
"if(typeof EventSource==='undefined')return;" +
"var t=" + topic + ",id=" + targetId + ";" +
"var el=document.getElementById(id);if(!el)return;" +
"function fmt(event){" + formatFnBody + "}" +
"var es,delay=1000;" +
"function connect(){" +
"try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){setTimeout(connect,delay);return;}" +
"es.onmessage=function(m){try{var d=JSON.parse(m.data);var h=fmt(d);if(h&&el)el.insertAdjacentHTML('beforeend',h);}catch(e){}};" +
"es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
"}connect();}catch(e){}})();"
);
}
/**
* Live comment-banner script: subscribe to a topic and increment a
* counter inside a banner element when new events arrive. Use on
* issue / PR detail pages to nudge the user to reload when remote
* comments land while their tab is open.
*
* Banner contract: the page renders a hidden element with the given
* id and an inner `<a class="js-live-count">` and `<a class="js-live-link">`.
* The script reveals the banner on the first event, updates the count
* text, and points the link at the current URL so a click reloads the
* page (cleanest way to pick up the new comment HTML server-side).
*/
export function liveCommentBannerScript(args: {
topic: string;
bannerElementId: string;
}): string {
const topic = safeJsonForScript(args.topic);
const id = safeJsonForScript(args.bannerElementId);
return (
"(function(){try{" +
"if(typeof EventSource==='undefined')return;" +
"var t=" + topic + ",bid=" + id + ";" +
"var b=document.getElementById(bid);if(!b)return;" +
"var n=0;var es,delay=1000;" +
"function show(){if(b.style)b.style.display='';" +
"var c=b.querySelector('.js-live-count');" +
"if(c)c.textContent=String(n);" +
"var lk=b.querySelector('.js-live-link');" +
"if(lk)lk.setAttribute('href',window.location.pathname+window.location.search);}" +
"function connect(){try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){setTimeout(connect,delay);return;}" +
"es.onmessage=function(){n++;show();};" +
"es.onerror=function(){try{es.close();}catch(e){}setTimeout(connect,delay);};" +
"}connect();}catch(e){}})();"
);
}
/**
* Live log-tail script: subscribe to a workflow-run topic, append step-log
* chunks to a <pre>, and auto-close when 'run-done' arrives.
*
* Unlike liveSubscribeScript, this helper distinguishes SSE event types
* (step-log / step-start / step-done / run-done) and writes plain text
* (escaped to prevent HTML injection) into a <pre>. All interpolated
* option strings are JSON-encoded via safeJsonForScript so that the
* resulting script fragment is safe to splice into server-rendered HTML.
*/
export function liveLogTailScript(opts: {
topic: string;
targetElementId: string;
jobId?: string;
onRunDone?: string;
}): string {
const topic = safeJsonForScript(opts.topic);
const targetId = safeJsonForScript(opts.targetElementId);
const jobFilter = safeJsonForScript(opts.jobId ?? "");
// onRunDone is raw JS supplied by the server. Wrap in try/catch.
const onRunDone = opts.onRunDone ? String(opts.onRunDone) : "";
const onRunDoneJson = safeJsonForScript(onRunDone);
return (
"(function(){try{" +
"if(typeof EventSource==='undefined')return;" +
"var t=" + topic + ",id=" + targetId + ",jf=" + jobFilter + ",onDone=" + onRunDoneJson + ";" +
"var el=document.getElementById(id);if(!el)return;" +
"var status=document.getElementById(id+'-status');" +
"function esc(s){return String(s==null?'':s).replace(/[&<>\"']/g,function(c){return {'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c];});}" +
"function setStatus(s){if(status)status.textContent=s;}" +
"function scroll(){try{el.scrollTop=el.scrollHeight;}catch(e){}}" +
"function append(txt){el.insertAdjacentHTML('beforeend',esc(txt));scroll();}" +
"function match(d){if(!jf)return true;return d&&d.jobId===jf;}" +
"var es;" +
"try{es=new EventSource('/live-events/'+encodeURIComponent(t));}catch(e){return;}" +
"es.addEventListener('open',function(){setStatus('live');});" +
"es.addEventListener('step-log',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
"var prefix='[step '+d.stepIndex+' '+(d.stream||'stdout')+'] ';" +
"var chunk=String(d.chunk==null?'':d.chunk);" +
"var lines=chunk.split('\\n');" +
"for(var i=0;i<lines.length;i++){if(i===lines.length-1&&lines[i]==='')continue;append(prefix+lines[i]+'\\n');}" +
"}catch(e){}});" +
"es.addEventListener('step-start',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
"append('>>> step '+d.stepIndex+' ('+(d.name||'')+') started\\n');" +
"}catch(e){}});" +
"es.addEventListener('step-done',function(m){try{var d=JSON.parse(m.data);if(!match(d))return;" +
"var dur=typeof d.durationMs==='number'?(d.durationMs<1000?d.durationMs+'ms':(d.durationMs/1000).toFixed(1)+'s'):'';" +
"append('<<< step '+d.stepIndex+' done (exit '+d.exitCode+(dur?', '+dur:'')+')\\n');" +
"}catch(e){}});" +
"es.addEventListener('run-done',function(m){try{setStatus('done');try{es.close();}catch(e){}if(onDone){try{(new Function(onDone))();}catch(e){}}}catch(e){}});" +
"es.onerror=function(){setStatus('disconnected');};" +
"}catch(e){}})();"
);
}
|