Repository navigation
Expand file tree
/
Copy pathserve.js
More file actions
365 lines (351 loc) · 15.8 KB
/
Copy pathserve.js
File metadata and controls
365 lines (351 loc) · 15.8 KB
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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
// Tiny zero-dependency preview server for the Wall of Names.
// node serve.js → http://localhost:8080
//
// Serves the static files and:
// /api/names — reads every names/*.json at request time (no build step,
// no shared manifest; each attendee only edits their own file)
// /api/active — if a Coder token is in the env (CODER_SESSION_TOKEN +
// CODER_URL, present on the admin Wall-of-Fame display), returns
// the count of distinct people with a workshop workspace going
// right now (running build + agent connected in the last 5 min).
// Returns {available:false} when no token (attendee preview).
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const cp = require("child_process");
const ROOT = __dirname;
const PORT = process.env.PORT || 8080;
// Coder API access for the live-activity indicator (admin display only).
const CODER_URL = (process.env.CODER_URL || "http://localhost:3000").replace(/\/$/, "");
const CODER_TOKEN = process.env.CODER_SESSION_TOKEN || process.env.CODER_TOKEN || "";
const ACTIVE_WINDOW_MS = 5 * 60 * 1000; // "active now" = activity in last 5 min
// Owners to exclude from the "active now" count (workshop hosts/admins and the
// prebuild pool, not attendees). Comma-separated usernames in ACTIVE_EXCLUDE;
// defaults to admin + the "prebuilds" system owner.
const ACTIVE_EXCLUDE = new Set(
(process.env.ACTIVE_EXCLUDE || "admin,prebuilds").split(",").map((s) => s.trim()).filter(Boolean)
);
const TYPES = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".woff2": "font/woff2",
".woff": "font/woff",
".ttf": "font/ttf",
".otf": "font/otf",
};
function readNames() {
const dir = path.join(ROOT, "names");
const out = [];
for (const f of fs.readdirSync(dir)) {
if (!f.endsWith(".json")) continue;
try {
const full = path.join(dir, f);
const entry = JSON.parse(fs.readFileSync(full, "utf8"));
// Stable ordering by "time added" so the wall renders chronologically and
// cards don't "pop around" between polls. Prefer the git first-commit time
// (survives clones/pulls, which reset file mtimes); fall back to the
// file's birthtime/mtime when git isn't available. _added is stripped
// before serving.
entry._added = addedTime(f, full);
out.push(entry);
} catch {
/* skip malformed entries so one bad file can't break the wall */
}
}
// Oldest first (earliest added at the front); ties broken by handle so the
// order is fully deterministic even when timestamps match.
out.sort((a, b) =>
(a._added - b._added) ||
String(a.handle || a.name || "").localeCompare(String(b.handle || b.name || "")));
for (const e of out) delete e._added;
return out;
}
// Cache of name file -> "time added" (ms). Git first-commit time is immutable
// per file, so once resolved we never recompute it; this keeps /api/names cheap
// even though it's polled every few seconds.
const _addedCache = new Map();
// The workshop template clones name-wall shallow (--depth 1) for speed, which
// leaves no real history, so the git first-commit time degenerates to the single
// clone commit (identical for every file). Unshallow ONCE, best-effort, so the
// chronological ordering has true per-file add times. No-op on a full clone or
// when offline (we then fall back to mtime).
let _unshallowed = false;
function ensureFullHistory() {
if (_unshallowed) return;
_unshallowed = true; // only ever try once
try {
const shallow = cp.execFileSync("git", ["-C", ROOT, "rev-parse", "--is-shallow-repository"],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }).trim();
if (shallow === "true") {
cp.execFileSync("git", ["-C", ROOT, "fetch", "--unshallow", "--quiet"],
{ stdio: "ignore", timeout: 30000 });
}
} catch { /* offline / no remote / already full — fall back to mtime */ }
}
function addedTime(fname, full) {
if (_addedCache.has(fname)) return _addedCache.get(fname);
ensureFullHistory();
let t = 0;
// Git: the FIRST commit that added this path (author time, seconds → ms).
try {
const o = cp.execFileSync(
"git",
["-C", ROOT, "log", "--diff-filter=A", "--follow", "--format=%at", "-1", "--", path.join("names", fname)],
{ encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 4000 }
).trim();
if (o) t = parseInt(o, 10) * 1000;
} catch { /* git missing or not a repo — fall through to mtime */ }
if (!t) {
try { const st = fs.statSync(full); t = (st.birthtimeMs || st.mtimeMs || 0); } catch { /* ignore */ }
}
// Only cache a real git time (immutable). An mtime fallback may still change
// on a future pull, so don't pin it — but it's deterministic within a build.
if (t) _addedCache.set(fname, t);
return t;
}
// Count people actively doing the workshop right now. We use WORKSPACES, not
// users: Coder only refreshes user.last_seen_at about once an hour (throttled in
// the apikey middleware: it writes last_seen_at only when key.LastUsed is >1h
// old), so a 5-minute "active now" can't be derived from it. A workspace's agent
// last_connected_at is a real heartbeat (updates every few seconds), and
// last_used_at tracks interaction — either being recent means the person is here.
function readActive(cb) {
if (!CODER_TOKEN) return cb({ available: false });
const url = new URL(CODER_URL + "/api/v2/workspaces?limit=1000");
const lib = url.protocol === "https:" ? https : http;
const req = lib.request(
url,
{ headers: { "Coder-Session-Token": CODER_TOKEN }, timeout: 4000 },
(r) => {
let body = "";
r.on("data", (c) => (body += c));
r.on("end", () => {
try {
const list = JSON.parse(body).workspaces || [];
const cutoff = Date.now() - ACTIVE_WINDOW_MS;
const recent = (t) => t && Date.parse(t) >= cutoff;
const owners = new Set();
for (const w of list) {
const owner = w.owner_name;
if (!owner || ACTIVE_EXCLUDE.has(owner)) continue;
if ((w.latest_build && w.latest_build.status) !== "running") continue;
// Only use agent heartbeat (last_connected_at) — it's a real
// every-few-seconds ping from the running agent. last_used_at
// reflects workspace/build activity and can stay stale for hours
// after an attendee has left, causing false "active" counts.
let live = false;
const resources = (w.latest_build && w.latest_build.resources) || [];
for (const res of resources) {
for (const a of res.agents || []) {
if (recent(a.last_connected_at)) live = true;
}
}
if (live) owners.add(owner);
}
cb({ available: true, count: owners.size, users: [...owners] });
} catch {
cb({ available: false });
}
});
}
);
req.on("error", () => cb({ available: false }));
req.on("timeout", () => { req.destroy(); cb({ available: false }); });
req.end();
}
// Small helper: GET a Coder API path with the admin token, parse JSON.
function coderGet(p, cb) {
if (!CODER_TOKEN) return cb(null);
// Same once-guard as ghGet: never call cb more than once (success + late
// error/timeout would otherwise double-respond and crash the server).
let done = false;
const once = (v) => { if (done) return; done = true; cb(v); };
const url = new URL(CODER_URL + p);
const lib = url.protocol === "https:" ? https : http;
const req = lib.request(url, { headers: { "Coder-Session-Token": CODER_TOKEN }, timeout: 4000 }, (r) => {
let body = "";
r.on("data", (c) => (body += c));
r.on("end", () => { try { once(JSON.parse(body)); } catch { once(null); } });
r.on("error", () => once(null));
});
req.on("error", () => once(null));
req.on("timeout", () => { req.destroy(); once(null); });
req.end();
}
// Admin roster: every org member, whether they hold the agents-access role (so
// the Agents UI works) and whether they're online now. Lets the host see who
// just logged in and who still needs the role (the role bot grants it, but this
// surfaces lag/failures). Needs the admin Coder token; else {available:false}.
const AGENTS_ROLE = process.env.AGENTS_ROLE || "agents-access";
function readMembers(cb) {
if (!CODER_TOKEN) return cb({ available: false });
coderGet("/api/v2/users/me", (me) => {
const org = me && me.organization_ids && me.organization_ids[0];
if (!org) return cb({ available: false });
let members = null, online = null;
const done = () => {
if (members === null || online === null) return;
const list = (members || []).map((m) => {
const roles = (m.roles || []).map((r) => r.name);
return {
username: m.username,
has_agents: roles.includes(AGENTS_ROLE) || roles.includes("organization-admin"),
is_admin: roles.includes("organization-admin"),
online: online.has(m.username),
};
}).filter((m) => !ACTIVE_EXCLUDE.has(m.username));
cb({ available: true, count: list.length, members: list });
};
coderGet("/api/v2/organizations/" + org + "/members", (ms) => { members = ms || []; done(); });
readActive((a) => { online = new Set(a && a.available ? a.users : []); done(); });
});
}
// Query GitHub for open PRs to the wall repo (the live "PR queue"). Needs a
// GitHub token in the env (GITHUB_TOKEN), present on the admin display.
const GH_TOKEN = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
const WALL_REPO = process.env.WALL_REPO || "coder-contrib/name-wall";
// GitHub GET helper (REST, token auth) returning parsed JSON.
function ghGet(path, cb) {
if (!GH_TOKEN) return cb(null);
// Guard: the response handler AND the error/timeout handlers can each fire,
// so ensure cb runs exactly once. Otherwise a request that errors after
// (or instead of) ending calls cb twice — the route handler then writes the
// response twice and the process dies with ERR_HTTP_HEADERS_SENT.
let done = false;
const once = (v) => { if (done) return; done = true; cb(v); };
const u = new URL("https://api.github.com" + path);
const req = https.request(u, {
headers: {
"Authorization": `Bearer ${GH_TOKEN}`,
"User-Agent": "name-wall",
"Accept": "application/vnd.github+json",
},
timeout: 4000,
}, (r) => {
let body = "";
r.on("data", (c) => (body += c));
r.on("end", () => { try { once(JSON.parse(body)); } catch { once(null); } });
r.on("error", () => once(null));
});
req.on("error", () => once(null));
req.on("timeout", () => { req.destroy(); once(null); });
req.end();
}
// Derive a human status for a PR from its review history + mergeability so the
// queue shows where each entry is: reviewing / changes-requested / re-review /
// conflicts / queued.
function prStatus(p, reviews) {
if (p.mergeable_state === "dirty" || p.mergeable === false) return "conflicts";
const rs = Array.isArray(reviews) ? reviews : [];
// Last decisive review (APPROVED or CHANGES_REQUESTED), ignoring COMMENTED.
let last = null;
for (const rv of rs) {
if (rv.state === "APPROVED" || rv.state === "CHANGES_REQUESTED") last = rv;
}
if (last && last.state === "CHANGES_REQUESTED") {
// If the author pushed after the review, it needs another look.
const pushed = Date.parse(p.updated_at || 0);
const reviewed = Date.parse(last.submitted_at || 0);
return pushed > reviewed + 1000 ? "re-review" : "changes-requested";
}
if (last && last.state === "APPROVED") return "approved";
return "reviewing";
}
function readPending(cb) {
if (!GH_TOKEN) return cb({ available: false });
ghGet(`/repos/${WALL_REPO}/pulls?state=open&sort=created&direction=asc&per_page=50`, (prs) => {
if (!Array.isArray(prs)) return cb({ available: false });
let merged = new Set();
try {
const mf = process.env.MERGED_FILE || "/tmp/name-wall-merged";
merged = new Set(fs.readFileSync(mf, "utf8").split(/\s+/).filter(Boolean).map(Number));
} catch { /* no merged file yet */ }
const open = prs.filter((p) => !merged.has(p.number));
if (!open.length) return cb({ available: true, count: 0, prs: [] });
// For each open PR, fetch its reviews AND its detail (the list endpoint
// omits mergeable_state) in parallel, then derive a human status.
let pending = open.length;
const out = [];
open.forEach((listPr, i) => {
let reviews = null, detail = null, got = 0;
const done = () => {
if (++got < 2) return;
const p = detail && detail.number ? detail : listPr;
out[i] = {
number: listPr.number,
title: listPr.title,
user: listPr.user && listPr.user.login,
url: listPr.html_url,
status: prStatus(p, reviews),
};
if (--pending === 0) cb({ available: true, count: out.length, prs: out });
};
ghGet(`/repos/${WALL_REPO}/pulls/${listPr.number}/reviews`, (r) => { reviews = r; done(); });
ghGet(`/repos/${WALL_REPO}/pulls/${listPr.number}`, (d) => { detail = d; done(); });
});
});
}
http
.createServer((req, res) => {
// Respond with JSON exactly once — ignores any second call (e.g. a stray
// double callback) instead of throwing ERR_HTTP_HEADERS_SENT and crashing.
const sendJSON = (data) => {
if (res.headersSent || res.writableEnded) return;
res.writeHead(200, { "Content-Type": TYPES[".json"] });
res.end(JSON.stringify(data));
};
if (req.url === "/api/names") {
sendJSON(readNames());
return;
}
if (req.url === "/api/active") {
readActive(sendJSON);
return;
}
if (req.url === "/api/pending") {
readPending(sendJSON);
return;
}
if (req.url === "/api/members") {
readMembers(sendJSON);
return;
}
let file = req.url === "/" ? "/index.html" : req.url.split("?")[0];
const full = path.join(ROOT, path.normalize(file));
// Reject traversal, missing files, AND directories (reading a dir as a
// stream throws EISDIR and would otherwise crash the whole server).
let stat;
try { stat = fs.statSync(full); } catch { stat = null; }
if (!full.startsWith(ROOT) || !stat || !stat.isFile()) {
res.writeHead(404);
res.end("Not found");
return;
}
res.writeHead(200, {
"Content-Type": TYPES[path.extname(full)] || "application/octet-stream",
// Cache static assets so the browser doesn't re-download fonts/CSS/JS on
// every poll and (crucially) for every sandboxed card iframe. Fonts +
// images are content-stable here, so cache them hard; HTML/JS shorter.
"Cache-Control": /\.(woff2?|ttf|otf|png|jpe?g|gif|svg|ico)$/i.test(full)
? "public, max-age=604800, immutable" // 7d for fonts/images
: "public, max-age=300", // 5m for html/css/js
});
const stream = fs.createReadStream(full);
// Never let a file-read error take down the process.
stream.on("error", () => { try { res.destroy(); } catch { /* ignore */ } });
stream.pipe(res);
})
.listen(PORT, "0.0.0.0", () => console.log(`Wall of Names preview on http://0.0.0.0:${PORT}`));
// Last-resort safety net: this is a long-running display server that must never
// hard-exit on a stray async error (a flaky GitHub/Coder fetch, a socket reset,
// etc.). Log and keep serving instead of letting an uncaught error kill it.
process.on("uncaughtException", (e) => console.error("[serve] uncaughtException (continuing):", e && e.message));
process.on("unhandledRejection", (e) => console.error("[serve] unhandledRejection (continuing):", e && (e.message || e)));