pr_01m47d15m3e54sn21z27rpy5n9/services/pages/src/index.ts

177 lines7,263 bytesCodeBlame
1/**
2 * The dispatcher for g1t.page: every app deployed by g1t is served here.
3 *
4 * The hostname's first label is the app's script name in the Workers for
5 * Platforms namespace (`web-git-fix-login-acme.g1t.page` is the fix-login
6 * branch's preview of acme's web project), so a request needs no lookup:
7 * the app is fetched by name and runs only for as long as it answers. An app no one visits
8 * runs nothing and costs nothing.
9 *
10 * Any other hostname is a project's custom domain, reaching here through
11 * Cloudflare for SaaS: the `DOMAINS` KV namespace, written by the
12 * deployments service, names its app, or the hostname it redirects to.
13 *
14 * Kept apart from g1t.sh, so apps share no cookies or origin with the site
15 * people sign in to.
16 */
17
18import { DOMAIN, FALLBACK, parseEntry, redirectTo, route, type DomainEntry } from "./route.ts";
19
20type Env = {
21 APPS: DispatchNamespace;
22 /** Custom hostname to `{ script, redirect }`. */
23 DOMAINS?: KVNamespace;
24};
25
26/** How long a custom hostname's entry is kept in this isolate and at the edge. */
27const ENTRY_TTL_MS = 60 * 1000;
28const entries = new Map<string, { at: number; entry: DomainEntry | null }>();
29
30function page(status: number, title: string, body: string, site = DOMAIN): Response {
31 const html = `<!doctype html>
32<html lang="en">
33<head>
34<meta charset="utf-8">
35<meta name="viewport" content="width=device-width, initial-scale=1">
36<meta name="robots" content="noindex">
37<title>${escape(title)} · ${escape(site)}</title>
38<style>
39 :root { color-scheme: dark; --bg: #121214; --fg: #ececf1; --muted: #9a9aa6; --accent: #b4a7f5; }
40 body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg);
41 font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; padding: 0 16px; }
42 main { max-width: 32rem; }
43 h1 { font-size: 1.5rem; margin: 0 0 .5rem; letter-spacing: -.01em; }
44 p { color: var(--muted); margin: .5rem 0; }
45 a { color: var(--accent); }
46 code { font: .9em ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg); }
47</style>
48</head>
49<body><main><h1>${escape(title)}</h1>${body}</main></body>
50</html>`;
51 return new Response(html, {
52 status,
53 headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" },
54 });
55}
56
57function escape(text: string): string {
58 return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
59}
60
61/** A custom hostname's entry: from this isolate, else KV (cached at the edge too). */
62async function lookup(env: Env, hostname: string): Promise<DomainEntry | null> {
63 const cached = entries.get(hostname);
64 if (cached && Date.now() - cached.at < ENTRY_TTL_MS) return cached.entry;
65 const value = env.DOMAINS ? await env.DOMAINS.get(hostname, { type: "json", cacheTtl: ENTRY_TTL_MS / 1000 }) : null;
66 const entry = parseEntry(value);
67 entries.set(hostname, { at: Date.now(), entry });
68 if (entries.size > 5000) entries.delete(entries.keys().next().value!);
69 return entry;
70}
71
72/** Runs the app `script` for the request; `shown` is the address named in its error pages. */
73async function dispatch(env: Env, request: Request, script: string, shown: string, missing: () => Response): Promise<Response> {
74 let app: Fetcher;
75 try {
76 app = env.APPS.get(script);
77 } catch {
78 return missing();
79 }
80 try {
81 return await app.fetch(request);
82 } catch (error) {
83 if (/worker not found|script not found|does not exist/i.test(String(error))) return missing();
84 return page(
85 502,
86 "The app failed",
87 `<p>The app at <code>${escape(shown)}</code> threw an error before it could answer.</p>`,
88 shown,
89 );
90 }
91}
92
93export default {
94 async fetch(request: Request, env: Env): Promise<Response> {
95 const host = new URL(request.url).hostname;
96 const where = route(host);
97 switch (where.kind) {
98 case "home":
99 return page(
100 200,
101 "g1t.page",
102 `<p>Apps deployed by <a href="https://g1t.sh">g1t</a>: every pull request's preview, and each repository's production.</p>
103 <p><a href="https://docs.g1t.sh/guides/deployments/">How deployments work</a></p>`,
104 );
105 case "fallback":
106 return page(
107 200,
108 "Custom domains on g1t",
109 `<p>Point a custom domain here, with a CNAME to <code>${FALLBACK}</code>, to serve a project's production on it. Add the domain first, under the project's Settings, Deployments, on g1t.</p>
110 <p><a href="https://docs.g1t.sh/guides/deployments/#custom-domains">How custom domains work</a></p>`,
111 );
112 case "invalid":
113 return page(404, "Nothing here", "<p>This is not the address of an app on g1t.page.</p>");
114 case "custom":
115 return custom(request, env, where.hostname);
116 case "app": {
117 const label = where.label;
118 let response = await dispatch(env, request, label, `${label}.${DOMAIN}`, () => missing(label));
119 // Previews are for the people reviewing a change, not search engines.
120 if (isPreview(label)) {
121 response = new Response(response.body, response);
122 response.headers.set("x-robots-tag", "noindex");
123 }
124 return response;
125 }
126 }
127 },
128} satisfies ExportedHandler<Env>;
129
130/** A custom domain: its app, or a 308 to the hostname it is paired with. */
131async function custom(request: Request, env: Env, hostname: string): Promise<Response> {
132 let entry: DomainEntry | null;
133 try {
134 entry = await lookup(env, hostname);
135 } catch (error) {
136 console.error("could not read domain", hostname, error);
137 return page(503, "Try again in a moment", `<p><code>${escape(hostname)}</code> could not be looked up just now.</p>`, hostname);
138 }
139 if (!entry) {
140 return page(
141 404,
142 "This domain is not set up",
143 `<p><code>${escape(hostname)}</code> points at g1t, but no project serves it. Its owner adds it under the project's Settings, Deployments, on <a href="https://g1t.sh">g1t</a>.</p>`,
144 hostname,
145 );
146 }
147 if (entry.redirect && entry.redirect !== hostname) {
148 return new Response(null, {
149 status: 308,
150 headers: { location: redirectTo(request.url, entry.redirect), "cache-control": "public, max-age=300" },
151 });
152 }
153 return dispatch(env, request, entry.script, hostname, () =>
154 page(
155 404,
156 "Nothing deployed here yet",
157 `<p><code>${escape(hostname)}</code> serves a project's production, and it is not up. Deploying the project brings it here.</p>`,
158 hostname,
159 ),
160 );
161}
162
163/** A branch's preview: `<project>-git-<branch>-<workspace>`. */
164function isPreview(label: string): boolean {
165 return label.includes("-git-");
166}
167
168function missing(label: string): Response {
169 const preview = isPreview(label);
170 return page(
171 404,
172 preview ? "This preview is not up" : "Nothing deployed here",
173 preview
174 ? `<p>A preview comes down when its pull request is closed or merged, or after its project's idle days without a visit. Pushing to the branch, or redeploying it from the project's Deployments page, brings it back.</p>`
175 : `<p>No app is deployed at <code>${escape(label)}.${DOMAIN}</code>. Deployments are turned on per project, from its Settings on g1t.</p>`,
176 );
177}