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

previews.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

previews.tsxBlame783 lines · 1 contributor
4bbacbeClaude1/**
1df50d5Claude2 * Per-branch / per-PR preview URLs (migrations 0062, 0077).
4bbacbeClaude3 *
1df50d5Claude4 * GET /:owner/:repo/previews — list view
5 * GET /:owner/:repo/pull/:prNumber/preview — redirect to live preview for this PR
6 * GET /previews/:owner/:repo/:prBranch/* — serve built static files
7 * POST /api/previews/rebuild/:prId — trigger a rebuild (webhook)
4bbacbeClaude8 *
9 * All page-local CSS is scoped under `.preview-*` so it can't bleed
10 * into the shared layout (per CLAUDE.md: do NOT modify shared
11 * layout/components/ui). Mirrors the gradient hairline + orb pattern
12 * used by environments.tsx / admin-integrations.tsx.
13 */
14
15import { Hono } from "hono";
1df50d5Claude16import { and, eq, desc } from "drizzle-orm";
4bbacbeClaude17import { db } from "../db";
1df50d5Claude18import { repositories, users, pullRequests, prPreviews } from "../db/schema";
19import { softAuth, requireAuth } from "../middleware/auth";
4bbacbeClaude20import type { AuthEnv } from "../middleware/auth";
21import { Layout } from "../views/layout";
22import { RepoHeader, RepoNav } from "../views/components";
23import { getUnreadCount } from "../lib/unread";
24import {
25 formatExpiresIn,
26 listPreviewsForRepo,
27 previewStatusLabel,
28} from "../lib/branch-previews";
1df50d5Claude29import { buildPreview } from "../lib/preview-builder";
30import { join } from "path";
31import { existsSync } from "fs";
32
33/** Minimal MIME type lookup for common web assets. */
34function getMimeType(filePath: string): string {
35 const ext = filePath.split(".").pop()?.toLowerCase() ?? "";
36 const map: Record<string, string> = {
37 html: "text/html; charset=utf-8",
38 htm: "text/html; charset=utf-8",
39 css: "text/css",
40 js: "application/javascript",
41 mjs: "application/javascript",
42 json: "application/json",
43 png: "image/png",
44 jpg: "image/jpeg",
45 jpeg: "image/jpeg",
46 gif: "image/gif",
47 svg: "image/svg+xml",
48 ico: "image/x-icon",
49 woff: "font/woff",
50 woff2: "font/woff2",
51 ttf: "font/ttf",
52 txt: "text/plain",
53 xml: "application/xml",
54 webp: "image/webp",
55 avif: "image/avif",
56 mp4: "video/mp4",
57 webm: "video/webm",
58 pdf: "application/pdf",
59 wasm: "application/wasm",
60 };
61 return map[ext] || "application/octet-stream";
62}
4bbacbeClaude63
64const r = new Hono<AuthEnv>();
65r.use("*", softAuth);
66
67async function loadRepo(owner: string, repo: string) {
68 try {
69 const [row] = await db
70 .select({
71 id: repositories.id,
72 name: repositories.name,
73 defaultBranch: repositories.defaultBranch,
74 ownerId: repositories.ownerId,
75 starCount: repositories.starCount,
76 forkCount: repositories.forkCount,
77 previewBuildsEnabled: repositories.previewBuildsEnabled,
78 })
79 .from(repositories)
80 .innerJoin(users, eq(repositories.ownerId, users.id))
81 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
82 .limit(1);
83 return row || null;
84 } catch (err) {
85 console.error("[previews] loadRepo failed:", err);
86 return null;
87 }
88}
89
90/* ─────────────────────────────────────────────────────────────────────────
91 * Scoped CSS — every class prefixed `.preview-*` so this page can't bleed
92 * into the layout. Same gradient hairline + orb language as environments.
93 * ───────────────────────────────────────────────────────────────────── */
94const previewStyles = `
eed4684Claude95 .preview-wrap { max-width: 1680px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
4bbacbeClaude96
97 .preview-head {
98 position: relative;
99 margin-bottom: var(--space-5);
100 padding: var(--space-4) var(--space-5);
101 background: var(--bg-elevated);
102 border: 1px solid var(--border);
103 border-radius: 14px;
104 overflow: hidden;
105 }
106 .preview-head::before {
107 content: '';
108 position: absolute;
109 top: 0; left: 0; right: 0;
110 height: 2px;
111 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
112 opacity: 0.7;
113 pointer-events: none;
114 }
115 .preview-head-orb {
116 position: absolute;
117 inset: -30% -10% auto auto;
118 width: 320px; height: 320px;
119 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
120 filter: blur(70px);
121 opacity: 0.7;
122 pointer-events: none;
123 z-index: 0;
124 }
125 .preview-head-inner {
126 position: relative;
127 z-index: 1;
128 display: flex;
129 align-items: flex-end;
130 justify-content: space-between;
131 gap: var(--space-4);
132 flex-wrap: wrap;
133 }
134 .preview-head-text { flex: 1; min-width: 240px; max-width: 720px; }
135 .preview-eyebrow {
136 display: inline-flex;
137 align-items: center;
138 gap: 8px;
139 font-family: var(--font-mono);
140 font-size: 11px;
141 letter-spacing: 0.14em;
142 text-transform: uppercase;
143 font-weight: 600;
144 color: var(--text-muted);
145 margin-bottom: 10px;
146 }
147 .preview-eyebrow-dot {
148 width: 8px; height: 8px;
149 border-radius: 9999px;
150 background: linear-gradient(135deg, #8c6dff, #36c5d6);
151 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
152 }
153 .preview-title {
154 margin: 0 0 6px;
155 font-family: var(--font-display);
156 font-size: clamp(22px, 2.6vw, 30px);
157 font-weight: 800;
158 letter-spacing: -0.022em;
159 line-height: 1.1;
160 color: var(--text-strong);
161 }
162 .preview-title-grad {
163 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
164 -webkit-background-clip: text;
165 background-clip: text;
166 -webkit-text-fill-color: transparent;
167 color: transparent;
168 }
169 .preview-sub {
170 margin: 0;
171 font-size: 13.5px;
172 line-height: 1.5;
173 color: var(--text-muted);
174 }
175
176 .preview-col-title {
177 margin: 0 0 var(--space-2);
178 font-family: var(--font-mono);
179 font-size: 11px;
180 text-transform: uppercase;
181 letter-spacing: 0.14em;
182 font-weight: 600;
183 color: var(--text-muted);
184 }
185
186 .preview-list {
187 display: flex;
188 flex-direction: column;
189 gap: var(--space-3);
190 margin-bottom: var(--space-5);
191 }
192
193 .preview-card {
194 position: relative;
195 padding: var(--space-4) var(--space-4);
196 background: var(--bg-elevated);
197 border: 1px solid var(--border);
198 border-radius: 14px;
199 overflow: hidden;
200 transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
201 }
202 .preview-card::before {
203 content: '';
204 position: absolute;
205 top: 0; left: 14px; right: 14px;
206 height: 1px;
207 background: linear-gradient(90deg, transparent 0%, rgba(140,109,255,0.45) 30%, rgba(54,197,214,0.45) 70%, transparent 100%);
208 opacity: 0;
209 transition: opacity 160ms ease;
210 }
211 .preview-card:hover {
212 transform: translateY(-1px);
213 border-color: rgba(140,109,255,0.32);
214 box-shadow: 0 8px 22px -10px rgba(0,0,0,0.40);
215 }
216 .preview-card:hover::before { opacity: 1; }
217
218 .preview-card-head {
219 display: flex;
220 align-items: center;
221 justify-content: space-between;
222 gap: var(--space-3);
223 flex-wrap: wrap;
224 margin-bottom: var(--space-2);
225 }
226 .preview-card-titles { flex: 1; min-width: 200px; }
227 .preview-card-branch {
228 margin: 0;
229 font-family: var(--font-mono);
230 font-size: 15px;
231 font-weight: 700;
232 color: var(--text-strong);
233 word-break: break-all;
234 }
235 .preview-card-url {
236 margin-top: 4px;
237 font-family: var(--font-mono);
238 font-size: 12.5px;
239 overflow: hidden;
240 text-overflow: ellipsis;
241 white-space: nowrap;
242 }
243 .preview-card-url a { color: var(--accent, #8c6dff); text-decoration: none; }
244 .preview-card-url a:hover { color: var(--accent, #36c5d6); text-decoration: underline; }
245 .preview-card-meta {
246 margin-top: 6px;
247 display: flex;
248 flex-wrap: wrap;
249 gap: 10px;
250 font-size: 11.5px;
251 color: var(--text-muted);
252 font-variant-numeric: tabular-nums;
253 }
254 .preview-card-meta code {
255 font-family: var(--font-mono);
256 font-size: 11.5px;
257 color: var(--text);
258 background: rgba(255,255,255,0.04);
259 padding: 1px 6px;
260 border-radius: 6px;
261 }
262
263 /* ─── status pills ─── */
264 .preview-pill {
265 display: inline-flex;
266 align-items: center;
267 gap: 5px;
268 padding: 2px 9px;
269 border-radius: 9999px;
270 font-size: 10.5px;
271 font-weight: 600;
272 letter-spacing: 0.05em;
273 text-transform: uppercase;
274 font-family: var(--font-mono);
275 background: rgba(255,255,255,0.04);
276 color: var(--text-muted);
277 box-shadow: inset 0 0 0 1px var(--border);
278 }
279 .preview-pill.is-building {
280 background: rgba(251,191,36,0.10);
281 color: #fde68a;
282 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
283 }
284 .preview-pill.is-ready {
285 background: rgba(52,211,153,0.10);
286 color: #6ee7b7;
287 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
288 }
289 .preview-pill.is-failed {
290 background: rgba(248,113,113,0.10);
291 color: #fecaca;
292 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.35);
293 }
294 .preview-pill.is-expired {
44f1a02Claude295 background: rgba(100,116,139,0.10);
296 color: #94a3b8;
297 box-shadow: inset 0 0 0 1px rgba(100,116,139,0.28);
298 }
299
300 /* ─── expired card treatment ─── */
301 .preview-card.is-expired .preview-card-branch { color: var(--text-muted); }
302 .preview-card.is-expired .preview-card-url-expired {
303 color: var(--text-muted);
304 text-decoration: line-through;
305 text-decoration-color: rgba(148,163,184,0.45);
306 }
307 .preview-rebuild-btn {
308 display: inline-flex;
309 align-items: center;
310 gap: 5px;
311 margin-top: var(--space-2);
312 padding: 4px 12px;
313 border-radius: 8px;
314 font-size: 12px;
315 font-weight: 600;
316 font-family: var(--font-mono);
317 color: #a48bff;
318 background: rgba(140,109,255,0.08);
319 border: 1px solid rgba(140,109,255,0.22);
320 cursor: pointer;
321 text-decoration: none;
322 transition: background 120ms ease, border-color 120ms ease;
323 }
324 .preview-rebuild-btn:hover {
325 background: rgba(140,109,255,0.15);
326 border-color: rgba(140,109,255,0.40);
4bbacbeClaude327 }
328 .preview-pill-dot {
329 width: 6px; height: 6px;
330 border-radius: 9999px;
331 background: currentColor;
332 }
333 .preview-pill.is-building .preview-pill-dot {
334 animation: previewPulse 1.4s ease-in-out infinite;
335 }
336 @keyframes previewPulse {
337 0%, 100% { opacity: 1; }
338 50% { opacity: 0.35; }
339 }
340
341 .preview-error {
342 margin-top: var(--space-2);
343 padding: 8px 12px;
344 border-radius: 8px;
345 font-family: var(--font-mono);
346 font-size: 12px;
347 color: #fecaca;
348 background: rgba(248,113,113,0.06);
349 border: 1px solid rgba(248,113,113,0.30);
350 white-space: pre-wrap;
351 word-break: break-word;
352 }
353
354 /* ─── empty state ─── */
355 .preview-empty {
356 position: relative;
357 padding: var(--space-6) var(--space-5);
358 background: var(--bg-elevated);
359 border: 1px dashed var(--border-strong, var(--border));
360 border-radius: 14px;
361 text-align: center;
362 overflow: hidden;
363 margin-bottom: var(--space-5);
364 }
365 .preview-empty-orb {
366 position: absolute;
367 inset: auto auto -40% 50%;
368 transform: translateX(-50%);
369 width: 320px; height: 320px;
370 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.08) 45%, transparent 70%);
371 filter: blur(70px);
372 opacity: 0.7;
373 pointer-events: none;
374 }
375 .preview-empty-inner { position: relative; z-index: 1; max-width: 460px; margin: 0 auto; }
376 .preview-empty-icon {
377 width: 44px; height: 44px;
378 margin: 0 auto var(--space-3);
379 border-radius: 14px;
380 background: linear-gradient(135deg, rgba(140,109,255,0.18), rgba(54,197,214,0.14));
381 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.32);
382 display: flex; align-items: center; justify-content: center;
383 color: #b69dff;
384 }
385 .preview-empty-title {
386 font-family: var(--font-display);
387 font-size: 16px;
388 font-weight: 700;
389 color: var(--text-strong);
390 margin: 0 0 6px;
391 letter-spacing: -0.01em;
392 }
393 .preview-empty-body {
394 font-size: 13.5px;
395 color: var(--text-muted);
396 line-height: 1.5;
397 margin: 0 0 var(--space-3);
398 }
399 .preview-empty code {
400 font-family: var(--font-mono);
401 font-size: 12px;
402 color: var(--text);
403 background: rgba(255,255,255,0.06);
404 padding: 1px 6px;
405 border-radius: 6px;
406 }
407
408 /* ─── secondary tab strip — only used when RepoNav has no slot ─── */
409 .preview-tabbar {
410 display: flex;
411 gap: 4px;
412 margin-bottom: var(--space-3);
413 border-bottom: 1px solid var(--border);
414 padding-bottom: 0;
415 }
416 .preview-tabbar a {
417 padding: 8px 12px;
418 font-size: 13px;
419 font-weight: 600;
420 color: var(--text-muted);
421 text-decoration: none;
422 border-bottom: 2px solid transparent;
423 margin-bottom: -1px;
424 }
425 .preview-tabbar a.is-active {
426 color: var(--text-strong);
427 border-bottom-color: #8c6dff;
428 }
429 .preview-tabbar a:hover { color: var(--text); }
430`;
431
432r.get("/:owner/:repo/previews", async (c) => {
433 const user = c.get("user");
434 const { owner, repo } = c.req.param();
435 const repoRow = await loadRepo(owner, repo);
436 if (!repoRow) return c.notFound();
437
438 const previews = await listPreviewsForRepo(repoRow.id);
439 const unread = user ? await getUnreadCount(user.id) : 0;
440 const now = new Date();
441
442 return c.html(
443 <Layout
444 title={`Previews — ${owner}/${repo}`}
445 user={user}
446 notificationCount={unread}
447 >
448 <RepoHeader
449 owner={owner}
450 repo={repo}
451 starCount={repoRow.starCount}
452 forkCount={repoRow.forkCount}
453 currentUser={user?.username}
454 />
455 <RepoNav owner={owner} repo={repo} active="code" />
456
457 <div class="preview-wrap">
458 <section class="preview-head">
459 <div class="preview-head-orb" aria-hidden="true" />
460 <div class="preview-head-inner">
461 <div class="preview-head-text">
462 <div class="preview-eyebrow">
463 <span class="preview-eyebrow-dot" aria-hidden="true" />
464 Branch previews · {owner}/{repo}
465 </div>
466 <h2 class="preview-title">
467 <span class="preview-title-grad">Previews.</span>
468 </h2>
469 <p class="preview-sub">
470 Every push to a non-default branch gets a unique preview
471 URL. Open the URL to see the branch as if it were live —
472 no merge required. Previews auto-expire 24 hours after
473 the last push.
474 </p>
475 </div>
476 </div>
477 </section>
478
479 <nav class="preview-tabbar" aria-label="Repository previews navigation">
480 <a href={`/${owner}/${repo}`}>Code</a>
481 <a class="is-active" href={`/${owner}/${repo}/previews`}>Previews</a>
482 <a href={`/${owner}/${repo}/deployments`}>Deployments</a>
483 </nav>
484
485 <h4 class="preview-col-title">
486 {previews.length === 0
487 ? "No previews yet"
488 : `${previews.length} preview${previews.length === 1 ? "" : "s"}`}
489 </h4>
490
491 {previews.length === 0 ? (
492 <div class="preview-empty">
493 <div class="preview-empty-orb" aria-hidden="true" />
494 <div class="preview-empty-inner">
495 <div class="preview-empty-icon" aria-hidden="true">
496 <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
497 <circle cx="12" cy="12" r="9" />
498 <path d="M12 7v5l3 2" />
499 </svg>
500 </div>
501 <h3 class="preview-empty-title">Push to a branch to get a preview URL</h3>
502 <p class="preview-empty-body">
503 Create a branch other than{" "}
504 <code>{repoRow.defaultBranch}</code>, push some commits,
505 and a unique preview URL will land here within seconds.
506 Each preview lasts 24 hours after the last push.
507 </p>
508 </div>
509 </div>
510 ) : (
511 <div class="preview-list">
512 {previews.map((p) => {
513 const shortSha = (p.commitSha || "").slice(0, 7);
514 const expiresLabel = formatExpiresIn(p.expiresAt, now);
44f1a02Claude515 const isExpired = p.status === "expired";
4bbacbeClaude516 const statusKey = p.status as
517 | "building"
518 | "ready"
519 | "failed"
520 | "expired";
521 const pillClass = `preview-pill is-${statusKey}`;
44f1a02Claude522 const cardClass = isExpired
523 ? "preview-card is-expired"
524 : "preview-card";
525 // Rebuild pushes a branch-preview re-enqueue via the API.
526 const rebuildHref = `/${owner}/${repo}/previews/rebuild?branch=${encodeURIComponent(p.branchName)}`;
4bbacbeClaude527 return (
44f1a02Claude528 <div class={cardClass}>
4bbacbeClaude529 <div class="preview-card-head">
530 <div class="preview-card-titles">
531 <h3 class="preview-card-branch">{p.branchName}</h3>
532 <div class="preview-card-url">
533 {p.status === "ready" ? (
534 <a href={p.previewUrl} target="_blank" rel="noopener noreferrer">
535 {p.previewUrl}
536 </a>
44f1a02Claude537 ) : isExpired ? (
538 <span class="preview-card-url-expired">
539 {p.previewUrl}
540 </span>
4bbacbeClaude541 ) : (
542 <span style="color: var(--text-muted)">
543 {p.previewUrl}
544 </span>
545 )}
546 </div>
547 <div class="preview-card-meta">
548 <span>
549 commit <code>{shortSha}</code>
550 </span>
551 <span>
44f1a02Claude552 {isExpired
553 ? "preview expired · push to branch to rebuild"
4bbacbeClaude554 : `expires in ${expiresLabel}`}
555 </span>
556 </div>
44f1a02Claude557 {isExpired && (
558 <a
559 href={rebuildHref}
560 class="preview-rebuild-btn"
561 title="Re-enqueue a preview build for this branch"
562 >
563 ↺ Rebuild
564 </a>
565 )}
4bbacbeClaude566 </div>
567 <div>
568 <span class={pillClass}>
569 <span class="preview-pill-dot" aria-hidden="true" />
570 {previewStatusLabel(p.status)}
571 </span>
572 </div>
573 </div>
574 {p.status === "failed" && p.errorMessage && (
575 <div class="preview-error">{p.errorMessage}</div>
576 )}
577 </div>
578 );
579 })}
580 </div>
581 )}
582 </div>
583
584 <style dangerouslySetInnerHTML={{ __html: previewStyles }} />
585 </Layout>
586 );
587});
588
1df50d5Claude589// ─── PR preview redirect ────────────────────────────────────────────────────
590// GET /:owner/:repo/pull/:prNumber/preview
591// Redirect to the live preview URL for this PR (from pr_previews table).
592// Falls back to the previews list if no ready build exists.
593r.get("/:owner/:repo/pull/:prNumber/preview", softAuth, async (c) => {
594 const { owner, repo, prNumber } = c.req.param();
595 const num = parseInt(prNumber, 10);
596
597 try {
598 // Resolve repo
599 const [repoRow] = await db
600 .select({ id: repositories.id })
601 .from(repositories)
602 .innerJoin(users, eq(repositories.ownerId, users.id))
603 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
604 .limit(1);
605 if (!repoRow) return c.notFound();
606
607 // Find the PR
608 const [pr] = await db
609 .select({ id: pullRequests.id })
610 .from(pullRequests)
611 .where(and(eq(pullRequests.repositoryId, repoRow.id), eq(pullRequests.number, num)))
612 .limit(1);
613 if (!pr) return c.notFound();
614
615 // Find the most recent ready preview for this PR
616 const [preview] = await db
617 .select({ previewUrl: prPreviews.previewUrl, status: prPreviews.status })
618 .from(prPreviews)
619 .where(and(eq(prPreviews.prId, pr.id), eq(prPreviews.status, "ready")))
620 .orderBy(desc(prPreviews.id))
621 .limit(1);
622
623 if (preview?.previewUrl) {
624 return c.redirect(preview.previewUrl, 302);
625 }
626 } catch (err) {
627 console.warn("[previews] PR preview redirect failed:", err instanceof Error ? err.message : err);
628 }
629
630 // Fall back to the previews list page
631 return c.redirect(`/${owner}/${repo}/previews`, 302);
632});
633
634// ─── Static file serving ────────────────────────────────────────────────────
635// GET /previews/:owner/:repo/:prBranch/*
636// Serves built output from the temp build directory created by preview-builder.
637// Only active when PREVIEW_DOMAIN is set (or when the dev fallback applies).
638r.get("/previews/:owner/:repo/:prBranch/*", async (c) => {
639 const { owner, repo, prBranch } = c.req.param();
640 const wildcard = c.req.param("*") || "";
641
642 // Resolve the most recent ready pr_previews row for this branch
643 try {
644 const [repoRow] = await db
645 .select({ id: repositories.id })
646 .from(repositories)
647 .innerJoin(users, eq(repositories.ownerId, users.id))
648 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
649 .limit(1);
650 if (!repoRow) return c.notFound();
651
652 const [preview] = await db
653 .select({
654 prId: prPreviews.prId,
655 headSha: prPreviews.headSha,
656 outputDir: prPreviews.outputDir,
657 status: prPreviews.status,
658 branchName: prPreviews.branchName,
659 })
660 .from(prPreviews)
661 .where(
662 and(
663 eq(prPreviews.repoId, repoRow.id),
664 eq(prPreviews.status, "ready"),
665 eq(prPreviews.branchName, prBranch)
666 )
667 )
668 .orderBy(desc(prPreviews.id))
669 .limit(1);
670
671 if (!preview || preview.status !== "ready") {
672 return c.text("Preview not ready yet", 404);
673 }
674
675 const outputDir = preview.outputDir || "dist";
676 // The build dir pattern from preview-builder.ts:
677 // /tmp/previews/<slug(prId)>-<shortSha>/<outputDir>
678 // We stored prId in the row, so read it back and reconstruct.
679 const PREVIEW_BUILD_DIR = process.env.PREVIEW_BUILD_DIR || "/tmp/previews";
680 const shortSha = preview.headSha.slice(0, 8);
681 const prIdSlug = preview.prId.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 60);
682 const buildBase = `${PREVIEW_BUILD_DIR}/${prIdSlug}-${shortSha}/${outputDir}`;
683
684 let filePath = wildcard ? join(buildBase, wildcard) : join(buildBase, "index.html");
685
686 // Prevent directory traversal
687 if (!filePath.startsWith(buildBase)) {
688 return c.text("Forbidden", 403);
689 }
690
691 // Directory → serve index.html
692 if (existsSync(filePath) && !filePath.endsWith("/")) {
693 // Check if it's a directory
694 try {
695 const stat = await Bun.file(filePath).exists();
696 if (!stat && existsSync(join(filePath, "index.html"))) {
697 filePath = join(filePath, "index.html");
698 }
699 } catch {}
700 }
701
702 if (!existsSync(filePath)) {
703 // Try with index.html appended
704 const indexPath = join(filePath, "index.html");
705 if (existsSync(indexPath)) {
706 filePath = indexPath;
707 } else {
708 return c.text("File not found", 404);
709 }
710 }
711
712 const file = Bun.file(filePath);
713 const contentType = getMimeType(filePath);
714
715 return new Response(file, {
716 headers: {
717 "Content-Type": contentType,
718 "Cache-Control": "public, max-age=300",
719 "X-Preview-Sha": preview.headSha.slice(0, 8),
720 },
721 });
722 } catch (err) {
723 console.warn("[previews] static serve failed:", err instanceof Error ? err.message : err);
724 return c.text("Preview unavailable", 500);
725 }
726});
727
728// ─── Rebuild trigger API ─────────────────────────────────────────────────────
729// POST /api/previews/rebuild/:prId
730// Triggers a fresh build for the given PR. Auth required (repo write access).
731r.post("/api/previews/rebuild/:prId", requireAuth, async (c) => {
732 const { prId } = c.req.param();
733 const user = c.get("user");
734
735 try {
736 const [pr] = await db
737 .select({
738 id: pullRequests.id,
739 repositoryId: pullRequests.repositoryId,
740 headBranch: pullRequests.headBranch,
741 authorId: pullRequests.authorId,
742 })
743 .from(pullRequests)
744 .where(eq(pullRequests.id, prId))
745 .limit(1);
746
747 if (!pr) return c.json({ error: "PR not found" }, 404);
748
749 // Only the PR author or repo owner can trigger a rebuild
750 if (user!.id !== pr.authorId) {
751 const [repo] = await db
752 .select({ ownerId: repositories.ownerId })
753 .from(repositories)
754 .where(eq(repositories.id, pr.repositoryId))
755 .limit(1);
756 if (!repo || repo.ownerId !== user!.id) {
757 return c.json({ error: "Unauthorized" }, 403);
758 }
759 }
760
761 // Get the current head SHA from the most recent preview row, or use a sentinel
762 const [existing] = await db
763 .select({ headSha: prPreviews.headSha })
764 .from(prPreviews)
765 .where(eq(prPreviews.prId, prId))
766 .orderBy(desc(prPreviews.id))
767 .limit(1);
768
769 const headSha = existing?.headSha ?? "unknown";
770
771 // Fire-and-forget rebuild
772 buildPreview(prId, pr.repositoryId, headSha).catch((err) =>
773 console.warn("[previews] rebuild failed:", err instanceof Error ? err.message : err)
774 );
775
776 return c.json({ ok: true, message: "Rebuild triggered" });
777 } catch (err) {
778 console.warn("[previews] rebuild endpoint failed:", err instanceof Error ? err.message : err);
779 return c.json({ error: "Internal error" }, 500);
780 }
781});
782
4bbacbeClaude783export default r;