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.
| febd4f0 | 1 | /** |
| 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 | ||
| 04f6b7f | 15 | // 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. | |
| 18 | const LS = String.fromCharCode(0x2028); | |
| 19 | const PS = String.fromCharCode(0x2029); | |
| 20 | ||
| febd4f0 | 21 | /** |
| 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 | */ | |
| 29 | function safeJsonForScript(v: unknown): string { | |
| 30 | return JSON.stringify(v) | |
| 04f6b7f | 31 | .split("<").join("\\u003C") |
| 32 | .split(">").join("\\u003E") | |
| 33 | .split("&").join("\\u0026") | |
| 34 | .split(LS).join("\\u2028") | |
| 35 | .split(PS).join("\\u2029"); | |
| febd4f0 | 36 | } |
| 37 | ||
| 38 | export 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 {'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[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 | } | |
| abfa9ad | 68 | |
| b584e52 | 69 | /** |
| 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 | */ | |
| 81 | export 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 | ||
| abfa9ad | 105 | /** |
| 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 | */ | |
| 115 | export 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 {'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[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 | } |