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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
sse-client.ts7.5 KB · 159 lines
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 {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[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 {'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[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){}})();"
  );
}